分布式锁(1) redisson

从伪超杀问题的解决思路及问题演变理解Redisson加锁机制

场景

  • 功能: 秒杀 –> 高并发

  • 架构: nginx + multiple TomCat + Redis Database (Redis中存一简单KV, K = 商品名, V = 库存)

  • Note: 均为伪场景, 实际秒杀问题并非这样设计

V1

1
2
3
4
5
6
7
8
9
10
11
12
public string deductStock(){
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // 得到stock数量
if ( strock > 0){
int realStrock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStrok + ""); // 写回
}
else{
库存不足;
}

return "end";
}

问题

单机内,TomCat集群皆无锁。

线程1 得到stock = 200 时, 线程2 也可能得到stock = 200, 两个线程操作后正常数量应为198, 但是均写回了199。

V2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public string deductStock(){
synchronized(this){
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // 得到stock数量
if ( strock > 0){
int realStrock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStrok + ""); // 写回
}
else{
库存不足;
}
}


return "end";
}
  • evolution1: added Synchronized keyword

  • 问题:

    单机内实现了并发安全, 但集群下无效,因为多个TomCat同一时刻下均各自出来一条线程访问redis, 此时依然有线程安全问题

V3

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
public string deductStock(){

String lockKey = "lockKey";

// 此处不可将设置key与设置超时两个步骤写成两句代码, 因为无法保证原子性, 如设置了key以后宕机了, 那么时间设置就丢失了。
Boolean result = StringRedisTemplate.opsForValue().setIfAbscent(lockKey, "test", 10, TimeUnit.Seconds);

if (! result)
retrun "error";

try{
nt stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // 得到stock数量
if ( strock > 0){
int realStrock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStrok + ""); // 写回
}
else{
库存不足;
}
}
finally{
stringRedisTemplate.delete(lockKey);
}
i



return "end";
}
  • evolution2:

    • 利用 redis的单线成特性, 使用setnx指令, 使各个线程串行的取set一个key, 如果set成功则代表抢锁成功, 此时没抢到锁的线程做自旋等待。
    • 同时需将key设置expire时间, 其目的是防止程序跑到finally前宕机而无法释放这个key, 也即无法释放锁导致死锁。
  • 问题:

    • 线程实际运行时间超过设置的expire时间, 一个线程还未结束时就释放了锁, 此时线程2拿到锁, 线程2运行过程中线程1运行结束调用finally代码, 从而删除了这个key,也即释放了这个锁。 但问题是此时这个被删除的锁其实是线程2的。 –> 这个现象称为永久失效?

V4

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
public string deductStock(){

String lockKey = "lockKey";
String clientID = ".."
// 此处不可将设置key与设置超时两个步骤写成两句代码, 因为无法保证原子性, 如设置了key以后宕机了, 那么时间设置就丢失了。
Boolean result = StringRedisTemplate.opsForValue().setIfAbscent(lockKey, clientID, 10, TimeUnit.Seconds);

if (! result)
retrun "error";

try{
# 更新内存并写回
}
finally{

if ( clientID.equals(redis.get(LockKey)))
{
stringRedisTemplate.delete(lockKey);
}

}
i



return "end";
}
  • Evolution:
    • V3的问题是一个线程删除了不属于自己的锁, 也即不属于自己的key, 所以V4将key的value设置成线程唯一的, 再删除key之前先判断这个key是不是自己的
  • 问题:
    • 依然会出现一个线程删除了不属于自己的key的情况。 如: key的超时时间为10s, 即10s后一个key会被自动删除。 假设在第9.99999s时, 一个线程运行了finally 中的if条件, 判断if为true, 所以开始执行delete操作。 而在执行delete时, 已经是第10.000001s。 这意味着该线程的key其实已经被自动删除了, 而此时再此执行delete, 则删除的还是另一个线程的锁,即key。
  • 该问题原因:
    • 所有线程都用了同一个名称的key,即锁。 –> 无法解决
      • 因为,利用redis实现分布式线程安全的本质是利用了“SETNX”, 这意味着所有线程必须使用同一个名称的KEY。
    • 一个线程还未正常运行结束时, key就超时被自动删除了 –> 可解决: 线程运行中为其持有的key续时间, which is Redisson did。

Redisson Lock

Redisson Lock即帮助自动的为一个线程持有的锁“续命”, 从而避免出现误删其他线程的key的问题。

总结 Redisson Lock工作原理本质:

  • 利用SETNX的特点(实际源码中并不是这么写, 但原理是一样的), 即判断一个key是否存在, 存在即不可持有, 不存在则可更新value, 来实现给线程分发锁。
  • 使每个线程持有的key中的value唯一, 来实现防止误删的问题。
  • 使每个线程持有的key自动续命, 来实现防止误删的问题。
  • Redisson Lock的源码中大量使用Lua脚本语言来保证操作的原子性。

Extra: 如何提高并发效率?

上述场景中, 所有操作时串行的,例如stock=1000, 那么每个线程都需排队操作者1000个库存。 效率低下。

若想提高并发效率, 可将stock分段, 如分成stock_001 = 200, stock_002 = 200 …. , 如此并发效率便提高五倍。 同时服务器压力也会增大。 即分段粒度越高, 并发效率越高。