这才是缓存Reload的正确姿势,你写对了吗?

在上一篇文章《 Redis高级玩法:如何利用SortedSet实现多维度排序 》中我提到了一个排行榜设计方案:用一个JOB定时计算然后Reload热门榜单。这里涉及一个很好的细节问题,那就是如何Reload,怎么保证Reload过程中,其他请求不会有任何影响。

DEL+LPUSH

有位非常细心的朋友就提到了这个问题:Reload缓存如果是先删除再lpush批量插入,那么在删除和批量插入这个时间间隙,访问榜单的请求就会拿不到数据。时序图如下所示:

DEL+LPUSH的问题

虽然DEL与LPUSH的时间间隔非常短,可能只有0.1ms,但是正是因为这0.1ms的时间间隙,导致你的实现方案并不完美,而我们的目标就是要做到 完美

pipeline

pipeline本质也是执行del+lpush两台命令,只不过客户端会先打包好这两条命令,然后再一起发送给Redis服务端执行。原理如下图所示:

pipeline原理

pipeline是被广泛使用的技术,并不是redis特有的,其主要意义就是节省了多个命令执行过程中的往返时间(Round Trip Time,即RTT),其对性能提升是非常显著的。例如,许多POP3协议实现已经支持此功能,大大加快了从服务器下载新电子邮件的过程。那么这种实现方式,会存在lrange访问不到数据的情况么?会。因为 pipeline机制只能优化吞吐量,但是无法提供原子性/事务保障 。这个得靠接下来介绍的eval或者multi+exec了。

multi+exec

既然谈到redis的原子性和事务,那就不得不说multi+exec了。这个组合命令也能保证执行的多个命令的原子性。以最常用的Redis客户端Jedis为例,有对其进行封装。通过multi+exec原子性执行del+lpush组合命令示例代码如下:

public class MultiExecTest {
    public static void main(String[] args) {
        try (JedisPool pool = new JedisPool(new JedisPoolConfig(), "192.168.0.1", 6379, 5000, "afei", 0);
             Jedis jedis = pool.getResource()) {
            String cacheKey = "HotApp";
            Transaction t = jedis.multi();
            t.del(cacheKey);
            t.lpush(cacheKey, "wechat", "alipay", "taobao", "qq", "tiktok");
            List result = t.exec();
            for (Object item:result){
                System.out.println(item);
            }

        }
    }
}

eval(推荐)

eval是redis用来保证原子性的命令。Redisson就是利用eval来实现分布式锁的。它加锁的源码如下:

 RFuture tryLockInnerAsync(...) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    // 获取锁时需要在redis实例上执行的lua命令
    return commandExecutor.evalWriteAsync(... ,
              // 首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "... ..." +
              // 获取分布式锁的KEY的失效时间毫秒数
              "return redis.call('pttl', KEYS[1]);",
              // 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]
                Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

Redis 使用单个Lua 解释器去运行所有脚本,并且,Redis也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或Redis命令被执行 。这和使用MULTI/EXEC封装的事务很类似。在其他别的客户端看来,脚本的效果要么是不可见的,要么就是已完成的。

所以,我们只需要借鉴这段源码就能实现一个原子性的Reload操作,如下实现,del和lpush就是一个原子操作啦,完美:

return commandExecutor.evalWriteAsync(... ,
  "redis.call('del', KEYS[1]); " +
  "redis.call('lpush', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ...); " + ,
  Collections.singletonList(KEY, list);

Reload总结

reload操作无论怎么实现,其关键就是Reload的步骤要是事务性的。以del+lpush组合命令reload榜单缓存数据为例,在del命令与lpush命令执行间隙,其他命令不能有任何执行的机会,这才是Reload操作是否足够健壮的关键。