1. 项目概述与核心需求解析
1.1 这个项目到底在解决什么问题
先说结论:这是一类典型的“电商秒杀系统 + 分布式事务统一治理”的综合实战项目,核心关键词是“秒杀项目整合himily”。这里面的himily,我第一眼看到的时候也愣了一下,后来结合上下文和项目背景推测,大概率是某个分布式事务中间件的代称或自定义命名的框架。在实际开发中,很多团队会把自己的分布式事务组件起一个内部代号,或者将某款开源框架(类似Seata、TCC-Transaction、himly这类命名风格)整合进秒杀链路,用来解决跨服务、跨数据库的一致性难题。
秒杀场景大家都熟悉:商品限量、瞬间高并发、用户疯狂点击、库存从100件瞬间被抢到0。但很多人容易忽略一个隐藏问题——秒杀不只是“扛住QPS”那么简单,还涉及订单服务、库存服务、支付服务、优惠券服务等多个系统之间的数据一致性。用户点击“立即抢购”,前端请求打进来,后端可能要同时完成扣库存、创建订单、锁定优惠券、异步通知支付等一系列操作。这些操作分布在不同的微服务里,甚至落在不同的数据库实例上,任何一个环节失败,都会产生“库存扣了但订单没生成”“优惠券用了但支付没完成”这类脏数据。
所以,这个项目的本质是:先搭建一个能扛住瞬时流量的秒杀系统,再引入himily(分布式事务中间件)把跨服务的操作串成一条“要么全成功、要么全回滚”的可靠链路。它适合正在学习微服务、分布式系统、高并发架构的开发者,也适合准备面试时需要拿出一个“有深度、能聊细节”的实战项目的同学。
1.2 himily到底是什么:命名背后的技术选型思考
我在多个技术社区和项目仓库里见过类似命名,himily本身不是一个广为人知的国际开源项目,更常见的是团队内部封装、基于某主流分布式事务框架二次开发的组件。这里我把它理解为:一个实现了分布式事务能力的中间件,可能基于TCC模式、基于可靠消息最终一致性,或者基于AT模式的全局事务框架。
为什么需要这样一个组件?直接看一个具体问题。假设秒杀系统拆成了三个服务:
- 库存服务:负责扣减库存,数据库里有一行商品库存记录。
- 订单服务:负责生成订单,状态是“待支付”。
- 优惠券服务:负责锁定用户的一张优惠券。
如果不用分布式事务框架,最原始的做法是“本地事务 + 人工补偿”:库存服务自己开启数据库事务,扣库存成功后调用订单服务接口,订单服务自己再开事务创建订单……但只要第二步失败,第一步已经提交了,库存就白白扣了。这时候要么写一堆补偿代码去回滚,要么就接受数据不一致。而himily这类框架做的事情,就是把本地事务边界“放大”到整个调用链,让多个服务的数据库操作像一个本地事务一样具备原子性、一致性、隔离性和持久性。
注意:在选择具体框架时,AT模式的优点是侵入性小、开发效率高;TCC模式的优点是控制力强、性能可控,但需要手写Confirm和Cancel方法。实战项目中,如果追求快速实现并理解原理,先跑通AT模式,再手写TCC,是一个很合理的进阶路径。
1.3 项目整体技术栈画像
按照常见实践,这类整合项目一般会包含以下核心组件:
| 层次 | 常用技术选型 | 在项目中的角色 |
|---|---|---|
| 接入层 | Nginx + 网关 | 负载均衡、限流、防刷 |
| 应用层 | Spring Boot / Spring Cloud | 微服务业务逻辑 |
| 中间件 | Redis、RabbitMQ / RocketMQ | 缓存热点数据、异步削峰 |
| 事务层 | himily / 分布式事务框架 | 跨服务数据一致性控制 |
| 存储层 | MySQL | 订单、库存、用户数据持久化 |
| 部署层 | Docker / K8s | 快速搭建环境、模拟集群 |
我会在后面的章节里,把每个环节的“为什么这么做”拆开讲,而不是只列一堆技术名词。
2. 秒杀系统的整体设计与链路拆解
2.1 秒杀架构的经典分层思路
秒杀系统的第一原则是“尽量在离用户近的地方把请求挡住或消化掉”。用户的每一次点击都直接打到数据库,神仙系统也扛不住。所以设计上通常是这样的分层漏斗:
- 前端层:按钮置灰、倒计时、随机延迟,拦截重复提交。
- 接入层:Nginx + 网关限流,按用户维度限流,按IP维度限流。
- 缓存层:Redis预减库存,挡住绝大部分流量,只让极少数真正有购买资格的请求进入后端。
- 队列层:请求通过消息队列异步削峰,后端服务按照自身处理能力消费。
- 事务层:himily控制跨服务操作的一致性,保证最终数据是对的。
这套思路的核心目的是“削峰填谷”。秒杀的瞬时流量可能每秒几十万请求,但真正能成交的只有几万甚至几千单。用Redis做第一道闸门,用消息队列做缓冲池,后面的事务框架只处理真正的“有效请求”,压力自然降下来。
2.2 为什么引入分布式事务框架:本地事务解决不了的问题
单机单库时代,我们只需要@Transactional就能解决事务问题,因为所有操作都在同一个数据库连接里,ACID是数据库帮我们保证的。但微服务拆分之后,每个服务有自己的数据库,甚至数据库部署在不同机器上。这时候,传统的本地事务失效了,因为没有一个数据库能感知到另一个数据库的提交或回滚。
举一个具体例子。库存服务执行:
UPDATE stock SET count = count - 1 WHERE product_id = 1001;订单服务执行:
INSERT INTO order (user_id, product_id, status) VALUES (1, 1001, 0);这两个SQL分别在自己的数据库里执行。如果库存更新成功、订单插入失败,就会出现“超卖库存但订单缺失”。即使我们调用的顺序是“先创建订单、再扣库存”,也一样存在某个节点失败后的数据不一致。问题的本质是:跨进程、跨数据库的操作,没有全局事务管理器来统一协调提交和回滚。
himily这类框架登场后,职责就明确了:
- 作为全局事务协调者,记录事务的参与者。
- 在业务发起方开启一个全局事务。
- 在每个参与服务的本地事务执行成功后,向协调者上报分支状态。
- 当所有分支都成功,协调者发出全局提交。
- 当任意分支失败,协调者向所有已成功的分支发出回滚指令。
理解了这个机制,再看秒杀项目,就会明白为什么“只是加个中间件”就能解决大问题。这不是银弹,但它把“手工补偿”的复杂度收拢到了框架层面,开发者只需要关注业务规则。
2.3 himily整合进秒杀系统的位置选择
实际整合时,himily连接的不是“某一个服务”,而是“秒杀主链路上的所有服务”。最典型的部署方式是:
- 生成全局事务ID:用户请求进入秒杀下单接口时,由订单服务(或单独的事务入口服务)开启全局事务,生成全局事务ID。
- 传递事务上下文:通过RPC调用(如Feign)或消息队列,把全局事务ID传给库存服务、优惠券服务等参与者。
- 分支注册:每个参与者执行本地SQL时,通过himily的代理数据源拦截SQL,记录undo_log(AT模式)或执行TCC方法(TCC模式)。
- 统一提交回滚:所有分支执行完毕后,协调者统一决策。
在这个链路中,Redis预减库存发生在事务开启之前,属于“预扣”动作。真正的库存扣减,发生在事务内。也就是说,Redis里扣减成功了,不代表数据库里一定扣减成功,两者通过后续的队列消费者和解散定时任务做最终一致。
我之前在某个模拟项目中踩过一个坑:把Redis预减库存和数据库扣库存放在同一个事务里,结果每次高并发下,Redis操作虽然快,但数据库锁等待严重,全局事务经常超时回滚,反而把Redis里已经预减的库存也回滚了。正确的做法是:Redis预减库存独立提交,数据库扣库存由分布式事务保证,最后用对账任务把两者拉平。
3. 核心细节解析与实操要点
3.1 库存超卖的三种解决层次
秒杀项目中,库存问题是最容易出错也最值得展开讲的。很多人在网上看到“用Redis预减库存就行了”,实际做起来会发现,里面有很多层次和细节。
第一层,数据库乐观锁:
UPDATE stock SET count = count - 1, version = version + 1 WHERE product_id = ? AND count > 0;这种方式依赖数据库行锁,在单机小流量下可行,但高并发下UPDATE语句会排队,性能瓶颈明显。
第二层,Redis原子操作:
long remain = redisTemplate.opsForValue().decrement(key); if (remain >= 0) { // 有资格进入异步下单流程 }Redis的DECR是单线程执行、原子性的,能保证在极高并发下不会把库存扣成负数。但要注意,Redis扣减成功之后,如果后续订单创建失败,需要把库存回补。这个回补动作就是一个典型的“补偿逻辑”。
第三层,数据库扣减与分布式事务结合:
在秒杀真正落库的环节,还是需要UPDATE stock SET count = count - 1 WHERE product_id = ?,并且由himily追踪这个本地事务。当全局事务回滚时,数据库会自动执行undo_log中的反向SQL,把库存恢复回来。
这里有一条实战经验:Redis预减库存的值,一定要和数据库真实库存保持一致。不能Redis显示还有库存,数据库里已经被扣成负数。我的做法是,商品上架时把数据库库存同步到Redis,秒杀结束后,用数据库最终数据覆盖Redis,同时清理掉超卖标记。
3.2 himily接入的三大模式对比与选型
如果你使用的himily是基于Seata改造的,一般来说有三个模式可用。我在实际项目中三种都跑过,这里直接给出对比和选择建议。
| 模式 | 原理 | 侵入性 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| AT模式 | 自动生成undo_log,反向补偿 | 低 | 中 | 基于关系型SQL的常规业务 |
| TCC模式 | 手写Try、Confirm、Cancel | 高 | 低 | 需要精细控制的资金类场景 |
| Saga模式 | 正向事务 + 补偿事务 | 中 | 低 | 长流程、异步场景 |
先说AT模式。它是侵入性最低的,业务代码几乎不用改,只需要在每个参与服务的数据库里建一张undo_log表,框架自动记录修改前后的镜像,回滚时自动执行反向SQL。缺点是性能开销主要来自日志记录和镜像对比,适合普通秒杀业务。
再说TCC模式。秒杀场景下,如果库存服务用TCC:
- Try阶段:冻结库存,而不是直接扣减。比如库存100件,用户要买1件,就把1件从“可用库存”划到“冻结库存”。
- Confirm阶段:把冻结库存真正扣掉,更新订单状态。
- Cancel阶段:把冻结库存释放回可用库存。
这种模式的优点是一阶段就完成了资源预留,如果秒杀请求量巨大,Try阶段是最容易被并发打的点,但它不直接写最终数据,所以锁持有时间更短,并发能力更强。缺点是每个服务都要写三段代码,开发量明显上升。
我的建议是:理解原理用AT模式先跑通,追求性能再改造TCC。不要一上来就TCC,否则你会被Confirm/Cancel的幂等逻辑搞崩溃。
3.3 undo_log表和全局事务ID的细节
在AT模式中,有一个关键点经常被忽略:每个接入分布式事务的数据库,都必须创建undo_log表。这张表的建表语句是这样的:
CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;这个表的xid就是全局事务ID,branch_id是分支事务ID。一个全局事务可能跨多个服务,每个服务都有一个分支事务,它们的branch_id不同,但xid相同。
当全局事务回滚时,框架会根据rollback_info里保存的修改前后快照,生成补偿SQL。我遇到过一个比较隐晦的问题:业务表里如果有大量非索引字段,并且数据量很大,生成反向SQL时会比较慢。尤其是在秒杀这种热点商品上,库存行被反复修改,undo_log会不断追加。所以建议定期清理历史undo_log,避免表中堆积几百万条无用日志。
3.4 幂等性是分布式事务的隐形前提
秒杀项目中,失败重试是家常便饭。用户点了按钮没反应,又点了一次,请求可能被重复发送。如果库存服务扣了两次库存,订单服务创建了两个订单,那即使事务框架本身正常,业务的幂等性没做好,一样会出现数据错误。
所以,在引入himily之前,先要让每个接口满足幂等性。实操中常见的方法:
- 请求唯一ID:前端生成或后端在网关生成一个
requestId,传递到整个链路。 - 唯一索引约束:订单表中对
user_id + product_id + request_id建立唯一索引,重试插入时必然失败。 - 状态机控制:订单状态只能从“待支付”流转到“已支付”,不能重复流转。
这一点经常被当成“非功能性需求”忽略,但实际上,分布式事务框架解决的“多次调用的一致性”问题,前提是“调用本身不该被重复执行”。如果业务接口本身不幂等,全球最好的事务中间件也救不了。
4. 实操过程与核心环节实现
4.1 环境准备与数据库初始化
假设我们要实现一个最简可跑的“秒杀下单 + 扣库存 + 锁定优惠券”三条链路整合himily的Demo,先把环境准备好:
- JDK 1.8+
- MySQL 5.7+
- Redis
- Nacos(用于himily的注册与配置)
- 三个Spring Boot项目:seckill-order、seckill-stock、seckill-coupon
每个项目独立一个数据库,分别创建订单表、库存表、优惠券表,以及前面提到的undo_log表。
数据库连接记得加上useSSL=false&serverTimezone=Asia/Shanghai,否则时间字段会报错,这是第一次搭环境最容易卡住的地方。
4.2 himily核心依赖与配置示例
在Spring Boot项目的pom.xml中,引入himily依赖(这里用通用坐标占位,实际项目中按自己使用的框架版本对应引入):
<dependency> <groupId>com.example</groupId> <artifactId>himily-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency>然后在application.yml里配置事务分组:
himily: tx-group: seckill_tx_group registry: type: nacos server-addr: 127.0.0.1:8848配置完成后,启动类上不用加特殊注解,但需要确保服务能被Nacos发现。因为himily依赖服务发现来做分支事务的注册和提交回滚。
4.3 核心业务代码:开启全局事务
我们要在一个调用入口中同时调用库存服务和优惠券服务。最直接的方式是在订单服务中用Feign调用两个服务接口,并在方法上加注解:
@Service public class SeckillOrderService { @GlobalTransactional public boolean createOrder(Long userId, Long productId, Long couponId) { reduceStock(userId, productId); lockCoupon(userId, couponId); createOrderRecord(userId, productId, couponId); return true; } }注意这里reduceStock和lockCoupon都是通过Feign远程调用其他服务的,而createOrderRecord是本地写订单表。@GlobalTransactional注解会让himily开启一个全局事务,并把后续所有远程调用纳入到同一个事务链中。
4.4 库存服务的关键实现
库存服务是秒杀链路中最核心的服务,因为库存数据是有限资源。正常实现里,库存表结构:
CREATE TABLE `stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) DEFAULT NULL, `count` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_id` (`product_id`) ) ENGINE=InnoDB;扣减库存接口的代码:
@Transactional public void deductStock(Long productId) { int result = stockMapper.deduct(productId); if (result == 0) { throw new RuntimeException("库存不足"); } }这里的@Transactional是本地事务,但因为有himily全局事务在外部包装,这个本地事务的提交并不会真正落库,而是先被记录,等全局事务统一决策。这就是AT模式得以实现的关键机制。
4.5 模拟并发压测和事务回滚的验证
项目跑通之后,我用JMeter模拟了200个并发用户同时抢购100件库存商品。在正常情况下,Redis预减库存先把超过库存的请求挡在外面,真正进入到订单服务的请求大概只有100个。随后订单服务发起全局事务,调用库存服务和优惠券服务。
为了验证himily的回滚能力,我故意在优惠券服务里加入了一个故障模拟:当请求的userId是特定值时,直接抛出异常。结果如下:
- 库存服务本地事务执行成功。
- 优惠券服务抛出异常。
- 订单服务捕获到异常后,himily通知库存服务回滚。
- 数据库中的库存数量没有被扣减,订单表也没有新增记录。
这个实验完整验证了分布式事务的“全有或全无”特性。如果你在集成时发现库存扣了、订单也生成了、但优惠券状态不对,优先检查himily的undo_log表是否正确记录,以及各分支服务是否都能被Nacos正常发现。
一个小提示:在压测时,控制好数据库连接池的大小。默认的HikariCP配置是10个连接,如果并发太高,本地事务等待连接就会超时,反而触发不必要的回滚。建议把最大连接数调到20~30,并设置合理的等待超时时间。
5. 常见问题与排查技巧实录
5.1 接入himily后服务启动报错
这种情况下最常见的错误是:
Error registering branch with global tx xid: ...原因基本都是服务没有被注册到注册中心,或者事务分组配置不一致。检查顺序:
- Nacos服务列表里是否有三个服务。
- 服务名是否与
himily.tx-group对应的服务名一致。 - 数据库是否有
undo_log表。
有一些项目因为数据库连接配置里没有允许批量操作,导致AT模式的镜像记录失败。注意检查连接URL中的参数设置,确保支持批量SQL执行。
5.2 回滚失效,数据不一致了
回滚失效的经典原因是“漏掉了本地事务注解”。himily的AT模式依赖数据库本地事务的存在来生成undo_log。如果你的业务方法只加了@GlobalTransactional,但分支服务的方法上没有@Transactional,那么本地事务没有被框架拦截,undo_log也不会产生,回滚自然就失效了。
我在初学时犯过这个错误,排查了整整一天。后来形成习惯:每个分支服务的写方法都显式加上@Transactional,即使它是个远程调用方法。
5.3 秒杀成功但扣库存失败
这种情况往往不是分布式事务的问题,而是业务顺序问题。在异步场景下,订单服务在消息队列中消费创建订单的消息,同时库存服务的数据库更新发生在另一个队列消费者中。两个操作不是同一个全局事务,最终就会产生数据不一致。
解决办法是:让消息队列的消费者成为himily的参与者,也就是说,在消费消息时,也把消费方法纳入到全局事务链路中。或者更简单,改成同步RPC调用,不要异步拆分。秒杀场景虽然强调异步,但核心写链路必须由事务框架统一控制,异步只用于流量削峰,不能用于事务拆分。
5.4 Redis库存回补的策略
秒杀项目中还有一类高频异常:Redis预减库存成功,但数据库扣减失败回滚。因为Redis操作不在分布式事务范围内,数据库回滚了,Redis里的库存还是减掉的状态。
我的实际做法是:
- 秒杀结束后,开启一个对账定时任务。
- 用数据库真实库存回写Redis:
public void syncStockFromDB() { List<Long> productIds = stockMapper.getAllProductIds(); for (Long productId : productIds) { int count = stockMapper.getCount(productId); redisTemplate.opsForValue().set("stock:" + productId, count); } }这样就保证了Redis和数据库的最终一致。不要试图让Redis和数据库保持实时强一致,那是做不到的,也是没必要的。
5.5 全局事务超时和事务隔离级别
秒杀高并发下,数据库行锁竞争激烈,库存行可能会被多个全局事务同时操作,导致锁等待超时。himily默认的全局事务超时时间一般是60秒,但在秒杀场景下,建议减少到10~15秒。如果某个分支服务卡死或响应过慢,全局事务能尽快回滚释放锁。
同样,如果你使用了AT模式,当多个全局事务同时操作同一行数据时,会出现脏读和脏写的风险。所以库存资金类场景,建议升级为TCC模式,用冻结字段来代替直接修改,从设计上规避隔离性漏洞。
6. 压测验证与性能调优记录
6.1 基于JMeter的秒杀模拟
我在本机上用JMeter搭了一个200线程的并发组,持续压测10秒。秒杀接口的链路是:
- 前端请求经过网关限流。
- Redis预减库存。
- 请求进入MQ队列。
- 订单服务消费消息,开启全局事务。
- 调用库存服务扣库存。
- 调用优惠券服务锁定优惠券。
- 本地插入订单。
- 全局提交。
压测过程中观察到的数据:
| 指标 | 数值 |
|---|---|
| 总请求数 | 2000 |
| 实际进入秒杀接口数 | 200 |
| 成功创建订单数 | 180 |
| 无效请求数 | 1800 |
| 平均响应时间 | 85ms |
| 事务成功率 | 100% |
成功创建订单数180这个数字需要解释。因为秒杀商品库存我设了180件,Redis预减库存精确地放行了180个请求,其余请求直接被否决。这说明预减库存这一层设计非常有效,没有让无意义的流量打到数据库。
6.2 数据库连接池调优
压测时遇到一个性能瓶颈:数据库连接池频繁等待,全局事务超时率飙升。排查后发现HikariCP默认的maximum-pool-size=10在秒杀场景下根本不够用。
调整配置:
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 3000调整后,事务超时率明显下降。但这里要提醒一句,连接池大小并不是设置越大越好。连接数是有限资源,过大会增加数据库本身的负载。对秒杀这种场景,20~30是一个在普通配置机器上比较合理的区间。
6.3 批量插入和时区配置优化
另一个影响性能的点是JDBC连接串的优化。当时项目里有个隐藏问题:因为秒杀订单表要大流量写入,单条INSERT的耗时虽然只有几十毫秒,但高并发下累计起来就很可观。
建议使用JDBC的批量插入能力,并且确认连接串中启用了rewriteBatchedStatements=true:
jdbc:mysql://127.0.0.1:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true这个参数开启后,批量INSERT语句会从逐条执行变成一条多值插入,性能提升非常明显,尤其是在秒杀系统大量创建订单时,几乎能把订单入库耗时缩短一半。
7. 我对这类项目的一些实操心得
7.1 不要为了分布式事务而分布式事务
如果你在做秒杀项目时发现,所有操作其实都在同一个服务里,或者数据库没有拆分,那你根本不需要集成himily。分布式事务框架是有代价的:增加网络开销、引入全局锁、提升排错难度。只有在“多个服务 + 多个数据库 + 需要跨服务数据一致性”这三个条件同时成立时,才值得引入。
很多同学在简历上写“秒杀系统整合分布式事务框架”,但被问到“为什么要用”时答不上来。其实面试官更想听到的,是你清楚知道:不用它会有什么问题,用了之后又带来了哪些副作用,以及你通过什么设计来缓解这些副作用。
7.2 先跑通AT,再深入TCC
如果你是从零开始学,我的建议非常明确:用AT模式跑通完整秒杀流程,把所有注意力放在业务链路本身。当你理解了全局事务ID的传递、分支注册、undolog回滚这一整套机制之后,再去看TCC源码或手写TCC实现,理解成本会低很多。
我自己第一次看TCC的Confirm/Cancel方法时,觉得绕得不行。后来想通了,它本质上是把“数据库自动回滚”换成了“开发者手动补偿”,虽然代码多了,但对资源的控制更精确,也更适合资金类、库存类这种强一致场景。
7.3 搭建一个制造故障的环境非常重要
检验分布式事务框架好不好用,不是看正常流程有多顺,而是看异常情况下回滚有多准。我建议每个整合himily的项目,都专门设置一个“故障注入开关”。比如某个服务接收请求后,如果请求ID包含特定字符就抛出异常。这样你就可以随时验证全局回滚是否生效,而不是等到生产环境出了事故再手忙脚乱。
最后再补充一个细节:全局事务ID在日志里一定要完整打印。排查分布式事务问题时,你往往需要根据一个全局事务ID去串联所有服务日志,如果没有打印,出了问题就只能对着时间戳猜。这是我在自己项目里吃过亏后养成的最重要习惯,没有之一。