【Redis合集-04】Redis 单线程模型深度拆解:事务、管道、Lua 与大 Key 治理

发布时间:2026/6/8 22:23:04

【Redis合集-04】Redis 单线程模型深度拆解:事务、管道、Lua 与大 Key 治理 目录1. Redis 各版本线程模型的实际变迁2. 单线程支撑高并发2.1 非阻塞 IO2.2 epoll 多路复用2.3 事件循环骨架3. 命令排队单线程的执行模型4. Redis 事务的原子性来源4.1 基本事务操作4.2 不支持回滚的设计逻辑4.3 乐观锁WATCH 命令5. 管道Pipeline减少网络往返5.1 管道解决什么问题5.2 管道与事务的核心区别5.3 管道实践批量写入与读取6. Lua 脚本最强的原子执行单元6.1 脚本的原子性与优势6.2 示例安全扣减库存6.3 编写 Lua 脚本的注意点7. 单线程的性能瓶颈与大 Key 治理7.1 典型阻塞问题7.2 大 Key 的定义与危害7.3 大 Key 的排查方法7.3.1 使用 redis-cli --bigkeys7.3.2 使用 MEMORY USAGE 命令精确度量7.3.3 编写脚本进行全量扫描7.3.4 通过 RDB 文件离线分析7.4 大 Key 的解决方案7.4.1 异步删除Lazy Free7.4.2 化整为零分批删除集合元素7.4.3 拆解数据避免大 Key 产生7.4.4 大 Key 的持久化与过期风险7.5 大 Key 问题总结8. Redis 6/7 的多线程 IO界限分明8.1 拆分目标8.2 工作流程8.3 配置与适用场景9. 为什么命令执行坚决不做多线程10. 总结1. Redis 各版本线程模型的实际变迁版本命令执行线程后台辅助线程网络 IO 线程 4.0纯单线程无无4.0单线程有异步删除等场景无6.0/7.0单线程有有多线程 IO命令的执行永远在单线程里串行进行。这个设计贯穿了 Redis 整个并发模型的始终。4.0 之前确实只有一个线程从网络读取到命令执行到响应写回全部自己干。遇到删除大键这类耗时操作时会直接把整个实例卡住。4.0 开始引入了后台线程bio专门负责内存释放这类繁重的工作比如UNLINK。但命令执行本身依旧单线程。6.0 / 7.0 更进一步把网络协议的解析和 socket 数据搬运分摊给多个 IO 线程而最终的键值读写、数据结构操作仍旧集中在主线程中顺序执行。2. 单线程支撑高并发单线程能扛住高负载核心靠的是非阻塞 IO和IO 多路复用。2.1 非阻塞 IORedis 为每个客户端连接套接字设置O_NONBLOCK标志。这样调用read()时如果 socket 缓冲区没有数据内核不会让线程挂起等待而是立即返回EAGAIN。Redis 主线程可以毫不费力地去检查其他连接。2.2 epoll 多路复用如果没有多路复用主线程就需要轮询所有 socket上万连接轮一遍 CPU 就耗光了。Linux 的 epoll 机制解决了这个问题Redis 把所有客户端 socket 的文件描述符注册到 epoll 实例中。主线程调用epoll_wait()等待事件直到有 socket 变为可读或可写。epoll 只返回“就绪”的 socket 列表Redis 只处理这些活跃连接。简单类比一个服务员要同时照看 100 桌客人。如果他每隔几秒去每桌问一次“要点菜吗”就算跑断腿也服务不了几桌但如果每张桌子都有个呼叫按钮他只需在吧台看哪个指示灯亮就去哪桌效率天差地别。2.3 事件循环骨架Redis 服务端的核心就是一个不断循环的事件处理引擎伪代码大致如下while (1) { // 1. 阻塞等待 epoll 返回就绪事件 int numevents aeApiPoll(eventLoop, tvp); for (int i 0; i numevents; i) { int fd eventLoop-fired[i].fd; // 就绪的文件描述符 int mask eventLoop-fired[i].mask; // 事件类型可读/可写 if (mask AE_READABLE) { // 有客户端发来命令读取并执行 fe-rfileProc(eventLoop, fd, fe-clientData, mask); } if (mask AE_WRITABLE) { // 可以向客户端写回结果 fe-wfileProc(eventLoop, fd, fe-clientData, mask); } } }重点在于rfileProc内部会解析命令、查找键、执行修改这些操作对于所有客户端来说都是串行化的。一个命令的执行过程不会掺杂进任何其他客户端的命令。3. 命令排队单线程的执行模型当多个客户端同时发送请求时服务器端的情况可以这样理解“一个命令没执行完下一个命令不会开始”这是 Redis 所有并发语义的基石。锁是不需要的上下文切换是不存在的。但也意味着如果某个命令执行耗时 200 毫秒这 200 毫秒内所有其他请求都会被阻塞。4. Redis 事务的原子性来源既然单线程保证命令不会交叉执行那么把一组命令打包成原子操作就变得非常简单这就是 Redis 事务的实现方式。4.1 基本事务操作事务通过MULTI、EXEC和DISCARD三个命令完成MULTI # 进入事务模式 SET user:1:name 张三 # 暂不执行加入事务队列 SET user:1:age 25 # 同上 INCR user:count # 同上 EXEC # 将所有命令按顺序一次性执行并返回结果流程解析MULTI将客户端标记为“事务状态”。后续每个命令都被压入该客户端的私有事务队列并不立即执行。EXEC触发队列中所有命令不间断地依次执行中间不会插入其他客户端的命令。所有结果打包返回。若在MULTI之后执行DISCARD则清空事务队列并退出事务状态。4.2 不支持回滚的设计逻辑Redis 的事务不提供回滚能力如果在事务中某条命令执行出错比如对字符串执行INCR后续命令依然会继续执行。MULTI SET key1 hello # 合法 INCR key1 # 类型错误执行时报错 SET key2 world # 仍会执行 EXEC # 返回OK, (error) ERR value is not an integer..., OK这种设计的原因Redis 单线程执行事务不会出现并发事务交叉修改数据的情况因此不需要因为并发冲突而回滚。语法错误应在开发阶段被发现运行时引入回滚机制会显著增加复杂度与 Redis 追求简单高效的哲学相悖。对于需要“条件判断再决定是否执行”的场景有更合适的WATCH乐观锁机制。4.3 乐观锁WATCH 命令WATCH可以在事务执行前监视一个或多个键。如果被监视的键在EXEC之前被其他客户端修改整个事务将被拒绝。# 初始余额为 100 SET balance 100 # --- 客户端1 --- WATCH balance # 监视 balance GET balance # 读取到 100 MULTI DECRBY balance 50 # 暂存入队列 EXEC # 提交事务 # 如果客户端2在客户端1 EXEC 之前执行了 # SET balance 200 # 那么客户端1的 EXEC 将返回 nil事务未执行底层原理WATCH会记录客户端的关注键。当其它客户端修改这些键时Redis 会将其标记为dirty。EXEC执行前会检查所有被监视的键是否dirty若是则拒绝执行整个事务。检查与执行之间是原子的因为整个过程在单线程中一气呵成。5. 管道Pipeline减少网络往返事务与管道经常被一起讨论因为两者在客户端都可能表现为“打包一批命令发送”。但它们是截然不同的机制。5.1 管道解决什么问题在没有管道的情况下每发送一个命令就要等待一个回复花费的时间与网络往返次数成正比发送 SET a 1 → 等待 OK → 收到 OK发送 SET b 2 → 等待 OK → 收到 OK发送 SET c 3 → 等待 OK → 收到 OK总耗时 ≈ 3 × RTT管道允许客户端将多个命令一次性发出再一次性收回所有结果5.2 管道与事务的核心区别维度事务MULTI/EXEC管道Pipeline原子性有。队列中的命令连续执行中间不插入其他命令无。命令各自独立执行可能被其他客户端命令穿插实现位置服务端。命令被缓存在事务队列等待EXEC触发客户端。打包发送但服务端仍逐条独立处理主要目的保证一组操作的原子性降低网络往返次数提升吞吐图示对比5.3 管道实践批量写入与读取在命令行下可以通过redis-cli --pipe或直接echo模拟管道。# 准备一个命令文件 commands.txt # 每行一条 Redis 命令 # SET batch:key1 value1 # SET batch:key2 value2 # ... # 通过管道导入大幅减少交互次数 cat commands.txt | redis-cli --pipe # 简单测试也可以用 echo -e 转义换行符 echo -e SET k1 v1\nSET k2 v2\nSET k3 v3 | redis-cli在 Java 中Spring Data Redis 提供了executePipelined方法public void batchSetWithPipeline() { // executePipelined 会将内部操作打包成一次网络请求 ListObject results redisTemplate.executePipelined( new RedisCallbackObject() { Override public Object doInRedis(RedisConnection connection) throws DataAccessException { StringRedisConnection stringConn (StringRedisConnection) connection; // 批量设置 100 个 key命令会暂存在客户端发送缓冲区 for (int i 0; i 100; i) { stringConn.set(batch:key: i, value: i); } // 也可以混合读取操作 for (int i 0; i 100; i) { stringConn.get(batch:key: i); } return null; // 返回值无实际意义 } } ); // results 列表包含所有命令的结果按顺序排列 System.out.println(共执行 results.size() 条命令); }使用管道的注意事项管道不保证原子性若需要“要么全成功要么全失败”必须用事务或 Lua 脚本。每批命令量不宜过大建议控制在1000 ~ 10000 条避免占用过多客户端与服务端的内存缓冲区。在高延迟网络跨地域专线、公网下管道带来的收益尤其明显。6. Lua 脚本最强的原子执行单元事务的局限性在于不能在命令执行期间做逻辑判断。例如“如果库存大于 0 就减库存”用事务很难直接实现因为事务队列中的命令在EXEC之前不会返回中间结果。Lua 脚本弥补了这个空缺。整个脚本在 Redis 的单线程中执行中间绝不打岔而且支持条件、循环、变量等编程能力。6.1 脚本的原子性与优势原子执行脚本一旦开始执行其他所有命令只能排队等待。减少网络开销一段复杂逻辑原本需要多次请求和响应现在一个EVAL请求即可。逻辑下沉计算直接在数据所在的服务端完成避免了大量数据在网络间的搬运。6.2 示例安全扣减库存-- 扣减库存脚本 -- KEYS[1]: 库存键如 stock:1001 -- ARGV[1]: 扣减数量 -- 返回 1 表示成功0 表示库存不足 local key KEYS[1] -- 库存键 local amount tonumber(ARGV[1]) -- 请求扣减的数量 local current tonumber(redis.call(GET, key) or 0) -- 当前库存 if current amount then redis.call(DECRBY, key, amount) -- 执行扣减 return 1 else return 0 end将这段脚本预加载到 Redis 中或者直接使用EVAL执行。Java 调用方式public boolean deductStock(String stockKey, int amount) { // Lua 脚本内容 String script local key KEYS[1]\n local amount tonumber(ARGV[1])\n local current tonumber(redis.call(GET, key) or 0)\n if current amount then\n redis.call(DECRBY, key, amount)\n return 1\n else\n return 0\n end; // 创建脚本对象指定返回值类型 DefaultRedisScriptLong redisScript new DefaultRedisScript(); redisScript.setScriptText(script); redisScript.setResultType(Long.class); // 执行脚本 Long result redisTemplate.execute( redisScript, Collections.singletonList(stockKey), // KEYS 列表 String.valueOf(amount) // ARGV 参数 ); return result ! null result 1; }6.3 编写 Lua 脚本的注意点脚本要短长时间执行会阻塞整个 Redis 实例绝对避免死循环。避免依赖随机结果如果在脚本里生成随机数AOF 重放时可能产生不一致。Redis 7 提供了部分改善。复用脚本使用SCRIPT LOAD将脚本缓存到服务端之后通过EVALSHA调用省去每次传输脚本正文的开销。Redis 7 支持只读脚本EVAL_RO允许在只读副本上执行脚本分担主节点压力。7. 单线程的性能瓶颈与大 Key 治理单线程串行执行是双刃剑简单稳定但一个耗时操作就能拖累全体。在所有阻塞风险中大 KeyBig Key是最常见也最具破坏力的一类。7.1 典型阻塞问题KEYS *全量扫描键空间数据量一大直接卡死。FLUSHALL/FLUSHDB删除大量数据时的阻塞。DEL删除包含百万级元素的集合或哈希。复杂的聚合操作如ZUNIONSTORE、SORT等。但这些命令如果偶尔执行尚可在低峰期操作真正持续引发性能灾难的往往是潜伏在业务中的大 Key。7.2 大 Key 的定义与危害大 Key 并不是一个精确的绝对值而是一个根据场景变化的阈值。通常可以这样界定String 类型单个 value 超过10 KB即可视为大 Key超过 1 MB 则是严重问题。集合类型List、Hash、Set、ZSet元素个数超过5000即应关注超过几十万甚至上百万则是巨型大 Key大 Key 带来的影响是多维度的客户端超时阻塞单次读取或操作大 Key 耗时上升导致客户端请求堆积。网络带宽挤占一个大 Key 的读取可能瞬间消耗几十 MB 流量影响其他请求。内存倾斜在 Cluster 模式下大 Key 会导致某个分片内存暴涨打破数据均匀分布。删除时阻塞直接DEL一个大集合会阻塞主线程数百毫秒甚至数秒。过期引发的死机式卡顿大 Key 过期时如果未开启 lazy freeRedis 会同步释放内存同样会严重阻塞。7.3 大 Key 的排查方法要治理大 Key首先得找到它们。排查通常分为离线扫描和在线监测两种思路。7.3.1 使用redis-cli --bigkeys这是最快速的排查工具非破坏性扫描适合初步摸底。# 扫描整个实例输出各类数据结构的最大 key 统计 redis-cli --bigkeys # 如果实例有密码或非默认端口 redis-cli -h 127.0.0.1 -p 6379 -a yourpassword --bigkeys它会输出类似下面的信息Biggest string found: article:content:98765 has 2048576 bytesBiggest hash found: user:session:all has 50000 fields限制--bigkeys只返回每种类型最大的那个键且采样不完整。如果想找出所有大 Key需要更细致的扫描。7.3.2 使用MEMORY USAGE命令精确度量对于怀疑的 Key可以直接查看其实际内存占用# 返回 key 的内存占用字节数Redis 4.0 MEMORY USAGE user:session:all结合DEBUG OBJECT可以得到更多信息如序列化长度但在生产环境需谨慎使用它有一定开销。7.3.3 编写脚本进行全量扫描当--bigkeys不够细致时可以通过 Lua 或客户端编程遍历所有键按类型统计大小并输出超过阈值的 Key。一个简单的 Python 示例import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) BIGKEY_THRESHOLD 10240 # 10 KB cursor 0 while True: cursor, keys r.scan(cursor, count1000) for key in keys: key_type r.type(key) if key_type string: size r.memory_usage(key) if size and size BIGKEY_THRESHOLD: print(f[大Key] {key} 类型string 大小{size} 字节) elif key_type in (hash, list, set, zset): length r.object(encoding, key) # 实际可用 llen/hlen/scard/zcard 获取元素数 # 这里简化演示实际应调用 len 方法 print(f[检查] {key} 类型{key_type} (需进一步判断元素数量)) if cursor 0: break在实际项目中更推荐使用现成的工具如redis-rdb-tools对 RDB 文件离线分析这样对线上服务无任何影响。7.3.4 通过 RDB 文件离线分析# 安装 redis-rdb-tools pip install rdbtools python-lzf # 分析 RDB 文件生成内存报告 rdb -c memory /path/to/dump.rdb memory_report.csv # 查看最大的 20 个键 awk -F, {print $4,$2} memory_report.csv | sort -nr | head -20这种方法可以得到全量且精确的内存分布是定期排查大 Key 的最稳妥手段。7.4 大 Key 的解决方案找到大 Key 后不能直接DEL了事那样反而会引发阻塞事故。解决方案要视情况而定。7.4.1 异步删除Lazy Free自 4.0 版本起Redis 引入了后台线程处理内存释放。用UNLINK代替DEL是删除大 Key 的首选方式。# 同步删除直接阻塞主线程 DEL big_hash_key # 异步删除主线程仅解除键引用后台线程负责内存回收 UNLINK big_hash_key同时建议在配置中打开全局 lazy free 开关# redis.conf lazyfree-lazy-eviction yes # 内存淘汰异步释放 lazyfree-lazy-expire yes # 过期键异步释放 lazyfree-lazy-server-del yes # 内部隐式删除异步化 replica-lazy-flush yes # 从库异步清理开启后即便大 Key 过期或内存淘汰也不会造成明显阻塞。7.4.2 化整为零分批删除集合元素当大 Key 是集合类型时可以分批次逐步删除每批少量让主线程有足够间隙处理其他请求。思路是配合SCAN族命令HSCAN、SSCAN、ZSCAN逐批取出元素再用HDEL/SREM/ZREM等批量删除。以删除一个大 Hash 为例public void deleteBigHash(String key, int batchSize) { CursorMap.EntryObject, Object cursor redisTemplate.opsForHash().scan(key, ScanOptions.scanOptions().count(batchSize).build()); int deletedCount 0; while (cursor.hasNext()) { Map.EntryObject, Object entry cursor.next(); redisTemplate.opsForHash().delete(key, entry.getKey()); deletedCount; // 每批删除后可以短暂休眠让出 CPU视紧急程度决定 if (deletedCount % batchSize 0) { try { Thread.sleep(10); // 10ms 暂停避免过度占用主线程 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }对于 List可以反复LPOP/RPOP分批弹出对于 ZSet可以按排名范围ZREMRANGEBYRANK或ZREMRANGEBYSCORE分段删除。7.4.3 拆解数据避免大 Key 产生治理的根本在于避免大 Key 产生这需要从业务设计上入手。String 类型避免将巨大的 JSON 或序列化对象完整存储。只缓存必要的摘要字段详细数据按需从数据库加载。Hash 类型当字段极多时比如一个 Key 存了所有用户信息应进行横向拆分。例如原本users:all存放所有用户可改为按用户 ID 哈希分片users:shard:0 → hash (id % 10 0)users:shard:1 → hash (id % 10 1)...每次操作时先计算分片再定位到对应的 Key。这样每个 Hash 的大小可控也利于 Cluster 模式下数据分布。Set 或 ZSet如果集合元素达到百万级考虑使用二级索引或按时间段拆分。例如排行榜每月一个 Keyrank:202401、rank:202402。List如果用作消息队列或日志收集务必设置长度上限使用LTRIM保持固定长度# 保留最新的 1000 条记录 LPUSH logs new log entry LTRIM logs 0 9997.4.4 大 Key 的持久化与过期风险大 Key 不仅在读写时可能阻塞在 RDB 持久化和 AOF 重写时也会引起高负载。处理思路调整save配置避免频繁全量 RDB降低大 Key 带来的 I/O 风暴。适当关闭 RDB如果架构中有从库负责持久化主库可以只开 AOF减少瞬时压力。监控过期时间尽量避免集中给大量大 Key 设置相同的过期时间否则可能同时触发无数 lazy free 任务间接拉高 CPU。可以在过期时间上加一个随机浮动值。7.5 大 Key 问题总结排查路径可概括为先用redis-cli --bigkeys或 RDB 离线分析工具快速定位最大的那些键。对于业务中频繁访问的疑似大 Key用MEMORY USAGE确认其真实内存占比。删除时一律使用UNLINK或编写分批删除脚本。从根源上修改数据模型将大 Key 拆解为多个小 Key并控制集合长度。开启 lazy free 相关配置作为保底。8. Redis 6/7 的多线程 IO界限分明Redis 6.0 引入“多线程”经常被误解为命令执行多线程实际上只是将网络 IO 拆到了多个线程中。8.1 拆分目标随着万兆网卡普及网络数据的解析、搬运转为 CPU 消耗大户。单线程既要忙网络 IO又要执行命令容易在解析协议上花掉大量时间。因此 Redis 决定将网络协议解析和 socket 数据读写交给多个 IO 线程并行处理而命令执行仍旧保持单线程。8.2 工作流程主线程接收连接并将待处理的 socket 分发给 IO 线程。多个 IO 线程并行读取 socket 数据解析出完整的命令。主线程汇总所有解析好的命令按顺序逐个执行。执行结果交给 IO 线程由它们并行写回客户端。命令执行环节没有任何并行化因此原子性、事务语义完全不变。无需加锁数据一致性没有任何额外风险。8.3 配置与适用场景# 设置 IO 线程数一般设为 CPU 核心数建议 ≤ 8 io-threads 4 # 默认只对写启用多线程读也需要手动开启 io-threads-do-reads yes多线程 IO 并非万能只有达到一定负载时才有收益。实测在 QPS 超过 10 万的场景下吞吐通常能提升 20% ~ 50%低负载时线程切换开销反而可能使性能略微下降。是否开启需要根据实际压测结果决定。9. 为什么命令执行坚决不做多线程这是 Redis 设计中的一个关键取舍CPU 极少是 Redis 的瓶颈。内存带宽、网络带宽往往先于 CPU 被耗尽。一旦命令执行并行化锁、竞态、死锁等问题会迅速让代码复杂化稳定性下降。简单正是 Redis 的核心价值。单线程执行模型几乎杜绝了并发 Bug。需要横向扩展时Redis Cluster 通过分片将请求分散到多个节点每个节点内部仍然保持单线程这是更清晰的并行路线。实际生产中单个 Redis 实例纯内存读写的吞吐可达数十万 QPS绝大多数系统受限于后端数据库或业务逻辑而非 Redis 本身。10. 总结最后如有改进之处欢迎指正觉得有帮助的话不妨点赞收藏支持一下感谢

相关新闻