ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Redis删除大key:DEL还是UNLINK?从底层机制到生产实践全解析

2026/9/11 12:31:46 拓冰建站 浏览量
Redis删除大key:DEL还是UNLINK?从底层机制到生产实践全解析 做了几年Redis运维和业务开发我踩过最惨的一个坑就是用DEL删除一个几百万元素的List直接把线上实例卡到客户端大面积超时。那一瞬间我才真正意识到在Redis的单线程模型下“删除一个key”并没有想象中那么简单。Redis 4.0之后官方给出了UNLINK这个异步删除命令网上很多资料会告诉你“DEL是同步UNLINK是异步”但真到生产环境怎么选、为什么有差别、有哪些隐藏的坑几句话根本说不清。这篇我结合自己的实操经验把DEL和UNLINK的底层机制、实测对比、选型建议、踩坑排查一次性讲透适合正在用Redis做缓存、队列或者准备Redis面试的朋友参考。1. 先搞清楚del和unlink到底差在哪里1.1 命令基本用法与返回值两个命令的用法几乎一模一样都支持一次删除多个keyDEL key [key ...] UNLINK key [key ...]先用一个最简单的例子看行为差异127.0.0.1:6379 SET foo bar OK 127.0.0.1:6379 DEL foo (integer) 1 127.0.0.1:6379 DEL foo (integer) 0 127.0.0.1:6379 SET foo bar OK 127.0.0.1:6379 UNLINK foo (integer) 1 127.0.0.1:6379 GET foo (nil)表面上看两者都是把key删掉返回值都是“成功删除的key数量”对于业务方来说最终结果没有任何区别key都没了GET返回nil。所以很多新手会疑惑既然效果一样为什么要搞两个命令关键差异不在“结果”而在“过程”。如果只看命令返回的时间DEL和UNLINK对小key几乎没有区别都是微秒级返回。但一旦遇到大key两者的表现就是天壤之别。1.2 核心差别同步与异步用一句直白的话概括DEL是“就地正法”主线程当场把这个key的value内存全部释放掉内存释放完了命令才返回。UNLINK是“先摘牌再处理”主线程先把key从全局哈希表里摘掉业务立刻看不到这个key了命令马上返回真正的内存释放交给后台线程慢慢做。这里要注意UNLINK并不是无脑异步的。Redis源码里有一个核算逻辑会估算释放这个key的代价也就是lazyfreeGetFreeEffort这个函数干的事。如果key很小比如一个简单的字符串释放成本极低UNLINK实际上就是同步释放和DEL没有性能差异。如果key很大比如一个几百万元素的Hash、Set或者List释放成本高Redis就会把value指针放到后台线程的待处理队列里由BIO线程异步释放。所以严谨一点说UNLINK是“必要时候异步小对象直接同步”。1.3 为什么会有unlink单线程模型的代价要理解UNLINK存在的意义先得回到Redis的单线程模型。Redis的主服务是单进程、单线程处理命令所有客户端发来的命令都在一个主线程里排队执行。这个设计让Redis不需要加锁性能极高但也带来一个致命问题任何一条命令如果执行时间太长后面的所有命令都得等着表现为整个实例的QPS断崖式下跌客户端纷纷超时。这里有一个很反直觉的地方大多数人都觉得写操作才费时删除操作不是很快吗其实对于大keyDEL恰恰是最容易卡住主线程的命令之一。举个例子。一个包含500万个元素的List在内存里就是500万个链表节点。DEL要做的不只是把key从字典里抹掉还要把500万个节点逐一从内存分配器里释放。这个遍历过程涉及大量内存地址随机访问和free调用耗时可能达到秒级。Redis 6.x、7.x对这种场景虽然做了优化但本质上的主线程遍历释放逻辑还在。UNLINK出现之前开发者的常规操作是“RENAME改名 分批慢慢删”麻烦且容易出错。Redis 4.0引入lazyfree机制后UNLINK直接解决了这个痛点主线程把key从字典摘除是O(1)常数级操作命令瞬间返回释放内存的重活丢给后台线程主线程继续服务其他请求。2. 底层机制拆解unlink凭什么不卡2.1 Redis的删除流程与内存回收先看普通DEL的完整执行流程。当主线程执行DEL时它会从全局哈希表中找到这个key对应的dictEntry然后把dictEntry从哈希表链条里摘除。释放dictEntry本身。释放key对象的引用和内存。释放value对象的引用和内存。问题出在第四步。如果一个value是巨大的List、Set或者HashRedis需要遍历整个数据结构来释放。List要遍历每个节点Set/Hash要遍历或释放每个桶和每个元素一个上千万元素的ZSet光遍历一次就是巨大的时间开销。UNLINK的流程则完全不同主线程从哈希表里摘除dictEntry这个操作O(1)。对value做“释放成本评估”估算值返回1表示值得异步释放。如果评估结果是要异步就把value指针塞进一个后台队列。主线程返回命令完成。后台BIO线程从队列取出value执行真正的内存释放。从业务视角看key在第一步之后就已经“消失”了后面几秒甚至更久的内存释放完全不阻塞Redis的正常读写。2.2 BIO后台线程与lazyfree机制Redis内部有一套BIOBackground I/O后台线程机制在Redis 4.0之前BIO主要用来处理文件关闭和AOF刷盘这类I/O操作。4.0之后为了支持lazyfree新增了一个“异步释放内存”的BIO线程。这里要注意一个细节UNLINK命令本身和配置项lazyfree-lazy-server-del不是一回事。UNLINK是命令层面的主动异步删除而lazyfree系列配置决定的是Redis内部操作是否采用异步释放。比如lazyfree-lazy-eviction内存淘汰策略逐出key时是否走异步释放。lazyfree-lazy-expire过期key被惰性删除或定期删除时是否走异步释放。lazyfree-lazy-server-del某些命令隐式删除旧key时比如RENAME覆盖旧key、SET覆盖旧key是否走异步释放。replica-lazy-flush从库在SYNC全量同步清空自身数据时是否走异步释放。实际生产中我一般建议把这些配置都设为yes除非你的Redis同时满足“内存很紧张、无法接受后台释放的延迟”这种特殊情况。原因很简单这些内部删除同样可能碰到大key一旦碰到就是无差别卡顿异步化之后对主线程的保护是最稳妥的。2.3 内存碎片与释放的代价转嫁后台线程释放内存也不是完全没有代价最典型的就是内存碎片问题。Redis默认内存分配器是jemalloc。主线程同步释放大key的时候释放的内存块如果连续能比较完整地交还给内存分配器但后台线程异步释放会打乱内存释放的时机和顺序可能出现大量不连续的空洞导致used_memory下来了但是RSS常驻内存没有明显下降mem_fragmentation_ratio内存碎片率飙升。所以UNLINK不是银弹。用了UNLINK之后不能只看“命令不卡了”就万事大吉还要关注内存碎片率。如果mem_fragmentation_ratio持续大于1.5可以考虑开启activedefrag进行在线碎片整理CONFIG SET activedefrag yes CONFIG SET active-defrag-threshold-lower 10 CONFIG SET active-defrag-upper-limit 100不过碎片整理也有额外CPU开销高峰期的Redis实例要谨慎开启。2.4 主从复制链路中的表现还有一个大家容易忽略的点主库执行UNLINK后向从库传播的命令是什么答案是DEL。也就是说主库用UNLINK把key异步删除后从库会收到一个DEL命令。从库收到DEL后它会根据自己节点上的lazyfree-lazy-server-del配置来决定是同步删除还是异步删除。这就意味着即使你在主库上放心地用了UNLINK如果从库没有开启对应的lazyfree配置从库依然可能因为DEL一个超大key而卡顿。这个问题在主从架构、读写分离场景下特别值得重视。我见过一个案例主库删大key用UNLINK很流畅但一个从库因为配置没同步结果在主从切换的时候炸了。所以强烈建议所有从库的lazyfree相关配置和主库保持一致或者直接统一配置模板下发。3. 实操演示判断大key到安全删除3.1 构造一个大key做实测理论讲再多不如动手跑一次。我本地的Redis 7.0环境先用Lua脚本构造一个100万元素的Listredis-cli DEL user:session:001 redis-cli eval for i1,1000000 do redis.call(rpush, KEYS[1], item_..i) end return redis.call(llen, KEYS[1]) 1 user:session:001注意这条Lua脚本本身在构造数据的时候也会阻塞主线程所以务必在测试环境跑。线上千万别这么干会直接卡死生产实例。构造完成后看下这个key的大小127.0.0.1:6379 LLEN user:session:001 (integer) 1000000 127.0.0.1:6379 MEMORY USAGE user:session:001 (integer) 91257032MEMORY USAGE命令是Redis 4.0之后提供的可以估算一个key占用的内存字节数。这里看到这个List大约占用了91MB。3.2 实测del与unlink的耗时差异用redis-cli配合time命令最简单的测法time redis-cli DEL user:session:001结果大概是(integer) 1 real 0m1.234s1秒多才能删完这1秒多时间内整个Redis实例的主线程是卡死的所有读写请求都在排队。再重新构造一次同样的List用UNLINK试试redis-cli eval for i1,1000000 do redis.call(rpush, KEYS[1], item_..i) end return redis.call(llen, KEYS[1]) 1 user:session:002 time redis-cli UNLINK user:session:002结果大概是(integer) 1 real 0m0.003s3毫秒不到就返回了。这里要理解3毫秒只是“把key从哈希表摘除”的时间后台线程释放91MB内存还在同时进行但主线程不受影响这就是UNLINK的核心价值。不过要注意虽然UNLINK返回快但如果一口气UNLINK几百个这种大小的大key后台线程同样会变成瓶颈表现为CPU中bio线程占用高、内存释放缓慢、lazyfree_pending_objects堆积。任何方案都要控制批量规模。3.3 扫描定位实例中的大key如果是被人告知“Redis变慢了”但又不知道是不是大key引起的最直接的排查工具就是redis-cli自带的--bigkeys参数redis-cli --bigkeys它会用SCAN遍历整个实例统计出各个类型里最大的几个key。这里有个经验--bigkeys用的是SCAN命令不会阻塞主线程太久所以生产环境低峰期可以跑但实例超大时也要留意SCAN的count和耗时。另外对于“怎么判断一个key算不算大”我个人的实践标准是String类型单value超过10MB就要警惕。List、Hash、Set、ZSet这类集合类型元素数量超过1万就该留意超过10万必须用UNLINK。单个key占用内存超过50MB删除时必须用UNLINK。这个标准不绝对取决于你的Redis集群规格和业务容忍延迟但按这个底线执行能避开绝大多数事故。3.4 RENAME改名的经典套路有时候业务代码里已经没有机会改成UNLINK了或者担心UNLINK的异步释放也会给后台线程带来压力这时候有一个非常经典的“RENAME 后台删除”套路# 第一步把大key改名 RENAME user:session:001 tmp:bigkey:001 # 第二步对改名后的key执行UNLINK UNLINK tmp:bigkey:001这个套路的价值在于RENAME是O(1)操作主线程几乎没有开销。改名的瞬间业务代码就再也GET不到user:session:001这个key了即使后续删除失败或者服务宕机最多留下一个没人访问的孤儿key不会影响线上业务。如果是Redis 4.0以前的版本没有UNLINK还可以退一步改名之后用分批循环去删比如List用LTRIM逐步缩减Hash用HSCAN分批HDEL每轮删除都留出时间窗口避免一次性释放大内存。3.5 超大key的分批删除方案UNLINK虽然异步但如果后台线程同时处理多个超大key内存释放还是可能积压。在某些极端场景比如一个超大Hash要删除但后台线程明显跟不上可以选择分批删除。以超大Hash为例删除思路是“先用HSCAN取出部分field再HDEL掉”# 每轮取500个field删掉 HSCAN big_hash 0 COUNT 500 HDEL big_hash field1 field2 field3 ...配合一个shell循环就能实现“每轮只删一小部分”的效果避免瞬时释放太多内存。对于超大ZSet可以用ZRANGE配合ZREM超大List可以用LTRIM从两端逐步裁剪超大Set用SSCAN配合SREM。原理都一样把一个大删除拆成很多次小删除让Redis的每个操作都在很短时间内完成。这个方法虽然笨但在老版本Redis上就是保命方案。我当年维护过一个Redis 3.2集群没有UNLINK全靠这套分批删除逻辑才敢处理几GB级别的大key。4. 常见问题与排错实录4.1 线上已经卡住怎么止损如果线上Redis已经因为DEL大key卡死了第一反应别慌按下面顺序处理不要继续向这个实例发起高并发请求必要时网关直接摘流量。用redis-cli执行PING确认是不是完全无响应。连接实例执行SLOWLOG GET 10看慢查询里是不是大量DEL记录。如果还在删除过程中且无法取消只能等它删完。Redis不会在命令执行中响应其他请求。等恢复后立刻检查大key并清理后续删除统一改UNLINK。这里有个很多人不知道的细节DEL命令的耗时如果超过了slowlog-log-slower-than阈值它是会被记录到慢查询日志里的。所以线上不要只盯着那些复杂的KEYS、SORT命令看到慢日志里频繁出现DEL就必须想到大key问题。4.2 unlink执行后内存为什么不降先明确一点UNLINK返回1只代表key已经从逻辑上被摘除了不代表内存已经还给操作系统。我看到不少朋友UNLINK一个几百MB的key然后立刻看used_memory发现没下降多少就以为异步删除失败了。实际上异步释放内存需要时间而且里面有两个机制在起作用后台线程是批量从队列里取任务处理的如果同一时刻堆积了大量释放任务前面排队的任务还没处理完内存下降自然滞后。即使后台线程释放了内存jemalloc也未必会把内存马上归还给OS它可能会留着作为缓存等待下次分配使用。这就是为什么删除后RSS下降不明显。正确的观察方式是# 查看等待异步释放的对象数 INFO stats | grep lazyfree_pending_objects # 反复看used_memory趋势而不是看单次快照 INFO memory | grep used_memorylazyfree_pending_objects这个指标非常关键如果它一直很大说明后台堆积了大量释放任务需要关注后台线程CPU和释放速度。4.3 异步删除的隐藏坑碎片与后台负载UNLINK把阻塞从主线程转移到了后台线程但后台线程负载过高时一样会成为瓶颈。我遇到过一次奇怪现象实例主线程延迟正常但整体内存释放极其缓慢重启后发现是后台线程CPU被打满了。排查方式是看info线程相关指标和系统负载。如果lazyfree_pending_objects长期在几千甚至几万说明异步释放队列堆积严重。这种时候需要主动控制删除节奏比如把一次UNLINK几百个key改成每批UNLINK几十个中间加几毫秒间隔。另外UNLINK大key之后内存碎片率可能飙升上面提到过activedefrag。但碎片整理也是耗CPU的操作生产环境建议在运维窗口期做不要在业务高峰期和异步大删除同时进行。4.4 面试中常问的几个延伸点如果正在准备面试除了“DEL和UNLINK的区别”面试官还可能顺着这些点追问Redis为什么是单线程单线程为什么还需要异步删除答单线程只是主线程处理命令lazyfree用后台线程做内存释放不违反单线程模型正因单线程才更怕长耗时命令。UNLINK适合删除多大的key答没有绝对阈值取决于实例规格和延迟要求一般超过1万元素的集合或10MB的字符串都建议用UNLINK。UNLINK会不会也丢失数据答不会UNLINK和DEL一样在命令返回前key已经从全局字典中摘除对客户端来说删除是立即生效的。如果Redis版本小于4.0怎么办答用RENAME隔离key然后分批删除或者升级Redis。UNLINK在主从架构下从库也会异步吗答主库传播的是DEL命令从库是否异步取决于从库自身的lazyfree配置。还有一个隐藏小问题UNLINK能不能删除正在过期的key其实过期key的删除走的是过期淘汰机制主动执行UNLINK也能删除但正常情况下不会有人去操作已经不存在的key。4.5 为什么我建议默认用unlink最后聊聊选型。我现在的代码规范里删除Redis key默认都用UNLINK除非Redis版本低于4.0。有人会担心UNLINK异步带来的内存释放延迟但在绝大多数缓存场景下一个key被删除之后业务根本不会关心它内存什么时候释放只关心它不可见、不再占用缓存空间。UNLINK完美满足这一点还顺便保护了主线程。DEL不是完全没用。如果你明确知道这是个小key或者你需要“命令返回后内存立刻释放”这种强一致性场景比如马上要评估内存容量、准备缩容DEL更直接。不过这种场景相对少日常业务代码里无脑UNLINK是更安全的选择。根据我个人经验操作Redis删除这类高频动作最大的敌人不是性能而是“你以为它很快结果它卡了整个集群”。DEL和UNLINK的选择本质上就是“是否愿意把不可控的耗时留给主线程”的问题。我的建议是能异步就别同步能在低峰期删就别在高峰期删能分批就别一把梭。Redis还有很多类似的细节坑比如SCAN和KEYS的选择、大key的预防、缓存穿透与雪崩治理每一个都能写一篇长文。这次先聊到这后面有机会再多分享一些实战里攒下来的经验。