分布式锁实践
审校日期:2026-09-05。分布式锁协调遵循相同协议的参与者;租约失效、故障转移和旧持有者继续执行都需要额外处理。Java 片段未在完整依赖工程编译,API 用法按下方官方文档核对。
1. 分布式锁要素
| 要素 | 说明 | 必须 |
|---|---|---|
| 互斥 | 任意时刻只有一个客户端能持有锁 | ✅ |
| 防死锁 | 锁到期自动释放或持有者异常时能释放 | ✅ |
| 可重入 | 同一线程可多次获取同一锁 | 通常 |
| 高性能 | 加锁/解锁快,高并发下无瓶颈 | ✅ |
| 高可用 | 在选定容错假设内可用;依赖多数派的系统失去多数后通常不能继续授予锁 | ✅ |
2. Redis 分布式锁
2.1 基础实现(SETNX)
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
// 简单实现(有问题,不推荐直接用)
public boolean lock(String key, String value, long expireTime) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue()
.setIfAbsent(key, value, Duration.ofSeconds(expireTime))
);
}
public void unlock(String key, String value) {
// 问题:不是原子操作,可能误删他人的锁
if (value.equals(redisTemplate.opsForValue().get(key))) {
redisTemplate.delete(key);
}
}
}
2.2 Lua 原子解锁
// 解锁 Lua 脚本(原子操作)
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public void unlock(String key, String value) {
redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key),
value
);
}
2.3 Redisson API 用法
@Service
public class DistLockService {
@Autowired
private RedissonClient redisson;
// 简单获取锁
public void lock(String resourceCode) {
RLock lock = redisson.getLock(resourceCode);
lock.lock(); // 未指定租约时由 watchdog 续期,超时配置默认 30 秒
}
// 带过期时间
public boolean tryLock(String resourceCode, long waitTime, long leaseTime) throws InterruptedException {
RLock lock = redisson.getLock(resourceCode);
return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
}
// 释放锁
public void unlock(String resourceCode) {
RLock lock = redisson.getLock(resourceCode);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 公平锁(按请求顺序获取)
public void fairLock(String resourceCode) {
RLock lock = redisson.getFairLock(resourceCode);
lock.lock();
}
// 读锁(多线程可并发读)
public void readLock(String resourceCode) {
RLock lock = redisson.getReadLock(resourceCode);
lock.lock();
}
// 写锁(独占)
public void writeLock(String resourceCode) {
RLock lock = redisson.getWriteLock(resourceCode);
lock.lock();
}
}
2.4 Redisson 使用示例
@Service
public class OrderService {
@Autowired
private DistLockService distLockService;
public void createOrder(Order order) throws InterruptedException {
String lockKey = "order:create:" + order.getUserId();
// 尝试获取锁,最多等3秒,锁自动10秒后过期
boolean locked = distLockService.tryLock(lockKey, 3, 10);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
// 业务逻辑
checkInventory(order);
deductInventory(order);
saveOrder(order);
} finally {
// 与加锁在同一线程释放;固定租约可能已过期
distLockService.unlock(lockKey);
}
}
}
3. ZooKeeper 分布式锁
3.1 Curator 实现
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>
// 调用方启动 Curator client,并按资源创建、复用 InterProcessMutex。
// 应用关闭时关闭 client;同一线程、同一对象完成 acquire/release。
public void withLock(InterProcessMutex lock) throws Exception {
boolean acquired = lock.acquire(30, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("获取锁超时");
}
try {
// 执行业务;同时处理连接 SUSPENDED/LOST 对所有权的影响。
} finally {
lock.release();
}
}
不能在 unlock 时重新 new 一个 InterProcessMutex;新对象没有原对象的线程持有状态。应用还需监听连接状态:连接挂起时不能假定仍安全持锁,会话丢失时应停止依赖旧锁的操作。
4. 对比与选型
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis SETNX | 性能高、部署简单 | 过期难精确、集群脑裂 | 短暂锁、并发量大的场景 |
| Redisson | 功能完善、可重入、续期 | 需引入 client | 需要 Redis 锁 API 与续期管理的场景 |
| ZooKeeper | 有序锁、临时节点、自动释放 | 性能略低、运维成本 | 需要严格顺序的场景 |
| Etcd | Raft 一致性、 Lease 机制 | 生态不如 ZK | Kubernetes 生态 |
5. 常见问题
5.1 锁过期,业务未完成
问题:线程A获取锁后业务执行过慢,锁过期自动释放,线程B获取同一把锁,两个线程同时执行。
缓解:未显式指定 leaseTime 时使用 watchdog 续期;指定固定租约时到期会释放。续期受进程暂停、网络和调度影响,不能保证业务不会越过租约继续运行。
lock.lock(); // 由 watchdog 管理续期
try {
// 业务操作
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
如果旧持有者可能写外部资源,应由资源侧原子校验 fencing token 或业务版本,拒绝落后的持有者。仅检查锁归属再写资源仍有竞态窗口。
5.2 主从切换丢锁
异步复制可能使新主节点缺失锁记录。不能通过同一 Redisson 客户端上的三个不同 key 模拟三个独立 Redis 实例。
Redlock 的多数条件是至少 floor(N / 2) + 1 个独立实例成功,还需检查获取耗时与剩余有效期;并非只数成功次数。Redisson 当前文档将 RedLock 标为弃用,不应把旧版 getRedLock 示例作为通用解决办法。应结合部署拓扑、复制确认与资源侧 fencing 设计故障处理。
5.3 锁重入问题
问题:同一线程多次获取同一把锁,需要记录持有次数。
解决:Redis 用计数器 + Lua 脚本;Redisson 内置可重入。
// Redis 可重入实现
private static final String REENTRANT_LOCK_SCRIPT =
"if redis.call('exists', KEYS[1]) == 0 then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('pexpire', KEYS[1], ARGV[2]) " +
" return 1 " +
"elseif redis.call('hget', KEYS[1], ARGV[1]) ~= false then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('pexpire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
5.4 数据库约束与条件更新
唯一索引用于业务身份去重;下例条件更新用于防止库存不足时扣减,两者职责不同。count 必须先校验为正整数。
// 原子条件更新(库存扣减),没有显式版本号
@Update("UPDATE product SET stock = stock - #{count} " +
"WHERE id = #{id} AND stock >= #{count}")
int deductStock(@Param("id") Long id, @Param("count") int count);
// 影响行数 0 表示商品不存在或库存不足;不证明本次请求已经执行
// 或者悲观锁(SELECT FOR UPDATE)
@Transactional
public void createOrder(Long productId) {
Product p = productMapper.selectByIdForUpdate(productId);
if (p.getStock() < 1) throw new BusinessException("库存不足");
p.setStock(p.getStock() - 1);
productMapper.updateById(p);
}
6. 使用原则
- 锁覆盖完整临界区:若正确性依赖数据库提交,释放必须在提交之后;在此约束下缩小范围
- 加锁失败处理:必须考虑超时,快速失败,不要无限等待
- 必须解锁:finally 中解锁,确保无论成功失败都释放
- 锁的粒度:按业务 ID 锁(userId、orderId),不要按通用 key
- 避免嵌套锁:跨服务调用时不要在内部锁住,易死锁
7. 可复现案例与来源
运行 node --test examples/consistency.test.js,其中两个测试展示旧租约持有者的迟到写入,以及资源侧 fencing 拒绝旧 token。案例是确定性内存模型,不代表已完成 Redis/ZooKeeper 故障演练。详见 并发一致性实验。