分布式锁的实现之 redis 篇
Redis(3)——分布式锁深入探究
《Redis深度历险 核心原理与应用实战》
目录
一、分布式锁
我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。
- 在单服务器系统我们常用本地锁来避免并发带来的问题
- 然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。
一般情况下,我们使用分布式锁主要有两个场景:
- 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
- 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;
基于锁的本质:同一时间只允许一个用户操作。所以理论上,能够满足这个需求的工具我们都能够使用:
- 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁 for update 关键字,也可以自己实现悲观/乐观锁来达到目的;
- 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
- 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 SETNX(set if not exists) 这样的指令,本身具有互斥性;
每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 锁超时、加事务 等,并且性能局限于数据库,诸如此类我们在此不作讨论,重点关注 Redis。
Redis分布式锁的实现——setnx
Redis 锁主要利用 Redis 的 setnx 命令。
- 加锁:SETNX key value
当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。 - 解锁:DEL key
通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。 - 锁超时:EXPIRE key timeout
设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
加锁解锁伪代码:
//尝试加锁
if (setnx(key, 1) == 1){
//设置锁过期时间
expire(key, 30)
try {
//TODO 业务逻辑
} finally {
//解锁
del(key)
}
}
问题1. setnx与expire是两条指令不能保证原子性
如果在setnx与expire之间服务器进程突然挂掉了,可能是因为机器掉线或者是人为造成的,就会导致expire得不到执行,也会造成死锁。
这种问题的根源就在于setnx与expire是两条指令而不是原子指令。
有很多分布式锁的开源代码专门用来解决这个问题,如使用Lua脚本将两个指令原子化执行等。但再Redis 2.8版本中,作者加入了set指令的扩展参数,使得两个指令可以一起执行,彻底解决了分布式锁的乱象。
set lock:myKey true ex 5 nx
问题2. 超时问题
超时导致锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
为了解决这一问题,可以将锁的 value 值设置为一个随机数(生成一个UUID来作为当前线程的标志),释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期了而被服务器自动释放的。
但是匹配 value 和删除 key 在 Redis 中并不是一个原子性的操作,也没有类似保证原子性的指令,所以可能需要使用像 Lua 这样的脚本来处理了,因为 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
超时导致并发
上述加锁方式也不是一个完美的方案,它只是相对安全一点。如果加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,而同时第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格串行执行。
- 为了避免这个问题,Redis分布式锁不要用于较长时间的任务。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。
- 还有一种方式是为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
问题3. 可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的,如Java中的ReentrantLock。
借助线程ThreadLocal
Redis分布式锁如果要支持可重入,需要对客户端的set方法进行包装,使用线程的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)) {
//如果当前线程ThreadLocal中已记录了此key的锁,那么锁计数+1
lockers.put(key, lockers.get(key) + 1);
return true;
} else {
if (SET key uuid NX EX 30) {
//当前线程之前没有持有锁,如果加锁成功,则存入ThreadLocal
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);
}
}
使用Hash结构
ThreadLocal记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis 一致性的问题,就会增加代码的复杂性。
另一种方式是使用Redis Hash数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。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]);
问题4. 集群环境下RedLock
为了保证 Redis 的可用性,一般采用主从方式部署。在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。
当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。
这种不安全也仅在主从发生failover的情况下才产生,而且持续时间极短,业务系统多数情况可以容忍。
RedLock
可以使用RedLock解决这个问题,它的流程比较复杂,不过已经有很多开源的library做了良好的封装,用户可以拿来即用。如redlock-py:
import redlock
addrd = [{
"host" : "localhsot",
"port" : 6379,
"db" : 0
},{
"host" : "localhsot",
"port" : 6479,
"db" : 0
},{
"host" : "localhsot",
"port" : 6579,
"db" : 0
}]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lock",5000)
if success:
print 'lock success'
dlm.unlock('user-lock')
else
print 'lock failer'
为了使用RedLock,需要提供多个Redis实例,这些实例之间相互独立,没有主从关系。同很多分布式算法一样,RedLock也使用“大多数机制”。
加锁时,它会向过半节点发送set(key,value,nx=TRUE,ex=xxx)指令,只要过半节点set成功,就认为它加锁成功。释放锁时,要向所有的节点发送del指令。
如果你很在乎高可用性,希望即使挂了一台Redis也完全不受影响,就应该考虑RedLock。不过代价也是有的,需要更多的Redis实例,性能也下降了,代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前需要再三斟酌。
二、延时队列
我们平时习惯于使用RabbitMQ和Kafka作为消息队列中间件,在应用程序之间增加异步消息传递功能。对于那些只有一组消费者的消息队列,使用Redis可以非常轻松地实现。但Redis不是专业的消息队列,它没有非常多的高级特性,没有ack保证,如果对消息的可靠性有极高要求,那么它就不适合使用。
异步消息队列实现——List列表结构
Redis的List数据结构常用来作为异步消息队列使用,用rpush和lpush操作入队,用lpop和rpop操作出队。它可以支持多个生产者和多个消费者并发进出消息,每个消费者拿到的消息都是不同的列表元素。
问题1. 队列空了怎么办
如果队列空了,客户端就会进入pop的死循环,这就是浪费生命的空轮询,不但拉高了客户端的CPU消耗,Redis的QPS也会被拉高。如果这样的空轮询客户端有几十个,Redis慢查询可能会显著增多。
通常我们使用sleep解决,无数据时sleep 1s。
阻塞读
用睡眠的方法虽然可以解决问题,但是会导致消息的延迟增大,如果只有1个消费者,延迟就是1s。
阻塞读的命令为blpop和brpop,前缀字符b代表的是blocking。
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来,消息的延迟几乎为零。
延时队列实现——Zset有序列表结构
延时队列可以通过Redis的zset来实现,我们将消息序列化成一个字符串作为zset的value,这个消息的处理时间作为score,然后用多个线程轮询zset获取到过期的任务进行处理。多个线程是为了保证可用性,即使挂了一个线程还有其他线程可以继续处理。因为有多个线程,需要考虑并发争抢任务,确保任务不会被多次执行。
public void lpop(){
while(!Thread.interrupted()){
//获取 以score排序,也就是执行时间离当前时间最近的、一个元素
Set<String> values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
if(values.isEmpty()){
Thread.sleep(500);
continue;
}
// 拿第一条,也只有一条
String s = values.iterator().next();
if(jedis.zrem(queueKey, s) > 0){
// 多线程并发的可能,最终只有一个进程可以抢到消息
TaskItem<T> task = JSON.parseObject(s, TaskType);
this.handleTask(task);
}
}
}
Redis的Zrem方法是多线程争抢任务的关键,他的返回值决定了当前实例有没有抢到任务。
三、限流
1. 简单限流——Zset结构
首先我们来看一个常见的、简单的限流策略。系统要限定用户的某个行为在指定的时间里只允许发生N次,如果使用Redis的数据结构来实现这个功能?
这个限流需求中存在一个滑动时间窗口,我们可以用zset数据结构的score值来圈出这个窗口,而value只需要保证唯一性即可,用uuid会比较浪费空间,那就改用毫秒时间戳吧。
如图,用一个zset结构记录用户的行为历史,每一个行为都会作为zset中的一个key保存下来,同一个用户的同一种行为用一个zset记录。
通过统计滑动窗口内的行为数量与阈值max_count进行比较,就可以得出当前的行为是否被允许。
public class RedisRateLimit {
private Jedis jedis;
public RedisRateLimit(Jedis jedis) {
this.jedis = jedis;
}
public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) throws IOException {
String key = String.format("hits:%s:%s", userId, actionKey);
long nowTs = System.currentTimeMillis();
Pipeline pipeline = jedis.pipelined();
pipeline.multi();
// 存储——key:用户行为、score:时间
pipeline.zadd(key, nowTs, "" + nowTs);
// 查询——key:用户行为、条件:score(当前时间-5s,当前时间)
pipeline.zremrangeByScore(key, 0, nowTs - period * 1000);
// 获取数量
Response<Long> count = pipeline.zcard(key);
pipeline.expire(key, period + 1);
pipeline.exec();
pipeline.close();
return count.get() <= maxCount;
}
public static void main(String[] args) throws IOException {
Jedis jedis = new Jedis();
RedisRateLimit redisRateLimit = new RedisRateLimit(jedis);
for (int i = 0; i < 20; i++) {
System.out.println(redisRateLimit.isActionAllowed("userId", "action-1", 60, 5));
}
}
}
整体思路是:没一个行为到来时,都维护一次时间窗口,将时间窗口外的记录全部清理掉,值保留时间窗口内的记录。因为这几个连续的操作都是针对同一个key的,使用pipeline可以显著提升Redis存储效率。
但是这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这个量很大,比如"限定60s内操作不得超过100万次"之类,它是不适合这样做限流的,因为会消耗大量的存储空间。
2. 漏斗限流——Redis-cell
漏斗限流是最常用的限流方法之一,漏斗的容量是有限的,如果将漏嘴堵住,然后一直往里灌水,它就会变满然后再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后就又可以继续往里面灌水。
如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果如果漏嘴流水的速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾出一部分空间。
- 漏斗的剩余空间:代表当前行为可以持续进行的数量
- 漏嘴的流水速率:代表系统允许改行为的最大频率
漏斗算法的Java实现:
public class RedisFunnelRateLimit {
static class Funnel {
int capacity; //容量
float leakingRate; //漏嘴流速
int leftQuota; //剩余容量
long leakingTs; //初始时间
public Funnel(int capacity, float leakingRate) {
this.capacity = capacity;
this.leakingRate = leakingRate;
this.leftQuota = capacity;
this.leakingTs = System.currentTimeMillis();
}
// 计算漏水量
void makeSpace() {
long nowTs = System.currentTimeMillis();
long deltaTs = nowTs - leakingTs;
// 前一段时间内的流水容量:时间差*速度
int deltaQuota = (int) (deltaTs * leakingRate);
// 间隔时间太长,整数数字过大溢出,则恢复漏洞原样
if (deltaQuota < 0) {
this.leftQuota = capacity;
this.leakingTs = nowTs;
return;
}
// 腾出空间太小,最小单位是1
if (deltaQuota < 1) {
return;
}
// 修正剩余容量
this.leftQuota += deltaQuota;
// 记录当前时间、下一次计算时做差值
this.leakingTs = nowTs;
if (this.leftQuota > this.capacity) {
this.leftQuota = this.capacity;
}
}
//灌水
boolean watering(int quota) {
// 在每一次灌水之前,都计算一下 前一段时间,用了多少容量
makeSpace();
if (this.leftQuota >= quota) {
this.leftQuota -= quota;
return true;
}
return false;
}
}
private Map<String, Funnel> funnels = new HashMap<>();
public boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) {
String key = String.format("%s:%s", userId, actionKey);
Funnel funnel = funnels.get(key);
if (funnel == null) {
funnel = new Funnel(capacity, leakingRate);
funnels.put(key, funnel);
}
return funnel.watering(1);
}
}
在每次灌水前都会调用makeSpace方法触发漏水,计算出当前的剩余空间。能腾出多少空间取决于过去了多久以及流水的速率。
如果把Funnel对象的几个字段存储到一个hash结构中,计算完最新值再回填到hash结构,就完成了一次行为频度的检测。但是从hash结构中取值、在内存中运算、回填到hash结构,这三个过程无法原子性,意味着需要适当的加锁,而一旦加锁,就意味着有加锁失败的可能,加锁失败就需要选择重试或放弃,将导致性能下降。
Redis-Cell
Redis 4.0提供了一个限流模块Redis-Cell,该模块使用了漏斗算法,并提供了原子的限流指令。
// 15: capacity容量
// 30、60:每30s最多进行操作60次,速率:30/60
key:reply 15 30 60
返回值:
0 // 0表示运行,1表示拒绝
15 // 漏洞容量
14 //漏洞剩余容量
-1 // 如果被拒绝了,需要多长时间后再重试
2 // 多长时间后,漏洞完全空出来
在执行限流指令时,如果被拒绝了,就需要丢弃或重试。指令已经将重试时间都帮你算好了,直接取返回结果的第四个值进行sleep即可。