ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis分布式缓存性能优化实战:QPS提升与P99延迟治理

Redis分布式缓存性能优化实战:QPS提升与P99延迟治理

把分布式缓存里的 Redis 性能成倍提上去,多数时候靠的不是换更贵的机器,而是把几处长期被忽略的浪费堵住。前阵子帮一个做交易系统的团队看下单链路,压测时 QPS 卡在九千上下死活上不去,CPU 使用率三成出头,网卡也没跑满,但 P99 延迟已经在四百毫秒附近来回跳。他们第一反应是 Redis 扛不住了,准备申请扩容。我把慢日志拉出来扫了一遍,排在前面的是对一个几百字段的 hash 反复 HGETALL,以及循环里一条一条 GET 的写法。后来一台机器没加,只把客户端和服务端两头的活儿重新捋了一遍,同一个实例的 QPS 翻了将近四倍,P99 压到六十毫秒以内。

这件事之后我越来越确信,"分布式缓存"这四个字容易让人把注意力全放在集群、分片、扩容上,而真正决定 Redis 吞吐的,是那些每天都在跑、却没人愿意回头看的小细节。这篇内容适合两类人:一类是已经能把 Redis 跑起来、写得动 GET 和 SET,但一遇到"QPS 上不去""延迟莫名抖动""加了节点也没见好转"就抓瞎的后端同学;另一类是在做缓存方案设计,需要给团队一份能直接落地的性能清单的负责人。我会尽量把每个动作背后的"为什么"讲清楚,而不是丢一堆参数让你照抄。文中涉及的命令和配置都在本地物理机和容器环境里跑过,你可以直接拿去对照自己的环境。

1. 先把瓶颈找准:为什么你的Redis看起来"没跑满"却很慢

性能优化最容易犯的错,是一上来就改参数。我见过太多人把 io-threads 从 1 调到 8、把连接池从 50 加到 500,结果 QPS 纹丝不动,还把问题搅得更乱。Redis 的快慢从来不是单一维度的事,它是一条从业务线程发起调用、经过序列化、走网络、进内核缓冲区、被事件循环读取、解析命令、执行、再原路返回的完整链路。链路上任何一环堵住,你在监控面板上看到的都只是"QPS 上不去"这一个结果。

所以我习惯在动手之前先做一件事:把这条链路拆成四层,逐个用数据排除。这四层分别是客户端层、网络层、服务端层和数据模型层。客户端层看的是往返次数、连接复用、序列化开销;网络层看的是 RTT、带宽、是不是跨了机房;服务端层看的是 CPU 单核跑满没有、慢查询、持久化抖动;数据模型层看的是命令复杂度、key 大小、热 key 分布。多数"Redis 慢"的案子,答案落在第一层和第四层,反而跟 Redis 本身关系不大。

这里有个很反直觉的结论:Redis 单实例跑不满多核,不代表它没到瓶颈。它执行命令的主线程就是单核,这一核跑到 90% 以上,剩下的核再空闲也没用。很多监控面板展示的是整机 CPU 平均值,八核机器上主线程满载、其他核闲着,平均值只有百分之十几,看起来"很健康",实际上已经到顶了。判断是否真的到顶,要看单核使用率,或者直接看 INFO 里的 instantaneous_ops_per_sec 和主线程的 CPU 时间占比。

1.1 单线程模型说的是命令执行,不是整条链路

刚接触 Redis 的人常有个误解,以为"单线程"意味着从头到尾只有一个线程在干活。准确的说法是:命令的解析和执行是单线程串行的,网络读写和协议解析在 6.0 之后可以交给多个 IO 线程分担。这个区别很关键,它决定了你调优的着力点在哪里。

命令执行串行带来两个直接后果。第一,任何一条耗时长的命令都会把后面排队的请求一起拖住,比如对一个百万元素的集合做 SMEMBERS,或者对一个超大 hash 做 HGETALL,执行期间其他所有客户端的请求都在等。第二,单个实例的吞吐上限受限于单核的处理能力,一条简单命令的执行时间通常在微秒级,理论上单核能跑到十万级 QPS,但这个数字会被网络往返、协议解析、内存分配层层打折。

理解了这一点,优化思路就清晰了:要么让每条命令更快(降低复杂度),要么让单位时间内命令更少(合并、缓存、减少无效请求),要么把网络这部分的负担从主线程上卸下去(IO 多线程)。这三条路分别对应后面的数据建模、客户端批量化、服务端参数调整,方向不同但可以叠加。

1.2 四层瓶颈的典型信号对照表

光讲道理没用,排查时你需要一些能直接对号入座的信号。下面这张表是我这几年反复用的一张速查表,横轴是层级,纵轴是"你观察到的现象",中间是对应的怀疑方向。实际排查时不必严格按顺序,但建议从客户端层开始看,因为那一层改起来成本最低、收益往往最大。

层级典型现象优先怀疑方向
客户端层QPS 上不去但服务端 CPU 不高,连接数远大于并发线程数循环单条命令、连接频繁创建销毁、超时重试风暴
网络层客户端与服务端延迟差几十毫秒,带宽打满跨机房调用、value 体积过大、未启用管道
服务端层主线程单核跑满,慢日志有记录复杂命令、大 key、持久化 fork 抖动、IO 线程未开
数据模型层某些 key 的访问量占比极高,个别分片负载不均热 key、超大 hash、无过期时间的僵尸 key

表格里"连接数远大于并发线程数"这一条值得单独说。我见过一个服务,线程池 200,连接池最大 500,按说够用,但实际连接数常年维持在四百多。原因是代码里在 try-finally 之外还有一条异常分支没归还连接,赶上批量任务失败就会漏一批,池子被占满后新请求开始排队等连接,表现出来就是"Redis 慢",实际上是客户端自己把自己堵死了。这种问题改服务端参数一辈子也解决不了。

2. 客户端这一层最容易拿到倍速:往返次数就是吞吐天花板

如果只能改一处,我一定先改客户端。原因很简单:Redis 的一次命令往返,绝大部分时间花在网络上,而不是执行上。本地同机架环境下,一次 RTT 大概在 0.1 到 0.3 毫秒,看起来不多,但你要知道,一条 GET 的执行时间可能只有几微秒。也就是说,一次调用的时间里有百分之九十以上是"路上"消耗的。把十次往返合并成一次,理论上就能接近十倍的提升,这是所有优化手段里性价比最高的。

很多人对 RTT 没概念,觉得"内网嘛,肯定很快"。可以做个简单测算:假设你的服务每秒要发十万次 Redis 请求,每次 RTT 0.2 毫秒,那么光是等待往返的时间累计起来就是二十秒,显然不可能由一个线程串行完成,必须靠大量并发连接来摊平。而如果同样的数据用管道合并成一千次批量请求,每次带一百条命令,等待时间立刻降到两秒量级。这就是为什么我说往返次数就是吞吐的天花板。

2.1 一次网络往返到底吃掉多少时间

把一次调用拆开看,客户端线程要做的事情包括:从连接池借连接、把命令按 RESP 协议序列化写入 socket 缓冲区、等待可读事件、读取响应、反序列化、归还连接。这里面借还连接是纯内存操作,通常纳秒级;序列化和反序列化跟 value 大小正相关;真正的大头是"写出去"到"读回来"之间的等待。

这个等待时间由三部分构成:内核协议栈的处理、物理链路传输、服务端事件循环的调度。前两个你基本改不了,能动手的是第三个和"次数"。服务端事件循环在每个周期里会处理一批就绪的连接,如果你的请求分散在一千个连接上、每个连接一次只发一条命令,事件循环就要做一千次调度;如果合并成十个连接、每个连接一次发一百条,调度次数立刻降两个数量级。这就是管道(pipeline)的价值所在,它改变的不只是往返次数,还有服务端的调度效率。

顺带提一句超时设置。客户端的读超时千万不要设得太激进,我见过设 50 毫秒的,赶上一次正常的持久化 fork 抖动就大面积超时,然后触发重试,重试又把流量翻倍压回去,形成雪崩。一般的经验值是读超时设在 500 毫秒到 1 秒,写超时略长一点,配合熔断和降级来兜底,而不是靠一个很短的超时去"快速失败"。

2.2 管道、批量命令与连接池的组合拳

管道和批量命令是两回事,很多人混着用。批量命令指的是 MGET、MSET、HMGET 这类一次能操作多个 key 的原生命令,它们在服务端就是一次命令执行,天然节省往返。管道则是客户端侧的技术,把多条独立的命令攒在一起一次性发出去,服务端逐条执行、逐条回包,客户端一次性读完。两者可以叠加使用。

下面这段是常见的错误写法和改造后的写法对比,场景是批量读取用户信息:

# 反面示例:循环单条读取,N 次往返 for uid in uid_list: user = r.get(f"user:{uid}") result.append(user) # 改造一:批量命令,一次往返 keys = [f"user:{uid}" for uid in uid_list] values = r.mget(keys) # 改造二:管道,适合命令类型不一致的场景 pipe = r.pipeline(transaction=False) for uid in uid_list: pipe.get(f"user:{uid}") pipe.hget(f"user:stat:{uid}", "last_login") results = pipe.execute()

注意transaction=False这个参数,它的作用是关闭 MULTI/EXEC 包裹。默认的 pipeline 会带上事务语义,服务端需要额外维护队列,开销略大;如果你不需要原子性,关掉它性能更好。另一个细节是每批的数量,我一般控制在 100 到 500 条之间。批次太大会让服务端单次处理的响应体积过大,占用输出缓冲区,极端情况下触发客户端输出缓冲区限制被断开;批次太小又起不到合并效果,具体数值建议在自己的环境里试。

连接池的配置同样有讲究。池子大小的经验公式是"并发线程数 × 单线程内同时占用的连接数",一般设成线程数的 1 到 1.5 倍就够,盲目调大反而会加剧服务端的连接管理开销。获取连接的超时要比读超时短,这样在池子耗尽时能快速失败,而不是把线程全部挂住。还有一个容易被忽略的点:连接要预热。服务刚启动时池子里是空的,第一批请求会集中创建连接,赶上流量高峰启动就会有一波明显的延迟毛刺,建议在启动阶段主动建立最小空闲连接。

提示:管道不是越大越好。曾经有人为了追求极致,把一万条命令塞进一个管道,结果单次响应超过了几十兆,客户端读取时内存暴涨,还触发了服务端的输出缓冲区限制。批量操作的第一原则是"可控",宁可多分几批。

2.3 序列化与value体积:看不见的带宽税

往返次数解决之后,下一个瓶颈往往是体积。同样是一万次读取,value 平均 100 字节和平均 2KB,网络带宽占用差二十倍,反序列化的 CPU 开销也差好几倍。我遇到过把整个商品详情对象序列化成 JSON 塞进 String 的,单个 value 三十多KB,缓存命中率确实高,但每次读取都要在客户端解析一遍,光 JSON 解析就吃掉了接口一半的响应时间。

序列化方案的选择上,JSON 胜在可读性和跨语言,但体积偏大、解析偏慢;二进制方案如 Protobuf、MessagePack 体积能小百分之三十到五十,解析也快,代价是可读性差、改字段要重新生成代码。我的建议是:内部服务之间传递的结构化数据优先用二进制方案,需要人工排查或者对外暴露的场景再用 JSON。如果只能用 JSON,至少把字段名压短,别把一长串英文属性名反复写进每一条记录里。

还有一个更根本的问题:这个字段真的需要缓存吗。很多缓存设计是把数据库整行搬过来,包括那些几乎不变化的备注、描述、富文本。这些内容完全可以拆成单独的 key,按需加载,主缓存只放热点字段。把 value 从 30KB 压到 300 字节,效果比任何参数调优都直接。我在一个内容类项目里做过这样的拆分,主缓存平均体积降了百分之九十,同样的硬件 QPS 提升了约两倍,接口 P99 从一百多毫秒降到四十毫秒。

3. 服务端参数与IO线程:把单核之外的能力也用起来

客户端改完之后,如果主线程单核还是接近满载,就该动服务端了。这里要先泼一盆冷水:服务端参数调优的收益,通常远小于客户端改造。我见过的实际案例里,参数调整带来的提升多在百分之十到百分之五十之间,很少能翻倍。所以顺序不能反,先把客户端和数据模型理顺,再考虑这些。

不过有几个参数确实值得认真对待,尤其是 IO 多线程和持久化相关的配置,它们处理不好会带来莫名其妙的延迟尖刺,处理好了能让高并发场景下的尾延迟明显收敛。

3.1 io-threads开几个才合适

从 6.0 开始,Redis 支持把网络读写的活儿分给多个 IO 线程。需要强调的是,它并没有让命令执行变成多线程,主线程依然串行执行所有命令,IO 线程只负责从 socket 读取数据、解析协议、把响应写回 socket。所以它的收益主要体现在"连接数多、单条命令小、网络开销占比高"的场景,如果你的瓶颈是复杂命令的执行时间,开再多 IO 线程也没用。

配置项有两个:

# 在配置文件中设置,需要重启生效 io-threads 4 io-threads-do-reads yes

io-threads的取值官方建议是不超过 8,且要小于机器的物理核数。我的经验是四核机器设 2 到 3,八核设 4,十六核以上设 6 到 8,再往上加收益递减明显。io-threads-do-reads控制读操作是否也走多线程,早期版本默认关闭,需要显式打开;打开后读路径的并行度提升,在大量短连接、小命令的场景下效果比较明显。改完这两个参数记得用真实流量压一遍,我遇到过开了之后因为线程间同步开销反而略微下降的情况,机器核数少的时候尤其明显。

另外别忘了检查是否开启了透明大页(THP)。这个特性会让内存分配产生不可预期的延迟,Redis 官方明确建议关闭:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

这个操作要在部署脚本里固化下来,容器环境里有时候重启就恢复了。判断是否生效可以直接读这个文件,返回[never]就对了。

3.2 内核参数与持久化设置里的延迟尖刺

内核层面有两个参数经常被忽略。一个是somaxconn,它决定了已完成三次握手的连接队列长度,默认值在很多发行版上是 128,高并发建连时容易溢出,表现为偶发的连接超时。建议调到 1024 以上:

sysctl -w net.core.somaxconn=2048 sysctl -w net.ipv4.tcp_max_syn_backlog=2048

另一个是tcp-keepalive,Redis 配置里默认 300 秒,作用是探测死连接。这个值本身不影响性能,但如果你的环境里有大量短命客户端,建议配合连接池的保活机制一起调,避免半开连接堆积占用文件描述符。

持久化才是延迟尖刺的主要来源。RDB 落盘和 AOF 重写都要 fork 子进程,fork 本身会阻塞主线程,阻塞时间和内存页表大小正相关。一个用了二十多GB内存的实例,fork 一次可能要几百毫秒,这段时间所有请求都在排队。更麻烦的是 copy-on-write,写入越频繁,COW 复制的内存页越多,内存占用会短暂翻倍,极端情况下触发 OOM。

应对办法有几个:控制单实例内存不超过 10GB 到 16GB,让 fork 的代价可控;把save规则的触发频率降下来,比如原来 300 秒内有 10 次写就落盘,可以放宽到 60 次;AOF 用everysec而不是always,前者最多丢一秒数据,后者每条命令都要 fsync,性能差一个数量级。如果业务对丢失完全零容忍,那应该在上层做双写或者用消息队列补偿,而不是指望always来保平安。

3.3 内存淘汰策略与碎片整理

maxmemory-policy的选择经常被随手设成allkeys-lru就完事,其实不同策略的适用场景差别很大。纯缓存场景、所有 key 都可能被淘汰,用allkeys-lru没问题;如果实例里既有缓存数据又有需要持久保留的数据,应该用volatile-lru或volatile-lfu,只淘汰设了过期时间的 key,避免把不该删的删掉。

allkeys-lfu和volatile-lfu这两个基于访问频率的策略值得多用。LRU 只看"最近有没有被访问",一个刚被批量扫描过的冷 key 会被误判为热点;LFU 统计的是访问频次,对周期性批量任务更友好。开启 LFU 之后还能用redis-cli --hotkeys直接找出热点 key,排查问题时非常方便,前提是maxmemory-policy必须是 lfu 系列,否则这个命令会报错。

内存碎片也值得定期看一眼。INFO memory里的mem_fragmentation_ratio是分配器视角的内存与操作系统视角的内存之比,正常在 1.0 到 1.5 之间。超过 1.5 说明碎片比较严重,可以考虑开启主动碎片整理:

activedefrag yes active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 active-defrag-threshold-upper 100

这几个参数的意思是:低于 100MB 碎片不整理,碎片率超过 10% 开始干活,超过 100% 全力整理。碎片整理本身消耗 CPU,建议在业务低峰开启观察,别在压测时打开,否则你会看到 CPU 莫名其妙涨上去。

4. 数据建模决定性能上限:命令选型和key设计的硬功夫

前面说的都是"路怎么走更快",这一节说的是"货本身有多重"。数据模型一旦定错,后面所有优化都是在给一个错误的骨架打补丁。我见过最典型的例子是把用户的行为流水塞进一个 List,单 key 存了几百万条,每次分页查询都用一个很大的 range,这个操作在服务端是 O(N) 的,直接把主线程卡住几百毫秒。

命令复杂度这件事,很多人只知道个大概,真到写代码时又忘了。我把常见的坑整理成一张表,贴在工位旁边那种:

命令复杂度风险场景替代方案
KEYS patternO(N)任何生产环境用 SCAN 渐进遍历
HGETALLO(N)字段数超过几百HMGET 按需取,或拆分 hash
SMEMBERSO(N)集合元素过万SSCAN 分批,或用 ZSET 分页
LRANGE 大范围O(S+N)一次取几千条缩小范围,用游标分页
DEL 大 keyO(N)删除百万元素的 key用 UNLINK 异步删除
FLUSHALLO(N)大实例上执行禁止在生产使用

KEYS这条我得多说一句。它在小数据量下跑得飞快,测试环境里用着毫无问题,一上生产就出事。因为它是全库扫描,执行期间主线程完全被占住,几百万个 key 扫下来可能几百毫秒,这段时间所有请求都在等。扫描类需求一律用SCAN,它通过游标分批返回,每次只处理一小部分,虽然可能返回重复元素、需要客户端去重,但对线上几乎无影响。SCAN的count参数建议设 100 到 1000,别设太大,否则单次扫描时间又会变长。

DEL和UNLINK的区别也值得记牢。DEL是同步释放内存,删一个大 key 会阻塞;UNLINK先把 key 从键空间摘掉,真正的内存回收交给后台线程异步做,对主线程几乎无影响。4.0 之后都支持,新项目里删大 key 一律用UNLINK。

4.1 大key的拆分手法与迁移姿势

多大的 key 算大?没有绝对标准,我的经验线是:String 类型的 value 超过 10KB,集合类型的元素数超过 5000,或者单个 key 占用的内存超过 1MB,就该考虑拆了。判断方法很简单,用redis-cli --bigkeys扫一遍,它会按类型列出最大的几个 key。注意这个命令内部用的是 SCAN,但仍然会消耗一定的 CPU,务必在低峰执行,而且不要在生产环境频繁跑。

拆分的思路按类型不同:

  • 大 hash:按字段前缀分桶,比如user:info:{uid}拆成user:info:{uid}:base、user:info:{uid}:ext,或者按 uid 取模拆成多个 hash。前提是业务能接受多次查询,这就要权衡了。
  • 大 list:按时间或者序号分段,feed:{uid}:202401、feed:{uid}:202402,读取时按需拼接。社交场景的信息流基本都是这么做的。
  • 大 set:如果只是用来做去重判断,可以考虑换成布隆过滤器;如果要做交并集,按元素哈希分片到多个 set。
  • 大 zset:按分数区间分片,或者按成员前缀分片。

拆分之后还要考虑迁移。线上正在跑的大 key 不能直接删了重建,得平滑过渡。我的做法是双写加读补偿:新代码同时写新旧两个结构,读的时候先读新结构,miss 了再回源读旧结构并回填,等新结构的数据完整了,再用UNLINK把旧 key 干掉。整个过程观察一到两周,确认没有遗漏的读路径再收尾。

4.2 热key的两条解法:打散与就近

热 key 是另一类麻烦。某个明星的直播房间、某款秒杀商品、某条突发的社会新闻,访问量可能是普通 key 的几千倍。由于 Redis 集群是按 key 哈希分片的,这一个 key 必然落在某一个节点上,那个节点的 CPU 被打满,其他节点却在闲着。这时候加节点完全没用,因为新的节点分不到这个 key 的流量。

解法有两条路。第一条是打散:在 key 后面加随机后缀,hot:item:123变成hot:item:123:0到hot:item:123:9,读的时候随机挑一个。这要求数据是只读的或者能容忍短暂不一致,写的时候十个副本都要更新。副本数一般设 5 到 10 个,太多了写放大严重。

第二条是就近:在应用进程内加一层本地缓存,比如用进程内的 LRU 结构,把最热的那部分数据放在离 CPU 最近的地方,连网络都不用走。本地缓存的有效期设短一点,一秒到五秒,既能挡住绝大部分热点流量,又不至于和 Redis 的数据差太远。这个方案对一致性要求不极端的场景几乎是必选项,代价是每个实例都要占一点内存,以及需要考虑失效通知。

还有一种辅助手段是给热点 key 单独扩容。有些集群方案支持把指定的 key 迁移到独立节点上,或者用读写分离让多个从节点分担读流量。这两种都属于架构层面的调整,成本比前两种高,一般作为最后手段。

5. 慢查询、抖动与故障的现场排查链路

性能问题最折磨人的地方不是"慢",而是"偶发地慢"。平均延迟看着挺好,P99 却飘在天上,日志里翻不出东西,监控上全是正常。这种时候最需要的是有一套固定的排查动作,而不是靠灵感乱猜。我把自己常用的链路整理了一遍,基本能覆盖八成以上的现场。

5.1 从slowlog到延迟监控的逐步收敛

第一步永远是看慢日志。Redis 会把执行时间超过阈值的命令记下来,默认阈值是 10000 微秒也就是 10 毫秒,生产环境建议调到 5000 微秒甚至更低,因为很多问题的元凶就是那些"看起来不算慢"的几毫秒命令,累积起来才要命。慢日志本身有长度限制,默认 128 条,调大到 512 到 1024 更利于回溯。

# 查看慢日志,取最近 20 条 redis-cli slowlog get 20 # 调整阈值和长度 redis-cli config set slowlog-log-slower-than 5000 redis-cli config set slowlog-max-len 1024

慢日志里每条记录包含命令、执行耗时、发生时间戳和客户端地址。看到耗时长的命令,先判断是复杂度问题还是数据量问题,再决定改代码还是拆数据。这里有个小技巧:如果慢日志里反复出现同一条命令,说明它是常态化的,必须改代码;如果只是零星出现,可能是数据量增长到临界点了,要检查对应的 key 有没有膨胀。

第二步看延迟监控。redis-cli --latency会持续采样 PING 的往返时间,能反映网络和服务端的综合延迟。redis-cli --latency-history会按时间段输出,方便对比不同时段的波动。如果延迟曲线呈现规律性的尖刺,大概率是持久化 fork 引起的,把尖刺的时间点和日志里的 RDB 保存记录对一对就能确认。

第三步用LATENCY系列命令做深度诊断:

# 查看最近的延迟事件 redis-cli latency history event_name # 让内置诊断给出建议 redis-cli latency doctor

latency doctor会列出最近发生的各类延迟事件,比如命令执行、fork、AOF fsync、过期键删除,并给出可读的建议。这个命令我在现场排查时用得很多,它能省去大量翻文档的时间。不过要注意,延迟监控本身有开销,latency-monitor-threshold默认是关闭的,只在排查期临时打开,排查完记得关掉。

5.2 几个把我坑惨的"伪Redis问题"

有些问题是挂着 Redis 的名头,实际上跟 Redis 没多大关系。我挑几个印象最深的说说,希望你能少走点弯路。

第一个是集中过期引发的周期性抖动。有一批缓存是在凌晨批量写入的,过期时间统一设成二十四小时,结果第二天凌晨同一秒有几万个 key 一起过期。Redis 的过期删除策略是惰性删除加定期采样,同一时刻大量 key 过期会让主线程在采样删除上多花时间,表现出来就是那一秒的延迟突然拉高。解决办法很简单:设置过期时间时加个随机偏移,ttl = 3600 + random(0, 300),把过期时间打散到五分钟的窗口里,抖动立刻消失。

第二个是客户端重试风暴。某次网络抖动导致少量请求超时,客户端配置了自动重试,重试次数 3 次。本来只是几条慢请求,重试之后变成三倍流量压回去,服务端压力变大,更多请求超时,形成正反馈。最后是加上了重试退避和熔断才稳住。经验是:重试一定要带指数退避和最大次数限制,而且只在幂等操作上重试,别见着超时就无脑重发。

第三个是大事务和 Lua 脚本阻塞。有人在 Lua 脚本里写了循环遍历一个大集合,单次执行几十毫秒。由于 Lua 脚本在服务端是原子执行的,执行期间所有其他命令都被挡住。脚本的原则是:只做简单的读写组合,循环次数控制在千次以内,超过就要拆。上线前用SCRIPT LOAD加EVALSHA的方式跑一遍,看看耗时能不能接受。

第四个是监控采集本身造成的负担。有人写了个脚本每分钟对全库跑一次--bigkeys,还在一个几千万 key 的实例上跑。这个命令虽然是 SCAN 实现的,但全库扫一遍的开销不小,采集频率太高会明显影响性能。采集类操作一律放到从节点或者低峰期做。

5.3 一份能照着走的排查顺序

把上面的经验固化成一个顺序,现场按这个走基本不会漏:

  1. 确认现象:是平均延迟高,还是 P99 高,还是 QPS 上不去,三者对应的方向完全不同。
  2. 看客户端指标:连接池等待时间、超时次数、重试次数。如果等待时间高,先查连接泄漏。
  3. 看服务端 CPU:重点看主线程所在核心的使用率,而不是整机平均值。
  4. 看慢日志:按耗时排序,判断是常态还是偶发。
  5. 看延迟监控:找规律性尖刺,和持久化、过期、脚本执行的时间点对齐。
  6. 看内存和碎片:确认没有触发淘汰风暴或者 swap。
  7. 看网络:客户端和服务端的延迟差,判断是不是跨了机房或者带宽打满了。

这个顺序的好处是从成本最低、最容易改的地方开始,避免一上来就怀疑服务端。按我的经验,走完前三步就能定位到六成的问题。

6. 横向扩展不是万能药:分片、读写分离与多级缓存的取舍

单实例优化到头之后,就该考虑横向扩展了。但扩展这件事有很多误解,最常见的是认为"加节点就能线性提升"。实际做过分片的人都知道,从一到二可能有百分之八十的提升,从二到四就只剩百分之四十,越往后边际收益越低,而运维复杂度是指数级上升的。

6.1 集群分片能带来什么、不能带来什么

集群分片的原理是把 key 空间按哈希分成 16384 个槽,每个节点负责一部分槽。客户端根据 key 算出槽号,直接连到对应的节点。这样做的好处是写能力和容量都能横向扩展,代价是跨槽操作变得麻烦。

它不能解决的问题有几个。第一是热 key,前面说过,单个 key 只能落在一个槽里,加节点分不走它的流量。第二是复杂多 key 操作,MGET 涉及多个 key 时,如果它们不在同一个槽,客户端就得拆成多次请求,往返次数又上去了。解决办法是用哈希标签,把需要一起操作的 key 用花括号括起来,{user:123}:profile和{user:123}:orders会被算到同一个槽,因为它们只对花括号里的内容做哈希。

第三是事务和 Lua 脚本的跨槽限制,涉及多个 key 的事务必须所有 key 在同一槽,否则直接报错。这一条在做方案设计时就要考虑进去,别等代码写完才发现跑不通。

读写分离是另一个常见手段,主节点写、从节点读。它的前提是业务能接受主从延迟,通常几十毫秒到几百毫秒不等,取决于复制缓冲区和网络状况。对于"写完立刻要读到自己写的数据"的场景,必须强制走主节点,否则会出现数据刚保存却读不到的诡异现象。我的建议是:默认走主,只有明确的、能容忍延迟的读场景才走从,别为了省那点流量把一致性搞乱。

6.2 本地缓存搭在Redis前面的一致性处理

多级缓存是我认为在高并发读场景下收益最大的架构手段。结构是:进程内本地缓存(有效期一到五秒)→ Redis → 数据库。本地缓存挡住绝大部分请求,Redis 挡住剩下的绝大部分,数据库只承接极少数穿透。

难点在一致性。最常用的做法是"更新数据库后删除缓存",配合一个短的本地缓存过期时间。删除缓存而不是更新缓存,是因为并发更新时更新缓存容易出现旧值覆盖新值。删完之后,其他实例的本地缓存还有残留,靠过期时间自然失效,最多脏几秒。如果业务对这几秒也无法容忍,就需要加一个失效广播:通过消息通道通知所有实例清掉对应的本地 key,实现秒级一致,代价是引入了一个额外的依赖。

这里有个坑要提醒:本地缓存的容量必须有上限,而且要有明确的淘汰策略。我见过有人用无界 map 做本地缓存,跑了两天内存涨到几个 G,触发了频繁的垃圾回收,接口延迟反而更差。用带容量限制的 LRU 结构,容量按热点数据的规模估,一般几千到几万个条目就足够覆盖绝大部分流量了。

7. 用压测把"成倍"验证出来

所有优化做完,必须用压测说话。我见过太多"我感觉快了"的结论,一上生产就打脸。压测的目的不只是拿到一个 QPS 数字,更重要的是找到新的瓶颈在哪里,以及验证各项优化到底贡献了多少。

7.1 redis-benchmark的正确用法与常见误判

自带的压测工具够用,但参数得设对,否则测出来的数字毫无意义:

# 基础压测:50 个连接,10 万请求,只测 get 和 set redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t get,set -q # 带管道:每批 16 条命令 redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -P 16 -t get,set -q # 指定 value 大小,接近真实业务 redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -d 512 -t get,set -q

几个容易踩的坑。第一,不设-d参数,默认 value 只有几个字节,测出来的 QPS 虚高,跟真实场景差好几倍。一定要按业务的平均 value 大小来设。第二,压测客户端本身成为瓶颈,单进程压测跑不满服务端,可以用--thread参数多线程,或者干脆多开几个压测进程。压之前先看看压测机的 CPU 和网卡有没有打满。第三,只用默认的随机 key,默认情况下 key 空间很小,所有请求都命中同一批 key,测出来的是纯内存操作的速度,没有反映真实的缓存命中分布,可以加-r参数扩大 key 空间。

还有一个误区是只看 QPS 不看延迟分布。同样的 QPS,P99 是 5 毫秒还是 500 毫秒,体验天差地别。压测时要同时记录延迟分位数,理想状态下 P99 应该在 P50 的三到五倍以内,超过十倍说明有长尾问题没解决。

7.2 一份优化前后的对照记录

拿我前面提到的那个交易系统举例,改造前后的对照大致是这样的。测试环境是同机架的两台物理机,客户端和服务端分开部署,value 平均 800 字节,读写比 9:1,压测时长 10 分钟取稳定段数据。

阶段主要动作QPSP50 延迟P99 延迟
改造前循环单条 GET,连接池 500约 90008ms420ms
第一步改批量命令 + 管道,每批 200约 260003ms95ms
第二步大 hash 拆分,value 瘦身约 340002ms62ms
第三步开 IO 线程 4,关 THP约 370002ms55ms
第四步热点数据加本地缓存约 520001ms48ms

可以看到,收益最大的是第一步,光是把往返次数降下来就接近三倍。后面的每一步收益递减,但叠加起来是数量级的变化。这也印证了我一开始的判断:先改客户端,再改数据模型,最后才动服务端参数。顺序反了,你可能花大量时间调参数,最后发现瓶颈根本不在那儿。

压测数据只能作为参考,真实流量下的表现还得看线上监控。上线后我一般会盯三个指标一周:主线程 CPU 使用率、P99 延迟、慢日志条数。这三个都平稳,才算真正稳住了。

最后分享一个我个人踩过好几次的习惯:每次做完优化,把改动点、预期收益、实测数据记在一个文档里,哪怕只是给自己看。Redis 的优化手段就那么多,但每一项在不同业务里的收益差别很大,积累几次之后你会形成自己的判断,看到一个新场景大概能猜到瓶颈在哪。这比记住一堆参数值有用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进