Spring Cloud 微服务接入 BFF 聚合层:从一个混乱的项目重构说起

当你的单体项目被拆成微服务,前端第一个崩溃 某天接手了一个从老单体拆出来的微服务电商项目。技术栈倒是很"大厂"——Spring Cloud Alibaba、Nacos、Sentinel、RocketMQ、ShardingSphere,你能想到的全塞上了。 但前端同事过来敲门的时候,事情就不太对劲了。 “咱这项目一共几个文档地址?” “9 个。"(每个后端服务一个 Knife4j 页面) “那我要调一个登录接口,该看哪个服务的文档?” “……好问题。” 这就是典型的微服务拆了,但没完全拆——后端确实拆成了 9 个独立服务,可前端仍然需要知道每个服务的地址、每个接口的路径、每个返回的字段含义。而且很多接口其实需要前端自己拼数据:登录完了再查一遍用户信息、再查一遍菜单权限、再查一遍角色列表。 前端不是在写业务,是在做 API 聚合。 BFF:不是新概念,但能解决真问题 BFF(Backend For Frontend)的核心思路很简单:每个前端都有一个专属的后端入口,这个入口干三件事: 聚合 — 把多个后端服务的数据合并成前端需要的一站式响应 裁剪 — 只返回前端真正需要的字段,不裸奔整个数据库实体 隔离 — 后端再怎么拆、再怎么重构,前端代码不用动 架构上看起来就是中间多了一层: flowchart LR subgraph CLIENT["📱 前端"] WEB(["管理后台 Web"]) APP(["移动端 小程序"]) end subgraph GW["🚪 网关层"] GATEWAY[Spring Cloud Gateway\nJWT · CORS · Sentinel] end subgraph BFF["🎯 BFF 聚合层"] ADMIN_BFF["mall-admin-api\n管理后台 BFF\n端口 8090"] MOBILE_BFF["mall-mobile-api\n移动端 BFF\n端口 8091"] end subgraph BACKEND["⚙️ 业务微服务"] AUTH[mall-auth-api] BASIC[mall-basic-api] PRODUCT[mall-product-api] ORDER[mall-order-api] MARKETING[mall-marketing-api] OTHERS[其余 4 个服务...] end WEB -->|"/api/admin/**"| GATEWAY APP -->|"/api/mobile/**"| GATEWAY GATEWAY --> ADMIN_BFF GATEWAY --> MOBILE_BFF ADMIN_BFF -->|Feign 调用| BACKEND MOBILE_BFF -->|Feign 调用| BACKEND classDef bffFill fill:#2e1065,stroke:#a855f7,stroke-width:2.5px,color:#f8fafc,font-weight:bold; class ADMIN_BFF,MOBILE_BFF bffFill; 为什么要加这一层?直接转发不行吗? 接手时项目就已经有两个"BFF 模块"了—— mall-mobile-api 和 mall-admin-api 。但打开一看,里面就一个 ForwardController ,用 RestTemplate + LoadBalancerClient 把所有请求原封不动转发到后端服务: ...

三月 12, 2023 · 4 分钟 · 739 字 · yaomingye

单体拆分微服务:9 个服务踩出来的 7 个典型错误

单体拆微服务,这 7 个坑你踩过几个? 接手了一套从单体架构拆分为微服务的 Spring Cloud Alibaba 项目。9 个服务,Spring Boot 3.3.5,集齐了 Nacos、Gateway、Sentinel、RocketMQ、ShardingSphere、Elasticsearch 全家桶——看 POM 文件像一份微服务教科书。 实际跑起来就发现问题了:项目虽然拆成了 9 个模块,但在架构思维上仍然是个单体。 用了微服务的壳,没改掉单体时代的坏习惯。一顿排查下来,发现了 7 个典型错误。 错误一:全量包扫描——每个服务都在扫整个宇宙 第一个映入眼帘的就是各个 Application 类上的注解: @ComponentScan(basePackages = "cn.net.mall") 9 个服务里有好几个直接扫整个项目包树。这意味着什么? mall-pay 启动的时候,Spring 会去扫描 mall-common 下的所有类,包括 cn.net.mall.util.RedisUtil 。而 RedisUtil 又依赖 StringRedisTemplate ,这个类来自 spring-boot-starter-data-redis ,偏偏 mall-pay 的 POM 里没加这个依赖。 mall-pay 启动 → 扫描 cn.net.mall → 发现 RedisUtil → 尝试创建 → StringRedisTemplate 不在 classpath → ClassNotFoundException → 启动失败 flowchart LR subgraph SCAN["全量扫描 `cn.net.mall `"] PAY["mall-pay\n@ComponentScan"] COMMON["mall-common\nRedisUtil ← 依赖 → StringRedisTemplate"] end subgraph CLASSPATH["pay 的 classpath"] DEPS["mall-common.jar\n(但有 optional=true)"] MISSING["❌ StringRedisTemplate 不在"] end PAY -->|"扫描到"| COMMON COMMON -.->|"尝试创建 bean"| MISSING MISSING -->|"ClassNotFoundException"| CRASH["启动崩溃"] classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa; class PAY,COMMON process; class MISSING,CRASH reject; class DEPS highlight; 更隐蔽的问题是:全量扫描让分仓库成为泡影。 微服务的核心理念之一就是独立开发、独立部署。如果每个服务都假设"所有模块在同一个 classpath 上",那一旦把服务拆到独立 Git 仓库,全量扫描就会漏掉其他服务的类——因为它根本不在 classpath 上。 ...

三月 6, 2023 · 8 分钟 · 1559 字 · yaomingye

Nacos 配置管理实战:config.import 与 bootstrap.yml 的取舍、多环境方案、模板标准化

Nacos 配置管理:一套标准化方案如何搞定 9 个微服务 接手了一套 Spring Cloud Alibaba 微服务项目。读完代码后先看了一遍它的配置管理——毕竟 9 个服务各有各的数据库、Redis、Token 密钥,还不用同一套连接机制,这不配置爆炸谁配置爆炸。 检查结果不出所料:有的服务用 bootstrap.yml 连 Nacos,有的用 config.import ,还有的既没 bootstrap.yml 也没 config.import ,全靠本地 application.yml 硬撑着。更离谱的是,9 个服务在 Nacos 上重复配置了 9 遍 Redis、9 遍 JWT 密钥,改一次密码要改 9 个 dataId。 分享一套标准化方案,从 9 个服务的混乱配置中理出了头绪。 bootstrap.yml 还是 config.import? Spring Cloud Alibaba 项目连接 Nacos 有两种做法。第一种是传统方案,在 bootstrap.yml 中配置 Nacos 参数: `` `yaml bootstrap.yml — 旧方案 spring: cloud: nacos: config: server-addr: ${NACOS_ADDR:localhost:8848} namespace: ${NACOS_NAMESPACE:mall} file-extension: yaml 需要额外引入 `spring-cloud-starter-bootstrap` 依赖才能生效。这是 Spring Cloud 2020 之前的标准做法。 第二种是新方案,直接在 `application.yml` 中通过 `spring.config.import` 指定: `` `yaml # application.yml — 新方案 spring: config: import: nacos:${spring.application.name}.yaml Spring Cloud 2023.x 已默认关闭 bootstrap 上下文,官方推荐使用 config.import 。 ...

三月 2, 2023 · 5 分钟 · 1030 字 · yaomingye

接手微服务项目的踩坑与重构:鉴权链路、补偿机制与 API 文档整理

接手了一套微服务项目,代码看起来挺像回事,仔细一看全是坑 这套项目表面上看骨架搭得不错——Spring Cloud Alibaba 全家桶、17 个 Maven 模块、9 个微服务、分库分表、ES 双写、SkyWalking 链路追踪,基础中间件能怼的全都怼上去了。 但真的跑起来、改起来、审计起来,才发现业务逻辑有不少问题:鉴权链路缺半截、下单不扣库存、@NoLogin 散落几十个地方、JWT 只存了 username 导致全链路 Redis 查重。基础设施搭得好,不等于项目能经受住实际业务场景的考验。 本文将项目里几个典型问题摆出来,聊聊踩坑过程和修复思路,同时配图说明关键流程的改造前后对比。 JWT claims:只放 username,剩下的全塞 Redis 第一个让人困惑的设计点在于 JWT 的使用方式。看下 Token 生成代码: // UserTokenHelper.java — 原实现 public String generateToken(String username, String json) { String token = Jwts.builder() .setSubject(username) // ← 只放了 username .setExpiration(generateExpired()) .signWith(SignatureAlgorithm.HS512, tokenSecret) .compact(); redisUtil.set(getTokenKey(username), token, 3600); // Redis 存一份 redisUtil.set(getUserKey(username), json, 3600); // 用户完整信息也存 Redis return token; } JWT 的 claims 字段本质上设计来承载结构化信息,签名保证不被篡改。但这里把它当成了一个随机字符串来用——JWT 里只放了 sub: "admin",userId、角色、权限全部丢进 Redis。 ...

二月 28, 2023 · 4 分钟 · 849 字 · yaomingye

四种限流算法在 Spring Cloud Gateway 中的实现:固定窗口、滑动窗口、漏桶、令牌桶

在 Gateway 里手写四种限流算法 目标说明 网关是流量的咽喉,限流是网关最重要的能力之一。这篇文章的目标很明确: 理解四种主流限流算法的核心逻辑:固定窗口、滑动窗口、漏桶、令牌桶 每种算法都能写出来并跑通,不只是看概念 集成到 Spring Cloud Gateway 中,作为自定义 GatewayFilter 使用 了解生产级方案:Redis + Lua 分布式限流 读完这篇文章,读者应该能回答:“为什么 Sentinel 选滑动窗口、Gateway 选令牌桶?“以及"如果让你自己写一个限流过滤器,你怎么写?” 前置条件 开始之前,确保环境满足以下条件: 依赖 版本要求 用途 JDK 11+ 运行 Spring Boot 应用 Spring Boot 2.7.x 基础框架 Spring Cloud 2021.0.x Gateway 依赖 Spring Cloud Gateway 3.1.x 网关核心 Redis(可选) 6.0+ 分布式限流 JMeter(可选) 5.5+ 压测验证 验证命令: java -version # 应输出 11 或更高 mvn -version # 确认 Maven 可用 redis-cli ping # 如果做分布式限流,确认 Redis 连通 ⚠️ 新手提示:本文的代码可以在一个独立的 Spring Boot 项目中运行,不需要完整的微服务集群。只要一个 Gateway 项目 + 一个后端服务即可验证。 ...

二月 12, 2023 · 8 分钟 · 1658 字 · yaomingye

SpringCloud微服务测试实战

SpringCloud微服务测试实战:分层策略、完整代码与AI时代的新思路 问题切入 写了一万行业务代码,测试用例只有三行——这种事情在微服务项目里尤其常见。不是开发者不想写测试,而是SpringCloud环境下的测试确实比单体应用复杂得多:服务之间通过Feign/Dubbo调用、配置在Nacos远端、消息通过RocketMQ/Kafka传递、数据库还分库分表。随便写个Service都依赖五六个外部组件,怎么测? 先说结论:微服务测试的核心思路是分层隔离。不同层级关注不同的验证目标,用不同的策略来隔离外部依赖。每一层有明确的边界和颗粒度,而不是不管三七二十一全部启动Spring容器。 flowchart TD subgraph Top[🔺 测试金字塔:越往上越慢、越贵、越少] subgraph L5[⏱️ 端到端测试] E2E[🌐 E2E测试\n全链路验证\n数量:极少] end subgraph L4[🔗 契约/集成测试] CONTRACT[📋 契约测试\nFeign/Dubbo接口契约\n数量:少量] INTEG[🔧 Service集成测试\nSpring容器+真实DB/Redis\n数量:适中] end subgraph L3[🧩 切片测试] WEB[🌐 Web层测试\n@WebMvcTest\n仅Controller上下文] DATA[🗄️ 数据层测试\n@DataJpaTest\n仅JPA上下文] end subgraph L2[⚡ 单元测试] UNIT[📐 纯单元测试\n无Spring容器\nMock所有依赖\n数量:大量] end end L5 --> L4 L4 --> L3 L3 --> L2 classDef layer fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold; class E2E,CONTRACT,INTEG,WEB,DATA,UNIT layer class L2 highlight class L2 data 这个金字塔翻译成SpringCloud语境下的操作指南,就是下面这张分层策略表: 测试层级 启动Spring容器? 真实依赖 Mock/Stub 单个耗时 覆盖目标 纯单元测试 否 无 所有外部依赖 毫秒级 业务逻辑分支 Web层切片 是(仅Controller) 无 Service/Mapper 1 ~ 3秒 参数校验/序列化/异常处理 数据层切片 是(仅JPA) 内嵌数据库(H2) 无 1 ~ 3秒 SQL映射/查询方法 Service集成测试 是(完整) H2/内嵌Redis Feign/MQ/外部API 3 ~ 8秒 事务边界/缓存/业务编排 契约测试 是(Consumer端) 无 对Provider的Stub 2 ~ 5秒 Feign接口签名一致性 端到端测试 是(全部服务) 全部 无 分钟级 全链路连通性 ⚠️ 新手提示:这张表建议存下来当速查卡。每次写完代码准备写测试时,先对着表想清楚"这一层该启动什么、该Mock什么",比盲目写省一半时间。 ...

一月 26, 2023 · 9 分钟 · 1870 字 · yaomingye

TCC + Saga——补偿型分布式事务

TCC + Saga 📖 前置阅读:本文假设读者已理解 Seata AT 模式的原理和局限。如果还不熟悉,建议先阅读 Seata AT 模式——undo_log 与二阶段原理。 一、⚡ AT 能回滚库存——但能回滚一条"已发出的短信"吗? AT 模式的局限——上一篇说了: AT 的自动回滚依赖 undo_log——生成反向 SQL INSERT → DELETE(undo_log 记录自增 ID——反向就是 DELETE) UPDATE → UPDATE(undo_log 记录前置镜像——反向就是把值改回去) 但以下操作——数据库回滚不了: ① 发了优惠券——HTTP POST 到营销系统的 API——数据库回滚不了 HTTP 调用 ② 发了短信——调了阿里云短信 API——阿里云不会因为你的"反向 SQL"就收回短信 ③ 调了第三方支付——Payment API 已经扣了钱——不能"生成反向 HTTP"退钱 ④ 给 Redis 写了一个计数器——Redis 没有 undo_log——AT 管不了 TCC 和 Saga 就是为这而生的——手动补偿——操作本身和撤回操作都由你写代码实现。 二、🔄 TCC——Try / Confirm / Cancel——你自己管理回滚 2.1 TCC 的本质——每个操作配一个"撤销操作" TCC 把每个业务操作拆成三个方法: Try(尝试) —— 预留资源——但不真正执行 Confirm(确认) —— 真正执行——Try 预留的资源生效 Cancel(取消) —— 释放 Try 预留的资源——回滚 和 AT 的区别: AT:你写一套代码——Seata 自动生成"撤销操作"(反向 SQL) TCC:你写三套代码——Try(正向)、Confirm(确认)、Cancel(撤销) → 写了三套代码——能处理任何类型的操作——不再局限于数据库 2.2 示例——“创建订单 + 发优惠券 + 扣积分”——用 TCC // ===== 场景:下单时——创建订单 + 发优惠券 + 扣积分 ===== // 订单是 DB 操作——但发优惠券是 HTTP API——扣积分也是 HTTP API // AT 回滚不了 HTTP API——用 TCC // ===== 订单服务——TCC 接口 ===== public interface OrderTccAction { /** * Try:预创建订单——状态为 PENDING——库存还没扣——订单还不能支付 * @param businessContext 在 TM 端传入的参数——和 @BusinessActionContextParameter 对应 */ @TwoPhaseBusinessAction( name = "order-create", // TCC 资源名 commitMethod = "confirmCreateOrder", // Confirm 方法 rollbackMethod = "cancelCreateOrder" // Cancel 方法 ) boolean tryCreateOrder( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "items") List<OrderItemDto> items, @BusinessActionContextParameter(paramName = "totalAmount") BigDecimal totalAmount ); /** * Confirm:把订单从 PENDING 变为 CREATED——正式生效 */ boolean confirmCreateOrder(BusinessActionContext context); /** * Cancel:把 PENDING 的订单变为 CANCELLED——释放预占 */ boolean cancelCreateOrder(BusinessActionContext context); } // ===== 订单服务——TCC 实现 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private OrderMapper orderMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { // ① 预创建订单——状态为 PENDING——不是正式订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING); // ← PENDING——不是正式订单——不可支付 order.setCreatedAt(LocalDateTime.now()); orderMapper.insert(order); // ② 把 orderId 存入 BusinessActionContext——Confirm/Cancel 会用到 // Seata 自动把方法返回值之外的参数存入 Context // 这里通过 RootContext 手动放 RootContext.bind("orderId_" + RootContext.getXID(), order.getId()); return true; // Try 成功——等待 TC 通知 Confirm 或 Cancel } @Override @Transactional public boolean confirmCreateOrder(BusinessActionContext context) { // ① 从 Context 中取出 orderId Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); // ② 把订单状态从 PENDING → CREATED——正式生效 Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.PENDING) { // 幂等——如果已经 Confirm 过了——不再处理 return true; } order.setStatus(OrderStatus.CREATED); orderMapper.updateById(order); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); Order order = orderMapper.selectById(orderId); if (order == null) { // 空回滚——Try 还没执行——Cancel 先到了——不做处理 return true; } if (order.getStatus() == OrderStatus.CANCELLED) { // 幂等——已经取消过了 return true; } order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } // ===== 优惠券服务——TCC 接口(HTTP API——AT 回滚不了)===== public interface CouponTccAction { @TwoPhaseBusinessAction( name = "coupon-grant", commitMethod = "confirmGrantCoupon", rollbackMethod = "cancelGrantCoupon" ) boolean tryGrantCoupon( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "couponType") String couponType ); boolean confirmGrantCoupon(BusinessActionContext context); boolean cancelGrantCoupon(BusinessActionContext context); } @Service public class CouponTccActionImpl implements CouponTccAction { @Autowired private CouponService couponService; // 这个 Service 调外部营销 API @Override public boolean tryGrantCoupon(Long userId, String couponType) { // Try:预占优惠券——调营销 API——标记为用户——但未激活 Coupon coupon = couponService.reserveCoupon(userId, couponType); // 外部 API 返回了 couponId RootContext.bind("couponId_" + RootContext.getXID(), coupon.getId()); return true; } @Override public boolean confirmGrantCoupon(BusinessActionContext context) { // Confirm:激活优惠券——用户可用 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); couponService.activateCoupon(couponId); // HTTP PUT /coupons/{id}/activate return true; } @Override public boolean cancelGrantCoupon(BusinessActionContext context) { // Cancel:回收优惠券——把预留的优惠券放回库存 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); if (couponId == null) { return true; // 空回滚——Try 还没执行完 } couponService.recycleCoupon(couponId); // HTTP DELETE /coupons/{id} return true; } } // ===== TM——全局事务发起方——调各个 TCC 接口 ===== @Service public class OrderApplicationService { @Autowired private OrderTccAction orderTccAction; @Autowired private CouponTccAction couponTccAction; @Autowired private PointTccAction pointTccAction; @GlobalTransactional public Order createOrderWithCoupon(CreateOrderRequest request) { // ① Try:预创建订单 boolean orderTry = orderTccAction.tryCreateOrder( request.getUserId(), request.getItems(), request.getTotalAmount()); if (!orderTry) throw new BusinessException("预创建订单失败"); // ② Try:预发优惠券——不是数据库操作——是 HTTP 调外部 API boolean couponTry = couponTccAction.tryGrantCoupon( request.getUserId(), "FIRST_ORDER"); if (!couponTry) throw new BusinessException("预发优惠券失败"); // ③ Try:预扣积分——也是 HTTP 调外部 API boolean pointTry = pointTccAction.tryDeductPoints( request.getUserId(), 100); if (!pointTry) throw new BusinessException("预扣积分失败"); // ④ 所有 Try 成功——TM 通知 TC 进 Confirm // TC 依次调每个 RM 的 confirmXxx() // → orderTccAction.confirmCreateOrder() ——订单 PENDING→CREATED // → couponTccAction.confirmGrantCoupon() ——优惠券激活 // → pointTccAction.confirmDeductPoints() ——积分确认扣除 return ...; // 返回订单信息 } // 如果任何一个 Try 抛异常——TC 依次调每个 RM 的 cancelXxx() // → orderTccAction.cancelCreateOrder() ——订单 PENDING→CANCELLED // → couponTccAction.cancelGrantCoupon() ——优惠券回收 // → pointTccAction.cancelDeductPoints() ——积分退回 } 2.3 TCC 的两个致命陷阱——空回滚与悬挂 陷阱一:空回滚——Try 没执行——Cancel 先到了 时间线: ① TM 调 Order TCC 的 Try——网络超时——TM 不知道 Try 成功了没有 ② TM 决定回滚——发起 Cancel ③ Cancel 到达 order-service——但此时 Try 还没收到(网络延迟)——或者 Try 正在执行 ④ Cancel 执行时——订单不存在(Try 还没创建)——Cancel 失败 这叫"空回滚"——Cancel 先于 Try 到达 解决——控制记录表: 在 Cancel 中——如果查不到订单——不能报错——记录一条"Cancel 已执行"的空记录 当 Try 终于到达时——先查"Cancel 是否已执行"——如果是——Try 不再执行 陷阱二:悬挂——Try 超时后——Cancel 执行了——Try 又到了 时间线: ① TM 调 Try——Try 执行中——卡住了(GC 停顿——网络延迟) ② TM 等 10 秒超时——发起 Cancel ③ Cancel 到达——顺利执行——订单状态改为 CANCELLED ④ 第 30 秒——Try 终于执行完了——订单 INSERT 进去了——状态是 PENDING ⑤ 结果:Cancel 已经执行了——但 Try 把数据又写进去了——这个 Try"悬挂"了 这叫"悬挂"——Try 在 Cancel 之后到达——Cancel 的撤销效果被 Try 覆盖了 解决——同样用控制记录表: Cancel 执行时——记录一条"xid=xxx 已 Cancel" Try 执行前——先查"xid=xxx 是否已 Cancel"——如果是——拒绝执行 -- ===== TCC 防悬挂 + 空回滚控制表——每个参与 TCC 的服务都建一张 ===== CREATE TABLE tcc_operation_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, xid VARCHAR(128) NOT NULL COMMENT '全局事务 ID', branch_id BIGINT NOT NULL COMMENT '分支事务 ID', action_name VARCHAR(64) NOT NULL COMMENT 'TCC 资源名——order-create/coupon-grant', status TINYINT NOT NULL COMMENT '1-Try 2-Confirm 3-Cancel', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_xid_branch_action (xid, branch_id, action_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; // ===== 改进后的 TCC 实现——带防悬挂 + 空回滚 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private TccOperationRecordMapper recordMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { String xid = RootContext.getXID(); Long branchId = RootContext.getBranchId(); // ① 防悬挂——检查 Cancel 是否已执行 TccOperationRecord cancelRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); // status=3 = Cancel if (cancelRecord != null) { // Cancel 先到了——Try 不能再执行——这就是"悬挂"——拒绝 return false; } // ② 记录 Try TccOperationRecord tryRecord = new TccOperationRecord(); tryRecord.setXid(xid); tryRecord.setBranchId(branchId); tryRecord.setActionName("order-create"); tryRecord.setStatus(1); // Try recordMapper.insert(tryRecord); // ③ 执行业务逻辑 Order order = new Order(); // ... 创建订单——状态 PENDING orderMapper.insert(order); RootContext.bind("orderId_" + xid, order.getId()); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { String xid = context.getXid(); Long branchId = context.getBranchId(); // ① 幂等——检查 Cancel 是否已执行 TccOperationRecord existingRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); if (existingRecord != null) { return true; // Cancel 已经执行过了——幂等——直接返回 } // ② 记录 Cancel——在查订单之前——防止空回滚 TccOperationRecord cancelRecord = new TccOperationRecord(); cancelRecord.setXid(xid); cancelRecord.setBranchId(branchId); cancelRecord.setActionName("order-create"); cancelRecord.setStatus(3); // Cancel recordMapper.insert(cancelRecord); // ③ 空回滚处理——查不到订单——不能报错 Long orderId = (Long) context.getActionContext().get("orderId_" + xid); if (orderId == null) { return true; // Try 没执行——空回滚——正常 } Order order = orderMapper.selectById(orderId); if (order == null) { return true; // Try 没执行完——空回滚——正常 } if (order.getStatus() == OrderStatus.CANCELLED) { return true; // 幂等 } // ④ 执行业务撤销 order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } ⚠️ 新手提示:空回滚和悬挂是 TCC 的两个经典坑——90% 的 TCC 实现都有这两个问题。解决方案就是一张操作记录表——在 Cancel 执行前先记一笔"Cancel 已执行"——在 Try 执行前先查"Cancel 是否已执行"。记录表的唯一键 (xid, branch_id, action_name) 天然防并发——并发的 Try 和 Cancel 只有一个能插入成功。 ...

十二月 29, 2022 · 8 分钟 · 1671 字 · yaomingye

Seata AT 模式——undo_log 与二阶段原理

Seata AT 模式 📖 前置阅读:本文假设读者已理解分布式事务的核心问题(多数据库操作一致性)和 BASE 最终一致性概念。如果还不熟悉,建议先阅读 分布式事务本质——CAP、BASE 与四大方案。 一、⚡ Seata AT 一句话——你写你的 SQL——它自动生成反向 SQL 回想 XA 2PC 的问题——锁住数据库行等协调者——性能黑洞。Seata AT 是怎么解决的? XA 2PC 的做法(性能黑洞): ① Prepare:执行 SQL——不提交——锁住行 ② 等协调者——这期间这些行都是锁着的——其他事务不能动 ③ Commit/Rollback:提交或回滚——释放锁 Seata AT 的做法(攒反向 SQL——事后再补): ① 一阶段:执行 SQL——立即提交——释放锁——同时记录 undo_log(反向 SQL) ② 如果全局事务成功:删掉 undo_log——完事 ③ 如果全局事务失败:根据 undo_log 执行反向 SQL——把数据改回去 核心区别:XA 是锁住行等结果——Seata 是先把活干了——记下 undo_log——失败了逆向执行。 二、🏗️ Seata 架构——TC / TM / RM 三角 flowchart LR TM["TM(Transaction Manager)\n全局事务管理者\n-- 标注 @GlobalTransactional"] RM1["RM(Resource Manager)\norder-service\n-- 操作 order 数据库"] RM2["RM(Resource Manager)\nproduct-service\n-- 操作 product 数据库"] RM3["RM(Resource Manager)\naccount-service\n-- 操作 account 数据库"] TC["TC(Transaction Coordinator)\nSeata Server\n-- 协调全局事务——管理全局锁"] TM -->|"① 开启全局事务"| TC TM -->|"② 调用 order-service"| RM1 RM1 -->|"③ 一阶段:执行业务 SQL + 记录 undo_log + 向 TC 注册分支事务"| TC TM -->|"④ 调用 product-service"| RM2 RM2 -->|"⑤ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑥ 调用 account-service"| RM3 RM3 -->|"⑦ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑧ 全局事务成功 → 通知 TC 提交"| TC TC -->|"⑨ 二阶段:通知所有 RM 删除 undo_log"| RM1 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM2 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM3 classDef style_TM fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa; classDef style_TC fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca; class TM style_TM; class TC style_TC;``` | 角色 | 全称 | 作用 | 在哪里 | |------|------|------|------| | TC | Transaction Coordinator | 协调全局事务——管理全局锁——决定提交还是回滚 | Seata Server——独立部署 | | TM | Transaction Manager | 定义全局事务边界——标 `@GlobalTransactional` 的方法 | 发起方服务(order-service) | | RM | Resource Manager | 管理分支事务——执行 undo_log 记录——向 TC 注册 | 每个参与方服务(product/account) | ## 三、🔍 undo_log 的核心原理——Seata AT 的灵魂 ### 3.1 undo_log 表结构 ```sql -- 每个参与分布式事务的数据库都需要一张 undo_log 表 -- Seata 提供了建表 SQL——直接执行即可 CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL COMMENT '分支事务 ID', xid VARCHAR(100) NOT NULL COMMENT '全局事务 ID', context VARCHAR(128) NOT NULL COMMENT '上下文', rollback_info LONGBLOB NOT NULL COMMENT '回滚信息——记录前置镜像和后置镜像', log_status INT(11) NOT NULL COMMENT '状态:0-正常 1-全局事务已完成', log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 3.2 undo_log 的工作原理——前置镜像 + 后置镜像 以"扣库存"为例——product 服务执行:UPDATE product SET stock = stock - 5 WHERE id = 1 一阶段——执行 SQL + 记录 undo_log: ① Seata 拦截 SQL——先查一下当前数据: SELECT stock FROM product WHERE id = 1 → stock = 10 ② 执行你的业务 SQL: UPDATE product SET stock = stock - 5 WHERE id = 1 → stock = 5 (后置镜像) ③ 立即提交——不锁行——释放数据库锁 ④ 记录 undo_log: 前置镜像:stock = 10 (SQL 执行前的值) 后置镜像:stock = 5 (SQL 执行后的值) 反向 SQL:UPDATE product SET stock = 10 WHERE id = 1 ⑤ 向 TC 注册:我的分支事务完成了——xid=xxx——undo_log 已记录 二阶段——提交: 全局事务成功 → TC 通知所有 RM 提交 → 删掉 undo_log 记录 → 完事 二阶段——回滚: 全局事务失败 → TC 通知所有 RM 回滚 → 读 undo_log 中的反向 SQL → 执行: UPDATE product SET stock = 10 WHERE id = 1 然后把数据改回去了 → 删掉 undo_log 记录 关键——为什么 AT 比 XA 快: ...

十二月 28, 2022 · 8 分钟 · 1567 字 · yaomingye

SkyWalking 中间件集成与链路分析实战

SkyWalking 中间件集成 📖 前置阅读:本文假设读者已搭建 SkyWalking 并了解 Trace/Span/Segment 概念。如果还不熟悉,建议先阅读 SkyWalking 分布式链路追踪——从零搭建 APM 平台。 一、⚡ 全链路通了——但只有 HTTP 调用——Dubbo 和 gRPC 看不到 上一篇搭好了 SkyWalking——/api/orders 的调用链能看到了——HTTP → Feign → MySQL 都有。 但我们的系统不止 HTTP: 真实调用链路: Browser → Gateway → order-service ├─ Feign → user-service (HTTP) ✅ SkyWalking 自动追踪 ├─ Dubbo → account-service (RPC) ❌ 看不到——Dubbo Span 没出来 ├─ gRPC → inventory-service (RPC) ❌ 看不到——gRPC Span 没出来 ├─ Sentinel → 限流熔断 ❌ 看不到——被限流的请求没有标记 ├─ RocketMQ → payment-service (异步) ❌ 看不到——MQ 跨进程 Trace 断了 └─ @Async → sendEmail (异步) ❌ 看不到——异步线程 Trace 丢了 Agent 不是万能的——不同中间件需要不同配置——有些还需要手动埋点。 ...

十二月 20, 2022 · 15 分钟 · 3027 字 · yaomingye

SkyWalking 分布式链路追踪——从零搭建 APM 平台

SkyWalking 分布式链路追踪 📖 前置阅读:本文假设读者已了解微服务基本概念和 Docker。如果已搭建 Prometheus + Grafana,理解本文会更快——两者互补。建议先阅读 Prometheus + Grafana 环境搭建与指标采集。 一、⚡ QPS 正常——但用户说"下单很慢"——是哪个服务慢了? 前两篇搭好了 Prometheus + Grafana——指标面板很漂亮——QPS、RT、错误率一目了然。 但凌晨 3 点的告警是这样的: PagerDuty:order-service P99 延迟从 50ms 涨到 3s——错误率 2%——还没触发告警阈值(5%) 你打开 Grafana: ✅ order-service:QPS 正常——RT 涨了但不知道原因 ✅ user-service:所有指标正常——没问题 ✅ product-service:所有指标正常——没问题 ✅ inventory-service:所有指标正常——没问题 ✅ payment-service:所有指标正常——没问题 → 你盯着仪表盘——所有服务看起来都"还行"——但订单就是慢了 有了 SkyWalking 链路追踪: ① 找到那条慢了 3s 的 /api/orders 请求的完整调用链路 ② 看到调用链:order-service → user-service(50ms) → product-service(45ms) → inventory-service(2800ms!!!) ← 找到了 ③ 展开 inventory-service 的 Span——MySQL SELECT 语句执行了 2.5s ④ 点开 SQL——SELECT * FROM inventory WHERE product_id = ?——没有索引——全表扫描 → 2 分钟定位——加索引——P99 回到 50ms Prometheus 告诉你"出问题了"——SkyWalking 告诉你"为什么出问题"。两者不是替代关系——是互补关系: ...

十二月 19, 2022 · 11 分钟 · 2142 字 · yaomingye
Cat Radio