第一次接触分布式锁是在2016年做电商秒杀系统时。当时我们的促销活动上线后,出现了严重的超卖问题——100件库存的商品竟然卖出了120多件。排查发现,在集群环境下多个节点的服务实例同时判断"库存>0"并执行扣减,导致并发问题。这就是典型的分布式环境下的资源竞争场景,也是分布式锁要解决的核心问题。
分布式锁本质上是一种跨进程、跨机器的互斥机制。与单机环境下的synchronized或ReentrantLock不同,它需要在网络不可靠的分布式系统中,确保同一时刻只有一个客户端能获取锁。这就像多人协作编辑文档时,谁先获取编辑权谁就能修改内容,其他人必须等待。
Redis的SETNX(SET if Not eXists)命令是实现锁的基础。当键不存在时设置值并返回1,键已存在则返回0。这个原子性操作完美契合了锁的"争抢"特性:
bash复制SETNX lock_key unique_value
但单纯使用SETNX会存在死锁风险——如果客户端获取锁后崩溃,锁将永远无法释放。因此我们通常需要设置过期时间:
bash复制SET lock_key unique_value NX PX 30000
这条命令在Redis 2.6.12后支持,实现了"设置值+过期时间"的原子操作。其中NX表示仅当键不存在时设置,PX指定毫秒级过期时间。
解锁操作看似简单却暗藏玄机。直接使用DEL命令删除锁键会导致严重问题:
bash复制# 错误示范!
DEL lock_key
假设客户端A获取锁后执行时间超过过期时间,锁自动释放。此时客户端B获取了锁,然后客户端A执行完调用DEL,就会误删客户端B的锁。正确的解锁姿势应该是:
lua复制if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这段Lua脚本通过验证unique_value来确保只有锁的持有者才能释放锁。在Redis中执行Lua脚本是原子性的,完美解决了并发安全问题。
当业务对可靠性要求极高时,单Redis节点可能成为单点故障。Antirez提出的Redlock算法通过多个独立Redis主节点来提升可靠性:
重要提示:即使使用Redlock,客户端也应该实现锁续期逻辑(看门狗机制),防止长时间操作导致锁提前过期。
对于可能长时间执行的任务,需要实现锁续期机制。常见两种方案:
java复制private void scheduleExpirationRenewal() {
Thread renewalThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(lockWatchdogTimeout / 3);
// 通过Lua脚本延长锁时间
renewExpiration();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
renewalThread.setDaemon(true);
renewalThread.start();
}
我们曾遇到过一个诡异现象:在物理机部署的Redis集群中,锁频繁提前释放。最终定位到是由于某台机器时钟快了3分钟,导致锁的实际有效期缩短。解决方案:
在需要递归调用的场景中,需要实现可重入锁。Redis原生不支持,但可通过以下方式实现:
lua复制local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('HINCRBY', KEYS[1], 'count', 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
end
解锁时也需要对应处理:
lua复制local counter = redis.call('HGET', KEYS[1], 'count')
if counter > 1 then
redis.call('HINCRBY', KEYS[1], 'count', -1)
return 1
else
redis.call('DEL', KEYS[1])
return 1
end
过粗的锁粒度会严重影响并发性能。我们优化过一个商品库存系统,将全局锁改为按商品ID哈希分片:
java复制public String getLockKey(Long itemId) {
// 按商品ID分片,避免所有库存变更争抢同一把锁
return "stock_lock:" + (itemId % 16);
}
这使得不同商品的库存操作可以并行处理,吞吐量提升了8倍。
直接使用忙等待(循环尝试获取锁)会导致Redis负载飙升。更优雅的方式是:
java复制// 伪代码示例
while (timeout > 0) {
if (tryLock()) {
return true;
}
long start = System.currentTimeMillis();
subscribe(lockChannel);
awaitNotification(timeout);
unsubscribe(lockChannel);
timeout -= (System.currentTimeMillis() - start);
}
java复制RLock lock = redisson.getLock("myLock");
try {
lock.lock();
// 业务代码
} finally {
lock.unlock();
}
java复制@Autowired
private RedisLockRegistry lockRegistry;
public void doWithLock() {
Lock lock = lockRegistry.obtain("lockKey");
try {
if (lock.tryLock(3, TimeUnit.SECONDS)) {
// 业务代码
}
} finally {
lock.unlock();
}
}
python复制def acquire_lock(conn, lockname, acquire_timeout=10):
identifier = str(uuid.uuid4())
end = time.time() + acquire_timeout
while time.time() < end:
if conn.setnx('lock:' + lockname, identifier):
conn.expire('lock:' + lockname, 10)
return identifier
elif not conn.ttl('lock:' + lockname):
conn.expire('lock:' + lockname, 10)
time.sleep(0.001)
return False
完善的监控是生产环境必备项,关键指标包括:
| 指标名称 | 监控方式 | 告警阈值 |
|---|---|---|
| 锁获取成功率 | Redis慢查询日志+自定义计数器 | <95%持续5分钟 |
| 平均锁持有时间 | 客户端埋点上报 | >10s(需业务评估) |
| 锁等待队列长度 | Redis LIST监控 | >100且持续增长 |
| 锁过期释放次数 | Keyspace通知统计 | 突然增长50%以上 |
在Grafana中建议配置如下面板:
当出现以下情况时,建议考虑其他分布式锁实现:
各方案对比:
| 特性 | Redis | ZooKeeper | etcd | 数据库 |
|---|---|---|---|---|
| 性能 | 最高 | 低 | 中 | 低 |
| 一致性 | 最终 | 强 | 强 | 强 |
| 可用性 | 高 | 中 | 高 | 依赖部署 |
| 实现复杂度 | 低 | 中 | 中 | 低 |
| 锁续期支持 | 需实现 | 原生 | 原生 | 无 |
在电商秒杀系统中,我们最终采用了Redis+本地缓存的混合方案:先用Redis分布式锁控制集群级别的并发,再用ThreadLocal锁控制单机内的并发,这样既保证了正确性,又极大提升了性能。