ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于微服务架构的Java在线教育平台:从业务拆分到订单链路与性能优化

基于微服务架构的Java在线教育平台:从业务拆分到订单链路与性能优化 简介面向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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程订单表;这里有几个细节要注意order_no 必须业务唯一因为支付回调、对账都要靠它定位订单用自增 id 当订单号会暴露销量而且容易被遍历uk_order_no 这个唯一索引不仅是数据约束它是后面防重复下单的兜底防线idx_status_create 这个联合索引是为了支撑后台订单列表的按状态分页查询否则订单表数据量上来之后后台打开一次要好几秒。很多新手会漏掉这个索引等订单量到几十万才会发现列表页巨慢。2.3 uaa-service 与真正的难点令牌与用户上下文传递认证服务拆出来单独做原因不只是“登录是个独立功能”而是它要承担令牌签发、刷新、注销这些安全敏感操作必须收敛在一个服务里不能每个服务自己写一套登录逻辑。我的做法是 uaa-service 负责登录后签发 JWTJWT 里只放 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 ThreadLocalLong 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 时要 StripPrefix2把 /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: - StripPrefix2 - id: learning-service uri: lb://learning-service predicates: - Path/api/learning/** filters: - StripPrefix2 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 可以在开发环境看到完整的请求响应报文排查问题效率极高。但生产环境务必改成 BASICFULL 日志会打爆磁盘。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 表业务操作和写消息表在同一个本地事务里提交然后一个定时任务把 status0 的消息发到 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 或 ServiceUnavailableExceptionNacos 控制台能看到服务但调用方就是连不上。换了不同版本的 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.enabledtrue、max-attempts3超过重试次数后进入死信交换机。最好的方案是消费端先做幂等再配合死信队列做兜底。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_nameorder-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 分文件滚动数据库连接池和线程池参数是否根据压测结果做了调整。任何一项不过关都不能上生产。这个项目做到能跑很容易做到能扛才有价值。建议你先按本文的架构把单机版跑通再做服务拆分每一层都验证通过后再往下走。我自己的习惯是每拆一个服务就把线上的日志和监控对比一次确认没有隐性依赖才继续拆下一个。希望这套方案能帮你少走弯路也祝你能把这个项目做得比其他人都扎实。本文还有配套的精品资源点击获取
返回列表