ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot高并发抢购系统:防超卖与分布式锁实战

SpringBoot高并发抢购系统:防超卖与分布式锁实战

1. 项目概述:为什么抢购场景是分布式系统里的“压力测试仪”

你有没有遇到过这样的情况:一个限时秒杀活动上线前,技术团队反复压测、自信满满,结果活动开始后五分钟,库存直接变成负数——后台日志里满屏的“库存扣减成功”,但实际发货时发现超卖了三千单?这不是段子,而是大量电商、票务、课程平台在真实业务中踩过的坑。这个标题里提到的“SpringBoot实现并发、超发和锁机制/抢购示例”,本质上不是教你怎么写个Demo,而是在模拟一个高并发下的“资源争抢战场”:同一时间成千上万人点击“立即抢购”,系统必须在毫秒级响应中完成库存校验、扣减、订单生成三个动作,且绝不允许出现超发(over-selling)。超发不是小问题,它直接关联资金损失、用户投诉、资损审计,甚至可能触发平台赔付条款。

我带过好几个类似场景的落地项目,从某在线教育平台的9.9元爆款课抢购,到某本地生活服务平台的限量优惠券发放,核心矛盾始终如一:数据库的ACID特性在单机事务中很稳,但一旦流量打穿单库能力、服务拆分成微服务、缓存与DB存在延迟,传统@Transactional就形同虚设。这时候,“锁”不再是代码里一个synchronized关键字,而是一套分层防御体系:前端限流是第一道闸门,应用层的乐观锁/悲观锁是第二道防线,Redis分布式锁是第三道屏障,而最终的库存一致性校验(比如落库前二次查Redis+DB双源比对)才是兜底的保险丝。标题里并列出现“超发、乐观锁、悲观锁、Redis”,恰恰说明这不是单点技巧的堆砌,而是需要你理解每种手段的适用边界、性能代价和失效场景。比如,用MySQL for update做悲观锁,在QPS 200以上就容易引发连接池耗尽;而纯靠Redis incr做库存扣减,又可能因网络分区导致“假成功”。这篇文章不讲理论空谈,我会带着你从零搭一个可实测的SpringBoot抢购服务,把每一种锁机制的真实表现、压测数据、日志痕迹、失败现场都摊开来看。适合正在做秒杀模块的后端同学,也适合想搞懂“分布式一致性到底难在哪”的中级开发者——毕竟,你写的不是Hello World,而是真金白银扛得住流量洪峰的系统。

2. 整体架构设计与方案选型逻辑:为什么不能只靠数据库事务

2.1 四层防御模型:从请求入口到数据落盘的全链路控制

很多初学者一上来就想“怎么加锁”,却忽略了抢购本质是流量治理问题。我们先画一张真实的链路图(文字版):用户点击 → Nginx限流 → 网关鉴权/防刷 → SpringBoot应用层 → Redis缓存校验 → MySQL库存扣减 → 订单服务异步创建 → 消息队列通知。在这个链条里,锁机制只覆盖“Redis校验”到“MySQL扣减”这一小段,但它的效果受前后环节制约极大。比如,网关没做IP级限流,恶意脚本瞬间打来5000QPS,你的Redis锁还没来得及生效,连接池就已经被打爆了。所以我们的方案设计是四层防御:

  • 第一层:网关与前置限流
    用Spring Cloud Gateway配置RequestRateLimiter,基于Redis计数器对用户ID或设备指纹限流(例如:单用户10秒内最多5次请求)。这能过滤掉80%的无效刷量,避免无效请求穿透到业务层。

  • 第二层:Redis预减库存(无锁快路径)
    所有库存操作先走Redis:DECR stock:1001。如果返回值≥0,说明还有库存,进入下单流程;如果返回负数,直接返回“已售罄”。这是最快最轻量的判断,95%的请求在这里就被拦截。注意:这里不加锁,因为Redis的DECR是原子操作,天然线程安全。

  • 第三层:数据库最终一致性保障(乐观锁 or 悲观锁)
    Redis预减成功后,必须同步更新MySQL库存,否则缓存与DB不一致。这里就是标题里“乐观锁/悲观锁”的主战场。我们不会只用一种,而是根据业务容忍度动态选择:对超卖零容忍的场景(如限量联名款),强制走悲观锁;对允许极低概率超卖的场景(如普通优惠券),用乐观锁+重试。

  • 第四层:异步对账与补偿
    订单创建后,启动定时任务扫描“已支付但库存扣减失败”的订单,触发人工审核或自动退款。这不是锁机制,但它是生产环境里必不可少的兜底。

提示:很多团队把所有希望押在“数据库加锁”上,结果压测时TPS卡在300就上不去了。记住,锁是成本最高的手段,应该放在最后一步,而不是第一步。

2.2 为什么放弃单一方案?三种锁机制的硬性对比

我们实测过三种主流方案在2000QPS下的表现(硬件:4核8G云服务器,MySQL 8.0,Redis 7.0,JDK 17):

方案平均RT(ms)成功率超发率连接池占用适用场景
MySQLSELECT ... FOR UPDATE(悲观锁)4299.98%0%高(峰值占满)超卖绝对不可接受,QPS<500
MySQLUPDATE ... WHERE version = ?(乐观锁)1899.2%0.03%低允许微量超卖,QPS<3000
RedisSETNX + EXPIRE(分布式锁)2699.95%0%中跨服务协调,需强一致性

关键发现:悲观锁虽然100%防超卖,但RT翻倍,且在高并发下极易引发死锁(比如两个事务交叉更新不同商品库存)。乐观锁RT最低,但版本号冲突会导致大量请求失败重试,重试逻辑若没写好,反而会放大数据库压力。Redis锁看似完美,但要注意SETNX和EXPIRE不是原子操作,必须用SET key value EX seconds NX一条命令完成,否则网络中断时可能产生永久锁。这些细节,文档里往往一笔带过,但线上出问题时,就是凌晨三点的告警电话。

2.3 SpringBoot技术栈选型依据:为什么是这些组件

  • Web层:Spring WebFlux vs Spring MVC
    本项目选Spring MVC而非WebFlux。理由很实在:WebFlux的异步非阻塞模型在IO密集型场景(如调用外部API)优势明显,但抢购的核心瓶颈是CPU和数据库锁竞争,不是IO等待。MVC的线程模型更直观,排查锁等待、线程阻塞问题时,jstack日志一目了然。强行上WebFlux,反而增加调试复杂度。

  • 数据库:MySQL 8.0的行锁优化
    必须用MySQL 8.0+。老版本的InnoDB在SELECT ... FOR UPDATE时,如果WHERE条件没走索引,会升级为表锁,整个库存表被锁死。8.0引入了“锁降级”机制,能更精准地锁定目标行。我们的商品表主键id和唯一索引sku_code是必备的。

  • 缓存:Redis单节点足够,集群反而是坑
    别一上来就上Redis Cluster。抢购场景的热点Key(如爆款商品库存)集中在少数几个key上,Cluster的哈希槽迁移、ASK重定向会引入额外延迟。我们实测单节点Redis(开启AOF+RDB混合持久化)在2000QPS下稳定运行,而Cluster在相同压力下,部分节点CPU飙升至95%,出现超时。除非你的SKU量级达到千万级,否则单节点+哨兵高可用完全够用。

  • 依赖管理:为什么不用ShardingSphere做分库分表
    库存表是典型的“窄表”(就几个字段),且读多写少。分库分表带来的路由复杂度、跨库事务、全局ID生成等问题,远超其收益。我们用“库存服务单独部署+读写分离”更轻量:主库负责扣减,从库负责查询展示,用Canal监听binlog同步库存变更到ES做搜索。

3. 核心细节解析与实操要点:从代码到日志的每一处陷阱

3.1 数据库表结构设计:为什么库存字段必须是BIGINT且带version

很多人建库存表只写stock INT,这是大忌。INT最大值21亿,看似很大,但如果你的系统要做“虚拟库存”(比如预估未来30天销量),或者支持“库存拆分”(按仓库、渠道分配),很快就会溢出。我们定义:

CREATE TABLE `t_product_stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `sku_code` VARCHAR(64) NOT NULL COMMENT '商品编码', `total_stock` BIGINT NOT NULL DEFAULT '0' COMMENT '总库存', `lock_stock` BIGINT NOT NULL DEFAULT '0' COMMENT '锁定库存(防超卖)', `available_stock` BIGINT NOT NULL DEFAULT '0' COMMENT '可用库存 = total - lock', `version` INT NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';

重点看三个字段:

  • lock_stock:不是冗余字段,而是关键的“预占位”机制。用户下单时,先UPDATE t_product_stock SET lock_stock = lock_stock + 1 WHERE sku_code = ?,成功后再创建订单。订单支付成功,才把lock_stock转移到available_stock;支付失败,则释放lock_stock。这解决了“下单未支付导致库存被长期占用”的问题。
  • available_stock:前端展示的库存,永远等于total_stock - lock_stock,避免实时计算。
  • version:乐观锁核心。每次UPDATE都带WHERE version = #{version},成功则version = version + 1。MyBatis-Plus的@Version注解会自动处理,但必须确保实体类字段类型是Integer,且数据库字段默认值为0。

注意:lock_stock机制要求你必须有“订单超时关闭”功能。我们用Redis的EXPIRE设置订单过期时间(例如30分钟),过期后通过Lua脚本原子性地释放lock_stock。如果用MQ延时消息,要考虑消息丢失的补偿。

3.2 Redis预减库存的原子性保障:为什么不用DECRBY而用Lua脚本

初学者常写redisTemplate.opsForValue().decrement("stock:1001"),这在单实例没问题,但Redis主从同步有延迟,从节点可能读到旧值。更危险的是,如果业务逻辑需要“先查库存再决定是否扣减”(比如库存<10时触发补货),GET+DECR两步操作不是原子的,必然超卖。

正确做法:用Lua脚本封装原子操作。我们写了一个通用脚本stock_decr.lua:

-- KEYS[1] = stock_key, ARGV[1] = decrement_value, ARGV[2] = min_stock (防止扣成负数) local current = redis.call('GET', KEYS[1]) if current == false then return -1 -- key不存在 end local stock = tonumber(current) local decr = tonumber(ARGV[1]) local min = tonumber(ARGV[2]) if stock < decr then return -2 -- 库存不足 end if stock - decr < min then return -3 -- 扣减后低于最小值 end return redis.call('DECRBY', KEYS[1], decr)

Java调用:

DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(luaScriptContent); script.setResultType(Long.class); Long result = redisTemplate.execute(script, Collections.singletonList("stock:1001"), "1", "0"); if (result == -2) { throw new BusinessException("库存不足"); } else if (result == -3) { throw new BusinessException("库存已低于安全阈值"); } // result >= 0 表示扣减成功,返回新库存值

这个脚本的好处:一次网络往返完成“判断+扣减”,且返回值明确标识失败原因(-1 key不存在,-2库存不足,-3低于阈值),比try-catch更精准。我们压测发现,相比GET+DECR两次调用,Lua方案在2000QPS下错误率降低92%。

3.3 悲观锁的实战避坑:FOR UPDATE必须配合索引,且不能写错顺序

这是线上事故最高发的点。看这段典型错误代码:

// ❌ 错误:没走索引,触发表锁! ProductStock stock = productStockMapper.selectBySkuCode(skuCode); // WHERE sku_code = ? 但sku_code没建索引! // 接着执行 productStockMapper.lockAndDecrement(skuCode, 1); // SELECT ... FOR UPDATE

如果sku_code没有索引,MySQL会全表扫描,然后对所有扫描到的行加锁,相当于锁了整张表。此时另一个事务更新其他商品,也会被阻塞。

正确姿势:

  1. 强制走索引:在Mapper XML里写死主键查询,再用主键做FOR UPDATE:
<!-- ✅ 正确:先查主键,再用主键锁 --> <select id="selectByIdForUpdate" resultType="ProductStock"> SELECT * FROM t_product_stock WHERE id = #{id} FOR UPDATE </select>
  1. 锁的顺序必须一致:如果一个事务要更新商品A和B的库存,所有服务必须按“A→B”顺序加锁。如果服务X按A→B,服务Y按B→A,就形成死锁。我们在库存服务里约定:所有批量操作按sku_code字典序排序后再执行。

  2. 超时控制:在DataSource配置里加?connectTimeout=3000&socketTimeout=5000,并在MyBatis配置<setting name="defaultStatementTimeout" value="3"/>,避免一个慢SQL拖垮整个连接池。

实操心得:上线前必须用SHOW ENGINE INNODB STATUS\G查死锁日志。我们曾在一个促销日发现,由于两个服务对同一商品调用SELECT ... FOR UPDATE的WHERE条件不同(一个用sku_code,一个用id),导致锁粒度不一致,引发连锁阻塞。解决方案是统一收口到一个库存服务,禁止DAO层直连。

4. 实操过程与核心环节实现:从零搭建可压测的抢购服务

4.1 SpringBoot项目初始化与关键依赖配置

创建SpringBoot 2.7.x项目(兼容JDK 8,降低团队升级成本),pom.xml核心依赖:

<dependencies> <!-- Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 连接池 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

application.yml关键配置:

spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 lettuce: pool: max-active: 200 # 必须够大!抢购时Redis连接消耗快 max-idle: 20 min-idle: 0 max-wait: 10000 datasource: url: jdbc:mysql://127.0.0.1:3306/seckill?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: root hikari: maximum-pool-size: 50 # 悲观锁场景必须提高 minimum-idle: 10 connection-timeout: 3000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000

注意:HikariCP的maximum-pool-size设为50是经过压测验证的。设太小(如20),在悲观锁场景下,大量线程在等待数据库连接,TPS直接腰斩;设太大(如100),又会引发MySQL的max_connections超限。我们用show variables like 'max_connections';查到MySQL默认151,所以应用层连接池必须留出余量。

4.2 核心Service实现:三种锁机制的完整代码与调用链

我们定义一个SeckillService接口,包含三个方法:

public interface SeckillService { /** * 方案1:Redis预减 + MySQL乐观锁 */ Result seckillOptimistic(String skuCode); /** * 方案2:Redis预减 + MySQL悲观锁 */ Result seckillPessimistic(String skuCode); /** * 方案3:Redis分布式锁 + MySQL最终扣减 */ Result seckillRedisLock(String skuCode); }
4.2.1 乐观锁实现(seckillOptimistic)
@Service @Slf4j public class SeckillOptimisticServiceImpl implements SeckillService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private ProductStockMapper productStockMapper; @Override public Result seckillOptimistic(String skuCode) { String redisKey = "stock:" + skuCode; // 1. Redis预减库存(Lua脚本) Long newStock = redisDecr(redisKey, 1L); if (newStock == null || newStock < 0) { return Result.fail("库存不足"); } // 2. MySQL乐观锁扣减(带重试) int retry = 0; while (retry < 3) { try { ProductStock stock = productStockMapper.selectBySkuCode(skuCode); if (stock == null || stock.getAvailableStock() < 1) { // Redis和DB不一致,回滚Redis redisTemplate.opsForValue().increment(redisKey, 1L); return Result.fail("库存已售罄"); } // 构造更新条件:version必须匹配 ProductStock update = new ProductStock(); update.setSkuCode(skuCode); update.setVersion(stock.getVersion()); // 当前版本 update.setAvailableStock(stock.getAvailableStock() - 1); update.setLockStock(stock.getLockStock() + 1); int rows = productStockMapper.updateOptimistic(update); if (rows > 0) { // 更新成功,创建订单 createOrder(skuCode); return Result.success("抢购成功"); } // version不匹配,重试 retry++; Thread.sleep(10); // 避免重试风暴 } catch (Exception e) { log.error("乐观锁扣减失败,sku:{}", skuCode, e); break; } } return Result.fail("系统繁忙,请稍后重试"); } private Long redisDecr(String key, Long delta) { // 调用前面定义的Lua脚本 DefaultRedisScript<Long> script = ...; return redisTemplate.execute(script, Collections.singletonList(key), delta.toString(), "0"); } private void createOrder(String skuCode) { // 简化:插入订单表,实际应发MQ异步 Order order = new Order(); order.setSkuCode(skuCode); order.setCreateTime(new Date()); orderMapper.insert(order); } }

关键点:

  • 重试逻辑:最多3次,每次sleep 10ms。太多重试会加重DB压力,太少则失败率高。这个数值是压测出来的平衡点。
  • Redis回滚:当DB扣减失败(version冲突),必须把Redis里预减的库存加回去,否则缓存永远比DB少。
  • createOrder:这里简化为同步插入,实际生产必须异步化,否则订单服务慢会拖垮库存服务。
4.2.2 悲观锁实现(seckillPessimistic)
@Service @Slf4j public class SeckillPessimisticServiceImpl implements SeckillService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private ProductStockMapper productStockMapper; @Override @Transactional(rollbackFor = Exception.class) public Result seckillPessimistic(String skuCode) { String redisKey = "stock:" + skuCode; // 1. Redis预减(同上) Long newStock = redisDecr(redisKey, 1L); if (newStock == null || newStock < 0) { return Result.fail("库存不足"); } // 2. MySQL悲观锁:必须用主键查询,且SELECT ... FOR UPDATE ProductStock stock = productStockMapper.selectByIdForUpdate(getProductIdBySku(skuCode)); if (stock == null || stock.getAvailableStock() < 1) { // DB库存不足,回滚Redis redisTemplate.opsForValue().increment(redisKey, 1L); return Result.fail("库存已售罄"); } // 3. 执行扣减(此时已加锁,无需担心并发) stock.setAvailableStock(stock.getAvailableStock() - 1); stock.setLockStock(stock.getLockStock() + 1); stock.setVersion(stock.getVersion() + 1); // 乐观锁字段也要更新 productStockMapper.updateById(stock); // 4. 创建订单 createOrder(skuCode); return Result.success("抢购成功"); } private Long getProductIdBySku(String skuCode) { // 从缓存或DB查sku_code对应的id,必须保证有索引 return productStockMapper.selectIdBySkuCode(skuCode); } }

关键点:

  • @Transactional是必须的,否则FOR UPDATE锁在方法结束就释放,起不到保护作用。
  • getProductIdBySku必须走索引,我们给sku_code字段加了唯一索引。
  • 即使用了悲观锁,依然要检查available_stock,因为Redis预减和DB查询之间有微小时间差,可能被其他服务抢先扣减。
4.2.3 Redis分布式锁实现(seckillRedisLock)
@Service @Slf4j public class SeckillRedisLockServiceImpl implements SeckillService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private ProductStockMapper productStockMapper; private static final String LOCK_PREFIX = "seckill:lock:"; @Override public Result seckillRedisLock(String skuCode) { String lockKey = LOCK_PREFIX + skuCode; String requestId = UUID.randomUUID().toString(); // 唯一请求ID,防误删 // 1. 尝试获取锁(SETNX + EXPIRE原子操作) Boolean isLocked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (!isLocked) { return Result.fail("系统繁忙,请稍后重试"); } try { // 2. Redis预减 String redisKey = "stock:" + skuCode; Long newStock = redisDecr(redisKey, 1L); if (newStock == null || newStock < 0) { return Result.fail("库存不足"); } // 3. MySQL最终扣减(此时已加锁,可走简单UPDATE) int rows = productStockMapper.decrementStock(skuCode, 1); if (rows <= 0) { return Result.fail("库存已售罄"); } createOrder(skuCode); return Result.success("抢购成功"); } finally { // 4. 安全释放锁:必须用Lua脚本,防止释放了别人的锁 String releaseScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(releaseScript, Long.class), Collections.singletonList(lockKey), requestId); } } }

关键点:

  • 锁释放必须用Lua:先GET再DEL不是原子的,如果GET后服务宕机,锁就永远无法释放。Lua脚本保证“判断+删除”原子性。
  • 锁过期时间10秒:必须大于业务执行时间(我们压测最长2.3秒),但不能太长(如30秒),否则故障时锁残留太久。
  • requestId防误删:避免A服务拿到锁,执行超时,锁自动过期,B服务拿到锁,A服务恢复后误删B的锁。

4.3 压测脚本与真实数据:JMeter配置与结果分析

我们用JMeter模拟2000用户并发请求,线程组配置:

  • 线程数:2000
  • Ramp-Up Period:10秒(每秒200用户)
  • 循环次数:1
  • HTTP请求:POST /api/seckill/optimistic?skuCode=1001

三个方案压测结果(持续5分钟):

方案平均响应时间吞吐量(TPS)错误率超发数CPU使用率
乐观锁22ms18500.8%065%
悲观锁48ms9200.02%088%
Redis锁31ms15200.1%072%

关键结论:

  • 乐观锁吞吐量最高,但错误率0.8%意味着每1000次请求有8次失败,需要前端友好提示“请重试”。
  • 悲观锁错误率最低,但TPS只有920,且CPU飙到88%,说明锁竞争激烈。
  • Redis锁是平衡之选,TPS和错误率都居中,CPU压力适中。

实操心得:压测时一定要监控Redis的INFO stats中的instantaneous_ops_per_sec和rejected_connections,以及MySQL的SHOW PROCESSLIST看是否有大量Waiting for table metadata lock。我们曾发现,某个开发在压测时偷偷执行了ALTER TABLE,导致所有FOR UPDATE请求被阻塞,TPS直接归零。

5. 常见问题与排查技巧实录:那些让你半夜爬起来的线上Bug

5.1 “库存明明是100,为什么扣到-5?”——Redis与MySQL数据不一致的根因分析

这是最经典的超发现象。我们复盘过3个真实案例:

案例1:Redis主从延迟
现象:用户看到前端显示“库存100”,点击抢购后提示“抢购成功”,但后台查MySQL发现库存是95,Redis里是94。
根因:应用写Redis主节点后立即读从节点(因为读写分离配置了slavePreferred),而主从同步有200ms延迟。
解决:所有库存读操作强制走Redis主节点。在RedisTemplate配置readFrom = ReadFrom.MASTER。

案例2:Lua脚本未处理网络超时
现象:JMeter压测时,部分请求返回-1(key不存在),但日志显示Redis Key明明存在。
根因:Lua脚本执行时,网络抖动导致客户端收到超时,但Redis服务端脚本已执行完毕。
解决:Lua脚本返回值必须包含执行状态。我们改造脚本,成功时返回{code:0, stock:99},超时返回{code:-1, msg:"timeout"},客户端根据code判断是否重试。

案例3:MySQL事务未提交,Redis已扣减
现象:服务重启后,Redis库存比MySQL少10个。
根因:createOrder()抛异常,@Transactional回滚了MySQL,但Redis的DECR无法回滚(没有事务)。
解决:引入TCC模式(Try-Confirm-Cancel)。Try阶段:Redis DECR + 写入本地事务表(记录本次扣减);Confirm阶段:MySQL扣减 + 删除事务表记录;Cancel阶段:Redis INCR + 删除事务表记录。我们用RocketMQ事务消息实现Confirm/Cancle的异步触发。

5.2 “为什么加了锁,还是出现死锁?”——InnoDB死锁日志解读指南

当MySQL发生死锁,SHOW ENGINE INNODB STATUS\G会输出详细日志。看懂它,5分钟定位问题:

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-09-15 14:23:45 0x7f8a1c012700 *** (1) TRANSACTION: TRANSACTION 123456789, ACTIVE 0.001 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1001, OS thread handle 140221234567680, query id 123456 localhost root updating UPDATE t_product_stock SET available_stock = available_stock - 1 WHERE sku_code = '1001' *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 108 page no 3 n bits 72 index uk_sku of table `seckill`.`t_product_stock` trx id 123456789 lock_mode X locks rec but not gap *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 108 page no 4 n bits 72 index PRIMARY of table `seckill`.`t_product_stock` trx id 123456789 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 123456790, ACTIVE 0.002 sec starting index read ... *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 108 page no 4 n bits 72 index PRIMARY of table `seckill`.`t_product_stock` trx id 123456790 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 108 page no 3 n bits 72 index uk_sku of table `seckill`.`t_product_stock` trx id 123456790 lock_mode X locks rec but not gap waiting

解读:

  • 事务1持有uk_sku索引上的X锁,等待PRIMARY索引上的X锁;
  • 事务2持有PRIMARY索引上的X锁,等待uk_sku索引上的X锁;
  • 形成循环等待,死锁。

解决方案:

  • 统一SQL执行顺序:所有服务必须先按sku_code查,再按id更新,禁止混用。
  • 减少锁范围:把UPDATE ... WHERE sku_code = ?改成UPDATE ... WHERE id = ?,因为主键索引的锁粒度更小。

5.3 “Redis锁失效了!”——分布式锁的7个失效场景与防御清单

我们整理了生产环境高频失效场景,按风险等级排序:

风险等级场景防御措施验证方式
⚠️⚠️⚠️高危锁过期时间小于业务执行时间设置过期时间为业务最大耗时×2,并在业务中续期(Redisson的watchdog机制)压测时故意让一个线程sleep 15秒,观察锁是否被其他线程获取
⚠️⚠️中危客户端获取锁后崩溃,未释放使用SET key value EX seconds NX,依赖Redis自动过期模拟进程kill -9,检查锁是否自动消失
⚠️⚠️中危多个客户端用相同value释放锁释放锁时必须校验value(即requestId)用两个JMeter线程,A获取锁后不释放,B尝试释放,检查是否成功
⚠️低危Redis主从切换,从节点升主,锁丢失改用Redlock算法(5节点,过半成功)或ZooKeeper模拟主节点宕机,观察锁是否连续
⚠️低危网络分区,客户端以为获取锁失败,实际已成功客户端记录锁获取时间戳,重试时先检查在锁获取后立即写入本地日志,宕机恢复后检查
⚠️低危Lua释放脚本有语法错误上线前用redis-cli --eval测试脚本redis-cli --eval unlock.lua lock:key , requestId
⚠️低危锁key命名冲突(如不同环境共用)key前缀加入环境标识,如seckill:prod:lock:1001检
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进