做秒杀这种高并发场景最怕的其实不是超卖而是“数据对不上账”。超卖有RedisLua就能挡住真正让人头疼的是库存扣了、订单也建了结果优惠券核销失败、积分没到账用户看到的是订单列表里一串“已取消”但库存却已经被扣走了。单体应用时代一个Transactional就能解决的事拆了微服务以后根本没人帮你管。我这次在某个电商秒杀项目里试了用Hmily框架标题里写的himily是同一个拼写变体来兜底分布式事务从接入到压测到故障演练踩了一堆坑这篇就把整个过程和实际问题整理出来给后面要搞秒杀的兄弟当个参考。1. 秒杀场景下的分布式事务到底难在哪1.1 一次秒杀背后牵扯多少个数据源先别急着谈框架我们把一次秒杀下单的真实链路拆开看。用户点击“立即抢购”前端请求打到秒杀网关网关做限流和风控然后把订单请求转发到订单服务。订单服务要做的第一件事是调用库存服务扣减库存库存扣成功之后创建订单记录接着还要通知营销服务核销一张优惠券再调用户服务给账号加积分。运气好点的项目还会在最后发一条短信或者推送消息。这一串操作里库存库、订单库、营销库、用户库是四个独立的数据库订单服务、库存服务、营销服务、用户服务是四个独立的进程。拆库拆服务之前所有写操作在同一个数据库实例里BEGIN TRANSACTION到COMMIT之间发生任何异常一个ROLLBACK全回滚数据天然一致。拆开之后订单服务调用库存服务的接口库存服务那边的事务已经提交了订单服务自己随后写库失败——这时候库存少了订单却没了两边对不上账。这种情况不是偶发而是高并发秒杀场景里的常态。瞬时流量一大任何一个下游服务的RT稍微波动整条链路上就充满了“这边成功那边失败”的半截事务。更麻烦的是秒杀场景里业务方还要求库存扣减和订单创建必须强一致库存扣了订单必须能在订单没了库存必须还回来。否则用户抢到了东西却看到订单被取消这种客诉处理起来比技术问题还痛苦。1.2 本地事务为什么扛不住有人可能会想我能不能在订单服务里用本地事务包住对库存服务的远程调用比如在Transactional方法里先调库存服务扣库存再写订单表如果写订单失败就抛异常让事务回滚。问题在于本地事务的ROLLBACK只能回滚当前数据库连接里执行过的SQL它管不到另一个进程、另一个数据库里已经提交的数据。库存服务接到扣减请求后如果用的是独立的事务那它的扣减操作在返回结果之前就已经COMMIT了订单服务这边回滚得再干净也动不了库存服务库里那行已经减掉的库存记录。更隐蔽的是网络层面的不确定性。订单服务调库存服务请求超时了库存服务那边到底扣没扣如果订单服务判定失败直接回滚库存服务那边实际扣了就是“幽灵扣减”。如果订单服务判定成功继续写订单库存服务那边实际没扣用户却能下单后面发货时发现库存对不上。这个经典的“超时双雄”问题靠本地事务和重试根本无解因为重试可能导致重复扣减不重试又可能丢失操作。1.3 常见分布式事务方案怎么取舍在引入Hmily之前我们团队把市面上常见的分布式事务方案都过了一遍各自特点整理成一张表方案一致性强度性能表现业务侵入性适用场景2PC/XA强一致差全局锁长事务低数据库原生支持银行转账等低频强一致TCC最终强一致好无全局锁资源只冻结高业务要拆三段高并发核心链路可靠消息最终一致性最终一致好异步解耦低引入MQ非核心链路如积分、短信本地消息表最终一致一般依赖定时扫表中每服务要建表中小团队简单场景对我们秒杀项目来说核心矛盾是库存和订单不能等最终一致用户抢完单立刻就要看到结果同时秒杀场景的QPS又很高不能接受2PC那种长时间锁资源。所以核心链路只能选TCC让库存服务在Try阶段先“冻结库存”而不是直接扣减等到订单确认后再真正扣减如果中间任何一步失败就执行Cancel把冻结的库存释放掉。而优惠券、积分这类非核心操作我们后来单独抽出去用RabbitMQ做最终一致性不占TCC的资源。这个分割很关键。如果你天真地把积分、短信、优惠券全塞进一个TCC事务里链路会变得非常长任何一个边缘服务抖动都会导致整个下单失败秒杀体验直接报废。强一致的链路越短越好这是我这次整合Hmily过程中最先确定下来的设计原则。2. Hmily框架到底是个什么东西2.1 Hmily的定位与核心模式Hmily是一个开源的分布式事务框架核心实现的是TCC模式也支持TAC和可靠消息模式。标题里写的himily其实是同一个框架名的常见笔误因为它官方拼写是Hmily读起来和“himily”差不多很多人顺手就写错了。它通过注解来声明事务参与者对业务代码的侵入主要集中在你需要把一个业务操作拆成Try、Confirm、Cancel三个阶段然后交给框架统一协调。TCC三个阶段的职责要搞清楚Try阶段完成业务检查和资源预留。比如库存服务不是直接扣减库存而是把“可用库存”减掉同时增加“冻结库存”相当于给这次下单预留了货。Confirm阶段提交确认。Try阶段全部成功后框架协调各参与方执行Confirm这里把冻结库存真正扣掉订单状态从“待确认”改成“已创建”。Cancel阶段回滚释放。任何一个参与方Try失败或者Confirm阶段出错框架执行Cancel把冻结库存释放回可用库存订单标记为取消。Hmily做的最有价值的一件事是把协调逻辑和异常恢复机制做成了现成的框架能力。如果让你自己写TCC你需要自己设计事务协调器、自己维护每个参与方的事务状态、自己写重试机制工作量不亚于再做一个中间件。Hmily把这些都封装好了每个参与方本地有一张事务日志表记录每笔事务的处理状态框架有调度线程定期扫描并重试那些没有完成的事务保证最终走到Confirm或Cancel。2.2 一次TCC调用的完整生命周期拿我们项目里的“创建秒杀订单”来走一遍Hmily的执行流程。主调方是订单聚合服务参与方有库存服务和营销服务。聚合服务入口方法标记了HmilyTCC这个注解就相当于声明“从这里开始后面的RPC调用要纳入一个分布式事务”。第一次RPC调用发生时Hmily会在调用方本地生成一个全局事务IDTransId通过RPC框架的隐式传参把事务上下文透传给参与方。参与方收到带有事务上下文的请求后执行标了HmilyTCC的Try方法在自己本地写入事务日志表记录当前事务状态是TRYING同时执行业务上的资源预扣操作。Try返回成功事务日志状态更新为CONFIRMING框架继续调下一个参与方。当所有参与方都Try成功聚合服务的主业务方法正常返回框架开始异步或同步地驱动各参与方的Confirm方法。参与方收到Confirm请求后根据本地事务日志判断自己是不是处于CONFIRMING状态是就执行业务确认然后把状态改成CONFIRMED。如果中间任何一个Try抛异常或者主业务方法在调用到一半时挂了框架会反向调用已经成功Try过的参与方的Cancel方法把预留的资源释放掉。整个过程的状态流转都落在本地事务日志表里即使服务重启调度线程也能捞起没有终态的事务继续处理。2.3 为什么在秒杀项目里选Hmily而不是其他框架当时我们团队还有个备选项是Seata的AT模式。AT模式的优点是业务侵入低你不需要把业务拆成三段框架通过解析SQL自动生成UNDO_LOG来回滚。但AT模式在秒杀场景有个致命问题它在全局事务提交前要持有全局锁所有操作同一行库存数据的并发事务会被阻塞。秒杀场景恰恰是海量请求同时打在同一行库存记录上AT模式的全局锁会成为吞吐瓶颈压测时事务等待率会直线上升。Hmily的TCC则没有全局锁它是在业务层面通过“冻结/释放”这种状态机来保证一致性资源没有被数据库锁住高并发下表现好得多。另一个选它的原因是部署足够轻事务状态记录在业务库自带的hmily_transaction_log表里不需要额外部署协调者集群这也是小团队比较看重的毕竟多一个中间件就多一堆运维的事。当然选了TCC就要接受它的代价业务代码必须拆成三套方法。这个改造量是实打实的尤其是老项目改过来特别痛苦。但如果项目是从零开始设计一开始就按TCC的思路去写业务接口这个成本反而没那么吓人。3. 集成前的准备与环境改造3.1 依赖引入与版本坑Hmily接入的第一步是加依赖。我们用的是Spring Boot 2.x技术栈引入对应的starter即可dependency groupIdorg.dromara/groupId artifactIdhmily-spring-boot-starter/artifactId version2.1.2/version /dependency这里要提醒一句Hmily的版本和Spring Boot版本有对应关系1.x的老版本不要用接口和注解的包路径都不一样。如果你用的是Spring Cloud Alibaba还要把RPC适配包加进来比如hmily-springcloud不然事务上下文没法在Feign调用中透传。我们一开始只加了starter结果Feign接口调用的时候事务上下文丢失查了半天才发现少了RPC适配依赖。这是新手接入最容易踩的坑。3.2 配置文件与事务日志库每个参与方服务的application.yml里都要配置Hmily的数据源信息因为它的运行机制是每个参与方各自维护本地事务日志日志库可以独立建一个也可以挂在业务库里。我们的做法是每个服务建一个独立的hmily库避免业务表变更影响日志表。核心配置如下hmily: server: configMode: local appName: seckill-stock repository: database: driverClassName: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/hmily?useUnicodetruecharacterEncodingutf8 username: root password: root maxTotal: 50 maxWaitMillis: 2000 recovery: maxRetry: 15 recoverDelayTime: 60配置项里的appName是服务唯一标识会写进事务日志表用于区分不同参与方。recovery下面的recoverDelayTime是恢复调度的延迟时间单位秒意思是事务卡住多久之后调度线程开始介入重试maxRetry是最大重试次数超过这个次数的事务会被标记为异常状态需要人工介入。这两个参数后续在故障演练里会反复用到建议上线初期不要调太大方便快速暴露问题。建表直接用官方提供的hmily.sql脚本核心表是hmily_transaction_log字段包括trans_id全局事务ID、app_name、trans_type、status、retry_count、create_time等。这张表是Hmily做恢复的根基千万不能手动删数据尤其是还在处理中的事务删了就意味着框架再也找不到它们业务会永久处于半完成状态。3.3 业务代码怎么拆出Try、Confirm、Cancel这是接入Hmily工作量最大的一步。以库存服务为例原来是“扣减库存”一个操作现在要拆成三个方法Try方法检查可用库存是否充足是的话把可用库存减掉冻结库存加上。这个阶段不做实际发货或者最终扣减只是“占住货”。Confirm方法把冻结库存真正扣减掉写入扣减流水让数据落地。Cancel方法把Try阶段扣掉的可用库存加回来冻结库存减掉恢复原状。三个方法必须都是接口方法不能是私有方法或内部调用。Hmily通过AOP代理参与方接口来拦截调用如果你在同一个类里this.tryMethod()这样内部调用代理不会生效事务日志写不进去一致性就崩了。我们团队当时有个同事图省事把Confirm和Cancel写成了private方法结果回滚的时候框架根本找不到方法下单链路倒是正常一撤销就报错。拆方法的过程也是在梳理业务边界的绝佳机会。你会发现很多业务逻辑其实不适合用TCC描述比如那种“发短信”的操作Try阶段根本没法预留什么资源强行拆反而别扭。这种操作就该扔出事务用最终一致性。所以拆方法之前先在纸上把每个参与方的资源模型画清楚再动手改代码。4. 秒杀下单链路整合Hmily实操4.1 订单服务的TCC实现示例订单服务在链路里是核心主调方但自己也要参与事务。我们的做法是订单聚合服务作为Hmily的事务发起方订单表操作拆成三段。Try阶段插入一条状态为“待确认”的订单记录同时把下单的原始请求快照保存下来Confirm阶段把订单状态改成“已确认”Cancel阶段把订单状态改成“已取消”并通过快照把预占的资源信息传给其他参与方做回滚。核心接口代码长这样public interface OrderService { HmilyTCC(confirmMethod confirmCreateOrder, cancelMethod cancelCreateOrder) boolean createOrder(SeckillOrderDTO orderDTO); boolean confirmCreateOrder(SeckillOrderDTO orderDTO); boolean cancelCreateOrder(SeckillOrderDTO orderDTO); }HmilyTCC注解里只声明了Confirm和Cancel的方法名Try就是当前注解方法本身。实现类里Try、Confirm、Cancel三套逻辑各自独立分别写SQL。这里有个细节三个方法的入参最好完全一致这样Hmily在调Confirm/Cancel时可以直接复用录制的方法参数不需要额外做数据转换。我们后来在Cancel里需要用到Try阶段的请求快照也是通过这个DTO传入的所以DTO里的字段一定要设计全别到了回滚的时候发现缺少关键信息。4.2 库存服务的TCC实现与Redis取舍库存服务是秒杀场景里压力最大的参与方。原生的做法是直接用SQL扣减库存在TCC改造里我们引入了冻结库存字段用一条UPDATE语句做TryUPDATE seckill_stock SET available_qty available_qty - #{quantity}, frozen_qty frozen_qty #{quantity} WHERE product_id #{productId} AND available_qty #{quantity}这条SQL天然是原子的条件里带上available_qty quantity在数据库层就挡住了超扣。Confirm时是UPDATE seckill_stock SET frozen_qty frozen_qty - #{quantity} WHERE product_id #{productId}Cancel则是逆操作UPDATE seckill_stock SET available_qty available_qty #{quantity}, frozen_qty frozen_qty - #{quantity} WHERE product_id #{productId}整个过程中业务库里始终保证available_qty frozen_qty等于初始库存总量这是对账的基础。关于Redis这是个很大的话题。秒杀场景经常会先把库存预热到Redis用Lua脚本做预扣但Hmily的事务日志和状态机是建立在数据库上的如果库存数据只在Redis里改数据库和Redis就会不一致。我们的最终方案是Redis依然用来做流量控制也就是在请求入口用Lua脚本判断“还有没有货”有货才放行放行后真正的TCC操作全部以数据库为准Redis后续通过异步任务或者定期重建缓存来同步。这样既保留了Redis抗瞬时流量的能力又保证了一致性数据的可靠来源是数据库两条线各司其职。4.3 优惠券核销的TCC处理营销服务负责优惠券核销。在原有的“未使用/已使用”两态模型上我们加了一个“锁定中”状态。Try阶段把优惠券从“未使用”改成“锁定中”Confirm阶段把“锁定中”改成“已使用”Cancel阶段把“锁定中”改回“未使用”。这里要注意的是状态机的设计状态字段一定要允许从“锁定中”分别走到“已使用”和“未使用”而不是简单加一个布尔字段。我们在设计阶段踩过一次坑最初的表结构只有used标记Try阶段把它置为1Cancel阶段再置回0。但这样一来Confirm和Cancel的操作就变成“设置某个值”完全没法和业务流水对应排查问题的时候根本看不出这张券到底经历了什么。后来改成显式的状态列每次状态变更都写流水对账和排障就舒服多了。4.4 主调链路编排与事务上下文传递聚合服务入口是这样编排的public SeckillResult doSeckill(SeckillOrderDTO orderDTO) { orderService.createOrder(orderDTO); inventoryService.tryDeductStock(orderDTO); marketingService.lockCoupon(orderDTO); return SeckillResult.success(); }这里的编排顺序是模拟真实业务先建订单占位再扣库存占货最后锁券。任意一步抛出异常Hmily会推动前面已经Try成功的方法执行Cancel。顺序本身也体现了业务优先级订单占位在最前面保证用户肯定有一笔订单记录可以查库存是核心资源放中间防止订单堆积。事务上下文传递这块要重点检查。Hmily在RPC调用时通过隐式参数传递事务上下文Feign或者Dubbo的拦截器会自动把TransId带上。如果你的聚合服务是自己包装的HTTP调用没有走Hmily的适配拦截器那参与方就感知不到事务Try方法会退化成普通方法调用。排查的方法很简单在参与方接口加个日志输出HmilyTransactionContextLocal.getInstance().get()看是否有值。没有值一定是传递链路断了。4.5 Confirm/ Cancel 幂等控制必须做扎实TCC的Confirm和Cancel方法天然会被重试因为Hmily的恢复调度线程在事务卡住时会重复调用。如果你的Confirm不是幂等的重复执行会导致库存被扣两次、订单被重复确认。我们的做法是给每张核心业务表都加事务状态字段配合事务日志表的status做双校验参与方收到Confirm请求后先查本地事务日志只有状态是CONFIRMING时才继续执行业务SQL执行完把状态改成CONFIRMED。这样即使框架重复推送第二次进来发现状态已经不是CONFIRMING直接返回成功不再执行业务。这个幂等控制看起来是个小细节实际上是大坑。我们在测试阶段没有做严格的幂等压测时模拟了一次网络抖动恢复线程重试之后库存流水表里出现了两行扣减记录虽然最终库存是平的但流水对不上对账系统的告警直接就爆了。后来补上状态机控制这个问题才算根治。5. 压测表现与链路优化5.1 TCC在秒杀场景下的性能开销很多团队不敢用TCC是怕它慢。我们自己在模拟项目X里压测了整合前后的链路数据下单请求的P99从整合前的180ms涨到220ms左右涨了大概40ms主要开销在两次额外的数据库写操作上——事务日志表的插入和状态更新。这个涨幅对秒杀场景完全可以接受因为秒杀链路真正的瓶颈从来不在业务SQL而在前置流量控制和下游服务的稳定性。Hmily的另一个优势是它不会锁数据库记录压测时并发线程全部操作同一行库存数据靠的是UPDATE语句自身的行锁串行化而不是全局锁。同样的压测条件如果换成AT模式的框架全局锁会把大量事务堵在等待队列里吞吐会掉得很明显。所以TCC本质上是用业务代码的复杂度换并发性能这个交换在秒杀项目里是划算的。5.2 限流削峰再放进TCC但这里必须说句实话Hmily再快也不能让TCC直接暴露在秒杀的最高峰值下。秒杀的吞吐特点是瞬间爆量假如瞬时来了10万请求你让10万个TCC事务同时打到MySQL业务库和事务日志库的连接池会瞬间被打满事务日志写入排队恢复调度线程也会抢锁。这不是框架的问题是任何分布式事务方案都扛不住这种毛刺流量。我们的做法是把秒杀的流量模型调成两层。第一层是网关上用Redis做计数器限流比如每秒钟最多放行2000个下单请求第二层是这2000个请求不是直接同步执行TCC而是丢进RabbitMQ队列聚合服务作为消费者按固定速率消费比如每秒处理500单。这样真正进入TCC链路的流量是平稳的数据库压力变得可预测。用户看到的“排队中”状态由另外一套异步进度查询接口支撑。这套削峰策略比在TCC内部做任何优化都更有效。5.3 数据一致性对账脚本再好的框架也不能保证100%不出问题所以一致性对账是必须做的。我们的对账任务每天跑两次逻辑不复杂但覆盖了所有参与方把订单表里状态为“已取消”的订单和库存流水表里的扣减记录做关联检查是否有“订单取消但冻结库存没释放”的记录把优惠券状态为“锁定中”的记录捞出来凡是超过2小时还没变更是“已使用”或“已取消”的拉出来人工复核。更直接的一个对账手段是查Hmily的事务日志表写一个定时SQLSELECT * FROM hmily_transaction_log WHERE status NOT IN (CONFIRMED, CANCELLED) AND retry_count 10这类记录就是已经重试了多轮还没走完的异常事务靠Hmily自身可能已经拉不起来了需要开发或者运维介入手工补偿。在对账脚本之前我们每周都要靠人工翻日志有了这个SQL以后异常事务的发现时间从“用户投诉”提前到了“定时任务跑完”节奏舒服多了。6. 常见问题排查与避坑指南6.1 回滚不生效的自查清单遇到“下单失败但库存没恢复”的问题先按下面的顺序排查排查点说明确认方法是否是接口方法HmilyTCC只对Spring代理的接口调用生效私有方法内部调用不生效检查事务日志表参与方有没有生成TRYING状态记录没有说明上下文没传到位看RPC适配包Feign/Dubbo需要引入对应的Hmily适配依赖否则TransId传不过去检查Cancel是否幂等回滚方法被重试后如果报错Hmily会一直重试直到超过maxRetry确认status状态流转状态机写错会导致Confirm/Cancel被跳过日志里会出现不明所以的“直接返回成功”我们实际遇到最多的情况就是前两种尤其是内部调用导致代理失效。AOP代理失效这个问题在Spring项目里很经典Hmily把代理做在了接口上从来不建议你在这个类内部调用自己的Try方法一定要通过注入的Service对象去调。6.2 空回滚与悬挂问题空回滚是TCC里很经典的一个问题。场景是这样的主调方调用库存服务Try方法因为网络超时主调方认为Try失败了触发Cancel但那个慢的网络请求其实已经到了库存服务并且Try已经在执行中或者说执行完了。此时Cancel到达库存服务如果它没判断“到底有没有预留资源”就会去释放一笔根本不存在的冻结库存导致可用库存变成负数。解决思路是在Cancel方法里先查自己本地事务日志如果发现自己根本不存在TRYING记录直接返回成功不做回滚操作。这就是“空回滚保护”。反向的“悬挂问题”则是Try请求延迟到达但Cancel已经被执行完了这时候Try再来就要拒绝执行不能让它再预留资源。实现的秘诀还是复用事务状态字段在Try执行前也做一次状态判断如果是CANCELLED状态就拒绝。这两个问题并不是只在Hmily里存在而是TCC模式的通病。Hmily在事务日志层面做了部分保护但它不可能知道你的业务语义所以最终这层判断必须在自己的代码里实现。我们当时写了一个简单的状态枚举和对应的判断逻辑在每个参与方方法入口处统一处理省得每个业务都自己写一遍。6.3 事务日志表膨胀问题Hmily的恢复机制依赖历史事务日志所以日志表只增不删。时间一长hmily_transaction_log表会膨胀得很厉害拖慢调度线程的扫描速度还会让事务日志的插入变慢。我们上线两个月后这张表就积累了上千万行数据调度线程每次扫描都在全表里游走QPS直接受影响。解决思路是分层归档已经处于CONFIRMED或CANCELLED终态、且创建时间超过3天的记录每天凌晨由定时任务搬到一张归档表hmily_transaction_log_archive业务库保留近3天的热数据。调度线程只扫热表恢复速度立刻恢复正常。还有一个建议是给这张表定期做表重建因为频繁的插入和更新会产生大量碎片重建之后IO下降明显。6.4 故障演练比写代码更重要接入一个新的框架最大的风险不是代码写不出来而是线上出了问题没人敢处理。我们做了一次故障演练在压测过程中人为地让订单服务的数据库连接池耗尽制造参与方Try阶段的随机超时情况然后观察Hmily的恢复调度线程能不能把事务拉回正确状态。第一次演练结果很不理想我们发现recoverDelayTime配置成半天导致事务卡了大半天才被捞起来用户早就投诉了。后来把恢复延迟改成60秒睡前告警事务卡超过10分钟就发通知给值班人员问题发现速度才达到要求。演练还暴露了一个数据库连接池参数问题Hmily的恢复调度线程和正常业务请求共用同一个数据源如果连接池上限设得太小调度线程在高峰期根本抢不到连接事务只能一直挂着。我们把恢复线程池单独配置保证它即使在高负载下也能拿到连接去做补偿操作。这类问题光靠写代码时动脑是发现不了的必须压测和演练才能暴露。建议任何要上Hmily的项目在上线前都做一次类似的破坏性演练。我个人做完这个项目最大的体会是Hmily接入本身并不复杂真正的复杂度在于你想清楚哪些操作必须进TCC、哪些操作该丢给MQ做最终一致性。强一致的链路越短越好边缘操作全部异步化这个决策比框架选型重要得多。再一个就是故障演练一定不能省测试环境里人为制造数据库连接超时、服务重启、RPC慢调用看Hmily能不能自己把事务走完这个验证过程会让你上线后安心很多。最后分享一个小技巧所有参与方的Confirm和Cancel方法入口都打一条带全局事务ID的日志线上排查问题时沿着事务ID把各个服务的日志串起来比看任何监控面板都直观。