ARTICLE DETAIL

资讯详情

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

SpringCloud分布式事务避坑:Seata AT模式解决订单与库存扣减难题

SpringCloud分布式事务避坑:Seata AT模式解决订单与库存扣减难题 先看一个业务场景用户在App下单订单服务创建订单库存服务扣减商品数量账户服务扣除用户余额。三个服务各自连接独立数据库代码各自正常但某次调用库存服务超时订单创建成功库存却没扣或者库存扣了两次用户钱被多扣一笔。这种问题在单体应用里几乎不会出现——一个数据库、一个本地事务全部搞定。但拆成SpringCloud微服务之后“本地事务”管不到别人家的数据库了。“钱不能算错库存不能扣重复”这句话看着像段子实际上就是我在这类项目里被用户投诉、被对账系统报警之后总结出来的血泪教训。这篇文章就是围绕SpringCloud分布式事务这个主题把订单与库存这类资金链路场景下的方案选型、落地步骤、踩坑排查写透。适合正在做微服务改造、或者刚接手订单库存类项目的同学尤其适合那种“分布式事务只停留在理论层面还没真正在代码里跑过”的朋友。1. 问题根源单库事务失效钱和库存为什么会对不上1.1 一次扣库存为什么需要分布式事务先看一个最典型的调用链订单服务调用库存服务扣减库存再调用账户服务扣减余额。三个服务独立部署各自连各自的数据库。在单体时代这件事简单粗暴Transactional包住三个表的更新任何一个异常全局回滚一气呵成。但微服务拆开后订单库、库存库、账户库是三个物理数据库Transactional只对当前服务自己的数据库有效。订单服务里的事务提交了但库存服务那边如果抛了异常订单服务已经无法回滚了。这就是分布式事务要解决的问题让分布在不同数据库、不同服务之间的多个操作达成最终一致。注意用词是“最终一致”不是所有方案都能做到强一致。对资金类场景强一致是目标最终一致是底线命中底线之后还要靠对账和补偿来兜底。我见过不少团队在这个阶段的选择是先不做分布式事务用“重试人工补偿”。短期订单量小的时候能撑住但一旦遇到促销、秒杀超时和重复请求同时涌进来库存能给你扣成负数余额能给你扣成欠款。1.2 “钱不能算错库存不能扣重复”背后的两个本质要求把这句话拆开看其实是两个截然不同的问题“钱不能算错”本质是原子性一致性。扣余额和扣库存必须绑定成同一个结果要么都成功要么都失败。不能出现订单显示已支付账户余额却没少更不能出现账户余额扣了订单却显示未支付。这类问题靠的是事务的原子提交、回滚机制和必要的行锁、乐观锁来控制。“库存不能扣重复”本质是幂等性防重控制。同一个订单、同一笔扣减请求无论网络超时重试多少次、消息队列重复投递多少次最终库存表里只能扣一次。若没有幂等设计一次请求超时后客户端自动重试库存就活生生被扣两次。很多团队只盯着第一个问题引入分布式事务框架把跨服务提交解决了却忽略了第二个问题。结果事务框架本身跑得好好的却被重复请求把库存打穿亏的还是自己。所以在落地分布式事务之前先自上而下同步一个共识框架解决“跨服务原子性”业务代码解决“幂等性”这俩缺一不可。2. 方案选型五种常见分布式事务方案的取舍2.1 两阶段提交2PC/XA的利与弊两阶段提交是最经典的分布式事务理论模型。参与者先全部执行预提交准备阶段事务协调者确认所有参与者都准备好了再统一发出提交指令。这种方案在理论上能实现强一致但代价非常明显准备阶段要锁资源协调者宕机后所有参与者全部阻塞等待性能会随参与方数量增加急剧恶化。在SpringCloud这种高并发、服务数量多、数据库多样MySQL、PgSQL、Redis混合的环境里XA方案几乎不具备落地条件。我自己的实际感受是2PC更适合那种“单笔操作极其重要、并发量极低”的金融级内部系统普通互联网业务订单系统很难承受它带来的性能损耗和运维复杂度。2.2 TCC与可靠消息最终一致性TCCTry-Confirm-Cancel是把事务的提交和回滚拆成三个业务方法Try阶段做资源预留Confirm阶段做真正的业务提交Cancel阶段做补偿回滚。TCC的优势是性能好、灵活性高但缺点也很明显——每个参与方都要实现Try、Confirm、Cancel三套逻辑业务侵入性极强。举个例子扣库存的Try要冻结库存Confirm才真正扣减Cancel要解冻库存。写起来非常繁琐而且Try阶段冻结的库存如果一直没有后续操作还要考虑超时释放复杂度直线上升。可靠消息最终一致性则是另一个路线业务操作和消息发送放在同一个本地事务里消息发送成功后由消息消费者执行后续操作再通过消息确认和定时补偿机制保证最终一致。这个方案性能最好但对业务场景有严格要求——它只适合“上游操作成功后下游最终会成功”的场景。如果下游操作需要做到“要么都成功要么都失败”比如余额扣减必须和库存扣减强绑定那可靠消息自身还需要额外补一个“反向操作”来对冲非常别扭。2.3 为什么Seata AT模式适合订单与库存场景Seata的AT模式本质上是把2PC的“资源锁定”从数据库层面搬到框架层面。业务方只需要写正常的业务SQL框架在SQL执行前后自动记录数据快照生成undo_log然后在全局事务提交或回滚时根据快照自动补偿。这意味着什么对业务代码的侵入程度极低原来怎么写的增删改加了GlobalTransactional之后还是那么写。对“订单与库存分布式事务”这个场景来说这正是最理想的团队不需要重新设计冻结/确认/补偿三套流程只靠一个注解就能把订单创建、库存扣减、余额扣减包进同一个全局事务。AT模式不是没代价它的代价在锁和性能上全局事务执行期间涉及的数据行会被框架加全局锁并发操作同一行时后到的事务会等待或超时。但对于大多数订单场景热点商品的库存扣减并发本来就高需要通过库存预热、缓存扣减等方式缓解而不是依赖分布式事务去扛超高并发。我最终给大多数业务系统的建议是如果要求的是“数据最终不出错、业务代码尽量少改动、场景是中等并发订单与库存”Seata AT是性价比最高的入门选择。3. Seata AT模式落地从依赖到注解的完整接入3.1 环境准备与依赖引入先说明这里以SpringCloud配合Seata 1.6.x及以上版本为例Nacos作为注册中心和配置中心MySQL作为业务数据库。下面这些操作我都在项目里实测过。Seata本身包含三个核心角色TCTransaction Coordinator事务协调器独立部署的服务负责维护全局事务状态驱动事务提交和回滚。TMTransaction Manager事务管理器嵌入在业务发起方代码里负责开启全局事务、提交或回滚全局事务。RMResource Manager资源管理器嵌入在业务参与方代码里负责管理分支事务的资源锁、上报分支事务状态。业务方要做的就是启动一个TC服务然后在每个参与方服务里集成TM/RM依赖。Maven依赖长这样dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2021.0.5.0/version /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.7.0/version /dependency版本号这里要多说一句SpringCloud Alibaba和Seata的版本匹配非常容易踩坑不同版本之间的API兼容性差异很大。我建议直接以SpringCloud Alibaba对应版本内置的Seata版本为准不要自己单独指定高版本Seata否则可能出现Cannot invoke io.seata.spring.boot.autoconfigure.properties.SeataProperties...这类启动报错。3.2 配置TC、undo_log与全局事务注解TC服务需要一份配置。我用的方式是下载Seata Server包修改conf/application.yml把注册中心和配置中心都指向Nacosserver: port: 7091 seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 application: seata-server group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata?useUnicodetruecharacterEncodingutf8 user: root password: 你的密码TC的全局事务会话需要持久化否则TC宕机后事务状态丢失所以生产环境必须把store模式配置成db并初始化Seata的几张表global_table、branch_table、lock_table。开发环境用file模式能偷懒但千万别带到生产。业务服务这边需要改application.ymlspring: cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: application-id: order-service tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:7091关键点tx-service-group要在业务服务和TC之间保持一致映射关系。很多启动报错“no available service”或“could not find service leader”八成就是vgroup-mapping配错导致客户端拿不到TC地址。业务数据库里还要建一张undo_log表这是AT模式回滚的核心依赖CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, branch_id BIGINT NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET utf8mb4;然后只需要在事务发起方的入口方法上加一个注解GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { orderMapper.insert(request.toOrder()); stockService.deductStock(request.getSkuId(), request.getQuantity()); accountService.deductBalance(request.getUserId(), request.getAmount()); }deductStock和deductBalance都是普通Feign调用或DubboReference调用被调用方不需要再额外加GlobalTransactional只需要正常执行自己的本地事务即可。Seata通过全局事务IDxid把多个分支事务串成一个全局事务这个xid会通过调用链自动透传。3.3 业务代码的防错细节余额防超扣、库存防重复加了注解只是第一步真正让“钱不能算错库存不能扣重复”落地的是业务代码里的细节处理。先看余额扣减。最容易出错的写法是先查余额、再判断够不够、然后再扣BigDecimal balance accountMapper.selectBalance(userId); if (balance.compareTo(amount) 0) { throw new BizException(余额不足); } accountMapper.deductBalance(userId, amount);这段代码在并发下是废的。两个请求同时查到余额100元都判断“够扣”然后都执行扣减余额直接变负数。正确做法是把余额判断放进SQL里用数据库行锁保证原子性int rows accountMapper.deductBalanceWithCheck(userId, amount); if (rows 0) { throw new BizException(余额不足); }对应的SQLUPDATE account SET balance balance - #{amount}, updated_at NOW() WHERE user_id #{userId} AND balance #{amount}balance #{amount}这个条件放在WHERE里让数据库引擎自己判断扣完会不会变负rows 0说明余额不够直接抛异常触发全局回滚。这一套下来哪怕并发再高也不会出现负数余额。另外我习惯在账户表加一个version字段做乐观锁防止ABA类的覆盖更新对账起来也方便。再看库存扣减。防重复扣减的核心思路是给每笔扣减一个唯一业务单号在扣减前先查这个单号是否已经扣过。比如订单号orderNoUPDATE inventory SET stock stock - #{quantity}, updated_at NOW() WHERE sku_id #{skuId} AND stock #{quantity}扣减SQL本身要带库存充足判断。但光有这一步还拦不住重复扣减因为同一个订单号重试两次这条SQL都会执行成功。所以要引入幂等控制INSERT INTO stock_deduct_log(order_no, sku_id, quantity, status) VALUES (#{orderNo}, #{skuId}, #{quantity}, 0)给order_no加唯一索引重复插入直接报主键冲突应用程序捕获这个冲突后直接返回“已经扣减过”不再执行后面的库存更新。这一招在订单服务里特别实用因为Feign调用超时后框架会自动重试没有幂等控制的时候每次重试都是一次真实的扣库存。除了唯一索引还有一种常见做法是状态机。订单表加一个status字段扣库存前先执行UPDATE order_info SET status 1 WHERE order_no #{orderNo} AND status 0受影响行数为0说明订单已经处理过了直接跳过。这种方式在“确认收货”“发货”这类状态流转场景中比唯一索引更自然但对账逻辑相对复杂。我的经验是下单扣库存场景用唯一索引最直接状态流转场景用状态机最顺手两者可以结合使用。4. 常见问题与排查技巧实录4.1 全局锁冲突与脏写AT模式最大的坑之一就是全局锁。Seata在执行分支事务SQL前会先获取全局锁防止其他事务对同一行数据做修改。高并发场景下多个全局事务同时操作同一行库存后到的事务会一直等待一旦超过timeout配置直接报“Global lock wait timeout”异常。我遇到过一个真实案例秒杀期间同一款SKU的并发扣减请求打到库存服务Seata全局锁把几乎所有请求都阻塞了最后大量请求超时失败用户疯狂投诉。排查思路分两步。第一步看是不是真的有跨服务长事务锁住了热点数据——如果是要从架构上避免用分布式事务处理单点热点比如库存预热到Redis做预扣再用异步消息同步到数据库第二步调整锁等待时间参数client.rm.lock.retryInterval和client.rm.lock.retryTimes给短期冲突多一些重试机会但不要调得过大否则请求堆积更严重。全局锁冲突还会引发另一种脏写问题事务A在回滚前释放了全局锁事务B立刻修改了同一行数据事务A回滚时用undo_log恢复数据覆盖了事务B的修改。Seata靠“先查全局锁、再执行回滚”的机制来避免但极端情况下仍可能出现。所以在AT模式下要严格控制全局事务的存活时长尽量别在全局事务里做远程调用等待、RPC超时重试、用户输入确认这类耗时操作。4.2 回滚失败与undo_log失效Seata AT模式依赖undo_log回滚undo_log一旦出问题回滚就会失败。最有代表性的场景是分支事务完成SQL执行并写入undo_log但全局事务回滚时undo_log里的beforeImage和afterImage与实际数据对不上。原因通常是业务代码在同一个方法里手动执行了额外SQL、或者数据库里存在触发器自动修改数据、又或者另一个非Seata管理的事务插进来改了同一行。这就导致框架无法根据快照还原数据只能记录下来靠人工处理。我的建议是三条缺一不可全局事务参与方的业务方法里不要混用Transactional和GlobalTransactional除非你非常清楚两者嵌套的语义否则容易出现“本地事务先提交了全局事务却回滚不掉”的局面。不要对Seata管理的数据表加自定义触发器、存储过程里改数据尽量让所有数据变更都走同一个框架链路。定期监控undo_log表的log_status如果长期存在大量非0的记录说明脏写或回滚失败频繁要立刻排查业务链路。4.3 幂等与重复扣减的实战处理前面讲了唯一索引和状态机两种幂等方案这里补充一个更隐蔽的场景Feign调用库存服务扣减成功后响应在网络上丢失订单服务触发重试库存服务又被调用了一次。这时候库存服务必须能识别“这是同一笔扣减”而不是简单地再扣一次。我的落地方案是库存服务提供的扣减接口强制校验orderNo参数扣减前先查stock_deduct_log表如果有记录就直接返回上次结果或者返回“已处理”标记没有记录才执行扣减。同时用数据库唯一索引兜底防住极端并发下重复插入的情况。这里有一个小细节值得注意查询和插入之间的并发窗口。两个重试请求同时到达都查到“没有记录”然后同时尝试插入。唯一索引能让其中一个失败失败的那个就当作“别人已经扣过了”处理即可。所以千万不要删掉唯一索引这是兜底的命根子。同理账户扣减那边我在account_transaction_log表里也建了(user_id, order_no)唯一索引记录每次资金变动这样既能幂等防重又能给后续对账留原始凭证。5. 最后分享一点实际体会做完这套方案之后我有几个很深的感触。第一个感触是分布式事务框架选型只是整个项目里最简单的一步真正花时间的永远是“梳理清楚每条数据链路的语义”。什么叫“库存不能扣重复”不是加个注解就完了而是一层层把Feign重试、消息重复投递、并发交叉这些边角都堵住。第二个感触是高并发热点场景别硬扛分布式事务。我后来把秒杀库存的扣减从同步事务改成了“Redis预扣异步落库定时对账”同步链路只保留普通订单的分布式事务系统稳定性立刻上了一个台阶。这个思路值得你后续在项目里重点验证——分布式事务不是银弹但把它用在对的地方它能帮你挡住最致命的资金错误和数据错乱。
返回列表