分布式应用中常见的并发问题
以修改用户的状态为例,一个操作将会拆分成三个步骤:
- 读出用户的状态。
- 在内存里进行修改操作。
- 改完之后存回去。
读取和保存两个操作并不是原子的,多线程情况下,会出现并发问题。
可以使用分布式锁来限制程序的并发执行。
分布式锁的使用
使用setnx
命令,set if not exist
,只允许被一个客户端占坑,用完之后,调用del
指令释放。
> setnx lock true
(integer) 1
# do something
> del lock
(integer) 1
其中存在问题:假设do something发生异常导致del指令没有被调用,这样就会形成死锁,锁永远都不会释放。
那如何解决呢?我们可以在拿到锁之后,设定一个过期时间比如10s,保证10s内锁自动释放。
> setnx lock true
> expire lock 10
# do something
> del lock
可是,这个操作包含setnx和expire两条指令,不是原子指令。如果setnx成功,expire失败了,还是会导致死锁。
从 Redis 2.6.12 版本开始, set命令的行为可以通过一系列参数来修改,从替代:setnx,setex,psetex等:
EX second
:设置键的过期时间为second
秒。SET key value EX second
效果等同于SETEX key second value
。PX millisecond
:设置键的过期时间为millisecond
毫秒。SET key value PX millisecond
效果等同于PSETEX key millisecond value
。NX
:只在键不存在时,才对键进行设置操作。SET key value NX
效果等同于SETNX key value
。XX
:只在键已经存在时,才对键进行设置操作。
也就是说我们可以使用:
> set lock true ex 10 nx
# do something
> del lock
将setnx和expire组合成一条原子指令,就是分布式锁的奥义所在。
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。
解决方案:
为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于delifequals这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。
逻辑如下:
# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。
public class RedisWithReentrantLock {
private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();
private Jedis jedis;
public RedisWithReentrantLock(Jedis jedis){
this.jedis = jedis;
}
private boolean _lock(String key) {
return jedis.set(key, "", SetParams.setParams().nx().ex(5)) != null;
}
private void _unlock(String key) {
jedis.del(key);
}
public boolean lock(String key) {
Map<String, Integer> refs = currentLocker();
Integer refCnt = refs.get(key);
if (refCnt != null) {
refs.put(key, refCnt + 1);
return true;
}
boolean ok = _lock(key);
if (!ok) {
return false;
}
refs.put(key, 1);
return true;
}
public boolean unlock(String key) {
Map<String, Integer> refs = currentLocker();
Integer refCnt = refs.get(key);
if (refCnt == null) {
return false;
}
refCnt -= 1;
if (refCnt > 0) {
refs.put(key, refCnt);
} else {
refs.remove(key);
this ._unlock(key);
}
return true;
}
private Map<String, Integer> currentLocker() {
Map<String, Integer> refs = lockers.get();
if (refs != null) {
return refs;
}
lockers.set(new HashMap<>());
return lockers.get();
}
public static void main(String[] args) {
Jedis jedis = new Jedis();
RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
System.out.println(redis.lock("codehole"));
System.out.println(redis.lock("codehole"));
System.out.println(redis.unlock("codehole"));
System.out.println(redis.unlock("codehole"));
}
}
客户端加锁失败
客户端在处理请求时加锁没加成功怎么办?一般有三种解决方案:
- 直接抛出异常,通知用户稍后重试。
- sleep一会,再重试
- 将请求转移至延时队列,一会再试。
我们分别看看三种方案:
直接抛出特定类型的异常
这种方式比较适合由用户直接发起的请求,用户看到错误对话框后,会先阅读对话框的内容,再点击重试,这样就可以起到人工延时的效果。如果考虑到用户体验,可以由前端的代码替代用户自己来进行延时重试控制。它本质上是对当前请求的放弃,由用户决定是否重新发起新的请求。
sleep一会,再重试
sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。如果碰撞的比较频繁或者队列里消息比较多,sleep 可能并不合适。如果因为个别死锁的 key 导致加锁不成功,线程会彻底堵死,导致后续消息永远得不到及时处理。
延
延时队列
这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突。