Administrator
发布于 2021-11-24 / 3460 阅读
77

Redis 6 多线程 IO 与客户端缓存

升级背景:单核 CPU 打满了

11 月初我们把缓存集群从 Redis 5.0.9 升到了 6.2.5。起因很直接:大促前压测,某个分片的 CPU 到了 92%,但机器是 8 核的——也就是说 Redis 把一个核跑满了,剩下 7 个核在旁边看着。

$ redis-cli -h 10.0.4.11 info stats | grep instantaneous_ops
instantaneous_ops_per_sec:82413

$ top -Hp $(pgrep redis-server)
  PID  %CPU
 1201  92.3     ← 只有一个线程在忙

这是 Redis 单线程模型的老问题:命令执行是单线程的,不管多少核,一个实例最多吃满一个核。

Redis 6 的多线程到底多线程在哪

这是最容易误解的地方。Redis 6 的多线程只做网络 IO,命令执行仍然是单线程

具体分工:

  • 主线程:接受新连接、把就绪的 socket 分配到队列、执行命令、把待回写的 socket 分配出去。
  • IO 线程:并行做 socket 读取 + 协议解析,以及响应回写。这些线程完全不碰数据,不执行任何命令。

所以它没有改变"单线程执行命令"这个核心设计,也就没有引入并发访问数据的问题,不需要加锁。它优化的是:协议解析和数据拷贝这部分纯 CPU 开销。

开启方式:

# redis.conf
io-threads 4
io-threads-do-reads yes

io-threads 官方建议:4 核机器设 2 到 3,8 核设 6。不要设成等于核数,要给主线程留余量。我们是 8 核容器,保守设了 4。

io-threads-do-reads 默认 no,只开写不开启读。必须显式设成 yes 才启用读的多线程。这两个参数都可以在运行时改:

127.0.0.1:6379> CONFIG SET io-threads 4
127.0.0.1:6379> CONFIG GET io-threads*
1) "io-threads"
2) "4"
3) "io-threads-do-reads"
4) "yes"

实测提升多少

redis-benchmark 在两个版本上跑了同样的压测(同机器,8 核 16 G,压测机和 server 分开)。

$ redis-benchmark -h 10.0.4.11 -p 6379 -t set,get -n 1000000 -c 200 -q
$ redis-benchmark -h 10.0.4.11 -p 6379 -t set,get -n 1000000 -c 200 -P 16 -q   # pipeline 16
场景Redis 5.0.96.2.5(io-threads=1)6.2.5(io-threads=4)
GET(无 pipeline)78,431 ops/s80,12495,203(+21%)
SET(无 pipeline)74,10275,33889,441(+21%)
GET(pipeline 16)412,338418,002733,412(+78%)
SET(pipeline 16)398,221401,554708,993(+78%)
P99 延迟(生产流量)1.82 ms1.79 ms0.94 ms

两个观察:

  • 6.2 不开多线程(io-threads=1)和 5.0 基本没差别,别指望升级版本本身能提速。
  • pipeline 和大包的收益(+78%)远高于小命令(+21%)。因为小命令场景下每个请求的网络 IO 量小,瓶颈不在解析;pipeline 一次塞 16 条命令,协议解析的工作量集中了,多线程才有发挥空间。

我们线上的实际效果:峰值从 8.2 万 QPS 涨到 13.7 万(+67%),单核 CPU 峰值降到 71%,P99 从 1.82 ms 降到 0.94 ms。收益比 benchmark 好,因为我们的 value 普遍偏大(平均 2.4 KB),吃到了回写多线程的红利。

所以结论是:大 value、pipeline、批量操作多的场景,开多线程 IO 收益明显;全是小 key 简单命令的话,提升有限。

RESP3 协议

Redis 6 引入了 RESP3,这是自 RESP2 之后协议层的第一次大改。客户端连接后要主动声明:

127.0.0.1:6379> HELLO 3
1# "server" => "redis"
2# "version" => "6.2.5"
3# "proto" => (integer) 3
4# "id" => (integer) 14
5# "mode" => "cluster"
6# "role" => "master"
7# "modules" => (empty array)

RESP2 只有五种类型(simple string、error、integer、bulk string、array),表达能力不够。比如 LPOP 一个不存在的 key 返回 nil,一个空列表也返回 nil,客户端分不清;浮点数只能当 bulk string 传。

RESP3 新增了这些类型:

类型前缀用途
Null_明确区分"没有值"
Double,浮点数,如 ,3.14
Boolean##t / #f
Big number(任意精度整数
Blob error!二进制安全的错误信息
Verbatim string=带编码信息的字符串
Map%键值对,HGETALL 可以直接返回
Set~无序集合
Attribute|附加元数据
Push>带外数据,服务端主动推

最实用的两个:MapHGETALLXREAD 这类命令的返回结构清晰了,客户端不用再把扁平数组两两配对;Push 让服务端可以在正常响应之外主动向客户端推消息——这是客户端缓存的基础。

兼容性上,RESP3 的 server 可以同时服务 RESP2 的客户端(默认连上来就是 RESP2),所以不需要一次性升级所有客户端。我们的 Lettuce 6.1.5 支持 RESP3,但没开,因为 RESP3 的 Java 客户端生态在 2021 年还不够成熟,遇到问题的排查成本高。

客户端缓存(Client-side caching)

这个功能也叫 tracking,思路是:客户端在本地缓存 key 的值,服务端记住"哪个客户端读过哪个 key",一旦这个 key 被修改,主动推一条失效消息给对应客户端。

# 客户端 2 订阅失效消息
127.0.0.1:6379> CLIENT ID
(integer) 12
127.0.0.1:6379> SUBSCRIBE __redis__:invalidate

# 客户端 1 开启 tracking,把失效消息重定向给客户端 2
127.0.0.1:6379> CLIENT TRACKING on REDIRECT 12
OK
127.0.0.1:6379> GET user:1001          # 服务端记下:客户端 1 读过 user:1001
"zhang"

# 另一个连接修改了这个 key
127.0.0.1:6379> SET user:1001 "li"

# 客户端 2 收到推送
1) "invalidate"
2) 1) "user:1001"

两种工作模式:

  • 默认模式:服务端为每个客户端维护一张"读过的 key"表。优点是推送精确,缺点是服务端要额外内存。表大小由 tracking-table-max-keys 限制(默认 100 万),满了会淘汰,被淘汰的 key 会收到一条"可能已失效"的消息,保守处理即可。
  • 广播模式(BCAST):服务端不记具体 key,客户端声明自己关心哪些 key 前缀,服务端在这个前缀下的 key 变化时广播给所有订阅者。省服务端内存,但推送不精确,客户端要做更多判断。
CLIENT TRACKING on REDIRECT 12 BCAST PREFIX user: PREFIX sku:

我们评估后没上这个功能,三个理由:

  • 需要客户端连接常驻。我们的服务在 K8s 里滚动发布,每次重启都要重建 tracking 关系,期间本地缓存的失效通知会漏掉。
  • 失效消息依赖 RESP3 的 Push 类型,Lettuce 6.x 对这块的支持在 2021 年还不完整,出问题不好查。
  • 我们已经用 Caffeine 本地缓存(5 分钟过期)解决了热点数据的读取压力,命中率 91%,再上客户端缓存的边际收益不大,但复杂度上升明显。

如果你的热点 key 极度集中、而且能接受一个较长生命周期的长连接,这个功能挺有价值——理论上能把热点读的网络往返完全消除。但对我们当时的场景,性价比不够。

顺带:6.x 其他用得上的改动

  • ACL(6.0):终于有多用户了。ACL SETUSER readonly on >pwd ~cache:* +get +mget,可以给运维和监控账号设最小权限。
  • GETDEL / GETEX(6.2):原子地"取值并删除"和"取值并设过期时间",以前要 Lua 或者两条命令。
  • COPY(6.2):跨实例复制 key,迁移数据方便。以前只能 DUMP + RESTORE
  • STRALGO LCS(6.0):字符串最长公共子序列,小众但偶尔有用。
  • SSL/TLS 支持(6.0):跨机房同步终于能加密了。

小结

  • Redis 6 的多线程只处理网络 IO(读、解析、回写),命令执行仍是单线程,所以没有并发安全问题,也不会突破单核的命令处理上限。
  • 配置 io-threads N + io-threads-do-reads yes,两者都要设。N 要小于核数,8 核设 4 到 6。
  • 提升幅度和数据特征强相关:我们 pipeline 场景 +78%、普通小命令 +21%、生产流量峰值 +67%(因为 value 平均 2.4 KB)。小 key 简单命令的收益很有限。
  • RESP3 新增了 Map、Push、Double 等类型。Push(带外推送)是客户端缓存的基础。它兼容 RESP2 客户端,不用一次性全升级。
  • 客户端缓存(CLIENT TRACKING)分默认模式和 BCAST 广播模式,前者精确但费服务端内存,后者省内存但推送粗。滚动发布环境要谨慎,连接重建期间失效通知会漏。
  • 我们的做法:开了多线程 IO,没上客户端缓存(Caffeine 本地缓存已经够用),RESP3 保持观望。

参考