ARTICLE DETAIL

资讯详情

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

微服务架构Java在线教育平台:Seata分布式事务与订单一致性设计

微服务架构Java在线教育平台:Seata分布式事务与订单一致性设计 简介一份基于微服务架构的Java在线教育平台设计与实现源码包面向正在学习微服务与Spring Cloud的Java开发者可帮助理解如何将课程、用户、测评、作业、社区等业务模块拆分为独立服务并完成接口协作。压缩包共189个文件、约156KB其中128个Java源文件构成各服务核心逻辑19个XML与9个properties文件提供配置支撑另含31个zbak备份文件与一篇md说明文档结构紧凑便于研读。已有55人学习过该资源。通过学习可掌握服务注册发现、负载均衡、断路器、分布式链路追踪等微服务治理组件的落地写法以及RESTful API、OAuth2/JWT安全认证等实践。预览中可见课程、教师、统计等核心业务模块的源码实现覆盖课程发布、教学管理和数据统计典型场景适合作为课程设计或项目实战的参考模板。1. 微服务架构的 Java 在线教育平台为什么这套设计与实现值得你拆开看答辩老师问了我一个问题我当场没接住“你下单的时候如果课程库存扣了但订单没生成你的系统怎么保证两边一致”那一刻我才意识到做一个能跑通的前后端 Demo 并不难难的是用微服务架构把在线教育平台的订单、课程、用户、支付这些业务串起来还能在数据一致性上站得住脚。这份资源就是一套基于微服务架构的 Java 在线教育平台设计与实现代码层面从网关到业务服务全部拆开涵盖 Nacos 注册中心、OpenFeign 远程调用、Redis 缓存、RabbitMQ 延时关单和 Seata 分布式事务。适合三类人拿它做毕业设计或课程设计的在校生想从单体项目转向微服务的 Java 工程师以及正在准备微服务架构面试、想找一份能落地复现的源码包的人。它能回答的不只是“怎么跑起来”还有“跑起来之后怎么向别人讲清架构”。2. 服务拆分与技术选型六大业务模块的边界、端口与依赖版本2.1 在线教育平台为什么必须拆微服务在线教育平台的业务天然适合微服务因为它的模块边界太清楚了。用户、课程、订单、支付、学习记录、评论通知每个模块的访问量和变更频率都不一样。比如课程详情是读多写少支付和订单是强一致场景学习记录则需要高吞吐写入。如果把这些东西全塞进一个 Spring Boot 单体到了课程大促或者答辩演示高并发的时候一个慢 SQL 就能把整站拖死更麻烦的是团队多人协作时每改一段代码都要重新构建整个应用。我把平台拆成了六个服务网关服务负责统一入口和鉴权路由认证服务管登录态与 Token用户服务管学员信息课程服务管课程与章节内容订单服务管下单与状态流转支付服务管支付回调与对账。每个服务独立部署、独立数据库服务之间只通过 OpenFeign 接口通信。这个拆分粒度不是越细越好而是按业务边界和事务边界来切。课程与章节放在同一个服务里因为它们的读写在同一事务里订单与支付拆开因为支付回调需要处理外部结果不能阻塞下单主链路。2.2 技术选型这套平台为什么选 Spring Cloud Alibaba 全家桶现在做 Java 微服务项目主流路线就是 Spring Cloud Alibaba。Nacos 同时承担注册中心和配置中心比 Eureka 加 Config 的组合少维护两个组件OpenFeign 做声明式远程调用比 RestTemplate 直观网关直接用 Spring Cloud Gateway性能和断言配置都比 Zuul 顺眼缓存用 Redis消息用 RabbitMQ分布式事务接入 Seata。资源仓库里的 pom 就是按这套组合配好的Spring Boot 用的 2.7.x 系列Spring Cloud Alibaba 用的 2021.x 系列JDK 默认配了 1.8。选型逻辑有一条主线尽量贴近一线互联网公司的生产组合又别引入太重的东西。比如分布式事务有人用 TCC、有人用 Saga但课程设计答辩时你能把 Seata AT 模式的自动回滚讲清楚比手写一堆补偿接口更让人信服。中间件能 Docker 跑的一律 Docker 跑MySQL、Redis、RabbitMQ 都给了容器编排文件免得装环境就劝退一半人。持久层选了 MyBatis-Plus不是为了炫技是因为单表 CRUD 和分页代码能少写很多把精力留给微服务本身。2.3 端口规划与服务注册部署前先看懂这张表服务模块、端口和注册名的对应关系必须提前定死。Nacos 默认暴露 8848网关对外是 9527下面这张端口分配表是资源包里的标准配置也是我在本地一直沿用的部署和排查问题的时候第一步就是核对端口有没有被占用、服务名拼没拼错。服务模块端口Nacos 注册名主要职责gateway-server9527gateway-server统一入口、路由转发、Token 鉴权auth-server9001auth-service用户认证、Token 签发user-server9002user-service学员信息、注册登录course-server9003course-service课程、章节、讲师、库存order-server9004order-service订单创建、状态流转、关单pay-server9005pay-service支付回调、对账每个服务都要往 resources 里放一个 bootstrap.yml里面写上 spring.application.name 和 Nacos 地址。我遇到过很多人把应用名写在 application.yml 里结果 Nacos 控制台显示的服务名和 Feign 里 FeignClient 指定的 name 对不上接口调用直接报找不到服务。这个坑在第 5 章还会专门说这里先记住一条Nacos 里的服务名 bootstrap.yml 的 spring.application.name FeignClient 的 name三者必须完全一致。2.4 网关路由配置把请求正确转发到六个服务网关是外部请求进入微服务集群的唯一入口。前端调 /api/course/getById网关就把请求转发给 course-service调 /api/order/create就转发给 order-service。下面是资源包里 gateway-server 的 application.yml 核心配置spring: application: name: gateway-server cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: online-edu gateway: routes: - id: course-route uri: lb://course-service predicates: - Path/api/course/** filters: - StripPrefix1 - id: order-route uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1 - id: auth-route uri: lb://auth-service predicates: - Path/api/auth/** filters: - StripPrefix1 redis: host: 127.0.0.1 port: 6379关键点是 uri 里的 lb://course-service。lb 表示走负载均衡后面跟的是 Nacos 注册中心的服务名不是 IP 地址。Predicates 配置的是路由匹配规则Path/api/course/** 表示所有以 /api/course/ 开头的请求都走这条路由。StripPrefix1 是把第一段路径去掉再转发也就是请求到 course-service 后路径变成 /getById不用再带 /course 前缀。鉴权用的全局过滤器写了放行白名单比如 /api/auth/login 和课程列表接口不需要 Token下单和下架接口必须带 Token这部分的判断逻辑在返还码和状态码上也做了区分方便前端统一处理。3. 课程下单与订单关单OpenFeign 调用、Redis 扣库存和延时消息3.1 下单主流程的代码骨架下单是一个跨服务的业务链订单服务收到下单请求后要去用户服务确认用户存在去课程服务确认课程在售再扣减课程库存最后在本地生成订单并发送延时消息等待支付。下面这段代码是 OrderService 里 createOrder 的核心实现也是整个平台业务逻辑最集中的地方Slf4j Service public class OrderServiceImpl extends ServiceImplOrderMapper, Order { private final UserClient userClient; private final CourseClient courseClient; private final RedisTemplateString, Object redisTemplate; private final OrderMapper orderMapper; private final RabbitTemplate rabbitTemplate; public OrderResult createOrder(CreateOrderDTO dto) { // 1. 调用 user-service 查询用户是否存在 UserDTO user userClient.getById(dto.getUserId()); if (user null) { throw new BizException(500, 用户不存在); } // 2. 调用 course-service 查询课程信息 CourseDTO course courseClient.getById(dto.getCourseId()); if (course null || course.getStatus() ! 1) { throw new BizException(500, 课程不存在或已下架); } // 3. Redis Hash 原子扣减库存避免超卖 Long remain redisTemplate.opsForHash() .increment(course:stock: dto.getCourseId(), stock, -1); if (remain 0) { redisTemplate.opsForHash() .increment(course:stock: dto.getCourseId(), stock, 1); throw new BizException(500, 课程库存不足); } // 4. 生成本地订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setCourseId(dto.getCourseId()); order.setAmount(course.getPrice()); order.setStatus(0); // 0待支付, 1已支付, 2已关闭, 3已退款 orderMapper.insert(order); // 5. 发送延时消息30 分钟后未支付自动关单 rabbitTemplate.convertAndSend(order.exchange, order.delay.key, order.getOrderNo(), message - { message.getMessageProperties().setDelay(30 * 60 * 1000); return message; }); log.info(订单创建成功 orderNo{}, order.getOrderNo()); return new OrderResult(order.getOrderNo()); } }这里的库存扣减用的是 Redis 的 Hash 结构key 是 course:stock:课程ID里面存一个 stock 字段。opsForHash().increment 是原子操作并发下单时不会出现两个请求都读到库存为 1 的情况。扣减后如果剩余小于 0要立刻把库存加回来再抛异常这一步是防止超卖的关键。generateOrderNo 生成订单号用的规则是“时间戳 用户ID后四位 随机数”保证并发下订单号不重复。延时消息设置的是 30 分钟这个值在资源包里是个常量改成 5 分钟就能在演示时更快看到关单效果。3.2 OpenFeign 客户端服务间调用就是这么写的订单服务要调用户服务和课程服务靠的是 OpenFeign。在 order-server 里定义一个 CourseClient 接口写法如下FeignClient(name course-service, path /api/course) public interface CourseClient { GetMapping(/{id}) CourseDTO getById(PathVariable(id) Long id); PostMapping(/stock/deduct) void deductStock(RequestParam(courseId) Long courseId, RequestParam(count) Integer count); PostMapping(/stock/release) void releaseStock(RequestParam(courseId) Long courseId, RequestParam(count) Integer count); }name 必须和课程服务在 Nacos 里的注册名完全一致path 要和课程服务 Controller 的 RequestMapping 前缀一致。Feign 的工作原理是启动时根据 name 去 Nacos 拉取服务实例列表然后通过动态代理帮你发 HTTP 请求你写的接口方法签名会拼出真实的 URL。所以 CourseDTO、UserDTO 这些对象不能在两个服务里各写各的资源包里单独放了一个 common-dto 模块订单服务通过 Maven 依赖引入。这里有个细节Feign 的接口要加 FeignClient 才能被扫描启动类还需要 EnableFeignClients 注解扫描路径默认是启动类所在包。如果你的 FeignClient 放在别的包路径下启动时会报找不到对应 bean需要在 EnableFeignClients 里指定 basePackages。资源包的代码里已经配好了 basePackages com.edu.order.feign自己加新接口时记得保持包路径一致。3.3 RabbitMQ 延时关单用死信队列给订单发“后悔药”订单创建后如果用户一直不支付订单就一直占着库存这不行。常见的做法是用 RabbitMQ 死信队列实现延时关单消息 30 分钟后过期过期后自动投递到死信队列由一个消费者消费并关闭订单。配置类核心代码如下Configuration public class RabbitDelayConfig { Bean public Queue orderDelayQueue() { return QueueBuilder.durable(order.delay.queue) .withArgument(x-dead-letter-exchange, order.exchange) .withArgument(x-dead-letter-routing-key, order.close.key) .build(); } Bean public Queue orderCloseQueue() { return QueueBuilder.durable(order.close.queue).build(); } Bean public Binding delayBinding() { return BindingBuilder.bind(orderDelayQueue()) .to(new TopicExchange(order.exchange)) .with(order.delay.key); } Bean public Binding closeBinding() { return BindingBuilder.bind(orderCloseQueue()) .to(new TopicExchange(order.exchange)) .with(order.close.key); } }这个方案的核心是给 order.delay.queue 设置两个参数x-dead-letter-exchange 指定死信消息去哪个交换机x-dead-letter-routing-key 指定死信消息的路由键。生产者发消息时不设置 TTL而是在发送时给每条消息设置 setDelay这样不同订单可以灵活控制超时时间。消息在 delay queue 里躺够 30 分钟变成死信后被投递到 order.exchange路由键换成 order.close.keycloseBinding 把消息送进 order.close.queue关单消费者在那里接消息。关单消费者的逻辑是根据 orderNo 查询订单如果状态还是待支付就更新为已关闭同时远程调用 course-service 的 releaseStock 释放库存如果订单已经支付了就直接忽略消息并确认。这个“先查状态再更新”的动作一定要在数据库层面做条件更新比如 UPDATE order SET status2 WHERE order_no? AND status0避免关单和支付的并发竞争。4. 分布式事务与数据一致性Seata AT 模式的落地与订单状态机兜底4.1 微服务下单产生的数据一致性问题下单流程同时涉及订单服务写订单表、课程服务扣库存表两个操作在各自服务里都有自己的本地事务。本地事务只能保证自己库里的操作原子性跨库就失效了。假设订单写成功但远程扣库存失败或者库存扣了但订单没生成都会造成数据对不上。这就是面试官常问的“Java 怎么保证数据一致性”在微服务场景下的标准提问方式。解决跨服务一致性有几种常用方案。本地消息表要自己建表写扫描任务TCC 要写 try-confirm-cancel 三组接口Seata AT 模式则在业务代码无侵入的情况下帮我们做回滚。对课程设计和中小型项目来说Seata AT 的性价比最高。它在第一阶段把每个分支事务的 undo_log 写进业务库第二阶段全局事务提交时就顺手删除这些日志回滚时就反过来执行补偿 SQL。方案对业务代码侵入一致性强度需要额外维护的组件本地消息表中等要写消息表和定时任务最终一致无TCC高每个接口写三组逻辑强一致无Seata AT低一个注解搞定强一致TC Server、每个库建 undo_log 表RabbitMQ 事务消息低最终一致消息中间件4.2 引入 Seata依赖、配置和全局事务注解资源包采用 Seata 的 AT 模式核心思路是在发起全局事务的方法上标注 GlobalTransactionalSeata 会在方法执行过程中协调所有参与者的分支事务。下面是在订单服务中开启全局事务的最简实现GlobalTransactional(name create-order, rollbackFor Exception.class) Transactional(rollbackFor Exception.class) public void createOrderWithStock(CreateOrderDTO dto) { // 远程调用 course-service 扣减数据库库存 courseClient.deductStock(dto.getCourseId(), 1); // 本地插入订单记录 orderMapper.insert(buildOrder(dto)); // 模拟异常如果这里抛错Seata 会同步回滚上面扣掉的库存 if (dto.getCourseId() 999L) { throw new RuntimeException(模拟创建订单失败); } }GlobalTransactional 的 name 属性是这个全局事务的标识符在 Seata Server 控制台能看到它rollbackFor 指定哪些异常触发回滚。这里双注解的原因是 GlobalTransactional 负责全局管理Transactional 负责本地事务。两个注解都加上之后远程扣库存和本地插订单被包进同一个全局事务任意一步抛异常Seata 都会根据 undo_log 反向生成补偿 SQL 把库存加回来。Seata 的服务端单独运行资源包里用 Docker 方式部署。连接到 Nacos 后客户端通过 application.yml 指定事务分组名如下seata: enabled: true application-id: order-server tx-service-group: online-edu-group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 application: seata-servertx-service-group 必须和 Seata Server 端的配置一致否则客户端找不到服务端启动时不会报错但全局事务调用时会一直卡在超时。每个业务数据库都要建 undo_log 表表结构和 Seata 官方给的建表语句一致遗漏这张表会导致回滚时报“undo_log 表不存在”。4.3 定时任务扫单给一致性再加一道保险即便有 Seata关单和支付回调解读还要防止极端情况漏处理比如 RabbitMQ 重启期间消息丢失、支付回调在重试次数内没送达。资源包里还做了定时任务兜底order-server 启动一个 Scheduled 任务每隔两分钟扫描订单表中状态为待支付且创建时间超过 30 分钟的订单批量关闭并释放库存。这个操作必须基于条件更新去做。核心 SQL 是 update order_info set status 2 where order_no ? and status 0只有返回值等于 1 才说明这次关闭操作是当前系统完成的才去调 releaseStock 释放库存。如果更新行数为 0说明订单在定时任务执行之前已经完成支付或已经关闭直接跳过。用数据库条件更新代替先查后改可以避免并发情况下重复关单或重复释放库存。我这里再解释一下为什么定时任务和延时消息要同时存在。延时消息负责缩短“从下单到关单”的延迟通常能做到秒级触发定时任务做低速兜底负责找回丢失消息。两个方案并行时一定要保证释放库存的接口是幂等的course-service 的 releaseStock 方法实现是库存先加 count再把加超的阈值压回 totalStock这样就算同一订单被关两次库存也不会超过初始总数。5. 本地部署与常见问题排查从启动到演示容易翻车的五个坑5.1 启动顺序错了服务之间谁也找不到谁拿到资源包后先别急着点启动。正确的启动顺序是 MySQL、Redis、RabbitMQ、Nacos、Seata Server 这些中间件先起来再启动业务服务。业务服务里先启动 auth-server、user-server、course-server确认它们在 Nacos 控制台出现后再启动 order-server 和 pay-server最后启动 gateway-server。如果反过来gateway 先启动了路由断言里指向的 lb://course-service 没有可用实例请求进来会直接报 503。5.2 坑一服务一直注册不上 Nacos控制台空白现象服务启动日志没有报错但 Nacos 控制台服务列表是空的。原因大概率是 bootstrap.yml 里的 namespace 和 Nacos 控制台不一致。Nacos 默认命名空间是 public如果代码里配了 namespace: online-edu但控制台没有创建这个命名空间服务会注册到不存在的命名空间下页面当然看不到。解决方法是去 Nacos 控制台新建命名空间把命名空间 ID 填进 bootstrap.yml或者直接把 namespace 这行删掉让服务注册到 public。这个问题在带命名空间的项目里非常常见每次新拉代码我都会先确认这行。5.3 坑二Feign 调用报 Load balancer does not have available server现象服务都注册上去了但调接口时返回“Load balancer does not have available server for client: course-service”。原因有两种第一种是 course-service 真的没注册上按 5.2 排查第二种是 2021 年之后的 Spring Cloud 移除了 RibbonFeign 默认没有负载均衡器实现需要单独引入 spring-cloud-starter-loadbalancer 依赖。资源包的 pom 里已经加了但如果你在自己项目里复刻代码时漏掉这个依赖就会踩中。解决方法是检查 order-server 的 pom 是否包含 spring-cloud-starter-loadbalancer加上后重启即可。5.4 坑三MySQL 8 连接报 Public Key Retrieval is not allowed现象服务启动时数据源初始化抛异常提示“Public Key Retrieval is not allowed”。原因是 MySQL 8 默认使用 caching_sha2_password 认证插件连接时如果 SSL 未启用驱动拒绝自动获取公钥。解决方法是修改 JDBC URL加上 allowPublicKeyRetrievaltrueuseSSLfalse比如 jdbc:mysql://127.0.0.1:3306/edu?allowPublicKeyRetrievaltrueuseSSLfalseserverTimezoneAsia/Shanghai。serverTimezone 必须带上否则还会出现时间差 8 小时的问题。数据库建表脚本在 resources/sql 目录下执行时注意选择 utf8mb4 字符集课程标题和学员评价里的 emoji 才能正常存。5.5 坑四JDK 版本切换后报“源发行版 17 需要目标发行版 17”现象Maven 编译时控制台输出“java: 警告: 源发行版 17 需要目标发行版 17”。这个报错在 IDEA 里尤其多原因是你机器的 JDK、Maven 编译器的 source/target、IDEA 的 Project SDK 三处版本不一致。比如 pom 里写了 java.version1.8/java.version但 IDEA 的 Project Structure 里 Project SDK 选成了 JDK 17编译时就会拿 17 的 javac 去按 8 的源级别解析。解决方法是把 pom 的 java.version 改成 1.8资源包默认就是 1.8再将 IDEA 的 Project SDK、Module SDK、Settings → Java Compiler 里的 Target bytecode version 全部切到 1.8最后执行 mvn clean compile。如果你刻意用 JDK 17就把前三处统一改成 17。5.6 坑五Seata 回滚失效库存和订单数据对不上现象下单时报错了但课程库存没有恢复。大概率是三个原因之一业务库没有建 undo_log 表、tx-service-group 分组不一致、数据源没有用 DataSourceProxy 代理。前两个按第 4 章的配置核对第三个要特别留意Seata AT 模式下业务数据源必须被代理否则 Seata 拦截不到 SQL 执行。资源包在 order-server 和 course-server 里都配置了 SeataDataSourceConfig用 Configuration 把 DataSource 包装成 DataSourceProxy 返回。复刻代码时不要嫌麻烦去掉这个配置类去掉之后事务回滚就变成“静默失效”业务不报错但数据就是不对。6. 验证平台的最终手段健康检查脚本、压测与链路追踪6.1 一键健康检查脚本演示前跑一遍每次答辩或演示前我会先跑一个脚本确认各个服务实例在线、下单接口响应正常。下面这段脚本是我压箱底的习惯直接放在项目根目录改成你的业务参数就能用#!/bin/bash # 检查六个服务在 Nacos 中的存活实例数 SERVICES(auth-service user-service course-service order-service pay-service gateway-server) for s in ${SERVICES[]}; do COUNT$(curl -s http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName$s | jq .hosts | length) echo $s 实例数: $COUNT done # 用真实登录 Token 调一次下单接口打印 HTTP 状态码 TOKEN$(curl -s -X POST http://127.0.0.1:9527/api/auth/login \ -H Content-Type: application/json \ -d {username:demo,password:123456} | jq -r .data.token) BODY{\userId\:1,\courseId\:2} CODE$(curl -s -o /dev/null -w %{http_code} \ -X POST http://127.0.0.1:9527/api/order/create \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN -d $BODY) echo 下单接口 HTTP 状态: $CODE脚本用 curl 直接打 Nacos OpenAPI 查实例数jq 解析 JSON环境里没有 jq 的话先 brew install jq 或 apt install jq。下单前先走一次登录接口拿 Token避免网关鉴权拦截导致误判。看到五个业务服务实例数都是 1、下单接口返回 200再上台演示才稳。6.2 压测与链路追踪把性能数字和调用链一起亮出来如果你想让答辩更有说服力可以用 JMeter 对下单接口做一次简单的 100 并发压测看 TPS 和错误率。压测前要注意关掉定时关单任务否则压测产生的待支付订单会被批量关闭导致数据看起来乱。链路追踪方面资源包里集成了 Spring Cloud Sleuth 和 Zipkin启动 Zipkin 后用 docker compose up -d zipkin 拉起来一次下单请求的完整调用链——从网关到订单服务再到课程服务、再走 Redis 和 RabbitMQ——都能在 Zipkin 的界面里看到。当你能在几十秒内把“下单请求跨了哪几个服务、每一步耗时多少”展示出来比任何架构图都有说服力。从那以后我每次做完微服务项目都会强制走一遍这个流程先跑健康检查脚本确认服务在线再用 JMeter 压一遍核心接口最后开 Zipkin 追踪一次完整调用链三步全过才敢拿出来见人。这套流程也让我后来调线上问题时不再像无头苍蝇一样乱翻日志而是直接通过链路追踪定位慢节点。项目的源码、SQL 脚本和 Seata 配置都在资源包里照着启动顺序搭起来再结合第 5 章的排错清单你也能一个下午把它跑通。希望帮到你。本文还有配套的精品资源点击获取
返回列表