《Redis深度历险》笔记

开篇 授人以 鱼不若授人以渔 —— Redis 可以用来做什么
小册的内容范
并没有涵盖 Redis 全部的内容知识点,比如 Redis 内置的 lua 脚本引擎就完全没有提
Redis 础数据结构
Redis 5 种基础数据结构,分别为: string ( 字符串 ) list ( 列表 ) set ( 集合 ) hash (
) zset ( 有序集合 )
Redis 安装
网页版 Try Redis似乎功能不全?
先启动本地ubuntu,然后启动redis:
sudo service redis-server start
sudo service redis-server start
然后用redis-cli连接
linux查看redis版本:redis-server -v

string ( 字符串 )
字符串结构使用非常广泛,一个常见的用途就是缓存用户信息。我们将用户信息结构体
使用 JSON 序列化成字符串,然后将序列化后的字符串塞进 Redis 来缓存。同样,取用户
信息会经过一次反序列化的过程
当字符串长度小于 1M 时,
扩容都是加倍现有的空间,如果超过 1M ,扩容时一次只会多扩 1M 的空间。需要注意的是
字符串最大长度为 512M
过期和 set 命令扩展
> setex name 5 codehole # 5s 后过期,等价于 set+expire
> setnx name codehole # 如果 name 不存在就执行 set 创建 ,如果存在就创建失败
list ( 列表 )
它是链表而不是数组
Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符
串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理
首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist ,也即是
压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。当数据量比较多的
时候才会改成 quicklist 。因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且
会加重内存的碎片化。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的 Redis 深度历险:核心原理与应用实践 | 钱文品 著
17 页 共 226
指针 prev next 。所以 Redis 将链表和 ziplist 结合起来组成了 quicklist 。也就是将多个
ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空
间冗余。
hash ( 字典 )
Java HashMap 在字典很大时, rehash 是个耗时的操作,需要一次性全部 rehash Redis
为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
set ( 集合 )
Redis 的集合相当于 Java 语言里面的 HashSet ,它内部的键值对是无序的唯一的。它的
内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL
当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来
存储活动中奖的用户 ID ,因为有去重功能,可以保证同一个用户不会中奖两次。
zset (有序列表)
zset 可能是 Redis 提供的最为特色的数据结构,它也是在面试中面试官最爱问的数据结
构。它类似于 Java SortedSet HashMap 的结合体,一方面它是一个 set ,保证了内部
value 的唯一性,另一方面它可以给每个 value 赋予一个 score ,代表这个 value 的排序权
重。它的内部实现用的是一种叫着「跳跃列表」的数据结构。
zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。 zset 可以用来存
粉丝列表, value 值是粉丝的用户 ID score 是关注时间。我们可以对粉丝列表按关注时间
进行排序。
zset 还可以用来存储学生的成绩, value 值是学生的 ID score 是他的考试成绩。我们
可以对成绩按分数进行排序就可以得到他的名次。
容器型数据 结构的通用规则
list/set/hash/zset 这四种数据结构是容器型数据结构,它们共享下面两条通用规则:
1 create if not exists
如果容器不存在,那就创建一个,再进行操作。比如 rpush 操作刚开始是没有列表的,
Redis 就会自动创建一个,然后再 rpush 进去新元素。
2 drop if no elements
如果容器里元素没有了,那么立即删除元素,释放内存。这意味着 lpop 操作到最后一
个元素,列表就消失了。
过期时间
Redis 所有的数据结构都可以设置过期时间,时间到了, Redis 会自动删除相应的对象。
需要注意的是过期是以对象为单位,比如一个 hash 结构的过期是整个 hash 对象的过期,
而不是其中的某个子 key
还有一个需要特别注意的地方是 如果一个字符串已经设置了过期时间,然后你调用了
set 方法修改了它,它的过期时间会消失
127.0.0.1:6379> set codehole yoyo
OK
127.0.0.1:6379> expire codehole 600
(integer) 1
127.0.0.1:6379> ttl codehole
(integer) 597
127.0.0.1:6379> set codehole yoyo
OK
127.0.0.1:6379> ttl codehole
(integer) -1
应用 1 千帆 竞发 —— 分布式
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用
完了,再调用 del 指令释放茅坑。
// 这里的冒号:就是一个普通的字符,没特别含义,它可以是任意其它字符,不要误解
> setnx lock:codehole true
OK
... do something critical ...
> del lock:codehole
(integer) 1
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样
就会陷入死锁,锁永远得不到释放
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s ,这样即使中间出现异常也
可以保证 5 秒之后锁会自动释放。
> setnx lock:codehole true
OK
> expire lock:codehole 5
... do something critical ...
> del lock:codehole
(integer) 1
但是以上逻辑还有问题。如果在 setnx expire 之间服务器进程突然挂掉了,可能是因
为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可
以一起执行就不会出现问题
Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx
expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁
library 可以休息了。 > set lock:codehole true ex 5 nx OK ... do something critical ... > del
lock:codehole 上面这个指令就是 setnx 和 expire 组合在一起的原子指令 ,它就是分布式锁的
奥义所在
时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至
于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,
但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻
辑执行完之间拿到了锁
终极方案
Lua 脚本可以保证连续多个指令的原子性执行。
#获取锁
if redis.call("exists",KEYS[1]) == 0 then
return redis.call("setex",KEYS[1],unpack(ARGV))
end
# delifequals 释放锁
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
26 页 共 226
else
return 0
end
可重入性
redis通过ThreadLocal实现可重入锁
public class RedisWithReentrantLock {
    private ThreadLocal<Map> lockers = new ThreadLocal<>();
    private Jedis jedis;

    public RedisWithReentrantLock(Jedis jedis) {
        this.jedis = jedis;
    }

    private boolean _lock(String key) {
        return jedis.set(key, "", "nx", "ex", 5L) != null;
    }

    private void _unlock(String key) {
        jedis.del(key);
    }

    private Map<String, Integer> currentLockers() {
        Map<String, Integer> refs = lockers.get();
        if (refs != null) {
            return refs;
        }
        lockers.set(new HashMap<>());
        return lockers.get();
    }

    public boolean lock(String key) {
        Map refs = currentLockers();
        Integer refCnt = refs.get(key);
        if (refCnt != null) {
            refs.put(key, refCnt + 1);
            return true;
        }
        boolean ok = this._lock(key);
        if (!ok) {
            return false;
        }
        refs.put(key, 1);
        return true;
    }

    public boolean unlock(String key) {
        Map refs = currentLockers();
        Integer refCnt = refs.get(key);
        if (refCnt == null) {
            return false;
        }
        refCnt -= 1;
        if (refCnt > 0) {
            refs.put(key, refCnt);
        } else {
            refs.remove(key);
            this._unlock(key);
        }
        return true;
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
        System.out.println(redis.lock("codehole"));
        System.out.println(redis.lock("codehole"));
        System.out.println(redis.unlock("codehole"));
        System.out.println(redis.unlock("codehole"));
    }
}
应用 2 缓兵之计 —— 时队列
Redis 的消息队列不是专业的消息队列,它没有非常多的高级特性,
没有 ack 保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用
异步消息 队列
Redis list( 列表 ) 数据结构常用来作为异步消息队列使用,使用 rpush/lpush 操作入队列,
使用 lpop rpop 来出队列
队列空了怎么办
是如果队列空了,客户端就会陷入 pop 的死循环,不停地 pop ,没有数据,接着再 pop
又没有数据。这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU redis QPS
会被拉高,如果这样空轮询的客户端有几十来个, Redis 的慢查询可能会显著增多。
通常我们使用 sleep 来解决这个问题,让线程睡一会,睡个 1s 钟就可以了。不但客户端
CPU 能降下来, Redis QPS 也降下来了
队列延迟
用上面睡眠的办法可以解决问题。但是有个小问题,那就是睡眠会导致消息的延迟增大。
如果只有 1 个消费者,那么这个延迟就是 1s 。如果有多个消费者,这个延迟会有所下降,因
为每个消费者的睡觉时间是岔开来的。
有没有什么办法能显著降低延迟呢?你当然可以很快想到:那就把睡觉的时间缩短点。这
种方式当然可以,不过有没有更好的解决方案呢?当然也有,那就是 blpop/brpop
这两个指令的前缀字符 b 代表的是 blocking ,也就是阻塞读。
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消
息的延迟几乎为零 。用 blpop/brpop 替代前面的 lpop/rpop ,就完美解决了上面的问题。
闲连接自动断开
你以为上面的方案真的很完美么?先别急着开心,其实他还有个问题需要解决。
什么问题?—— 空闲连接 的问题。
如果线程一直阻塞在哪里, Redis 的客户端连接就成了闲置连接,闲置过久,服务器一般
会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来。
所以编写客户端消费者的时候要小心,注意捕获异常,还要重试。 .
锁冲突处理
请求时加锁没加成功怎么办
1 直接抛出异常 通知用 户稍后重试
2 sleep 一会再重
3 请求转移至延时队列 过一会再试
时队列的实现
zrangebyscore key min max [WITHSCORES] [LIMIT offset count]

可选的 LIMIT 参数指定返回结果的数量及区间(就像SQL中的 SELECT LIMIT offset, count ),注意当 offset 很大时,定位 offset 的操作可能需要遍历整个有序集,此过程最坏复杂度为 O(N) 时间。

可选的 WITHSCORES 参数决定结果集是单单返回有序集的成员,还是将有序集成员及其 score 值一起返回。

min 和 max 可以是 -inf 和 +inf ,这样一来,你就可以在不知道有序集的最低和最高 score 值的情况下,使用 ZRANGEBYSCORE 这类命令。

默认情况下,区间的取值使用闭区间 (小于等于或大于等于),也可以通过给参数前增加 ( 符号来使用可选的开区间 (小于或大于)。

使用zset实现延时队列

public class RedisDelayingQueue<T> {
    static class TaskItem<T> {
        public String id;
        public T msg;
    }

    // fastjson 序列化对象中存在 generic 类型时,需要使用 TypeReference
    private Type TaskType = new TypeReference<TaskItem<T>>() {
    }.getType();
    private Jedis jedis;
    private String queueKey;

    public RedisDelayingQueue(Jedis jedis, String queueKey) {
        this.jedis = jedis;
        this.queueKey = queueKey;
    }

    public void delay(T msg) {
        TaskItem task = new TaskItem();
        task.id = UUID.randomUUID().toString(); // 分配唯一的 uuid
        task.msg = msg;
        String s = JSON.toJSONString(task); // fastjson 序列化
        jedis.zadd(queueKey, System.currentTimeMillis() + 5000, s); // 塞入延时队列 ,5s 后再试
    }

    public void loop() {
        while (!Thread.interrupted()) {
            System.out.println("消费者未中断");
            // 只取一条
            Set values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
            if (values.isEmpty()) {
                try {
                    Thread.sleep(500); // 歇会继续
                } catch (InterruptedException e) {
                    break;
                }
                continue;
            }
            String s = String.valueOf(values.iterator().next());
            if (jedis.zrem(queueKey, s) > 0) { // 抢到了
                TaskItem task = JSON.parseObject(s, TaskType); // fastjson 反序列化
                this.handleMsg((T)task.msg);
            }
        }
        System.out.println("消费者被中断>>>>>>");
    }

    public void handleMsg(T msg) {
        System.out.println(msg);
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        RedisDelayingQueue queue = new RedisDelayingQueue<>(jedis, "q-demo");
        Thread producer = new Thread() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    queue.delay("codehole" + i);
                }
            }
        };
        Thread consumer = new Thread() {
            public void run() {
                queue.loop();
            }
        };
        producer.start();
        consumer.start();
        try {
            System.out.println("producer.join() start");
            producer.join(); //等待producer执行完
            System.out.println("producer.join() end");
            Thread.sleep(6000);
            System.out.println("consumer.interrupt() start");
            consumer.interrupt();
            System.out.println("consumer.interrupt() end");
            consumer.join();
            System.out.println("consumer.join() end");
        } catch (InterruptedException e) {
        }
    }
}
producer.join() start
消费者未中断
producer.join() end
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
消费者未中断
codehole0
消费者未中断
codehole5
消费者未中断
codehole4
消费者未中断
codehole6
消费者未中断
codehole1
消费者未中断
codehole3
消费者未中断
codehole2
消费者未中断
codehole8
消费者未中断
codehole7
消费者未中断
codehole9
消费者未中断
消费者未中断
consumer.interrupt() start
消费者被中断>>>>>>
consumer.interrupt() end
consumer.join() end
进一步优化
上面的算法中同一个任务可能会被多个进程取到之后再使用 zrem 进行争抢,那些没抢到
的进程都是白取了一次任务,这是浪费。可以考虑使用 lua scripting 来优化一下这个逻辑,将
zrangebyscore zrem 一同挪到服务器端进行原子化操作,这样多个进程之间争抢任务时就不
会出现这种浪费了。
应用 3 节衣缩食 ——
位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们
可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit
等将 byte 数组看成「位数组」来处理。
零存整取
127.0.0.1:6379> setbit s 1 1
(integer) 0
127.0.0.1:6379> setbit s 2 1
(integer) 0
127.0.0.1:6379> setbit s 4 1
(integer) 0
127.0.0.1:6379> setbit s 9 1
(integer) 0
127.0.0.1:6379> setbit s 10 1
(integer) 0
127.0.0.1:6379> setbit s 13 1
(integer) 0
127.0.0.1:6379> setbit s 15 1
(integer) 0
127.0.0.1:6379> get s
"he"
零存零取
127.0.0.1:6379> setbit w 1 1
(integer) 0
127.0.0.1:6379> setbit w 2 1
(integer) 0
127.0.0.1:6379> setbit w 4 1
(integer) 0
127.0.0.1:6379> getbit w 1 # 获取某个具体位置的值 0/1
(integer) 1
127.0.0.1:6379> getbit w 2
(integer) 1
127.0.0.1:6379> getbit w 4
(integer) 1
127.0.0.1:6379> getbit w 5
(integer) 0
整存零取
127.0.0.1:6379> set w h # 整存
(integer) 0
127.0.0.1:6379> getbit w 1
(integer) 1
127.0.0.1:6379> getbit w 2
(integer) 1
127.0.0.1:6379> getbit w 4
(integer) 1
127.0.0.1:6379> getbit w 5
(integer) 0
如果对应位的字节是不可打印字符, redis-cli 会显示该字符的 16 进制形式。
127.0.0.1:6379> setbit x 0 1
(integer) 0
127.0.0.1:6379> setbit x 1 1
(integer) 0
127.0.0.1:6379> get x
"\xc0"
统计和查找
bitcount 用来统计指定位
置范围内 1 的个数, bitpos 用来查找指定范围内出现的第一个 0 1
术指令 bitfield
bitfield 有三个子指令,分别是
get/set/incrby ,它们都可以对指定位片段进行读写,但是最多只能处理 64 个连续的位,如果
超过 64 位,就得使用多个子指令, bitfield 可以一次执行多个子指令
bitfield 指令提供了溢出策略子指令 overflow ,用户可以选择溢出行为,默认是折返
(wrap) ,还可以选择失败 (fail) 报错不执行,以及饱和截断 (sat) ,超过了范围就停留在最大
最小值。 overflow 指令只影响接下来的第一条指令,这条指令执行完后溢出策略会变成默认
值折返 (wrap)
应用 4 四两 拨千斤 —— HyperLogLog
统计网站每个网页每天的 UV 数据,
UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就
要求每一个网页请求都需要带上用户的 ID ,无论是登陆用户还是未登陆用户都需要一个唯一
ID 来标识
使用set很浪费空间
Redis 提供了 HyperLogLog 数据结构就是用来解决
这种统计问题的。 HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不
精确,标准误差是 0.81% ,这样的精确度已经可以满足上面的 UV 统计需求了
使用方法
HyperLogLog 提供了两个指令 pfadd pfcount ,根据字面意义很好理解,一个是增加
计数,一个是获取计数。 pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID ,就将用
ID 塞进去就是。 pfcount scard 用法是一样的,直接获取计数值
pfmerge ,用于
将多个 pf 计数值累加在一起形成一个新的 pf
注意事
相比 set 存储方案, HyperLogLog 所使用的空间那真
是可以使用千斤对比四两来形容了
HyperLogLog 实现原理
应用 5 层峦叠嶂 —— 布隆 过滤器
使用场景,比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内
容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何
实现推送去重的?
如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists
询,当系统并发量很高时,数据库是很难扛住压力的
历史记录全部缓存起来,那得浪费多大存储空间
啊?而且这个存储空间是随着时间线性增长,你撑得住一个月,你能撑得住几年么?
布隆过滤器 (Bloom Filter) 闪亮登场了,它就是专门用来解决这种去重问题的。
它在起到去重的同时,在空间上还能节省 90% 以上,只是稍微有那么点不精确,也就是有
一定的误判概率
布隆 过滤器是什么
可以理解为一个不怎么精确的 set 结构
当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存
在。
套在上面的使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过
的新内容,它也会过滤掉极小一部分 ( 误判 ) ,但是绝大多数新内容它都能准确识别。这样就
可以完全保证推荐给用户的内容都是无重复的
布隆 过滤器基本使用
布隆过滤器有二个基本指令, bf.add 添加元素, bf.exists 查询元素是否存在,它的用法
set 集合的 sadd sismember 差不多。注意 bf.add 只能一次添加一个元素,如果想要
一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,就需
要用到 bf.mexists 指令
Java 客户端 Jedis-2.x 没有提供指令扩展机制,所以你无法直接使用 Jedis 来访问
Redis Module 提供的 bf.xxx 指令。 RedisLabs 提供了一个单独的包 JReBloom ,但是它是基
Jedis-3.0 Jedis-3.0 这个包目前还没有进入 release ,没有进入 maven 的中央仓库,需要
Github 上下载。在使用上很不方便,如果怕麻烦,还可以使用 lettuce ,它是另一个
Redis 的客户端,相比 Jedis 而言,它很早就支持了指令扩展
以下是Google Guava实现的布隆过滤器

第三方布隆过滤器

BloomFilter的java实现

bf.reserve 有三个参数,分别 是 key, error_rate initial_size 。错误率越低,需要的空间越大。 initial_size 参数表示预计放 入的元素数量,当实际数量超出这个数值时,误判率会上升。使用Google Guava提供的BloomFilter,易用性高,灵活性好,参数灵活化配置。

添加依赖

<dependency>

<groupId>com.google.guava</groupId>

<artifactId>guava</artifactId>

<version>22.0</version>

</dependency>

创建BloomFilter

//容量1000000,误判率0,03

BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), 1000000, 0.03);

添加数据

bloomFilter.put("1306341993XXX");

判断数据是否存在

boolean isExist = bloomFilter.mightContain("1306341993XXX");

注意:此过滤器不支持元素的删除(由于存在哈希冲突),只支持元素的新增。

若存在元素删除的场景需考虑使用增强版的布隆过滤器或者布谷鸟过滤器,但性能上会有一定的折扣。

注意事
布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合,
error_rate 设置稍大一点也无伤大雅。
布隆 过滤器的原理
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索
引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位
置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出
来,看看位数组中这几个位置是否都位 1 ,只要有一个位为 0 ,那么说明布隆过滤器中这个
key 不存在。如果都是 1 ,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这
些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会
很大,如果这个位数组比较拥挤,这个概率就会降低
布隆 过滤器的其它应用
爬虫系统 中,我们需要对 URL 进行去重,已经爬过的网页就可以不用爬了。但是
URL 太多了,几千万几个亿,如果用一个集合装下这些 URL 地址那是非常浪费空间的。这
时候就可以考虑使用布隆过滤器。它可以大幅降低去重存储消耗,只不过也会使得爬虫系统
错过少量的页面。
布隆过滤器在 NoSQL 数据库领域 使用非常广泛,我们平时用到的 HBase Cassandra
还有 LevelDB RocksDB 内部都有布隆过滤器结构,布隆过滤器可以显著降低数据库的 IO
请求数量。当用户来查询某个 row 时,可以先通过内存中的布隆过滤器过滤掉大量不存在的
row 请求,然后再去磁盘进行查询。
邮箱系统的垃圾邮件过滤功能 也普遍用到了布隆过滤器,因为用了这个过滤器,所以平
时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低
应用 6 断尾求生 —— 简单限流
如何使用 Redis 实现简单限流策略
系统要限定用户的某个行为在指定的时间里
只能允许发生 N 次,如何使用 Redis 的数据结构来实现这个限流的功能?
public class SimpleRateLimiter {
    private Jedis jedis;
    public SimpleRateLimiter(Jedis jedis) {
        this.jedis = jedis;
    }
    public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
        String key = String.format("hist:%s:%s", userId, actionKey);
        long nowTs = System.currentTimeMillis();
        Pipeline pipe = jedis.pipelined();  //因为有多个操作,使用Pipeline提升读取效率
        pipe.multi();
        pipe.zadd(key, nowTs, "" + nowTs);
        pipe.zremrangeByScore(key, 0, nowTs - period * 1000);
        Response<Long> count = pipe.zcard(key);
        pipe.expire(key, period + 1);
        pipe.exec();
        pipe.close();
        return count.get() <= maxCount;
    }
    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
        for(int i=0;i<20;i++) {
            System.out.println(limiter.isActionAllowed("laoqian", "reply", 60, 5));
        }
    }
}
因为这几个连续的 Redis 操作都是针对同一个 key 的,使用 pipeline 可以显著提升
Redis 存取效率。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这
个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流
的,因为会消耗大量的存储空间
应用 7 一毛不拔 —— 漏斗限流
Redis-Cell
Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell 。该模块也使用了漏斗算法,并
提供了原子的限流指令。有了这个模块,限流问题就非常简单了
cl.throttle

 限流算法:限流实现详解_z.haoui的博客-CSDN博客_限流实现

应用 8 近水楼台 —— GeoHash
用数据 库来算附近的人
关系型数据库处理距离问题很麻烦
GeoHash 算法
GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一
条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算「附
近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行
在使用 Redis 进行 Geo 查询时,我们要时刻想到它的内部结构实际上只是一个
zset(skiplist) 。通过 zset score 排序就可以得到坐标附近的其它元素 ( 实际情况要复杂一
些,不过这样理解足够了 ) ,通过将 score 还原成坐标值就可以得到元素的原始坐标
Redis Geo 指令基本使用
增加
geoadd company 116.48105 39.996794 juejin
距离
geodist company juejin ireader km
获取元素位置
geopos company juejin
我们观察到获取的经纬度坐标和 geoadd 进去的坐标有轻微的误差,原因是 geohash
二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。对于「附
近的人」这种功能来说,这点误差根本不是事
获取元素的 hash
127.0.0.1:6379> geohash company ireader
1) "wx4g52e1ce0"
geohash 可以获取元素的经纬度编码字符串,上面已经提到,它是 base32 编码。 你可
以使用这个编码值去 http://geohash.org/${hash} 中进行直接定位,它是 geohash 的标准编码
附近的公司
georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元
# 范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
1) "ireader"
2) "juejin"
3) "meituan"
# 范围 20 公里以内最多 3 个元素按距离倒排
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
1) "jd"
2) "meituan"
3) "juejin"
# 三个可选参数 withcoord withdist withhash 用来携带附加参数
# withdist 很有用,它可以用来显示距离
127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc
1) 1) "ireader"
2) "0.0000"
3) (integer) 4069886008361398
4) 1) "116.5142020583152771"
2) "39.90540918662494363"
2) 1) "juejin"
2) "10.5501"
3) (integer) 4069887154388167
4) 1) "116.48104995489120483"
2) "39.99679348858259686"
3) 1) "meituan"
2) "11.5748"
3) (integer) 4069887179083478
4) 1) "116.48903220891952515"
2) "40.00766997707732031"
& 注意事
Redis 的集群环境中,集合
可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成
较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M ,否则会导致集群迁移出现
卡顿现象,影响线上服务的正常运行。
所以,这里建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。
如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按
市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小
应用 9 大海 捞针 —— Scan
指令 keys
keys 算法是遍 历算法 杂度是 O(n) 如果 实例中有千万级以上的 key 这个指令
就会 导致 Redis 务卡顿 所有 读写 Redis 的其它的指令都会被延后甚至会超 时报错
Redis 单线程程序 顺序执行所有指令 其它指令必 须等到当前的 keys 指令 执行完了才
可以 继续
scan 相比 keys 具备有以下特点 :
1 杂度虽然也是 O(n) 但是它是通 过游标分步进行的 不会阻塞 线程 ;
2 提供 limit 参数 可以控制每次返回 结果的最大条数 limit 只是一个 hint 返回的
结果可多可少 ;
3 keys 它也提供模式匹配功能 ;
4 务器不需要为游标保存状态 标的唯一状态就是 scan 返回 给客户端的游标整数 ;
5 返回的 结果可能会有重复 需要客 户端去重复 这点非常重要 ;
6 历的过程中如果有数据修改 动后的数据能不能遍历到是不确定的 ;
7 单次返回的结果是空的并不意味着遍历结束 而要看返回的游 标值是否为零 ;
scan 础使用
scan 参数提供了三个参数,第一个是 cursor 整数值 ,第二个是 key 的正则模式 ,第三
个是 遍历的 limit hint 。第一次遍历时, cursor 值为 0 ,然后将返回结果中第一个整数值作为
下一次遍历的 cursor 。一直遍历到返回的 cursor 值为 0 时结束
127.0.0.1:6379> scan 0 match key99* count 1000
1) "13976"
2) 1) "key9911"
2) "key9974"
3) "key9994"
4) "key9910"
5) "key9907"
6) "key9989"
7) "key9971"
8) "key99"
9) "key9966"
10) "key992"
11) "key9903"
12) "key9905"
127.0.0.1:6379> scan 13976 match key99* count 1000
从上面的过程可以看到虽然提供的 limit 1000 ,但是返回的结果只有 10 个左右。因
为这个 limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量 ( 约等于 )
字典的 结构
Redis 中所有的 key 都存储在一个很大的字典中,这个字典的结构和 Java 中的
HashMap 一样,是一维数组 + 二维链表结构,第一维数组的大小总是 2^n(n>=0) ,扩容一
次数组大小空间加倍,也就是 n++
scan 指令返回的游标就是第一维数组的位置索引,我们将这个位置索引称为槽 (slot)
如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。 limit 参数就表示需要遍历的
槽位数,之所以返回的结果可能多可能少,是因为不是所有的槽位上都会挂接链表,有些槽
位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。每一次遍历都会将 limit
数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端
字典 扩容
Java 中的 HashMap 有扩容的概念,当 loadFactor 达到阈值时,需要重新分配一个新的
2 倍大小的数组,然后将所有的元素全部 rehash 挂到新的数组下面。 rehash 就是将元素的
hash 值对数组长度进行取模运算,因为长度变了,所以每个元素挂接的槽位可能也发生了变
化。又因为数组的长度是 2^n 次方,所以取模运算等价于位与操作
渐进式 rehash
Java HashMap 在扩容时会一次性将旧数组下挂接的元素全部转移到新数组下面。如
HashMap 中元素特别多,线程就会出现卡顿现象。 Redis 为了解决这个问题,它采用渐
进式 rehash
它会同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐
地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同
时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面去寻找。
scan 也需要考虑这个问题,对与 rehash 中的字典,它需要同时扫描新旧槽位,然后将
结果融合后返回给客户端
更多的 scan 指令
scan 指令是一系列指令,除了可以遍历所有的 key 之外,还可以对指定的容器集合进
行遍历。比如 zscan 遍历 zset 集合元素, hscan 遍历 hash 字典的元素、 sscan 遍历 set
合的元素。
它们的原理同 scan 都会类似的,因为 hash 底层就是字典, set 也是一个特殊的
hash( 所有的 value 指向同一个元素 ) zset 内部也使用了字典来存储所有的元素内容,所以
这里不再赘述
key 扫描
有时候会因为业务人员使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的
hash ,一个很大的 zset 这都是经常出现的。这样的对象对 Redis 的集群数据迁移带来了很
大的问题,因为在集群环境下,如果 某个 key 太大,会数据导致迁移卡顿 。另外在内存分配
上, 如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存, 这也会导致
卡顿。 如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生
在平时的业务开发中,要尽量避免大 key 的产生。
如果你观察到 Redis 的内存大起大落,这极有可能是因为大 key 导致的
如何定位大 key
适用命令:redis-cli -h 127.0.0.1 -p 6379 --bigkeys

--bigkeys 是 redis 自带的命令,对整个 Key 进行扫描,统计 string,list,set,zset,hash 这几个常见数据类型中每种类型里的最大的 key。

string 类型统计的是 value 的字节数;另外 4 种复杂结构的类型统计的是元素个数,不能直观的看出 value 占用字节数,所以 --bigkeys 对分析 string 类型的大 key 是有用的,而复杂结构的类型还需要一些第三方工具

原理 1 鞭辟入里 —— 线程 IO 模型
Redis 是个单线程程序! 这点必须铭记。
也许你会怀疑高并发的 Redis 中间件怎么可能是单线程。很抱歉,它就是单线程,你的
怀疑暴露了你基础知识的不足。莫要瞧不起单线程,除了 Redis 之外, Node.js 也是单线
程, Nginx 也是单线程,但是它们都是服务器高性能的典范。
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算。正因为 Redis 是单线
程,所以要小心使用 Redis 指令,对于那些时间复杂度为 O(n) 级别的指令,一定要谨慎使
用,一不小心就可能会导致 Redis 卡顿。
Redis 单线程如何处理那么多的并发客户端连接?
这个问题,有很多中高级程序员都无法回答,因为他们没听过多路复用这个词汇,不知
select 系列的事件轮询 API ,没用过非阻塞 IO
非阻塞 IO
当我们调用套接字的读写方法,默认它们是阻塞的,比如 read 方法要传递进去一个参数
n ,表示读取这么多字节后再返回,如果没有读够线程就会卡在那里,直到新的数据到来或者
连接关闭了, read 方法才可以返回,线程才能继续处理。而 write 方法一般来说不会阻塞,除
非内核为套接字分配的写缓冲区已经满了, write 方法就会阻塞,直到缓存区中有空闲空间挪
出来了。
非阻塞 IO 在套接字对象上提供了一个选项 Non_Blocking ,当这个选项打开时,读写方
法不会阻塞,而是能读多少读多少,能写多少写多少。能读多少取决于内核为套接字分配的
读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区的空闲空间字节
数。读方法和写方法都会通过返回值来告知程序实际读写了多少字节。
有了非阻塞 IO 意味着线程在读写 IO 时可以不必再阻塞了,读写可以瞬间完成然后线
程可以继续干别的事了。
事件 轮询 ( 多路复用 )
非阻塞 IO 有个问题,那就是线程要读数据,结果读了一部分就返回了,线程如何知道
何时才应该继续读。也就是当数据到来时,线程如何得到通知。写也是一样,如果缓冲区满
了,写不完,剩下的数据何时才应该继续写,线程也应该得到通知。

事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API select 函数,它是
操作系统提供给用户程序的 API 。输入是读写描述符列表 read_fds & write_fds ,输出是与之
对应的可读可写事件。同时还提供了一个 timeout 参数,如果没有任何事件到来,那么就最多
等待 timeout 时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过
了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应
的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,我们把这个死循环称为事
件循环,一个循环为一个周期。
每个客户端套接字 socket 都有对应的读写文件描述符。
read_events, write_events = select(read_fds, write_fds, timeout)
for event in read_events:
handle_read(event.fd)
for event in write_events:
handle_write(event.fd)
handle_others() # 处理其它事情,如定时任务等
因为我们通过 select 系统调用同时处理多个通道描述符的读写事件,因此我们将这类系
统调用称为多路复用 API 。现代操作系统的多路复用 API 已经不再使用 select 系统调用,而
改用 epoll(linux) kqueue(freebsd & macosx) ,因为 select 系统调用的性能在描述符特别多时
性能会非常差。它们使用起来可能在形式上略有差异,但是本质上都是差不多的,都可以使
用上面的伪代码逻辑进行理解。
服务器套接字 serversocket 对象的读操作是指调用 accept 接受客户端新连接。何时有新连
接到来,也是通过 select 系统调用的读事件来得到通知的。
事件轮询 API 就是 Java 语言里面的 NIO 技术
Java NIO 并不是 Java 特有的技术,其它计算机语言都有这个技术,只不过换了一
个词汇,不叫 NIO 而已。
指令 队列
Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行
顺序处理,先到先服务。
应队列
Redis 同样也会为每个客户端套接字关联一个响应队列。 Redis 服务器通过响应队列来将
指令的返回结果回复给客户端。 如果队列为空,那么意味着连接暂时处于空闲状态,不需要
去获取写事件,也就是可以将当前的客户端描述符从 write_fds 里面移出来。等到队列有数据
了,再将描述符放进去。避免 select 系统调用立即返回写事件,结果发现没什么数据可以
写。出这种情况的线程会飙高 CPU
时任务
服务器处理要响应 IO 事件外,还要处理其它事情。比如定时任务就是非常重要的一件
事。如果线程阻塞在 select 系统调用上,定时任务将无法得到准时调度。那 Redis 是如何解
决这个问题的呢?
Redis 的定时任务会记录在一个称为 最小堆 的数据结构中。这个堆中,最快要执行的任
务排在堆的最上方。在每个循环周期, Redis 都会将最小堆里面已经到点的任务立即进行处
理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是 select 系统调
用的 timeout 参数。因为 Redis 知道未来 timeout 时间内,没有其它定时任务需要处理,所以
可以安心睡眠 timeout 的时间。
Nginx Node 的事件处理原理和 Redis 也是类似的
原理 2 头接耳 —— 通信 协议
Redis 的作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处
理上。所以即使 Redis 使用了浪费流量的文本协议,依然可以取得极高的访问性能。 Redis
将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情
况下可以达到了 10w/s 的超高 QPS
RESP(Redis Serialization Protocol)
RESP Redis 序列化协议的简写。它是一种直观的文本协议,优势在于实现异常简
单,解析性能极好
Redis 协议里有大量冗余的回车换行符,但是这不影响它成为互联网技术领域非常受欢
迎的一个文本协议。 有很多开源项目使用 RESP 作为它的通讯协议 。在技术领域性能并不总
是一切,还有简单性、易理解性和易实现性,这些都需要进行适当权衡
原理 3 未雨 绸缪 —— 持久化
Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制
来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。
Redis 的持久化机制有两种, 第一种是快照,第二种是 AOF 日志 。快照是一次全量备
份, AOF 日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧
凑,而 AOF 日志记录的是内存数据修改的指令记录文本。 AOF 日志在长期的运行过程中会
变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。
所以需要定期进行 AOF 重写,给 AOF 日志进行瘦身。

快照原理

Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化。会使用操作系统的 COW 机制来进行数据段页面的分离

AOF 原理
AOF 日志存储的是 Redis 服务器的顺序指令序列, AOF 日志只记录对内存进行修改的
指令记录。
Redis 在长期运行的过程中, AOF 的日志会越变越长。如果实例宕机重启,重放整个
AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 AOF 日志瘦
AOF 重写
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进
程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。
序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加
完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
fsync
在生产环境的服务器中, Redis 通常是每隔 1s 左右执行一次 fsync 操作,周期 1s
是可以配置的。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能
使得数据少丢失。
快照是通过开启子进程的方式进行的,它是一个比较耗资源的操作。
1 、遍历整个内存,大块写磁盘会加重系统负载
2 AOF fsync 是一个耗时的 IO 操作,它会降低 Redis 性能,同时也会增加系
IO 负担
所以 通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行 。从节
点是备份节点,没有来自客户端请求的压力,它的操作系统资源往往比较充沛。
但是如果出现网络分区,从节点长期连不上主节点,就会出现数据不一致的问题,特别
是在网络分区出现的情况下又不小心主节点宕机了,那么数据就会丢失,所以在生产环境要
做好实时监控工作,保证网络畅通或者能快速修复。另外还应该再增加一个从节点以降低网
络分区的概率,只要有一个从节点数据同步正常,数据也就不会轻易丢失
Redis 4.0 混合持久化
重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常
使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis
例很大的情况下,启动需要花费很长的时间。
Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb
件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自
持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。
于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可
以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升
原理 4 厉风行 —— 管道
大多数同学一直以来对 Redis 管道有一个误解,他们以为这是 Redis 服务器提供的一种
特别的技术,有了这种技术就可以加速 Redis 的存取效率。但是实际上 Redis 管道
(Pipeline) 本身并不是 Redis 服务器直接提供的技术,这个技术本质上是由客户端提供的,
跟服务器没有什么直接的关系。下面我们对这块做一个深入探究。
Redis 的消息交互

 管道压力测试

39W qps到极限

 深入理解管道本

我们开始以为 write 操作是要等到对方收到消息才会返回,但实际上不是这样的。 write
操作只负责将数据写到本地操作系统内核的发送缓冲然后就返回了。剩下的事交给操作系统
内核异步将数据送到目标机器。但是如果发送缓冲满了,那么就需要等待缓冲空出空闲空间
来,这个就是写操作 IO 操作的真正耗时。
我们开始以为 read 操作是从目标机器拉取数据,但实际上不是这样的。 read 操作只负
责将数据从本地操作系统内核的接收缓冲中取出来就了事了。但是如果缓冲是空的,那么就
需要等待数据到来,这个就是读操作 IO 操作的真正耗时。
所以对于 value = redis.get(key) 这样一个简单的请求来说, write 操作几乎没有耗时,直接
写到发送缓冲就返回,而 read 就会比较耗时了,因为它要等待消息经过网络路由到目标机器
处理后的响应消息 , 再回送到当前的内核读缓冲才可以返回。这才是一个网络来回的真正开
销。
而对于管道来说,连续的 write 操作根本就没有耗时,之后第一个 read 操作会等待一个
网络的来回开销,然后所有的响应消息就都已经回送到内核的读缓冲了,后续的 read 操作
直接就可以从缓冲拿到结果,瞬间就返回了
原理 5 同舟共 ——
Redis 在形式上看起来也差不多,分别是 multi/exec/discard multi 指示事务的开始,
exec 指示事务的执行, discard 指示事务的丢弃。
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2
上面的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是缓存在
服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕
后一次性返回所有指令的运行结果。因为 Redis 的单线程特性,它不用担心自己在执行队列
的时候被其它指令打搅,可以保证他们能得到的「原子性」执行。
上图显示了以上事务过程完整的交互效果。 QUEUED 是一个简单字符串,同 OK 是一
个形式,它表示指令已经被服务器缓存到队列里了
redis事务没有原子性
优化
上面的 Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事
务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以 通常 Redis 的客户端在执行
事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作 。比如我
们在使用 Python Redis 客户端时执行事务时是要强制使用 pipeline 的。
pipe = redis.pipeline(transaction=true)
pipe.multi()
pipe.incr("books")
pipe.incr("books")
values = pipe.execute()
Watch
Redis 提供了这种 watch 的机制,它就是一种乐观锁
watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时,也就是服务器收到
exec 指令要顺序执行缓存的事务队列时, Redis 会检查关键变量自 watch 之后,是否被
修改了 ( 包括当前事务所在的客户端 ) 。如果关键变量被人动过了, exec 指令就会返回 null
回复告知客户端事务执行失败,这个时候客户端一般会选择重试
注意事项
Redis 禁止在 multi exec 之间执行 watch 指令,而必须在 multi 之前做好盯住关键
变量,否则会出错。
使用Java实现余额加倍
public class TransactionDemo {
    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        String userId = "abc";
        String key = keyFor(userId);
        jedis.setnx(key, String.valueOf(5)); // setnx 做初始化
        System.out.println(doubleAccount(jedis, userId));
        jedis.close();
    }

    public static int doubleAccount(Jedis jedis, String userId) {
        String key = keyFor(userId);
        while (true) {
            jedis.watch(key);
            int value = Integer.parseInt(jedis.get(key));
            value *= 2; // 加倍
            Transaction tx = jedis.multi();
            tx.set(key, String.valueOf(value));
            List<Object> res = tx.exec();
            if (res != null) {
                break; // 成功了
            }
        }
        return Integer.parseInt(jedis.get(key)); // 重新获取余额
    }

    public static String keyFor(String userId) {
        return String.format("account_{}", userId);
    }
}
原理 6 小道消息 —— PubSub
为了支持消息多播, Redis 不能再依赖于那 5 种基本数据类型了。它单独使用了一个模
块来支持消息多播,这个模块的名字叫着 PubSub ,也就是 PublisherSubscriber ,发布者订阅
者模型。
原理 7 开源 节流 —— 对象压缩
Redis 是一个非常耗费内存的数据库,它所有的数据都放在内存里。如果我们不注意节
约使用内存, Redis 就会因为我们的无节制使用出现内存不足而崩溃。 Redis 作者为了优化数
据结构的内存占用,也苦心孤诣增加了非常多的优化点
32bit vs 64bit
Redis 如果使用 32bit 进行编译,内部所有数据结构所使用的指针空间占用会少一半,
如果你对 Redis 使用内存不超过 4G ,可以考虑使用 32bit 进行编译,可以节约大量内存。
4G 的容量作为一些小型站点的缓存数据库是绰绰有余了,如果不足还可以通过增加实例的
方式来解决。
对象压缩存储 (ziplist)
Redis ziplist 是一个紧凑的字节数组结构,如下图所示,每个元素之间都是紧挨着
Redis intset 是一个紧凑的整数数组结构,它用于存放元素都是整数的并且元素个数
较少的 set 集合
存储界限
当集合对象的元素不断增加,或者某个 value 值过大,这种小对象存储也会被升级为标准结构
内存回收机制
Redis 并不总是可以将空闲内存立即归还给操作系统。
如果当前 Redis 内存有 10G ,当你删除了 1GB key 后,再去观察内存,你会发现
内存变化不会太大。原因是操作系统回收内存是以页为单位,如果这个页上只要有一个 key
还在使用,那么它就不能被回收。 Redis 虽然删除了 1GB key ,但是这些 key 分散到了
很多页面中,每个页面都还有其它 key 存在,这就导致了内存不会立即被回收。
不过,如果你执行 flushdb ,然后再观察内存会发现内存确实被回收了。原因是所有的
key 都干掉了,大部分之前使用的页面都完全干净了,会立即被操作系统回收。
Redis 虽然无法保证立即回收已经删除的 key 的内存,但是它会重用那些尚未回收的空
闲内存
内存分配算法
内存分配是一个非常复杂的课题,需要适当的算法划分内存页,需要考虑内存碎片,需
要平衡性能和效率。
Redis 为了保持自身结构的简单性,在内存分配这里直接做了甩手掌柜,将内存分配的
细节丢给了第三方内存分配库去实现。目前 Redis 可以使用 jemalloc(facebook) 库来管理内
存,也可以切换到 tcmalloc(google) 。因为 jemalloc 相比 tcmalloc 的性能要稍好一些,所以
Redis 默认使用了 jemalloc
原理 8 备无患 —— 主从同步
很多企业都没有使用到 Redis 的集群,但是至少都做了主从。有了主从,当 master
掉的时候,运维让从库过来接管,服务就可以继续,否则 master 需要经过数据恢复和重启
的过程,这就可能会拖很长的时间,影响线上业务的持续服务。
CAP 原理
CAP 原理就好比分布式领域的牛顿定律,它是分布式存储的理论基石。自打 CAP 的论
文发表之后,分布式存储中间件犹如雨后春笋般一个一个涌现出来。理解这个原理其实很简
单,本节我们首先对这个原理进行一些简单的讲解。
C - C onsistent 一致性
A - A vailability 可用性
P - P artition tolerance 分区容忍性
分布式系统的节点往往都是分布在不同的机器上进行网络隔离开的,这意味着必然会有
网络断开的风险,这个网络断开的场景的专业词汇叫着「 网络分区
一句话概括 CAP 原理就是—— 网络分区发生时,一致性和可用性两难全
增量同步
Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本
地的内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指
令流来达到和主节点一样的状态,一遍向主节点反馈自己同步到哪里了 ( 偏移量 )
因为内存的 buffer 是有限的,所以 Redis 主库不能将所有的指令都记录在内存 buffer
中。 Redis 的复制内存 buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆
盖前面的内容。
如果因为网络状况不好,从节点在短时间内无法和主节点进行同步,那么当网络状况恢
复时, Redis 的主节点中那些没有同步的指令在 buffer 中有可能已经被后续的指令覆盖掉
了,从节点将无法直接通过指令流来进行同步,这个时候就需要用到更加复杂的同步机制 —
— 快照同步
快照同步
快照同步是一个非常耗费资源的操作,它首先需要在主库上进行一次 bgsave 将当前内
存的数据全部快照到磁盘文件中,然后再将快照文件的内容全部传送到从节点。从节点将快
照文件接受完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完
毕后通知主节点继续进行增量同步。
在整个快照同步进行的过程中,主节点的复制 buffer 还在不停的往前移动,如果快照同
步的时间过长或者复制 buffer 太小,都会导致同步期间的增量指令在复制 buffer 中被覆
盖,这样就会导致快照同步完成后无法进行增量复制,然后会再次发起快照同步,如此极有
可能会陷入快照同步的死循环。所以务必配置一个合适的复制 buffer 大小参数,避免快照复制的死循环
增加从 节点
当从节点刚刚加入到集群时,它必须先要进行一次快照同步,同步完成后再继续进行增
量同步
盘复制
主节点在进行快照同步时,会进行很重的文件 IO 操作,特别是对于非 SSD 磁盘存储
时,快照会对系统的负载产生较大影响。特别是当系统正在进行 AOF fsync 操作时如果
发生快照, fsync 将会被推迟执行,这就会严重影响主节点的服务效率。
所以从 Redis 2.8.18 版开始支持无盘复制。所谓无盘复制是指主服务器直接通过套接字
将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一遍将序
列化的内容发送到从节点,从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,
再进行一次性加载
Wait 指令
Redis 的复制是异步进行的, wait 指令可以让异步复制变身同步复制,确保系统的强一
致性 ( 不严格 ) wait 指令是 Redis3.0 版本以后才出现的
wait 提供两个参数,第一个参数是从库的数量 N ,第二个参数是时间 t ,以毫秒为单
位。它表示等待 wait 指令之前的所有写操作同步到 N 个从库 ( 也就是确保 N 个从库的同
步没有滞后 ) ,最多等待时间 t 。如果时间 t=0 ,表示无限等待直到 N 个从库同步完成达成
一致。
主从复制是 Redis 分布式的基础, Redis 的高可用离开了主从复制将无从进行。后面的
章节我们会开始讲解 Redis 的集群模式,这几种集群模式都依赖于本节所讲的主从复制。
不过复制功能也不是必须的,如果你将 Redis 只用来做缓存,跟 memcache 一样来对
待,也就无需要从库做备份,挂掉了重新启动一下就行。但是只要你使用了 Redis 的持久化
功能,就必须认真对待主从复制,它是系统数据安全的基础保障
集群 1 李代桃僵 —— Sentinel(哨兵)

我们可以将 Redis Sentinel 集群看成是一个 ZooKeeper 集群,它是集群高可用的心脏,
它一般是由 3 5 个节点组成,这样挂了个别节点集群还可以正常运转。
它负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为
主节点。客户端来连接集群时,会首先连接 sentinel ,通过 sentinel 来查询主节点的地址,
然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 sentinel 要地
址, sentinel 会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节
点切换
消息 丢失
Redis 主从采用异步复制,意味着当主节点挂掉时,从节点可能没有收到全部的同步消
息,这部分未同步的消息就丢失了。如果主从延迟特别大,那么丢失的数据就可能会特别
Sentinel 无法保证消息完全不丢失,但是也尽可能保证消息少丢失。它有两个选项可以
限制主从延迟过大。( 异步刷盘可能会丢数据 )
min-slaves-to-write 1
min-slaves-max-lag 10
第一个参数表示主节点必须至少有一个从节点在进行正常复制,否则就停止对外写服
务,丧失可用性。
何为正常复制,何为异常复制?这个就是由第二个参数控制的,它的单位是秒,表示如
10s 没有收到从节点的反馈,就意味着从节点同步不正常,要么网络断开了,要么一直没
有给反馈
sentinel 的默认端口是 26379 ,不同于 Redis 的默认端口 6379 ,通过 sentinel 对象的
discover_xxx 方法可以发现主从地址,主地址只有一个,从地址可以有多个
有个问题是,但 sentinel 进行主从切换时, 客户端如何知道地址变更了 ? 通过分析源
码,我发现 redis-py 在建立连接的时候进行了主库地址变更判断
连接池建立新连接时,会去查询主库地址,然后跟内存中的主库地址进行比对,如果变
更了,就断开所有连接,重新使用新地址建立新连接。如果是旧的主库挂掉了,那么所有正
在使用的连接都会被关闭,然后在重连时就会用上新地址。
但是这样还不够,如果是 sentinel 主动进行主从切换,主库并没有挂掉,而之前的主库
连接已经建立了在使用了,没有新连接需要建立,那这个连接是不是一致切换不了?
继续深入研究源码,我发现 redis-py 在另外一个点也做了控制。那就是在处理命令的时
候捕获了一个特殊的异常 ReadOnlyError,在这个异常里将所有的旧连接全部关闭了,后续指
令就会进行重连
主从切换后,之前的主库被降级到从库,所有的修改性的指令都会抛出 ReadonlyError
如果没有修改性指令,虽然连接不会得到切换,但是数据不会被破坏,所以即使不切换也没
关系
集群 2 分而治之 —— Codis
Codis Redis 集群方案之一
Codis 分片原理
Codis 要负责将特定的 key 转发到特定的 Redis 实例,那么这种对应关系 Codis 是如
何管理的呢?
Codis 将所有的 key 默认划分为 1024 个槽位 (slot) ,它首先对客户端传过来的 key
crc32 运算计算哈希值,再将 hash 后的整数值对 1024 这个整数进行取模得到一个余
数,这个余数就是对应 key 的槽位
槽位数量默认是 1024 ,它是可以配置的,如果集群节点比较多,建议将这个数值配置大
一些,比如 2048 4096
不同的 Codis 实例之间槽位关系如何同步
如果 Codis 的槽位映射关系只存储在内存里,那么不同的 Codis 实例之间的槽位关系
就无法得到同步。所以 Codis 还需要一个分布式配置存储数据库专门用来持久化槽位关系。
Codis 开始使用 ZooKeeper ,后来连 etcd 也一块支持了
Codis 将槽位关系存储在 zk 中,并且提供了一个 Dashboard 可以用来观察和修改槽位
关系,当槽位关系变化时, Codis Proxy 会监听到变化并重新同步槽位关系,从而实现多个
Codis Proxy 之间共享相同的槽位关系配置
Codis 的代价
Codis Redis 带来了扩容的同时,也损失了其它一些特性。因为 Codis 中所有的 key
分散在不同的 Redis 实例中,所以 事务就不能再支持了,事务只能在单个 Redis 实例中完
。同样 rename 操作也很危险,它的参数是两个 key ,如果这两个 key 在不同的 Redis
例中, rename 操作是无法正确完成的。 Codis 的官方文档中给出了一系列不支持的命令列
Codis 优点
Codis 在设计上相比 Redis Cluster 官方集群方案要简单很多,因为它将分布式的问题交
给了第三方 zk/etcd 去负责,自己就省去了复杂的分布式一致性代码的编写维护工作。而
Redis Cluster 的内部实现非常复杂,它为了实现去中心化,混合使用了复杂的 Raft
Gossip 协议,还有大量的需要调优的配置参数,当集群出现故障时,维护人员往往不知道从
何处着手
集群 3 众志成城 —— Cluster
RedisCluster Redis 的亲儿子,它是 Redis 作者自己提供的 Redis 集群化方案。
相对于 Codis 的不同,它是 去中心化 的,如图所示,该集群有三个 Redis 节点组成,
每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。这三个节点相
互连接组成一个对等的集群,它们之间通过一种特殊的二进制协议相互交互集群信息

Redis Cluster 可以为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其
中某个从节点提升为主节点。如果某个主节点没有从节点,那么当它发生故障时,集群将完
全处于不可用状态。不过 Redis 也提供了一个参数 cluster-require-full-coverage 可以允许部分
节点故障,其它节点还可以继续提供对外访问
络抖动
真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如
网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正
为解决这种问题, Redis Cluster 提供了一种选项 cluster-node-timeout ,表示当某个节点持
timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个
选项,网络抖动会导致主从频繁切换 ( 数据的重新复制 )
还有另外一个选项 cluster-slave-validity-factor 作为倍乘系数来放大这个超时时间来宽松容
错的紧急程度。如果这个系数为零,那么主从切换是不会抗拒网络抖动的。如果这个系数大
1 ,它就成了主从切换的松弛系数
可能下 线 (PFAIL-Possibly Fail) 与确定下 线 (Fail)
因为 Redis Cluster 是去中心化的 ,一个节点认为某个节点失联了并不代表所有的节点都
认为它失联了。所以集群还得经过一次协商的过程, 只有当大多数节点都认定了某个节点失
联了,集群才认为该节点需要进行主从切换来容错
Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变 。比
如一个节点发现某个节点失联了 (PFail) ,它会将这条信息向整个集群广播,其它节点也就可
以收到这点失联信息。如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集
群的大多数,就可以标记该节点为确定下线状态 (Fail) ,然后向整个集群广播,强迫其它节
点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换
Cluster 基本使用
Cluster 不支持事务, Cluster mget 方法相
Redis 要慢很多,被拆分成了多个 get 指令, Cluster rename 方法不再是原子的,它
需要将数据从原节点转移到目标节点
槽位迁移感知
第一个 moved 是用来纠正槽位的
第二个 asking 指令和 moved 不一样,它是用来临时纠正槽位的
集群 变更感知
当服务器节点变更时,客户端应该即时得到通知以实时刷新自己的节点关系表。那客户
端是如何得到通知的呢?这里要分 2 种情况:
目标节点挂掉了,客户端会抛出一个 ConnectionError ,紧接着会随机挑一个节点来
重试,这时被重试的节点会通过 moved error 告知目标槽位被分配到的新的节点地址。
运维手动修改了集群信息,将 master 切换到其它节点,并将旧的 master 移除集
群。这时打在旧节点上的指令会收到一个 ClusterDown 的错误,告知当前节点所在集群不可
( 当前节点已经被孤立了,它不再属于之前的集群 ) 。这时客户端就会关闭所有的连接,清
空槽位映射关系表,然后向上层抛错。待下一条指令过来时,就会重新尝试初始化节点信
拓展 1 耳听八方 —— Stream
Redis5.0 最大的
新特性就是多出了一个数据结构 Stream ,它是一个新的强大的支持多播的可持久化的消息队
列,作者坦言 Redis Stream 狠狠地借鉴了 Kafka 的设计
消息 ID
消息 ID 的形式是 timestampInMillis-sequence ,例如 1527846880572-5 ,它表示当前的消
息在毫米时间戳 1527846880572 时产生,并且是该毫秒内产生的第 5 条消息。消息 ID 可以
由服务器自动生成,也可以由客户端自己指定
删改查
1 xadd 追加消息
2 xdel 删除消息,这里的删除仅仅是设置了标志位,不影响消息总长度
3 xrange 获取消息列表,会自动过滤已经删除的消息
4 xlen 消息长度
5 del 删除 Stream
独立消
我们可以在不定义消费组的情况下进行 Stream 消息的独立消费,当 Stream 没有新消
息时,甚至可以阻塞等待。 Redis 设计了一个单独的消费指令 xread ,可以将 Stream 当成普
通的消息队列 (list) 来使用。使用 xread 时,我们可以完全忽略消费组 (Consumer Group)
的存在,就好比 Stream 就是一个普通的列表 (list)
# 从 Stream 头部读取两条消息
127.0.0.1:6379> xread count 2 streams codehole 0-0
1) 1) "codehole"
2) 1) 1) 1527851486781-0
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
2) 1) 1527851493405-0
2) 1) "name"
2) "yurui"
3) "age"
4) "29
客户端如果想要使用 xread 进行顺序消费,一定要记住当前消费到哪里了,也就是返回
的消息 ID 。下次继续调用 xread 时,将上次返回的最后一个消息 ID 作为参数传递进去,
就可以继续消费后续的消息
建消 费组
Stream 通过 xgroup create 指令创建消费组 (Consumer Group) ,需要传递起始消息 ID 参数用
来初始化 last_delivered_id 变量
Stream 提供了 xreadgroup 指令可以进行消费组的组内消费,需要提供消费组名称、消
费者名称和起始消息 ID 。它同 xread 一样,也可以阻塞等待新消息。读到新消息后,对应
的消息 ID 就会进入消费者的 PEL( 正在处理的消息 ) 结构里,客户端处理完毕后使用 xack
指令通知服务器,本条消息已经处理完毕,该消息 ID 就会从 PEL 中移除
Stream 消息太多怎么 ?
读者很容易想到,要是消息积累太多, Stream 的链表岂不是很长,内容会不会爆掉 ?xdel
指令又不会删除消息,它只是给消息做了个标志位。
Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 xadd 的指令提供
一个定长长度 maxlen ,就可以将老的消息干掉,确保最多不超过指定长度
Stream 的消费模型借鉴了 Kafka 的消费分组的概念,它弥补了 Redis Pub/Sub 不能持
久化消息的缺陷。但是它又不同于 kafka Kafka 的消息可以分 partition ,而 Stream 不行。
如果非要分 parition 的话,得在客户端做,提供不同的 Stream 名称,对消息进行 hash
模来选择往哪个 Stream 里塞
拓展 2 无所不知 —— Info 指令
Info 指令显示的信息非常繁多,分为 9 大块,每个块都有非常多的参数,这 9 个块分
别是 :
1 Server 务器运行的环境参数
2 Clients 户端相关信息
3 Memory 务器运行内存统计数据
4 Persistence 持久化信息
5 Stats 通用 统计数据
6 Replication 主从复制相关信息
7 CPU CPU 使用情况
8 Cluster 集群信息
9 KeySpace 键值对统计数量信息
Redis 每秒 执行多少次指令
info stats |grep ops
info clients
这个信息也是比较有用的,通过观察这个数量可以确定是否存在意料之外的连接。如果
发现这个数量不对劲,接着就可以使用 client list 指令列出所有的客户端链接地址来确定源
头。
关于客户端的数量还有个重要的参数需要观察,那就是 rejected_connections ,它表示因
为超出最大连接数限制而被拒绝的客户端连接次数,如果这个数字很大,意味着服务器的最
大连接数设置的过低需要调整 maxclients 参数
Redis 内存占用多大 ?
info memory | grep used | grep human
复制 积压缓冲区多大
info replication |grep backlog
拓展 3 遗漏补 —— 谈分布式锁
Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感
知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节
点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当
另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户
端同时持有,不安全性由此产生
不过 这种不安全也仅仅是在主从发生 failover 的情况下才会产生 ,而且持续时间极短,
业务系统多数情况下可以容忍。
Redlock 算法
加锁时,它会向过半节点发送 set(key, value, nx=True, ex=xxx) 指令,只要过半节点 set
成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还
需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读
写,意味着相比单实例 Redis 性能会下降一些
Redlock 使用 场景
如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock 。不
过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的
library ,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌
拓展 4 朝生暮死 —— 过期策略
过期的 key 集合
redis 会将每个设置了过期时间的 key 放入到一个 独立的字典中 ,以后会定时遍历这个
字典来删除到期的 key 。除了定时遍历之外,它还会使用惰性策略来删除过期的 key ,所谓
惰性策略就是在客户端访问这个 key 的时候, redis key 的过期时间进行检查,如果过期
了就立即删除。 定时删除是集中处理,惰性删除是零散处理
时扫描策略
Redis 默认会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key ,而是
采用了一种简单的贪心策略。
1 过期字典中随机 20 key
2 删除这 20 key 中已 经过期的 key
3 如果 过期的 key 比率超 1/4 那就重复步 1
同时,为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时
间的上限,默认不会超过 25ms
如果有大批量的 key 过期, 要给过期时间设置 一个随机范围 ,而不能全部在同一时间过期
库的过期策略
从库不会进行过期扫描, 从库对过期的处理是被动的 。主库在 key 到期时,会在 AOF
文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的
key
因为指令同步是异步进行的,所以主库过期的 key del 指令没有及时同步到从库的
话,会出现主从数据的不一致,主库没有的数据在从库里还存在,比如上一节的集群环境
布式锁的算法漏洞就是因为这个同步延迟产生的
拓展 5 优胜劣汰 —— LRU
Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的 交换 (swap)
交换会让 Redis 的性能急剧下降,对于访问量比较频繁的 Redis 来说,这样龟速的存取效率
基本上等于不可用
当实际内存超出 maxmemory 时, Redis 提供了几种可选策略 (maxmemory-policy) 来让
用户自己决定该如何腾出新的空间以继续提供读写服务。
noeviction 不会继续服务写请求 (DEL 请求可以继续服务 ) ,读请求可以继续进行。这样
可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
volatile-lru 尝试淘汰设置了过期时间的 key ,最少使用的 key 优先被淘汰。没有设置过
期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU ,而是 key 的剩余寿命 ttl 的值, ttl
越小越优先被淘汰。
volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key
allkeys-lru 区别于 volatile-lru ,这个策略要淘汰的 key 对象是全体的 key 集合,而不
只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。
allkeys-random 跟上面一样,不过淘汰的策略是随机的 key
volatile-xxx 策略只会针对带过期时间的 key 进行淘汰, allkeys-xxx 策略会对所有的
key 进行淘汰。如果你只是拿 Redis 做缓存,那应该使用 allkeys-xxx ,客户端写缓存时
不必携带过期时间。如果你还想同时使用 Redis 的持久化功能,那就使用 volatile-xxx
策略,这样可以保留没有设置过期时间的 key ,它们是永久的 key 不会被 LRU 算法淘
汰。
LRU 算法
实现 LRU 算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照
一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问
时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时
间顺序。 位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素就是最近刚
刚被人用过的元素,所以暂时不会被踢。
近似 LRU 算法
Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU
算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。近似
LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU
算法非常近似的效果。 Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字
段,这个字段的长度是 24 bit ,也就是最后一次被访问的时间戳。
上一节提到处理 key 过期方式分为集中处理和懒惰处理, LRU 淘汰不一样,它的处理
方式只有懒惰处理。当 Redis 执行写操作时,发现内存超出 maxmemory ,就会执行一次
LRU 淘汰算法。这个算法也很简单,就是随机采样出 5( 可以配置 ) key ,然后淘汰掉最
旧的 key ,如果淘汰后内存还是超出 maxmemory ,那就继续随机采样淘汰,直到内存低于
maxmemory 为止。
如何采样就是看 maxmemory-policy 的配置,如果是 allkeys 就是从所有的 key 字典中
随机,如果是 volatile 就从带过期时间的 key 字典中随机。每次采样多少个 key 看的是
maxmemory_samples 的配置,默认为 5
拓展 6 平波 缓进 —— 懒惰删除
删除指令 del 会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延
迟。不过如果删除的 key 是一个非常大的对象,比如一个包含了千万元素的 hash ,那么删
除操作就会导致单线程卡顿。
Redis 为了解决这个卡顿问题,在 4.0 版本引入了 unlink 指令,它能对删除操作进行懒
处理,丢给后台线程来异步回收内存。
> unlink key
OK
flush
Redis 提供了 flushdb flushall 指令,用来清空数据库,这也是极其缓慢的操作。
Redis 4.0 同样给这两个指令也带来了异步化,在指令后面增加 async 参数就可以将整棵大树
连根拔起,扔给后台线程慢慢焚烧。
> flushall async
OK
AOF Sync 也很慢
Redis 需要每秒一次 ( 可配置 ) 同步 AOF 日志到磁盘,确保消息尽量不丢失,需要调用
sync 函数,这个操作会比较耗时,会导致主线程的效率下降,所以 Redis 也将这个操作移到
异步线程来完成。执行 AOF Sync 操作的线程是一个独立的异步线程,和前面的懒惰删除线
程不是一个线程,同样它也有一个属于自己的任务队列,队列里只用来存放 AOF Sync
务。
更多异步 删除点
Redis 回收内存除了 del 指令和 flush 之外,还会存在于在 key 的过期、 LRU 淘汰、
rename 指令以及从库全量同步时接受完 rdb 文件后会立即进行的 flush 操作。
Redis4.0 为这些删除点也带来了异步删除机制,打开这些点需要额外的配置选项。
1 slave-lazy-flush 库接受完 rdb 文件后的 flush 操作
2 lazyfree-lazy-eviction 内存达到 maxmemory 时进行淘汰
3 lazyfree-lazy-expire key 过期删除
4 lazyfree-lazy-server-del rename 指令 删除 destKey
拓展 7 妙手仁心 —— 优雅地使用 Jedis
Java 程序一般都是多线程的应用程序,意味着我们很少直接使用 Jedis ,而是要用到
Jedis 的连接池 —— JedisPool 。同时因为 Jedis 对象不是线程安全的,当我们要使用 Jedis
对象时,需要从连接池中拿出一个 Jedis 对象独占,使用完毕后再将这个对象还给连接池
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class JedisTest {
    public static void main(String[] args) {
        JedisPool pool = new JedisPool();
        try (Jedis jedis = pool.getResource()) { // 用完自动 close
            doSomething(jedis);
        }
    }
    private static void doSomething(Jedis jedis) {
        // code it here
    }
}
拓展 8 居安思危 —— Redis
指令安全
Redis 有一些非常危险的指令,这些指令会对 Redis 的稳定以及数据安全造成非常严重
的影响。比如 keys 指令会导致 Redis 卡顿, flushdb flushall 会让 Redis 的所有数据全
部清空
Redis 在配置文件中提供了 rename-command 指令用于将某些危险的指令修改成特别的
名称,用来避免人为误操作。比如在配置文件的 security 块增加下面的内容 :
rename-command keys abckeysabc
如果还想执行 keys 方法,那就不能直接敲 keys 命令了,而需要键入 abckeysabc 。 如
果想完全封杀某条指令,可以将指令 rename 成空串,就无法通过任何字符串指令来执行这
条指令了。
rename-command flushall ""
端口安全
Redis 默认会监听 *:6379
运维人员务必在 Redis 的配置文件中指定监听的 IP 地址,避免这样的惨剧发
生。更进一步,还可以增加 Redis 的密码访问限制,客户端必须使用 auth 指令传入正确的
密码才可以访问 Redis
Lua 脚本安全
开发者必须禁止 Lua 脚本由用户输入的内容 (UGC) 生成,这可能会被黑客利用以植入
恶意的攻击代码来得到 Redis 的主机权限。
同时,我们应该让 Redis 以普通用户的身份启动,这样即使存在恶意代码黑客也无法拿
root 权限
SSL 代理
Redis 并不支持 SSL 链接,意味着客户端和服务器之间交互的数据不应该直接暴露在公
网上传输,否则会有被窃听的风险。如果必须要用在公网上,可以考虑使用 SSL 代理。
SSL 代理比较常见的有 ssh ,不过 Redis 官方推荐使用 spiped 工具,可能是因为
spiped 的功能相对比较单一,使用也比较简单,易于理解。下面这张图是使用 spiped ssh
通道进行二次加密 ( 因为 ssh 通道也可能存在 bug)
拓展 9 墙有耳 —— Redis 安全通信
应用部署在 A 机房,存储部署在 B 机房。如果使用普通 tcp 直接访问,因为跨机
房所以传输数据会暴露在公网,这非常不安全,客户端服务器交互的数据存在被窃听的风
Redis 本身并不支持 SSL 安全链接,不过有了 SSL 代理软件,我们可以让通信数据透
明地得到加密,就好像 Redis 穿上了一层隐身外套一样。 spiped 就是这样的一款 SSL 代理
软件,它是 Redis 官方推荐的代理软件
spiped 原理
让我们放大细节,仔细观察 spiped 实现原理。 spiped 会在客户端和服务器各启动一个
spiped 进程

每一个 spiped 进程都会有一个监听端口 (server socket) 用来接收数据,同时还会作为一
个客户端 (socket client) 将数据转发到目标地址。
spiped 进程需要成对出现,相互之间需要使用相同的共享密钥来加密消息
1 极度深寒 —— 探索 字符串 内部 结构
Redis 的字符串叫着「 SDS 」,也就是 Simple Dynamic String 。它的结构是一个带长度信
息的字节数组
Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 len capacity 一样
长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符
embstr vs raw
Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded) ,当
长度超过 44 时,使用 raw 形式存储
扩容策略
字符串在长度小于 1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空
间。当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分
1M 大小的冗余空间
2 极度深寒 —— 探索 字典 内部
dict Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到
字典外,整个 Redis 数据库的所有 key value 也组成了一个全局字典,还有带过期时间
key 集合也是一个字典。 zset 集合中存储 value score 值的映射关系也是通过 dict
构实现的
渐进式 rehash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元
素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 表示很难承受
这样耗时的过程。步子迈大了会扯着蛋,所以 Redis 使用渐进式 rehash 小步搬迁。虽然慢一
点,但是肯定可以搬完
3 极度深寒 —— 探索 压缩列表 内部
Redis 为了节约内存空间使用, zset hash 容器对象在元素个数较少的时候,采用压
缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任
何冗余空隙。
增加元素
因为 ziplist 都是紧凑存储,没有冗余空间 ( 对比一下 Redis 的字符串结构 ) 。意味着插
入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存
大小, realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可
能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。
如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist
不适合存储大型字符串,存储的元素也不宜过多
IntSet 小整数集合
set 集合容纳的元素都是整数并且元素个数较小时, Redis 会使用 intset 来存储结合
元素。 intset 是紧凑的数组结构,同时支持 16 位、 32 位和 64 位整数
4 极度深寒 —— 探索 快速列表 内部
Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表
linkedlist ,也就是元素少时用 ziplist ,元素多时用 linkedlist
为了进一步节约空间, Redis 还会对
ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度
5 极度深寒 —— 探索 跃列表 内部 结构
Redis zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value score
对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获
value 列表的功能,这就需要另外一个结构「跳跃列表」
zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist) hash 结构在讲字典结构时
已经详细分析过了,它很类似于 Java 语言中的 HashMap 结构
6 极度深寒 —— 探索 紧凑列表 内部
Redis 5.0 又引入了一个新的数据结构 listpack ,它是对 ziplist 结构的改进,在存储空间
上会更加节省,而且结构上也比 ziplist 要精简
7 极度深寒 —— 探索 基数 内部
Rax Redis 内部比较特殊的一个数据结构,它是一个有序字典树 ( 基数树 Radix
Tree) ,按照 key 的字典序排列,支持快速地定位、插入和删除操作。 Redis 五大基础数据结
构里面,能作为字典使用的有 hash zset hash 不具备排序功能, zset 则是按照 score
行排序的。 rax zset 的不同在于它是按照 key 进行排序的
参考 资料
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值