接手过好几个线上Redis性能问题之后,我的体会是:性能优化这件事,大部分人一上来就盯参数和命令,其实方向就错了。Redis号称单线程扛十万级QPS,但真到生产环境,延迟抖动、OOM、慢查询、连接被打满,乱七八糟的问题一堆。你问它为什么慢,它甩给你一段日志,看不懂就抓瞎。这篇文章就把我从定位问题到动手优化的完整思路梳理一遍,从基线采集、数据结构选型,到内存、网络、持久化的调优,最后附上排查实录和避坑清单。不管是刚入门的新手,还是已经踩过几个坑的开发者,照着这个框架走,至少能少走半年的弯路。
很多人对Redis性能优化有个误解,以为调几个参数、换个机器就完事。实际上,性能是设计出来的,不是调出来的。数据的形状、键的设计、操作的粒度,决定了你能跑多快;内存策略、持久化配置、网络开销,决定了你能稳多久。这篇文章不会只丢给你一堆配置项,而是把每个决策背后的逻辑讲透,让你以后遇到类似问题,自己能判断该往哪个方向查。
1. 从基线开始:没有数据的优化都是拍脑袋
1.1 先回答三个问题
拿到任何一个Redis性能问题时,我第一件事不是查配置,而是先搞清楚三件事:当前到底有多慢?慢在哪个环节?优化之后拿什么证明有效?
“到底有多慢”不能靠体感。有人说“感觉有点卡”,这不算数。你要有数字:P99延迟是多少?每秒处理多少请求?内存碎片率多少?慢查询一分钟出现几次?没有基线,你后面做的任何改动都说不清楚效果,甚至可能把问题搞得更隐蔽。
“慢在哪个环节”要分层看。客户端到Redis之间的网络、Redis自身处理命令、持久化落盘、主从同步,每个环节的瓶颈特征不一样。网络慢是延迟均匀升高,命令慢是P99突刺,持久化导致的问题是周期性卡顿。先定位层,再往下钻。
“拿什么证明有效”就是优化前后的对比。我习惯在优化前把baseline记录成一张表:延迟均值、P99、P99.9、命中率、内存碎片率、慢查询数量。后面每一步改动,都对着一项或几项指标看变化。没有对比的优化,都是心理安慰。
1.2 手把手建立性能基线
Redis自带两个工具,足够完成大部分基线采集。第一个是延迟测试,直接在服务器上跑:
redis-cli --intrinsic-latency 100这个命令统计Redis进程内部延迟的分布。参数100表示测试100秒。正常情况下,结果里max值应该在微秒级别。如果max出现了毫秒级跳动,说明这台机器的CPU调度、内存带宽或虚拟化环境有问题。顺带说一句,这个命令测的是本机到Redis进程的延迟,不经过网络,所以它是排障的第一步——先把服务器本身的底子查干净。
第二个是压测工具redis-benchmark,我记得常规用法是这样:
redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get -P 16-c是并发连接数,-n是总请求数,-P是pipeline批量数。注意,-P设成16的时候测出来的是批量操作的吞吐,不代表单条命令的延迟表现,报告里的requests per second会虚高。所以压测的时候要分开看:单发模式测延迟,pipeline模式测吞吐上限。
还有一些指标要从INFO里读。执行redis-cli info,重点看这几个字段:
instantaneous_ops_per_sec:当前每秒操作数。latency相关的监控项。used_memory_rss / used_memory:如果比值高,说明内存碎片严重。rejected_connections:连接被拒绝的次数。sync_full或主从相关的计数器,判断同步是否频繁。
另外,我强烈建议开启latency monitor,这是排查延迟抖动最省事的工具。只要在运行时执行:
redis-cli CONFIG SET latency-monitor-threshold 100把阈值设成100毫秒,之后所有超过100ms的命令都会被记录到延迟事件里,用redis-cli --latency-history就能看到时间线。我在几次线上问题里,都是靠它抓到“某个时段内大量慢命令”的把柄。
注意:
--intrinsic-latency测的是物理环境,--latency-history测的是命令执行事件,两者配合能快速区分“机器的问题”和“Redis自身的问题”。
2. 数据的形状决定性能:数据结构与编码优化
2.1 选错类型的代价
Redis慢,很多时候不是Redis本身慢,而是数据结构和访问模式不匹配。最典型的案例:用String存了一个很大的JSON,每次只需要修改其中的一个字段,结果每次都是先GET整个JSON、反序列化、改字段、再序列化、SET回去。一次业务逻辑,变成了几十KB的序列化和网络传输,吞吐直接被打下去。
这类问题的解法通常是拆类型。字段独立、更新频繁的数据,用Hash来存,每个字段是单独的entry,修改一个字段只传输一个field的值;需要排序、范围查询的,用ZSet,别自己搞客户端排序;需要去重、判断成员关系的,用Set;需要先进先出的,用List。选类型的核心就一句话:让每次命令操作的数据量最小,让每个命令匹配你真正的访问模式。
还有一种是key设计的问题。比如用时间戳做key的前缀,把热点集中在某个分片或某一段连续的key上。虽然Redis集群对key做了哈希分布,但如果你所有的key都落在同一个业务前缀下,而且访问都集中在某一小段时间,还是会造成局部过热。设计key的时候要有意识地加一些随机后缀或业务维度,让请求尽量均匀散开。
2.2 大key与热key的识别与处理
在性能优化领域,大key和热key是绕不开的两个问题。先说大key。一个Hash有几万甚至几十万个field,一个String存了几MB的图片或文本,这就是大key。它的危害不只是占用内存,而是每次执行相关命令都可能造成Redis主线程长时间阻塞,尤其是删除(DEL)和导出(KEYS/SCAN)的时候。
识别大key最安全的方式是用redis-cli --bigkeys,它会在线上实例做一次scan,统计各类数据结构中最大的几个key。注意这里有个坑:--bigkeys的原理是遍历整个key空间,线上流量高的时候最好在低峰期跑,而且它统计的是每类数据结构的top1,不代表全貌。
发现大key之后,处理方案要看数据类型。大的String可以拆成多个小key,或者改成Hash存一段一段的;大的Hash/Set/ZSet可以用hscan/sscan/zscan分批取数据,然后逐步删除;如果实在拆不了,至少在删除的时候不能用DEL,要用UNLINK。Redis 4.0之后UNLINK能异步释放内存,不阻塞主线程。这是我见过最容易踩的坑:一个几百MB的key,一条DEL下去,整个实例卡了几秒,QPS瞬间掉零。
再说热key。某个key每秒被请求几万次,单线程的Redis处理这个key的时候,其他请求全部排队。识别热key可以用redis-cli --hotkeys(需要开启LFU策略),或者客户端在访问时做本地计数。处理手段我看过不少,最实用的是三个方向:一是加本地缓存,把热key的值在应用进程内缓存几秒,直接砍掉对Redis的访问;二是把这个key复制成多份,加不同后缀(比如key:0到key:9),每个副本只承担一部分流量;三是拆分key结构,把热点分散到多个key上。这三种做法各有利弊,本地缓存适合读多写少,复制key适合整体热,拆结构适合热点集中在某个字段的情况。
2.3 内部编码与内存结构
从Redis存储的角度讲,同一类型的key内部编码还有差异,这直接关系到内存效率和访问速度。比如List,老的redis版本可能用quicklist,Python的list对比不好类比,但是你可以理解为是一个压缩的双端链表,每个节点里塞一个ziplist;Hash和ZSet在元素少、值小的情况下会用listpack(早期是ziplist),把多个entry紧凑排列在一段连续内存里。连续内存带来的好处是两个:一是省内存,省去了指针的开销;二是局部性好,遍历速度快。
但要注意,一旦元素数量或单个元素大小超过阈值,Redis会自动从紧凑编码转换成常规的hashtable或skiplist。这个转换是双向不可逆的(转换后不会自动转回去,直到key被删除)。转换过程本身是原子的,大对象转换时可能瞬间卡一下。所以我维护线上实例时,会有意识地预估每个key的规模,让数据尽量保持在紧凑编码的阈值范围内。相关的阈值在redis.conf里通过hash-max-listpack-entries、hash-max-listpack-value这类参数控制。
谈到内存碎片,这是另一个容易被忽视的点。INFO memory里的mem_fragmentation_ratio如果长期大于1.5,说明内存碎片很多。成因为频繁更新、删除大小不一的值,导致内存分配器无法回收空洞。我处理过最典型的一个案例:一个实例的used_memory只有2GB,但RSS占用了6GB,直接导致操作系统内存告警。后来在低峰期执行了memory purge(仅限jemalloc)并重启解决。更建议的做法是从源头入手,控制key的更新频率和值大小波动,必要时开启activedefrag yes,让Redis在后台自动整理碎片。
3. 运行时调优:内存、连接与网络开销
3.1 淘汰策略怎么选
内存是有上限的,maxmemory设了之后,数据到了上限怎么办,由淘汰策略决定。很多人的第一反应是选allkeys-lru,因为听上去最通用,但这常常是性能事故的开端。
淘汰策略直接影响命中率,而命中率就是性能。noeviction最安全,写不进去直接报错,虽然不优雅,但至少不会静默丢数据;allkeys-lru对整个key空间做LRU近似淘汰,适合所有key都不分轻重缓急的场景;volatile-lru只淘汰带过期时间的key,适合把缓存和持久数据混在同一个实例里的场景。实际生产里,我见过用allkeys-lfu处理读多写少的热点数据的,因为LFU比LRU更能抵御偶发扫描型访问。
这里要提醒一个细节:maxmemory的取值不是越大越好。如果你给Redis 8GB内存,maxmemory设成7GB,剩下的1GB是留给操作系统页缓存和内存碎片余量的。踩过最大的坑就是把maxmemory设成了物理内存的上限,结果内存碎片涨一点,操作系统直接OOM Killer把Redis进程干掉了。我的习惯是maxmemory设为物理内存的70%到80%,留足余量,再配合监控告警。
3.2 连接池和pipeline
Redis是单线程处理命令,但网络IO和命令执行其实是分开的(新版本还有IO多线程),不过对于客户端而言,连接数的控制依然非常重要。每个连接都有建立和销毁的成本,TCP握手加Redis的握手协议,一次连接几十微秒就没了。高并发场景下如果客户端每次请求都新建连接,光握手就能把Redis拖垮。
解决方案就是连接池。连接池的核心参数是最大连接数、最小空闲连接数和获取连接的超时时间。连接池开得太大也没有意义,因为Redis单线程处理能力固定,你开1000个连接和开200个连接,吞吐上限差不多,反而连接多了增加上下文切换和内存占用。我之前调过一个服务,连接池从1000调到200,性能没下降,CPU使用率反而降了不少。
连接池解决了连接复用,pipeline解决的是往返开销。普通模式下,客户端发一条命令,等一个响应,再发下一条,每次都要走一次网络RTT。pipeline把多条命令打包到一次请求里,服务端处理完后一次性返回。注意,pipeline虽然能大幅提升吞吐,但它不是无脑用的。一是pipeline里的命令会排队等待处理,如果其中一条特别慢,后面的都会被拖住;二是pipeline打包的命令不宜过大,我一般控制在50到100条以内,太大反而可能造成网络包分片和内存占用问题。
3.3 IO多线程的取舍
Redis 6.0引入了IO多线程,网上很多人一听就兴奋,觉得单线程的瓶颈终于解除了。实际上没那么神。默认情况下,IO多线程是关闭的,它只处理网络读写,命令执行仍然是主线程单线程。打开IO多线程之后,网络包的解析和发送可以分散到多个线程,这对大流量场景下的网络吞吐有帮助,尤其是客户端连接数多、小命令多的时候。
开启方式是在redis.conf中设置:
io-threads 4 io-threads-do-reads yes人话就是,4个IO线程负责读写socket,主线程继续排队执行命令。注意,核心数不多的时候,开太多IO线程反而因为线程切换开销导致性能下降,我一般建议不超过CPU核心数的一半。
再提醒一下,IO多线程不是解决慢查询的手段。命令本身慢(比如大key的排序、超大hash的遍历),该卡还是卡。所以必须先排除慢查询和大key,再谈IO多线程优化。
4. 持久化不是免费午餐
4.1 RDB与AOF的阻塞点
Redis性能波动有个很隐蔽的来源:持久化。RDB快照生成的时候,Redis会fork出一个子进程,把内存数据写入磁盘。fork本身在主线程里执行,如果内存很大(比如20GB),fork的瞬间可能卡几百毫秒甚至更久。而且子进程跑RDB时,会通过写时复制机制共享内存页,如果那个时段写入流量大,主线程会被迫复制大量内存页,造成CPU和内存带宽的双重压力。
AOF的阻塞点不同。AOF是追加写文件,本身很快,但有两个问题:一个是每个写命令都要写系统缓冲区,fsync策略如果设为everysec,每秒刷一次盘,理论上最多丢一秒数据;如果设为always,每条命令都刷盘,性能直接掉一个量级。另一个是AOF文件重写,重写期间fork、写临时文件,同样有阻塞风险。
4.2 合理的持久化配置
我从性能和可靠性的平衡角度,给一套比较稳的配置组合。第一,如果你的Redis是纯缓存场景,数据丢了可以从上游重建,直接关闭持久化(save ""、appendonly no),性能和内存都是最优的。第二,如果是混合场景,建议AOF开启,appendfsync everysec,RDB作为兜底,比如每小时一次。第三,避免在主库上同时跑RDB和AOF重写,尽量错开时间,我通常把RDB的定时任务放在业务低峰期,AOF重写用auto-aof-rewrite-percentage控制阈值。
还有一个大家容易忽略的点:不要在主从架构的所有节点上同时执行快照。主库做RDB,从库也做RDB,两把fork一起发生,服务器直接卡死。我处理过一个案例,主从同时进入RDB周期,主库延迟从1ms飙到800ms,持续了十几秒。后来改成从库错峰执行,并开启repl-diskless-sync,问题才解决。
5. 慢查询与阻塞:排查实录
5.1 慢查询日志怎么读
Redis会把超过指定耗时的命令记录到慢查询日志里。配置项是slowlog-log-slower-than,单位是微秒。我一般设成10000(10ms),因为Redis单条命令正常应该在微秒级别,超过10ms就要警惕。慢查询队列长度slowlog-max-len设成1024,够排查用。
查看慢查询用:
redis-cli slowlog get 10输出里能看到命令、耗时、时间戳和客户端地址。我最常遇到的是两类慢命令:一类是KEYS *,全库扫描,这个命令在Redis官方文档里都直接说不建议生产使用;另一类是HGETALL一个大Hash,几千个field一次性拉回。解决办法不是优化命令,而是改设计——用SCAN代替KEYS,用HMGET按需取字段。
5.2 常见的阻塞元凶
如果慢查询日志里看不到明显的慢命令,但延迟还是高,就要查阻塞源。常见的阻塞源有这么几类:
第一,过期key的清理。Redis默认每秒做10次过期扫描,每次最多清一部分key。如果某个时刻突然有大量key同时过期,清理线程会占用主线程时间。解决方法是给key的过期时间加随机偏移,比如expire设置成基准值加上一个随机数,让过期时间分散开。
第二,内存页换出。如果系统内存不足,Redis的内存页被换到swap,访问这些数据的时候会有几十毫秒的延迟。用redis-cli info看used_memory和RSS的差异,再看系统的vmstat的si/so。
第三,CPU争抢。Redis进程被别的进程抢占CPU,尤其是同机部署了其他高CPU应用。看top里Redis进程的CPU使用率,如果和系统总CPU使用率差距过大,就要考虑隔离部署。
下面这个表是我平时排查时用的速查表,遇到延迟问题直接对照:
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 延迟均匀升高 | 网络带宽打满、连接过多 | 看网卡流量,查连接数 |
| P99突刺 | 大key操作、慢命令 | slowlog、bigkeys |
| 周期性卡顿 | RDB fork、AOF fsync、过期清理 | 看时间点和持久化配置 |
| 延迟突然持续高 | 内存换页、CPU争抢 | vmstat、top |
| 延迟在从库更明显 | 主从同步积压 | 看master_repl_offset差距 |
6. 避坑清单:我这几年踩过的坑
最后分享几个印象最深的教训。
第一个教训是:不要在生产环境跑KEYS *。有次半夜帮同事排查问题,同事说“我就跑一次看看有哪些key”,然后整个实例卡了将近三十秒,上游服务超时告警一片。后来所有检索类需求都用SCAN,并且加COUNT限制。SCAN虽然也可能在游标过程中碰到正在变动的key,但至少不阻塞主流程。
第二个教训是:主从库的内存规格要一致。曾经为了省钱,给从库配了比主库小的内存,结果主库触发RDB fork之后,从库同步数据时内存不够,直接OOM,主从断开了。从库的内存规格如果比主库小,很多问题在同步峰值时才会暴露。
第三个教训是:优化前一定记得记录基线,优化后不要同时改多个参数。我有一次改了淘汰策略,又开了IO多线程,还调整了连接池,结果性能变好了,但根本不知道是哪个改动起了作用。之后严格一次只改一个变量,对比前一次基线,这样每次改动的影响都清清楚楚。
第四个教训是关于监控的。Redis的INFO输出里有很多指标,但单独看一两项说明不了问题。我后面搭了一套简单的采集:每30秒抓一次INFO和slowlog,存成历史曲线,出问题的时候回放曲线,基本上大问题都能定位到分钟级别的时间点。这套东西不需要多复杂的工具,一个定时脚本加一个时序数据库就能做到。
Redis性能优化做到最后,你会发现拼的其实是两件事:一是对数据结构的理解,二是在生产中长期积累的排障直觉。前者靠学,后者只能靠踩坑。希望这篇文章能让你在遇到性能问题时,有一个明确的排查起点,而不是在一堆参数面前发懵。