简介:面向Java后端开发者与微服务学习者的在线教育平台项目源码包,围绕微服务架构设计与实现展开,覆盖课程检索、在线测评、作业批阅、社区互动等典型业务场景。资源共一百八十九个文件,压缩包大小约一百五十六KB,以一百二十八个Java源文件为主体,辅以XML、properties等配置文件和备份文件,并包含项目说明文档,便于梳理启动流程与业务模块。已有五十五人学习。整体按用户管理、课程资源、测评作业、社区论坛等微服务模块组织,便于理解服务拆分、接口交互、注册发现与负载均衡等落地细节。通过阅读源码可掌握主流微服务开发框架在真实项目中的集成方式,同时学习认证授权、容器化部署等实践,并体会服务自治与独立扩容带来的工程优势,适合作为课程设计或毕业设计的参考。
1. 基于微服务架构的Java在线教育平台:它到底是怎样一个项目
如果你搜到这个标题,大概率是两种情况:要么在准备课程设计或毕业设计,要么公司要搭一个在线教育的业务中台。先说结论:这个项目不是“把单体加个网关拆几个模块”那么简单,它的核心难点集中在三个地方——业务域怎么拆才不踩坑、订单到上课这条链路上的数据一致性怎么保证、以及视频播放这类高并发读场景怎么不被打垮。它值不值得做,取决于你要的是“能答辩的课程设计”还是“能扛住真实流量的小型生产系统”,两者在技术选型和深度上差别很大。
这篇文章按我实际做过的方案来写,技术栈选 Spring Cloud Alibaba 这一套(Nacos + Gateway + OpenFeign + Sentinel),数据库 MySQL + Redis,消息用 RabbitMQ。这个选型不是因为它最潮,而是它资料多、坑少、学生和中小团队都能快速上手。下面从架构拆分讲到核心链路实现,再讲基础设施配置和五个最容易翻车的坑,最后给你一个能直接照做的性能调优和验证清单。
2. 服务拆分的边界:在线教育平台的领域模型与微服务划分
2.1 为什么不能按“前台、后台、管理端”来拆服务
很多人第一次做微服务,习惯按页面拆:用户服务、讲师服务、管理员服务。这是最典型的错误。微服务拆分的依据是“业务能力”和“数据边界”,不是“页面归属”。按页面拆会导致一个服务的 Mapper 疯狂 join 别的服务的表,最后 service 层变成远程调用大杂烩,性能差且没法独立发布。
在线教育平台最稳的拆分方式是围绕“用户—内容—交易—学习”四条主线来划。我一般拆成六个核心服务:
- uaa-service:认证与用户,管登录注册、JWT 签发、讲师/学员角色。
- course-service:课程内容,管课程分类、课程详情、章节、视频元数据。
- order-service:交易订单,管购物车、下单、支付回调、订单状态机。
- learning-service:学习进度,管选课后课程发放、章节学习记录、最近学习位置。
- file-service:文件与视频,管视频上传、转码回调、封面图。
- gateway-service:统一入口,路由转发、统一鉴权、限流。
每个服务独立数据库,服务之间禁止直接 join 对方的表。这是微服务架构的铁律,课程设计答辩时老师最爱问的就是“你怎么保证服务间的数据一致性”,后面第 4 章专门讲。
2.2 订单服务与课程服务为什么必须分开
在线教育里订单服务和课程服务是最容易被人为合并的一对。有人觉得下单不就是“查课程价格 + 减库存 + 生成记录”吗,放一个服务里省事。但你要考虑两个场景:第一,课程上下架、价格调整是运营高频操作,而订单是交易核心链路,两者并发模型完全不同;第二,订单服务将来要对接支付渠道、处理退款,它需要独立扩容和独立灰度。
订单服务自己维护一份课程快照,把课程标题、原价、实付价、讲师名在下单那一刻拷贝到订单表里。这样即使课程后续改价或下架,历史订单依然有据可查。这也是一个硬性要求:订单表里不能只存 course_id,必须冗余课程快照字段。下面给出订单表的核心建表语句,注意我特意把状态字段设计成 tinyint 并用注释说明含义,这是为了避免后期枚举歧义。
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号,全局唯一,不能直接用自增id', `user_id` bigint NOT NULL, `course_id` bigint NOT NULL, `course_title` varchar(128) NOT NULL COMMENT '课程标题快照,下单时冗余', `course_price` decimal(10,2) NOT NULL COMMENT '原价快照', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消 3已退款 4关闭', `pay_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status_create` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程订单表';这里有几个细节要注意:order_no 必须业务唯一,因为支付回调、对账都要靠它定位订单,用自增 id 当订单号会暴露销量而且容易被遍历;uk_order_no 这个唯一索引不仅是数据约束,它是后面防重复下单的兜底防线;idx_status_create 这个联合索引是为了支撑后台订单列表的按状态分页查询,否则订单表数据量上来之后,后台打开一次要好几秒。很多新手会漏掉这个索引,等订单量到几十万才会发现列表页巨慢。
2.3 uaa-service 与真正的难点:令牌与用户上下文传递
认证服务拆出来单独做,原因不只是“登录是个独立功能”,而是它要承担令牌签发、刷新、注销这些安全敏感操作,必须收敛在一个服务里,不能每个服务自己写一套登录逻辑。我的做法是 uaa-service 负责登录后签发 JWT,JWT 里只放 userId、角色、过期时间,不放昵称头像这类可变信息,避免令牌作废问题。
真正难的不是签发,而是服务间如何拿到“当前用户是谁”。OpenFeign 调用从网关过来,网关已经解析过令牌,但下游服务之间的调用不会再经过网关,所以必须自己做上下文传递。常见做法是网关把 userId 放进请求头 X-User-Id,然后在每个服务里用 HandlerInterceptor 解析并放入 ThreadLocal。下面给出这个拦截器的核心代码,它也是你项目里必须有的基础设施。
public class UserContextInterceptor implements HandlerInterceptor { private static final String HEADER_USER_ID = "X-User-Id"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = request.getHeader(HEADER_USER_ID); if (StringUtils.hasText(userId)) { UserContext.set(Long.valueOf(userId)); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } } public class UserContext { private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>(); public static void set(Long userId) { USER_ID_HOLDER.set(userId); } public static Long get() { return USER_ID_HOLDER.get(); } public static void clear() { USER_ID_HOLDER.remove(); } }这段代码虽然短,但有两个致命细节:第一,afterCompletion 里必须 clear,否则线程池复用线程时下一个请求会读到上一个用户的 userId,这是线上事故级别的问题;第二,网关透传的头不能叫 userId 直接传,要用 X-User-Id 这种约定前缀,避免和前端传来的参数混淆。你还要在网关层做一次校验,如果令牌无效直接拒绝,不允许请求进到下游服务再验,否则所有服务的过滤器都要写一遍这套逻辑。
3. 从下单到上课:核心业务链路的完整实现
3.1 下单接口怎么设计才能防止重复下单和超卖
在线教育没有实物库存,但“超卖”问题依然存在,只不过形态变了。用户同时点两次下单、或下单后重复提交,会产生多个待支付订单;再极端一点,同一个课程如果有限时秒杀价格,就会真的被打穿。所以下单接口的防重设计比减库存更关键。我的做法是三层防线:前端按钮置灰只是体验层,真正防住靠的是后端幂等 + 唯一约束 + 分布式锁。
第一层是幂等令牌。客户端先请求一个下单令牌,服务端把令牌存在 Redis 里,下单时传入令牌并做“读取并删除”的原子操作,保证同一个令牌只能用一次。这里禁止用 get 再 delete 的组合,因为并发下两个请求可能同时读到同一个令牌。用 Lua 脚本或者 Redis 的 delete-if-exists 原子操作。第二层是 order_no 的唯一索引,即使令牌被穿透,数据库也会拒绝重复订单号。第三层是针对秒杀场景的分布式锁,锁的 key 设计为 lock:course:create:{userId}:{courseId},加锁时带上用户维度,防止同一用户并发创建同一课程的订单。
@Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(Long userId, Long courseId, String token) { // 第一步:校验并消费幂等令牌,防止重复提交 boolean consumed = redisTemplate.execute( new DefaultRedisScript<>("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class), Arrays.asList("idem:" + token), token); if (consumed == null || consumed == 0L) { throw new BizException("请勿重复提交订单"); } // 第二步:分布式锁,同一用户同一课程只允许一个下单流程 String lockKey = "lock:course:create:" + userId + ":" + courseId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!locked) { throw new BizException("下单处理中,请稍后"); } try { // 第三步:查课程快照信息 CourseSnapshotDTO course = courseClient.getCourseSnapshot(courseId); // 第四步:生成订单并落库,订单号用雪花算法 OrderInfo order = new OrderInfo(); order.setOrderNo(snowflake.nextIdStr()); order.setUserId(userId); order.setCourseId(courseId); order.setCourseTitle(course.getTitle()); order.setCoursePrice(course.getPrice()); order.setPayAmount(course.getPrice()); order.setStatus(OrderStatusEnum.WAIT_PAY.getCode()); orderMapper.insert(order); return order; } finally { redisTemplate.delete(lockKey); } }代码逻辑分四层:第一步消费令牌把“用户手抖”这类重复请求挡在门外,第二步分布式锁把真正的并发冲突串行化,第三步通过 OpenFeign 调 course-service 获取课程快照,第四步落库。注意几个要点:@Transactional 只覆盖本服务的数据库事务,不覆盖远程调用,所以 courseClient.getCourseSnapshot 放在事务里其实是小坑——如果远程调用耗时过长,数据库连接会被长时间占用。我一般建议在事务外先查课程信息,再开启事务写入订单;如果是课程设计写在一起能跑通,但你要知道线上不会这么干。finally 里必须删除分布式锁,防止异常时锁残留导致后续下单全部被阻塞。
3.2 支付回调与订单状态机的推进
支付回调是订单服务里最容易写错的地方。支付宝或微信的回调通知是异步的、可能重复的,而且不保证顺序。你的回调接口必须做到幂等,也就是同一笔支付通知到达多次,结果都一样。这里要设计一个状态机,订单状态只允许按照“待支付 → 已支付 → 已取消 / 已退款”的方向流转,不允许从已支付跳回待支付。
我的回调处理核心思路是数据库乐观锁。更新订单时带上 status 条件,如果影响行数为 0 说明状态已经被别的回调线程改过了,直接返回成功。这样可以不做分布式锁,因为数据库的行锁已经帮我们做了串行化。注意回调接口的返回值很重要,支付渠道要求返回“success”字符串,任何业务异常都不能让支付渠道认为回调失败,否则它会一直重试,造成消息堆积。
@Transactional(rollbackFor = Exception.class) public void handlePayNotify(String orderNo, String tradeNo, BigDecimal payAmount) { // 乐观锁更新:只有待支付状态才能推进为已支付 int updated = orderMapper.updateStatusByCondition( orderNo, OrderStatusEnum.WAIT_PAY.getCode(), OrderStatusEnum.PAID.getCode(), tradeNo, payAmount); if (updated == 0) { // 已处理过或状态不允许推进,直接返回,不抛异常 log.warn("重复回调或状态异常,orderNo={}", orderNo); return; } // 状态推进成功后,发 MQ 消息通知 learning-service 发放课程 UserCourseGrantMessage message = new UserCourseGrantMessage(); message.setUserId(userId); message.setCourseId(courseId); rabbitTemplate.convertAndSend("order.exchange", "course.grant", message); }这段代码里最值得学习的是乐观锁的思想:update 语句的 where 条件里带上当前状态,而不是先 select 再判断再 update。后者在并发下必然出问题。MQ 发消息这一步也要注意:事务提交成功后才允许发消息,否则会出现订单已支付但 MQ 消息没发出去的极端情况。我一般用 Spring 的 TransactionSynchronizationManager 在 afterCommit 里发送,课程设计阶段可以直接用 @TransactionalEventListener 的 AFTER_COMMIT 事件来做。
3.3 课程发放与学习进度:消费 MQ 的细节
learning-service 负责消费“课程已支付”消息,给用户的课程列表里加一门课。这是典型的分布式事务最终一致性场景。消费端最大的坑是消息重复消费:RabbitMQ 的 at-least-once 投递语义决定了同一个消息可能被投递多次,消费端必须自己保证幂等。
我的做法是在 learning-service 里建一张 learn_course 表,user_id 和 course_id 有唯一索引。消费消息时先尝试插入,如果插入时撞了唯一索引就说明已经发放过,直接 ack 并返回。这种“数据库唯一索引兜底”的模式比 Redis 判重更可靠,因为 Redis 可能丢数据,数据库不会。下面给出消费端的核心代码框架。
@RabbitListener(queues = "course.grant.queue") public void onCourseGrant(Message message, Channel channel) throws IOException { long deliveryTag = message.getMessageProperties().getDeliveryTag(); String body = new String(message.getBody()); try { CourseGrantMessage grantMsg = JSON.parseObject(body, CourseGrantMessage.class); // 幂等插入:唯一索引 uk_user_course 兜底,重复消费不会报错 int inserted = learnCourseMapper.insertIgnore(grantMsg.getUserId(), grantMsg.getCourseId()); if (inserted == 0) { log.info("课程已发放过,直接确认,userId={}, courseId={}", grantMsg.getUserId(), grantMsg.getCourseId()); } channel.basicAck(deliveryTag, false); } catch (Exception e) { // 异常时不要立即重回队列,记录日志并人工处理 log.error("课程发放消费失败", e); channel.basicNack(deliveryTag, false, false); } }消费端这里最容易犯的毛病是把业务逻辑写在 try 外面,或者 catch 里调用 basicNack 时 requeue 参数给 true。注意我写的是 basicNack(deliveryTag, false, false),第三个参数 false 表示不重回队列。如果写成 true,一条坏消息会无限循环消费,把整个队列堵死。正确的做法是失败后进死信队列或者记录日志后人工补偿。
学习进度上报也一样,用户每学习一个章节就上报进度,这个接口的写入频率非常高。不能每次都直接 update,我建议在 learning-service 里先写 Redis,用 Hash 结构存 userId 维度下的章节进度,然后定期批量刷入 MySQL。课程设计如果不想引入定时任务,可以直接同步 update,但要说明白这个接口将来一定是要异步化的。
4. 基础设施选型与关键配置:Nacos、Gateway、OpenFeign 的参数细节
4.1 Nacos 注册中心与配置中心的版本选型
Spring Cloud Alibaba 的版本兼容性是个大坑,这里的每一个版本号都不是随便写的。我的推荐组合是:Spring Boot 2.7.x + Spring Cloud 2021.0.x + Spring Cloud Alibaba 2021.0.5.0 + Nacos Server 2.2.x。这个组合经过大量生产验证,资料多,遇到的坑网上都有解决方案。不要一上来就追新,Spring Boot 3 配 Spring Cloud Alibaba 的坑远比想象中多。
Nacos Server 部署时有两类实例要搞清楚:临时实例和持久实例。默认是临时实例,客户端心跳停止后会被自动剔除,适合微服务这种动态扩缩容场景。如果你在 Nacos 控制台手动注册过服务实例,它默认是持久实例,不会被心跳淘汰,服务下线了还在列表里,调用方就会时不时报连接失败。我自己踩过这个坑,当时排查了一下午,最后发现是有人手动在控制台加了实例。
Nacos 作为配置中心时,配置文件要按 dataId 的规则命名才生效。服务名.yaml 是扩展名格式,服务名.properties 是另一种老格式。我建议用 yaml,并且在 bootstrap.yml 里配置共享配置,把 datasource、redis、rabbitmq 这类公共配置放到一个 shared-configs 里,避免每个服务重复维护一份数据库连接。
4.2 Gateway 的路由、鉴权与跨域配置
Spring Cloud Gateway 是替代 Zuul 的方案,基于 WebFlux,不能把普通的 Spring MVC 过滤器直接丢进去用。路由配置看起来简单,但 StripPrefix 这个参数很容易搞错。服务注册到 Nacos 后,路由到下游服务时路径前缀要剥掉一层,否则下游服务会收到一个带服务名的奇怪路径。比如网关匹配路径是 /api/order/**,转发到 order-service 时要 StripPrefix=2,把 /api/order 剥掉,下游才能正确映射到 /order/list 这样的接口。
跨域配置也是网关层的职责。我在网关写了一个全局 CORS 配置,允许的前端域名写死,允许的请求头里必须包含 X-User-Id 和 Authorization,因为前端要传 token。注意网关配了跨域后,下游服务就不要再配 CORS 了,否则会出现重复响应头,浏览器直接报错。
spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=2 - id: learning-service uri: lb://learning-service predicates: - Path=/api/learning/** filters: - StripPrefix=2 default-filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20这段配置里的 RequestRateLimiter 是网关层的简单限流,replenishRate 表示每秒补充的令牌数,burstCapacity 表示桶的容量。10/20 的意思是允许 20 个突发请求,之后每秒稳定 10 个。这个值要根据你的压测结果调整,给太小会把正常用户挡住,给太大又起不到保护作用。注意 RequestRateLimiter 依赖 Redis,如果不配 Redis 网关启动会报错。按用户维度做限流需要自定义 KeyResolver,把 userId 或 IP 作为限流维度。
4.3 OpenFeign 的超时、重试与日志配置
OpenFeign 是服务间同步调用的主力,但默认配置下它有 1 秒的连接超时和 5 秒的读取超时,这在跨服务查询一个复杂聚合接口时基本必挂。我一般在配置里调大读取超时,连接超时保持短一点,因为连不上就该快速失败,不应该等太久。另外要区分两个超时参数:connectTimeout 是建立 TCP 连接的时间,readTimeout 是发出请求后等响应的时间。很多人只调 readTimeout,忽略 connectTimeout,结果服务端压力大时连接池排队,照样报超时。
feign: client: config: default: connectTimeout: 3000 readTimeout: 10000 loggerLevel: FULL course-service: connectTimeout: 3000 readTimeout: 5000对单独的 course-service 单独配置 readTimeout 为 5 秒,因为课程快照接口很轻,不应该超过 5 秒;而 order-service 调用 learning-service 的某些聚合接口需要有更长的等待时间。loggerLevel 设成 FULL 可以在开发环境看到完整的请求响应报文,排查问题效率极高。但生产环境务必改成 BASIC,FULL 日志会打爆磁盘。
OpenFeign 还有一个隐藏坑:默认的重试机制在 GET 请求上会重试,在 POST 请求上不会。如果服务端处理了请求但响应超时,客户端重试 GET 请求是安全的,但重试 POST 请求可能造成重复下单。所以自定义重试策略时,只对 GET 放开重试,POST/PUT/DELETE 一律不重试,宁可让请求失败走人工补偿,也不能让用户莫名其妙下了两单。
5. 数据一致性方案的取舍:什么时候用 Seata,什么时候别用
5.1 在线教育平台里哪些链路真正需要分布式事务
微服务架构下,一个用户操作可能跨多个服务,比如下单选课涉及 order-service 写订单、learning-service 发放课程,还有可能涉及 uaa-service 更新用户积分。很多人一听到跨服务就想到 Seata 分布式事务,然后把所有涉及两个以上服务的操作都包一层全局事务。这是最大的误区。
Seata 的 AT 模式会在全局事务期间持有数据库行锁,并发能力和性能都显著下降。在线教育里真正的强一致场景其实只有一个半:一个是支付回调里订单状态和课程发放的强绑定,半个是下单扣减优惠券。学习进度、浏览记录、课程评价这些都是最终一致性场景,根本不需要全局锁。我的经验是先列一个清单,把“必须同时成功或同时失败”的业务挑出来,再决定要不要上 Seata。大多数课程设计项目其实一个 Seata 都不需要,用本地事务 + MQ + 幂等就足够了,但你必须在答辩时能说清楚为什么。
5.2 用本地消息表实现最终一致性,并且避开 Seata 的坑
如果确实有强一致需求,我推荐先考虑“本地消息表 + 定时任务补偿”这套方案,而不是直接引入 Seata。原理也不复杂:在发起方服务里建一张 local_message 表,业务操作和写消息表在同一个本地事务里提交,然后一个定时任务把 status=0 的消息发到 MQ,消费方处理成功后回调更新消息状态。
这套方案能覆盖 90% 的场景,而且不引入额外基础设施。Seata 引入之后你要多维护一个 TC 服务,还要处理全局锁超时、分支事务回滚失败这些问题。在课程设计里用 Seata 很容易被答辩老师追问“如果全局事务协调器挂了怎么办”,用本地消息表就没有这个问题,因为你只依赖 MySQL 和 RabbitMQ,任何一个挂了都能用手工补偿解决。
我见过太多人把 Seata 用在不该用的地方,比如下单时同步调 learning-service 发课程,把两个服务的数据库操作包在一个全局事务里。结果压测时发现 TPS 上不去,一查是 Seata 的全局锁在排队。血泪经验是:跨服务调用的耗时越长,越不能用同步全局事务;异步化 + 最终一致性才是微服务架构的主流解法。
5.3 分布式锁的 Redis 实现为什么不能只用 SETNX
在秒杀或优惠券场景里要用分布式锁,很多人写的代码是 setIfAbsent 加 expire 两步操作,这两个操作中间如果服务宕机,锁就永远不会过期。必须用一条原子命令同时完成加锁和设置过期时间,Spring Data Redis 的 setIfAbsent 支持传入 Duration 参数,可以做到原子性。释放锁的时候还要校验 value 是不是自己设置的,防止把别人的锁误删。下面这段代码是删除锁时用 Lua 脚本保证原子性的标准写法。
private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; public void unlock(String lockKey, String requestId) { redisTemplate.execute( new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class), Collections.singletonList(lockKey), requestId); }requestId 是加锁时生成的 UUID,释放锁时必须传同一个。这里的核心逻辑是先比较再删除,整个过程用 Lua 保证原子性。如果你只是用 redisTemplate.delete(lockKey),在高并发下 A 线程的锁过期后 B 线程加锁成功,A 线程执行完业务直接 delete,会把 B 的锁删掉,然后 C 线程又能加锁,锁就完全失效了。这个边界坑在答辩时拿出来讲,是很好的加分项。
6. 五个必踩的坑:现象、原因和解决办法
6.1 服务间调用时 LocalDateTime 反序列化报错
现象:order-service 通过 OpenFeign 调 course-service 时,返回的 JSON 里有 LocalDateTime 字段,调用方直接报 InvalidFormatException 或 Cannot deserialize。
原因:Spring Boot 默认的 Jackson 序列化 LocalDateTime 会输出数组格式,而服务提供方如果没有配置 JavaTimeModule,消费方无法解析。另一个常见原因是两边服务配置了不同的 Jackson 序列化规则。
解决:统一在公共模块里配置 Jackson 的 ObjectMapper,注册 JavaTimeModule,并设置写入格式为 yyyy-MM-dd HH:mm:ss。所有服务引用同一份配置,不要在各自服务里重写。如果接口里只有日期没有时间,直接用 LocalDate 字段,避免把 DateTime 和 Date 混用。
6.2 Feign 调用默认超时导致接口必挂
现象:网关调 order-service 正常,order-service 调 course-service 时偶尔正常,偶尔报 Read timed out。压测时几乎必现。
原因:OpenFeign 默认读取超时只有 5 秒,如果 course-service 的接口因为做了聚合查询耗时超过 5 秒,调用直接就失败。而且连接超时 1 秒在很多网络环境下都不够,服务间调用只要跨机器就会中招。
解决:全局配置 connectTimeout 为 3 秒,readTimeout 为 10 秒;对慢接口单独在 @FeignClient 的 configuration 里指定更长的超时。关键是要区分 connectTimeout 和 readTimeout 的语义,连接超时短、读取超时长是通用原则。
6.3 Nacos 版本与 Spring Cloud Alibaba 版本不兼容
现象:服务启动时报 NoSuchMethodError 或 ServiceUnavailableException,Nacos 控制台能看到服务,但调用方就是连不上。换了不同版本的 Nacos Server 后问题时好时坏。
原因:Spring Cloud Alibaba 2021.0.5.0 要求 Nacos Server 2.x,如果用的是 1.4.x 的 Nacos,客户端的部分 API 会不兼容。反过来,Nacos 2.2 以上版本的某些接口 2.1 客户端不支持。
解决:我固定用 Nacos Server 2.2.3 配 Spring Cloud Alibaba 2021.0.5.0,客户端依赖使用 com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery 和 spring-cloud-starter-alibaba-nacos-config,版本号继承 BOM,不要在子服务里单独写版本。
6.4 MQ 消费端一条坏消息堵死整个队列
现象:某个消息消费失败后,RabbitMQ 的管理台里 queue 的 unacked 消息数持续上涨,其他正常消息全部积压。重启消费者也一样,因为那条坏消息总是被重新投递。
原因:消费异常的 catch 块里调用了 basicNack 且 requeue 参数为 true,或者用的是 Spring 的默认重试机制没有配置最大重试次数,导致一条消息无限重试。
解决:消费逻辑里 catch 住所有异常,记录日志后 basicNack(deliveryTag, false, false) 不重回队列,让消息进死信队列。Spring Boot 里可以配置 spring.rabbitmq.listener.simple.retry.enabled=true、max-attempts=3,超过重试次数后进入死信交换机。最好的方案是消费端先做幂等,再配合死信队列做兜底。
6.5 网关跨域配置导致的下游重复响应头
现象:前端访问接口时浏览器报“Access-Control-Allow-Origin header contains multiple values”,请求时而成功时而失败。
原因:网关和后端服务都配置了 CORS,浏览器收到两个不同的跨域响应头会直接拒绝。特别是网关加了 CORS 过滤器后,下游服务的 Spring MVC 又默认处理了 OPTIONS 预检请求。
解决:只在网关配置一次 CORS,下游所有服务关闭跨域处理。如果是 Spring Security 项目,还要在 Security 的过滤器链里放行 OPTIONS 请求,否则预检请求会撞上认证过滤器,返回 401。
7. 从能跑到能扛:链路追踪、压测与容量评估的落地清单
7.1 接入 SkyWalking 做全链路追踪
微服务排障最痛苦的是不知道请求到底卡在哪个服务。我在这个项目的 DEV 环境接入了 SkyWalking,通过 agent 方式把 traceId 贯穿所有服务。配置很简单:每个服务启动时加 -javaagent:/path/to/skywalking-agent.jar -Dskywalking.agent.service_name=order-service,服务就会自动上报链路数据,不需要改业务代码。
排查问题时,在 SkyWalking 的后台按 traceId 搜索,能直接看到这一段链路里每个服务的耗时分布。比如用户反馈选课慢,你一看 trace 发现在 course-service 和 file-service 之间耗了 4 秒,直接定位是 file-service 的接口问题,不用逐台机器翻日志。我平时还有个习惯,在服务间调用时手动在 header 里透传 traceId,这样排查时能串起业务日志和链路日志。
7.2 用 JMeter 压测出三个核心容量指标
做压测前先把典型场景拆出来:登录获取令牌、课程列表浏览、提交订单支付回调。这三个场景分别代表读多、写多、异步处理三条链路。我压测时用 JMeter 分布式压测,注意压测机不要和应用部署在同一台机器,否则结果完全没有参考意义。每个场景压两轮:第一轮看接口的 TP99 延迟,第二轮逐步增大并发直到出现错误率升高,记下那个临界并发数。
在线教育平台最典型的热点瓶颈在课程详情接口和视频播放鉴权。课程详情是首页高并发读,我在 course-service 前加了一层 Redis 缓存,缓存 key 是 course:detail:{courseId},失效时间设 30 分钟加随机抖动,防止缓存雪崩。视频播放鉴权这个接口很奇怪,它会被播放器频繁请求,但很多人没给它设置限流,导致一个用户频繁切换清晰度时把鉴权服务打挂,压测时记得单独为这个接口做一份压测数据。
7.3 视频播放场景的带宽与鉴权分离方案
最后说说视频服务。在线教育里视频点播是最吃带宽的场景,如果视频文件也走应用服务器,带宽成本会直接拖垮整个项目。我的做法是:file-service 只管上传和转码回调,真正的视频播放走 CDN 或对象存储的私有读链接。关键点是防盗链和时效性,不能把视频 URL 直接放在接口返回里让它永久有效。
我采用的方法是:播放接口返回一个带签名和过期时间的临时 URL,过期时间默认 30 分钟,URL 里带上 md5 签名参数。这样即使 URL 被爬到,过期后也无法播放。签名算法逻辑很简单:将 videoId、过期时间戳、secretKey 拼接后做 MD5。注意 secretKey 不能放在前端代码里,只能存在于服务端。课程设计里这个方案已经足够,但答辩时可以提一句生产环境一般用阿里云或腾讯云的 URL 鉴权方案,原理是一样的。
7.4 上线前的清单与我的最后一条建议
上线前我会按这个清单过一遍:Nacos 所有服务是否注册成功并保持心跳稳定;网关路由是否可以从测试环境穿透到每个服务;订单支付链路的 MQ 消息是否能在 RabbitMQ 重启后自动恢复;Redis 缓存是否设置了合理的过期时间和空值缓存;日志是否按服务名和 traceId 分文件滚动;数据库连接池和线程池参数是否根据压测结果做了调整。任何一项不过关,都不能上生产。
这个项目做到能跑很容易,做到能扛才有价值。建议你先按本文的架构把单机版跑通,再做服务拆分,每一层都验证通过后再往下走。我自己的习惯是每拆一个服务,就把线上的日志和监控对比一次,确认没有隐性依赖才继续拆下一个。希望这套方案能帮你少走弯路,也祝你能把这个项目做得比其他人都扎实。
本文还有配套的精品资源,点击获取