Redis实战指南:缓存穿透、持久化与线上排障经验 做了这么多年后端我见过太多团队把 Redis 用得稀里糊涂业务逻辑里随手set一个 key过期时间随缘填线上出问题了就重启 Redis。网上聊 Redis 的文章汗牛充栋但大部分都在讲命令很少讲什么时候该用、什么时候不该用、出了问题怎么查。这篇文章不打算重复手册我想从实际项目里 Redis 的定位、缓存三兄弟的坑、结构化数据设计、持久化策略、线上排障这五个维度把我这些年积累的真实经验捋一遍。适合正在写业务的后端开发、刚接手 Redis 运维的同学也适合那些Redis 用了两三年但遇到问题就懵的朋友。Redis 本身的安装部署我就不展开了默认你已经有一个能跑起来的实例。需要提醒的是本文所有经验都来自通用场景不绑定特定版本但建议至少用 6.x 以上很多新特性和改进确实香。1. 别急着写代码先弄懂 Redis 在你项目里到底是什么角色很多人有个错觉Redis 就是缓存数据库所有数据塞进去就完了。这个认知偏差是后面一系列线上事故的根源。1.1 Redis 为什么快单线程模型的底层逻辑Redis 的核心处理模型是单线程事件循环配合 IO 多路复用。我见过不少初级开发一听到单线程就皱眉单线程怎么可能快这里要把因果搞清楚。Redis 的操作基本都在内存里完成哈希表的读写是微秒级真正的瓶颈在网络 IO。单线程模型最大的好处是没有锁竞争没有上下文切换的开销。数据结构和命令实现里可以放心地做各种原子操作不用考虑并发改写同一块内存的问题。这就解释了一个现象Redis 的读写吞吐量能轻松到十万级 QPS而同样做缓存的某本地内存组件一旦并发线程多了性能反而因为锁竞争往下掉。所以在判断这个操作能不能让 Redis 扛的时候优先考虑的是命令复杂度而不是线程数。1.2 什么数据适合进 Redis一个三层筛选漏斗我在项目里判断一个数据是否该放进 Redis会过三层筛子读写比例是不是明显读多写少如果写和读一样频繁缓存的价值就大打折扣。时效容忍度能不能接受数据有短暂的不一致比如用户头像可以容忍 5 分钟延迟但库存扣减绝对不能靠缓存做最终一致。数据量级单个 value 是不是超过 100KB如果 value 动不动几 MBRedis 的吞吐会迅速劣化反而不如直接走磁盘存储。一条配置数据、一份商品详情、一个用户会话这些天然符合读多写少、小体积、弱一致的特征。而一份几十 MB 的报表、一套复杂的跨表查询结果就不适合硬塞 Redis。1.3 最容易翻车的两种用法第一种是把 Redis 当万能存储。有人把订单数据、账单流水直接写 Redis然后说反正 Redis 快。可 Redis 的持久化能力压根不是为这个设计的内存成本也高得离谱。第二种是把 Redis 当强一致数据库。Redis 的主从复制是异步的主节点刚写完还没同步从节点就切上来了数据就丢了。那些用 Redis 存钱包余额的方案我每次看到都替他们捏把汗。这个角色判断没做对后面的缓存命中率、过期策略都是空中楼阁。我见过最夸张的案例有人把全量用户表灌进 Redis内存干到 20GB结果命中率不到 10%——因为大量冷数据永远没人访问。2. 缓存三兄弟的坑穿透、击穿、雪崩的处理方案怎么选缓存最经典的三道坎几乎每个上规模的项目都会遇到。网上方案很多但真正落地时各有取舍我挨个说说实际经验。2.1 缓存穿透攻击型流量怎么挡穿透是指请求一个缓存和数据库里都不存在的数据。比如查一个不存在的用户 ID缓存查不到数据库也查不到于是每次请求都打到数据库。如果有坏人拿不存在的 ID 疯狂刷数据库直接被打挂。常规方案是缓存空值。查不到数据库记录时向 Redis 里写一个 value 为 null 的 key过期时间设短一些比如 60 秒。这样后续同样的请求直接命中空缓存。这个办法简单有效但有个副作用空值 key 会占内存而且如果非法 ID 是随机生成的空值缓存策略也会失效。更稳妥的方案是布隆过滤器。启动时把所有存在的 ID 加载到布隆过滤器里请求先进过滤器def is_id_exist(bloom_filter, user_id): if not bloom_filter.might_contain(user_id): # 一定不存在直接返回 return False # 可能存在继续走缓存 - 数据库链路 return True布隆过滤器的特点是一定不存在的肯定不存在存在的可能有误判。它能挡住绝大多数非法 ID 请求。代价是需要维护过滤器和数据源的一致性新 ID 入库时必须同步加入过滤器否则合法请求也会被误杀。我在项目里通常两者结合布隆过滤器挡非法流量空值缓存挡合法但数据确实缺失的场景。2.2 缓存击穿热点 key 过期瞬间的惊群效应击穿和穿透一字之差含义完全不同。击穿是指一个热点 key 在过期那一瞬间大量请求同时涌入发现缓存没了全冲去数据库。比如电商秒杀的商品详情页平时都打缓存突然 key 过期了几百个请求同时查库数据库压力瞬间拉满。最常用的方案是互斥锁重建缓存。核心逻辑是缓存 miss 后先尝试获取分布式锁只有拿到锁的线程才去查库并回填缓存其他线程先 sleep 一下再查缓存def get_hot_value(key): value redis.get(key) if value is not None: return value # 尝试获取锁锁的 key 用业务 key 加后缀 if redis.set(flock:{key}, 1, nxTrue, ex5): try: value db_query(key) redis.set(key, value, ex3600) return value finally: redis.delete(flock:{key}) else: time.sleep(0.05) return get_hot_value(key) # 递归重试这个方案的缺点是要写递归重试逻辑而且会有短暂的窗口期第一个拿到锁的线程还在查库回填时其他线程都在 sleep相当于短暂卡了一下。另一个思路是逻辑过期缓存不设置物理过期时间而是把过期时间写进 value 里。每次读缓存时发现逻辑过期就异步起一个线程去刷新当前请求继续返回旧值。这个方案没有卡顿但拿到的数据可能已经过期了一段时间适合对实时性要求不高的热点数据。我个人的取舍标准是数据一致性要求高就选互斥锁实时性要求更高就选逻辑过期。没有银弹。2.3 缓存雪崩批处理 key 同时失效的连环爆炸雪崩是大量 key 在同一段时间集中过期或者 Redis 节点本身就挂了。前者可以通过设置过期时间抖动来规避给每个 key 的过期时间加一个随机偏移量比如 240 到 360 秒之间随机取让过期时间分散开import random expire_time random.randint(240, 360) redis.set(key, value, exexpire_time)这个随机偏移能解决批量导入缓存时全部设成同一过期时间的问题。我见过有团队用pipeline批量设置 1 万个 key过期时间都是 300 秒于是每 5 分钟整点数据库就迎来一波流量尖峰。加了抖动之后峰值直接削平。至于 Redis 节点宕机导致的雪崩那就是运维层面的高可用问题了主从复制加哨兵是底线这个后面展开说。3. 把 Redis 当数据结构用我在业务中高频复用的五种设计这是 Redis 最被低估的部分。大部分人只会用set和get但 Redis 的价值大头在于那几种原生数据结构。用好它们很多业务逻辑能省掉一大半代码。3.1 ZSET 做排行榜实时 Top N 的正确打开方式排行榜需求到处都是积分榜、热销榜、活跃榜。用关系型数据库做排行榜等你 ORDER BY LIMIT 跑完一次查询榜单早就变了。Redis 的 ZSET 把 score 从小到大维护插入、更新、排名查询全是 O(logN)。以积分榜为例# 用户 1001 增加 5 分 ZINCRBY leaderboard 5 user:1001 # 查用户 1001 的排名从高到低所以用 REV ZREVRANK leaderboard user:1001 # 查 Top 10 ZREVRANGE leaderboard 0 9 WITHSCORES我做过一个积分活动的实时榜单百万级用户每秒钟都有积分变动全程靠 ZSET 扛没有一条 SQL 参与榜单计算。需要注意的一点是ZREVRANGE的排名是从 0 开始的业务里展示如果要第 1 名记得加 1。3.2 HyperLogLog 做 UV 统计12KB 内存装下千万级用户访问UV独立访客统计是个典型的准确率不用 100%但浪费内存可不行的场景。用 SET 去重千万级 UV 的内存开销非常可观。HyperLogLog 的标准错误率是 0.81%内存固定大约 12KB。PFADD today_uv user:1001 user:1002 PFCOUNT today_uv我用这个方案做过一个活动页的每日 UV 统计跑了一个月内存占用稳定得可怕。当然HyperLogLog 有个坑它不支持删除单个元素。如果你需要精确到某个用户是否访问过那就得用 SET 或者布隆过滤器不能指望 PF 系列。3.3 Lua 脚本做库存扣减把原子性写进 Redis秒杀、抢购这类场景最怕超卖。Redis 的DECRBY本身是原子的但先查库存够不够再扣减这个逻辑组合起来就不是原子的了——除非你用 Lua 脚本-- 库存扣减脚本 local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1调用的时候用EVAL把这脚本整体发过去Redis 在单线程模型里执行整个脚本中间不会被其他命令插队。这个方案成功压测扛住了某抽奖活动整场的并发扣减数据库端只处理最终落库压力小了很多。需要提醒的是Lua 脚本里最好不要做耗时的操作比如循环几千次甚至KEYS查询因为它会阻塞整个 Redis 实例吃掉的性能是全局性的。3.4 SETNX 分布式锁锁误删的坑我替你们踩过了用 Redis 做分布式锁是老话题但最常见的坑是误删锁。线程 A 拿到锁执行时间超长锁因为过期时间到了自动释放线程 B 拿到锁开始执行这时候线程 A 终于跑完了执行DEL把线程 B 的锁删了。标准解法是锁的 value 存一个唯一标识删除前先比对再删而且比对和删除必须是一个原子操作还是 Luaif redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end关于过期时间续期我见过很多简陋的自研方案其实主流客户端库里已经有看门狗机制锁加成功后后台线程会每隔一段时间自动延续过期时间直到业务逻辑执行完释放锁。如果你不想自己造轮子直接引入成熟的分布式锁库是最稳的选择不要重复发明这个轮子。3.5 Stream 做轻量消息队列比 List 可靠得多List 的BLPOP也能实现队列但消息消费后如果客户端崩溃消息就丢了。Stream 结构提供了消费者组和消息确认机制每条消息都有一个 ID消费者消费后必须显式XACK否则消息会留在队列里待重新投递。XADD order_stream * order_id 1001 XREADGROUP GROUP order_group consumer1 COUNT 1 STREAMS order_stream Stream 在 Redis 5.0 之后就比较成熟了。如果你的团队不想引入额外的消息队列中间件而且消息量不大比如每秒几千条以内Stream 是个轻量可行的选择。但它毕竟不是专业 MQ没有延迟队列、死信队列这种高级语义量大了还是老实上专业消息队列。4. 线上 Redis 慢和丢数据持久化与内存策略的取舍这一章聊运维侧的硬核问题。Redis 用着用着变慢了、重启后数据丢了这两件事我都遇到过核心都在持久化配置和内存淘汰策略上。4.1 RDB 和 AOF要性能还是要安全Redis 默认开 RDB定期把内存快照写盘。它的恢复速度快但两次快照之间的数据会丢。AOF 则是把每个写命令追加到日志文件可以做到秒级甚至更小的数据丢失窗口但文件大、恢复慢。我建议生产环境用混合持久化方式aof-use-rdb-preamble yes。它的原理是AOF 文件头部用 RDB 格式存一个全量快照快照之后追加增量命令。这样重启时先加载 RDB 再应用增量恢复速度快数据丢失窗口也小。对比一下各方案持久化方式数据丢失窗口恢复速度性能开销适用场景纯 RDB两次快照之间全丢快低可容忍分钟级丢失的缓存场景纯 AOF取决于 fsync 策略最差秒级慢高数据较重要需要接近不丢混合持久化秒级较快中生产环境兜底推荐fsync 策略也要注意appendfsync everysec是性能和安全的均衡点always每条命令都刷盘性能损失很大磁盘差的机器直接拖垮写入。4.2 内存淘汰策略选错了数据会悄悄消失很多新人不知道 Redis 有一个maxmemory上限。一旦内存用完写入就会触发淘汰策略。默认策略是noeviction不淘汰直接报错很多线上实例就是这么莫名其妙写不进去数据的。各策略怎么选我用一张表说清楚策略淘汰范围适合场景allkeys-lru所有 key 中淘汰最久未访问的纯缓存数据可重建allkeys-lfu所有 key 中淘汰访问频率最低的热点明显的缓存volatile-lru设置了过期时间的 key 中淘汰有冷热之分且有业务数据保底要求volatile-ttl按剩余存活时间淘汰最快的批量过期场景noeviction不淘汰写满报错绝对不能丢数据的业务我的建议是如果你是纯缓存直接上allkeys-lfu。LFU 比 LRU 聪明的地方在于能识别曾经热但现在不热的 keyLRU 面对一次性的热点访问很容易误淘汰真正频繁访问的 key。至于监控命中率别靠猜。连上 Redis 实例看两个计数器INFO stats # 关注 keyspace_hits 和 keyspace_misses命中率 hits / (hits misses)。如果命中率长期低于 80%别急着调淘汰策略先检查是不是有大量冷数据压根不该进缓存。5. 高危命令与排查链路一次 slowlog 定位的完整过程我记忆中最清晰的一次线上事故是某次活动预热期间 Redis 节点 CPU 突然飙到 90% 以上所有读写都慢了一倍。当时排查过程我完整复盘一下这个过程比最终结论更有参考价值。5.1 第一层用 INFO 看命中率和连接数我先连上实例执行INFO发现keyspace_hits和keyspace_misses的比例倒挂——命中率只有 40% 左右。按道理缓存命中率骤降说明大量请求根本打不到缓存。正常业务不会突然改变访问模式我第一反应是有人引入了新的查询逻辑key 设计可能有问题。顺着这个思路看了当前连接的客户端列表和每秒命令统计发现keyspace_misses暴涨的 key 前缀很集中都是一个业务模块在查。这个模块的代码我扫了一遍发现有人直接用KEYS命令做模糊匹配KEYS user:*5.2 第二层slowlog 和 big key 的实锤KEYS是出了名的高危命令它需要遍历整个 key 空间而且单线程模型下会阻塞主流程。我当时直接用SLOWLOG GET拉最近慢命令SLOWLOG GET 50果不其然几十条慢日志全是KEYS user:*单条执行耗时都在几百毫秒到秒级。这里必须强调生产环境禁用KEYS是底线。你不确定能不能用的时候就默认不能用。替代方案是SCAN游标遍历SCAN 0 MATCH user:* COUNT 100它不会阻塞每次返回一批需要循环迭代直到游标回到 0。模糊查询的需求用这个完全能解决。顺着 slowlog 继续挖我又发现一个隐藏隐患部分 value 已经涨到几 MB。用内置命令快速扫一下大 keyredis-cli --bigkeys结果有几个 Hash 结构里存了上百万字段每次读写都极慢。这就是典型的 big key 问题单个 key 太大Redis 处理的时间就长一长就开始排队排队就拖垮所有请求。5.3 第三层修复与验证修复方案分三步把业务代码里的KEYS全部替换为SCAN并给 Redis 配置禁用危险命令从源头堵死rename-command KEYS rename-command FLUSHALL rename-command FLUSHDB 把 big key 拆分比如大的 Hash 按业务维度拆成多个小的 Hash每个控制在 10KB 以内。拆完再跑INFO stats命中率回到 95% 以上P99 延迟恢复到毫秒级。这个排查链路看起来顺理成章其实有个容易忽略的细节替换代码后务必要灰度观察slowlog是不是真的不再出现新记录了。很多人改完代码就收工结果还有定时任务在偷偷执行KEYS第二天又复现。5.4 日常巡检清单别等线上出事才想起 Redis这一章最后分享一份我一直在用的巡检清单每周花十分钟跑一遍能挡住 80% 的低级事故检查INFO stats的命中率和INFO memory的内存水位内存水位超过maxmemory的 70% 就要评估扩容或清理。拉SLOWLOG GET 20看最近有没有慢命令重点排查KEYS、HGETALL、SMEMBERS这类 O(N) 命令。跑redis-cli --bigkeys识别大 key制定拆解计划。确认主从复制正常INFO replication看master_link_status是否为 up。核对持久化配置确保appendonly yes且appendfsync everysec或者混合持久化开启。监控淘汰策略触发的 key 数量如果触发频繁说明内存真的不够了该扩容扩容该调整过期时间调整过期时间。这套巡检我跑了几年Redis 相关的线上生产事故基本都在早期被拦住了。真等用户反馈说系统慢了再去查代价往往是加班到凌晨。最后再分享一个小技巧很多排查经验不是看书看来的是线上事故逼出来的。我建议你把自己负责的 Redis 实例所有的配置项导出来一份逐条看一遍注释比任何教程都管用。尤其是那些你从没动过的参数——往往就是你最该理解的参数。