1. 分布式锁的前世今生
第一次接触分布式锁是在2016年做电商秒杀系统时遇到的。当时我们的订单服务部署了5个实例,结果活动开始后库存出现了超卖。排查发现多个实例同时判断有库存,都进行了扣减操作。这个惨痛教训让我深刻认识到:在分布式环境下,传统的单机锁机制完全失效了。
分布式锁本质上是一个所有服务实例都能访问的"信号灯",用来协调多个进程对共享资源的访问顺序。它的核心诉求就三点:
- 互斥性:同一时刻只能有一个客户端持有锁
- 避免死锁:即使客户端崩溃,锁也能自动释放
- 容错性:只要大部分Redis节点存活,客户端就能正常获取和释放锁
注意:分布式锁不是银弹。我曾见过团队滥用分布式锁导致系统吞吐量下降90%的案例,一定要评估是否真的需要分布式锁。
2. Redis分布式锁的实现方案
2.1 基础版:SETNX + EXPIRE
最朴素的实现方式:
SETNX lock_key unique_value # 尝试获取锁 EXPIRE lock_key 10 # 设置过期时间但这里有个致命问题——两条命令不是原子的。如果执行完SETNX后客户端崩溃,锁就永远无法释放。我在预发环境就踩过这个坑,最后只能手动删除key。
2.2 进阶版:SET扩展命令
Redis 2.6.12后支持扩展参数:
SET lock_key unique_value NX EX 10这个方案解决了原子性问题,但仍有缺陷:
- 业务执行时间超过10秒会导致锁提前释放
- 误删其他客户端的锁(比如A客户端阻塞导致锁过期,B获取锁后A恢复执行DEL操作)
2.3 完整版:Lua脚本实现
生产环境推荐方案:
-- 加锁脚本 if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 else return 0 end -- 解锁脚本 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end这个方案通过Lua保证原子性,且通过unique_value(建议UUID+线程ID)避免误删。我在金融支付系统中实际使用,QPS 3000+场景下稳定运行2年。
3. 生产环境中的那些坑
3.1 锁续约问题
当业务执行时间不确定时,需要实现锁续约机制。我们的做法是:
- 获取锁成功后启动守护线程
- 每过TTL的1/3时间就检查锁是否仍属于自己
- 如果是则延长TTL,否则主动释放
private void scheduleExpirationRenewal() { Thread renewalThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(lockTTL / 3); if (redisTemplate.execute(renewScript, Collections.singletonList(lockKey), clientId, String.valueOf(lockTTL)) == 1L) { continue; } break; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); renewalThread.setDaemon(true); renewalThread.start(); }3.2 Redis集群脑裂问题
在主从切换时可能出现多个客户端同时持有锁。我们通过RedLock算法缓解:
- 向5个独立Redis实例顺序获取锁
- 当获取到3个以上实例的锁且总耗时小于锁TTL时才算成功
- 释放时向所有实例发送删除命令
不过在实践中发现RedLock性能下降严重(约降低60%),最终我们通过:
- 使用更可靠的云Redis服务
- 设置合理的锁TTL(通常不超过5秒)
- 添加降级策略(比如本地缓存)
4. 性能优化实战记录
4.1 锁粒度控制
曾有个商品详情页项目,初期对整个商品对象加锁,QPS只能到500。优化方案:
- 按商品ID哈希分片
- 对库存数据单独加锁
- 非关键路径采用乐观锁 优化后QPS提升到12000+。
4.2 锁等待策略
直接轮询会导致Redis压力过大。我们实现了指数退避策略:
public boolean tryLock(long waitTime, TimeUnit unit) { long start = System.currentTimeMillis(); do { if (acquireLock()) { return true; } long elapsed = System.currentTimeMillis() - start; long remaining = unit.toMillis(waitTime) - elapsed; if (remaining <= 0) break; // 指数退避 long sleepTime = Math.min(baseSleepTimeMs * (long)Math.pow(2, attempt), maxSleepTimeMs); Thread.sleep(Math.min(sleepTime, remaining)); attempt++; } while (true); return false; }这个方案将Redis的QPS降低了70%,同时提高了锁获取成功率。
5. 监控与告警体系建设
5.1 关键监控指标
- 锁等待时间P99
- 锁占用时长超过TTL的比例
- 锁获取失败率
- 锁冲突热点Key
我们使用Prometheus+Grafana搭建的监控看板曾及时发现某个商品ID的锁竞争异常,避免了大促期间的库存异常。
5.2 日志规范
所有锁操作必须记录trace日志:
[LOCK_ACQUIRE] key=product_1234, client=client_678, ttl=5000ms, result=SUCCESS [LOCK_RELEASE] key=product_1234, client=client_678, result=SUCCESS这对排查跨服务调用时的死锁问题特别有用。
6. 替代方案选型参考
虽然Redis分布式锁应用广泛,但在某些场景下其他方案可能更合适:
6.1 Zookeeper方案
优势:
- 临时顺序节点特性天然适合锁实现
- Watch机制避免轮询 劣势:
- 写入性能较差(通常只有Redis的1/10)
- 运维复杂度高
6.2 数据库方案
通过唯一索引实现:
INSERT INTO locks(name, client_id, expire_time) VALUES ('order_lock', 'client_123', NOW() + INTERVAL 10 SECOND);适合已有数据库依赖且并发不高的场景。我们内部审批系统就采用这种方案。
在实际项目中,我通常会根据团队技术栈和业务特点做技术选型。对于大部分Java技术栈的互联网应用,Redis分布式锁仍是性价比最高的选择。