proxy热key缓存能力
背景
本文主要考虑的是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的命中与未命中情况,是对缓存效果的”旁路观测”,本身不影响请求的处理路径。
命令处理流程
一个请求到来时,有以下操作步骤
- 查看当前
hks_map是否缓存了该热cmd,如果缓存了,直接将对应访问次数+1 - 如果没有缓存,并达到了当前
hks_map限制的数量(如1024),直接将hks_map访问次数少的一半清理掉,然后将当前cmd的访问次数置为1,并加入hks_map。 - 查看
hkc_map是否有该cmd条目,并缓存有相应rsp回复,如果有,增加该cmd的命中次数hit_qps,并直接将对应的rsp返回给请求方。 - 如果有该cmd条目,但没有rsp回复,说明请求没有命中缓存,如请求还在发送处理中,增加热key总qps次数但不增加命中次数。用于统计命中率。
一个回复到来时:
- 查看当前hkc_map是否有当前的cmd,如果有,直接更新其对应的rsp结构,并记录当前缓存时间,往返延迟。
缓存真实时间
考虑这样一种场景,某个热cmd同时是大key,那么其请求往还延时会很长,如果我们只按设置的缓存时间缓存这个rsp,势必导致很长的一段时间比例中,回复没有被缓存(过期后等待新的rsp)。
因此我们实际的缓存时间为,expire_ms + latency_ms。
backup_req
backup_req是一种重要缓存保持机制。以上的方案当缓存过期时,会存在大量的流量穿透到redis-server,以获取新的rsp。
因此可以在每一次请求的处理中增加一环逻辑:
- 判断当前请求的rsp离过期时间是否大于一半的过期时间了,如果是的,我们可以把这次请求不用缓存返回,而是主动将其置为backup_req穿透到redis-server中,用于续约和获取新数据。
该机制在热key持续存在的情况下可以保持热key缓存一直存在,而不会引发缓存雪崩。
