个人 blog: iyuhp.top
原文链接: Redis Lock
Redis Lock
当我们想通过 redis 涉及一个分布式锁时,第一时间必然是想到 strings set
命令:
set r_lock random_value PX 10000 NX
- r_lock : key , 锁名称,可以是任何一个值
- random_value : 客户端设置锁的时候给定的一个 unique 的值
- PX : 过期时间 , 毫秒, 这里为 10 s
- NX : not exist ,即当 key r_log 不存在时才会成功
当 client 获取锁时,尝试该命令,如果成功表明获得锁。
这样实现有什么问题呢?
-
client A 获得锁后, 由于业务处理时间过长或其他原因,未及时释放锁,超时后被自动释放,client B 获取到锁,此时 A 业务处理完成,释放锁, 执行
del key r_lock
, client B 的锁被删除... -
client 获得锁后,直接挂掉 ...
-
client A 获取锁释放后,其他等待的 client 不能及时知道这个信息,他们仍然再等待一个固定的轮询时间
-
对同一个线程而言,应该有重入的机制
-
client A 获取锁,设置超时时间 15s ,但是实际业务处理时间为 20s。虽然 client A 一切正常,但是在 15s 后,锁仍然被释放掉了...
-
redis cluster 模式下,client c1 从 redis server A 处获得锁,A 宕机,数据还未同步给 slave_A, slave_A 被选为 master,当 client c2 尝试获取锁时,成功获得锁...
对于第一个问题,我们通过 random_value 可以解决。每个尝试释放锁的动作,都需要先校验设置的 value 值,如果一致,才允许 del
操作。所以当 A 尝试释放锁的时候,因为 value 早已改变,它就无法删除属于 B 的锁了。这个删除动作,应该是一个原子操作,可以用下面的 lua 脚本来描述:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
对于问题二,如果没有其他措施,则会形成死锁。我们可以通过给锁加上过期时间,来解决这个问题。即便 client 掉线,锁时间到期后,会被 redis 删除,其他 client 可以继续获取锁,这里通过 set ... PX 10000
来设置过期时间
对于问题三,可以理解为对取锁机制的优化,在 redisson 中,通过 pub/sub 来解决这个问题。redisson 在第一次尝试获取锁的时候,如果没有获取到,就会监听 redisson_lock__channel:{r_lock}
这个 channel。
获取到锁的 client 在用完锁后,会 publish 一条消息 PUBLISH redisson_lock__channel:{_lock_} 1
, 等待的 client 收到消息后,就可以继续竞争,而不用等到下一次的轮询到来才开始
对于问题四,可以认为是一个类似 JAVA ReentrantLock
, 在 redisson 的实现中,并非通过 set
原语而是 hset
, field 是当前线程的 id ,这样的话,当通过一个线程来获取锁的时候,我就给 field 的 value 加一,而释放锁的时候,一直减一,直到为 0 时,删除该 key 即可。
对于问题五, 在 redisson 中,通过开启一个 Timer 来处理过期问题:
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
// 该方法最终会开启一个定时 Timer 用来处理 key 过期问题
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
可以看到,当 leaseTime 不为 -1 时 ,也就是我们执行 lock() 方法时,设置了超时时间,如 lock.lock(15, TimeUnit.SECONDS);
时,redisson 不会对 key 延时。
不设置时,则会最终执行如下方法:
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// ...
RFuture<Boolean> future = renewExpirationAsync(threadId);
// ...
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
internalLockLeaseTime
(可配置 cfg.setLockWatchdogTimeout(45 * 1000L);
) 默认为 30s, 这里取 1/3 即 10s (此时则为 15s),即每 10s( 15s ) 执行一次,直到当前线程执行完毕
对于最后一个问题,redis 给出了一个叫做 redLock 的算法,参见 这里 , 在 redisson 中,对应的实现为 redLock
。
该算法认为,为了避免问题六,我们每次获取锁的时候,都需要获取集群中超过一半 master 的锁,也就是需要在超过一半的 master 上申请锁,只有超过半数,本次获取锁的动作才认为成功。
不过这种做法实现起来很麻烦, 虽然 redisson 给出了实现,但实际使用依旧繁琐。如果无法容忍这样的极小概率事件,可以适当考虑下 zk 或其他更加可靠的分布式锁。
LRU
即 least recently used, 最近最少使用。常用的中间件,基本都会有自己的 LRU 算法,用来回收内存。
redis 中可以通过
maxmemory 100m
这个配置来限制内存的使用,在 64 位系统,默认为 0 ,即不限制内存,在 32 位系统为 3G
当达到指定的内存限制大小时,redis 通过不同的策略做出不同的回收行为,该策略可以通过
maxmemory-policy
来配置, redis 提供了以下可选项:
- noeviction:不驱逐,即不回收。此时 redis 会对增加内存的操作返回错误,del 等操作能正常执行
- allkeys-lru:对所有的 key 进行 LRU
- volatile-lru:只针对设置了过期时间的 key 进行 LRU
- allkeys-random:随机清除所有 key 集合中的部分
- volatile-random:随机清除设置了过期时间 key 中的部分
- volatile-ttl:清除设置了过期时间的 key 集合中 TTL (存活时间) 较短的部分
为 key 设置过期时间,也会消耗内存,所以使用 allkeys-lru
会更加高效
redis 中的 LRU 不是完整的 LRU ,它并不会根据策略取扫描所有的 key ,而是扫描样本数量的 key ,进行 LRU。样本数量可以进行配置:
maxmemory-samples 5 # 5 是 redis 默认值
这样做虽然不能真正做到 LRU ,但是能减少内存的消耗
我们可以通过 config set maxmemory-samples 10
来在运行时调整该值大小。 10 个样本,根据 redis 给出的数据,已经十分接近真实的 LRU
现在我们实际操作下:
➜ scripts-redis ./run.sh cli
Connecting to redis server...
Connected to 7000
127.0.0.1:7000>
127.0.0.1:7000>
127.0.0.1:7000> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
127.0.0.1:7000> CONFIG set maxmemory-policy allkeys-lru
OK
127.0.0.1:7000> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
可以看到,此时集群的 maxmemory-policy
已经变成了 allkeys-lru
我们可以通过 redis 提供的 lru test 工具来测试:
redis-cli -a 123456 -p 7000 --lru-test 1000000
本来想贴下跑完后的日志,结果...
➜ scripts-redis sudo grep "Out of memory" /var/log/messages
[sudo] password for dylan:
Apr 15 12:12:11 iZbp10xxkuq2vc7q4vabqtZ kernel: Out of memory: Killed process 16397 (redis-cli) total-vm:452400kB, anon-rss:431280kB, file-rss:0kB, shmem-rss:0kB
嗯,1G 内存,除去其他进程占用,实际只有 450M 左右可用... 我不跑了!
有兴趣可以自己跑一把,看看大概的 miss 是多少
Expires
redis 常用的关于过期的命令
-
expire key seconds :设置指定 秒 后过期: expire key 10
-
pexpire key milliseconds : 设置指定 毫秒 后过期: expire key 10000
-
expireat key seconds-timestamp : 设置指定 时间戳 后过期,时间戳以秒计算 : expireat key 1586868680
-
pexpiread key milliseconds-timestamp : 设置指定 时间戳 后过期,时间戳以毫秒计算 : expireat key 1586868680000
-
persist key : 取出一个 key 的过期时间 : persist key
-
TTL / PTTL key : 查看 key 的过期时间
- -1 : key 存在且未设置过期时间
- -2 : key 不存在或已过期
redis 什么时候会去回收 key ?
- 每一次访问 key 的时候,会先判断 key 是否过期,过期就删除,也就是常说的 惰性删除
- redis 启动时会创建一个 cron ,定期清理部分过期的 key ,默认 10次/s 。也就是常说的定期删除
- redis 会随机抽取 10 个 key (根据配置的
maxmemory-samples
确定) - 删除其中过期的 key
- 如果过期的 key 超过样本的 25% ,则重复步骤 1,直到小于 25%
- redis 会随机抽取 10 个 key (根据配置的
One More Thing
几种常见的缓存算法
- LRU : least recently used ,最近最少使用,则会被淘汰
- LFU : least frequently used ,最不经常使用,则会被淘汰
- FIFO : first in first out , 先进先出,即最先进缓存,最先被淘汰
我们假设一个定长为 3 的队列,依次存入数据 1,2,3 ,此时队列如下:
head <- 3 <- 2 <- 1 <- tail
然后有 client 依次访问了 1,1,3,3,2 ,此时队列的顺序就变成了:
head <- 2 <- 3 <- 1 <- tail # LRU 组织数据方式
head <- 3 <- 1 <- 2 <- tail # LFU 组织数据方式
此时按照 LRU ,则会淘汰数据 1 , 而按照 LFU ,因为这段时间内, 数据 2 只被访问了一次,所以会淘汰数据 2
关于 redis 的部分就可以暂时告一段落了 :sweat_smile: