1. 问题现象:分布式锁失效的典型场景
上周排查一个线上问题,订单系统的库存扣减在促销高峰期出现了超卖。技术团队一脸懵——明明用Redis实现了分布式锁,日志也显示锁获取成功,为什么最终数据还是对不上?这个场景太经典了,几乎每个做高并发系统的团队都踩过这个坑。
先还原事故现场:秒杀场景下,200台服务器节点并发处理订单,每个请求都通过Redis的SETNX命令获取锁,锁超时设置为3秒。表面上看:
- 获取锁:
SETNX lock_key 1 EX 3 - 释放锁:
DEL lock_key
但监控发现,当QPS突破5000时:
- 线程A获取锁成功,开始执行业务逻辑
- 因GC停顿或网络延迟,线程A的执行时间超过3秒
- Redis自动释放锁(过期机制)
- 线程B获取到同一把锁
- 线程A和线程B同时修改共享数据
- 最终数据不一致
关键点:这种问题在测试环境很难复现,因为测试环境的并发量和真实生产环境的流量根本不在一个量级。很多团队直到大促爆雷才发现锁根本没起作用。
2. 分布式锁的三大认知误区
2.1 误区一:认为SETNX+EXPIRE就是分布式锁
90%的初级开发者会这样实现锁:
// 错误示范! Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock", "1"); redisTemplate.expire("lock", 3, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { redisTemplate.delete("lock"); } }这段代码有三个致命缺陷:
- SETNX和EXPIRE不是原子操作,中间可能崩溃导致死锁
- 删除锁时没有校验持有者,可能误删其他线程的锁
- 超时时间难以预估,设短了业务没执行完,设长了系统吞吐量下降
2.2 误区二:忽视业务执行时间的不确定性
我见过最离谱的案例:某金融系统设置锁超时30秒,结果某次批量处理触发了Full GC,STW停顿了35秒,锁自动释放后引发资金账户重复扣款。教训是:
- 永远不要假设业务代码能在固定时间内完成
- JVM停顿、DB慢查询、第三方接口超时都可能拖长执行时间
- 解决方案:实现锁续期机制(看门狗模式)
2.3 误区三:忽略Redis集群的脑裂问题
在Redis哨兵或集群模式下,当发生网络分区时可能出现:
- 客户端在Master节点获取锁成功
- Master宕机但未同步到Slave
- 故障转移后新的Master上没有该锁
- 另一个客户端也能获取锁
这种情况虽然罕见,但在金融、交易等场景必须考虑。解决方案:
- RedLock算法(有争议)
- 使用Zookeeper/etcd等CP系统
- 业务层添加补偿机制
3. 工业级分布式锁实现方案
3.1 基于Redisson的最佳实践
Redisson的分布式锁实现堪称教科书级别,核心逻辑:
RLock lock = redisson.getLock("orderLock"); try { // 尝试加锁,超时时间30秒,锁自动释放时间10秒 boolean res = lock.tryLock(30, 10, TimeUnit.SECONDS); if (res) { // 业务逻辑 } } finally { lock.unlock(); }其核心机制包括:
- 哈希槽分配:通过
KEYS[1]计算slot,保证相同key总是路由到同一节点 - 看门狗线程:每10秒检查持有锁的客户端是否存活,自动续期
- 锁释放校验:通过UUID+线程ID验证锁持有者身份
- 订阅发布机制:实现阻塞等待锁的线程唤醒
3.2 锁续期(Watch Dog)实现原理
Redisson的看门狗机制源码节选:
private void scheduleExpirationRenewal(long threadId) { // 每隔internalLockLeaseTime/3时间执行续期 Timeout task = commandExecutor.getConnectionManager() .newTimeout(new TimerTask() { @Override public void run(Timeout timeout) { // 执行LUA脚本延长锁时间 renewExpirationAsync(threadId); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); }这个设计精妙之处在于:
- 默认锁超时30秒,每10秒续期一次
- 客户端崩溃时,锁最终会自动释放避免死锁
- 续期失败会主动释放锁并抛出异常
3.3 不同场景下的锁选型建议
| 场景特征 | 推荐方案 | 注意事项 |
|---|---|---|
| 吞吐量优先,允许偶发超卖 | Redis单节点锁 | 设置合理的超时时间 |
| 强一致性要求 | Zookeeper顺序节点 | 性能较差,适合低频关键操作 |
| 长事务处理 | Redis锁+本地事务状态表 | 需要实现补偿机制 |
| 跨资源协调 | Saga模式+本地消息表 | 复杂度高,需要完善回滚逻辑 |
4. 高并发下的锁优化技巧
4.1 分段锁提升并发度
电商库存经典优化方案:将1000件库存拆分为10个分段(stock_1到stock_10),每个分段独立加锁。这样理论上并发能力提升10倍:
// 获取分段锁key String segmentKey = "stock_" + (itemId.hashCode() % 10); RLock lock = redisson.getLock(segmentKey);4.2 避免锁粒度过粗
反面教材:整个订单系统共用一个全局锁。正确做法应该是:
- 按业务维度拆分:订单锁、库存锁、优惠券锁
- 按数据ID哈希:order_lock_[id%16]
- 读写分离:读锁(共享锁)与写锁(排他锁)分离
4.3 锁等待优化策略
当锁竞争激烈时,可以:
- 添加随机退避:
Thread.sleep(10 + random.nextInt(50)) - 设置等待超时:
lock.tryLock(100, TimeUnit.MILLISECONDS) - 降级处理:获取锁失败后走缓存旧数据或排队流程
5. 终极解决方案:超越分布式锁
对于真正的高并发场景(如双十一秒杀),分布式锁可能不是最佳选择。更优方案包括:
5.1 乐观锁实现
UPDATE inventory SET count = count - 1 WHERE item_id = 1001 AND count >= 1配合版本号校验:
int affected = jdbcTemplate.update( "UPDATE orders SET status = ? WHERE id = ? AND version = ?", newStatus, orderId, oldVersion); if (affected == 0) { throw new OptimisticLockException(); }5.2 异步队列削峰
用Kafka/RocketMQ实现生产者-消费者模式:
- 接收请求后立即写入MQ
- 消费者单线程顺序处理
- 通过堆积告警动态扩容消费者
5.3 本地缓存+定期核对
适合最终一致性场景:
- 节点本地原子计数器处理请求
- 每10秒同步一次全局数据
- 对账服务修复差异
在实际项目中,我们最终采用的混合方案:
- 高频查询:本地缓存 + 版本号
- 库存扣减:Redis分段锁 + 乐观锁
- 订单创建:Kafka顺序消息 + 幂等处理
这个方案支撑了去年双十一峰值8.7万QPS的订单创建,全程零超卖。技术选型的核心逻辑是:根据业务特征选择最适合的并发控制手段,而不是无脑上分布式锁。