1. 分布式锁的本质与业务场景
在电商秒杀系统中,当1000个用户同时点击"立即抢购"按钮时,如何确保库存不会被超卖?这就是分布式锁要解决的核心问题。去年双十一,我们团队就遇到了一个典型的分布式锁失效案例:某爆款商品库存显示剩余100件,实际下单成功数却达到了150单,直接导致需要紧急联系供应商补货并给用户道歉。
分布式锁的本质是在分布式系统中实现互斥访问的协调机制。与单机锁不同,它需要解决三个核心难题:
- 网络分区问题:锁信息需要在多个节点间同步,网络延迟或中断可能导致锁状态不一致
- 故障恢复问题:持有锁的客户端崩溃后,如何避免死锁
- 时钟漂移问题:依赖系统时间的锁可能因节点时间不同步而失效
在黑马点评这类高并发场景中,分布式锁的典型应用包括:
- 优惠券发放防超发
- 热门商品库存扣减
- 用户重复评论拦截
- 秒杀活动排队处理
关键认知误区:很多人以为用了Redis的SETNX就是分布式锁,实际上这仅仅是实现了最基础的互斥,距离生产可用的分布式锁还有很大差距。真正的分布式锁需要具备容错、续期、可重入等特性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis分布式锁的实现演进
2.1 初版方案:SETNX + DEL
最朴素的实现方式:
bash复制# 加锁
SETNX lock_key 1
# 业务处理
do_something()
# 释放锁
DEL lock_key
这个方案存在致命缺陷:
- 如果客户端在do_something()时崩溃,锁将永远无法释放
- 没有锁超时机制,容易导致死锁
- 非原子性操作可能引发竞态条件
2.2 改进版:SETNX + EXPIRE
为解决死锁问题,加入超时机制:
bash复制SETNX lock_key 1
EXPIRE lock_key 10
但这样仍然不是原子操作,如果在SETNX和EXPIRE之间进程崩溃,还是会出现死锁。Redis 2.6.12之后提供了更好的解决方案:
2.3 生产级方案:SET扩展参数
使用SET命令的NX和PX参数实现原子操作:
bash复制SET lock_key unique_value NX PX 10000
参数说明:
- NX:仅当key不存在时设置
- PX:设置过期时间(毫秒)
- unique_value:客户端唯一标识,用于安全释放锁
对应的释放锁逻辑需要使用Lua脚本保证原子性:
lua复制if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
3. 集群环境下的特殊挑战
在Redis Cluster或主从架构中,分布式锁面临新的挑战。考虑以下场景:
- 客户端A在Master节点获取锁成功
- Master在锁同步到Slave前崩溃
- Slave晋升为新的Master
- 客户端B也能获取相同的锁,导致互斥失效
这就是著名的Redis集群锁失效问题。对此,Redis作者提出了Redlock算法:
3.1 Redlock算法实现步骤
- 获取当前毫秒级时间戳T1
- 依次向N个独立的Redis节点请求加锁(使用相同的key和value)
- 当从大多数节点(N/2+1)获取锁成功,且总耗时小于锁超时时间时,认为加锁成功
- 锁的实际有效时间 = 初始有效时间 - 获取锁总耗时
- 释放锁时需要向所有节点发送释放请求
Java实现示例:
java复制public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long startTime = System.currentTimeMillis();
int lockedNodes = 0;
for (RedisNode node : redisNodes) {
if (tryAcquire(node, leaseTime, unit)) {
lockedNodes++;
}
}
long elapsed = System.currentTimeMillis() - startTime;
boolean acquired = lockedNodes >= majority && elapsed < waitTime;
if (!acquired) {
unlockInternal(); // 释放已获取的锁
}
return acquired;
}
3.2 Redlock的争议与替代方案
Martin Kleppmann曾撰文指出Redlock存在的问题:
- 依赖系统时钟,时钟跳跃会导致锁失效
- GC停顿可能导致锁过期
- 网络延迟难以精确评估
替代方案建议:
- 对于强一致性需求,考虑Zookeeper/etcd等CP系统
- 对于高可用需求,可以使用Redis+定时续约的方案
4. 黑马点评中的实战应用
4.1 优惠券发放场景
在黑马点评的优惠券发放模块中,我们采用如下实现:
java复制public Result seckillVoucher(Long voucherId) {
// 获取分布式锁
String lockKey = "lock:voucher:" + voucherId;
String clientId = UUID.randomUUID().toString();
try {
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
if (!locked) {
return Result.fail("操作太频繁,请稍后再试");
}
// 查询优惠券库存
Voucher voucher = voucherService.getById(voucherId);
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 扣减库存
boolean success = voucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
// 创建订单
// ...
} finally {
// 使用Lua脚本释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
clientId);
}
}
4.2 锁优化技巧
-
锁分段:将热点商品ID哈希到多个锁,提升并发度
java复制int segment = voucherId.hashCode() % 16; String lockKey = "lock:voucher:" + voucherId + ":" + segment; -
锁续约:后台线程定期延长锁持有时间
java复制ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() -> { redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS); }, 3, 3, TimeUnit.SECONDS); -
等待队列:使用Redis的List结构实现公平锁
java复制// 加锁时 redisTemplate.opsForList().rightPush("lock:queue", clientId); // 检查自己是否在队列头部 String head = redisTemplate.opsForList().index("lock:queue", 0); if (clientId.equals(head)) { // 获取锁成功 }
5. 常见问题排查手册
5.1 锁永远获取不到
可能原因:
-
锁未设置过期时间,前一个持有者崩溃导致死锁
- 解决方案:确保每次加锁都设置合理的TTL
-
锁过期时间设置过短,业务未完成锁已释放
- 解决方案:根据压测结果调整超时时间,或实现锁续约机制
-
Redis内存不足,key被逐出
- 解决方案:监控Redis内存使用,设置适当的maxmemory-policy
5.2 出现多个客户端同时持有锁
可能原因:
-
集群环境下主从切换导致锁丢失
- 解决方案:使用Redlock或切换到CP系统
-
客户端处理时间超过锁超时时间
- 解决方案:实现锁续约,或优化业务逻辑减少处理时间
5.3 性能瓶颈分析
压测时发现分布式锁成为系统瓶颈,可通过以下方式优化:
- 减少锁粒度:从商品维度锁改为库存分段锁
- 本地锁+分布式锁二级缓存:先获取本地锁再尝试分布式锁
- 异步处理:将抢购请求放入队列异步处理
- 锁分离:读锁和写锁分离
6. 分布式锁的黄金法则
经过多个项目的实战总结,我认为分布式锁的使用必须遵守以下原则:
- 设置合理的超时时间:通常建议设置为平均业务处理时间的3-5倍
- 确保释放锁的原子性:必须使用Lua脚本验证值再删除
- 客户端标识必须唯一:建议使用UUID+线程ID的组合
- 避免长时间持有锁:锁内代码应该尽可能精简
- 考虑锁的可重入性:同一线程多次获取锁应该成功
在实际项目中,我们团队曾遇到过因未遵守第4条导致的严重事故:一个开发者在锁内执行了数据库全表扫描,导致系统整体响应时间飙升。最终通过将锁内操作拆分为"快速校验+异步处理"两个阶段解决了问题。
对于黑马点评这类中等规模的系统,我的建议是:
- 普通场景使用Redis单节点锁(SET NX PX + Lua)
- 关键业务使用Redlock或Zookeeper
- 超高并发场景考虑锁分段+本地缓存组合方案
