我把一套知识问答类社区的后端缓存体系,从单机 Redis 一路折腾到千万级 QPS 的过程完整拆开讲一遍。这个演进过程并不神秘,核心是解决三件事:单实例能力不够怎么办,数据量装不下怎么办,热点流量打崩节点怎么办。搞清楚这三个问题,你也能设计出一套扛住 2000 万 QPS 的 Redis 架构。
先说结论:2000 万 QPS 绝对不是靠某一种黑科技堆出来的,而是多级缓存、分片路由、连接层优化、热点治理共同作用的结果。真正打到 Redis 主进程的请求,可能只有总 QPS 的两到三成,剩下的都被客户端本地缓存和接入层挡掉了。这一点在后面的演进过程里会反复出现。
1. 演进的整体脉络与设计思路
1.1 为什么所有系统都是从单机起步
几乎每个业务系统刚上线时的 Redis 都是单机的,这不是偷懒,而是最合理的决策。早期用户量小,请求量低,一个 8 核 16G 的实例跑上几万 QPS 绰绰有余。方案越简单越好,单机没有网络分区问题,没有数据迁移问题,也没有一致性争议,谁都能维护。
我见过不少团队一上来就规划三机房五集群,最后发现维护成本比业务成本还高。Redis 的演进应该跟随业务曲线走,而不是跟着技术潮流走。单机 Redis 最大的价值就是让业务以最低复杂度跑起来,把精力先留给产品逻辑。
1.2 从单机到千万级的关键跳板
我们的演进路线大致经历了四个阶段:单机主备、读写分离、分片集群、多级缓存加接入层优化。每一步都是被逼的。
单机主备是为了解决宕机问题,主库挂了备库顶上,业务不中断。读写分离是为了解决读多写少场景下 CPU 和网卡被打满的问题,把读流量分散到从节点。分片集群是为了解决单机内存装不下的问题,把数据按 key 打散到多个节点。多级缓存和接入层优化才是冲击千万 QPS 的核心,因为不管单机还是集群,直接打到 Redis 的请求总量总是有限制的。
在规划演进路线时,我建议你画一张简单的流量分层图。最外层是客户端本地缓存,接着是代理层和接入层,再往里是 Redis 集群,最后是数据库。每一层都负责过滤掉一部分请求,层层递减,才不至于让 Redis 裸奔。
1.3 2000 万 QPS 的目标拆解
2000 万 QPS 听起来吓人,拆开算一下就没那么夸张。假设业务整体 2000 万 QPS,其中 60% 到 70% 是重复查询同一批热点数据,客户端本地缓存能挡掉其中的 95%。剩下的约 600 万到 800 万请求进入接入层,接入层再做一次短时间缓存,又能挡掉一半。最终到达 Redis 集群的请求量大概在 300 万到 500 万 QPS 左右。
如果把 500 万 QPS 分摊到 50 个分片实例上,每个实例只需要承担 10 万 QPS。这正是单实例 Redis 完全能够承受的范围。所以目标拆解很重要,你不需要让单个 Redis 去扛 2000 万,而是要让每一层都只做自己该做的事。
注意:做容量规划时永远别拿纯理论值去算,必须把缓存命中率、重复请求比例、热点集中度这些因素全部加进去,否则你会发现架构搭得再大也扛不住真实流量。
2. 单机时代的正确用法与瓶颈界定
2.1 单实例 Redis 的真实能力上限
很多人对 Redis 单实例 QPS 有误解,觉得官方 benchmark 能跑 10 万到 12 万 QPS,业务上就一定没问题。实测下来,生产环境中单实例能稳定跑 6 万到 8 万 QPS 已经很不错了,原因在于业务操作不会像 benchmark 一样全是简单的 SET 和 GET。
你会有 MGET、PIPELINE、LUA、事务、复杂查询,也会有超大 value 的读写。一旦命令的平均耗时从 0.05 毫秒涨到 0.3 毫秒,QPS 就会明显下滑。Redis 是单线程模型,所有命令串行执行,一个耗时 1 秒的慢命令就能让整个实例卡住。
我在单机阶段做过的优化按收益排序是这样的:第一,合并请求,把多次 RTT 合并成一次 PIPELINE,QPS 能翻一倍。第二,去掉可有可无的持久化,AOF 的 everysec 改成关闭或加大刷盘间隔,CPU 和 IO 压力立刻降下来。第三,减少大 Key,把超过 100KB 的 value 拆分或压缩,吞吐提升非常明显。
2.2 单机没崩之前要做的三件事
别等到 Redis 被压垮再治理,单机阶段就应该做三件基本功。第一是慢查询日志分析,定期捞取超过 100 毫秒的命令,逐个优化。慢命令往往是 SORT、KEYS、SMEMBERS 这类全量操作,必须处理掉。第二是大 Key 巡检,用 redis-cli --bigkeys 跑一遍,也能通过 DEBUG OBJECT 或 memory usage 命令统计,把大 Key 列入改造清单。第三是热点 Key 分析,提前识别流量集中在哪些 key 上,为后续改造做准备。
这三件事决定了你什么时候该主动升级架构。它们发生频率越高,说明单机越接近瓶颈。等 Redis 的机器 load average 持续超过 4,网卡跑满 70% 以上,CPU 出现单核 100% 而其他核空闲的情况,就可以启动下一阶段了。
2.3 单机架构下最容易忽视的隐患
单机部署还有一个经常被忽视的问题:机器故障恢复时间。没有主备的情况下,数据全在内存里,机器硬件故障意味着缓存全部丢失。虽然 Redis 数据可以从数据库回源,但瞬时回源会把数据库打垮,造成雪崩。
主备加上哨兵是单机阶段最必要的投资。主备之间用异步复制,主库正常写入,从库实时同步全量数据。主库宕机后,哨兵在几秒内完成主从切换,客户端重新感知新主节点,业务几乎无感。很多人觉得主备浪费一台机器,实际上它花的是最少的钱,买的是最关键的容灾能力,这账必须算清楚。
3. 主从与读写分离:水平扩展的第一步
3.1 主从复制的核心原理和注意点
主从复制的核心是 RDB 快照加命令传播。从库启动或断线重连时,主库生成一份 RDB 全量发送给从库,之后持续推送写命令流。这个机制有两个坑:全量同步期间主库要做 fork 生成快照,内存和 CPU 都会出现尖刺;从库数量太多时,主库要同时给多个从库发送不同阶段的增量数据,网络负担很重。
所以从库数量不能无节制增加。我在实践中通常控制在一主两从,最多一主三从。如果还需要更多只读副本,就把从库之间再挂一层级联复制,避免主库连接数爆炸。
注意:主从复制是异步的,严格来说存在数据丢失窗口。主库写入成功但尚未同步到从库时,主库宕机,这部分数据就丢了。对缓存系统来说通常可以接受,但你要有清醒认知,不要在主从架构上面做需要强一致性的业务逻辑。
3.2 读写分离的真正收益和实操姿势
读写分离的逻辑很简单:写请求打主库,读请求打从库。问题在于客户端怎么知道请求该往哪发。最粗放的方式是在代码里配置两个连接池,写操作走主池,读操作走从池。稍微讲究一点的做法是在代理层做读写分离。
我实测下来的收益是,从库分担了大约 65% 的读流量后,主库的 CPU 使用率直接从 82% 降到 35%,慢查询数量下降了 80%。但读写分离也引入了新问题,最主要的是复制延迟。从库的数据通常是几百毫秒到秒级的延迟,刚写完立刻去读,很可能读到旧数据。解决办法是代价极高的:要么业务上接受最终一致性,要么对敏感操作强制读主库。
3.3 哨兵高可用切换的实战体会
哨兵本身也是一个集群,至少三个节点,通过投票机制决定主库是否下线。这套机制能工作,但有一个特点必须知道:网络抖动时可能触发误切换。我曾经遇到一场事故,两个机房之间的网络抖动持续 30 秒,哨兵集群判断主库不可达,完成切换后网络又恢复,导致旧主库和新主库同时短暂存活,产生脑裂。
规避脑裂的办法是调大 quorum 和切换超时时间,让哨兵多确认几次再宣布主库死亡。慢一点没关系,缓存系统短暂不可写几千毫秒,远比脑裂造成的数据错乱损失小。哨兵模式下还要注意客户端的版本,新版客户端大多支持服务发现,能感知到主节点变化自动切换,老版本客户端就必须自己实现断线重连逻辑。
4. 分片集群:数据切分与容量扩展
4.1 为什么必须分片:内存和 CPU 的双重天花板
主从和读写分离解决的是能力瓶颈,但没有解决容量问题。单台机器内存再怎么加,总归有上限,而且 Redis 单线程模型对 CPU 的利用率也到了一个极限。当内存使用超过 30GB 时,RDB 快照的 fork 耗时和内存拷贝压力已经让线上操作出现明显抖动。
分片集群的本质是把数据按照某个规则打散到多台机器。分片后每个实例只负责全量数据的一部分,内存、CPU、网络全部得到了水平扩展。2000 万 QPS 的目标里,如果没有分片,集群不可能承载 500 万 QPS 的直接访问,因为单机的网卡和 socket 连接数已经先到极限了。
4.2 Redis Cluster 与代理分片两种路线的取舍
当时我们对比了 Redis Cluster 和代理分片方案。Redis Cluster 的优势是去中心化,客户端直连节点,没有代理层转发损耗;劣势是客户端要处理重定向逻辑,而且多 key 操作跨 slot 时不支持。
代理分片的优势是客户端完全无感知,不管底层怎么变,客户端只连代理。劣势是代理本身可能成为瓶颈和单点。我们的选择是分场景混用:核心缓存走集群直连,追求最低延迟;边缘业务走代理,降低客户端改造成本。
Redis Cluster 使用 16384 个哈希槽,每个 key 经过 CRC16 计算得到一个槽位,槽位分配到不同节点上。客户端拿到 key 的槽位后,如果和目标节点不一致,服务端会返回 ASK 或 MOVED 重定向,客户端需要重新发起请求。在压测环境下,这种重定向会带来一次额外 RTT,所以客户端要做好缓存路由表,尽量直接命中正确节点。
4.3 扩缩容实战:从扩容开始讲细节
扩容是分片集群最考验操作的部分。扩大 Redis Cluster 时,会先把新节点加入集群,然后从其他节点迁移部分哈希槽到新节点。迁移过程中,每个槽的数据会以 slot 为单位生成增量迁移任务,发送到目标节点,并持续同步新产生的写入。
这里最容易踩的坑是迁移速度太快导致源节点 CPU 飙升。Redis 迁移对源节点是扫描 key 加序列化加网络发送,过程中源节点内部还有内存拷贝。我在线上控制迁移速率,每秒钟迁移 80 到 120 个槽,单个大 key 迁移前先单独处理大 key,迁移期间监控源节点的 load average。整个集群扩到 40 个节点时,平滑程度仍然可控。
数据倾斜是分片之后必然会遇到的事。哈希槽是均匀的,但业务 key 的访问频率不是均匀的。一个超级热点 key 落到某个节点后,该节点流量会远超其他节点。这种场景要么做热点 key 的本地缓存,要么在 key 后面拼接随机后缀,打散到多个节点,由客户端负责聚合查询。
注意:设计 key 时要提前想好分片规则。如果某个业务场景必须频繁执行跨多 key 的操作,尽量让这些 key 落在同一个槽或同一个节点的同号段中,否则 Cluster 模式下就需要引入额外的中间层做合并,复杂度会指数级上升。
5. 从 Redis 本身到 Redis 生态:多级缓存与接入层优化
5.1 客户端本地缓存才是千万 QPS 的第一道防线
到了这个阶段,如果还想着让所有请求都打到 Redis 上,架构一定扛不住 2000 万 QPS。真正撑住流量的第一层,是客户端本地缓存,也就是进程内缓存。
一个进程内可以放一个带过期时间的本地缓存,比如全量 100 万条基础配置,设置成分钟级过期。热点数据的客户端缓存命中率在业务峰值时期可以稳定在 90% 以上。假设总请求量 200 万 QPS,本地缓存命中 180 万,打到 Redis 的就只剩 20 万。这就是多级缓存最核心的威力。
实现本地缓存时,要注意内存上限和淘汰策略。我用的是固定容量加 LRU 淘汰,每条记录预估容量并设置最大条目数,超过后淘汰最久未使用的。每次请求先查本地,未命中再查 Redis,查到后写入本地并设置过期时间。这里最关键的参数是过期时间,太短命中率上不来,太长容易读到旧数据,一般是秒级或分钟级,根据业务容忍度调整。
5.2 连接层的优化:连接池、协议多路复用和管道
Redis 的瓶颈之一在于连接数量。每个实例能承载的连接数受 fd 限制和内存限制影响,几万个连接会把网卡打满。优化方向是缩小连接总量。具体做法是客户端使用连接池,控制每个实例的连接数在 50 到 200 之间,而不是无限制创建连接。
第二个优化点是管道模式。业务中有大量小的读请求,每次请求来回一个 RTT,管道可以把多个命令一次性打包发出。实测使用管道后,同样场景下的 QPS 从 8 万提升到 16 万左右。第三个优化点是协议本身,Redis 2.6 之后的 RESP3 协议在纯文本解析上有优化,客户端升级到支持新协议的版本也能获得 10% 到 20% 的吞吐提升。
5.3 缓存一致性:多级缓存最容易翻车的地方
引入客户端本地缓存后,最大的问题是一致性。Redis 里的数据更新了,本地缓存怎么同步?常见做法有三种版本号轮询、过期时间容忍、主动失效推送。
版本号轮询是最稳妥的,每次请求带上版本号,低于最新版本的本地缓存直接作废。主动失效推送是让 Redis 作为 pub/sub 消息源,数据变更时发布 key 失效事件,各客户端订阅事件后移出本地缓存。前者增加一次判断,后者引入消息依赖。我采用的折中方案是:热点数据使用主动失效推送加秒级过期兜底,非热点数据只用过期时间。
延迟双删也是个常见技巧。先更新数据库,再删 Redis key,然后等几百毫秒,再删一次 Redis key。第一次删除是为了清掉旧数据,第二次删除是为了清掉在第一次删除和数据库更新之间可能被写入的脏数据。这个技巧能解决大多数并发场景下的缓存不一致,但不能根治,只能结合业务容忍度使用。
5.4 热点 Key 探测与击穿、雪崩的防护
千万级 QPS 系统最怕的三件事是缓存穿透、缓存击穿、缓存雪崩。
穿透是查询根本不存在的数据,每次都会打到数据库。解决办法是为空结果建立短失效时间的缓存,同时使用布隆过滤器快速过滤大概率不存在的 key。击穿是某个热点 key 缓存失效的瞬间,大量请求同时回源。解决办法是分布式锁或互斥锁让只有一个请求回源,其他请求短暂等待。雪崩是大规模 key 同一时间失效。解决办法是给过期时间加上随机扰动,让失效点分散,同时设置多级缓存兜底。
热点 key 探测可以用客户端统计加 Redis 端的 monitor 命令配合。生产环境里,我用本地计数器和 Redis 的 sorted set 做滑动窗口统计,识别一个时间窗口内访问频次超过阈值的 key,自动触发本地缓存预热和打散策略。这套机制上线后,热点 key 导致的单节点 CPU 抖动问题基本消失。
6. 高可用、监控与故障复盘
6.1 监控指标:只盯 QPS 远远不够
几千个 Redis 实例的监控,不能只看总 QPS,必须拆到实例。我常用的监控指标体系是:每个实例的 CPU 使用率、内存使用率、连接数、慢命令数量、网络入包出包速率、复制延迟、key 过期数量。每个指标都有对应的告警阈值。
慢命令数量是最容易被忽视的指标。Redis 单线程下,一个慢命令会阻塞整个实例,所以慢命令监控必须做成分钟级实时,只要出现一次耗时 500 毫秒以上的命令,立即告警。大 key 也要定期扫描,我把巡检脚本接入了定时任务,每天凌晨对全量实例做一次 bigkey 分析,结果推送到值班群,从源头降低故障概率。
6.2 典型故障:主从切换丢数据、热点打爆分片
我遇到的故障里,影响范围最大的一次是热点活动页面的 key 与其他大量数据落在同一个分片。活动爆发时,该分片的 CPU 先被打满,接着连接数超限,整个分片不可用。由于其他分片健康,部分路由请求会被重定向到故障分片,客户端不断重试,最终导致大面积超时。
处理过程分三步:第一步,通过监控定位到故障分片与热点 key。第二步,在客户端配置紧急降温开关,直接让热点 key 短时间变成动态 key,并开启本地缓存。第三步,等流量平稳后,把该分片的一部分哈希槽迁移出去,重新均衡数据分布。这次事件让我养成了两个习惯:大促前必须做热点 key 预分析,分片集群永远预留 20% 到 30% 的冗余容量。
主从切换丢数据的故障也遇到过。主库在写入后、从库完成同步前宕机,哨兵切换后,最近一小段时间的缓存数据丢失。由于我们底层有数据库兜底,最终的一致性问题不大,但业务确实出现了短时间缓存回源。这个故障的结论是:对一致性敏感的数据,不要放在 Redis 异步复制链路里,要么走数据库,要么做特殊标记允许回源。
6.3 容量规划与压测方法
架构演进不能靠感觉,必须用压测数据说话。我做的压测是分层的:先压单实例,找出单个 Redis 在当前硬件上的最大稳定 QPS;再压集群,找出分片后整体吞吐;最后压全链路,加入客户端本地缓存和接入层,看整体能扛多少。
压测工具用 redis-benchmark 打底,再用自研压测脚本模拟真实业务流量分布。这里的关键是要模拟热点倾斜,不能假设每个 key 访问次数都一样。真实环境往往是 20% 的 key 承担 80% 的流量,压测里如果不加热点 key,结果会和线上差距很大。用真实流量回放方式压测是最准确的,压测时同步采集 Redis 端的慢命令、CPU、延迟三项数据,综合判断容量水位。
7. 常见问题与排查技巧实录
7.1 缓存穿透问题的排查套路
线上突然出现数据库负载飙升,第一反应是查缓存穿透。我一般会先看 Redis 的命中率监控是否突然下降,再看数据库的慢日志是不是出现了大量同一个空 key 的查询,或者多个不存在的 key 轮询查询。如果确认穿透,立刻加布隆过滤器加载全部合法 key,并为空查询设置短缓存,比如 30 秒到 60 秒。
布隆过滤器实现时要注意误判率的参数设置。通常在数据量 1000 万时,使用 10 个 bit 位加 7 个哈希函数,误判率在 1% 左右,足够满足业务。过滤器冷启动时可以分批加载 key,配合 Redis 里的 bitmap 或独立的布隆模块,整体影响可控。
7.2 热点 Key 导致 CPU 抖动的定位方法
CPU 抖动时,先在 Redis 实例上用 monitor 命令抓取当前正在执行的命令,观察是否有某个 key 或某条命令高频出现。再配合 INFO commandstats 查看命令调用次数分布。如果确认热点 key,立即在客户端对该 key 做短时间本地缓存,或将 key 打散成带随机后缀的多个子 key。
打散后的 key 在查询时必须经过客户端聚合,比如 set 了 64 个分片 key,一次查询需要并行访问全部 64 个分片再聚合结果。这里要评估聚合带来的额外开销,通常热点 key 的 value 不大,并行查询反而会提升整体性能。压测数据表明,打散 16 份时聚合消耗可以忽略,但打散 256 份时聚合耗时反而上升。
7.3 分片集群里的数据倾斜与事务边界
排查数据倾斜,先用 CLUSTER SLOTS 查看各节点槽位分布,再用 INFO memory 对比各节点内存占用。如果有节点内存明显高于其他节点,就用 RANDOMKEY 加 DEBUG OBJECT 找出大体积 key,然后针对性地迁移到空闲节点或拆分。
集群模式下事务边界是一个必须注意的问题。Redis Cluster 默认不支持跨 slot 事务,某些业务逻辑依赖多个 key 原子操作时,就必须改用 LUA 脚本并将所有 key 声明到同一个哈希标签中。哈希标签是 key 中包含花括号的部分,例如 {user:123}:profile 和 {user:123}:orders 会路由到同一个槽位。我强烈建议在设计 key 阶段就规划好跨 key 操作。
7.4 一次真实失败案例的完整复盘
有一次我们上线新的缓存预热逻辑,批量往 Redis 写入大量 key。由于没有控制写入速率,预热脚本把某个分片的网络带宽打满了,导致该分片的正常读写全部超时。这次故障让我明白,预热和批量写入操作必须有自我保护机制,必须做速率限制和分片间串联执行,不能一窝蜂并发写。
从那之后,所有批量操作都增加了严格的控制层,包括每秒写入量限制、单条数据大小上限检查、失败重试和退避机制。Redis 的系统能力没有上限问题,但当业务系统没有自控能力时,系统越强伤害越大。这个教训比任何技术方案都深刻。
经历了这一切之后,我最大的体会是:架构演进不是做加法,而是做减法。你要逐渐减少 Redis 核心节点需要处理的请求量,而不是想方设法提高单节点的处理能力。缓存命中率、连接复用率、热点打散率这三个指标,才是我日常最关心的。2000 万 QPS 不是某个架构的终点,只要业务还在增长,就会有新的瓶颈出现。但只要你把每一层的边界划分清楚,把监控和治理做在前面,再大的流量都有路可走。