我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。
由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。
在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。
整体大纲
什么是锁?
锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。
就相当于给资源加了一扇门,获取到锁的线程可以进入门里面使用资源,获取不到锁的线程在门外排队等候。
什么是分布式锁?
提起锁我们会想到 Java
中的 synchronized
和 ReentrantLock
,但它们只适合在单应用中使用,在多应用中就不适合使用了,多应用就是现在非常流行的分布式环境。可以把 Java
中的 synchronized
和 ReentrantLock
称为本地锁。
分布式锁就是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。
怎么实现分布式锁?
分布式锁比较常见的实现方式有三种:
Memcached
实现的分布式锁:使用add
命令,添加成功的情况下,表示创建分布式锁成功。ZooKeeper
实现的分布式锁:使用ZooKeeper
顺序临时节点来实现分布式锁。Redis
实现的分布式锁。
Redis
分布式锁的实现思路是使用 setnx(set if not exists)
,如果创建成功则表明此锁创建成功,否则代表这个锁已经被占用创建失败。
分布式锁实现
127.0.0.1:6379> setnx lock true
(integer) 1 # 创建锁成功
# 逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 # 释放锁
如果在锁未被删除之前,其他程序再来执行 setnx
是不会创建成功的。
setnx 的问题
如果此程序在创建了锁之后,程序异常退出了,那么这个锁将永远不会被释放,就造成了死锁的问题。
我们可以使用 expire key seconds
设置超时时间,即使出现程序中途崩溃的情况,超过超时时间之后,这个锁也会解除,不会出现死锁的情况了。
但这样依然会有问题,因为命令 setnx
和 expire
处理是一前一后非原子性的,因此如果在它们执行之间,出现断电和 Redis
异常退出的情况,因为超时时间未设置,依然会造成死锁。
可以使用 LUA
脚本来解决这个问题。示例:
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
// 使用实例
EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100
带参数的 Set
因为 setnx
和 expire
前后操作存在原子性的问题,这个问题到 Redis 2.6.12
时得到了解决,因为这个版本可以使用 set
并设置超时和非空判定等参数了。
127.0.0.1:6379> set lock true ex 30 nx
OK # 创建锁成功
127.0.0.1:6379> set lock true ex 30 nx
(nil) # 在锁被占用的时候,企图获取锁失败
ex n
为设置超时时间,nx
为元素非空判断,用来判断是否能正常使用锁的。
分布式锁的问题
执行中的极端问题,和释放锁极端问题,我们依旧要考虑。
锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
通过在 value
中设置当前线程加锁的标识,在删除之前验证 key
对应的 value
判断锁是否是当前线程持有。可生成一个 UUID
标识当前线程,使用 LUA
脚本做验证标识和解锁操作。
// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:
- 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。
如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis
可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
在本地记录记录重入次数,如 Java 中使用 ThreadLocal 进行重入次数统计,简单示例代码:
private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
Map<String, Integer> lockers = LOCKERS.get();
if (lockers.containsKey(key)) {
lockers.put(key, lockers.get(key) + 1);
return true;
} else {
if (SET key uuid NX EX 30) {
lockers.put(key, 1);
return true;
}
}
return false;
}
// 解锁
public void unlock(String key) {
Map<String, Integer> lockers = LOCKERS.get();
if (lockers.getOrDefault(key, 0) <= 1) {
lockers.remove(key);
DEL key
} else {
lockers.put(key, lockers.get(key) - 1);
}
}
本地记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis
一致性的问题,就会增加代码的复杂性。另一种方式是 Redis Ma
p 数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。Redission
加锁示例:
// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
// 设置 lock_key 线程标识 1 进行加锁
redis.call('hset', KEYS[1], ARGV[2], 1);
// 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
// 自增
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);
无法等待锁释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
- 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
- 另一种方式是使用
Redis
的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。如下:
集群
主备切换
为了保证 Redis
的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis
将指令记录在本地内存 buffer
中,然后异步将 buffer
中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。
在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。
集群脑裂
集群脑裂指因为网络问题,导致 Redis master
节点跟 slave
节点和 sentinel
集群处于不同的网络分区,因为 sentinel
集群无法感知到 master
的存在,所以将 slave
节点提升为 master
节点,此时存在两个不同的 master
节点。Redis Cluster
集群部署方式同理。
当不同的客户端连接不同的 master
节点时,两个客户端可以同时拥有同一把锁。如下:
代码实战
import org.apache.commons.lang3.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import utils.JedisUtils;
import java.util.Collections;
public class LockExample {
static final String _LOCKKEY = "REDISLOCK"; // 锁 key
static final String _FLAGID = "UUID:6379"; // 标识(UUID)
static final Integer _TimeOut = 90; // 最大超时时间
public static void main(String[] args) {
Jedis jedis = JedisUtils.getJedis();
// 加锁
boolean lockResult = lock(jedis, _LOCKKEY, _FLAGID, _TimeOut);
// 逻辑业务处理
if (lockResult) {
System.out.println("加锁成功");
} else {
System.out.println("加锁失败");
}
// 手动释放锁
if (unLock(jedis, _LOCKKEY, _FLAGID)) {
System.out.println("锁释放成功");
} else {
System.out.println("锁释放成功");
}
}
/**
* @param jedis Redis 客户端
* @param key 锁名称
* @param flagId 锁标识(锁值),用于标识锁的归属
* @param secondsTime 最大超时时间
* @return
*/
public static boolean lock(Jedis jedis, String key, String flagId, Integer secondsTime) {
SetParams params = new SetParams();
params.ex(secondsTime);
params.nx();
String res = jedis.set(key, flagId, params);
if (StringUtils.isNotBlank(res) && res.equals("OK"))
return true;
return false;
}
/**
* 释放分布式锁
* @param jedis Redis 客户端
* @param lockKey 锁的 key
* @param flagId 锁归属标识
* @return 是否释放成功
*/
public static boolean unLock(Jedis jedis, String lockKey, String flagId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(flagId));
if ("1L".equals(result)) { // 判断执行结果
return true;
}
return false;
}
}