做后端这行,缓存是绕不过去的一道坎。很多团队从本地缓存切换到 Redis 分布式缓存时,都会经历一次“从能用变成不好用”的转折:原来在 JVM 里放个 Map,一调就返回,切到 Redis 后发现网络开销、序列化开销、连接管理全来了。这不是 Redis 的问题,而是很多文章只讲命令不讲定位。这篇文章想系统聊清楚分布式缓存的核心知识:它到底解决了什么问题、三个最容易让系统挂掉的事故、缓存和数据库怎么保持一致、高可用方案怎么选,以及线上经常踩的优化和排障细节。不管你是刚接手的初级开发,还是正在设计缓存架构的负责人,都可以把它当一份实战笔记来看。
1. 分布式缓存的位置:单机缓存为什么扛不住
1.1 从本地缓存切到 Redis 时,最先撞上的问题
很多项目早期都会用本地缓存,比如在一个 Java 服务里直接放一个 ConcurrentHashMap。这种方式在单机、低并发下确实能用,但一旦服务部署了多个实例,问题立刻暴露:用户的请求被负载均衡分发到不同机器,每个机器上的缓存内容可能完全不一样。A 实例更新了数据,B 实例还在给用户返回旧值,排查起来非常费劲。
更麻烦的是,本地缓存和应用进程共享内存。缓存一多,应用自身的堆内存就不够用了;一旦服务重启,缓存数据全部清空,瞬时流量全部压到数据库。当年我接手的一个用户查询服务就是这种状态,每次发版后数据库都要被冲击一轮,慢查询列表里全是同一类 SQL。
后来换成 Redis 作为统一缓存层,数据集中存储,所有实例读到的是同一份内容,一致性由缓存层统一控制。这才是“分布式缓存”的含义——它不是简单把数据从应用内存挪出来,而是把缓存从“进程内部的临时变量”升级成了“独立的、可扩展的基础设施”。
但要清醒一点:分布式缓存并不天然比本地缓存快。它带来的是数据一致性和共享能力,代价是网络 IO。所以真正合理的架构通常是一层本地缓存做一级加速,Redis 做二级兜底,而不是让所有请求都穿 Redis。接下来聊缓存的核心价值时会更清楚。
1.2 缓存解决的其实是“流量不对称”
缓存不是用来存储的,是用来挡 QPS 的。数据库的瓶颈在于磁盘随机读和单机并发能力,而绝大多数读取型接口的访问规律是“少量热点数据被大量请求访问”。比如一个商品详情页,可能 80% 的流量集中在 20% 的热门商品上。没有缓存时,这 20% 的查询会把数据库打到崩溃。
我习惯在引入缓存前先算一笔流量账。假设某个查询接口 QPS 是 2000,数据库单机能稳定支撑 500 QPS。上线缓存,命中率如果能到 95%,数据库实际承担的查询 QPS 就降到 100 左右,这是非常可观的降载效果。缓存命中率低怎么办?先别急着加缓存,要回头分析数据是否值得缓存——如果每次请求查的都是完全不重复的数据,缓存只会白白增加一层延迟。
所以缓存的本质是“读多写少”场景下,用内存的读写速度去换磁盘的随机 IO 次数。这样理解,Redis 高并发、高性能的形象才不会跑偏。
1.3 上缓存前,算清三笔账
第一笔是容量账。Redis 是内存存储,容量很贵。上线前要大致估算:单个 value 平均多大、每天新增多少 key、期望保留多久。比如一个用户信息缓存,value 2KB,1000 万用户就是近 20GB 数据,单机 32GB 内存也只能勉强放下。不估算清楚,缓存上线第一天就可能命中率低、淘汰频繁。
第二笔是一致性账。缓存和数据库是两个存储系统,更新顺序稍有不当就会出现数据不一致。业务能不能容忍“用户改完资料后,首页头像延迟几秒生效”?如果能,走最终一致;如果要求强一致,比如支付状态这类关键数据,就不要盲目套缓存,得用特殊方案。
第三笔是运维账。分布式缓存引入了新的节点,意味着监控、告警、故障转移、网络延迟全都要纳入考虑。一个没有监控的 Redis 集群,出问题时完全是在盲修。
1.4 不要忽略本地缓存与 Redis 的分层配合
分布式缓存也分内部层次。很多高并发方案会采用两级缓存:一级本地缓存,命中后完全不发网络请求;二级 Redis,本地未命中再访问。更新时,Redis 更新后还要想办法把本地缓存失效掉,常见的有消息广播通知各实例删除本地 key。
这种方式能显著降低 Redis 的压力,但复杂度也上来了。本地缓存必须设短过期时间,避免长期不一致。如果团队没有能力维护失效通知,宁可不要一级缓存,直接用 Redis,至少问题可控。
2. 过期与淘汰:两个决定缓存命运的底层机制
2.1 Redis 不是到点就删 key
很多初学者以为设置了过期时间,Redis 到点就会把 key 删掉。实际上,Redis 的过期 key 删除用的是“惰性删除 + 定期删除”的组合,并不是定点全量扫描。
惰性删除的意思是,只有当某个 key 被访问时,才检查它是否过期,过期就删除。定期删除则是 Redis 每隔一段时间(默认是每秒执行 10 次采样循环)抽取一部分 key 检查,如果发现过期就清理一部分。这个组合避免了“全库扫描”带来的阻塞,但也带来一个结果:已经过期的 key,在内存压力没上来之前,可能还会残留在内存中一段时间。
这就引出了 maxmemory。没有设置 maxmemory 时,Redis 对内存扩张几乎不设上限;一旦接近物理极限,操作系统会先响应变慢,然后可能 OOM。所以生产环境必须显式配置 maxmemory,比如:
maxmemory 4gb这里要注意,maxmemory 指 Redis 自己管理的内存上限,不包含系统层碎片。实际使用时要留出 20% 到 30% 的余量给内存碎片和复制缓冲区。
2.2 八种淘汰策略,生产环境怎么选
当内存达到 maxmemory,Redis 会根据 maxmemory-policy 决定怎么清理。常用的策略有:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| noeviction | 不淘汰,写入直接报错 | 适用于严禁丢弃数据的场景,但生产慎用 |
| allkeys-lru | 在所有 key 中按 LRU 淘汰 | 通用缓存场景,最推荐 |
| allkeys-lfu | 在所有 key 中按 LFU 淘汰 | 有明显冷热分布、热点集中的场景 |
| volatile-lru | 只从设置了过期时间的 key 中淘汰 | 有必须长期保留的 key 时使用 |
| volatile-random | 只从设过期的 key 中随机淘汰 | 冷数据无法预判时兜底 |
| volatile-ttl | 淘汰剩余 TTL 最短的 key | 较少使用,效果不稳定 |
生产环境最常见的配置是:
maxmemory-policy allkeys-lruallkeys-lru 的意思是即使 key 没有设置过期时间,内存吃紧时也会被淘汰。对于缓存场景这通常是合理的,因为缓存数据本身的语义就是“丢了可以从数据库重新加载”。如果你有一类 key 绝对不能被淘汰,比如临时锁、计数器,那就不要放在同一个实例里,或者把它们单独保存到专门的不允许淘汰的 Redis 实例。
我要特别提醒一句:noeviction 这个策略在写多缓存场景下很容易把自己坑死。到达内存上限后,所有写命令直接返回 OOM error,上层业务甚至可能把它当成正常失败不断重试,最终消耗大量请求资源。这类故障比缓存命中率下降更难排查。
2.3 过期时间怎么定,才算有经验
很多人设置过期时间喜欢统一传一个值,比如 3600 秒。所有 key 同一时期创建、同一时刻过期,这在缓存预热或批量导入时会直接引发一次“缓存雪崩”,这个坑后面专门讲。
我的经验是分级设置,并按业务场景给过期时间加随机偏移。比如:
- 验证码:2 分钟
- 用户登录态:30 分钟到 2 小时
- 配置类数据:10 分钟到 1 小时
- 商品详情、列表页:5 分钟到 30 分钟
- 排行榜:1 分钟到 5 分钟
每个 key 的 TTL = 基础过期时间 + random(0, 300) 秒。这样即使同一批数据同时写入,过期时间也被打散了。
还有一点容易忽略:过期时间不是淘汰时间的承诺。缓存到了 TTL 确实会失效,但如果在 TTL 之前就因为内存紧张被 allkeys-lru 淘汰了,照样会穿透到数据库。所以监控里不能只看命中率,还要看淘汰量,比如 INFO stats 里的 evicted_keys 如果持续上涨,说明容量规划不合理,而不是简单调大内存就能解决。
3. 穿透、击穿、雪崩:三个高频故障的根因与组合拳
3.1 缓存穿透:请求了一个根本不存在的数据
穿透的触发条件是:查询的 key 在缓存和数据库里都不存在。比如商品详情接口,攻击者故意传入一个乱写的商品 ID(比如 -10001),缓存里查不到,数据库里也没有,但每个请求都会打到数据库。如果量一大,数据库连接会被这种无效查询占满。
处理穿透,首先要知道它和击穿、雪崩的根本区别——它跟“过期”没有关系,纯粹是被无效数据打穿。应对方案有两条主线。
第一条是空值缓存:如果数据库查询结果为空,就把空值写入 Redis,并设置一个很短的 TTL,比如 30 秒到 1 分钟。这样同一个无效 key 短期内不会反复打到数据库。但要注意,空值缓存会引入大量无意义 key,攻击者如果每次都用不同的伪造 ID,缓存里会堆积垃圾数据,所以要配合接口参数校验,从源头拒绝非法数据。
第二条是布隆过滤器。它的思想是用一个位数组和多个哈希函数判断 key “一定不存在”。预先把数据库里的合法 ID 全部写入过滤器,查询前先判断,如果过滤器说“不存在”,就直接返回,不用再查缓存和数据库。布隆过滤器允许误判,但不允许漏判,所以用在“判断不存在”这个场景特别合适。
实际使用中,我会把参数校验 + 空值缓存作为默认方案,布隆过滤器用在对一致性要求不敏感、合法 ID 变化不频繁的场景。布隆过滤器的缺点是删除数据很麻烦,标准布隆过滤器不支持删除操作,需要考虑计数器实现,复杂度上升。
3.2 缓存击穿:热点 key 过期的那一瞬间
击穿和穿透最大的区别是:key 在数据库里确实存在,而且是高并发访问的热点,但在缓存过期的瞬间,大量请求同时发现缓存没有,全部去数据库加载数据,数据库瞬间被打穿。好比你微博上一条评论突然爆了,大家在同一条缓存记录过期的时间点全部涌入后台。
解决击穿的核心思路只有一个:制造一个“只能让一个人去查数据库”的窗口。
方案一是互斥锁:当缓存未命中,先尝试获取一个分布式锁(比如用 Redis 的 setnx),只有拿到锁的线程才去查数据库并回填缓存,其他线程等待片刻后重新读缓存。锁一定要设置超时时间,否则 Redis 或持有锁的线程异常退出时,锁不释放,整个缓存重建流程会死锁。我用过一个比较稳的伪代码结构:
String value = redis.get(key); if (value == null) { if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) { try { value = db.query(key); redis.set(key, value, expireTime); } finally { redis.del(lockKey); } } else { Thread.sleep(50); value = redis.get(key); // 重试读缓存 } }这个方案的代价是,如果缓存重建很慢(比如数据库查询 1 秒),会让大量请求阻塞在锁等待上,接口延迟会上升。所以互斥锁适合缓存重建耗时较短、并发峰值明显的场景。
方案二是逻辑过期:在缓存 value 里额外存一个逻辑过期时间,Redis 本身不设置 TTL。所有请求读取后判断逻辑时间是否过期,过期了则当前请求返回旧数据,同时让一个后台线程去更新缓存。这个方案把“查数据库”这个高延迟动作从同步链路里移出去,接口响应始终快,但会有短暂旧数据,适合可以容忍“过期瞬间读旧值”的业务。
3.3 缓存雪崩:同一时刻太多 key 失效或 Redis 不可用
雪崩和击穿的场景不同。击穿是一个热点 key 失效,雪崩是大面积 key 在同一时刻失效,或者缓存节点整个不可用,导致流量全部压向数据库。批量写入缓存后如果所有 key 的 TTL 一样,就会出现“一到整点全部 key 同时过期”的壮观雪崩。
应对雪崩,第一件事就是打散过期时间。T恤上讲过,给 TTL 加随机偏移,比如 base + random(0, 300) 秒,可以把一个集中失效点拆散到 5 分钟内。
第二件事是多级缓存和降级。本地缓存可以挡掉相当一部分流量,即使 Redis 全部失效,也没关系,本地还有一层短时缓存,能帮数据库扛过峰值。数据库压力依旧大的时候,启动限流降级,返回默认数据或者提示稍后重试,总比数据库整体宕机好。
第三件事是解决 Redis 节点不可用。这就要用到后面会讲到的哨兵、Cluster 这类高可用方案,不能指望单节点永远不出问题。
3.4 三种故障的排查和区分
实际排障时,我首先区分三类现象:DB 慢查询里全是不存在的数据查询,基本指向穿透;某段时间一个特定 key 的缓存命中率骤降,指向击穿;大量不同的 key 同时失效,或者整个 Redis 集群不可用,指向雪崩。
排查时我会同时看三块指标:Redis 的命中率曲线、数据库慢查询列表、接口 QPS 走势。命中率下降 + DB SQL 同一段代码大量出现,基本就是某一种缓存故障开始的前兆。发现苗头后第一时间启用对应兜底方案,而不是先调代码。
4. 缓存和数据库的一致性,更新顺序决定一切
4.1 一致性问题的根源
缓存和数据库是两个系统,不可能用同一份事务保证强一致。如果业务在“用户改昵称”后要求所有地方立刻读到新昵称,那最稳妥的方案是直查数据库,或者引入分布式事务,但这两种情况都不适合继续用普通缓存。
大多数缓存场景接受的是最终一致——数据库更新成功后,缓存会在一个很小的窗口内维持旧值,然后通过删除或更新缓存恢复一致。这个时间窗口越短,方案越复杂。
延迟双删、binlog 订阅、Cache Aside,都是在“窗口时长”和“实现复杂度”之间做选择。
4.2 四种更新顺序,为什么“先更新库再删缓存”是默认最优
第一种,先更新数据库再更新缓存。这种方案在并发下会互相覆盖:线程 A 和线程 B 同时更新同一个 key,可能线程 A 先写库,线程 B 后写库,但线程 B 先写缓存、线程 A 后写缓存,最终缓存里是旧值,数据库是新值,长期不一致。不推荐。
第二种,先删缓存再更新数据库。更新数据库的过程中,一个读请求发现缓存没有,回源查到旧值并把旧值写回缓存。之后数据库更新完成,但缓存里存的还是旧值,同样长期不一致。
第三种,先更新数据库再删缓存,也就是常说的 Cache Aside Pattern。这是通用场景下最稳的选择。原因很简单:数据库更新成功后再删缓存,让缓存失效,下一次读请求会重新加载最新数据。只有“数据库更新成功但删除缓存失败”这一个失败分支,而这个问题可以用重试或消息队列补偿。
第四种,先更新缓存再更新数据库,基本属于错误方案,除非有极强的缓存一致性要求并且接受回滚成本,正常项目不会这么用。
还有一点经验很多人没意识到:删除缓存比更新缓存更好。更新缓存需要把新值序列化、传输、写入,成本高;如果更新完这个 key 短期内没人读,这次更新就白做了。删除缓存则是等下次读的时候按需加载,实际效果反而更好。
4.3 延迟双删:到底在防什么
延迟双删是一种补偿方案,核心流程:
- 先删除缓存
- 更新数据库
- 休眠一小段时间
- 再次删除缓存
为什么还要第二次删除?因为第一次删除后,可能有其他线程正在把旧值写回缓存。等到数据库更新完成后,这个旧值已经回到缓存里了,短时间内不会被修正。第二段删除就是为了把这个窗口期内残留的旧缓存清掉。
延迟双删的关键是“延迟多久”。时间太短,旧值写回还没发生就删了,等于没删;时间太长,接口阻塞严重。我的经验是把延迟时间设定为略大于“业务高峰期一次数据库查询 + 主从同步的平均耗时”,比如 500ms 到 1000ms。但这个值其实很难估算准确,所以生产环境我一般不用同步 sleep 阻塞线程,而是把第二次删除投递到延迟队列或消息队列异步执行。
它的本质是提高一致性概率,而不是保证绝对一致。真正高一致需求的业务,还得看下一种方案。
4.4 基于 Binlog 的异步通知方案
如果要追求更高的最终一致可靠性,推荐“数据库变更驱动缓存”的思路。原理是:所有缓存失效动作不再由业务代码显式触发,而是由数据库变更事件触发。
具体链路是:更新数据库成功后,binlog 会产生一条变更事件;一个专注于扫描 binlog 的订阅组件读到这个事件,把“某个主键对应的缓存需要删除”的消息投递给消费者;消费者再调用 Redis 删除对应 key。如果删除失败,消息还能重试,不会出现“删除失败就永久不一致”的情况。
这个方案的好处是业务代码里不需要写删除缓存的逻辑,更新链路真正解耦。缺点是引入了额外的中间件和一定延迟。另外主从结构下要注意事件重复消费,做好幂等,最好是按 key 执行删除操作,天然幂等,重复删不会有副作用。
如果公司已经有消息队列,我会优先推荐这个方案,配合 Cache Aside 作为读路径的兜底,整体一致性可靠性会高很多。
4.5 兜底手段:过期时间是最便宜的补偿
说了这么多方案,最终还是要承认,没有任何一种缓存一致性方案是零窗口的。所以过期时间是最后一道保险:无论之前因为什么原因出现了不一致,只要 TTL 一到,缓存失效,下一次读请求就会重新加载数据,自动对齐。
因此在设计缓存时,我会把过期时间当成“最大不一致窗口”,所有要求一致性窗口小于 N 秒的数据,TTL 不能超过 N 秒,但也不能太短,否则命中率下降。
除此之外,可以给缓存里带一个版本号或时间戳。读取时如果发现版本低于数据库版本,主动回源。这个思路适合数据量不大但一致性要求略高的场景。
5. 主从、哨兵、Cluster:高可用方案别选错
5.1 主从复制:读写分离要付出什么代价
Redis 主从复制解决的是单点问题和读写压力分摊问题。主节点负责写,从节点负责读。从节点挂掉时,主节点照常;但主节点挂掉时,如果没有自动故障转移机制,服务还是不可用。
主从复制的同步过程大致是:从节点启动后向主节点发送同步请求,主节点生成 RDB 快照发给从节点,从节点加载完成后,主节点再通过复制缓冲区把后续的写命令增量发给从节点。
这里有个关键细节:主从复制是异步的。这意味着从节点数据有毫秒级延迟。如果业务是“写后立刻读”,让请求直接读从节点,可能会读到旧值。我的做法是,写操作完成后必须读最新的场景,读主节点或者等一个短暂的强制主节点读取;只有对实时性要求不高的数据才放心走从节点。
电气注意从节点数量不是越多越好,每多一个从节点,主节点就要多维护一条复制流,资源消耗上升。读压力大时优先考虑集群,而不是无限加从节点。
5.2 哨兵:自动故障转移才是高可用的开始
主从复制解决了读写分离,但没有解决“主节点宕机后谁来接管”。这正是哨兵存在的意义。
哨兵是一个独立角色,会持续监控主从节点的健康状态。当主节点疑似下线,哨兵会发起投票;只有超过半数哨兵都认为主节点下线,才会进入故障转移,自动把某个从节点提升为新的主节点。
部署哨兵有两条硬经验。第一,哨兵至少部署 3 台,而且数量应为奇数,这样投票才能形成多数;只有 1 台哨兵的所谓 HA,实际上这台哨兵挂了就等于没有高可用。第二,客户端不能直连主节点地址,而是先从哨兵获取当前主节点地址,否则故障转移后客户端还连旧节点,照样不可用。
哨兵模式还有一个限制:它解决的是高可用,不解决容量问题。数据量超过单机内存时,再高的可用性也扛不住容量不足导致的写入失败,这时候就要考虑后面说的 Cluster。
5.3 Redis Cluster:数据分片和可用性一起解决
Redis Cluster 把数据分散到多个节点,每个节点负责一部分哈希槽。整个集群有 16384 个槽位,一个 key 属于哪个槽由 CRC16(key) % 16384 决定。客户端访问任一节点,如果 key 不在当前节点,节点会返回 MOVED 重定向,由客户端重新请求正确节点。
集群模式最常见的问题是“多 key 操作失效”。比如一个 MGET 命令带了 5 个 key,这 5 个 key 分布在不同的槽上,命令就没法执行。解决办法是利用 Hash Tag:在 key 里用大括号包裹一部分内容,Redis 只对 {} 内的内容计算槽,例如user:{1001}:name和user:{1001}:age一定会落在同一个节点、同一个槽。
这意味着设计 key 时要提前规划哪些 key 需要一起进行多 key 操作。如果项目里大量依赖 MGET、事务、Lua 脚本,Cluster 的限制会很明显,使用前一定要评估。
集群至少需要 3 主 3 从,每个主节点挂一个从节点做故障转移。主节点故障时,从节点会自动升级,期间可能有几十秒的不可用,客户端要有重试机制。
5.4 选型清单
我给团队选型时一般看这张表:
| 场景 | 推荐方案 |
|---|---|
| 数据量小(单机可承载)、允许短暂不可用 | 单机 Redis |
| 读多写少、能接受从节点延迟 | 主从复制 |
| 需要自动故障转移、数据量仍在单机范围内 | 哨兵模式 |
| 数据量大、写入量大、需要水平扩展 | Redis Cluster |
哨兵模式在中小规模项目里性价比最高,部署简单,故障转移自动。Cluster 是最后手段,不要因为觉得“集群很高级”就硬上,集群的客户端连接管理、多 key 限制、批量操作改造都会带来额外成本。
6. 线上性能优化与避坑清单
6.1 Pipeline 为什么能快一个量级
Redis 命令在网络上往返一次就是一次 RTT。单个接口里执行 10 条 KEYS 类命令,时间主要耗在网络 IO 上,而不是 Redis 本身。Pipeline 可以把一批命令一次性发给 Redis,再一次性收结果,把多次 RTT 压缩成一次。
举个简单的例子,批量写入 1000 个 key,用 Python 客户端是这样的:
import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) pipe = r.pipeline(transaction=False) for i in range(1000): pipe.set(f"key:{i}", i) pipe.execute()注意 pipeline 不是无脑把所有命令都塞进去。一次 pipeline 的命令数量太大(比如超过几千条),Redis 会长时间阻塞处理,影响其他请求。我一般控制单批在 500 到 1000 条左右,数据量大时拆成多批。
还要注意,pipeline 期间命令不保证实时返回,如果中间有命令报错,结果是最后统一返回,不利于逐条判断。适合批量写入、批量读取、批量删除,不适合依赖中间结果的复杂流程。
6.2 大 Key 和热 Key
大 key 是线上最常见的隐形杀手。一个 String 类型的 value 达到几十 KB,或者一个 Hash/Set 集合里有几万个元素,都算大 key。大 key 的风险是:写入时占用大量带宽,删除时阻塞 Redis 主线程,在集群模式下还会导致内存分布不均。
定位大 key 最简单的方式是使用redis-cli --bigkeys扫描,它会把不同类型里最大的 key 列出来。处理大 key 的思路是拆分:一个大的 Hash 可以拆成多个小 Hash,按哈希或用户维度分片;大的 String 看能否改成满足业务需求的结构。删除大 key 时不要用 DEL 硬删,DEL 是同步操作会阻塞,Redis 4.0 之后可以用 UNLINK 异步删除。
热 key 则是某个 key 的 QPS 远超单节点承载能力。常见解法有三种:第一,加本地缓存,把热 key 数据放到 JVM 本地;第二,热点 key 多副本,比如同一个 key 拆成 key#1 到 key#N,读取时随机选一个副本,把热点分散到不同节点;第三,对只读热点增加从节点分担读流量。副本方案要注意数据短时不一致,只适用于读多写少的热点。
6.3 连接池:配置不好,Redis 再快也白搭
Redis 本身是单线程处理命令,但客户端连接数不代表并发处理能力。如果每次请求新建连接、用完关闭,握手开销会变成主要瓶颈。连接池的核心是复用连接。
连接池参数有几个关键点:最大连接数要跟服务 QPS 匹配,不要盲目给到几千,Redis 一个节点通常单个实例就能支撑几万 QPS,连接数多了反而增加上下文切换。我习惯把最大连接数控制在 50 到 200 之间,空闲连接保留 20 个左右,获取连接超时时间设置 1 到 3 秒。如果连接池经常满,先看是不是某个慢命令阻塞了连接池的归还,而不是继续调大连接数。
6.4 内存优化:省内存也是省成本
Redis 内存贵,同样的数据用不同结构存储,占用的内存差别很大。关键优化点有三个。
第一是序列化方式。不要用默认的 JDK 序列化把一个 Java 对象直接塞进 Redis,序列化后的一长串字节不仅体积大,而且不可读。用 JSON 或者更紧凑的二进制序列化,同样的用户对象可能省一半内存。
第二是 key 命名。key 要短而有含义,比如user:1001:profile比user_profile_by_user_id_1001短很多,如果 key 数量有几千万,这个差距直接体现在内存上。
第三是数据结构选择。简单对象用 String 存 JSON,字段多且经常只读部分字段时考虑 Hash 结构。Redis 对小体积的集合类型有压缩编码,如果 Hash 里字段不多,存储开销非常小。但要注意,一旦某个 Hash 的元素个数或单个元素长度超过阈值,它就会从压缩结构退化为正常的哈希表结构,内存暴增。所以批量往 Hash 里塞数据时要提前评估规模,掐好阈值。
6.5 慢命令和故障排查
线上排查 Redis 问题,我的习惯是先看慢日志:执行 SLOWLOG GET,找出执行时间超过阈值的命令。生产环境特别要注意 KEYS 这种 O(N) 命令,大数据量下它会阻塞主线程,线上禁止使用,需要匹配时改为 SCAN 分批遍历。
再查看命令统计,也就是 INFO commandstats,看哪些命令调用次数多、耗时高。配合客户端监控的响应延迟,能判断问题出在 Redis 本身还是网络链路。如果延迟高但 Redis 内部耗时很低,优先排查网络和客户端序列化。
还有一个容易踩的坑:monitor 命令虽然能实时看到所有命令,但会持续给 Redis 主线程增加大量输出开销,线上不能长时间开,只在故障现场短时间抓包用。
我在实际项目里还总结了一条经验:任何性能优化,都要先量化再动手。你先记录优化前接口延迟和 Redis 各命令耗时,改完再对比,判断是否真正生效。不要一上来就猜是连接池问题或慢命令问题,大多数情况下都是大 key 或者批量命令拖垮了节点。
最后分享一个非常小的实战细节:给每个环境设置不同的 key 前缀,比如dev:user:1001、prod:user:1001,隔离环境数据。否则测试环境的脏数据覆盖到生产环境,排查起来会让人抓狂。分布式缓存这门技术,难点从来不在命令本身,而在这些从需求到部署、从异常到优化的细节里。把这套逻辑理清了,不管用 Redis 还是其他缓存中间件,你都能快速上手并避免踩坑。