背景

本文主要考虑的是redis的代理架构,即客户端连接proxy,proxy通过一定的哈希算法将对应的key路由到对应redis-server上。

这种代理架构会将相同的key路由到同一个server上。而redis作为单线程程序,存在热key瓶颈问题,一般认为简单的string操作,热key的qps最多10w。

但是业务有时存在一种诉求,比如电商库存场景,业务可能短时间内存在大量的热key读请求,使得存在热点。

方案

在proxy上实现热key的自动探测和缓存能力,并能对外输出和展示热key列表和对应的qps。

考虑到在redis数据结构下,如果缓存的是热key,proxy势必需要实现一整套的redis数据结构。因此最终实现的是缓存热cmd(命令)的回复。

提供三个配置参数:

  • 热cmd最小qps: min_qps
  • 缓存过期时间: expire_ms
  • 缓存命令条数:cache_count

只有命令的qps大于min_qps时,该命令才会被缓存与展示,一条命令缓存成功后可以最小持续expire_ms的时间,一共允许缓存cache_count的命令数量。

数据结构

一秒一次,通过统计红黑树hks_map统计该1秒内的所有cmd的访问次数。即每秒都会清空hks_map

统计红黑树紧密关联的为缓存红黑树hkc_map,其存放了缓存的热cmd对应的

  • 收到回复时的时间戳
  • rsp回复
  • 该回复总延时,即发送过去时打个时间戳,收到时计算差值
  • 是否已经发送了backup_req(后文讲解)

在每秒清空hks_map的同时,会对其遍历,以生成一个新的hkc_map。查看是否有访问次数大于min_qps的命令,如果有,将其导入hkc_map,如果此时老的hkc_map还有对应的rsp缓存,我们将其复制到新的以防止替换过程的缓存失效。

以上为缓存功能的数据结构,为了展示热cmd,还存在一个聚合红黑树hk_record_map:

hk_record_map 定位

hk_record_map 是一个以 cmd(含 key 的命令字符串)为键的红黑树,每个节点记录该命令在一个统计窗口内的总请求数、缓存命中次数、响应延迟累计和最大响应延迟。它不参与缓存决策,仅作为旁路观测结构——在 hkc_map 返回结果(无论命中还是未命中)时顺带写入,最终通过 stats 接口对外展示热 key 的实际缓存效果。

数据流三阶段

第一阶段:采集(主线程,每次请求)

每当 hkc_map 完成一次查找后,会以 10% 的概率对当前 cmd 进行采样记录,将本次是否命中及响应延迟写入 hk_record_map。若该 cmd 已在树中存在,则累加计数和延迟;若不存在,则新建节点插入树中。采用采样而非全量记录,是为了降低主线程的统计开销。

第二阶段:聚合(主线程,定期执行)

每隔 5 秒,主线程遍历一次 hk_record_map,将所有节点打上当前时间戳后依次插入 curr_queue(一个单向队列),随后清空 hk_record_map,开始下一轮采集。为了将数据进一步转交给读取方,主线程每隔 300ms 尝试用非阻塞锁curr_queue 合并到 shadow_queue。若此时锁被 stats 线程持有,则直接跳过本次合并,不阻塞主线程。为防止队列无限增长,curr_queue 条目数超过 2048 时新条目会被直接丢弃。

第三阶段:展示(stats 线程,按需)

stats 线程读取 shadow_queue,将其中每个节点的数据序列化为 JSON 输出。由于采集时只记录了 10% 的采样,展示时需要将计数乘以 10 再除以 5(窗口秒数),还原出近似的真实 QPS。

关键设计点

特性 说明
采样率 10% 降低主线程每次请求的统计开销
双队列(curr / shadow) 主线程写 curr_queue,stats 线程读 shadow_queue,读写解耦
非阻塞 trylock 合并队列时若锁竞争则直接放弃,保证主线程不被统计逻辑阻塞
容量保护 curr_queue 超 2048 条时丢弃新条目,防止内存无限增长
QPS 还原 展示时 count × 10 / 5 = 实际 QPS

与 hks_map / hkc_map 的关系

三个红黑树各司其职,形成”统计 → 缓存 → 观测”的完整链路:

  • hks_map:统计所有 cmd 的请求频率,每秒清空一次,用于评选热 key。
  • hkc_map:存储热 key 的缓存响应,命中时直接返回,不再穿透到 redis-server。
  • hk_record_map:记录 hkc_map 的命中与未命中情况,是对缓存效果的”旁路观测”,本身不影响请求的处理路径。

命令处理流程

一个请求到来时,有以下操作步骤

  1. 查看当前hks_map是否缓存了该热cmd,如果缓存了,直接将对应访问次数+1
  2. 如果没有缓存,并达到了当前hks_map限制的数量(如1024),直接将hks_map访问次数少的一半清理掉,然后将当前cmd的访问次数置为1,并加入hks_map
  3. 查看hkc_map是否有该cmd条目,并缓存有相应rsp回复,如果有,增加该cmd的命中次数hit_qps,并直接将对应的rsp返回给请求方。
  4. 如果有该cmd条目,但没有rsp回复,说明请求没有命中缓存,如请求还在发送处理中,增加热key总qps次数但不增加命中次数。用于统计命中率。

一个回复到来时:

  1. 查看当前hkc_map是否有当前的cmd,如果有,直接更新其对应的rsp结构,并记录当前缓存时间,往返延迟。

缓存真实时间

考虑这样一种场景,某个热cmd同时是大key,那么其请求往还延时会很长,如果我们只按设置的缓存时间缓存这个rsp,势必导致很长的一段时间比例中,回复没有被缓存(过期后等待新的rsp)。

因此我们实际的缓存时间为,expire_ms + latency_ms

backup_req

backup_req是一种重要缓存保持机制。以上的方案当缓存过期时,会存在大量的流量穿透到redis-server,以获取新的rsp。

因此可以在每一次请求的处理中增加一环逻辑:

  1. 判断当前请求的rsp离过期时间是否大于一半的过期时间了,如果是的,我们可以把这次请求不用缓存返回,而是主动将其置为backup_req穿透到redis-server中,用于续约和获取新数据。

该机制在热key持续存在的情况下可以保持热key缓存一直存在,而不会引发缓存雪崩。