
1. 内存管理的底层账本Redis把内存花在了哪里我先说个真实经历。有段时间我们线上一个Redis实例的内存涨得特别快快得有点诡异。刚扩容完一周used_memory又飙到了快90%可我们存的数据量并没有暴增。后来用redis-cli --bigkeys扫了一遍才发现问题根本不在于value有多大而是key的数量超乎想象——说白了存了海量的小key而每个key在Redis里的固定开销被大家严重低估了。很多人以为内存空间就是key加value的大小实际上还有dictEntry、SDS头、指针、内存分配器对齐等一堆隐藏成本。一个空的字符串key在Redis里少说也要占80~100字节你存几百上千万个key光这些结构性开销就能吃掉几个GB。所以聊Redis内存管理必须先把这个账算明白。1.1 不要只盯着value大小key本身和元数据都是开销Redis的RedisObject结构本身有16字节左右包含type、encoding、lru/lfu、refcount和ptr指针。再往下每个key在哈希表里还对应一个dictEntry也有16字节左右包含key指针、value指针、next指针。key字符串本身也有SDS头。这些叠加起来一个很小的value最终落地内存往往是裸数据的好几倍。实际项目中我踩过一个典型坑曾经有个埋点系统key格式是user:{userId}:event:{eventId}一个userId加eventId组合下来平均key长度40多字节value只是个10字节以内的计数。高峰期有几千万这样的key内存直接爆了。这就是典型的没计算key结构成本。所以内存管理第一条经验在Redis里key不只是业务标识它本身就是内存的固定税。能压缩key长度就压缩能用hash/zip结构打包一批字段就直接打包能合并的碎片化key尽量合并。1.2 数据结构编码选型ziplist到listpack的取舍Redis的节省内存不只是少存东西这么简单而是靠内部编码来做。比如list、hash、zset在元素少、值小的时候会使用紧凑的内存布局而不是直接上链表或跳表。核心编码大概有这么几类我把它们在内存和性能上的取舍整理成表格方便对照编码方式适用条件默认阈值内存优势性能/局限性intset集合元素都是整数且数量小于512连续数组极小内存插入非整数或超阈值会升级为hashtableziplisthash/list/zset元素小、数量少连续内存块几乎没有指针开销查找退化为O(n)写多时可能连锁更新listpackRedis 7.0替代ziplist用于list/hash/zset紧凑编码连续内存比ziplist消除连锁更新风险仍是紧凑编码适合小对象quicklistlist的大对象场景双向链表节点内部是ziplist/listpack平衡了内存和增删性能hashtable元素多或值大时的hash/zset/set哈希表O(1)查找内存开销大有指针和dictEntry开销一个很现实的例子是存一个10万字段的购物车hash如果全塞进去后编码从listpack直接升级成了hashtable内存会增加一个量级。我后来处理一些大hash缓存时宁可手动拆成多个小hash分段存储也不让单hash无限膨胀内存效率会差出好几倍。这里要特别提醒很多工具显示的内存分析比如RedisInsight看到的内存占用是以实际编码为基准的但不会直接告诉你某个key为什么用了这个编码。排查内存问题时用OBJECT ENCODING key去看内部编码往往能发现大量我以为存的是省内存结构、其实早就被升级的情况。1.3 过期与淘汰内存释放的真相Redis的内存管理里过期key和淘汰策略往往是面试里最容易混在一起的两个概念。先理清过期key是到了时间就该删掉淘汰策略是内存满了才开始腾空间。两者机制完全不同。过期删除靠两种方式惰性删除和定期删除。惰性删除是当key被访问时才发现过期了顺手删掉——问题是过期key如果一直没人访问就一直占着内存。定期删除则是Redis主循环每隔一段时间随机抽查一批设置过期时间的key把过期的删掉。这两套加在一起保证了过期key不会长期赖着不走但也不是一到点立即消失。真正决定Redis上限的是maxmemory和maxmemory-policy。生产环境我一般把maxmemory设为物理内存的70%~80%左右因为Redis除了数据本身还有复制积压缓冲区、AOF重写临时内存等额外开销你配置得太满一旦触发fork或重写就容易OOM。六种策略里最常用的是allkeys-lru或allkeys-lfu只设置了部分key过期时间的业务又会用volatile-lru但这里有个隐患——如果业务里完全没有设TTL的keyvolatile策略等于没触发内存照样会爆。我自己倾向于所有缓存类数据全部设置TTL淘汰策略用allkeys-lfu。对于热点数据特别明显的场景LFU比LRU要稳。LFU的缺点是冷门永不翻身新晋热点数据初期访问量还没上来容易被淘汰对于短期突增热点LRU反而更灵敏。所以要结合业务判断没有银弹。1.4 内存碎片被忽略的隐形内存流失说个更隐蔽的问题。有段时间我观察线上实例used_memory不高但used_memory_rss却高得吓人RSS和used_memory之间的差值接近20%。这种现象通常是内存碎片化——大量key反复写入、过期、删除之后jemalloc分配器难以找到足够大的连续内存块导致已经申请的物理内存无法归还操作系统实际占用远大于逻辑占用。碎片率used_memory_rss / used_memory大于1.5就算明显碎片化了。解决办法有两个方向一是开启activedefrag yes让Redis在后台自动搬移内存、合并碎片二是对实例做一次主从切换或重启很多情况下RSS会直接降下来。但重启有代价如果是集群节点选业务低峰期操作切换前务必先确认从节点数据同步完整。另外要注意碎片率高不等于内存泄漏。有些运维同学一看到RSS涨就以为是泄漏其实只是分配器行为。真正排查泄漏要结合INFO memory里的各项指标和过期淘汰数据一起看别上来就杀进程重启。2. 缓存问题的排查清单穿透、击穿、雪崩与一致性缓存的问题基本绕不开穿透、击穿、雪崩这老三样再加上一个缓存一致性。面试的时候大家都能背概念但实际项目里真正难的不是知道解决方案而是在哪个环节用哪个方案、怎么同一个方案搭配着用。这一章我把四种问题放在一起讲因为它们经常同时出现而且解决手段会相互影响。2.1 缓存穿透缓存也扛不住查不存在的数据穿透的本质是查询一个缓存和数据库里都不存在的数据。因为缓存没有值每次请求都直接漏到数据库流量一大数据库就被打穿。我见过一个比较典型的案例某个查询接口接收用户输入一个订单号攻击者用随机数批量请求不存在的订单号Redis里的命中率瞬间变成零数据库QPS直接打满。常规三个对策参数校验兜底。如果订单号格式都不合法直接在接口层拦截掉这是成本最低的一层。空值缓存。把null值也写入缓存TTL设置短一点比如30~60秒。这个方案要注意空值大量堆积也会吃内存需要对空值key做数量或TTL限制。布隆过滤器。初始化时把所有合法ID刷入布隆过滤器请求来了先走过滤器判断不存在就直接返回存在才去查缓存和DB。布隆过滤器的误判率取决于位数组大小和哈希函数个数一般误判率设到1%~0.1%就够用太低了位数组会非常大。我实际用下来最省心的是参数校验空值缓存的组合。布隆过滤器虽然很经典但在动态ID场景比如订单号是不断新增的需要维护增量数据进入过滤器成本其实不低而且误判仍然会给数据库带来少量查询。不能说布隆过滤器不好但它不是所有场景的最优解。2.2 缓存击穿热点key过期的那一秒击穿和穿透一字之差但问题完全不同。击穿指的是一个热点key在缓存过期的瞬间大量请求同时打到数据库说白了是单个key失效导致并发直冲。最典型的场景就是秒杀商品的库存key活动一开Redis里的库存key正好过期瞬间所有用户的查询都打到DB。常见应对有几个我按场景选型互斥锁加锁重建缓存。只允许一个线程去查数据库并重建缓存其他线程等待或降级用旧值。实现上推荐基于其他机制做分布式锁下面会细讲单机的话直接用进程内锁就够了。缺点是会短暂阻塞读请求。逻辑过期。不给key设物理TTL而是把过期时间写到value里。查询时如果发现逻辑上过期了就返回旧值同时后台异步去刷新缓存。这个方案性能最高很多高并发缓存就是用这种方式做的但需要接受一段时间内读到旧数据。热点key预热。活动开始前或者周期性对热点key做提前刷新尽量让热点key不要裸奔到过期那一刻。如果并发量不是极其夸张我一般优先用逻辑过期。因为互斥锁在高QPS场景里会大量线程阻塞线程池容易资源耗尽逻辑过期把写和读完全解耦读永远走的是缓存最稳。代价是数据不是强一致但缓存本来就不是强一致系统忍一忍也就过去了。2.3 缓存雪崩大面积过期与Redis宕机雪崩有两个维度一是大量key同时过期导致请求同时落到DB二是Redis整体挂了缓存直接失效。两种情况的应对策略不同不要只记一个。如果是大面积key同时过期解决方案其实很简单TTL别设成固定值加一个随机偏移量。比如原来都是60秒改成60 random(0, 300)秒让过期时间分布均匀而不是集中在同一秒。这里有个细节随机范围也不能太大否则缓存命中率开始波动要结合缓存定期更新的策略去平衡。比如你每10分钟刷新一次缓存TTL随机范围可以设在600~900秒之间保证大部分请求能命中同时不会导致同一时刻集体失效。如果是Redis本身宕机缓存层直接失效这就要看容灾架构了。常规做法是Redis集群加哨兵或Cluster自动主从切换同时业务侧增加多级缓存本地用Caffeine之类的进程内缓存顶住第一波流量。我之前在商详页项目里就加了本地缓存Redis抖动时本地命中率能兜住80%以上的读请求等Redis恢复后再从Redis取。这个方案对读多写少的场景尤其有效。2.4 缓存一致性先更新数据库还是先删缓存缓存一致性这个问题没有标准答案只有业务可接受的不一致时间窗口。最常用的Cache Aside模式里有两个核心选择先更新数据库再删缓存还是先删缓存再更新数据库。更新顺序的不同会导致不同的数据风险我习惯先说结论实际生产中先更新数据库再删除缓存更安全。为什么因为先删缓存再更新数据库有一个经典的竞态窗口——线程A删了缓存还没更新DB线程B读缓存读到空直接去DB拿到了旧值并发回写缓存这时线程A再把新值写进DB结果缓存里永远留下旧值数据就错了。反过来先更新DB再删缓存虽然也存在一个时间窗口DB更新完但缓存还没删此时读到的还是旧值但缓存里的旧值最终会被删除下次读取时拉新一致性最终会收敛。删缓存失败怎么办这就是延迟双删的由来先删缓存再更新DB等几百毫秒再删一次。但如果第二次删除失败仍然会留下脏数据。我的做法是用事务性消息或MQ订阅binlog进行补偿删除。简单说就是在更新DB成功后发一条删除缓存的消息消费端去删缓存失败就重试。这个模式很多团队管它叫异步双删本质上就是把删除操作做成可靠消息而不是赌网络一定成功。此外还有一个注意事项更新非常频繁的key不要每次都删缓存那样缓存命中率会惨不忍睹。更合理的做法是让缓存本身TTL短一点依赖过期时间来收敛一致性宁可让缓存失效快一点也不要因为删缓存太频繁让DB承受不必要的写压力。2.5 热key与大key低配集群最容易翻车的地方热key和大key是两个经常被混在一起提的问题但它们完全是两回事。热key是访问量极大大key是数据体量极大二者都可能导致Redis卡顿或内存不平衡。热key问题体现在单个key的读QPS高到把单台分片CPU打满其他分片闲着。解决热key最直接的办法是本地缓存key副本打散在业务里把同一个key复制成key#1、key#2等多个副本客户端随机选择访问让流量分散到不同实例。这是很小成本、很好用的实践。大key问题体现在单个key的value特别大比如一个list有几百万个元素。任何涉及大key的命令比如HGETALL、SMEMBERS、甚至DEL都会阻塞Redis单线程导致其他请求排队。这里要特别提醒删除大key也是阻塞的Redis 4.0以上建议用UNLINK替代DEL它是异步删除不阻塞主线程。我处理过一次同事线上误用DEL删一个几十MB的hash直接卡了几秒幸亏是低峰期否则后果很难说。3. 分布式锁的实现演进从SETNX到看门狗分布式锁是Redis最容易被看似会用的东西。很多同学写个SETNX就觉得自己会了但生产环境一跑就出各种问题——锁提前释放、锁误删、锁续期不够、主从切换丢锁。这一章我按演进顺序把分布式锁讲透你会在过程中看到每个版本解决了什么问题又引入了什么新问题。3.1 第一代实现SETNX EXPIRE为什么是错的最原始的写法是两条命令SETNX lock_key client_id EXPIRE lock_key 30问题很明显这两条命令不是原子的。SETNX成功之后如果进程在EXPIRE执行前崩溃锁就永远不释放其他线程全部卡死。后来有人改成把过期时间放在客户端生成后一起传算是缓解但也只是治标不治本因为取锁和设置过期仍是两步操作中间仍然有窗口。我在一些老项目里见过这种代码印象很深。查了很久的问题最后发现就是锁过期没设置成功导致死锁。不管是简历上写了多久的“熟悉Redis”只要用这种两段式写法生产环境迟早要出事。所以第一代写法可以直接否决不用犹豫。3.2 第二代实现SET NX EX Lua脚本正确的原子取锁命令长这样SET lock_key client_id NX EX 30这一条命令同时完成了加锁和过期时间设置原子性有保证。释放锁时为了防止误删别人的锁通常要用Lua脚本校验value是否属于自己的客户端ID再执行删除。因为直接DEL的话可能发生一个极端情况线程A的锁过期了线程B拿到锁线程A这时才来执行DEL把B的锁误删了。一个标准的释放锁Lua脚本如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这里要求value必须是全局唯一且能标识持有者的ID一般用UUID 线程ID拼一下就行。这个方案已经很稳了是我个人在生产环境最常用的锁实现。它的限制是如果业务执行时间超过了过期时间锁会自动释放导致临界区同时被多个线程进入。解决方式有两种要么把过期时间设得很长不优雅要么引入看门狗自动续期。3.3 Redisson看门狗锁续期的工作原理Redisson解决业务执行时间超过锁过期时间的方式是看门狗机制。整体流程获取锁时默认leaseTime为30秒如果没指定leaseTime后台会起一个定时任务每10秒即锁剩余时间的1/3去为锁自动续期到30秒。业务执行完手动解锁后看门狗任务会取消。这样只要业务线程一直在跑锁就一直不会过期直到业务正常释放。看门狗的本质是不能靠静态过期时间要用心跳来动态续命。这和分布式系统里常见的租约续期是一个道理。但看门狗不是银弹。我遇到过一个典型坑业务线程内部调了一个外部接口这个接口响应超时阻塞了很久线程没死但锁被续期了很多次。外部依赖一旦长时间不返回锁就长时间被持有别的线程全部干等。所以看门狗只是解决了业务正常执行但比较慢的问题解决不了业务被外部依赖卡死的问题。做锁设计时一定要给业务代码设合理的执行超时时间别让一条慢调用把一个分布式锁拖成分布式毒药。另外Redisson的RLock是支持可重入的它内部用hash结构记录锁持有者及重入次数。如果业务里有嵌套调用、同一个线程多次获取锁不加可重入会导致自己把自己锁死。单机进程内的synchronized是可重入的换成Redisson时也要保持语义一致否则代码里很容易出现第二次acquire同一个锁直接死等的诡异bug。3.4 集群模式下的核心争议Redlock到底靠不靠谱很多人以为分布式锁的终局是Redlock——向多个Redis节点同时加锁超过半数成功才算获取成功。但这些年关于Redlock的争议就没停过。Redis作者antirez写过文章辩护分布式系统专家Martin Kleppmann也写过文章批评核心争议点在于Redlock依赖了时钟和网络延迟的假设而这些假设在现实里可能被打破。我举两个例子。第一个是操作系统时钟发生跳变比如NTP同步或云主机时钟漂移节点上的锁过期时间判断就会混乱一个节点认为锁没过期另一个认为已经过期了这时超过半数也没法保证安全。第二个是客户端在持有锁做业务时发生长时间GC比如JVM Full GC停顿几十秒锁已经过期了其他客户端趁机获取了锁等GC恢复后第一个客户端还傻乎乎地在写共享资源数据照样冲突。这两类问题Redlock都没办法彻底解决因为它是在不同节点上锁来降低风险而不是消除风险。所以我的态度比较务实如果你的Redis是单节点或主从架构直接使用SET NX EX配合Lua脚本已经完全够用。如果Redis是Cluster集群并且你的业务对数据安全要求真的非常高那与其纠结Redlock不如先解决Redis节点挂了对其他业务的影响——做多机房隔离、做好主从切换比在锁算法里抠概率更有意义。分布式锁的安全模型里最实际的一环是设置足够短的锁过期时间并把业务里所有访问共享资源的操作约束在过期时间以内。3.5 可重入与公平性这俩问题比想象中常见最后一个容易被忽视的点是可重入和公平锁。可重入我上面提到了Redisson默认支持核心是内部用hash记录线程标识和重入计数。公平锁在Redisson里也有实现但公平锁的实现依赖Redis的有序集合对等待线程排队锁的获取和释放都要做多个命令的原子操作性能比非公平锁低一个档次。如果业务不要求严格的FIFO顺序没必要用公平锁直接用非公平锁就行。顺带提一下分布式锁的释放必须放在finally这个老生常谈的问题但实际项目中我见过太多人忘了。锁不放在finally里释放一旦业务抛异常锁就一直挂着最终靠过期时间兜底。但兜底时间设长了其他线程等待太久设短了业务没跑完锁就丢了。所以代码规范里应该强制获取锁之后业务逻辑整体放在try-finally中finally里判断当前线程是否仍持有锁再决定是否释放。4. 三件事其实是一道系统题一个线上案例复盘内存管理、缓存问题、分布式锁看起来是三个独立专题但在真实系统里它们是互相咬合的整体。这一章我用一个秒杀场景把前面所有内容串起来复盘你会清楚地看到内存为什么会被打爆缓存为什么会被打穿锁为什么会在最不该丢的时候丢其实是同一场事故的三个侧面。4.1 场景拆解秒杀系统的Redis使用方式假设商品详情页加秒杀活动QPS峰值有几万库存只有1000件。整个系统里Redis承担了三类职责商品详情缓存量大但相对静态可以提前预热到Redis本地缓存。库存扣减实时写要用分布式锁或者原子操作不能直接用缓存套DB。用户购买记录防止同一用户重复下单要存一个短TTL的去重标记。这三类key对Redis的内存、过期策略、访问模式要求完全不一样。详情缓存是大key但读多写少库存key是热点中的热点去重key是海量小key。如果把它们全部塞进同一个Redis实例、用同一种TTL、同一种淘汰策略崩溃只是时间问题。一个典型的错误做法是把所有商品详情用set存成一个大JSON库存key不设过期时间用户去重key也没设TTL然后maxmemory-policy还是默认的noeviction。结果活动还没开始内存先满了接着新写入全部报错秒杀还没开始就已经挂了。内存管理那章讲的不同key类型分治在这个场景里体现得淋漓尽致。4.2 内存预热与锁的合理串接秒杀活动开始前需要做两件事第一库存key预热。先把数据库里的库存量加载到Redis这步要趁早做避免活动开始后流量同时打到DB。我用过一段预热代码大致逻辑是def preload_stock(item_id, stock): key fstock:{item_id} redis.set(key, stock) redis.expire(key, 3600 random.randint(0, 600))这里一定要加TTLkey释放不了的话活动结束库存key就成了僵尸数据一直占用内存。第二个细节是TTL随机化避免所有商品同时过期造成大面积击穿雪崩。第二用分布式锁控制扣减流程。这里我不用普通的读-减-写三段式因为不是原子的容易超卖而是直接上Redis的原生原子命令DECRBY。只有当库存扣减成功后再把用户的购买记录写入去重key。那这个场景还需要分布式锁吗答案是如果只用单机Redis的DECRBY确实不需要分布式锁。但实际业务里有些库存扣减不只走Redis还要同步扣数据库里的可用库存这时候就需要分布式锁保证Redis扣减数据库扣减这个跨系统操作不会并发执行。我踩过一次坑两个服务实例同时看到库存还有1件各自都做了Redis扣减成功然后又同时去数据库更新剩余库存结果最终超卖了一件。后来用分布式锁把整个检查库存-扣减Redis-扣减数据库操作串行化才算解决。很多人问那为什么不直接用数据库原子更新呢当然可以但如果高峰期所有请求都直接打数据库数据库是撑不住的。Redis负责挡第一波流量分布式锁负责协调多服务实例对库存的双写一致性两者各司其职。4.3 我踩过的最贵的坑清单写到最后把我在真实项目里踩过、也给团队复盘过的几种典型坑列出来每条都是花钱买来的教训锁的value写死。多实例部署时所有节点用同一个字符串作为锁的value结果A实例的锁过期后B实例拿到锁A实例的删除逻辑用自己的value去删照样能删除别人的锁。因为value没有唯一性判断等于没判断。这是很多人写了很久分布式锁都会犯的低级错误。等锁超时不设置。tryLock等待时间没设或者设成了无限等待结果持有锁的实例挂了所有请求都堵在等锁那一步业务快速堆积。正确的做法是设置一个合理的等待时间比如tryLock(waitTime2s, leaseTime30s)超时直接返回失败或者走降级路径。Redis实例内存打满后锁的写入失败。当Redis达到maxmemory并且淘汰策略不当时SET NX EX会直接返回OOM错误锁拿不到、业务直接挂掉。缓存穿透和空值缓存混用。空值缓存如果TTL设置太长大量不存在的key会堆积成为大key反过来又拖垮内存。空值缓存一定要限制数量和TTL。没有监控Redis内存碎片率。RSS长期偏高却没人发现直到OOM触发或实例频繁重启才排查其实早该开启自动碎片整理。这三块知识看起来是三个独立的话题但在实际系统里面内存问题会影响锁的可用性缓存的过期策略会影响锁的持有时间和业务稳定性。建议学习的时候不要一个一个孤立地背面试题而是把它们放进一个具体的业务场景里去理解。最后分享一个我个人的习惯生产环境所有Redis相关配置变更都要先在压测环境模拟内存满缓存失效锁竞争三合一场景验证整个业务的容忍度再考虑上线。这比任何面试题都更能帮你把分布式系统做好。