分布式锁

分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁

常见的分布式锁实现

  • MySQL
  • Redis
  • Zookeeper
  • etcd

比较

锁类型 互斥 高可用 高性能 安全性
MySQL 本身的互斥锁机制 一般 断开连接,自动释放锁
Redis 通过 SETNX 实现 过期时间,自动释放锁
Zookeeper 节点唯一性与有序性实现互斥 一般 临时节点,断开连接,自动释放锁

Redis 分布式锁

  • 获取锁

互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败则返回false

1
2
3
4
5
# 添加锁 利用 SETNX 的互斥特性
SETNX lock_key lock_value

# 添加锁过期时间 避免服务器宕机引起的死锁
EXPIRE lock_key 10

p.s. 如果添加锁后服务器立刻宕机,没有成功设置过期时间,锁会一直存在,导致死锁

因此需要保证添加锁和设置过期时间是原子操作,可以使用 Redis 的 SET 命令来实现原子操作

1
2
# 原子操作 NX是互斥 EX是设置过期时间
SET lock_key lock_value NX EX 10
  • 释放锁

    • 手动释放
    • 超时释放:获取锁时添加过期时间
1
2
# 释放锁 
DEL lock_key

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ILock.java
public interface ILock {

/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期自动释放
* @return true代表获取锁成功
*/
boolean tryLock(long timeoutSec);

/**
* 释放锁
*/
void unLock();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// SimpleRedisLock.java
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;

public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}

private static final String KEY_PREFIX = "lock:";

@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
long threadId = Thread.currentThread().getId();
// 获取锁
boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 避免自动拆箱出现空指针异常
}

@Override
public void unLock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

修改原来的一人一单代码,改为使用分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public Result seckillVoucher(Long voucherId) {
...

// 创建锁对象 锁定用户id (分布式锁)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(1200);
// 判断获取锁是否成功
if (!isLock) {
// 获取锁失败, 返回错误
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unLock();
}

// // 分布式情况下,会有并发问题
// synchronized (userId.toString().intern()) {
// // 获取代理对象(事务)
// IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// // 如果不获取代理对象,会调用this.func导致事务失效
// // 因为spring通过代理模式来实现事务,如果直接调用this.func,那么就不会走代理,导致事务失效
// return proxy.createVoucherOrder(voucherId);
// }
}

新的问题

如果一个线程获取锁后,执行了很长时间,导致锁过期了,那么其他线程就会获取到锁,导致误删其他锁,引发数据不一致,这就是业务堵塞导致锁过期的问题。

解决方案:

  1. 在获取锁时存入线程标识(可以用UUID),因为不同的jvm可能会存在相同的线程标识,需要进一步区分,以保证全局唯一性

  2. 在释放锁时,判断当前线程标识是否和锁中的线程标识一致,如果一致则释放锁,否则不释放锁

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;

public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}

private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 避免自动拆箱出现空指针异常
}

@Override
public void unLock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的线程标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}

这样可以避免因为业务堵塞导致锁过期而引发的误删锁问题

新的问题

如果一个线程获取锁后,已经进行完释放锁前的判断标识操作,此时如果因为gc阻塞了,导致锁过期了,那么其他线程就会获取到锁,导致释放了其他线程的锁,引发数据不一致,这就是标识判断与释放锁不是原子操作的问题。

注意,因为已经进行了判断标识操作,所以之后会直接释放其他线程的锁

alt text

解决方案:

使用Lua脚本来实现原子操作

Redis Lua脚本

Redis 提供了 Lua 脚本的支持,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。