
简介这份资源是面向Java后端学习者与微服务入门者的在线教育平台项目源码围绕课程检索、视频学习、在线测评、作业提交与社区互动等核心业务将系统拆解为用户管理、课程资源、测评、作业处理与社区论坛等独立服务适合用于课程设计、毕业设计或微服务架构练手。压缩包共189个文件以128个java源码为主体辅以19个xml配置、9个properties参数文件及若干备份文件整体约156KB结构紧凑便于快速通读。项目采用Spring Boot搭建服务基础结合Spring Cloud组件实现服务注册发现、客户端负载均衡、断路器、分布式链路追踪与统一配置管理并借助Docker完成容器化部署安全层面涉及OAuth2与JWT认证授权。目前已有55人学习读者可从中获取完整的服务拆分思路、RESTful接口设计范例与工程目录组织方式理解微服务协同与容错机制的具体落地为后续独立开发或架构演进提供可参考的实践样本。1. 微服务架构下的在线教育平台为什么单体撑不过三个月的课去年帮一个做 K12 的团队做架构评审他们的在线教育平台上线两个月日活刚过八千选课接口的 P99 已经飙到 4.2 秒。翻代码一看选课、订单、直播推流、题库、用户中心全塞在一个 Spring Boot 工程里一次发版要停服二十分钟。这不是个例是绝大多数 Java 在线教育平台从单体走向微服务架构的真实起点。这个标题讲的事情很具体用 Java 技术栈把在线教育平台拆成一组可独立部署的服务并让它真正跑起来。它解决的不是能不能上课而是双十一式的报名洪峰来了订单服务挂了会不会把直播也拖死。适合谁看手上有一个正在膨胀的 Java 单体教育项目、或者正准备从零搭一套的工程师。下面按我实际落地的顺序讲先定服务边界再搭最小可运行骨架然后处理分布式下最要命的数据一致性和高并发最后说几个只有踩过才知道的坑。2. 服务怎么拆在线教育平台的领域边界与 Java 技术选型拆微服务最容易翻车的地方不是技术是边界。我见过把用户和权限拆成两个服务的结果每次登录要跨两次网络调用。在线教育平台的业务其实很清晰按领域驱动设计的思路能划出几条天然的分界线。2.1 六个核心服务的职责划分一个能跑起来的在线教育平台我一般会拆成这六个服务每个服务对应一个独立的数据库 schema服务名核心职责关键表是否高频用户服务 user-service注册登录、实名、角色user、role、user_role中课程服务 course-service课程 CRUD、章节、大纲course、chapter、category中订单服务 order-service下单、支付回调、退款orders、payment、refund高学习服务 study-service选课记录、进度、笔记enroll、progress、note高直播服务 live-service房间、推拉流地址、回放live_room、stream_log高题库服务 exam-service题目、组卷、判分question、paper、answer中拆分的判断标准只有一条变更频率和数据一致性边界是否一致。订单和学习记录会随营销活动频繁改课程内容相对稳定直播是独立的技术栈涉及流媒体这三类放一起就是灾难。用户服务被所有服务依赖所以它必须最先拆出来、最稳定。提示不要一上来就拆六个。如果团队只有三四个后端先拆 user、course、order 三个study 和 exam 可以先合在 course 里等 QPS 真的上来了再拆。微服务的运维成本是单体的三到五倍拆早了是自找麻烦。2.2 Java 技术栈的选型理由为什么是 Java 而不是别的在线教育平台有两个硬需求一是事务二是生态。订单和支付必须强一致Spring 的事务管理成熟到闭眼用题库、判分这类逻辑用 Java 的面向对象建模比脚本语言好维护得多。具体选型框架Spring Boot 3.x Spring Cloud Alibaba。Nacos 做注册中心和配置中心一个组件解决两件事比 Eureka Config 的组合省心。网关Spring Cloud Gateway。响应式模型在高并发下比 Zuul 省内存路由配置写在 Nacos 里可以动态刷新。远程调用OpenFeign。声明式接口配合 LoadBalancer 做客户端负载均衡代码里看不到 URL。持久层MyBatis-Plus。教育平台的查询条件千变万化按分类、价格、销量、进度筛选MyBatis-Plus 的 Wrapper 比 JPA 的 Specification 写起来直观。这里插一句热词里提到的mybatisplus 根据 java 实体类生成创建表的 sql 语句实际项目里我不用它自动建表DDL 必须走 Flyway 版本管理自动建表在生产环境是定时炸弹。缓存Redis。课程详情、热门榜单、验证码、分布式锁全靠它。消息队列RocketMQ。选课成功后的通知、订单超时关闭、直播开播提醒都是典型的异步场景。2.3 用 Nacos 搭起最小可运行骨架光说选型没用得能跑起来。下面这段是订单服务接入 Nacos 的最小配置application.ymlserver: port: 8083 spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: edu-dev config: server-addr: 127.0.0.1:8848 file-extension: yaml namespace: edu-dev datasource: url: jdbc:mysql://127.0.0.1:3306/edu_order?useUnicodetruecharacterEncodingutf8 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver逻辑说明spring.application.name是服务在 Nacos 里的唯一标识网关和 Feign 都靠它找服务。namespace用来隔离开发、测试、生产三套环境这个字段不填本地调试会连到测试环境的注册中心血泪经验。file-extension: yaml决定了 Nacos 配置中心里 Data ID 的格式必须是order-service.yaml写错一个字母就拉不到配置。参数说明server-addr是 Nacos 地址集群部署时用逗号分隔多个。namespace填的是命名空间 ID不是名称在 Nacos 控制台复制。数据库连接池这里没写默认 HikariCP生产环境要把maximum-pool-size设成 CPU 核数的两倍左右教育平台晚高峰并发集中池子太小会排队。启动类上加EnableDiscoveryClientFeign 接口上加FeignClient(name course-service)就能跨服务调用了。这一步跑通骨架就立住了。3. 分布式下的数据一致性选课扣库存和订单支付怎么不打架微服务拆完第一个撞上来的问题就是数据一致性。单体时代一个Transactional搞定的事现在跨了三个服务。在线教育平台最典型的场景用户选课要扣课程名额、生成订单、写学习记录三个动作分属三个服务。3.1 为什么最终一致性是教育平台的正确选择先想清楚要不要强一致。选课这个场景用户点完按钮等 200 毫秒和等 2 秒体验差别巨大但名额扣了订单没生成这种中间态只要最终能自愈用户是感知不到的。所以教育平台我一般用最终一致性而不是 Seata 那种强一致的分布式事务。Seata 的 AT 模式性能损耗在选课洪峰下是扛不住的而且它依赖全局锁一个服务卡住全链路都卡。最终一致性的落地套路是本地事务 消息队列 幂等消费。核心思路是先落本地状态再发消息下游消费消息补偿。3.2 选课扣库存的可靠消息实现下面这段是课程服务扣减名额并发送消息的核心代码Service public class EnrollService { Autowired private CourseMapper courseMapper; Autowired private RocketMQTemplate rocketMQTemplate; Transactional(rollbackFor Exception.class) public void enroll(Long courseId, Long userId) { // 1. 乐观锁扣减名额version 字段防并发超卖 int affected courseMapper.decreaseStock(courseId); if (affected 0) { throw new BizException(课程名额已满); } // 2. 写本地消息表与业务在同一个事务里 LocalMessage msg new LocalMessage(); msg.setBizId(courseId _ userId); msg.setTopic(enroll_topic); msg.setStatus(0); // 0待发送 msg.setContent(JSON.toJSONString(new EnrollEvent(courseId, userId))); localMessageMapper.insert(msg); } // 定时任务扫描待发送消息投递到 MQ Scheduled(fixedDelay 5000) public void scanAndSend() { ListLocalMessage list localMessageMapper.selectByStatus(0); for (LocalMessage m : list) { rocketMQTemplate.syncSend(m.getTopic(), m.getContent()); localMessageMapper.updateStatus(m.getId(), 1); } } }逻辑说明第一步用乐观锁扣名额SQL 是update course set stock stock - 1 where id ? and stock 0靠数据库行锁保证不超卖比 Redis 预扣减简单可靠。第二步把消息写进本地消息表和扣减在同一个事务里这样扣了名额但消息没发出去的情况就不会发生。第三步用定时任务扫描待发送消息投递到 MQ投递成功才改状态。这就是本地消息表方案牺牲一点实时性最多 5 秒延迟换来的是不丢消息。参数说明fixedDelay 5000是上一轮执行完再等 5 秒不是固定频率避免消息堆积时任务重叠。syncSend是同步发送会等 Broker 确认比sendOneWay可靠但慢消息量不大时用同步。bizId用来做下游幂等下游收到消息先查这个 ID 处理过没有。注意本地消息表的扫描任务在多实例部署时会重复扫描要么加分布式锁要么用select ... for update skip locked让每个实例领不同的消息。我一般用 Redis 分布式锁简单直接。3.3 订单支付的幂等与对账支付回调是另一个重灾区。第三方支付平台可能重复回调网络抖动也可能让你收到两次。订单服务的回调接口必须幂等PostMapping(/pay/callback) public String payCallback(RequestBody PayNotify notify) { // 1. 用支付流水号做幂等键Redis setnx 防重 Boolean first redisTemplate.opsForValue() .setIfAbsent(pay:notify: notify.getTradeNo(), 1, 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(first)) { return success; // 重复回调直接返回成功 } // 2. 校验签名防止伪造回调 if (!signUtil.verify(notify)) { return fail; } // 3. 更新订单状态状态机保证只能从待支付到已支付 orderMapper.updateStatus(notify.getOrderNo(), OrderStatus.PAID); return success; }逻辑说明setIfAbsent是 Redis 的原子操作同一个tradeNo只有第一次能设置成功后续回调直接返回成功避免重复处理。签名校验不能省热词里java 邮件伪造发件人这类安全问题在教育平台同样存在支付回调不验签等于把订单状态交给别人改。状态机更新用where status PENDING条件防止已支付订单被改回。参数说明幂等键的过期时间设 24 小时覆盖支付平台的重试窗口。tradeNo用支付平台的流水号不要用自己生成的订单号因为同一订单可能多次支付尝试。对账是最后一道防线。每天凌晨跑一个定时任务拉取支付平台的账单和本地订单比对差异订单人工介入。这一步很多团队上线时不做等财务发现账对不上就晚了。4. 高并发场景实战直播选课洪峰下的缓存与限流教育平台的流量是脉冲式的。晚上八点开课、限时秒杀、名师直播这些场景下 QPS 能从几百瞬间冲到几万。微服务架构如果不做防护一个热点接口能把整条链路拖垮。4.1 多级缓存挡住课程详情读流量课程详情是读多写少的典型。我一般用本地缓存 Redis两级public CourseDetail getCourseDetail(Long courseId) { String key course:detail: courseId; // 1. 先查 Caffeine 本地缓存纳秒级 CourseDetail local caffeineCache.getIfPresent(key); if (local ! null) { return local; } // 2. 本地没有查 Redis String json redisTemplate.opsForValue().get(key); if (json ! null) { CourseDetail detail JSON.parseObject(json, CourseDetail.class); caffeineCache.put(key, detail); return detail; } // 3. 都没有查库加分布式锁防缓存击穿 RLock lock redissonClient.getLock(lock:course: courseId); lock.lock(); try { // 双重检查 json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, CourseDetail.class); } CourseDetail detail courseMapper.selectDetail(courseId); // 过期时间加随机值防缓存雪崩 long expire 3600 ThreadLocalRandom.current().nextInt(600); redisTemplate.opsForValue().set(key, JSON.toJSONString(detail), expire, TimeUnit.SECONDS); caffeineCache.put(key, detail); return detail; } finally { lock.unlock(); } }逻辑说明Caffeine 本地缓存扛住单机热点Redis 扛住跨实例共享数据库只在下沉时被访问。分布式锁防缓存击穿热点课程过期瞬间大量请求同时打到库上锁保证只有一个线程去查库。过期时间加随机值防缓存雪崩避免大批 key 同时失效。参数说明Caffeine 的maximumSize设成单机内存能承受的量一般几千条expireAfterWrite设 5 分钟比 Redis 短保证本地缓存不会太脏。Redis 过期时间 1 小时加随机 10 分钟。Redisson 的锁要设leaseTime防止持锁线程挂了锁不释放。4.2 网关层限流与熔断降级缓存挡不住的写流量得在网关层限流。Spring Cloud Gateway 集成 Sentinelspring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 500 redis-rate-limiter.burstCapacity: 1000 key-resolver: #{ipKeyResolver}逻辑说明RequestRateLimiter基于 Redis 令牌桶replenishRate是每秒补充的令牌数即稳定 QPSburstCapacity是桶容量允许的突发流量。key-resolver决定限流维度按 IP 限流防刷按用户 ID 限流防单用户刷单。参数说明replenishRate设成后端服务能承受的 QPS 的 80%留 20% 余量。burstCapacity一般是replenishRate的两倍应对秒杀开始的瞬时洪峰。限流触发后返回 429前端要处理这个状态码给用户当前人数过多请稍后重试的提示而不是白屏。熔断用 Sentinel 的SentinelResource给订单查询这类非核心接口配降级方法返回缓存里的旧数据或默认值。核心的支付接口不降级宁可排队也不能出错。提示限流阈值不是拍脑袋定的。上线前用 JMeter 压测找到服务开始报错的拐点阈值设在拐点的 70%。压测环境要和生产同规格否则数据没意义。5. 避坑指南微服务教育平台上线后最容易翻车的五件事这一章全是踩过的坑每条都按现象 → 原因 → 解决写能帮你省下至少两周的排查时间。坑一服务间循环依赖导致启动死锁现象user-service 和 course-service 互相用 Feign 调用本地启动时两个服务都卡在启动阶段日志停在Started Application之前。原因Spring 容器初始化时A 服务的 Feign 客户端需要 B 服务注册到 NacosB 服务又需要 A形成循环等待。解决拆掉循环依赖。用户服务不该依赖课程服务把查用户已购课程这个逻辑挪到课程服务由课程服务调用户服务拿基础信息。如果实在拆不掉用Lazy延迟注入 Feign 客户端但这是治标架构上必须解耦。坑二Nacos 配置刷新导致数据源连接池被重建现象在 Nacos 改了某个配置服务日志里出现大量connection closed正在处理的请求报数据库连接异常。原因RefreshScope作用在数据源 Bean 上配置一变整个 Bean 被销毁重建连接池里的连接全断了。解决数据源配置不要放在会动态刷新的配置里或者把数据源 Bean 排除出刷新范围。我一般把数据库、Redis 这类基础连接配置放在bootstrap.yml里不走动态刷新业务开关类的配置才放 Nacos 动态刷新。坑三分布式锁没设过期时间服务挂了锁不释放现象某个课程的名额扣减接口突然全部超时日志显示获取锁失败重启服务后恢复。原因用 Redis 的setnx加锁但没设过期时间持锁的实例被 OOM kill 后锁永远不释放。解决用 Redisson 的lock.lock(10, TimeUnit.SECONDS)显式设 leaseTime。或者用tryLock带超时拿不到锁就快速失败。绝对不要用裸的setnx手写分布式锁Redisson 的看门狗机制能自动续期比手写可靠。坑四Feign 默认超时太短慢接口被误判为失败现象订单服务调课程服务查详情偶发Read timed out但课程服务日志显示请求正常处理完了。原因Feign 默认连接超时 10 秒、读超时 60 秒但 Ribbon 的默认读超时是 1 秒两个配置打架实际生效的是 1 秒。解决在配置里显式设置feign.client.config.default.connectTimeout5000、readTimeout10000同时设ribbon.ReadTimeout保持一致。慢接口单独配更长的超时但要有上限否则线程池会被拖垮。坑五消息重复消费导致学习进度被覆盖现象用户反馈学习进度偶尔会回退明明看到第 10 节了刷新变成第 8 节。原因进度更新消息被重复消费后到的旧消息覆盖了新进度。解决消费端做幂等用userId courseId chapterId做唯一键更新时用where progress newProgress条件只允许进度前进不允许后退。或者消息里带时间戳比库里记录的时间新才更新。这个坑很隐蔽因为重复消费不是必现压测时也测不出来。6. 从能跑到好用链路追踪与灰度发布的落地技巧平台跑起来只是及格线线上出问题时能不能快速定位、新功能能不能安全上线才是微服务架构真正的价值所在。这一章讲两个我每次都会配的能力。链路追踪用 SkyWalkingJava 项目接入几乎零代码改动加个 agent 参数就行java -javaagent:/opt/skywalking/agent/skywalking-agent.jar \ -Dskywalking.agent.service_nameorder-service \ -Dskywalking.collector.backend_service127.0.0.1:11800 \ -jar order-service.jar逻辑说明-javaagent在 JVM 启动时挂载探针字节码增强自动埋点Spring Cloud、Feign、MyBatis、Redis 的调用链都能串起来。service_name要和 Nacos 里的服务名一致否则拓扑图对不上。backend_service是 SkyWalking OAP 的地址。参数说明生产环境采样率默认是 100%高并发下对性能有影响可以在 agent 配置里设agent.sample_n_per_3_secs限制每秒采样数。慢 SQL 和异常调用会被强制采样不用担心漏掉问题。灰度发布用 Nacos 的权重和元数据。给新版本实例打上version2.0的元数据标签网关根据请求头里的用户标识路由内部测试用户走新版本普通用户走老版本。观察新版本一周的监控指标没问题再逐步调权重从 10% 到 50% 到 100%。这套流程比蓝绿部署省资源比金丝雀发布可控。最后说个我自己的习惯每次上线前我会把这次改动的服务、涉及的接口、回滚步骤写在一张纸上贴在显示器边。微服务的回滚比单体复杂订单服务回滚了但课程服务没回滚数据可能对不上。有这张纸出问题时手不抖。这套架构我从单体一路拆过来最大的教训是——别为了微服务而微服务先让业务跑通再让架构撑住。希望帮到你。本文还有配套的精品资源点击获取