Skip to content

开发运维陷阱

flushall/flushdb 误操作

Redis 的 flushall/flushdb 命令可以做数据清除,对于 Redis 的开发和运维人员有一定帮助,然而一旦误操作,它的破坏性也是很明显的。

缓存与存储

被误操作 flush 后,根据当前 Redis 是缓存还是存储使用策略有所不同:

缓存:
对于业务数据的正确性可能造成损失还小一点,因为缓存中的数据可以从数据源重新进行构建。
如果业务方并发量很大,可能会对后端数据源造成一定的负载压力,这个问题也是不同忽视

存储:
对业务方可能会造成巨大的影响,也许 flush 操作后的数据是重要配置,也可能是一些基础数据,也可能是业务上的重要一环,如果没有提前做业务降级操作,那么最终反馈到用户的应用可能就是报错或者空白页面等,其后果不堪设想。
即使做了相应的降级或者容错处理,对于用户体验也有一定的影响。

所以 Redis 无论作为缓存还是作为存储,如何能在 flush 操作后快速恢复数据才是至关重要的。

借助 AOF 机制恢复

Redis 执行了 flush 操作后,AOF 持久化文件会受到什么影响呢?如下所示:

  • appendonly no: 对 AOF 持久化没有任何影响,因为根本就不存在 AOF 文件
  • appendonly yes: 只不过是在 AOF 文件中追加了一条记录,例如下面就是 AOF 文件中的 flush 操作记录:
1
2
3
*1
$8
flushall

虽然 Redis 中的数据被清除了,但是 AOF 文件还保存着 flush 操作之前完整的数据,这对恢复数据是很有帮助的。
注意问题如下:

(1) 如果发生了 AOF 重写,Redis 遍历所有数据库重新生成 AOF 文件,炳辉覆盖之前的 AOF 文件。所以如果 AOF 重写发生了,也就意味着之前的数据就丢掉了,那么利用 AOF 文件来恢复的办法就失效了。
所以当误操作后,需要考虑如下两件事:

  • 调大 AOF 重写参数 auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size,让 Redis 不能产生 AOF 自动重写
  • 拒绝手动 bgrewriteaof

(2) 如果要用 AOF 文件进行数据恢复,那么必须要将 AOF 文件中的 flushall 相关操作去掉,为了更加安全,可以在去掉之后使用 redis-check-aof 这个工具去检验和修复一下 AOF 文件,确保 AOF 文件格式正确,保证数据恢复正常。

RDB 有什么变化

Redis 执行了 flushall 操作后,RDB 持久化文件会受到什么影响呢?

(1) 如果没有开启 RDB 自动策略,也就是配置文件中没有类似如下配置:

1
2
3
save 900 1
save 300 10
save 60 10000

那么除非手动执行过 save、bgsave 或者发生了主从的全量复制,否则 RDB 文件也会保存 flush 操作之前的数据,可以作为恢复数据的数据源。注意问题如下:

  • 防止手动执行 save、bgsave,如果此时执行 save、bgsave,新的 RDB 文件就不会包含 flush 操作之前的数据,被老的 RDB 文件进行覆盖
  • RDB 文件中的数据可能没有 AOF 实时性高,也就是说,RDB 文件很可能很久以前主从复制生成的,或者之前用 save、bgsave 备份的

(2) 如果开启了 RDB 的自动策略,由于 flush 涉及键值数量较多,RDB 文件会被清除,意味着使用 RDB 恢复基本无望

综上所述,如果 AOF 已经开启了,那么用 AOF 来恢复是比较合理的方式,但是如果 AOF 关闭了,那么 RDB 虽然数据不是很实时,但是也能恢复部分数据,完全取决于 RDB 是什么时候备份的。
当然 RDB 并不是一无是处,它的恢复速度要比 AOF 快很多,但是总体来说对于 flush 操作之后不是最好的恢复数据源

从节点有什么变化

Redis 从节点同步了主节点的 flush 命令,所以从节点的数据也是被清除了,从节点的 RDB 和 AOF 的变化与主节点没有任何区别

快速恢复数据

下面使用 AOF 作为数据源进行恢复演练

(1) 防止 AOF 重写。快速修改 Redis 主从的 auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size 变为一个很大的值,从而防止了 AOF 重写的发生,例如:

1
2
config set auto-aof-rewrite-percentage 1000
config set auto-aof-rewrite-min-size 1000000000000

(2) 去掉主从 AOF 文件中的 flush 相关内容:

1
2
3
*1
$8
flushall

(3) 重启 Redis 主节点服务器,恢复数据

安全的 Redis

2015 年 11 月,全球数万个 Redis 节点遭受到了攻击,所有数据都被清除了,只有一个叫 crackit 的键存在,这个键的值很像一个公钥,如下所示:

1
2
3
4
5
127.0.0.1:6379> get crackit
"\n\n\nssh-rsa
AAAABB...
...
==root@zw_xx_192\n\n\n\n"

数据丢失对于很多 Redis 的开发者来说是致命的,经过相关机构的调查发现,被攻击的 Redis 有如下特点:

  • Redis 所在的机器有外网 IP
  • Redis 以默认端口 6379 为启动端口,并且是对外网开放的
  • Redis 是以 root 用户启动的
  • Redis 没有设置密码
  • Redis 的 bind 设置为 0.0.0.0 或者 ""

攻击者充分利用 Redis 的 dir 和 dbfilename 两个配置可以使用 config set 动态设置,以及 RDB 持久化的特性,将自己的公钥写入到目标机器的 /root/.ssh/authotrized_keys 文件中,从而实现了对目标机器的攻陷

Redis 密码机制

简单的密码机制

Redis 提供了 requirepass 配置提供密码功能,如果添加这个配置,客户端就不能通过 redis-cli -h {ip} -p {port} 来执行命令。
例如下面启动一个密码为 hello_redis-devops 的 Redis:

1
redis-server --requirepass hello_redis_devops

此时通过 redis-cli 执行命令会收到没有权限的提示:

1
2
3
redis-cli
127.0.0.1:6379> ping
(error) NOAUTH Authentication required.

Redis 提供了两种方式访问配置了密码的 Redis:

  • redis-cli -a 参数。使用 redis-cli 连接 Redis 时,添加 -a 加密码的参数,如果密码正确就可以正常访问 Redis 了,具体操作如下:
1
2
3
4
redis-cli -h 127.0.0.1 -p 6379 -a
hello_redis_devops
127.0.0.1:6379> ping
PONG
  • auth 命令。通过 redis-cli 连接后,执行 auth 加密码命令,如果密码正确就可以正常访问 Redis 了,具体操作如下:
1
2
3
4
5
redis-cli
127.0.0.1:6379> auth hello_redis_devops
OK
127.0.0.1:6379> ping
PONG

运维建议

这种密码机制能在一定程度上保护 Redis 的安全,但是在使用 requirepass 时候要注意以下几点:

  • 密码要足够复杂(64 个字节以上),因为 Redis 的性能很高,如果密码比较简单,完全是可以在一段时间内通过暴力破解来破译密码。
  • 如果是主从结构的 Redis,不要忘记在从节点的配置中加入 masterauth (master 的密码) 配置,否则会造成主从节点同步失效
  • auth 是通过明文进行传输的,所以也不是 100% 可靠,如果被攻击者劫持也相当危险

伪装危险命令

引入 rename-command

Redis 中包含了很多“危险”的命令,一旦错误使用或者误操作,后果不堪设想,例如如下命令:

  • keys: 如果键值较多,存在阻塞 Redis 的可能性
  • flushall/flushdb: 数据全部被清除
  • save: 如果键值较多,存在阻塞 Redis 的可能性
  • debug: 例如 debug reload 会重启 Redis
  • config: config 应该交给管理员使用
  • shutdown: 停止 Redis

理论上这些命令不应该给普通开发人员使用,那有没有什么好的方法能够防止这些危险的命令被随意执行呢?
Redis 提供了 rename-command 配置解决这个问题。
下面直接用一个例子说明 rename-command 的作用。例如当前 Redis 包含 10000 个键值对,现使用 flushall 将全部数据清除:

1
2
127.0.0.1:6379> flushall
OK

例如 Redis 添加如下配置:

1
rename-command flushall jlikfjalijl3i4jl3jql34j

那么再执行 flushall 命令的话,会收到 Redis 不认识 flushall 的错误提示,说明我们成功地用 rename-command 对 flushall 命令做了伪装:

1
2
127.0.0.1:6379> flushall
(error) ERR unknown command 'flushall'

而如果执行 jlikfjalijl3i4jl3jql34j(随机字符串),那么就可以实现 flushall 的功能了,这就是 rename-command 的作用,管理员可以对认为比较危险的命令做 rename-command 处理

没有免费的午餐

rename-command 虽然对 Redis 的安全有一定帮助,但是天下并没有免费的午餐。使用了 rename-command 时可能会带来如下麻烦:

  • 管理员要对自己的客户端进行修改,例如 jedis.flushall() 操作内部使用的是 flushall 命令,如果用 rename-command 后需要修改为新的命令,有一定的开发和维护成本
  • rename-command 配置不支持 config set,所以在启动前一定要确定哪些命令需要使用 rename-command
  • 如果 AOF 和 RDB 文件包含了 rename-command 之前的命令,Redis 将无法启动,因为此时它识别不了 rename-command 之前的命令
  • Redis 源码中有一些命令是写死的,rename-command 可能造成 Redis 无法正常工作。例如 Sentinel 节点在修改配置时直接使用了 config 命令,如果对 config 使用 rename-command,会造成 Redis Sentinel 无法正常工作

最佳实践

在使用 rename-command 的相关配置时,需要注意以下几点:

  • 对于一些危险的命令(例如 flushall),不管是内网还是外网,一律使用 rename-command 配置
  • 建议第一次配置 Redis 时,就应该配置 rename-command,因为 rename-command 不支持 config set
  • 如果涉及主从关系,一定要保持主从节点配置的一致性,否则存在主从数据不一致的可能性

防火墙

可以使用防火墙限制输入和输出的 ip 或者 ip 范围、端口或者端口范围,在比较成熟的公司都会对有外网 IP 的服务器做一些端口的限制,例如只允许 80 端口对外开放。
因为一般来说,开放外网 IP 的服务器中 Web 服务器比较多,但通常存储服务器的端口无需对外开放,防火墙是一个限制外网访问 Redis 的必杀技

bind

对于 bind 的错误认识

很多开发者在一开始看到 bing 的这个配置时都是这么认为的: 指定 Redis 只接收来自于某个网段 IP 的客户端请求。

但事实上 bind 指定的是 Redis 和哪个网卡进行绑定,和客户端是什么网段没有关系。
例如使用 ifconfg 命令获取当前网卡信息如下:

1
2
3
4
5
6
7
8
9
eth0      Link encap:Ethernet  Hwaddr 90:B1:1C:0B:18:02  
          inet addr:10.10.xx.192  Bcast:10.10.xx.255  Mask:255.255.255.0
          …
eth1      Link encap:Ethernet  Hwaddr 90:B1:1C:0B:18:03  
          inet addr:220.181.xx.123  Bcast:220.181.xx.255  Mask:255.255.255.0
          …
lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          …

包含了三个 IP 地址:

  • 内网地址: 10.10.xx.192
  • 外网地址: 220.181.xx.123
  • 回环地址: 127.0.0.1

如果当前 Redis 配置了 bing 10.10.xx.192,那么 Redis 访问只能通过 10.10.xx.192 这块网卡进入,通过 redis-cli -h 220.181.xx.123 -p 6379 和本机 redis-cli -h 127.0.0.1 都无法连接到 Redis

只能通过 10.10.xx.192 作为 redis-cli 的参数

1
redis-cli -h 10.10.xx.192

bind 参数可以设置多个,例如下面的配置表示当前 Redis 只接受来自 10.10.xx.192 和 127.0.0.1 的网络流量:

1
bind 10.10.xx.192 127.0.0.1

运维提示

配置 bind 0.0.0.0 表示不限制网卡的访问

  • 如果机器有外网 IP,但部署的 Redis 是给内部使用的,建议去掉外网网卡或者使用 bind 配置限制流量从外网进入
  • 如果客户端和 Redis 部署在一台服务器上,可以使用回环地址(127.0.0.1)
  • bind 配置不支持 config set,所以尽可能在第一次启动前配置好

Redis 3.2 提供了 protected-mode 配置(默认开启),它的含义可以用如下伪代码解释:

1
2
3
4
5
if (protected-mode && !requirepass && !bind) {
    Allow only 217.0.0.1,::1 or socket connections
        Deny (with the long message ever!)
    others
}

如果当前 Redis 没有配置密码,没有配置 bind,那么只允许来自本机的访问,也就是相当于配置了 bind 127.0.0.1

定期备份数据

天有不测风云,假如有一天 Redis 真的被攻击了(清理了数据,关闭了进程),那么定期备份的数据能够在一定程度挽回一些损失,定期备份持久化数据是一个比较好的习惯

不使用默认端口

Redis 的默认端口是 6379,不使用默认端口从一定程度上可降低被入侵者发现的可能性,因为入侵者通常本身也是一些攻击程序,对目标服务器进行端口扫描,
例如 MySQL 的默认端口 3306,Memcache 的默认端口 11211、Jetty 的默认端口 8080 等都会被设置成攻击目标,
Redis 作为一款较为知名的 NoSQL 服务,6379 必然也在端口扫描的列表中,虽然不设置默认端口还是有可能被攻击者入侵,但是能够在一定程度上降低被攻击的概率

使用非 root 用户启动

root 用户作为管理员,权限非常大。如果被入侵者获取 root 权限后,就可以在这台机器以及相关机器上“为所欲为”了。
建议在启动 Redis 服务的时候使用非 root 用户启动。
事实上,许多服务,例如 Resin、Jetty、HBase、Hadoop 都建议使用非 root 启动

处理 bigkey

bigkey 是指 key 对应的 value 所占的内存空间比较大,例如一个字符串类型的 value 可以最大存到 512MB,一个列表类型的 value 最多可以存储 2^{32} - 1 个元素。
如果按照数据结构来细分的话,一般分为字符串类型 bigkey 和非字符串类型 bigkey

  • 字符串类型: 体现在单个 value 值很大,一般认为超过 10KB 就是 bigkey,但这个值和具体的 OPS 相关
  • 非字符串类型: 哈希、列表、集合、有序集合,体现在元素个数过多

bigkey 无论是空间复杂度和时间复杂度都不太友好,下面将介绍它的危害

注意:
因为非字符串数据结构中,每个元素实际上也是一个字符串,但这里只讨论元素个数过多的情况

bigkey 的危害

bigkey 的危害体现在三个方面:

  • 内存空间不均匀(平衡): 例如在 Redis Cluster 中,bigkey 会造成节点的内存空间使用不均匀
  • 超时阻塞: 由于 Redis 单线程的特性,操作 bigkey 比较耗时,也就意味着阻塞 Redis 可能性增大
  • 网络拥塞: 每次获取 bigkey 产生的网络流量较大,假设一个 bigkey 为 1MB,每秒访问量为 1000,那么每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算时候 128MB/s) 的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个 bigkey 可能会对其他实例造成影响,其后果不堪设想

bigkey 的存,在并不是完全致命的,如果这个 bigkey 存在但是几乎不被访问,那么只有内存空间不均匀的问题存在,相对于另外两个问题没有那么重要紧急,但是如果 bigkey 是一个热点 key(频繁访问),那么其带来的危害不可想像,所以在实际开发和运维时一定要密切关注 bigkey 的存在

如何发现

redis-cli --bigkeys 可以命令统计 bigkey 的分布,但是在生产环境中,开发和运维人员更希望自己可以定义 bigkey 的大小,而且更希望找到真正的 bigkey 都有哪些,这样才可以去定位、解决、优化问题。
判断一个 key 是否为 bigkey,只需要执行 debug object key 查看 serializedlength 属性即可,它表示 key 对应的 value 序列化之后的字节数,例如我们执行如下操作:

1
2
3
127.0.0.1:6379> debug object key
Value at:0x7fc06c1b1430 refcount:1 encoding:raw serializedlength:1256350 lru:11686193 
    lru_seconds_idle:20

可以发现 serializedlength=11686193 字节,约为 1M,同时可以看到 encoding 是 raw,也就是字符串类型,那么可以通过 strlen 来看一下字符串的字节数为 2247394 字节,约为 2MB:

1
2
127.0.0.1:6379> strlen key
(integer) 2247394

serializedlength 不代表真实的字节大小,它返回对象使用 RDB 编码序列化后的长度,值会偏小,但是对于排查 bigkey 有一定辅助作用,因为不是每种数据结构都有类似 strlen 这样的方法。

在实际生产环境中发现 bigkey 的两种方式如下:

被动收集:
许多开发人员确实可能对 bigkey 不了解或重视程度不够,但是这种 bigkey 一旦大量访问,很可能就会带来命令慢查询和网卡跑满问题,开发人员通过对异常的分析通常能找到异常原因可能是 bigkey,这种方式虽然不被推荐,但是在实际生产环境中却大量存在,建议修改 Redis 客户端,当抛出异常时打印出所操作的 key,方便排查 bigkey 问题

主动检测:
scan + debug object: 如果怀疑存在 bigkey,分别计算每个 key 的 serializedlength,找到对应 bigkey 进行相应的处理和报警,这种方式是比较推荐的方式。

开发提示:

  • 如果键值个数比较多,scan + debug object 会比较慢,可以利用 Pipeline 机制完成
  • 对于元素个数较多的数据结构,debug object 执行速度比较慢,存在阻塞 Redis 的可能
  • 如果有从节点,可以考虑在从节点上执行。

如何删除

当发现 Redis 中有 bigkey 并且确认要删除时,如何优雅地删除 bigkey?
无论是什么数据结构,del 命令都将其删除。但是相信通过上面的分析后你一定不会这么做,因为删除 bigkey 通常来说会阻塞 Redis 服务。
下面给出一组测试数据分别对 string、hash、list、set、sorted set 五种数据结构的 bigkey 进行删除,bigkey 的元素个数和每个元素的大小不尽相同

注意:
下面测试和服务器硬件、Redis 版本比较相关,可能在不同的服务器上执行速度不太相同,但是能提供一定的参考价值

下表展示了删除 512KB 到 10MB 的字符串类型数据所花费的时间,总体来说由于字符串类型结构相对简单,删除速度比较快,但是随着 value 值的不断增大,删除速度也逐渐变慢

key 类型 512KB 1MB 2MB 5MB 10MB
string 0.22ms 0.31ms 0.32ms 0.56ms 1ms

下表展示了非字符串类型的数据结构在不同数量级、不同元素大小下对 bigkey 执行 del 命令的时间,总体上看元素个数越多、元素越大,删除时间越长,相对于字符串类型,这种删除速度已经足够可以阻塞 redis

key 类型 10万(8个字节) 100万(8个字节) 10万(16字节) 100万(16个字节) 10万(128个字节) 100万(128字节)
hash 51ms 950ms 58ms 970ms 96ms 2000ms
list 23ms 134ms 23ms 138ms 23ms 266ms
set 44ms 873ms 55ms 881ms 73ms 1319ms
sorted set 51ms 845ms 57ms 859ms 59ms 969ms

下图是上表的折线图,可以更加方便的发现趋势

删除bigkey耗时

从上分析可见,除了 string 类型,其他四种数据结构删除的速度有可能很慢,这样增大了阻塞 Redis 的可能性。
既然不能用 del 命令,那有没有比较优雅的方式进行删除呢?这时候就需要前面介绍的 scan 命令的若干类似命令拿出来: sscan、hscan、zscan

string

对于 string 类型使用 del 命令一般不会产生阻塞:

1
del bigkey

hash、list、set、sorted set

下面以 hash 为例子,使用 hscan 命令,每次获取部分(例如 100 个)field-value,再利用 hdel 删除每个 field(为了快速可以使用 Pipeline):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public void delBigHash(String bigKey) {
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    // 游标
    Strging cursor = "0";
    while (true) {
        ScanResult<Map.Entry<String, String>> scanResult = jedis.hscan(bigKey, cursor, new ScanParams().count(100));
        // 每次扫描后获取新的游标
        cursor = scanResult.getStringCursor();
        // 获取扫描结果
        List<Entry<String, String>>list = scanResult.getResult();
        if (list == null || list.size() == 0) {
            continue;
        }
        String[] fields = getFieldsFrom(list);
        // 删除多个 field
        jedis.hdel(bigKey, fields);
        // 游标为 0 时停止
        if (cursor.equals("0")) {
            break;
        }
    }
    // 最终删除 key
    jedis.del(bigKey);
}

/**
 * 获取field数组
 * @param list
 * @return
 */
private String[] getFieldsFrom(List<Entry<String, String>> list) {
    List<String> fields = new ArrayList<String>();
    for(Entry<String, String> entry : list) {
        fields.add(entry.getKey());
    }
    return fields.toArray(new String[fields.size()]);
}

开发提示:

请勿忘记每次执行到最后执行 del key 操作

最佳实践思路

由于开发人员对 Redis 的理解程度不同,在实际开发中出现 bigkey 在所难免,重要的是,能通过合理的检测机制及时找到它们,进行处理。
作为开发人员在业务开发时应注意不能将 Redis 简单暴力的使用,应该在数据结构的选择和设计上更加合理,例如出现了 bigkey,要思考一下可不可以做一些优化(例如拆分数据结构)尽量让这些 bigkey 消失在业务中,
如果 bigkey 不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要 hmget,而不是 hgetall)。
可喜的是, Redis 在 4.0 版本支持 lazy delete free 的模式,删除 bigkey 不会阻塞 Redis

寻找热点 key

热门新闻事件或商品通常会给系统带来巨大的流量,对存储这类信息的 Redis 来说却是一个巨大的挑战。
以 Redis Cluster 为例,它会造成整体流量的不均衡,个别节点出现 OPS 过大的情况,极端情况下热点 key 甚至会超过 Redis 本身能够承受的 OPS,因此寻找热点 key 对于开发和运维人员非常重要。
下面从四个方面来分析热点 key

客户端

客户端其实是距离 key "最近"的地方,因为 Redis 命令就是从客户端发出的,例如在客户端设置全局字典(key和调用次数),每次调用 Redis 命令时,使用这个字典进行记录,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 使用Guava的AtomicLongMap,记录key的调用次数 
public static final AtomicLongMap<String> ATOMIC_LONG_MAP = AtomicLongMap.create();
String get(String key) {
    counterKey(key);
    ...
}
String set(String key, String value) {
    counterKey(key);
    ...
}
void counterKey(String key) {
    ATOMIC_LONG_MAP.incrementAndGet(key);
}

为了减少对客户端代码的侵入,可以在 Redis 客户端的关键部分进行计数,例如 Jedis 的 Connection 类中的 sendCommand 方法是所有命令执行的枢纽。

1
2
3
4
5
6
7
public Connection sendCommand(final ProtocolCommand cmd, final byte[]... args) {
    // 从参数中获取key
    String key = analysis(args);
    // 计数
    counterKey(key);
    ...
}

同时为了防止 ATVOMIC_LONG_MAP 过大,可以对其进行定期清理

1
2
3
public void scheduleCleanMap() {
    ERROR_NAME_VALUE_MAP.clear();
}

使用客户端进行热点 key 的统计非常容易实现,但是同时问题也非常多:

  • 无法预知 key 的个数,存在内存泄漏的危险
  • 对于客户端代码有侵入,各个语言的客户端都需要维护此逻辑,维护成本较高
  • 只能了解当前客户端的热点 key,无法实现规模化运维统计

当然除了使用本地字典计数外,还可以使用其他存储来完成异步计数,从而解决本地内存泄漏问题。
但是另外两个问题还是不好解决。

代理端

像 Twemproxy、Codis 这些基于代理的 Redis 分布式架构,所有客户端的请求都是通过代理完成的。
此架构是最适合做热点 key 统计的,因为代理是所有 Redis 客户端和服务端的桥梁。
但并不是所有 Redis 都是采用此种架构。

Redis 服务端

使用 monitor 命令统计热点 key 是很多开发和运维人员首先想到的,monitor 命令可以监控到 Redis 执行的所有命令,下面为一次 monitor 命令执行后部分结果:

1
2
3
4
5
6
7
8
1477638175.920489 [0 10.16.xx.183:54465] "GET" "tab:relate:kp:162818"
1477638175.925794 [0 10.10.xx.14:35334] "HGETALL" "rf:v1:84083217_83727736"
1477638175.938106 [0 10.16.xx.180:60413] "GET" "tab:relate:kp:900"
1477638175.939651 [0 10.16.xx.183:54320] "GET" "tab:relate:kp:15907"
...
1477638175.962519 [0 10.10.xx.14:35334] "GET" "tab:relate:kp:3079"
1477638175.963216 [0 10.10.xx.14:35334] "GET" "tab:relate:kp:3079"
1477638175.964395 [0 10.10.xx.204:57395] "HGETALL" "rf:v1:80547158_83076533"

monitor统计热点key

如上图所示,利用 monitor 命令的结果就可以统计出一段时间内的热点 key 排行榜、命令排行榜、客户端分布等数据,例如下面的伪代码统计了最近 10 万条命令中的热点 key:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 获取10万条命令
List<String> keyList = redis.monitor(100000);
// 存入到字典中,分别是key和对应的次数
AtomicLongMap<String> ATOMIC_LONG_MAP = AtomicLongMap.create(); 
// 统计
for (String command : commandList) {
    ATOMIC_LONG_MAP.incrementAndGet(key);
}
// 后续统计和分析热点key
statHotKey(ATOMIC_LONG_MAP);

Facebook 开源的 redis-faina 正是利用上述原理使用 Python 语言实现的,例如下面获取最近 10 万条命令的热点 key、热点命令、耗时分布等数据。
为了减少网络开销以及加快输出缓冲区的消费进度,monitor 尽可能在本机执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
redis-cli -p 6380 monitor | head -n 100000 | ./redis-faina.py
Overall Stats
========================================
Lines Processed         50000
Commands/Sec            900.48
Top Prefixes
========================================
tab     27565   (55.13%)
rf              15111   (30.22%)
ugc     2051    (4.10%)
...
Top Keys
========================================
tab:relate:kp:9350      2110    (4.22%)
tab:relate:kp:15907     1594    (3.19%)
...
Top Commands
========================================
GET     25700   (51.40%)
HGETALL 15111   (30.22%)
...
Command Time (microsecs)
========================================
Median  622.75
75%     1504.0
90%     2820.0
99%     6798.0

此种方法会有两个问题:

  • monitor 命令在高并发条件下,会存在内存暴增和影响 Redis 性能的隐患,所以此种方法适合在短时间内使用
  • 只能统计一个 Redis 节点的热点 key,对于 Redis 集群需要进行汇总统计

机器

Redis 客户端使用 TCP 协议与服务端进行交互,通信协议采用的是 RESP。
如果站在机器的角度,可以通过对机器上所有 Redis 端口的 TCP 数据包进行抓取完成热点 key 的统计:

此种方法对于 Redis 客户端和服务端来说毫无侵入,是比较完美的方案,但是仍然存在两个问题:

  • 需要一定的开发成本,但是一些开源方案实现了该功能,例如 ELK(ElasticSearch Logstash Kiibana) 体系下的 packebeat 插件,可以实现对 Redis、MySQL 等众多主流服务的数据包抓取、分析、报表展示。
  • 由于是以机器为单位进行统计,要想了解一个集群的热点 key,需要进行后期汇总

最后我们总结出解决热点 key 问题的三种方案。选用哪种要根据具体业务场景来决定。下面是三种方案的思路。

(1) 拆分复杂数据结构:
如果当前 key 的类型是一个二级数据结构,例如哈希类型。
如果该哈希元素个数较多,可以考虑将当前 hash 进行拆分,这样该热点 key 可以拆分成若干个新的 key 分布到不同 Redis 节点上,从而减轻压力。

(2) 迁移热点key:
以 Redis Cluster 为例,可以将热点 key 所在的 slot 单独迁移到一个新的 Redis 节点上,但此操作会增加运维成本

(3) 本地缓存加通知机制:
可以将热点 key 放在业务端的本地缓存中,因为是业务端的内存中,处理能力要高出 Redis 数十倍,但当数据更新时,此种模式会造成各个业务端和 Redis 数据不一致,通常会使用发布订阅机制来解决类似问题