
缓存这块我这些年差不多是被Redis“教育”过来的。刚工作那会儿觉得缓存就是把数据放内存里读快一点用起来才发现里面的门道比想象中多得多——光是一个缓存穿透就能把数据库打挂一个key过期时间设置不合理就能引发雪崩。这篇东西主要讲Redis做分布式缓存时那些绕不开的细节怎么选数据结构、序列化怎么做、更新策略怎么定、遇到穿透/击穿/雪崩怎么防、集群怎么搭、线上出问题怎么排查。适合正在做后端开发、准备把Redis用到生产环境或者虽然用了很久但总觉得哪里没吃透的同学。1. 缓存设计的基本盘为什么分布式缓存选Redis1.1 缓存到底在解决什么问题很多刚接触缓存的同学会把缓存理解成“存一份数据副本读的时候快一点”。这个说法不全面。缓存要解决的其实是两件事一是降低响应延迟二是保护后端资源。先说延迟。数据库的查询通常在毫秒级但如果数据量大、表结构复杂、或者走了远程调用延迟就会上升。而内存中的读取是微秒甚至纳秒级的一个Redis的GET操作正常网络下1ms内就能返回。对于读多写少的业务把热点数据放到Redis里接口延迟可以从几十毫秒降到个位数毫秒。再谈保护。每一个请求直接打到数据库数据库的连接数、CPU、磁盘IO都会被消耗。数据库的连接池是有限的比如常见配置100200个连接超过这个数请求就要排队甚至报错。而Redis单机就能扛住10万的QPS把高频读请求拦截在Redis这一层数据库的负载会大大降低。这也是为什么缓存常说“挡在数据库前面的一堵墙”。1.2 为什么是Redis而不是本地缓存或Memcached本地缓存比如应用内存里的Map、或者各种进程内缓存框架最大的问题是多实例不一致。应用部署了3台机器同一个用户的请求打到不同机器各自的缓存内容可能不同而且服务重启后缓存全部丢失瞬时的大量请求又会直接打到数据库产生缓存击穿。Redis是独立部署的进程所有应用实例共享同一份缓存天然解决了多实例一致性的问题。Memcached虽然也是分布式缓存但Redis能后来居上靠的是数据结构和持久化这两张牌。Memcached只有简单的KV类型而Redis提供了String、Hash、List、Set、ZSet、Stream等丰富的数据结构很多业务逻辑可以直接在Redis里完成不需要取回数据到应用层再计算。另外Redis支持RDB和AOF两种持久化机制重启后能恢复数据Memcached重启就全丢了这在很多需要缓存预热的场景里是致命差异。再加上Redis自带的原子操作、分布式锁、发布订阅能力让它在缓存之外还能承担很多基础设施的职能。提示选型时不要只看“缓存”两个字。如果你的业务需要复杂数据结构、需要缓存数据不因重启而丢失、需要在缓存上做原子操作Redis是更合适的选择。1.3 什么时候不该用Redis缓存经验告诉我引入缓存前先想清楚数据适不适合缓存。强一致要求高的数据不适合。比如账户余额、库存扣减这类数据如果先更新数据库再删除缓存在删除缓存前的短暂窗口内用户可能读到旧值如果先删缓存再更新数据库一旦更新失败缓存里就没数据了数据库又要被压。虽然可以用分布式锁等方式缓解但本质上缓存会引入最终一致性的窗口期强一致场景会做得非常痛苦。更新频繁且读很少的数据也不适合缓存。每次写入都要更新缓存而缓存读的比例很低这就是纯开销。判断一个数据适不适合缓存核心指标是读多写少 热点集中。如果每个key都只被读一两次缓存命中率会很低反而浪费内存空间。2. 数据选型与序列化方案最容易踩坑的地方2.1 五种常用数据结构的使用场景Redis里最常用的五种数据结构值得逐一捋一遍因为选错了结构后续编码和性能都会很难受。String是最基础的kv结构适合存用户信息JSON串、Token、计数器。Redis的INCR命令能完成原子自增做限流计数、订单号生成都很方便。我在项目里用String做过一个简单的API限流以用户ID接口名为keyvalue存访问次数配上过期时间INCR之后判断次数是否超限整个逻辑只需要两行命令。Hash适合存对象类型的数据。与String存整个JSON的区别是Hash可以单独读写对象的某个字段。做一个用户资料缓存如果只需要改用户的昵称用Hash只需要HSet nickname字段而String必须整个取出来反序列化、修改、再序列化写回效率和并发安全性都差很多。当字段多且经常部分更新时Hash是更好的选择。List用LPUSH加上BRPOP可以实现一个简单的消息队列。虽然现在有专门的消息中间件但轻量场景下Redis List足够胜任。需要注意的是List的某个key数据量过大会变成大Key后面会详细说需要控制单个列表的长度或者用LTRIM做裁剪。Set适合做去重和集合运算。比如用SADD记录某场活动的用户IDSISMEMBER判断用户是否参与SINTER可以算出两场活动的共同用户。ZSet则在Set的基础上加了分数适合做排行榜。我还用它做过一个滑动窗口限流以当前秒为单位的时间戳作为分数每次请求ZADD进去再用ZREMRANGEBYSCORE把窗口外的记录删掉最后ZCARD统计窗口内的请求数。2.2 序列化方式的坑Redis本身只存字节用什么方式把对象变成字节由客户端序列化器决定。序列化方案选得不对后面会遇到一串问题。我见过不少项目直接用JDK默认序列化比如某些客户端默认配置结果往Redis里写了个Java对象在命令行里看到的全是\xAC\xED等乱码而且JDK序列化后的字节体积大、性能差。更好的做法是使用JSON或二进制序列化方案。JSON可读性好、跨语言方便但体积相对大一点如果追求极致性能可以考虑Protocol Buffers或Kryo但会引入额外的schema管理成本。这里有一个我踩过很多次的坑修改了实体类字段后老缓存数据反序列化失败。比如JSON序列化的时候老数据里没有的字段反序列化会得到空值这个还好但用了Protobuf这类强类型方案老数据格式不兼容就直接抛异常。所以线上改动实体类时要么在序列化方案里做兼容处理要么同步处理缓存版本的平滑迁移。我现在的习惯是缓存对象里增加一个version字段或者用单独的缓存key前缀区分版本。注意生产环境中Redis里存了什么一定要有一个全局的命名规范和序列化规范否则后期数据排查时你会面对一堆看不懂的乱码根本没法定位问题。2.3 连接池与多级缓存应用里访问Redis一定要用连接池不用连接池会导致每次操作都新建连接性能极差。常见客户端如Lettuce、Jedis都支持连接池配置一般初始化时设置MaxTotal、MaxIdle、MinIdle、MaxWaitMillis等参数。调试线上问题的时候我发现连接池的MaxTotal配置太大会导致系统资源浪费配置太小高并发下会让请求阻塞等待连接。有一个大致的经验公式预估单台Redis每秒需要处理的命令数除以单连接每秒能处理的命令数得到需要的连接数下限再乘以1.52的冗余系数作为MaxTotal。具体数值看业务不能盲目照抄网上的配置需要压测验证。多级缓存是另一个思路本地内存做一级缓存Redis做二级缓存数据库做三级。应用先查本地再查Redis最后查库。优点是性能更好、对Redis的压力更小缺点是本地缓存的一致性问题很难处理需要用消息通知或其他手段主动失效各实例的本地缓存。我的建议是多级缓存适合那种数据变化不频繁但读取量极大的场景如果数据经常更新还是老老实实用Redis单级缓存少给自己找麻烦。3. 缓存更新策略与一致性绕不开的三个核心难题3.1 Cache Aside与延迟双删缓存一致性最经典的做法是Cache Aside模式读的时候先读缓存读不到读数据库再回填缓存写的时候更新数据库然后删除缓存。为什么是删除缓存而不是更新缓存因为更新缓存需要额外的序列化操作而且如果多个线程并发写后写的先到、先写的后到缓存里的数据反而是旧的删除缓存则可以让下一次读取重新加载。但Cache Aside有一个经典的竞态窗口线程A读缓存没有命中去读数据库拿到旧值此时线程B更新了数据库并删除缓存线程A随后把旧值回填到缓存。结果缓存里又是一份过期数据。解决这个问题的常见手段是延迟双删更新数据库后先删除一次缓存等待几百毫秒再删除一次缓存。这样即使有线程回填了旧值第二次删除也能把它清掉。这里的等待时间需要估算要保证大于“线程A从读库到回填缓存”的耗时一般压测后定300500ms比较常见。同时把写操作的代码尽量加上分布式锁减少并发写导致的脏数据概率。3.2 缓存穿透、击穿与雪崩成因和应对这三个概念经常被放在一起说但成因和解决思路完全不同。缓存穿透查询的数据在数据库里根本不存在所以缓存里也一直没有这个key每次请求都会打到数据库。攻击者可以构造一堆不存在的ID来打爆数据库。解决办法有两个思路一是缓存空值把不存在的结果也缓存起来设置较短的过期时间比如60秒这样可以挡住大部分重复请求二是用布隆过滤器在缓存前加一层过滤器把所有可能存在的数据ID放进去ID不在过滤器中直接返回不需要查库。布隆过滤器有误判率但能保证“一定不存在的”不会通过适合ID规律性强的场景。缓存击穿某个热点key在过期的那一瞬间大量请求同时打到数据库。和穿透不同击穿时的数据是存在的只是缓存刚好到期了。解决办法第一个是逻辑过期value里存一个过期时间戳读到时判断是否过期过期了就只让一个线程去刷新缓存其他线程返回旧值不会都把请求放到数据库第二个是互斥锁缓存过期后获取分布式锁只有拿到锁的线程去查库并回填缓存其他线程等待后重新读缓存。两种方案各有取舍逻辑过期实现相对复杂但性能最好互斥锁简单直接但阻塞时间可能拖慢接口响应。缓存雪崩大量key在同一时间段集中过期或者Redis实例宕机导致大量请求直接打到数据库。解决过期时间集中的问题主要是在设置过期时间时加一个随机值比如 基础过期时间 随机0300秒让过期时间分散开。Redis宕机的应对方案则靠高可用架构主从切换、哨兵、集群这部分在后面单独讲。提示生产环境我建议三个方案都做过期时间加随机偏移是必须的互斥锁或逻辑过期按热点key的重要性选择空值缓存和布隆过滤器按接口防攻击的强度要求选择。3.3 分布式锁的正确写法Redis做分布式锁最常见的坑是用SET key value EX seconds NX这条命令——对着一个key设置值只有key不存在时才生效配合过期时间实现锁。看起来没有问题但很多人在释放锁的时候踩了坑。我第一次写分布式锁时就犯过一个错误A线程拿到锁、处理业务、然后DEL key但此时锁已经过期了B线程拿到了锁A线程的DEL把B的锁给删了。解决方案是value里存一个唯一标识比如UUID或线程ID删除前先GET判断是不是自己的锁是才删并且GET和DEL要保证原子性用Lua脚本实现。正确的Lua脚本大概是这样的if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end也就是先检查value是否匹配匹配才DEL这两步在Lua脚本里是原子的不会被其他线程的DEL操作横插进来。锁的超时时间设置也是个难点。设置太短业务还没执行完锁就过期了设置太长如果持锁方宕机其他线程要等很久。有些项目引入了Redisson的看门狗机制锁快过期时自动续期实现相对复杂。如果不想引入额外依赖我的折中建议是把超时时间设为业务预估耗时的35倍并加上监控告警如果锁频繁超时说明业务执行时间超出预期需要线程排查接口性能问题。4. 高可用与集群形态单机之外的选择4.1 主从复制与哨兵单机Redis会面临两个问题一是可用性宕机了缓存全部不可用二是容量单机内存有限。解决可用性问题的最初级形态是主从复制一个主节点负责写多个从节点负责读。主节点把每次写操作同步给从节点主节点挂了可以由从节点顶上。但主从复制本身不提供自动切换的能力这需要**哨兵Sentinel**来完成。哨兵做的事情可以概括为监控、通知、自动故障转移。哨兵进程定时给Master发心跳如果Master在指定时间内没有响应哨兵会经过判断多个哨兵投票确认后把某个从节点提升为新主节点并修改客户端配置指向新的Master。部署时需要至少3个哨兵实例奇数个才能避免误判和脑裂场景。我部署过的最小高可用方案是1个Master 2个Slave 3个Sentinel。这样可以容忍一个从节点宕机或一个哨兵节点宕机Master宕机后能在几十秒内完成自动切换。需要提醒的是主从切换期间会有短暂的不可用如果你对可用性要求极高还需要在客户端做重试和熔断逻辑。4.2 Cluster集群的拆与合当单机内存不够或写入并发太大时需要用到Redis Cluster。Cluster把key分散到16384个哈希槽slot中每个节点负责一部分slot。写入某个key时Redis根据key的CRC16值 % 16384计算出应该落到哪个slot再由该slot所在的节点处理。集群部署有个需要注意的点客户端需要支持集群模式否则路由时会报错。另外Cluster的所有节点都要求开启集群模式数据会自动分片不会出现某个节点数据过多的情况。扩容时可以加节点并重新分片slot这个过程对业务有影响要在低峰期操作。Cluster的一个固有限制是不支持多key操作比如MGET跨不同槽位的key因为key可能分布在不同的节点上无法在一个节点内原子执行。如果业务里经常有批量操作可以在key设计时用哈希标签hash tag把相关的key强制放到同一个slot里比如用{user:1001}:orders和{user:1001}:cart这样花括号内的内容参与哈希计算两个key必然落在同一个slot。4.3 容量预估与内存回收容量预估很容易被忽略等内存打满了才去扩容已经晚了。我一般用这个方式估算单个key平均大小乘以key总量再加上Redis自身的开销每个key约100字节左右额外开销、主从复制缓冲、以及可能存在的持久化缓冲最终预留30%以上的缓冲空间。比如业务预计有1亿个key、平均每个key 200字节那么粗略算下来是 20GB左右加上开销和冗余至少要准备3035GB的内存。内存回收策略上Redis提供了多种淘汰策略maxmemory-policy常见的有策略行为适用场景noeviction内存满了不接受新写入缓存数据不能丢的强一致场景allkeys-lru在所有key中淘汰最近最少使用通用缓存场景allkeys-lfu淘汰访问频率最低的key热点集中明显、频率优先于时间volatile-lru仅淘汰设置了过期时间的key有永久key需要保留的场景volatile-ttl优先淘汰剩余存活时间最短的key需要尽快腾出空间的场景我生产上最常用的是allkeys-lru因为它只关心“多久没被访问”对缓存类业务最直观。注意淘汰策略只适用于设置了maxmemory的情况如果你没设置maxmemory内存满了Redis会直接报错这在生产环境是非常危险的。我见过线上事故就是因为没配置淘汰策略内存写满后所有写入请求都失败了。5. 性能问题排查与实战经验5.1 大Key与热Key的发现线上Redis很多性能问题都出在大Key和热Key上。大Key指的是单个key的value过大比如超过10MB或者一个集合类型的key内元素过多比如几十万个。大Key会导致网络传输耗时增加、阻塞Redis单线程处理其他命令、以及删除操作时长时间阻塞。如何发现大Key可以用redis-cli --bigkeys扫描会自动遍历所有key分析出最大的若干key。我定期会跑一次这个命令做巡检。排查代码如下redis-cli --bigkeys -h your_host -p 6379如果发现大Key处理方式要根据结构来。对于Hash、Set、ZSet可以分批删除或者拆分成多个小的key对于String大value可以考虑压缩存储或者拆分到多个key中但操作时要注意拆分的成本。热Key是某一两个key被超高频率访问把Redis节点的CPU打爆。Redis是单线程模型热Key会让单个节点成为瓶颈即使集群里其他节点闲着也没用。热Key的常见应对方案是本地缓存兜底在应用层对热Key做多级缓存减少Redis压力还有一种方案是Key复制比如把同一个热Key复制成多个副本key比如hotkey#1、hotkey#2请求随机访问不同的副本来分散热点。5.2 慢查询日志与监控Redis提供了慢查询日志可以设置阈值CONFIG SET slowlog-log-slower-than 10000 # 单位微秒这里设置超过10ms的记为慢查询 CONFIG SET slowlog-max-len 128 # 记录最多128条 SLOWLOG GET 10 # 查看最近10条慢查询慢查询日志会记录命令名称、参数、执行时间、客户端地址。排查时重点关注两类一是复杂度高的命令比如KEYS *、大范围的SMEMBERS、HGETALL这些命令设计时应该避免在线上使用改用SCAN或增量获取二是网络或CPU导致的阻塞需要配合监控面板看当时Redis实例的状态。线上监控我建议至少关注这几个指标命中率hits/evicted_keys、内存使用率、客户端连接数、慢查询数量、以及阻塞时长blocked_clients。命中率一旦低于90%就要注意缓存是否有效连接数过高则要检查是否发生连接泄露或线程池配置问题。5.3 我遇到过的一次缓存雪崩事故复盘有一次线上活动做秒杀夜里12点整上线活动页面绑定的配置信息大量写入了Redis而且过期时间都设置成了相同的时长。到了凌晨4点左右缓存集中过期大量配置查询请求直接打到数据库。数据库连接池被打满服务大批报错。当时情况很紧急我临时把过期的key做了批量回填并且把过期时间改成固定基准加随机偏移同时给数据库增加了连接池上限和限流参数。虽然恢复了但教训很深刻任何批量写入的key过期时间都必须在统一基准上加上随机值。后来我把过期时间设置封装成了一个工具类自动在传入的基础过期时间上加上010%的随机秒数。那次事故还让我意识到一个点架构上要有兜底。比如即使Redis穿透了数据库的连接池和限流策略要能撑住流量冲击否则一旦缓存失效整个系统是裸奔的。6. 从运维视角看Redis集群治理6.1 巡检与容量规划缓存系统最怕的不是出问题而是出了问题才发现没有监控。我建议每个使用Redis的项目都做一次完整的巡检内容包括实例的内存使用率是否超过70%、是否存在大Key/热Key、过期key占比是否异常、主从延迟是否过大、慢查询数量是否呈上升趋势。容量规划应该是常态化的。线上数据增长后原来预估的内存很快就可能不够用。给Redis预留内存余量、定期检查maxmemory配置与淘汰策略是否合理、规划扩容方案这三件事建议纳入例行运维清单。如果Redis使用的服务器上还有其它进程尤其要注意内存分配情况避免操作系统因为内存不足触发OOM把Redis杀掉。6.2 灰度发布与缓存变更规范缓存设计跟服务发布一样也应该有规范。比如修改缓存key的TTL策略、切换序列化方式、修改淘汰策略都属于高风险变更应该走灰度流程。我自己习惯的做法是先在测试环境压测验证再到一台生产实例上小流量验证观察命中率和延迟稳定后才全量铺开。任何缓存相关的变更都要有回滚预案——比如旧key还可以读取的话不要急着删除可以双写一段时间。另外一个容易被忽略的是缓存监控告警的阈值设置。不要把告警阈值设得太灵敏否则报警信息全部变成“狼来了”大家会疲劳。比如命中率低于90%时可以告警但如果是查询本来就不集中、整体流量很低的服务命中率低是正常的不需要特别关注。告警规则应该结合业务实际情况定制而不是复制一套通用模板。6.3 最后分享一个长期受益的经验我后来在多个项目里一直坚持一个原则缓存代码必须围绕统一的封装层来写。不管是读缓存的逻辑、缓存回填、过期时间设置、还是key命名都收敛到同一个管理类中。这样做的好处显而易见线上需要排查问题时能快速定位哪些key、什么策略、如何修改新来的同事接入缓存时也不会随手写出各种风格迥异的代码。key命名规范也是很重要的事我一般用业务:场景:维度:标识的格式比如order:detail:2025:10001这样看日志和排查问题时能一眼看懂。key里不加空格和特殊符号统一小写减少不必要的转义和歧义。如果你正在做一个新的项目或者准备优化现有项目的缓存体系我的建议是先从监控和规范入手再谈性能和架构。一个没有监控、没有治理规范的缓存集群即使性能再高出了问题也只能靠救火。而把基础规范和监控做了大部分所谓“Redis问题”其实是可以在发生前就被识别和规避的。