做后端这几年凡是线上Redis出问题十有八九都是缓存设计阶段埋的雷。要么是缓存穿透直接打爆数据库要么是热key把单节点CPU跑满再要么就是一批key同时过期后端服务瞬间卡死。今天这篇不聊Redis命令手册专门聊聊缓存设计与性能优化里最关键的几个决策点以及我在真实项目里踩过的坑、最后沉淀下来的方案。适合刚接触缓存的后端同学也适合已经在线上扛过几次事故、想系统梳理一遍的人。1. 缓存设计先想清楚这三件事1.1 缓存什么、缓存多粗很多同学刚开始做缓存容易陷入一个误区只要是读多写少的数据就一股脑往Redis里塞。结果缓存里堆了一堆低频访问的数据内存成本飙升性能收益却很小。缓存设计的第一个问题是先定义清楚什么数据值得缓存。我一般用两个标准来判断访问频率高且数据相对稳定。数据库查询代价高比如复杂的聚合查询、多表关联、外部接口结果。这两个条件至少要满足一个。像用户基本信息、商品详情、配置类数据属于典型的值得缓存像实时库存那种写入极其频繁、一致性要求极高的数据就不适合直接塞Redis硬塞只会让你同时面对缓存一致性和并发更新的双重折磨。第二个问题是缓存粒度。以用户信息为例如果你只需要用户的昵称和头像但缓存里存的是整个用户对象的JSON序列化结果每次读缓存都要反序列化一个大对象浪费带宽和CPU。反过来如果业务场景就是一次性展示用户所有信息你却把每个字段拆成独立的key又会造成大量的key管理和多次网络请求。我的经验是字段频繁单独修改的场景优先用Hash数据结构按字段读写整体读多、单字段修改少的场景用String存序列化后的对象。这个选择没有绝对的对错但要结合业务的实际读写比例来定不要一上来就无脑存JSON。1.2 过期时间怎么定才不给自己挖坑过期时间设计得不好是引发线上故障最常见的原因之一。最典型的反面案例缓存设置了固定过期时间。某电商团队做过一个营销活动页活动在0点开始他们把活动配置缓存统一设置为凌晨0点过期。结果一到0点所有缓存同时失效数据库瞬间被请求打满活动还没开始就崩了。固定过期时间造成的本质问题是缓存雪崩。解决方案也简单给过期时间加一个随机抖动。比如基础过期时间设为1小时实际过期时间在3600秒到4200秒之间随机分布import random expire_time 3600 random.randint(0, 600)这样所有key的过期时间被打散数据库不会在同一时刻承受集中压力。另外一个容易忽略的问题是要不要设置过期时间我见过有人把不经常变化的数据设置为永不过期比如说城市列表。这种数据确实变动少但一旦需要更新就会面临缓存里是旧数据的尴尬。我的建议是再稳定的数据也设置一个较长的过期时间比如24小时或48小时作为最终兜底。即使更新缓存的动作失败了最坏情况也只是短时间内读到旧数据而不是永远读到旧数据。1.3 穿透、击穿、雪崩三个高频故障的应对方案这三个概念几乎每个Redis相关的面试都会问但在线上真正处理过的人才明白它们的差异有多重要。故障类型核心问题典型表现应对方案缓存穿透查询不存在的数据缓存和数据库都没有每次请求直达数据库布隆过滤器、缓存空值缓存击穿热点key突然失效单个热点key过期大量并发请求打到数据库互斥锁、逻辑过期缓存雪崩大量key同时失效一批key在同一个时间点过期数据库压力激增过期时间随机化、多级缓存缓存穿透的应对布隆过滤器是最常用的方案。它用极小的内存代价判断一个key是否可能存在。对于恶意请求伪造大量不存在的ID布隆过滤器可以直接拦截掉大部分。另外也可以缓存空值把不存在的key也写进缓存过期时间设短一点比如30到60秒这样数据库的压力会小很多。缓存击穿我用得比较多的是互斥锁方案。核心逻辑是当缓存失效时不是所有请求都去查数据库而是只让一个请求去回源其他请求短暂等待key hot_product_detail_10001 # 尝试获取锁 lock_ok redis.set(flock_{key}, 1, nxTrue, ex5) if lock_ok: # 拿到锁的线程去查数据库并重建缓存 data query_db() redis.set(key, data, ex3600) redis.delete(flock_{key}) else: # 没拿到锁短暂等待后重试读缓存 time.sleep(0.05) data redis.get(key)这个方案的代价是锁等待期间会有一小部分请求被阻塞但数据库保住了。还有进阶版的逻辑过期方案value里存一个过期时间戳读的时候发现过期就返回旧数据同时异步线程去刷新缓存适合可以容忍短暂数据不一致的场景。雪崩的应对除了过期时间随机化之外多级缓存也很有用。在Redis前面加一层本地缓存比如用进程内缓存承接部分热点读请求Redis的压力就小很多。同时如果Redis节点本身宕机了要做好限流和降级保证数据库不会被直接打崩。2. 缓存与数据库的一致性别再靠玄学2.1 Cache Aside 模式先更新库还是先删缓存缓存与数据库的一致性是缓存设计里争议最多、坑最多的话题。先说结论线上最常用的是Cache Aside模式也叫旁路缓存。读的流程是先读缓存缓存没有就查数据库然后把数据回填到缓存。写的流程是先更新数据库然后删除缓存。为什么写的时候是删缓存而不是更新缓存因为更新缓存的操作成本高而且并发情况下容易出问题。假设两个线程同时更新同一个数据线程A把缓存更新成了自己的值线程B又把缓存更新成了自己的值。如果线程B的数据库更新比线程A晚提交数据库里是B的值但缓存却很可能是A的值缓存和数据库就永远不一致了。删缓存就简单得多下次读的时候发现缓存没有会从数据库重新加载加载到的一定是数据库里最新的值。还有一个关键问题为什么先更新数据库而不是先删缓存如果先删缓存、再更新数据库就会出现一个时间窗口线程A删掉了缓存还没来得及更新数据库线程B来读缓存发现没有就去数据库读到了旧值然后把旧值重新写回缓存。等线程A更新完数据库缓存里已经是旧值了而且这个脏数据不知道要过多久才会被清理。所以Cache Aside的正确顺序是固定的先更新数据库后删除缓存。这个顺序不能乱。2.2 延迟双删不是万能药问题出在哪先更新数据库再删缓存真的就安全了吗还有一种极端情况线程A更新了数据库接下来要删缓存。但在删除之前线程B来读缓存发现缓存还没被删掉读到了旧值。如果线程B读到的旧值恰好需要回填而Redis里的旧缓存还没被删除那么旧值可能被覆盖回来。为了解决这个窗口期问题有人提出了延迟双删先删缓存再更新数据库然后过一段时间再删一次缓存。# 延迟双删 redis.delete(key) update_db() time.sleep(500) # 单位毫秒 redis.delete(key)延迟双删确实能在大多数场景减少不一致的概率但它不是银弹。我第一次用这个方案的时候以为万事大吉后来发现两个问题第一延迟的时间很难定。如果延迟太短没等到读请求全部结束第二次删除就执行了等于没删如果延迟太长这个时间窗口内缓存都是空的相当于缓存被提前击穿数据库压力上升。第二删除操作可能失败。第二次删除如果因为网络抖动失败缓存里的旧值依然会残留。所以延迟双删需要配合重试机制至少要把删除失败的key记录下来异步补偿。2.3 追求更可靠的一致性可以怎么做更可靠的做法是引入数据库变更订阅机制。现在很多中间件都能解析数据库的binlog把数据变更事件发到消息队列然后由消费端异步删除或重建缓存。这个方案的优点很明显数据库更新和缓存删除之间是解耦的更新数据库成功后就提交事件不管后续缓存操作是否成功都可以通过消息队列的重试机制保证最终一定执行。脏数据残留的窗口期非常短而且整个过程是异步的不会影响主业务流程。我之前在一个交易系统里用过类似的方案业务方只更新数据库一个监听程序订阅变更事件解析出变更的表和主键ID然后去Redis删除对应的key。这样业务代码里完全没有手动操作缓存逻辑一致性问题从靠开发者自觉变成了靠机制保证。需要注意的是这种方案也不是强一致从数据库更新到缓存删除之间存在一个短暂的窗口期但通常这个窗口期在毫秒级绝大多数业务是可以接受的。如果业务真的需要强一致比如金融交易类场景最稳妥的做法是不要用缓存直接读数据库。什么都想要最后往往什么都保不住。3. 性能优化重点关注内存、大热key与命令3.1 数据结构选型直接决定内存效率Redis的性能优化第一步永远是内存优化因为Redis是内存型数据库内存就是它的命。我见过最典型的问题用String类型存储一个包含大量字段的对象每个对象的value是几百字节的JSON串。看起来没什么问题但如果对象的数量达到百万级别内存消耗就非常惊人了。Redis内部对小型集合类型做了特殊的编码优化比如Hash在字段数量少、字段值短的时候会用紧凑编码存储比String存JSON省内存得多。相关配置项在redis.conf里可以找到hash-max-ziplist-entries 512 hash-max-ziplist-value 64意思是当Hash的字段数量不超过512个且每个字段的value长度不超过64字节时Redis使用紧凑编码。一旦超过这个阈值就会转换成普通编码内存开销显著上升。所以如果一个对象字段不多、每个字段值不长用Hash去存往往比用String存整个JSON更省内存。反过来如果对象字段很多或者某个字段值很长Hash的field开销就不划算了这时候用String存序列化后的整体对象反而更合适。还有一个小技巧给key起名不要太长。一个key多了20个字节几百万个key就多出几十MB甚至上百MB内存。但这不代表要用无意义的短keykey的可读性还是要保证的。合理的做法是控制在合适的长度比如业务域:模块:ID这种格式既清晰又不冗余。3.2 大key和热key性能杀手必须定期治理大key和热key是Redis线上故障的两大元凶而且经常是结伴出现的。大key指的是value特别大的key。比如一个String类型的value有几十KB甚至几MB或者一个集合类型里有几十万个元素。大key的危害很明显读写大key会占用大量网络带宽和内存删除大key的时候DEL命令是O(N)复杂度会阻塞Redis主线程导致所有请求卡住。我之前处理过一个故障用户查的慢后来发现是一个List类型的key里面有上百万个元素某个清理程序每次执行都直接DEL这个key。DEL执行期间Redis完全阻塞所有读写请求排队表现就是服务大面积超时。发现大key用redis-cli自带的扫描命令redis-cli --bigkeys它会扫出最大的几个key。需要说明的是这个大key扫描用的是SCAN命令不会阻塞线上业务可以放心跑。另外删除大key不要用DEL要用UNLINK。UNLINK是异步删除Redis主线程会立刻返回后台线程慢慢释放内存。热key的问题则完全不同。热key是指某个key的访问量特别大所有流量都打到这个key所在的节点上导致单节点CPU、带宽被打满。常见的应对方案有三种客户端本地缓存把热点数据在服务进程内缓存一份直接扛掉大部分读请求。增加副本给热key所在的节点增加只读副本把读流量分散到多个节点。对key做分片把一个热key拆成多个带后缀的key比如user_10001_bucket_1到user_10001_bucket_10读的时候随机选一个。这三种方案我实际都用过。本地缓存效果最明显但要注意本地缓存的数据新鲜度key分片实现简单但会让缓存的失效和更新变复杂。如果业务对一致性要求不那么高本地缓存优先。3.3 慢查询、阻塞与连接池几个容易被忽视的细节慢查询大多数时候不是Redis本身慢而是命令选得不对。Redis是单线程模型所有命令在主线程里执行。一个O(N)的命令执行时间过长会阻塞后续所有命令。最常见的反面案例是KEYS命令在有几百万个key的实例上执行KEYS *很有可能直接卡死几秒钟。正确做法是用SCAN命令分批遍历。类似的还有SMEMBERS取一个大set的全部成员HGETALL取一个大hash的全部字段。尽量不要在生产环境用这些命令获取全量数据如果只是想判断某个元素是否存在用SISMEMBER、HEXISTS这些O(1)命令就足够了。定位慢查询Redis提供了现成的方案。在redis.conf里设置slowlog-log-slower-than 10000 slowlog-max-len 128注意单位是微秒10000就是10毫秒。设置完成后用SLOWLOG GET 100查看超过阈值的命令。我在排查线上问题的时候第一步永远是看慢查询很多时候问题直接就暴露了。除了命令本身连接方式也影响性能。一次Redis请求的RTT在网络上的开销有时比Redis处理命令还大。如果业务需要频繁读取大量key用pipeline批量发送命令可以大幅减少RTTimport redis r redis.Redis(hostlocalhost, port6379) pipe r.pipeline(transactionFalse) for key in keys: pipe.get(key) values pipe.execute()pipeline不是银弹一次性塞太多命令会导致响应数据积压内存暴涨。我习惯把批量控制在500到1000条左右具体要看value的大小。另外连接池的参数也要合理配置。连接池太小高并发下会大量等待获取连接连接池太大空闲连接又白白占资源。我之前在Java项目里用的是常见连接池配置spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5这个配置对大部分中小规模服务是合理起步值具体要根据QPS和单个操作的耗时微调。3.4 持久化策略该省的性能不能省Redis持久化对性能的影响是被很多人低估的。RDB持久化通过bgsave生成快照涉及fork子进程。如果Redis实例内存很大fork操作耗时可能达到几十毫秒甚至上百毫秒期间会阻塞主线程。所以不要频繁触发RDB尤其内存大的实例。AOF持久化则每次写操作都会追加日志如果appendfsync设置为always每次操作都要刷盘性能损耗巨大设置为everysec每秒刷一次盘性能损耗小很多最多丢一秒数据。线上实践中我比较倾向的组合是主节点开启AOFappendfsync设为everysec同时关闭RDB。这样既保证数据相对安全又把持久化对性能的影响降到最低。如果业务对数据安全要求不那么高甚至可以考虑完全关闭持久化只把Redis当成纯缓存来用。反正缓存数据可以从数据库重新加载持久化的意义就不大。这种情况下主节点挂了直接从集群里切换数据丢了一部分也不影响业务。但有一个取舍要特别注意如果主节点完全关闭持久化而主节点宕机后发生主从切换新主节点是从旧主节点同步数据的由于旧主节点没有持久化文件从节点可能变成空数据。这个风险要提前有预案至少要让从节点开启持久化避免出现这种尴尬局面。4. 容量规划与监控让问题暴露在发生之前4.1 maxmemory和淘汰策略线上怎么配很多Redis实例的OOM故障根源是没设置maxmemory。Redis本质是内存数据库如果不限制内存使用操作系统内存被写满Redis进程会被OOM killer杀掉直接整体宕机。设置了maxmemory还不够必须配套合理的淘汰策略淘汰策略作用范围适用场景noeviction不淘汰数据不可丢失内存满时拒绝写allkeys-lru所有key纯缓存场景冷数据淘汰volatile-lru有过期时间的key只淘汰设置了过期时间的keyallkeys-random所有key数据访问无规律volatile-ttl有过期时间的key优先淘汰剩余ttl最短的key我用的最多的是allkeys-lru。纯缓存场景下它能在内存满时自动淘汰最长时间没被访问的数据行为很符合缓存预期。但要注意一个坑如果某些key是永远不想被淘汰的比如配置类数据allkeys-lru下它们也可能被淘汰。这时候有两条路一是定期刷新这些key让它们始终保持高活跃度二是改用volatile-lru给这些不想淘汰的key不设置过期时间其他业务key都设置过期时间这样没有过期时间的key永远不会被淘汰。4.2 监控Redis这几个指标必须盯不监控的Redis就像不体检的身体出问题的时候都是大问题。我自己的监控清单是这样的缓存命中率、内存碎片率、慢查询数量、阻塞客户端数量、复制延迟。缓存命中率用info stats命令里的两个计数器算redis-cli info stats # keyspace_hits: 123456 # keyspace_misses: 23456命中率 keyspace_hits / (keyspace_hits keyspace_misses)。命中率长期低于80%说明缓存设计有问题要么缓存的数据不是热点数据要么过期时间设置得不合理。我遇到过命中率跌到50%的案例一查发现是代码里读了一个key但写入用的是另一个key大小写不一致缓存完全失效了。内存碎片率用info memory里的mem_fragmentation_ratio字段。碎片率在1到1.5之间是正常的偏高说明内存碎片化严重可以考虑重启实例或者开启active defrag需要Redis 4.0以上版本。阻塞客户端数用info clients查看blocked_clients。如果这个数字长时间不为0说明有慢操作阻塞了Redis大多数时候是上面说的大key操作。复制延迟主从架构下需要关注。在主节点执行info replication查看master_repl_offset在从节点查看slave_repl_offset两个值的差就是复制积压量。差距持续增大说明从节点同步不过来可能是网络问题也可能是从节点在处理慢查询。4.3 容量预估给一个能直接套用的计算过程线上Redis内存不足扩容是一个很麻烦的事情尤其是单节点内存要迁移的时候。所以容量预估要做在前面。我以一个实际场景为例某项目有1000万注册用户需要缓存最近活跃用户的资料。假设最近活跃用户100万每个用户资料序列化后约2KB。单个key的大小除了value本身还有key名和Redis内部存储结构的开销。简单估算时可以把key名加内部开销算作100字节左右。那么value总大小 100万 x 2000字节约等于1.9GBkey和内部开销 100万 x 100字节约等于95MBRedis内存碎片率按1.3算实际占用约2.6GB预留30%的内存余量大约需要3.4GB如果是主从架构从节点也要占用同样大小的内存两个节点各自内存至少要4GB稳妥起见直接上8GB这个计算过程不复杂但很多人容易漏掉碎片率和余量。等内存被打满了再想方案就只能熬夜扩容或者清缓存了。5. 一些踩坑后的心得体会最后聊一点我在实际项目里的体会。有一次我们线上Redis命中率突然下跌监控报警后我排查了很久最后发现是版本发布时把某个缓存的key前缀从存量用户改成了增量用户新旧key不一致等于缓存全部失效。那次之后我要求所有缓存key的变更必须走评审任何key结构或命名调整都要评估对线上缓存命中率的影响。还有一次是热key问题。某个大促活动把商品详情的流量打到一个key上单分片的CPU直接跑满。当时临时上了本地缓存才扛过去。后来复盘发现这个热key其实早就有苗头但监控只关注了节点整体CPU没关注单key的访问频次。如果当时把热点key监控做起来完全可以提前预防。做缓存设计这么多年我的核心感受是缓存出问题绝大多数不是Redis本身的问题而是设计问题。key命名是否规范、过期时间是否随机、大key有没有治理、监控有没有覆盖到位这些才是决定Redis能否稳定运行的关键。先把基础设计做扎实再谈高可用和性能优化顺序不能反。这套思路在我的项目里反复验证过希望对你也有参考价值。如果你正在设计新的缓存方案或者线上已经出现过缓存故障可以从上面提到的几个维度逐一排查大概率能找到问题所在。