Appearance
分布式锁实现
分布式锁是微服务架构中最基础也最容易出错的组件——在多个进程/服务之间实现互斥访问共享资源。单机时代用 synchronized 或 ReentrantLock,但这些锁只对同一个 JVM 有效,多个服务实例之间互不可见。
一、什么是分布式锁
单机锁(synchronized / ReentrantLock):
服务 A 唯一实例,部署在一台机器上
┌─────────────────────┐
│ synchronized (lock) {│
│ 扣库存(); │ ← 只有一个 JVM,锁有效
│ } │
└─────────────────────┘
问题:把服务 A 部署 3 个实例后,synchronized 只管自己 JVM 内部的线程,
管不了其他实例。3 个实例各自有各自的锁,互不干扰 = 锁失效。
分布式锁:
服务 A 实例 1 ──→ 抢锁 ──→ 拿到锁,扣库存
服务 A 实例 2 ──→ 抢锁 ──→ 没拿到,等待/重试
服务 A 实例 3 ──→ 抢锁 ──→ 没拿到,等待/重试
锁不在 JVM 里,而在一个所有实例都能访问的"第三方"(Redis / ZK / Etcd / DB)二、分布式锁解决了什么问题
核心问题:多个服务实例同时操作同一份共享资源,导致数据不一致。
2.1 典型场景
| 场景 | 没有分布式锁 | 有分布式锁 |
|---|---|---|
| 秒杀扣库存 | 100 件库存,卖出 200 件(超卖) | 同一时刻只有 1 个实例操作库存 |
| 重复下单 | 用户手快点了 2 次,生成 2 个订单 | 同一用户加锁,第二次请求被拦截 |
| 定时任务 | 3 个实例同时执行,发了 3 封相同邮件 | 只有抢到锁的实例执行 |
| 幂等控制 | 网络超时重试,重复扣款 | 锁 + 唯一标识防止重复执行 |
| 资源分配 | 2 个用户同时抢到同一个优惠券 | 抢锁成功后扣减,先到先得 |
2.2 本质
分布式锁 = 把"互斥"这个能力从 JVM 内部搬到 JVM 外部
单机:锁在 JVM 堆内存中,只有同一进程内的线程可见
分布式:锁在 Redis/ZK/Etcd 中,所有进程都能看到同一把锁
这就是为什么 synchronized 在微服务中失效——
不是 synchronized 有问题,而是锁的作用域变了。三、分布式锁的五个必要条件
| 条件 | 说明 | 反例 |
|---|---|---|
| 互斥性 | 同一时刻只有一个客户端持有锁 | 两个实例同时扣库存 → 超卖 |
| 防死锁 | 持有锁的客户端崩溃后,锁能被自动释放 | 客户端挂了,锁永远不释放 |
| 解铃还须系铃人 | 只有持有锁的客户端才能释放锁 | 客户端 A 的锁被客户端 B 误删 |
| 可重入 | 同一个客户端可以多次获取同一把锁 | 递归调用时死锁 |
| 高可用 | 锁服务本身不能是单点故障 | Redis 单节点挂了,锁全失效 |
三、Redis 实现分布式锁
3.1 错误示范:这样做锁不住
java
// ❌ 错误写法:两步操作不是原子的
public boolean wrongLock(String lockKey) {
String result = redisTemplate.opsForValue().get(lockKey); // ① GET
if (result != null) {
return false; // 锁已被占用
}
// ② SET —— ①和②之间有并发窗口
redisTemplate.opsForValue().set(lockKey, "1", Duration.ofSeconds(30));
return true;
}并发场景下,两个请求同时执行:
请求 1:GET lock:order → null(锁不存在)
请求 2:GET lock:order → null(锁不存在,请求1还没执行SET)
请求 1:SET lock:order → 成功
请求 2:SET lock:order → 成功(覆盖了请求1的锁!)
两个请求都认为自己拿到了锁 💥3.2 正确版:SETNX 一条命令搞定
加锁命令(Redis 2.6.12+):
SET lock:order:1001 uuid-12345 NX PX 30000
│ │ │ │
│ │ │ └── 过期时间 30000 毫秒(30 秒,防死锁)
│ │ └── NX = Not eXists(不存在才设置,保证互斥)
│ └── 唯一标识(知道是谁加的锁,解铃还须系铃人)
└── 锁的 key
返回 OK → 加锁成功
返回 nil → 锁已被其他客户端持有java
/**
* 基础版 Redis 分布式锁——正确但只适合学习
* 生产环境请用 Redisson(3.4 节)
*/
public class SimpleRedisLock implements AutoCloseable {
private final StringRedisTemplate redisTemplate;
private final String lockKey;
private final String lockValue;
public SimpleRedisLock(StringRedisTemplate redisTemplate, String lockKey) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
// value = UUID + 线程ID,确保每个锁持有者身份唯一
this.lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
}
/**
* 加锁
* @param expireSeconds 锁过期时间(秒),防止死锁
* @return true=加锁成功 false=锁已被占用
*/
public boolean tryLock(int expireSeconds) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(success);
}
/**
* 释放锁——Lua 脚本保证原子性(判断身份 + 删除)
*/
public void unlock() {
String script =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue);
}
@Override
public void close() {
unlock();
}
}
// 使用 try-with-resources 自动释放
try (SimpleRedisLock lock = new SimpleRedisLock(redisTemplate, "lock:order:1001")) {
if (lock.tryLock(30)) {
deductStock();
} else {
throw new RuntimeException("系统繁忙,请稍后重试");
}
}3.3 为什么释放锁必须用 Lua
❌ 两步操作不是原子的——会发生误删:
时刻 1:客户端 A 执行 GET lock:order → 返回 uuid-A(匹配,是我的锁)
时刻 2:锁刚好过期,Redis 自动删除了锁
时刻 3:客户端 B 执行 SET lock:order uuid-B NX PX 30000 → 拿到了锁
时刻 4:客户端 A 执行 DEL lock:order → 删了客户端 B 的锁!💥
结果:客户端 B 以为自己持有锁,实际上锁已经被 A 删了,
C 又可以拿到锁 → A、B、C 可能同时操作共享资源
✅ Lua 原子操作——判断和删除在一条命令中完成:
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
" 1 lock:order:1001 uuid-12345
Redis 单线程执行 Lua 脚本,整个脚本执行期间不会插入其他命令,
所以"判断 + 删除"绝对不会被打断。3.4 锁过期问题与看门狗
核心矛盾:锁过期时间不好设置
设置 5 秒 → 业务执行了 6 秒 → 锁提前释放 → 其他线程拿到锁 → 并发问题
设置 60 秒 → 业务 1 秒就执行完了,但锁被占 60 秒 → 其他线程白白等待 → 性能差
设置 60 秒 → 业务执行到 5 秒时,服务突然 GC 停顿 10 秒 → 锁过期 → 同上
看门狗(Watchdog)机制:
加锁成功 → 启动看门狗线程
每 1/3 过期时间检查一次:业务线程还活着吗?
活着 → 续期锁(重置过期时间)→ 继续等待
业务完成 → 释放锁 → 停止看门狗java
public class RedisLockWithWatchdog implements AutoCloseable {
private final StringRedisTemplate redisTemplate;
private final String lockKey;
private final String lockValue;
private ScheduledExecutorService watchdog;
private volatile boolean locked = false;
public RedisLockWithWatchdog(StringRedisTemplate redisTemplate, String lockKey) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
}
public boolean tryLock(int expireSeconds) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(expireSeconds));
if (Boolean.TRUE.equals(success)) {
locked = true;
startWatchdog(expireSeconds);
}
return Boolean.TRUE.equals(success);
}
private void startWatchdog(int expireSeconds) {
watchdog = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "watchdog-" + lockKey);
t.setDaemon(true); // 守护线程,主线程退出时自动结束
return t;
});
int renewInterval = expireSeconds / 3; // 每 1/3 过期时间续期
watchdog.scheduleAtFixedRate(() -> {
if (!locked) {
watchdog.shutdown();
return;
}
// 续期:只有当 value 匹配时才续期(防止误续别人的锁)
String script =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('EXPIRE', KEYS[1], ARGV[2]) " +
"else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue, String.valueOf(expireSeconds));
if (result == null || result == 0) {
locked = false; // 续期失败,锁可能已被其他人持有
watchdog.shutdown();
}
}, renewInterval, renewInterval, TimeUnit.SECONDS);
}
public void unlock() {
locked = false;
if (watchdog != null && !watchdog.isShutdown()) {
watchdog.shutdown();
}
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), lockValue);
}
@Override
public void close() { unlock(); }
}3.5 Redisson:生产级 Redis 分布式锁
手写分布式锁容易出错,Redisson 是目前最成熟的 Redis Java 客户端,内置了分布式锁全家桶。
3.5.1 快速上手
xml
<!-- Spring Boot 3.x 最新依赖 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.37.0</version>
</dependency>yaml
# application.yml
spring:
redis:
redisson:
config: |
singleServerConfig:
address: "redis://127.0.0.1:6379"
connectionPoolSize: 64
connectionMinimumIdleSize: 24java
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void deductStock(String orderId) {
RLock lock = redissonClient.getLock("lock:order:" + orderId);
try {
// tryLock(等待时间, 锁过期时间, 时间单位)
// 等待 10 秒拿不到锁就放弃,拿到锁后 30 秒自动释放
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
doDeductStock(orderId);
} finally {
// 重要:判断锁是否仍被当前线程持有
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} else {
throw new RuntimeException("系统繁忙,请稍后重试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
}
}
}3.5.2 Redisson 看门狗机制详解
Redisson 看门狗默认行为:
加锁时没有指定 leaseTime(过期时间):
→ 默认锁 30 秒过期
→ 看门狗每 10 秒续期一次,续期到 30 秒
→ 业务执行多久,锁就持有多久,不会提前释放
加锁时指定了 leaseTime:
→ 锁按指定时间过期
→ 看门狗不启动
→ 到时间自动释放,不管业务是否执行完
// 看门狗模式(推荐):不指定过期时间,让 Redisson 自动管理
lock.tryLock(10, TimeUnit.SECONDS); // 只指定等待时间
// 固定时间模式:指定过期时间,看门狗不工作
lock.tryLock(10, 30, TimeUnit.SECONDS); // 等待 + 过期都指定Redisson 看门狗内部实现(源码级):
1. 加锁时,Redisson 在 Redis 中写入一个 Hash:
HSET lock:order:1001 <uuid:threadId> 1
PEXPIRE lock:order:1001 30000
2. 启动看门狗定时任务(基于 Netty 的 HashedWheelTimer,性能极高)
每 internalLockLeaseTime / 3 = 10 秒执行一次
3. 续期时执行 Lua 脚本:
if redis.call('HEXISTS', KEYS[1], ARGV[2]) == 1 then
return redis.call('PEXPIRE', KEYS[1], ARGV[1])
else
return 0
end
4. unlock() 时取消看门狗任务3.5.3 Redisson 可重入锁原理
java
// 可重入:同一个线程可以多次获取同一把锁
public void methodA() {
RLock lock = redissonClient.getLock("lock:order:1001");
lock.lock();
try {
methodB(); // 内部再次获取同一把锁
} finally {
lock.unlock();
}
}
public void methodB() {
RLock lock = redissonClient.getLock("lock:order:1001");
lock.lock(); // 不会死锁,因为是同一个线程
try {
// 业务逻辑
} finally {
lock.unlock();
}
}Redisson 可重入锁的数据结构(Hash):
key: lock:order:1001
field: <uuid:threadId> → value: 2 (重入计数)
第一次 lock() → value = 1
第二次 lock() → value = 2
第一次 unlock() → value = 1
第二次 unlock() → value = 0 → 删除 key,释放锁3.5.4 Redisson 锁类型全景
| 锁类型 | 接口 | 特点 | 适用场景 |
|---|---|---|---|
| 可重入锁 | RLock | 同一线程可多次获取 | 大多数场景,默认选择 |
| 公平锁 | RFairLock | 按请求顺序排队获取 | 需要公平排队,避免饥饿 |
| 读写锁 | RReadWriteLock | 读读不互斥,读写互斥 | 读多写少(如配置读取) |
| 联锁 | RMultiLock | 多把锁同时获取/释放 | 需要同时锁定多个资源 |
| 红锁 | RedissonRedLock | 多节点过半确认 | 高可用,防止主从丢锁 |
| 自旋锁 | RLock + 自旋 | 循环重试不阻塞 | 锁持有时间极短(< 1ms) |
| 信号量 | RSemaphore | 限制并发访问数 | 限流、连接池控制 |
java
// 公平锁:按请求顺序排队
RFairLock fairLock = redissonClient.getFairLock("lock:order:1001");
fairLock.lock();
// 读写锁:读读并发,读写互斥
RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:config");
// 读锁(多个线程可以同时持有)
RLock readLock = rwLock.readLock();
readLock.lock();
// 写锁(独占)
RLock writeLock = rwLock.writeLock();
writeLock.lock();
// 联锁:同时锁定多个资源
RLock lock1 = redissonClient.getLock("lock:order:1001");
RLock lock2 = redissonClient.getLock("lock:user:1001");
RMultiLock multiLock = redissonClient.getMultiLock(lock1, lock2);
multiLock.lock(); // 两把锁同时获取,全部成功才算成功
// 信号量:限制并发数
RSemaphore semaphore = redissonClient.getSemaphore("semaphore:task");
semaphore.trySetPermits(10); // 最多 10 个并发
semaphore.acquire(); // 获取一个许可
try { doWork(); }
finally { semaphore.release(); }3.5.5 AOP 注解式分布式锁(生产推荐)
java
/**
* 自定义注解:一行注解搞定分布式锁
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
/** 锁的 key,支持 SpEL 表达式 */
String key();
/** 锁前缀 */
String prefix() default "lock:";
/** 等待获取锁的时间(秒),-1 表示不等待 */
long waitTime() default 3;
/** 锁过期时间(秒),-1 表示使用看门狗 */
long leaseTime() default -1;
/** 获取锁失败时的提示信息 */
String failMessage() default "系统繁忙,请稍后重试";
}java
/**
* 分布式锁 AOP 切面
*/
@Aspect
@Component
@Order(1) // 确保在 @Transactional 之前执行
public class RedisLockAspect {
@Autowired
private RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
// 解析 SpEL 表达式生成锁 key
String lockKey = parseKey(redisLock.key(), redisLock.prefix(), joinPoint);
RLock lock = redissonClient.getLock(lockKey);
boolean acquired;
if (redisLock.leaseTime() == -1) {
// 看门狗模式
acquired = lock.tryLock(redisLock.waitTime(), TimeUnit.SECONDS);
} else {
acquired = lock.tryLock(redisLock.waitTime(), redisLock.leaseTime(), TimeUnit.SECONDS);
}
if (!acquired) {
throw new RuntimeException(redisLock.failMessage());
}
try {
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String parseKey(String keyExpr, String prefix, ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
EvaluationContext context = new StandardEvaluationContext();
String[] paramNames = discoverer.getParameterNames(method);
Object[] args = joinPoint.getArgs();
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
}
// 支持 SpEL:如 #orderId 会取方法参数 orderId 的值
Expression expression = parser.parseExpression(keyExpr);
String key = expression.getValue(context, String.class);
return prefix + key;
}
}java
// 使用:一行注解即可
@Service
public class OrderService {
@RedisLock(key = "#orderId", prefix = "lock:order:", waitTime = 5)
public void deductStock(String orderId) {
// 业务逻辑:自动加锁,方法执行完自动释放
orderMapper.deductStock(orderId);
}
@RedisLock(key = "#userId + ':' + #productId", waitTime = 3, failMessage = "请勿重复下单")
public void createOrder(Long userId, Long productId) {
// 自动加锁 + 自动释放
}
}3.6 RedLock:多节点 Redis 高可用方案
问题:主从架构下,主节点挂了,锁可能丢失
客户端 A 在主节点加锁成功
主节点还没来得及同步到从节点就挂了
哨兵将从节点提升为主节点(锁数据丢失)
客户端 B 在新主节点加锁成功
→ 客户端 A 和 B 同时持有锁!💥
RedLock 方案(至少 3 个独立 Redis 节点,非主从关系):
客户端 A:
1. 获取当前时间戳 T1
2. 依次向 Node1、Node2、Node3 请求加锁
- 每个节点超时时间远小于锁过期时间(如 50ms)
- 失败不阻塞,立即尝试下一个
3. 获取当前时间戳 T2
4. 成功条件:
✅ 超过半数节点(N/2 + 1)加锁成功
✅ 总耗时 < 锁过期时间
5. 如果成功,锁真实有效时间 = 锁过期时间 - (T2 - T1)
6. 如果失败,向所有节点发起释放java
// Redisson 的 RedLock 实现
RLock lock1 = redissonClient1.getLock("lock:order:1001");
RLock lock2 = redissonClient2.getLock("lock:order:1001");
RLock lock3 = redissonClient3.getLock("lock:order:1001");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
if (redLock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
deductStock();
} finally {
redLock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}RedLock 争议(Martin Kleppmann vs antirez 论战): Martin 认为时钟跳跃(NTP 校时)可能导致锁过期判断错误;antirez 认为时钟跳跃是极端情况,且可以通过 fencing token 解决。实际生产建议: 绝大多数场景用 Redisson 单节点 + 哨兵就够了,真正需要 RedLock 的场景极少。如果对一致性要求极高,直接用 ZK 或 Etcd(CP 系统)。
四、ZooKeeper 实现分布式锁
4.1 原理:临时顺序节点
ZooKeeper 的临时顺序节点机制:
节点类型:
- 临时节点(EPHEMERAL):客户端断开连接 → 节点自动删除
- 顺序节点(SEQUENTIAL):ZK 自动在节点名后追加递增序号
加锁流程:
① 所有客户端在 /lock 下创建临时顺序节点
② 获取 /lock 下所有子节点,按序号排序
③ 如果自己是最小的序号 → 获取锁成功
④ 如果不是最小的 → 对前一个节点注册 Watcher(监听删除事件)
⑤ 前一个节点被删除 → 收到通知 → 重新检查自己是否最小
/lock
├── /lock/_c_0000000001 ← 客户端 A(最小,持有锁)
├── /lock/_c_0000000002 ← 客户端 B(监听 0000000001)
└── /lock/_c_0000000003 ← 客户端 C(监听 0000000002)
客户端 A 释放锁(删除 0000000001)→ ZK 通知客户端 B
→ 客户端 B 成为最小的 → 获取锁 → 通知客户端 C 监听自己ZK 锁的天然优势:
✅ 防死锁:客户端断开连接,临时节点自动删除,锁自动释放
✅ 公平性:顺序节点天然排队,按请求顺序获取锁
✅ 通知机制:Watcher 主动通知,不用轮询
✅ 强一致:ZK 是 CP 系统,不会出现锁丢失4.2 Curator 实现(推荐)
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.7.1</version>
</dependency>java
@Configuration
public class CuratorConfig {
@Bean
public CuratorFramework curatorFramework() {
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("127.0.0.1:2181")
.sessionTimeoutMs(60000)
.connectionTimeoutMs(15000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 重试策略
.namespace("distributed-lock") // 命名空间隔离
.build();
client.start();
return client;
}
}java
@Service
public class OrderService {
@Autowired
private CuratorFramework curatorClient;
public void deductStock(String orderId) throws Exception {
// InterProcessMutex:可重入互斥锁
InterProcessMutex lock = new InterProcessMutex(
curatorClient, "/lock/order/" + orderId);
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
doDeductStock(orderId);
} finally {
lock.release();
}
}
} catch (Exception e) {
throw new RuntimeException("获取锁失败", e);
}
}
}4.3 Curator 提供的锁类型
| 锁类型 | 类名 | 说明 |
|---|---|---|
| 互斥锁 | InterProcessMutex | 可重入互斥锁,最常用 |
| 读写锁 | InterProcessReadWriteLock | 读读不互斥,读写互斥 |
| 信号量 | InterProcessSemaphoreV2 | 控制并发数,租约模式 |
| 多重锁 | InterProcessMultiLock | 同时管理多把锁 |
五、Etcd 实现分布式锁
Etcd 是 CoreOS 开源的分布式 KV 存储,基于 Raft 共识算法,是 Kubernetes 的核心组件。Etcd 的 Lease(租约)+ 事务机制天然适合分布式锁,是 ZK 的现代替代方案。
5.1 原理
Etcd 分布式锁机制:
① 创建 Lease(租约),设置 TTL
② 以 Lease 的身份写入一个 key(如 /lock/order/1001)
③ 利用 Etcd 的事务(Transaction)保证原子性:
- 如果 key 不存在 → 写入成功 → 获取锁
- 如果 key 已存在 → 写入失败 → 锁被占用
④ 持有锁期间,定时续租(KeepAlive)
⑤ 释放锁:删除 key 或让 Lease 过期5.2 Java 实现(jetcd)
xml
<dependency>
<groupId>io.etcd</groupId>
<artifactId>jetcd-core</artifactId>
<version>0.7.8</version>
</dependency>java
public class EtcdLock implements AutoCloseable {
private final Client client;
private final Lease leaseClient;
private final KV kvClient;
private final String lockKey;
private long leaseId;
private ScheduledExecutorService keepAliveExecutor;
public EtcdLock(String endpoints, String lockKey) {
this.client = Client.builder()
.endpoints(endpoints.split(","))
.build();
this.leaseClient = client.getLeaseClient();
this.kvClient = client.getKVClient();
this.lockKey = lockKey;
}
/**
* 加锁
* @param ttlSeconds 租约 TTL(秒)
*/
public boolean tryLock(long ttlSeconds) throws Exception {
// ① 创建租约
leaseId = leaseClient.grant(ttlSeconds).get().getID();
// ② 启动续约
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
keepAliveExecutor.scheduleAtFixedRate(() -> {
try {
leaseClient.keepAliveOnce(leaseId).get();
} catch (Exception e) {
// 续约失败,锁可能已失效
}
}, ttlSeconds / 3, ttlSeconds / 3, TimeUnit.SECONDS);
// ③ 事务写入:key 不存在才写入
ByteSequence key = ByteSequence.from(lockKey, StandardCharsets.UTF_8);
ByteSequence value = ByteSequence.from(
UUID.randomUUID().toString(), StandardCharsets.UTF_8);
Txn txn = kvClient.txn()
.If(new CmpOp(key, Cmp.Op.EQUAL, CmpTarget.createRevision(0))) // revision=0 表示 key 不存在
.Then(Op.put(key, value, PutOption.newBuilder().withLeaseId(leaseId).build()))
.Else(Op.get(key));
TxnResponse response = txn.commit().get();
return response.isSucceeded();
}
public void unlock() {
if (keepAliveExecutor != null) {
keepAliveExecutor.shutdown();
}
if (leaseId != 0) {
leaseClient.revoke(leaseId);
}
}
@Override
public void close() {
unlock();
client.close();
}
}六、四大方案全面对比
6.1 技术维度对比
| 维度 | Redis | ZooKeeper | Etcd | 数据库 |
|---|---|---|---|---|
| 算法 | 单线程 + 内存 | ZAB 协议 | Raft 协议 | 行锁 / 唯一索引 |
| 一致性 | AP(最终一致) | CP(强一致) | CP(强一致) | CP(强一致) |
| 性能(QPS) | 10 万+ | ~1 万 | ~2 万 | ~500 |
| 锁释放 | 依赖过期时间 | 临时节点自动删除 | 租约自动过期 | 依赖过期时间 |
| 公平性 | 需手动实现 | 天然支持 | 需手动实现 | 需手动实现 |
| 可重入 | Redisson 支持 | Curator 支持 | 需手动实现 | 需手动实现 |
| 运维成本 | 低 | 中 | 中 | 最低 |
| 社区活跃度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | — |
| 学习成本 | 低 | 中 | 中 | 低 |
6.2 生态对比
Redis 生态(最广泛):
✅ Redisson:功能最全,锁类型丰富,看门狗,AOP 注解
✅ Spring Integration:spring-integration-redis
✅ 几乎所有语言都有成熟客户端
ZooKeeper 生态(经典):
✅ Curator:Apache 官方高级 API,封装完善
⚠️ 年轻团队较少选择,逐步被 Etcd 替代
Etcd 生态(云原生):
✅ Kubernetes 标配,云原生生态首选
✅ jetcd:Java 官方客户端
⚠️ Java 生态成熟度不如 Redis
数据库(兜底):
⚠️ 性能最差,仅在没有 Redis/ZK/Etcd 时使用七、选型决策树
你的场景 → 推荐方案
───────────────────────────────────────────────────────────────
最普遍:已有 Redis,高并发,允许最终一致 → Redisson(单节点 + 哨兵)
→ 默认看门狗模式,不指定 leaseTime
强一致性:金融、交易、对锁丢失零容忍 → ZK(Curator)或 Etcd
→ 基于 CP 共识算法,不会丢锁
公平排队:秒杀场景,需要严格按请求顺序 → ZK 顺序节点 或 Redisson RFairLock
云原生:K8s 环境,已有 Etcd → Etcd(jetcd)
→ 不需要额外部署锁服务
已有 ZK,团队熟悉 ZK → Curator(不要新引入 ZK 仅为锁)
超高性能:锁持有时间 < 1ms,不允许等待 → Redisson 自旋锁 + 本地缓存
只有数据库,没有其他组件 → 数据库唯一索引 + 定时清理
→ 仅限遗留系统,新项目不推荐
大规模分布式:100+ 实例,极高可用 → RedLock(3 节点 Redis)
→ 或 Etcd 集群(已内置 Raft 高可用)八、Spring Boot 3.x 生产级配置
java
/**
* 分布式锁工厂:统一管理 Redis 和 ZK 锁
*/
@Configuration
public class DistributedLockConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(24)
.setRetryAttempts(3)
.setRetryInterval(1500)
.setTimeout(3000)
.setConnectTimeout(10000);
return Redisson.create(config);
}
@Bean
public CuratorFramework curatorFramework() {
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("127.0.0.1:2181")
.sessionTimeoutMs(60000)
.connectionTimeoutMs(15000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.namespace("distributed-lock")
.build();
client.start();
return client;
}
}九、常见坑与解决方案
9.1 锁过期,业务没执行完
症状:数据库偶尔出现重复数据,但频率很低
原因:锁过期时间 30 秒,业务在 GC 停顿后执行了 35 秒,
锁在 30 秒时自动释放,另一个实例拿到锁,两个实例同时操作
解决(按优先级):
1. 使用 Redisson 看门狗模式(不指定 leaseTime)
2. 优化业务逻辑,减少锁持有时间
3. 数据库层加唯一索引兜底9.2 主从切换丢锁
症状:Redis 主从切换后,短时间内出现重复操作
原因:主节点还没来得及同步锁数据到从节点就挂了
解决:
1. 业务层做幂等兜底(数据库唯一索引)
2. 使用 RedLock(3 节点独立 Redis,过半确认)
3. 改用 ZK / Etcd(CP 系统,强一致)9.3 可重入问题导致死锁
症状:递归调用或 AOP 嵌套时,线程卡死
methodA() {
lock.lock(); // 第一次加锁,成功
methodB(); // 内部调用
}
methodB() {
lock.lock(); // 第二次加锁,锁已被持有 → 不可重入锁 → 死锁
}
解决:使用 Redisson RLock 或 Curator InterProcessMutex(都支持可重入)9.4 锁粒度问题
❌ 粒度太粗——一把锁锁所有:
lock:product → 所有商品共用一个锁 → 卖 A 商品时,B 商品也要排队
✅ 粒度合适——一个商品一把锁:
lock:product:1001 → 每个商品独立锁 → 互不干扰
✅ 库存分片——秒杀场景进一步拆分:
lock:product:1001:0 → 库存分片 0
lock:product:1001:1 → 库存分片 1
...
用户 user_id % 10 → 路由到对应分片 → 并行度提升 10 倍9.5 集群脑裂(Split-Brain)
Redis 集群网络分区:
客户端 A ──→ Redis 节点 1 → 加锁成功
客户端 B ──→ Redis 节点 2 → 认为节点 1 挂了,自己成为主
→ 客户端 B 加锁成功
→ 两个客户端同时持有锁!
解决:
1. Redis 集群配置:min-replicas-to-write 1 min-replicas-max-lag 10
2. 使用 RedLock(过半确认)
3. 业务层加 fencing token(单调递增的版本号)十、分布式锁性能测试数据
测试环境:Redis 7.x 单节点,本地回环,3 个客户端并发
| 锁类型 | 加锁+释放 TPS | 平均延迟 | P99 延迟 |
|--------|-------------|---------|---------|
| Redisson RLock(看门狗) | ~25,000/s | 0.04ms | 0.5ms |
| Redisson RFairLock | ~8,000/s | 0.12ms | 2ms |
| Curator(ZK) | ~3,000/s | 0.3ms | 5ms |
| 手写 SETNX + Lua | ~50,000/s | 0.02ms | 0.2ms |
| 数据库唯一索引 | ~500/s | 2ms | 20ms |
结论:Redisson 的 RLock 性能足够 99% 的场景,手写 SETNX 只快 2 倍,
但 Redisson 多了看门狗、可重入、公平锁、读写锁等能力。
除非你确定需要极致性能且场景极其简单,否则直接用 Redisson。十一、面试要点
Q1:Redis 分布式锁怎么实现?
加锁:SET lock_key uuid NX PX 30000;释放:Lua 脚本判断 value 匹配后 DEL;续期:看门狗定时 RENEW。生产环境推荐用 Redisson,封装了看门狗、可重入、公平锁、读写锁等。
Q2:Redis 分布式锁释放时为什么要用 Lua?
GET + DEL 是两步操作,不是原子的。如果 GET 之后、DEL 之前锁过期了,另一个客户端获取了锁,就会误删别人的锁。Lua 脚本将判断和删除合并为原子操作。
Q3:Redis 和 ZK 分布式锁怎么选?
Redis 性能高(10 万+ QPS),适合高并发场景,但主从切换可能丢锁;ZK 强一致,适合金融、交易等场景,但性能较低(~1 万 QPS)。绝大多数互联网项目用 Redis + Redisson 就够了。
Q4:RedLock 解决了什么问题?有什么争议?
解决 Redis 主从切换丢锁的问题。争议在于 Martin Kleppmann 认为时钟跳跃可能导致锁失效,antirez 认为实际场景中极少发生。生产环境用 Redisson 单节点 + 哨兵 + 业务幂等兜底更实际。
Q5:分布式锁和数据库唯一索引的关系?
分布式锁是同步工具,唯一索引是数据约束。两者配合:分布式锁控制并发访问,唯一索引做最终兜底。即使锁失效了,唯一索引也能保证数据不重复。
Q6:看门狗是什么?为什么需要它?
看门狗是自动续期机制。锁过期时间不好设置(太短业务没执行完,太长浪费性能),看门狗定时检查业务是否还在执行,在则续期,业务完成则停止。Redisson 默认开启,不指定 leaseTime 即可。
Q7:Etcd 和 ZK 在分布式锁上有什么区别?
Etcd 基于 Raft,ZK 基于 ZAB,都是 CP 系统。Etcd 是 Go 写的,ZK 是 Java 写的;Etcd 是 K8s 标配,云原生生态更好;ZK 在 Java 生态更成熟(Curator 封装完善)。新项目偏向 Etcd。
