Redis 锁及内存两三事

846 阅读8分钟

个人 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 获取锁时,尝试该命令,如果成功表明获得锁。


这样实现有什么问题呢?

  1. client A 获得锁后, 由于业务处理时间过长或其他原因,未及时释放锁,超时后被自动释放,client B 获取到锁,此时 A 业务处理完成,释放锁, 执行 del key r_lock , client B 的锁被删除...

  2. client 获得锁后,直接挂掉 ...

  3. client A 获取锁释放后,其他等待的 client 不能及时知道这个信息,他们仍然再等待一个固定的轮询时间

  4. 对同一个线程而言,应该有重入的机制

  5. client A 获取锁,设置超时时间 15s ,但是实际业务处理时间为 20s。虽然 client A 一切正常,但是在 15s 后,锁仍然被释放掉了...

  6. 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%

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:

Reference

Redis CN LRU

Read More

MySQL LRU 实现