<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Redis on 止语Lab</title>
        <link>https://www.wujiachen.com.cn/tags/redis/</link>
        <description>Recent content in Redis on 止语Lab</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh-cn</language>
        <lastBuildDate>Thu, 04 Jun 2026 11:51:24 +0800</lastBuildDate><atom:link href="https://www.wujiachen.com.cn/tags/redis/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>分布式锁不是选 Redis 就完事了</title>
            <link>https://www.wujiachen.com.cn/posts/distributed-lock-selection/</link>
            <pubDate>Thu, 04 Jun 2026 11:51:22 +0800</pubDate>
            <guid>https://www.wujiachen.com.cn/posts/distributed-lock-selection/</guid>
            <description>&lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/cover.png&#34; alt=&#34;Featured image of post 分布式锁不是选 Redis 就完事了&#34; /&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/cover.png&#34; alt=&#34;封面&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;本文实验基于 Redis 7.x / etcd 3.5.x / ZooKeeper 3.8.x / Go 1.22+&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;&lt;strong&gt;导读&lt;/strong&gt;：Redis SETNX 的三个失败场景，一套诊断框架，帮你做选型。&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;h2 id=&#34;0-你的锁该做次体检了&#34;&gt;&lt;a href=&#34;#0-%e4%bd%a0%e7%9a%84%e9%94%81%e8%af%a5%e5%81%9a%e6%ac%a1%e4%bd%93%e6%a3%80%e4%ba%86&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;0. 你的锁该做次体检了&#xA;&lt;/h2&gt;&lt;p&gt;凌晨三点，监控告警炸了——同一个订单扣了两次款。&lt;/p&gt;&#xA;&lt;p&gt;你打开日志，发现两个 Pod 同时拿到了那把&amp;quot;分布式锁&amp;quot;。这画面让人脑壳疼：明明加了 Redis 锁，为什么还出问题？&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch0-midnight-alert.png&#34; alt=&#34;凌晨告警&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;我先抛一个反直觉的判断：&lt;strong&gt;你以为加了锁就安全？这是把分布式当单机来想了&lt;/strong&gt;。Redis SETNX 不是&amp;quot;加了就保你&amp;quot;的护身符。它更像一个温度计，能给你显示一个数，但显示出来不等于事就结了。&lt;/p&gt;&#xA;&lt;p&gt;这篇文章不打算给你&amp;quot;Redis vs etcd vs ZK 选哪个最好&amp;quot;的标准答案。我想做的是带你做一次体检——把 Redis 锁在三种场景下会怎么&amp;quot;病&amp;quot;先摆出来。&lt;/p&gt;&#xA;&lt;p&gt;再分析这三种&amp;quot;病&amp;quot;的共因，然后看 etcd 和 ZK 是怎么针对这些根因设计的，最后给你一棵决策树。结尾我不会替你下处方，只把判断条件交到你手上。&lt;/p&gt;&#xA;&lt;p&gt;你需要的前置只有：知道 SETNX 是怎么用的，用过 Redis 主从（或 Sentinel），听说过 etcd 和 ZK。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;1-症状清单三种你可能没意识到的失败场景&#34;&gt;&lt;a href=&#34;#1-%e7%97%87%e7%8a%b6%e6%b8%85%e5%8d%95%e4%b8%89%e7%a7%8d%e4%bd%a0%e5%8f%af%e8%83%bd%e6%b2%a1%e6%84%8f%e8%af%86%e5%88%b0%e7%9a%84%e5%a4%b1%e8%b4%a5%e5%9c%ba%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1. 症状清单：三种你可能没意识到的失败场景&#xA;&lt;/h2&gt;&lt;p&gt;我把三种场景压在一章里，因为它们不是&amp;quot;独立故障&amp;quot;——后面你会看到，它们其实有共同的根因。&lt;/p&gt;&#xA;&lt;p&gt;每个场景我都用同样的四步呈现：现象 → 复现代码 → 时序 → 为什么。代码全部能在本机跑，evidence 目录里有完整可执行版本（go run ./01-process-crash 那种）。&lt;/p&gt;&#xA;&lt;h3 id=&#34;11-症状一进程崩溃锁卡死直到-ttl&#34;&gt;&lt;a href=&#34;#11-%e7%97%87%e7%8a%b6%e4%b8%80%e8%bf%9b%e7%a8%8b%e5%b4%a9%e6%ba%83%e9%94%81%e5%8d%a1%e6%ad%bb%e7%9b%b4%e5%88%b0-ttl&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1.1 症状一：进程崩溃，锁卡死直到 TTL&#xA;&lt;/h3&gt;&lt;p&gt;最常见的场景。客户端 A 拿到了锁，但执行业务时进程被 kill -9——可能 OOM，可能滚动更新强杀。它来不及调 DEL 释放锁。这把锁会卡在 Redis 里，直到 TTL 自己过期。&lt;/p&gt;&#xA;&lt;p&gt;我跑了个小实验。A SETNX 拿锁 TTL=10s 后什么都不做（模拟崩溃）；B 不停尝试 SETNX，每 200ms 一次。&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.00s] client_A SETNX → true   (TTL=10s)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.00s] client_A 模拟 kill -9，不调用 DEL/UNLINK&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.00s] client_B 开始尝试 SETNX，每 200ms 一次&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 10.09s] client_B SETNX → true  ✅ 拿到锁（第 51 次尝试）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;B 等了 10.09 秒，尝试了 51 次。&lt;/p&gt;&#xA;&lt;p&gt;这不是 Redis 的 bug，是设计选择。Redis 默认配置下没有&amp;quot;客户端心跳&amp;quot;机制，它没法知道你是真在干活还是已经死了，只能让 TTL 来兜底。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch1-e1-ttl-wait.png&#34; alt=&#34;等满 TTL&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;你能做的是把 TTL 调短。但这又会带来另一个问题：如果业务跑得比 TTL 长，锁还没用完就过期了，让别人误抢。这就引出第二个症状。&lt;/p&gt;&#xA;&lt;p&gt;而且 TTL 这个值你没法做&amp;quot;绝对正确&amp;quot;。设 1 秒，业务偶尔 1.2 秒就翻车；设 30 秒，崩溃恢复就要等半分钟。你的实际选择是在&amp;quot;误抢概率&amp;quot;和&amp;quot;恢复延迟&amp;quot;之间画一条线，没有银弹。&lt;/p&gt;&#xA;&lt;h3 id=&#34;12-症状二续期失败醒来已不是自己的锁&#34;&gt;&lt;a href=&#34;#12-%e7%97%87%e7%8a%b6%e4%ba%8c%e7%bb%ad%e6%9c%9f%e5%a4%b1%e8%b4%a5%e9%86%92%e6%9d%a5%e5%b7%b2%e4%b8%8d%e6%98%af%e8%87%aa%e5%b7%b1%e7%9a%84%e9%94%81&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1.2 症状二：续期失败，醒来已不是自己的锁&#xA;&lt;/h3&gt;&lt;p&gt;业务比 TTL 长怎么办？工业界标准做法是 &lt;strong&gt;watchdog 续期&lt;/strong&gt;（如 Java 的 Redisson 看门狗）：后台线程每隔 N 秒续期一次锁，让锁在业务完成前不会过期。下面用 Go goroutine 模拟同样逻辑。&lt;/p&gt;&#xA;&lt;p&gt;听起来挺好。但 watchdog 跟业务在同一个进程里。&lt;/p&gt;&#xA;&lt;p&gt;我做了个 STW 模拟。A SETNX TTL=3s，watchdog 间隔 1s。业务跑 2 秒后，整个进程被&amp;quot;冻结&amp;quot;5 秒，GC pause、虚拟机迁移、调度延迟都是常见的原因。watchdog 一起停了。&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.00s][A] SETNX client_A → true (TTL=3s)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.00s][A.watchdog] EXPIRE → 续期成功？true&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 2.00s][A] ⚠️ 模拟 STW 5s 开始（业务 + watchdog 都被冻结）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 5.04s][B] SETNX client_B → true  ✅ 抢到锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 7.00s][A] STW 结束，业务继续&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 7.00s][A] GET 锁 owner = &amp;#34;client_B&amp;#34;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 7.00s][A] ⚠️ 锁已易主——但仅在主动 GET 时才察觉&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;T+5.04s B 抢到了锁。T+7s A 苏醒。如果业务代码没做 owner 校验就直接 DEL+扣库存，一次双持事故就这样发生了。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch1-e2-stw-dual-hold.png&#34; alt=&#34;双持时序&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;这里的关键不是&amp;quot;watchdog 没用&amp;quot;，在没有长时间进程冻结的常规场景下，watchdog 确实能兜住。&lt;strong&gt;真正没解的是跨 TTL 的 STW&lt;/strong&gt;。如果你业务最长会卡多久心里没数，TTL 选多大都是赌博。&lt;/p&gt;&#xA;&lt;p&gt;工程上能做的兜底是：每次写之前 GET owner 字段，确认还是自己再继续；用 Lua 脚本做 GET+DEL 原子比较。但这只是把竞态推后，没消灭。A 真的发出 GET 之前，那一小段窗口里还是可能扣到 B 的库存上。&lt;/p&gt;&#xA;&lt;p&gt;我在线上见过的处理是这样：把锁拿来当&lt;strong&gt;意图协调&lt;/strong&gt;而不是&lt;strong&gt;互斥保护&lt;/strong&gt;——锁只决定&amp;quot;谁来发起这次扣减&amp;quot;，真正防扣超的是数据库的乐观锁（version 字段或 SQL 条件更新）。Redis 锁丢一次，最坏是两个 worker 同时尝试，但数据库会让其中一个失败。这种&amp;quot;锁 + 强幂等&amp;quot;的组合，是大部分用 Redis 锁的业务实际在用的兜底。&lt;/p&gt;&#xA;&lt;h3 id=&#34;13-症状三主从切换的瞬间锁就消失了&#34;&gt;&lt;a href=&#34;#13-%e7%97%87%e7%8a%b6%e4%b8%89%e4%b8%bb%e4%bb%8e%e5%88%87%e6%8d%a2%e7%9a%84%e7%9e%ac%e9%97%b4%e9%94%81%e5%b0%b1%e6%b6%88%e5%a4%b1%e4%ba%86&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1.3 症状三：主从切换的瞬间，锁就消失了&#xA;&lt;/h3&gt;&lt;p&gt;前两个场景至少 Redis 自己没&amp;quot;撒谎&amp;quot;。第三个场景更刺眼：&lt;strong&gt;Redis 的复制是异步的&lt;/strong&gt;。主库写入成功后，可能还没同步到从库，主库就挂了。&lt;/p&gt;&#xA;&lt;p&gt;我在本机用 Docker 起了一对主从，跑了一个最坏时序：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.00s] === E3 主从切换丢锁实验开始 ===&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.01s] ✅ 主从关系建立：master=127.0.0.1:6379, replica=127.0.0.1:6380&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.01s] 🔌 replica 提前断开复制（模拟最坏复制延迟）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.01s][A] master.SETNX → true (TTL=30s)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.95s] 💥 旧 master 已关闭&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.16s] ⚠️  新 master 上 GET order:1003:lock → 不存在（锁丢了！）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.16s][B] new_master.SETNX → true&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A 在 master 上 SETNX 成功。在复制还没到达 replica 时，master 挂了。哨兵把 replica 提升为新 master。客户端 B 连上新 master，对同一个 key 做 SETNX，&lt;strong&gt;也成功了&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;这一刻，同一把锁被两个客户端持有。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch1-e3-failover-lock-lost.png&#34; alt=&#34;主从切换丢锁&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;为了让本机能稳定复现这个时序，实验里我让 replica 提前断开复制。&lt;strong&gt;真实事故里这个延迟来自网络抖动、replica 还在做 RDB 全量同步、跨机房链路阻塞&lt;/strong&gt;。延迟具体是 100 毫秒还是 5 秒不重要，重要的是它存在。&lt;/p&gt;&#xA;&lt;p&gt;Redis 异步复制优先保证低延迟和高可用，以写入一致性为代价。如果你在 redis.conf 里配 &lt;code&gt;min-replicas-to-write 1&lt;/code&gt;，写入会要求至少 1 个 replica 满足滞后条件才接受。这能&lt;strong&gt;缩小&lt;/strong&gt;丢锁的窗口（Redis 文档的原话是&amp;quot;best effort data safety mechanism&amp;quot;），但不能彻底消除——复制仍是异步的。代价是可用性下降（replica 不可达时拒绝写入），窗口缩小了，没消除。&lt;/p&gt;&#xA;&lt;h3 id=&#34;14-三个症状到这里就够了&#34;&gt;&lt;a href=&#34;#14-%e4%b8%89%e4%b8%aa%e7%97%87%e7%8a%b6%e5%88%b0%e8%bf%99%e9%87%8c%e5%b0%b1%e5%a4%9f%e4%ba%86&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1.4 三个症状到这里就够了&#xA;&lt;/h3&gt;&lt;p&gt;读到这你会发现一件事：&lt;strong&gt;这三个场景都不是 Redis 实现得不好，它们是 SETNX 这个抽象本身的边界&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;根因在更深一层。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;2-病理分析三种症状的共同根因&#34;&gt;&lt;a href=&#34;#2-%e7%97%85%e7%90%86%e5%88%86%e6%9e%90%e4%b8%89%e7%a7%8d%e7%97%87%e7%8a%b6%e7%9a%84%e5%85%b1%e5%90%8c%e6%a0%b9%e5%9b%a0&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2. 病理分析：三种症状的共同根因&#xA;&lt;/h2&gt;&lt;p&gt;把三个症状摆开看，它们看起来很不一样：一个是进程死了、一个是进程没死但卡了、一个是 Redis 自己换了主。但我发现，它们在更深一层是&lt;strong&gt;同一类问题&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;我归出三个根因。&lt;/p&gt;&#xA;&lt;h3 id=&#34;21-根因一异步是性能的代价也是一致性的漏洞&#34;&gt;&lt;a href=&#34;#21-%e6%a0%b9%e5%9b%a0%e4%b8%80%e5%bc%82%e6%ad%a5%e6%98%af%e6%80%a7%e8%83%bd%e7%9a%84%e4%bb%a3%e4%bb%b7%e4%b9%9f%e6%98%af%e4%b8%80%e8%87%b4%e6%80%a7%e7%9a%84%e6%bc%8f%e6%b4%9e&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2.1 根因一：异步是性能的代价，也是一致性的漏洞&#xA;&lt;/h3&gt;&lt;p&gt;Redis 主从是异步复制；watchdog 续期是异步心跳；客户端连接 Redis 也是异步往返。&lt;strong&gt;异步说白了就是&amp;quot;我假设另一边在线，但我不真知道&amp;quot;&lt;/strong&gt;。你写入主库假设它会同步到 replica，但你不会等同步完成；watchdog 假设业务还在跑，但 STW 期间它什么都不知道。&lt;/p&gt;&#xA;&lt;p&gt;E3 是异步复制踢到的雷。E2 是异步心跳踢到的雷。E1 也算——客户端断了，server 端没 hook 去知道。&lt;/p&gt;&#xA;&lt;p&gt;你想消灭异步？可以，上 raft，写入要求过半节点 ack 才返回。代价是 QPS 下降，因为每次写都要走一轮共识。&lt;/p&gt;&#xA;&lt;h3 id=&#34;22-根因二时钟假设比你想象的更脆&#34;&gt;&lt;a href=&#34;#22-%e6%a0%b9%e5%9b%a0%e4%ba%8c%e6%97%b6%e9%92%9f%e5%81%87%e8%ae%be%e6%af%94%e4%bd%a0%e6%83%b3%e8%b1%a1%e7%9a%84%e6%9b%b4%e8%84%86&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2.2 根因二：时钟假设比你想象的更脆&#xA;&lt;/h3&gt;&lt;p&gt;Redis 锁的 TTL 是基于 server 端时钟。Redlock 算法依赖五个 Redis 节点的时钟&amp;quot;基本一致&amp;quot;。Kleppmann 那篇著名的文章里反复强调，&lt;strong&gt;分布式系统中时钟不可靠&lt;/strong&gt;。NTP 调整、虚拟机时钟漂移、容器宿主机迁移，都会让&amp;quot;server 端的 1 秒&amp;quot;和&amp;quot;另一个 server 端的 1 秒&amp;quot;不再相等。&lt;/p&gt;&#xA;&lt;p&gt;E2 的本质是进程暂停（process pause）：客户端被冻结，无法执行任何代码（包括续期），而 Redis 服务端时钟正常走，TTL 照常过期。这不是时钟漂移，是进程对时间流逝完全无感知。时钟假设问题更多暴露在 Redlock 场景——多个 Redis 节点间的时钟漂移会直接破坏&amp;quot;多数派有效时间计算&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;时钟假设藏在分布式锁的每一寸里：TTL、心跳、过期判定、共识 leader election 都依赖时钟。差别只是&lt;strong&gt;有些方案让时钟假设暴露在皮肤上，有些把它藏在皮下&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;h3 id=&#34;23-根因三进程状态对-server-是黑盒&#34;&gt;&lt;a href=&#34;#23-%e6%a0%b9%e5%9b%a0%e4%b8%89%e8%bf%9b%e7%a8%8b%e7%8a%b6%e6%80%81%e5%af%b9-server-%e6%98%af%e9%bb%91%e7%9b%92&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2.3 根因三：进程状态对 server 是黑盒&#xA;&lt;/h3&gt;&lt;p&gt;Redis 不知道你的进程是不是还在跑。它只能看 TCP 连接是不是断了，但 TCP 连接断的判定依赖 keepalive 或主动 RST，可能要等几十秒。&lt;/p&gt;&#xA;&lt;p&gt;E1 里 A 进程被 kill -9 之后，TCP 连接&amp;quot;看起来还在&amp;quot;，Redis 没法及时知道，要等 TTL 自己过期。&lt;/p&gt;&#xA;&lt;p&gt;这是为什么 etcd 和 ZK 走了完全不同的路。它们让 server 端主动维护&amp;quot;客户端是不是活着&amp;quot;的状态，用 lease（etcd）或 session（ZK）。客户端定期发心跳，断了 server 主动清理。&lt;strong&gt;这把&amp;quot;我假设你在线&amp;quot;换成了&amp;quot;我看着你呢&amp;quot;&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;h3 id=&#34;24-看出来了吗&#34;&gt;&lt;a href=&#34;#24-%e7%9c%8b%e5%87%ba%e6%9d%a5%e4%ba%86%e5%90%97&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2.4 看出来了吗&#xA;&lt;/h3&gt;&lt;p&gt;E1/E2/E3 不是 Redis 的&amp;quot;三个 bug&amp;quot;，是 SETNX 这个 API 的三个体质风险。要选别的方案，&lt;strong&gt;关键在于它们针对这三个根因做了不同的设计&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch2-root-cause-map.png&#34; alt=&#34;根因映射&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;3-处方对照etcd-与-zk-是怎么针对根因设计的&#34;&gt;&lt;a href=&#34;#3-%e5%a4%84%e6%96%b9%e5%af%b9%e7%85%a7etcd-%e4%b8%8e-zk-%e6%98%af%e6%80%8e%e4%b9%88%e9%92%88%e5%af%b9%e6%a0%b9%e5%9b%a0%e8%ae%be%e8%ae%a1%e7%9a%84&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;3. 处方对照：etcd 与 ZK 是怎么针对根因设计的&#xA;&lt;/h2&gt;&lt;p&gt;我不打算讲&amp;quot;etcd 是什么、ZK 是什么&amp;quot;，这两个概念的入门资料满地都是。我想讲的是：&lt;strong&gt;它们针对症状一、二、三分别用了什么机制&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;h3 id=&#34;31-etcdlease--txncas--watch&#34;&gt;&lt;a href=&#34;#31-etcdlease--txncas--watch&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;3.1 etcd：lease + Txn(CAS) + Watch&#xA;&lt;/h3&gt;&lt;p&gt;这三件套各自对应一个根因。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Lease 解决&amp;quot;进程状态对 server 是黑盒&amp;quot;&lt;/strong&gt;。客户端 Grant 一个 lease（带 TTL），KeepAlive 定期续期。客户端断了，server 自己 revoke。我跑了个 demo：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.01s] === 场景 2：B 获锁后崩溃 → server 自动 revoke ===&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.01s][B] Grant lease (id=18f9e7d22bbd80b, ttl=3s)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.01s][B] Txn(CAS) Put → succeeded=true（不调 KeepAlive，模拟崩溃）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 4.50s][C] Watch 到 key 删除，立即 Txn(CAS) → 拿到锁，等待 3.489s&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;B 不调 KeepAlive 模拟崩溃，3.489 秒（接近 TTL=3s，多出的约 0.5s 来自 lease 过期检查间隔 + raft 提交延迟）后 server 自动 revoke 了 lease，绑定的 key 自动消失。&lt;strong&gt;这里和 Redis 的关键差别&lt;/strong&gt;：Redis 是&amp;quot;key 自己 TTL 过期&amp;quot;，etcd 是 lease 到期后 revoke 走 raft 共识——集群一致删除，不会因主从切换丢失过期判定。&lt;/p&gt;&#xA;&lt;p&gt;原子互斥靠 Txn(CAS)：&lt;code&gt;If(CreateRevision == 0).Then(Put)&lt;/code&gt;——只在 key 不存在时写入。这跟 SETNX 表面上是一回事，但底层不同：etcd 的 Txn 走 raft，写入需要多数派节点 ack 才算成功。&lt;/p&gt;&#xA;&lt;p&gt;剩下的问题是抢占公平——etcd &lt;code&gt;concurrency&lt;/code&gt; 包的实现是：所有候选者在同一 prefix 下创建带 lease 的 key，按 CreateRevision 排序，最小者持锁；其余 watch 自己的前驱 key 删除事件——与 ZK watch 前驱等价，无惊群。&lt;/p&gt;&#xA;&lt;p&gt;需要明确：etcd 的 lease KeepAlive 同样跑在客户端进程里——STW 时它一样停，lease 照样会被 server revoke。etcd 对 E2 的防御不是&amp;quot;永远不丢锁&amp;quot;，而是 revision 做 fencing：即使锁被 revoke、新客户端拿到锁，旧客户端苏醒后的写入会被存储层拒绝（revision 已过期）。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;对应根因二（异步/复制）&lt;/strong&gt;：raft 是多数派共识，写入过半才返回。&lt;strong&gt;没有&amp;quot;复制还没到达 replica，master 就挂了&amp;quot;这个窗口&lt;/strong&gt;——挂了的 master 它的写入也没&amp;quot;成功&amp;quot;，新 leader 不会有这条幻数据。E3 在 etcd 里不存在。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch3-etcd-mechanism.png&#34; alt=&#34;etcd 机制&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;32-zk临时顺序节点--watch-前驱&#34;&gt;&lt;a href=&#34;#32-zk%e4%b8%b4%e6%97%b6%e9%a1%ba%e5%ba%8f%e8%8a%82%e7%82%b9--watch-%e5%89%8d%e9%a9%b1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;3.2 ZK：临时顺序节点 + Watch 前驱&#xA;&lt;/h3&gt;&lt;p&gt;ZK 的设计哲学跟 etcd 类似，但有两个有趣的差别。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;EPHEMERAL 节点 = etcd 的 lease&lt;/strong&gt;。Session 断了，节点自动删，锁自动释放。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;SEQUENTIAL（顺序）+ Watch 前驱 = 公平 + 抗惊群&lt;/strong&gt;。所有候选者在同一路径下创建顺序节点，ZK 给每个附加单调递增序号；序号最小的是持锁者，其他人 Watch 自己&lt;strong&gt;前一个&lt;/strong&gt;节点的删除事件。这有两个好处：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;公平&lt;/strong&gt;：按到达顺序排队，不是先轮询到的赢&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;无惊群&lt;/strong&gt;：只 watch 前驱，持锁者释放只唤醒一个后继&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;etcd &lt;code&gt;concurrency&lt;/code&gt; 包已内置类似 ZK 的 watch 前驱机制（按 CreateRevision 排序），两者在公平性和抗惊群上实质等价。ZK 的独特优势是 SEQUENTIAL 序号天然可作 fencing token（单调递增，无需额外映射）。&lt;/p&gt;&#xA;&lt;p&gt;我跑了个三 client demo：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.04s][C1] 创建顺序节点 lock-0000000000&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.04s][C1] 🟢 我是序号最小（lock-0000000000）→ 持锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.23s][C2] 创建顺序节点 lock-0000000001&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.23s][C2] Watch 前驱节点 lock-0000000000&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.43s][C3] 创建顺序节点 lock-0000000002&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.43s][C3] Watch 前驱节点 lock-0000000001&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.74s][C2] 收到前驱 lock-0000000000 删除事件，重新检查序列&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.74s][C1] 🔓 已释放锁（删除 lock-0000000000）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 0.74s][C2] 🟢 我是序号最小（lock-0000000001）→ 持锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.65s][C3] 收到前驱 lock-0000000001 删除事件&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[T+ 1.65s][C3] 🟢 我是序号最小（lock-0000000002）→ 持锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;三个客户端按顺序拿锁。任何一个中途 session 断，ZK 删它的 ephemeral 节点，后继自动顶上。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;SEQUENTIAL 序号本身就是 fencing token&lt;/strong&gt;：单调递增，存储层可以拒绝旧 token。这是 Kleppmann 在那篇文章里推崇 ZK 的关键原因——fencing 不是另外加的一层，是协议自带的。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch3-zk-chain.png&#34; alt=&#34;ZK 链路&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;33-这两个方案不是没有代价&#34;&gt;&lt;a href=&#34;#33-%e8%bf%99%e4%b8%a4%e4%b8%aa%e6%96%b9%e6%a1%88%e4%b8%8d%e6%98%af%e6%b2%a1%e6%9c%89%e4%bb%a3%e4%bb%b7&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;3.3 这两个方案不是没有代价&#xA;&lt;/h3&gt;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;维度&lt;/th&gt;&#xA;          &lt;th&gt;etcd&lt;/th&gt;&#xA;          &lt;th&gt;ZK&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;写入 QPS（本机串行）&lt;/td&gt;&#xA;          &lt;td&gt;896（实测）&lt;/td&gt;&#xA;          &lt;td&gt;未实测（参考：ZAB 共识 + JVM 开销，预期略低于 etcd）&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;客户端语言生态&lt;/td&gt;&#xA;          &lt;td&gt;Go 原生，gRPC 多语言&lt;/td&gt;&#xA;          &lt;td&gt;Java（Curator）成熟，其他语言一般&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;运维心智&lt;/td&gt;&#xA;          &lt;td&gt;raft 集群 + compact + quota&lt;/td&gt;&#xA;          &lt;td&gt;JVM 调优 + observer + ZAB&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Fencing token&lt;/td&gt;&#xA;          &lt;td&gt;revision 号&lt;/td&gt;&#xA;          &lt;td&gt;sequential 序号&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;两者都用强一致协议（raft/ZAB），都用 server 端心跳替代客户端 TTL，都内置抢占公平和 fencing。但&lt;strong&gt;都比 Redis 慢&lt;/strong&gt;：本机 benchmark 测下来 etcd 比 Redis 慢 2.21 倍（单 client 串行 round-trip：Redis 1978 QPS，etcd 896 QPS）。&lt;/p&gt;&#xA;&lt;p&gt;慢这件事不能怪 etcd 写得差，是共识协议本身有代价：每次写要等多数派节点 ack。这是它能消除症状三的代价。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;4-处方判定qps--一致性--运维成本三维决策树&#34;&gt;&lt;a href=&#34;#4-%e5%a4%84%e6%96%b9%e5%88%a4%e5%ae%9aqps--%e4%b8%80%e8%87%b4%e6%80%a7--%e8%bf%90%e7%bb%b4%e6%88%90%e6%9c%ac%e4%b8%89%e7%bb%b4%e5%86%b3%e7%ad%96%e6%a0%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4. 处方判定：QPS × 一致性 × 运维成本三维决策树&#xA;&lt;/h2&gt;&lt;p&gt;到这里，三种方案的能力边界你应该清楚了。能力边界不等于决策。你的业务是什么决定了你该选谁。&lt;/p&gt;&#xA;&lt;p&gt;我把三个维度做成决策树，每条分叉对应一个真实约束。&lt;/p&gt;&#xA;&lt;h3 id=&#34;41-三维度的含义&#34;&gt;&lt;a href=&#34;#41-%e4%b8%89%e7%bb%b4%e5%ba%a6%e7%9a%84%e5%90%ab%e4%b9%89&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4.1 三维度的含义&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;QPS&lt;/strong&gt;：单位时间获锁/释放的次数。Redis 单实例本机实测 1978，etcd 896，比例 2.21x；并发吞吐差距更大（etcd 写入要走 raft 共识）。如果你的锁场景是&amp;quot;秒杀峰值 5000 QPS&amp;quot;，三方案都能接住；如果是&amp;quot;每秒 10 万次去重&amp;quot;，只有 Redis 撑得住。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;一致性&lt;/strong&gt;：失败一次的代价。&amp;ldquo;扣库存扣超&amp;quot;和 &amp;ldquo;缓存重建多算一次&amp;rdquo; 完全不是一个量级——前者三天事故复盘，后者用户都没察觉。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;运维成本&lt;/strong&gt;：你团队有没有 Java 同学维护 ZK？你有没有独立部署的 etcd 集群？时钟同步、跨机房链路，这些隐形成本不能忽略。&lt;/p&gt;&#xA;&lt;h3 id=&#34;42-决策树&#34;&gt;&lt;a href=&#34;#42-%e5%86%b3%e7%ad%96%e6%a0%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4.2 决策树&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;你需要分布式锁的目标是什么？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;A. 互斥即可&lt;/strong&gt;（去重/限流/防重复点击）&#xA;→ Redis SETNX（主从 + watchdog），业务侧做幂等兜底&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;B. 必须保证不重复&lt;/strong&gt;（金融/库存/订单）&#xA;→ &lt;strong&gt;B1. QPS &amp;lt; 5k&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;团队有 etcd 运维经验 + 独立部署的 etcd 集群 → &lt;strong&gt;etcd&lt;/strong&gt;&lt;/li&gt;&#xA;&lt;li&gt;否则 → &lt;strong&gt;ZK&lt;/strong&gt;（如果有 Java 团队）/ &lt;strong&gt;etcd&lt;/strong&gt;（独立部署）&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;→ &lt;strong&gt;B2. QPS &amp;gt; 5k&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;业务能拿 fencing token 在写入侧校验 → &lt;strong&gt;Redis SETNX + 业务 token&lt;/strong&gt;&lt;/li&gt;&#xA;&lt;li&gt;否则 → &lt;strong&gt;重新设计&lt;/strong&gt;（这种 QPS 下分布式锁未必是答案，考虑分片 / 无锁队列 / 乐观并发）&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;⚠️ &lt;strong&gt;k8s 控制面的 etcd 不适合直接承载业务锁&lt;/strong&gt;——资源竞争会影响集群稳定性，且权限隔离不足。已有 etcd 指独立部署的集群。&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch4-decision-tree.png&#34; alt=&#34;决策树&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;43-一个-worked-example电商超卖&#34;&gt;&lt;a href=&#34;#43-%e4%b8%80%e4%b8%aa-worked-example%e7%94%b5%e5%95%86%e8%b6%85%e5%8d%96&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4.3 一个 worked example：电商超卖&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;业务&lt;/strong&gt;：库存扣减&#xA;&lt;strong&gt;QPS&lt;/strong&gt;：日常 500，秒杀峰值 5000&#xA;&lt;strong&gt;一致性要求&lt;/strong&gt;：库存不能扣超&#xA;&lt;strong&gt;已有基础设施&lt;/strong&gt;：独立 etcd 集群（团队有运维经验）+ Redis 主从&lt;/p&gt;&#xA;&lt;p&gt;走决策树：&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;必须保证不重复 → 右分支&lt;/li&gt;&#xA;&lt;li&gt;QPS &amp;lt; 5k（峰值刚好踩线）→ 左分支&lt;/li&gt;&#xA;&lt;li&gt;团队有 etcd 运维经验 + 独立集群 → &lt;strong&gt;选 etcd&lt;/strong&gt;&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;可能的反对意见：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&amp;ldquo;Redis + 业务幂等更轻&amp;rdquo;——可以。但业务幂等需要订单号或自然键去重，且要承认 E1/E2/E3 三场景的概率&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;etcd 只能 ~10k QPS&amp;rdquo;——确实有上限，但 5k 还在舒适区&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h3 id=&#34;44-反例什么时候选-redis-也是对的&#34;&gt;&lt;a href=&#34;#44-%e5%8f%8d%e4%be%8b%e4%bb%80%e4%b9%88%e6%97%b6%e5%80%99%e9%80%89-redis-%e4%b9%9f%e6%98%af%e5%af%b9%e7%9a%84&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4.4 反例：什么时候选 Redis 也是对的&#xA;&lt;/h3&gt;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;场景&lt;/th&gt;&#xA;          &lt;th&gt;为什么 Redis 够&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;限流（每秒 N 次）&lt;/td&gt;&#xA;          &lt;td&gt;不需要严格互斥，丢一两次锁可以接受&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;缓存重建去重&lt;/td&gt;&#xA;          &lt;td&gt;多个 worker 重建结果一致，丢锁最多多重建一次&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;后台批处理调度&lt;/td&gt;&#xA;          &lt;td&gt;业务侧本身有任务 ID 去重&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;防止重复点击&lt;/td&gt;&#xA;          &lt;td&gt;短期 TTL，宁可松一点也别拖延用户响应&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&lt;strong&gt;核心判断&lt;/strong&gt;：失败一次的代价 vs 严格一致性的运维成本。失败的代价小，Redis 性价比最高。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch4-matrix-plus-quote.png&#34; alt=&#34;决策矩阵&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;5-医患交底redlock-论战与我不替你做选择&#34;&gt;&lt;a href=&#34;#5-%e5%8c%bb%e6%82%a3%e4%ba%a4%e5%ba%95redlock-%e8%ae%ba%e6%88%98%e4%b8%8e%e6%88%91%e4%b8%8d%e6%9b%bf%e4%bd%a0%e5%81%9a%e9%80%89%e6%8b%a9&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;5. 医患交底：Redlock 论战与&amp;quot;我不替你做选择&amp;rdquo;&#xA;&lt;/h2&gt;&lt;p&gt;不写完 Redlock 论战，这篇文章就缺了一块。但我不打算下结论。&lt;/p&gt;&#xA;&lt;p&gt;Redlock 的核心思路：部署 N 个独立 Redis 节点（无主从），客户端依次向每个节点 SETNX，超过半数成功且总耗时 &amp;lt; TTL 剩余时间，则认为获锁成功。antirez 设计它是为了绕过单点 Redis 的主从复制问题——用多数派代替主从。&lt;/p&gt;&#xA;&lt;p&gt;Kleppmann 在 2016 年那篇 &lt;em&gt;How to do distributed locking&lt;/em&gt; 里说：如果你需要&lt;strong&gt;正确性&lt;/strong&gt;，不要用 Redlock；如果你只要&lt;strong&gt;效率锁&lt;/strong&gt;，单节点 Redis 就够。他主张用 fencing token——单调递增 token 配合存储层拒绝旧 token，这才是真正的安全。&lt;/p&gt;&#xA;&lt;p&gt;antirez（Redis 作者）的反驳是：&lt;strong&gt;时钟跳变在实践中远小于锁 TTL&lt;/strong&gt;，工程上够用；fencing token 的论证更像学术分析而非工程现实——如果你已经有 fencing token 能用，那本身就不需要分布式锁了。&lt;/p&gt;&#xA;&lt;p&gt;我读过双方的原文，老实说没法当裁判。如果非要站队——我更认同 Kleppmann 的安全性分析框架，前四章的实验已经说明了原因。但 antirez 关于工程约束的反驳是真实的：不是每个团队都有能力落地 fencing token。两边的论据都成立：Kleppmann 在分析理论安全性的边界，antirez 在守护工程务实的可行性。截至 2026 年，双方核心分歧仍未解决。&lt;/p&gt;&#xA;&lt;p&gt;我能给你的是判断条件——&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;如果你的业务&amp;quot;丢一次锁会引来三天的事故复盘&amp;quot;，按 Kleppmann 的判断走——选 etcd 或 ZK，要 fencing&lt;/li&gt;&#xA;&lt;li&gt;如果你的业务&amp;quot;丢一次锁顶多多发一次通知&amp;quot;，按 antirez 的判断走——单 Redis 或 Redlock 都行&lt;/li&gt;&#xA;&lt;li&gt;如果你说不清自己在哪一档，&lt;strong&gt;先去想清楚业务&lt;/strong&gt;，再回来选锁&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;回到开头那个凌晨三点的告警。你的锁不是&amp;quot;要不要选最强的&amp;quot;，是&amp;quot;要不要先体检&amp;quot;。看清楚自己的业务能容忍什么，再决定开什么药。&lt;/p&gt;&#xA;&lt;p&gt;我不替你做这个选择。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/distributed-lock-selection/ch4-conclusion.png&#34; alt=&#34;先看清你的业务，再选你的锁&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;附录实验代码和原始数据&#34;&gt;&lt;a href=&#34;#%e9%99%84%e5%bd%95%e5%ae%9e%e9%aa%8c%e4%bb%a3%e7%a0%81%e5%92%8c%e5%8e%9f%e5%a7%8b%e6%95%b0%e6%8d%ae&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;附录：实验代码和原始数据&#xA;&lt;/h2&gt;&lt;p&gt;本文 6 组实验的代码和运行日志已开源：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;GitHub：&lt;a class=&#34;link&#34; href=&#34;https://github.com/wujiachen0727/zhiyulab-evidence/tree/main/distributed-lock-selection&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;zhiyulab-evidence/distributed-lock-selection&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;子目录&lt;/th&gt;&#xA;          &lt;th&gt;内容&lt;/th&gt;&#xA;          &lt;th&gt;对应正文&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;&lt;code&gt;01-process-crash/&lt;/code&gt;&lt;/td&gt;&#xA;          &lt;td&gt;进程崩溃后锁卡死直到 TTL 实验&lt;/td&gt;&#xA;          &lt;td&gt;§1.1 症状一&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;&lt;code&gt;02-renewal-fail/&lt;/code&gt;&lt;/td&gt;&#xA;          &lt;td&gt;STW 续期失败双持实验&lt;/td&gt;&#xA;          &lt;td&gt;§1.2 症状二&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;&lt;code&gt;03-master-failover/&lt;/code&gt;&lt;/td&gt;&#xA;          &lt;td&gt;Redis 主从切换丢锁实验（docker-compose）&lt;/td&gt;&#xA;          &lt;td&gt;§1.3 症状三&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;&lt;code&gt;04-etcd-lock/&lt;/code&gt;&lt;/td&gt;&#xA;          &lt;td&gt;etcd lease+Txn(CAS) 锁实验&lt;/td&gt;&#xA;          &lt;td&gt;§3.1 etcd&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;&lt;code&gt;05-zk-lock/&lt;/code&gt;&lt;/td&gt;&#xA;          &lt;td&gt;ZK 临时顺序节点锁实验&lt;/td&gt;&#xA;          &lt;td&gt;§3.2 ZK&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;每个子目录都有独立 README，说明如何复现。二进制编译产物不入库，跑实验前自己 &lt;code&gt;go build&lt;/code&gt;。&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;原文发布于 &lt;a class=&#34;link&#34; href=&#34;https://www.wujiachen.com.cn/posts/distributed-lock-selection&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;止语Lab&lt;/a&gt;&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;</description>
        </item><item>
            <title>限流：令牌桶、漏桶、滑动窗口怎么选</title>
            <link>https://www.wujiachen.com.cn/posts/rate-limiter-algorithms/</link>
            <pubDate>Tue, 02 Jun 2026 23:24:19 +0800</pubDate>
            <guid>https://www.wujiachen.com.cn/posts/rate-limiter-algorithms/</guid>
            <description>&lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/cover.png&#34; alt=&#34;Featured image of post 限流：令牌桶、漏桶、滑动窗口怎么选&#34; /&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/cover.png&#34; alt=&#34;封面&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;上周面试，我问候选人：&amp;ldquo;Nginx 的 &lt;code&gt;limit_req&lt;/code&gt; 是哪种限流算法？&amp;rdquo;&lt;/p&gt;&#xA;&lt;p&gt;他答得很标准——漏桶。教科书上就是这么写的。&lt;/p&gt;&#xA;&lt;p&gt;我继续追问：&amp;ldquo;那 &lt;code&gt;burst&lt;/code&gt; 参数为什么在？加了 &lt;code&gt;nodelay&lt;/code&gt; 之后行为还是漏桶吗？&amp;rdquo;&lt;/p&gt;&#xA;&lt;p&gt;他想了一会儿，没答上来。&lt;/p&gt;&#xA;&lt;p&gt;我不觉得这是他的问题。三种限流算法——令牌桶、漏桶、滑动窗口——几乎每个后端工程师都能背出来。但一问到具体实现细节，大部分人就开始打结。不是因为不够努力，是因为这三种算法的&amp;quot;标准答案&amp;quot;和生产实现之间，有一条很少有人讲清楚的鸿沟。&lt;/p&gt;&#xA;&lt;p&gt;我自己也是读了 Nginx 源码之后才意识到的。教科书里那个干净的理论模型，跟线上跑的那段代码，中间差了不止一个参数。然后我又去算了一下滑动窗口的内存开销，再看了分布式令牌桶的并发问题——三个误解串起来就是一条线。&lt;/p&gt;&#xA;&lt;p&gt;今天把三个最常见的误解拆开讲。每个误解我都跑了代码或算了数据，不是嘴上说&amp;quot;有偏离&amp;quot;就完了——你会看到实测输出、内存表格、并发测试结果。看完之后，面对一个具体的业务场景，你至少知道从哪几个维度做取舍，而不是停在&amp;quot;看情况&amp;quot;三个字上。&lt;/p&gt;&#xA;&lt;p&gt;三个误解分别是：Nginx 的 &lt;code&gt;limit_req&lt;/code&gt; 不是你以为的漏桶、滑动窗口的&amp;quot;精确&amp;quot;有惊人的内存代价、令牌桶在分布式下一点都不简单。&lt;/p&gt;&#xA;&lt;p&gt;本文聚焦算法选型。限流 Key 设计（粒度选择、CGNAT 下 IP 聚合等）是另一个大话题，这里不展开。下文的代码是核心逻辑简化表达。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/opening-textbook-vs-production.png&#34; alt=&#34;教科书定义与生产真相的认知分裂&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;一nginx-真的是漏桶吗&#34;&gt;&lt;a href=&#34;#%e4%b8%80nginx-%e7%9c%9f%e7%9a%84%e6%98%af%e6%bc%8f%e6%a1%b6%e5%90%97&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;一、Nginx 真的是漏桶吗？&#xA;&lt;/h2&gt;&lt;p&gt;我也曾这么以为，直到我去读了 &lt;code&gt;ngx_http_limit_req_module.c&lt;/code&gt; 的源码。&lt;/p&gt;&#xA;&lt;p&gt;教科书说漏桶的核心特征是&lt;strong&gt;匀速&lt;/strong&gt;：不管上游来了多少请求，下游永远以固定速率处理。超出速率的请求全部排队等待。这个模型的价值在于——无论入口流量多疯狂，出口永远平稳。对下游友好。&lt;/p&gt;&#xA;&lt;p&gt;问题是——当你在 Nginx 配了 &lt;code&gt;limit_req zone=api burst=5 nodelay&lt;/code&gt; 之后，行为完全不是&amp;quot;匀速排队&amp;quot;。它会把 burst 容量内的请求全部立即放行，超出的直接拒绝。这跟漏桶的&amp;quot;平滑输出&amp;quot;完全是两回事。&lt;/p&gt;&#xA;&lt;h3 id=&#34;我跑了一组实测&#34;&gt;&lt;a href=&#34;#%e6%88%91%e8%b7%91%e4%ba%86%e4%b8%80%e7%bb%84%e5%ae%9e%e6%b5%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;我跑了一组实测&#xA;&lt;/h3&gt;&lt;p&gt;用 Go 写了两个版本对比。纯漏桶严格匀速，Nginx 风格的 burst+nodelay 模式。喂同一段突发流量——10 个请求在 50ms 内密集到达，rate 限制是 2 请求/秒。&lt;/p&gt;&#xA;&lt;p&gt;纯漏桶的核心逻辑：每个请求必须等到上一个请求过了 500ms 间隔才能通过。没有&amp;quot;提前消费&amp;quot;的概念，来早了就排队。&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// 核心逻辑简化（伪代码）&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// 纯漏桶：到了就排队，固定间隔逐个放行&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;interval&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Second&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;/&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Duration&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;rate&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 500ms&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;next&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;lastTick&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Add&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;interval&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Now&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;().&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Before&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;next&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;false&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;next&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Sub&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Now&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;())&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 限速等待&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nginx burst+nodelay 的核心逻辑：维护一个令牌桶（容量=burst），有令牌就立即放行，没令牌直接拒绝——不排队。&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// 核心逻辑简化（伪代码）&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// Nginx-like：桶里有令牌就放，没有就拒绝&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;+=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;elapsed&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;rate&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;float64&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;burst&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;float64&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;burst&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;--&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;true&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 立即通过&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;false&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 直接拒绝，不排队&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;实测结果对比（配置：rate=2r/s, burst=5, 10 个请求突发到达）：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;请求序号&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;纯漏桶（rate-check 模式）&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Nginx burst+nodelay&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;1&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 500ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;立即通过&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;2&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 494ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;立即通过&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;3&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 489ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;立即通过&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;4&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 483ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;立即通过&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;5&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 477ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;立即通过&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;6&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;限速等待 472ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;拒绝&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;7-10&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;继续等待&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;全部拒绝&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;说明：此处简化为 rate-check 模式（拒绝+告知剩余等待时间），非 FIFO 排队。等待时间递减表示&amp;quot;距下一可用时刻的剩余时间&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;两列放一起看就很清楚了。纯漏桶就是&amp;quot;限速了，挨个来，500ms 一个不商量&amp;quot;。Nginx 模式是&amp;quot;桶里有余量就放，用完了直接拒绝&amp;quot;。&lt;/p&gt;&#xA;&lt;h3 id=&#34;推演为什么这不是漏桶&#34;&gt;&lt;a href=&#34;#%e6%8e%a8%e6%bc%94%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%99%e4%b8%8d%e6%98%af%e6%bc%8f%e6%a1%b6&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;推演：为什么这不是漏桶？&#xA;&lt;/h3&gt;&lt;p&gt;从行为反推定义。当 &lt;code&gt;burst=5 + nodelay&lt;/code&gt; 时：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;桶里最多存 5 个令牌&lt;/li&gt;&#xA;&lt;li&gt;有令牌就立即放行（不等待）&lt;/li&gt;&#xA;&lt;li&gt;没令牌就拒绝（不排队）&lt;/li&gt;&#xA;&lt;li&gt;令牌以 rate 速率持续补充&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;这在行为上已经是&lt;strong&gt;令牌桶&lt;/strong&gt;——有令牌就放行、没有就拒绝。漏桶那套&amp;quot;匀速漏出、超额排队&amp;quot;的规矩，加了 burst+nodelay 之后已经不存在了。&lt;/p&gt;&#xA;&lt;p&gt;Nginx 源码里的 &lt;code&gt;excess&lt;/code&gt; 字段和 &lt;code&gt;delay&lt;/code&gt; 分支证实了这一点（源码：&lt;a class=&#34;link&#34; href=&#34;https://github.com/nginx/nginx/blob/release-1.26.2/src/http/modules/ngx_http_limit_req_module.c#L195&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;ngx_http_limit_req_module.c#L195&lt;/a&gt;，L195 handler 入口，L296 &lt;code&gt;if (!delay)&lt;/code&gt; 分支，L454 &lt;code&gt;excess&lt;/code&gt; 计算）。&lt;code&gt;nodelay&lt;/code&gt; 把&amp;quot;延迟处理&amp;quot;变成了&amp;quot;立即消费 burst 容量&amp;quot;。文档没骗你——在默认模式（无 burst、无 nodelay）下确实是漏桶。&lt;/p&gt;&#xA;&lt;p&gt;但不少需要快速响应的生产配置会加 burst+nodelay，因为纯漏桶的匀速特性对突发流量太不友好了，用户点一下按钮要等 500ms 才能响应，体验是不可接受的。不加 nodelay 时 burst 请求会排队逐个放行，这仍然是漏桶行为（带有限队列深度）。&lt;/p&gt;&#xA;&lt;p&gt;换句话说，Nginx 不是&amp;quot;要么漏桶要么令牌桶&amp;quot;——它的 &lt;code&gt;limit_req&lt;/code&gt; 是一个可配置的连续光谱。不加 burst 和 nodelay 时是教科书漏桶，加了之后逐渐向令牌桶行为滑动。理解这个光谱，你才能针对具体业务选对配置。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/nginx-behavior-spectrum.png&#34; alt=&#34;Nginx limit_req 行为光谱&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;我们也曾以为配上 &lt;code&gt;limit_req&lt;/code&gt; 就稳了——结果发现 burst+nodelay 让一段突发流量直接穿透了&amp;quot;我以为存在的&amp;quot;排队机制。因为我们以为它在排队，其实它在消耗 burst 额度后直接放行。&lt;/p&gt;&#xA;&lt;h3 id=&#34;选型启示&#34;&gt;&lt;a href=&#34;#%e9%80%89%e5%9e%8b%e5%90%af%e7%a4%ba&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;选型启示&#xA;&lt;/h3&gt;&lt;p&gt;这里有一个简单的判断：严格匀速保护下游（数据库写入限速、消息队列消费限速）用纯漏桶模式，不加 nodelay；容忍短暂突发但限制总量（API 网关、用户请求入口）用 burst+nodelay，接受它本质是令牌桶。&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;教科书说&lt;/th&gt;&#xA;          &lt;th&gt;生产真相&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Nginx limit_req 是漏桶&lt;/td&gt;&#xA;          &lt;td&gt;默认模式是，加 burst+nodelay 后行为是令牌桶&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;漏桶特征是匀速排出&lt;/td&gt;&#xA;          &lt;td&gt;匀速只在无 burst 参数时成立&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;漏桶和令牌桶是两种算法&lt;/td&gt;&#xA;          &lt;td&gt;Nginx 同一个模块两种行为，一个参数的事&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/nginx-burst-nodelay-timeline.png&#34; alt=&#34;纯漏桶 vs Nginx burst&amp;#43;nodelay 行为时序对比&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;二滑动窗口的精确是免费的吗&#34;&gt;&lt;a href=&#34;#%e4%ba%8c%e6%bb%91%e5%8a%a8%e7%aa%97%e5%8f%a3%e7%9a%84%e7%b2%be%e7%a1%ae%e6%98%af%e5%85%8d%e8%b4%b9%e7%9a%84%e5%90%97&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;二、滑动窗口的精确是免费的吗？&#xA;&lt;/h2&gt;&lt;p&gt;一笔内存账让我重新认识了滑动窗口。&lt;/p&gt;&#xA;&lt;p&gt;滑动窗口日志多精确啊，每个请求的时间戳都记着，窗口内精确计数。面试答这个总没错吧？直到我算了一下——精确是用真金白银的内存换来的。&lt;/p&gt;&#xA;&lt;h3 id=&#34;一笔让人清醒的内存账&#34;&gt;&lt;a href=&#34;#%e4%b8%80%e7%ac%94%e8%ae%a9%e4%ba%ba%e6%b8%85%e9%86%92%e7%9a%84%e5%86%85%e5%ad%98%e8%b4%a6&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;一笔让人清醒的内存账&#xA;&lt;/h3&gt;&lt;p&gt;滑动窗口日志（Sliding Window Log）的原理说白了就是：把每个请求的到达时间存进 Redis ZSET，查询时用 &lt;code&gt;ZREMRANGEBYSCORE&lt;/code&gt; 删除窗口外的旧记录，再 &lt;code&gt;ZCARD&lt;/code&gt; 看窗口内有多少条。&lt;/p&gt;&#xA;&lt;p&gt;完美——但你算过这个 ZSET 要存多少东西吗？&lt;/p&gt;&#xA;&lt;p&gt;我按 Redis ZSET 的 skiplist 内存模型做了测算。skiplist 编码下每个节点约 100 字节（含 zskiplistNode + dictEntry + SDS + jemalloc 对齐，基于 Redis 内存模型估算），60 秒窗口：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: right&#34;&gt;QPS&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: right&#34;&gt;用户数&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;窗口内总条目&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;需要的内存&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;100&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;1,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;600 万&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;572 MB&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;1,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;1,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;6,000 万&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;5.7 GB&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;10,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;1,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;6 亿&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;56 GB&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;10,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: right&#34;&gt;10,000&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;60 亿&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;560 GB&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;以上按均匀 QPS 分布估算（最不利情况）。真实流量通常是长尾分布（90% 用户低频），实际内存低于此值——但数量级差距不变。&lt;/p&gt;&#xA;&lt;p&gt;1K QPS × 1K 用户 = 5.7 GB。一台标准 Redis 实例（8-16 GB）能塞下但已经很紧张了。万级 QPS？直接超过单台容量上限。十万级？根本不可行。&lt;/p&gt;&#xA;&lt;p&gt;而 Sliding Window Counter（分 10 个子桶，每个子桶只存一个计数器）同样 10K QPS × 1K 用户只需要 &lt;strong&gt;80 KB&lt;/strong&gt;（裸计数值；含 Redis Hash key 元数据约 200-400 KB）。&lt;/p&gt;&#xA;&lt;p&gt;内存差距：&lt;strong&gt;数万倍&lt;/strong&gt;（4-5 个数量级）。&lt;/p&gt;&#xA;&lt;p&gt;这就是&amp;quot;精确&amp;quot;两个字背后的代价。&lt;/p&gt;&#xA;&lt;h3 id=&#34;三变种实测精度差多少&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e5%8f%98%e7%a7%8d%e5%ae%9e%e6%b5%8b%e7%b2%be%e5%ba%a6%e5%b7%ae%e5%a4%9a%e5%b0%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三变种实测：精度差多少？&#xA;&lt;/h3&gt;&lt;p&gt;内存省了数万倍，精度损失有多大？我用 Go 实现了三种变种对比。配置：窗口 1 秒，限额 5 个请求。测试场景是跨窗口边界——前一个窗口尾部（800-900ms）发 3 个请求，新窗口头部（1050-1150ms）再发 3 个。&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;请求&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;偏移&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;SW Log（精确）&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;SW Counter（近似）&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Fixed Window&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;1-3&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;800-900ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;4-5&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;1050-1100ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;✓ 通过&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;1150ms&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;✗ 拒绝&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;✗ 拒绝&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;✓ 放行&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;第 6 个请求是分水岭。SW Log 和 SW Counter 都知道&amp;quot;最近 1 秒内够了&amp;quot;，拒绝。但 Fixed Window 因为窗口重置把计数清了——它觉得新窗口才 3 个没超额，放行了。实际 1 秒内过了 6 个，超额 20%。&lt;/p&gt;&#xA;&lt;p&gt;这就是 Fixed Window 的经典问题：窗口边界存在&amp;quot;双倍放行&amp;quot;漏洞。理论最坏情况是 2×limit（100% 超额）——窗口尾部打满 + 新窗口头部打满。在流量均匀的情况下很难触发，但突发流量恰好卡在窗口交界——这在限流场景里恰恰是常态。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/window-boundary-error-comparison.png&#34; alt=&#34;三种窗口算法边界误差对比&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;至于 SW Counter 的精度上界：子桶数 = N 时，最大误差 ≤ 1/N。&lt;/p&gt;&#xA;&lt;p&gt;推导不复杂：你把窗口切成 N 段，每段只知道&amp;quot;这一段有多少请求&amp;quot;而不知道精确时间。最差情况是某段请求全部集中在段首或段尾，窗口滑动时该桶的比例估算偏差最大为 1 个子桶的全量。&lt;/p&gt;&#xA;&lt;p&gt;10 个子桶最差 10% 误差，100 个子桶 1% 误差，1000 个子桶 0.1% 误差。对绝大多数业务来说，10-100 个子桶就够了——比起数万倍的内存差距，1% 精度损失简直是白捡的。&lt;/p&gt;&#xA;&lt;p&gt;还有一个不常被提到的性能差异：SW Log 每次请求要执行 &lt;code&gt;ZADD&lt;/code&gt; + &lt;code&gt;ZREMRANGEBYSCORE&lt;/code&gt; + &lt;code&gt;ZCARD&lt;/code&gt; 三个 Redis 命令（即使 pipeline），而 SW Counter 只需要 &lt;code&gt;HINCRBY&lt;/code&gt; 一个命令加一次过期桶的清理。高 QPS 下 Redis 的 CPU 开销差距也不小。&lt;/p&gt;&#xA;&lt;p&gt;我们也踩过这个坑。早期在一个服务上线了 ZSET 滑动窗口限流，QPS 一上来 Redis 内存增长飞快，差点把实例撑爆。监控曲线直接是一条斜线往上走——每秒钟多存几千条时间戳，60 秒窗口意味着缓存里永远攒着几十万条数据。后来改成 Counter 变种（10 个子桶），内存直接从 GB 级回到 KB 级别，拒绝行为跟 Log 版本在非边界时刻完全一致。&lt;/p&gt;&#xA;&lt;h3 id=&#34;选型启示-1&#34;&gt;&lt;a href=&#34;#%e9%80%89%e5%9e%8b%e5%90%af%e7%a4%ba-1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;选型启示&#xA;&lt;/h3&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;安全、金融场景&lt;/strong&gt;（登录爆破检测、支付 API 精确配额、合规审计）→ SW Log，精确性强需求，内存代价可以用集群兜&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;常规高并发&lt;/strong&gt;（API 网关、服务间限流）→ SW Counter（10-100 子桶），99% 精度 + 极低内存&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;低成本不敏感场景&lt;/strong&gt;（内部管理接口、批处理触发）→ Fixed Window，但要知道边界风险&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;教科书告诉你&amp;quot;滑动窗口最精确&amp;quot;，但没告诉你精确是用 O(QPS×窗口) 的内存换来的。当 99% 精度（Counter）够用时，多付数万倍内存就是纯浪费。至于 Fixed Window——能用，但窗口边界最坏情况可突发 2×limit（100% 超额），别在安全场景用它。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/sliding-window-memory-bill.png&#34; alt=&#34;滑动窗口精确性的内存账单&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;三令牌桶在分布式下还简单吗&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e4%bb%a4%e7%89%8c%e6%a1%b6%e5%9c%a8%e5%88%86%e5%b8%83%e5%bc%8f%e4%b8%8b%e8%bf%98%e7%ae%80%e5%8d%95%e5%90%97&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三、令牌桶在分布式下还简单吗？&#xA;&lt;/h2&gt;&lt;p&gt;10+ 实例共享限流额度的第一天就出事了。&lt;/p&gt;&#xA;&lt;p&gt;令牌桶不就是&amp;quot;定时往桶里加令牌，请求来了扣一个&amp;quot;吗？20 行代码能写出来，面试手写都行。问题出在生产环境——多个实例同时去操作同一个令牌桶。&lt;/p&gt;&#xA;&lt;h3 id=&#34;100-个并发打过来你的桶兜不住&#34;&gt;&lt;a href=&#34;#100-%e4%b8%aa%e5%b9%b6%e5%8f%91%e6%89%93%e8%bf%87%e6%9d%a5%e4%bd%a0%e7%9a%84%e6%a1%b6%e5%85%9c%e4%b8%8d%e4%bd%8f&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;100 个并发打过来，你的桶兜不住&#xA;&lt;/h3&gt;&lt;p&gt;单机令牌桶确实简单。问题出在分布式：多个实例共享 Redis 中同一个令牌桶时，&amp;ldquo;读取当前令牌数 → 本地计算 → 写回新值&amp;quot;这三步之间有个致命间隙——GET 和 SET 不是原子的。&lt;/p&gt;&#xA;&lt;p&gt;我用 100 个 goroutine 模拟了并发场景，桶里只有 10 个令牌，预期最多放行 10 个请求。朴素版的问题在于 GET 和 SET 之间有个时间窗口——在真实 Redis 场景下这个窗口是一次网络往返（约 1ms），在这 1ms 内所有到达的请求都能读到&amp;quot;还有令牌&amp;rdquo;。&lt;/p&gt;&#xA;&lt;p&gt;核心逻辑简化表达（伪代码）：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// 核心逻辑简化（伪代码）&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// 朴素版核心问题：GET 和 SET 之间释放了锁&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;redis&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;GET&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;bucket&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 100 个人同时读到 &amp;#34;10&amp;#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// ← 这里有 1ms 的网络往返时间&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;redis&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;SET&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;bucket&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;tokens&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 100 个人各自写 &amp;#34;9&amp;#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;true&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;                    &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 全部认为自己扣到了&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;5 轮实测结果：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;版本&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;预期最多放行&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;实际放行&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;超发率&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;朴素版（GET/SET 两步）&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;10&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;900%&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Lua 原子版&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;10&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;注意：这是零延迟极端模拟（100 goroutine 同进程共享变量），测的是设计缺陷的理论上界。真实 Redis 网络场景下超发率随并发度和 RTT 变化，但非原子操作导致超发的本质不变——只要并发窗口内有多个请求同时读到旧值，就一定超发，并发越高超发越严重。&lt;/p&gt;&#xA;&lt;p&gt;Lua 原子版把&amp;quot;读-算-写&amp;quot;封装在一个 Redis Lua 脚本里。Redis 单线程保证了脚本执行期间没有并发干扰——第一个请求的脚本没执行完，第二个请求的脚本排队等。所以精确限制在 10 个。&lt;/p&gt;&#xA;&lt;h3 id=&#34;三个方案的取舍三角&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e4%b8%aa%e6%96%b9%e6%a1%88%e7%9a%84%e5%8f%96%e8%88%8d%e4%b8%89%e8%a7%92&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三个方案的取舍三角&#xA;&lt;/h3&gt;&lt;p&gt;我们实际踩过这个坑。去年 Q3 一个订单服务上了 10+ 实例共享限流，每台自己维护本地令牌桶，结果流量分布不均——80% 流量打到 3 台热点实例，本地桶早就用完了疯狂拒绝，其他实例的额度却空着。全局看通过量远低于设定额度，但热点实例的用户体验已经降级了。&lt;/p&gt;&#xA;&lt;p&gt;这里要区分两个独立问题：流量分布不均是 LB 层问题（一致性哈希、加权轮询可解），中心化限流解决的是多实例共享额度的精确性——两者需分别处理。&lt;/p&gt;&#xA;&lt;p&gt;切中心化方案时有三条路：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 A：各自本地桶（静态分片）&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;把总额度按实例数均分——10 个实例各分 10 QPS。零网络开销，响应快。但流量不均时问题严重：80% 的请求打到 3 台热点实例，它们的 10 QPS 额度瞬间耗尽，而其他 7 台实例的额度完全闲置。全局看总通过量远低于设定的 100 QPS 额度——用户觉得限流太严，但没有任何一台超额。扩缩容时还要重算分片，运维成本不低。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 B：Redis WATCH + MULTI（乐观锁）&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;用 Redis 的 WATCH 机制做乐观并发控制——WATCH key，读取当前值，本地计算，MULTI/EXEC 提交。如果 key 在这期间被别人改了，EXEC 返回 nil，重试。理论上正确，实际上在千级 QPS 单 key 竞争下性能退化严重。&lt;/p&gt;&#xA;&lt;p&gt;为什么？3 个实例 × 1000 QPS = 每秒 3000 个请求竞争同一个 Redis key。WATCH 到 EXEC 之间大约 1-2ms（一次网络往返），这个窗口内平均有 3-6 个请求并发。第一个提交成功，其他全部重试。&lt;/p&gt;&#xA;&lt;p&gt;重试又会冲突——形成重试风暴。&lt;/p&gt;&#xA;&lt;p&gt;按 3000 QPS × 1ms 窗口估算，约 3-6 并发冲突，P99 延迟推算为 50-100ms——未实测，属工程推算。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 C：Lua 原子脚本&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;整个&amp;quot;补充令牌 → 检查余额 → 扣减&amp;quot;在一个 Lua 脚本里完成。Redis 单线程顺序执行，天然原子，无需重试。代价是 Redis 成了限流链路的单点——所有限流判定都要经过这一跳。Redis 宕机 = 限流消失。&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;维度&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;本地桶&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;WATCH+MULTI&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Lua 脚本&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;精确性&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★☆&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★★&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★★&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;延迟&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★★（零网络）&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★☆☆（重试）&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★☆&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;高并发&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★★&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★☆☆&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;★★☆&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;故障域&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;单实例&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Redis 单点&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Redis 单点&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/distributed-rate-limit-triangle.png&#34; alt=&#34;分布式限流取舍三角&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;我们最终选了 &lt;strong&gt;Lua 脚本 + 本地桶降级&lt;/strong&gt;：正常情况走 Redis Lua 保证精确，Redis 故障时自动降级到本地桶，牺牲短暂精度换取可用性不中断。降级期间的超发容忍度设为额度的 20%（即 100 QPS 额度允许短暂放到 120）——对我们的场景来说，短暂超额 20% 不会触发下游熔断阈值，业务可接受。恢复 Redis 连接后自动切回精确模式。安全场景（防刷）应 fail-closed（Redis 挂了就拒绝），可用性优先场景 fail-open（降级放行）。&lt;/p&gt;&#xA;&lt;p&gt;GET/SET 两步走的朴素实现，只要并发窗口内有多个请求同时读到旧值，就一定超发。每次看到代码评审里有人写 &lt;code&gt;tokens = redis.Get(); if tokens &amp;gt; 0 { redis.Set(tokens - 1) }&lt;/code&gt;，我都会指出来：这段代码在单机可以跑，上了多实例就是定时炸弹。&lt;/p&gt;&#xA;&lt;h3 id=&#34;选型启示-2&#34;&gt;&lt;a href=&#34;#%e9%80%89%e5%9e%8b%e5%90%af%e7%a4%ba-2&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;选型启示&#xA;&lt;/h3&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;单机、进程内限流&lt;/strong&gt;（本地中间件、单实例小服务）→ 内存令牌桶，确实 20 行代码&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;多实例共享限流&lt;/strong&gt;（几乎所有生产场景）→ Lua 原子脚本，别省这一步&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;超高并发 + 可接受粗精度&lt;/strong&gt;（万级 QPS 网关入口）→ 本地桶 + 周期性 Redis 同步&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;教科书说&lt;/th&gt;&#xA;          &lt;th&gt;生产真相&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;令牌桶实现简单&lt;/td&gt;&#xA;          &lt;td&gt;单机简单，分布式下原子性是硬门槛&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;GET→计算→SET 就行&lt;/td&gt;&#xA;          &lt;td&gt;非原子操作在并发窗口内必然超发&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Redis 能解决一切&lt;/td&gt;&#xA;          &lt;td&gt;Redis 本身是单点，故障降级策略不可少&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/distributed-token-overissue.png&#34; alt=&#34;GET/SET 并发窗口导致令牌桶超发&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;四从纠偏到选型&#34;&gt;&lt;a href=&#34;#%e5%9b%9b%e4%bb%8e%e7%ba%a0%e5%81%8f%e5%88%b0%e9%80%89%e5%9e%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;四、从纠偏到选型&#xA;&lt;/h2&gt;&lt;p&gt;三个误解纠完了。把真相组合起来，选型就不再是&amp;quot;看情况&amp;quot;了——至少你知道&amp;quot;看的是什么情况&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;限流选型的核心不是&amp;quot;哪个算法最好&amp;quot;（没有最好，只有最合适），而是你在哪几个维度上愿意做取舍。下次拿到一个限流需求，问自己三个问题：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;问题 1：流量模式是什么？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;流量特征&lt;/th&gt;&#xA;          &lt;th&gt;选择&lt;/th&gt;&#xA;          &lt;th&gt;为什么&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;需要严格匀速&lt;/td&gt;&#xA;          &lt;td&gt;漏桶（不加 nodelay）&lt;/td&gt;&#xA;          &lt;td&gt;保护下游不被突发打穿&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;允许突发但限总量&lt;/td&gt;&#xA;          &lt;td&gt;令牌桶（burst+nodelay）&lt;/td&gt;&#xA;          &lt;td&gt;快速响应 + 总量可控&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&lt;strong&gt;问题 2：精度和内存怎么取舍？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;场景&lt;/th&gt;&#xA;          &lt;th&gt;选择&lt;/th&gt;&#xA;          &lt;th&gt;代价&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;安全、金融（零容忍）&lt;/td&gt;&#xA;          &lt;td&gt;SW Log&lt;/td&gt;&#xA;          &lt;td&gt;内存 O(QPS×窗口)，可能需要 Redis 集群&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;常规业务&lt;/td&gt;&#xA;          &lt;td&gt;SW Counter（10-100 桶）&lt;/td&gt;&#xA;          &lt;td&gt;精度损失 ≤ 1%，内存降 4-5 个数量级&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;非关键接口&lt;/td&gt;&#xA;          &lt;td&gt;Fixed Window&lt;/td&gt;&#xA;          &lt;td&gt;窗口边界最坏可突发 2×limit（100% 超额）&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;&lt;strong&gt;问题 3：部署模式是什么？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;这个问题决定了你需要在&amp;quot;正确性&amp;quot;和&amp;quot;可用性&amp;quot;之间做什么取舍。&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;部署&lt;/th&gt;&#xA;          &lt;th&gt;选择&lt;/th&gt;&#xA;          &lt;th&gt;注意&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;单机&lt;/td&gt;&#xA;          &lt;td&gt;内存实现&lt;/td&gt;&#xA;          &lt;td&gt;不需要考虑原子性，sync.Mutex 就够了&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;多实例（&amp;lt; 10 台）&lt;/td&gt;&#xA;          &lt;td&gt;Lua 原子脚本&lt;/td&gt;&#xA;          &lt;td&gt;别用两步 GET/SET，一定超发&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;超高并发多实例（10+ 台）&lt;/td&gt;&#xA;          &lt;td&gt;本地桶 + 定期同步&lt;/td&gt;&#xA;          &lt;td&gt;接受粗精度换低延迟，同步周期按业务容忍度调&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;如果你用 Alibaba Sentinel（流控组件）、Envoy、Kong 等中间件，算法选择已被框架预设——此时理解底层行为帮助你正确配置参数，而非从头选算法。&lt;/p&gt;&#xA;&lt;p&gt;三个问题答完，算法选型基本确定。剩下的是工程细节——连接池大小、降级策略、监控告警，以及客户端配合（429 响应 + Retry-After header + 指数退避）。这些重要，但属于实现层面，不再是&amp;quot;选哪个算法&amp;quot;的决策。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/rate-limiter-selection-flow.png&#34; alt=&#34;三问选型决策流&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;一个补充：工业级系统几乎都是混合架构。我们的网关入口用令牌桶应对突发，到数据库层切漏桶保护写入，安全层滑动窗口精确防刷。没有哪个业务只用一种算法打天下。理解单算法是地基，在此之上做组合才是日常。&lt;/p&gt;&#xA;&lt;p&gt;云服务（Sentinel、Envoy、API Gateway）封装了这些细节，但封装不等于不需要理解。你调 Sentinel 的 &lt;code&gt;flowRule.grade = RuleConstant.FLOW_GRADE_QPS&lt;/code&gt; 时，底下跑的是滑动窗口还是令牌桶？精度是多少？分布式下怎么同步？如果你不知道答案，那你也无法判断它的行为是否符合你的业务预期。正确配置的前提是知道背后在做什么——否则你会像我们一样，以为 &lt;code&gt;limit_req&lt;/code&gt; 在排队，其实它在放行。&lt;/p&gt;&#xA;&lt;p&gt;回到开头那个面试。如果候选人能说出&amp;quot;Nginx 加了 burst+nodelay 不是纯漏桶了&amp;quot;，或者&amp;quot;分布式令牌桶的关键不是算法而是原子性&amp;quot;，我会认为他不只是在背答案。因为这样的回答说明他自己验证过，或者至少——踩过坑。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/rate-limiter-algorithms/hybrid-rate-limiting-stack.png&#34; alt=&#34;工业级限流组合架构&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;建议回去看看你们的 Nginx 配置，burst+nodelay 是不是你以为的那样。&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;原文发布于 &lt;a class=&#34;link&#34; href=&#34;https://www.wujiachen.com.cn/posts/rate-limiter-algorithms&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;止语Lab&lt;/a&gt;&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;</description>
        </item><item>
            <title>从「背场景」到「做决策」：Redis 选型决策树</title>
            <link>https://www.wujiachen.com.cn/posts/redis-engineering/</link>
            <pubDate>Sun, 17 May 2026 12:41:55 +0800</pubDate>
            <guid>https://www.wujiachen.com.cn/posts/redis-engineering/</guid>
            <description>&lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/cover.png&#34; alt=&#34;Featured image of post 从「背场景」到「做决策」：Redis 选型决策树&#34; /&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/cover.png&#34; alt=&#34;封面&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h1 id=&#34;从背场景到做决策redis-选型决策树&#34;&gt;&lt;a href=&#34;#%e4%bb%8e%e8%83%8c%e5%9c%ba%e6%99%af%e5%88%b0%e5%81%9a%e5%86%b3%e7%ad%96redis-%e9%80%89%e5%9e%8b%e5%86%b3%e7%ad%96%e6%a0%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;从「背场景」到「做决策」：Redis 选型决策树&#xA;&lt;/h1&gt;&lt;p&gt;每次有人问我&amp;quot;Redis 能做什么&amp;quot;，我都想反问一句——你是在问它能做什么，还是在问你&lt;strong&gt;该让它做什么&lt;/strong&gt;？&lt;/p&gt;&#xA;&lt;p&gt;这两个问题差了一整个段位。&lt;/p&gt;&#xA;&lt;p&gt;打开任何一篇 Redis 使用场景文章，你会看到相似的清单：缓存、分布式锁、排行榜、消息队列、限流器、布隆过滤器……11 种，15 种，甚至有人凑出 16 种。背下来不难。面试时像报菜名一样说出来也不难。&lt;/p&gt;&#xA;&lt;p&gt;难的是什么？&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;面对一个具体业务需求，你怎么在 3 分钟内判断&amp;quot;该不该让 Redis 做这件事&amp;quot;？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;背 11 种场景是记忆力。给定需求，快速判断用不用 Redis、用哪种数据结构、什么时候该撤退——这才是决策力。后者在面试中更有区分度。&lt;/p&gt;&#xA;&lt;p&gt;我见过太多开发者掉进一个陷阱：因为&amp;quot;Redis 能做 X&amp;quot;就让 Redis 做了 X，然后在生产环境踩坑。能做和该做之间，隔着一道判断题。&lt;/p&gt;&#xA;&lt;p&gt;这篇文章给你一棵决策树。沿着 3 个问题往下走，每个场景的答案会自己浮出来。不用背，用推导。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;1-决策树全貌3-个问题定生死&#34;&gt;&lt;a href=&#34;#1-%e5%86%b3%e7%ad%96%e6%a0%91%e5%85%a8%e8%b2%8c3-%e4%b8%aa%e9%97%ae%e9%a2%98%e5%ae%9a%e7%94%9f%e6%ad%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;1 决策树全貌：3 个问题定生死&#xA;&lt;/h2&gt;&lt;p&gt;先给结论。决策 Redis 是否适合某个场景，你只需要回答 3 个问题：&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;读写比如何？&lt;/strong&gt;（读多写少 / 写多读少 / 均衡）&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;一致性要求到什么级别？&lt;/strong&gt;（最终一致 / 强一致 / 事务级）&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;数据量级和内存的关系？&lt;/strong&gt;（热数据 &amp;lt; 内存 / 热数据 &amp;gt; 内存 / 全量远超内存）&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;为什么偏偏是这 3 个？不是 4 个，不是 5 个？&lt;/p&gt;&#xA;&lt;p&gt;因为它们直接对应 Redis 的 3 个核心架构特征：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;内存存储 → 容量有天花板，数据量级是硬约束&lt;/li&gt;&#xA;&lt;li&gt;单线程命令执行 → 原子性天然强，但一条慢命令阻塞全局&lt;/li&gt;&#xA;&lt;li&gt;丰富数据结构 → 不同结构匹配不同读写模式，选错结构等于自废武功&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;其他维度当然也重要，但它们是二级问题。先用上面 3 个问题定方向，再用以下清单细化：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;延迟 SLA&lt;/strong&gt;：P99 要求 &amp;lt; 1ms？需要评估网络拓扑和持久化策略对延迟的影响&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;运维复杂度&lt;/strong&gt;：团队有没有 Redis Cluster / Sentinel 的运维经验？&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;内存成本&lt;/strong&gt;：云环境 Redis 内存 GB 单价远高于磁盘存储，中小团队要算账&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;持久化需求&lt;/strong&gt;：RDB fork 在大数据集（&amp;gt;10GB）上可阻塞数百毫秒，AOF rewrite 同理&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;3 个问题定方向，4 个维度做细化。先过了主线再走支线。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch1-decision-tree.png&#34; alt=&#34;Redis选型决策树&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;抽象看不够直观。走几个真实场景试试。&lt;/p&gt;&#xA;&lt;h3 id=&#34;走树演示3-个场景&#34;&gt;&lt;a href=&#34;#%e8%b5%b0%e6%a0%91%e6%bc%94%e7%a4%ba3-%e4%b8%aa%e5%9c%ba%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;走树演示：3 个场景&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;场景 A：电商秒杀库存扣减&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;读写比？→ 高并发写（瞬时数万请求同时扣减）&lt;/li&gt;&#xA;&lt;li&gt;一致性？→ 强一致（超卖不可接受，一件都不能多）&lt;/li&gt;&#xA;&lt;li&gt;量级？→ SKU 级别，万级热门商品&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;决策路径走到了&amp;quot;Redis 原子操作 + 额外保障&amp;quot;。具体方案：用 Lua 脚本做原子&amp;quot;检查库存 &amp;gt; 0 再扣减&amp;quot;。注意，单纯 DECR 只是原子递减，不检查下界，库存可以减到负数，不能防超卖。Redis 在这个场景是最优解：纯内存操作，微秒级响应，单线程天然避免竞争。&lt;/p&gt;&#xA;&lt;p&gt;但有退出条件：如果扣减需要跨多个服务（比如同时扣库存 + 扣优惠券 + 扣积分），单靠 Redis 的原子性不够，需要分布式事务方案（如 TCC）兜底。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;场景 B：社交 Feed 流排序&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;读写比？→ 读远多于写（用户发帖少，刷 Feed 极频繁）&lt;/li&gt;&#xA;&lt;li&gt;一致性？→ 最终一致（晚 3 秒看到新动态完全可接受）&lt;/li&gt;&#xA;&lt;li&gt;量级？→ 单用户 Feed 百级条目，但用户量千万级&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;走到&amp;quot;Redis 缓存-经典场景&amp;quot;。用 ZSET 按时间戳排序，每个用户一个 ZSET 存 Feed ID 列表。读取用 ZREVRANGE 取最新 N 条，O(log N + K) 响应。&lt;/p&gt;&#xA;&lt;p&gt;退出条件：如果 Feed 需要个性化推荐算法介入（不是简单的时间排序），Redis 只能做缓存层，排序逻辑必须在应用层或推荐引擎中完成。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;场景 C：订单状态机&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;读写比？→ 均衡（状态流转频繁，查询也频繁）&lt;/li&gt;&#xA;&lt;li&gt;一致性？→ 事务级（订单从&amp;quot;待支付&amp;quot;变&amp;quot;已支付&amp;quot;不能出错，不能丢）&lt;/li&gt;&#xA;&lt;li&gt;量级？→ 日百万订单&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;走到了&amp;quot;不推荐 Redis 做主存储&amp;quot;。订单状态机需要的是：持久化保证 + 事务性状态流转 + 审计日志。这些都不是 Redis 的强项。&lt;/p&gt;&#xA;&lt;p&gt;正确方案：MySQL/PostgreSQL 做订单主存储（InnoDB 行锁保证状态流转原子性），Redis 做热订单的查询加速缓存。各司其职。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch1-three-scenarios.png&#34; alt=&#34;三场景走树演示&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;3 个场景，每个 30 秒内有答案。这就是决策树的价值，不用背，用推。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;2-缓存redis-的甜蜜区&#34;&gt;&lt;a href=&#34;#2-%e7%bc%93%e5%ad%98redis-%e7%9a%84%e7%94%9c%e8%9c%9c%e5%8c%ba&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;2 缓存——Redis 的甜蜜区&#xA;&lt;/h2&gt;&lt;p&gt;缓存是 Redis 的主场。绝大多数 Redis 实例都在做缓存相关的工作（据 Redis Labs 历年调查，缓存是排名第一的用途）。&lt;/p&gt;&#xA;&lt;p&gt;但我想说的不是&amp;quot;Redis 能做缓存&amp;quot;这种废话。我想说的是：&lt;strong&gt;缓存场景里，Redis 的甜蜜区到底在哪？你的用法在不在甜蜜区里？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;答案藏在数据结构的底层编码方式中。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch2-encoding-switch.png&#34; alt=&#34;编码切换对比&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;编码决定效率&#34;&gt;&lt;a href=&#34;#%e7%bc%96%e7%a0%81%e5%86%b3%e5%ae%9a%e6%95%88%e7%8e%87&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;编码决定效率&#xA;&lt;/h3&gt;&lt;p&gt;Redis String 有两种内部编码：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;embstr&lt;/strong&gt;（value ≤ 44 字节）：Redis 对象头（16 字节）和 SDS 字符串体在同一块连续内存中。一次 malloc，零碎片，CPU cache 友好。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;raw SDS&lt;/strong&gt;（value &amp;gt; 44 字节）：对象头和字符串体分开分配。两次 malloc，有内存碎片风险，且访问需要多一次指针跳转。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;这意味着一个被大多数人忽略的设计原则：&lt;strong&gt;如果你的缓存 value 能控制在 44 字节以内，内存效率最高。&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;什么东西适合？session token（通常 32-40 字符）、UUID（36 字符）、短数字 ID、布尔标记、小计数器。这些是 String 缓存的绝对甜蜜区。&lt;/p&gt;&#xA;&lt;p&gt;Hash 也有类似的分水岭。Redis 7.x 中，field 数量 ≤ 128（&lt;code&gt;hash-max-listpack-entries&lt;/code&gt; 默认值）且每个 field 值 ≤ 64 字节时，使用 listpack 编码（紧凑的线性字节数组，取代了旧版的 ziplist）。一旦超过任一阈值，自动升级为 hashtable。&lt;/p&gt;&#xA;&lt;p&gt;差距有多大？listpack 的内存效率比 hashtable 高出约 3-5 倍（100 field 场景）。同样存 100 个用户属性字段，listpack 编码可能只占几 KB，hashtable 编码占用要翻几番。&lt;/p&gt;&#xA;&lt;p&gt;这个阈值很多教程都写错——旧版 ziplist 时代默认确实是 512，Redis 7.x 换成 listpack 后改成了 128。别踩这个坑。listpack 是线性结构，查找是 O(N)。field 越多，HGET 越慢。实践中建议将单个 Hash 控制在 100 field 以内——既在默认阈值安全线内享受 listpack 的内存优势，又不让查找成为瓶颈。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;设计原则&lt;/strong&gt;：用户 profile 拆成多个小 Hash（每个控制在 100 field 以内），比塞进一个 500 field 的巨型 Hash 省内存也更快。&lt;/p&gt;&#xA;&lt;p&gt;ZSET 同理：元素 ≤ 128 时用 listpack（紧凑但查找 O(N)），超过后升级为 skiplist + hashtable（O(log N) 但内存消耗翻 2-3 倍）。排行榜设计的第一步是预估你的最大元素量——这决定了底层编码，进而决定了内存成本和查询模式。&lt;/p&gt;&#xA;&lt;h3 id=&#34;缓存模式的选择&#34;&gt;&lt;a href=&#34;#%e7%bc%93%e5%ad%98%e6%a8%a1%e5%bc%8f%e7%9a%84%e9%80%89%e6%8b%a9&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;缓存模式的选择&#xA;&lt;/h3&gt;&lt;p&gt;Cache-Aside（旁路缓存）是最常见的模式。但它有一个天然弱点：读取 miss 后写入缓存，到下次更新 DB 前，缓存数据可能已过期。&lt;/p&gt;&#xA;&lt;p&gt;三种缓存模式的适用条件：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;模式&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;一致性&lt;/th&gt;&#xA;          &lt;th&gt;适用场景&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Redis 角色&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Cache-Aside&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;最终一致&lt;/td&gt;&#xA;          &lt;td&gt;读多写少，容忍秒级延迟&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;旁路加速&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Write-Through&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;高一致&lt;/td&gt;&#xA;          &lt;td&gt;读写均衡，不容忍脏读&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;同步写入&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;Write-Behind&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;弱一致&lt;/td&gt;&#xA;          &lt;td&gt;写极多，容忍数据丢失&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;写缓冲&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;注：Write-Through 和 Write-Behind 在 Redis 中无原生支持，需要应用层代码实现同步/异步写入逻辑。Write-Through 的&amp;quot;高一致&amp;quot;也非严格线性一致——并发写入时仍存在一致性窗口。&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch2-cache-patterns.png&#34; alt=&#34;缓存模式选择&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;大部分业务场景用 Cache-Aside 就够了。如果你发现自己在实现 Write-Through 的复杂逻辑——停下来想想，是不是场景本身就不适合缓存。&lt;/p&gt;&#xA;&lt;h3 id=&#34;缓存的退出条件&#34;&gt;&lt;a href=&#34;#%e7%bc%93%e5%ad%98%e7%9a%84%e9%80%80%e5%87%ba%e6%9d%a1%e4%bb%b6&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;缓存的退出条件&#xA;&lt;/h3&gt;&lt;p&gt;什么时候 Redis 缓存不该用？两个核心信号：&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;热数据集远超可用内存，或数据更新极其频繁且无规律&lt;/strong&gt;：经验上，缓存命中率低于 85-90% 时（具体阈值因业务而异），缓存带来的收益会被 miss 后回源 DB 的开销抵消。监控你的 keyspace_hits / (keyspace_hits + keyspace_misses)。如果 TTL 设 10 秒，数据 3 秒就变了，缓存命中那 3 秒拿到的是脏数据，其余 7 秒是 miss，这种场景缓存是负优化。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;一致性要求到了&amp;quot;不允许任何脏读&amp;quot;的级别&lt;/strong&gt;：Cache-Aside 模式天然有窗口期（DB 更新后缓存失效前的那个瞬间）。如果这个窗口不可接受，别硬上缓存，改架构。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;满足任意一个，重新评估方案。不是 Redis 不行，是场景不对。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;3-分布式锁边界在哪&#34;&gt;&lt;a href=&#34;#3-%e5%88%86%e5%b8%83%e5%bc%8f%e9%94%81%e8%be%b9%e7%95%8c%e5%9c%a8%e5%93%aa&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;3 分布式锁——边界在哪&#xA;&lt;/h2&gt;&lt;p&gt;分布式锁用 Redis？可以。但先问清楚边界在哪。&lt;/p&gt;&#xA;&lt;p&gt;大部分教程告诉你 &lt;code&gt;SET key value NX EX 30&lt;/code&gt; 就完事了。在单 Redis 实例、并发不高、允许偶尔失败的场景里，确实够用。Redisson 框架把这件事封装得更好——加上了看门狗续期、可重入支持、公平锁等特性。&lt;/p&gt;&#xA;&lt;p&gt;问题不在于&amp;quot;怎么加锁&amp;quot;。问题在于：&lt;strong&gt;你的&amp;quot;分布式锁&amp;quot;到底需要多强的互斥保证？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;脑裂场景锁为什么会失效&#34;&gt;&lt;a href=&#34;#%e8%84%91%e8%a3%82%e5%9c%ba%e6%99%af%e9%94%81%e4%b8%ba%e4%bb%80%e4%b9%88%e4%bc%9a%e5%a4%b1%e6%95%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;脑裂场景：锁为什么会失效&#xA;&lt;/h3&gt;&lt;p&gt;Redis 主从复制是异步的。这一句话背后藏着一个致命的边界条件。&lt;/p&gt;&#xA;&lt;p&gt;构造一个时序，你就明白问题有多真实：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T0  客户端A → Master: SET lock:order NX EX 30 → 成功，获得锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T1  Master 宕机（锁数据尚未同步到 Replica）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T2  Sentinel 检测到 Master 不可达，开始 failover（通常需要数秒）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T3  Replica 被提升为新 Master（但它没有 lock:order 这个 key！）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T4  客户端B → 新Master: SET lock:order NX EX 30 → 成功，也获得了锁&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;T5  💥 客户端A 和 客户端B 同时持有锁 → 互斥性被打破&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch3-brain-split.png&#34; alt=&#34;脑裂时序&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;从 T0 到 T5，可能只有 3-5 秒。但这几秒足以让两个客户端同时操作同一份资源。&lt;/p&gt;&#xA;&lt;p&gt;你可能想说：这个概率很低。没错，低——但&amp;quot;概率低&amp;quot;不等于&amp;quot;可以无视&amp;quot;。如果锁保护的是支付扣款操作，一次失效就是一笔资金损失，一个 P0 级事故。&lt;/p&gt;&#xA;&lt;p&gt;Redisson 的看门狗能解决这个问题吗？不能。看门狗解决的是&amp;quot;锁过期但业务没执行完&amp;quot;的问题，解决不了&amp;quot;Master 宕机数据丢失&amp;quot;的问题。这是两个完全不同层面的风险。&lt;/p&gt;&#xA;&lt;h3 id=&#34;redlock复杂度陷阱&#34;&gt;&lt;a href=&#34;#redlock%e5%a4%8d%e6%9d%82%e5%ba%a6%e9%99%b7%e9%98%b1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;Redlock：复杂度陷阱&#xA;&lt;/h3&gt;&lt;p&gt;Redis 作者 antirez 为了解决上述问题，提出了 Redlock 算法：向 N（推荐 5）个&lt;strong&gt;完全独立&lt;/strong&gt;的 Redis 实例同时申请锁，获得多数派（≥ N/2 + 1 = 3）响应且总耗时未超过锁的有效期，才视为加锁成功。&lt;/p&gt;&#xA;&lt;p&gt;听起来合理。但 Martin Kleppmann 在 2016 年那篇经典文章（&amp;quot;&lt;a class=&#34;link&#34; href=&#34;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;How to do distributed locking&lt;/a&gt;&amp;quot;）里提出了尖锐批评：Redlock 依赖节点时钟大致同步，但时钟跳变（NTP 校准、VM 迁移）会让过期计算不可靠；客户端拿到锁后遭遇 GC pause 或网络分区，锁可能早已过期但客户端不知道。更根本的问题是——Redis 不是共识系统，让它承担互斥保证，是用错误的工具解决问题。&lt;/p&gt;&#xA;&lt;p&gt;我认为 Kleppmann 的分析在理论层面是对的。不过 antirez 在回应文章&amp;quot;&lt;a class=&#34;link&#34; href=&#34;http://antirez.com/news/101&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Is Redlock safe?&lt;/a&gt;&amp;ldquo;中反驳：Redlock 不要求绝对时钟同步，只要时钟漂移远小于 TTL 就够了（现代硬件通常满足）；GC pause 导致锁过期是所有分布式锁的通病，不是 Redlock 特有的问题。&lt;/p&gt;&#xA;&lt;p&gt;两边都有道理。但工程实践中，判断不能只看理论——还要看成本。&lt;/p&gt;&#xA;&lt;p&gt;我的决策建议是这样的：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;90% 的场景&lt;/strong&gt;：单 Redis 实例锁 + 合理过期时间（业务执行时间的 3 倍以上） + 业务层幂等设计 = 足够安全。脑裂是极低概率事件，用业务补偿兜底。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;锁失效会造成资金损失&lt;/strong&gt;：etcd 或 ZooKeeper。它们基于 Raft/ZAB 共识算法，互斥保证是协议级别的。但注意：etcd/ZK 写入吞吐通常在千级 QPS，获锁并发量极高（万级/秒）时需要应用层排队或分段锁。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Redlock&lt;/strong&gt;：要维护 5 个独立 Redis 实例，运维成本不低，但安全性提升有限（Kleppmann 的批评仍然成立）。性价比很差，不推荐作为首选。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;一句话决策：&lt;strong&gt;锁失效会造成不可逆损失（资金/数据）？会 → etcd/ZK。不会 → Redis 单实例 + 幂等补偿。&lt;/strong&gt; Redlock 是一个复杂度陷阱——花了 5 倍的运维成本，买了一个&amp;quot;好一点但仍然不完美&amp;quot;的保证。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch3-lock-decision.png&#34; alt=&#34;锁方案决策&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;分布式锁考验的是一致性边界。接下来换个维度——看数据结构选择怎么影响性能天花板。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;4-排行榜计数器选对结构&#34;&gt;&lt;a href=&#34;#4-%e6%8e%92%e8%a1%8c%e6%a6%9c%e8%ae%a1%e6%95%b0%e5%99%a8%e9%80%89%e5%af%b9%e7%bb%93%e6%9e%84&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;4 排行榜/计数器——选对结构&#xA;&lt;/h2&gt;&lt;p&gt;ZSET 天生就是排行榜的数据结构。O(log N) 插入，O(log N + M) 范围查询（M 为返回元素数），O(log N) 查排名。看起来无可挑剔。&lt;/p&gt;&#xA;&lt;p&gt;但&amp;quot;用 ZSET 做排行榜&amp;quot;不是一句话就能搞定的事。数据量从 1 万到 1000 万，最优方案完全不同。拍脑袋选了方案 A，等数据量涨到百万级才发现扛不住——代价是重构。&lt;/p&gt;&#xA;&lt;h3 id=&#34;zset-底层skiplist-的代价&#34;&gt;&lt;a href=&#34;#zset-%e5%ba%95%e5%b1%82skiplist-%e7%9a%84%e4%bb%a3%e4%bb%b7&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;ZSET 底层：skiplist 的代价&#xA;&lt;/h3&gt;&lt;p&gt;先搞清楚 ZSET 的底层。当元素超过 128 个（或单元素超过 64 字节），ZSET 从 listpack 升级为 skiplist + hashtable 双结构。&lt;/p&gt;&#xA;&lt;p&gt;skiplist 的查找是 O(log N)——这是期望值。Redis 的 skiplist 提升概率 p=0.25，搜索深度按 log₄(N) 计算。N = 1 万时，平均约 7 层。N = 1000 万时，平均约 12 层。每多一层就是一次内存随机访问（pointer chase），CPU cache miss 率随之上升。&lt;/p&gt;&#xA;&lt;p&gt;7 层和 12 层，看起来差距不大对吧？但乘以每秒数万次操作，这 5 层的差距就是 QPS 天花板的分水岭。&lt;/p&gt;&#xA;&lt;h3 id=&#34;三方案性能推演&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e6%96%b9%e6%a1%88%e6%80%a7%e8%83%bd%e6%8e%a8%e6%bc%94&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三方案性能推演&#xA;&lt;/h3&gt;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;⚠️ 以下推演基于 Redis 时间复杂度和官方 benchmark 数据（redis-benchmark 单实例 ~10 万 QPS 基线），非同环境直接对比实测&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch4-performance.png&#34; alt=&#34;三方案性能对比&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 A：单 ZSET 全量存储&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;最简单直接的方案。一个 ZSET 装所有参与者的分数。&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;更新分数：ZADD key score member → O(log N)&lt;/li&gt;&#xA;&lt;li&gt;查 Top-100：ZREVRANGE key 0 99 WITHSCORES → O(log N + 100)&lt;/li&gt;&#xA;&lt;li&gt;查某人排名：ZREVRANK key member → O(log N)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;N = 1 万时，每次操作约 7 跳，单实例轻松达到十万级 QPS。N = 100 万时，约 10 跳，QPS 仍在万级以上。N = 1000 万时，约 12 跳加上内存压力（单个 ZSET 占内存可达 GB 级），QPS 进一步下降但仍可用。&lt;/p&gt;&#xA;&lt;p&gt;能用吗？当然能。但数据量从万级到千万级，性能衰减是肉眼可见的。千万级 ZSET 还有 Big Key 风险：DEL 可能阻塞主线程数秒（Redis 4.0+ 推荐用 UNLINK 异步删除），Cluster 迁移容易超时。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 B：分桶 ZSET（按分数段或时间段分片）&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;核心思路：把一个大 ZSET 拆成多个小 ZSET。&lt;/p&gt;&#xA;&lt;p&gt;比如按分数区间：score 0-999 放 rank:bucket:0，score 1000-1999 放 rank:bucket:1。每个桶的 N 小，skiplist 层数少，单桶操作更快。&lt;/p&gt;&#xA;&lt;p&gt;但代价是：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;查 Top-K 可能需要跨桶合并（从最高分桶开始取，不够再取次高桶）&lt;/li&gt;&#xA;&lt;li&gt;实现复杂度显著上升&lt;/li&gt;&#xA;&lt;li&gt;查某人全局排名需要计算低分桶的总人数作为偏移&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;适合的场景：写入 QPS 极高（万级以上且持续增长）、分数分布相对均匀、可以接受实现复杂度的团队。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;方案 C：ZSET + Hash 缓存热数据&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;ZSET 仍然存完整排行，但将 Top-100（或 Top-1000）用一个 Hash 单独缓存。定时任务（比如每 5 秒）从 ZSET 取最新 Top-K 写入 Hash。&lt;/p&gt;&#xA;&lt;p&gt;读 Top-100 变成 HGETALL → O(K)（K=100，不随排行榜总规模增长）。写入仍然打到 ZSET（O(log N)），但高频的&amp;quot;查看排行榜&amp;quot;请求被 Hash 挡住了，不再重复扫 skiplist。&lt;/p&gt;&#xA;&lt;p&gt;代价：排行榜有最多 5 秒的数据延迟。对于游戏排行、内容热度榜——完全可接受。对于实时竞拍——不行。&lt;/p&gt;&#xA;&lt;h3 id=&#34;方案选择决策&#34;&gt;&lt;a href=&#34;#%e6%96%b9%e6%a1%88%e9%80%89%e6%8b%a9%e5%86%b3%e7%ad%96&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;方案选择决策&#xA;&lt;/h3&gt;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;数据量&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;写入压力&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;推荐方案&lt;/th&gt;&#xA;          &lt;th&gt;核心理由&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;万级&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;低&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;方案 A&lt;/td&gt;&#xA;          &lt;td&gt;简单即正义，ZADD + ZREVRANGE 性能绰绰有余&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;十万-百万级&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;中等&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;方案 C&lt;/td&gt;&#xA;          &lt;td&gt;读写解耦，查询不受 N 增长影响&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;百万级以上&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;高&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;方案 B&lt;/td&gt;&#xA;          &lt;td&gt;分桶分压，每个桶维持高效操作&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;千万级以上&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;任意&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Redis Cluster 分片 + B/C 组合&lt;/td&gt;&#xA;          &lt;td&gt;单实例内存和 CPU 都扛不住。注意：Cluster 模式下分桶 ZSET 需用 hash tag &lt;code&gt;{rank}:bucket:N&lt;/code&gt; 保证同 slot 操作&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;10 万以下闭眼方案 A。10 万以上先试方案 C，最小改动最大收益。百万级数据量才需要动分桶的心思。&lt;/p&gt;&#xA;&lt;p&gt;排行榜和计数器的决策核心是数据结构选型。下一个场景换个赛道——消息队列，这里要决策的不是&amp;quot;用什么结构&amp;rdquo;，而是&amp;quot;用不用 Redis&amp;quot;。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;5-消息队列进退线&#34;&gt;&lt;a href=&#34;#5-%e6%b6%88%e6%81%af%e9%98%9f%e5%88%97%e8%bf%9b%e9%80%80%e7%ba%bf&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;5 消息队列——进退线&#xA;&lt;/h2&gt;&lt;p&gt;Redis Stream 在 5.0 引入，到 7.x 已经相当成熟。消费者组（Consumer Group）、消息确认（XACK）、消息持久化（AOF）、消费进度追踪——该有的功能都有了。&lt;/p&gt;&#xA;&lt;p&gt;但&amp;quot;能当消息队列用&amp;quot;和&amp;quot;该当消息队列用&amp;quot;是完全不同的两个命题。&lt;/p&gt;&#xA;&lt;p&gt;我的判断是：Redis Stream 是一个优秀的&amp;quot;轻量级异步解耦&amp;quot;工具，但它不是、也不该是你的&amp;quot;企业级消息中间件&amp;quot;。&lt;/p&gt;&#xA;&lt;h3 id=&#34;redis-stream-vs-kafka维度拆解&#34;&gt;&lt;a href=&#34;#redis-stream-vs-kafka%e7%bb%b4%e5%ba%a6%e6%8b%86%e8%a7%a3&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;Redis Stream vs Kafka：维度拆解&#xA;&lt;/h3&gt;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;⚠️ 以下对比基于官方文档和公开 benchmark 推演，非同环境直接对比实测&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch5-stream-vs-kafka.png&#34; alt=&#34;Stream vs Kafka&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;吞吐量差距&lt;/strong&gt;：Redis Stream 单实例约 10-15 万 msg/s（消息体 &amp;lt; 1KB）。Kafka 单 broker 可达约 100 万 msg/s。差一个数量级。Redis 是纯内存操作所以延迟低，但吞吐上限受单线程模型限制。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;持久化保证&lt;/strong&gt;：Redis Stream 的持久化依赖 AOF（append-only file）。如果用 everysec 策略，极端宕机可能丢 1 秒数据。如果用 always 策略，性能打折。Kafka 的分区副本机制（acks=all + ISR）提供的持久化保证强得多——Leader 写入后等待所有 ISR 副本确认。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;消息堆积容忍度&lt;/strong&gt;：Redis Stream 的数据全在内存。堆积 100 万条小消息就要占几百 MB 内存。通常用 MAXLEN 或 MINID 做截断。Kafka 是磁盘顺序写，百 GB 甚至 TB 级堆积都是正常操作。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;运维成本&lt;/strong&gt;：Redis Stream 零额外部署——你已有的 Redis 集群就能承载。Kafka 需要独立集群：以前是 ZooKeeper + broker，现在 KRaft 模式好一些，但仍需要独立运维。对于小团队，这不是小事。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;消费语义&lt;/strong&gt;：Redis Stream 的 XREADGROUP + XACK 机制实现了 at-least-once 语义。但如果消费者崩溃后没有 XACK，消息会留在 pending 列表里等待认领（XCLAIM）。这个机制能用，但没有 Kafka Consumer Group 的 rebalance 那么成熟和自动化。&lt;/p&gt;&#xA;&lt;h3 id=&#34;场景分界线&#34;&gt;&lt;a href=&#34;#%e5%9c%ba%e6%99%af%e5%88%86%e7%95%8c%e7%ba%bf&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;场景分界线&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;用 Redis Stream 的信号&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;日消息量在十万级以下&lt;/li&gt;&#xA;&lt;li&gt;消息体小（&amp;lt; 1KB，典型的事件通知类）&lt;/li&gt;&#xA;&lt;li&gt;允许极端场景丢少量尾部消息&lt;/li&gt;&#xA;&lt;li&gt;团队已有 Redis 运维经验，不想为了一个异步需求引入 Kafka 全家桶&lt;/li&gt;&#xA;&lt;li&gt;需求本质是&amp;quot;轻量级解耦&amp;quot;——A 服务通知 B 服务做某件事&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;strong&gt;切换到 Kafka 的信号&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;日消息量十万级以上&lt;/li&gt;&#xA;&lt;li&gt;消息不能丢（金融级可靠性要求）&lt;/li&gt;&#xA;&lt;li&gt;需要 TB 级堆积能力和任意时间点消息回溯&lt;/li&gt;&#xA;&lt;li&gt;多个消费者组需要独立消费同一份数据流&lt;/li&gt;&#xA;&lt;li&gt;已有 Kafka 集群和运维团队&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;strong&gt;灰色地带&lt;/strong&gt;（日十万到百万之间）：取决于你的团队。有 Kafka 经验就直接上 Kafka，省得以后迁移。没有的话，Redis Stream 先扛着，监控好内存和堆积量，什么时候扛不住再迁移。迁移成本不高，因为 Stream 和 Kafka 的消费模型很接近。&lt;/p&gt;&#xA;&lt;p&gt;一句话决策：&lt;strong&gt;日消息量十万级以下、允许极端场景丢数据 → Redis Stream。超过这个线 → Kafka。&lt;/strong&gt; 先用 Stream 扛着，扛不住再迁 Kafka，迁移成本不高。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;6-冷门场景快速决策&#34;&gt;&lt;a href=&#34;#6-%e5%86%b7%e9%97%a8%e5%9c%ba%e6%99%af%e5%bf%ab%e9%80%9f%e5%86%b3%e7%ad%96&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;6 冷门场景：快速决策&#xA;&lt;/h2&gt;&lt;p&gt;有些 Redis 场景不需要一整章来展开。但面试时经常被问到。给你每个场景的快速决策结论和边界条件。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ch6-cold-scene-cards.png&#34; alt=&#34;冷门场景卡片&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h3 id=&#34;布隆过滤器&#34;&gt;&lt;a href=&#34;#%e5%b8%83%e9%9a%86%e8%bf%87%e6%bb%a4%e5%99%a8&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;布隆过滤器&#xA;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;用它的唯一正当理由&lt;/strong&gt;：你需要快速回答&amp;quot;这个元素&lt;strong&gt;一定不在&lt;/strong&gt;集合中&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;注意&amp;quot;一定不在&amp;quot;这个措辞。布隆过滤器说&amp;quot;不在&amp;quot;，那就真不在。说&amp;quot;在&amp;quot;，有一定概率是误判（false positive）。它解决的是&amp;quot;确定性否定&amp;quot;问题。&lt;/p&gt;&#xA;&lt;p&gt;典型场景：缓存穿透防护（拦截不存在的 key 请求）、爬虫 URL 去重、推荐系统已曝光过滤。&lt;/p&gt;&#xA;&lt;p&gt;内存开销多大？基于 BloomFilter 最优参数公式（m = -(n × ln p) / (ln 2)²）推算：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;预期元素数&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;误判率 1% 内存&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;误判率 0.1% 内存&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;最优哈希函数数&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;100 万&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~1.14 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~1.71 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;7 / 10&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;1000 万&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~11.4 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~17.1 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;7 / 10&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;1 亿&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~114 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~171 MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;7 / 10&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;千万级以下，Redis BF 模块（RedisBloom）一条 &lt;code&gt;BF.ADD&lt;/code&gt; 命令搞定，内存开销极低（十几 MB）。不用自己实现哈希函数，不用自己管理位数组。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;不用 Redis 布隆过滤器的信号&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;需要精确判断&amp;quot;在/不在&amp;quot;（布隆有误判，不适合）&lt;/li&gt;&#xA;&lt;li&gt;需要删除元素（标准布隆不支持删除，Cuckoo Filter 可以但换了个数据结构）&lt;/li&gt;&#xA;&lt;li&gt;元素量超过亿级且内存敏感（占用 100MB+ 的 Redis 内存，要权衡是否划算）&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h3 id=&#34;延时队列&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e6%97%b6%e9%98%9f%e5%88%97&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延时队列&#xA;&lt;/h3&gt;&lt;p&gt;Redis 实现延时队列的经典姿势：ZSET 中 score 设为消息到期的时间戳（Unix timestamp），消费者轮询 &lt;code&gt;ZRANGE key min max BYSCORE&lt;/code&gt;（Redis 6.2+ 推荐语法，旧版用 &lt;code&gt;ZRANGEBYSCORE&lt;/code&gt;）取出到期消息，然后 ZREM 删除。&lt;/p&gt;&#xA;&lt;p&gt;为什么用 ZSET？因为 score 排序天然就是时间排序，按当前时间过滤就是取&amp;quot;已到期的所有任务&amp;quot;。O(log N + M) 的时间复杂度，M 是到期任务数。&lt;/p&gt;&#xA;&lt;p&gt;本质上这是一个&amp;quot;穷人版&amp;quot;延时队列。功能够用、部署简单、不引入新依赖。适合日任务量不大（十万级以下）、精度要求秒级、不需要严格 exactly-once 语义的场景。ZRANGE&amp;hellip;BYSCORE + ZREM 不是原子操作，需要 Lua 脚本或分布式锁配合。&lt;/p&gt;&#xA;&lt;p&gt;什么时候该换？任务量到了百万级（轮询压力大，ZSET 内存膨胀），或者需要毫秒级精度，或者需要严格消息不丢。这时候 RocketMQ（原生延时消息）或 XXL-JOB 是更合适的选择。够用就用，扛不住再换，迁移成本很低。&lt;/p&gt;&#xA;&lt;h3 id=&#34;社交关系共同好友关注列表&#34;&gt;&lt;a href=&#34;#%e7%a4%be%e4%ba%a4%e5%85%b3%e7%b3%bb%e5%85%b1%e5%90%8c%e5%a5%bd%e5%8f%8b%e5%85%b3%e6%b3%a8%e5%88%97%e8%a1%a8&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;社交关系（共同好友/关注列表）&#xA;&lt;/h3&gt;&lt;p&gt;Set 的交集运算 SINTER 看起来天然适合&amp;quot;共同好友&amp;quot;。两个用户各一个 Set 存关注列表，SINTER 取交集，完事。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;用 Redis Set 的条件&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;用户总量 &amp;lt; 百万&lt;/li&gt;&#xA;&lt;li&gt;单用户关注/好友列表 &amp;lt; 5000 人&lt;/li&gt;&#xA;&lt;li&gt;查询不频繁（非每次打开页面都算共同好友）&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;这个量级下，SINTER 操作耗时在毫秒级以内。内存占用需要留出余量——100 万用户 × 平均 500 关系 × 每个 member 实际占用约 60-70 字节（含 dictEntry、SDS header、内存对齐等 Redis 元数据开销）≈ 30-35 GB。一台大内存 Redis 能扛，但要做好容量规划。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;不用 Redis Set 的信号&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;用户量千万级以上 → 内存装不下（千万用户 × 1000 关系 = 百亿 member）&lt;/li&gt;&#xA;&lt;li&gt;单用户关系数超万 → SINTER 两个万级 Set 的耗时会到达几十毫秒，阻塞单线程&lt;/li&gt;&#xA;&lt;li&gt;需要多跳关系查询（好友的好友）→ Set 只能做一跳交集，多跳需要图计算&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;大规模社交图谱该用图数据库（Neo4j、TigerGraph）做离线分析，或者用 Spark/Flink 预计算共同好友列表存入缓存。Redis 只适合做&amp;quot;热点用户&amp;quot;的关系缓存——微博大 V 的粉丝列表 Top-N，而不是全量关系图。如果用 Redis Cluster，注意 SINTER 要求 key 在同一 slot，需用 hash tag（如 &lt;code&gt;{user}:friends&lt;/code&gt;）保证。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h2 id=&#34;决策速查表&#34;&gt;&lt;a href=&#34;#%e5%86%b3%e7%ad%96%e9%80%9f%e6%9f%a5%e8%a1%a8&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;决策速查表&#xA;&lt;/h2&gt;&lt;p&gt;回到最开头的问题：Redis 能做什么？&lt;/p&gt;&#xA;&lt;p&gt;答案是它能做很多事。但&lt;strong&gt;你该让它做什么&lt;/strong&gt;，取决于你的 3 个业务约束条件。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/redis-engineering/ending-cheatsheet.png&#34; alt=&#34;决策速查表&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;场景&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;推荐数据结构&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;推荐度&lt;/th&gt;&#xA;          &lt;th&gt;退出条件（触发任一则重新评估）&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;缓存&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;String / Hash&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;强推&lt;/td&gt;&#xA;          &lt;td&gt;热数据远超内存 / 不容忍任何脏读&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;分布式锁&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;String + NX&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;推荐&lt;/td&gt;&#xA;          &lt;td&gt;锁失效会造成资金损失&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;排行榜&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;ZSET&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;强推&lt;/td&gt;&#xA;          &lt;td&gt;数据量千万级以上且写入压力极高&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;计数器/限流&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;String INCR&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;强推&lt;/td&gt;&#xA;          &lt;td&gt;需要跨实例全局精确计数&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;消息队列&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Stream&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;条件推荐&lt;/td&gt;&#xA;          &lt;td&gt;日消息量十万级以上 / 消息不能丢&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;延时队列&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;ZSET&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;条件推荐&lt;/td&gt;&#xA;          &lt;td&gt;任务量大 / 需要毫秒级精度&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;布隆过滤器&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;BF 模块&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;推荐&lt;/td&gt;&#xA;          &lt;td&gt;需要精确判断 / 需要删除元素&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;社交关系&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Set&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;条件推荐&lt;/td&gt;&#xA;          &lt;td&gt;用户量千万以上 / 需要多跳查询&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;会话存储&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;Hash&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;推荐&lt;/td&gt;&#xA;          &lt;td&gt;会话数据需要复杂查询或联表&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;h3 id=&#34;面试时怎么用这棵树&#34;&gt;&lt;a href=&#34;#%e9%9d%a2%e8%af%95%e6%97%b6%e6%80%8e%e4%b9%88%e7%94%a8%e8%bf%99%e6%a3%b5%e6%a0%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;面试时怎么用这棵树&#xA;&lt;/h3&gt;&lt;p&gt;面试官问&amp;quot;Redis 有哪些使用场景&amp;quot;时，不要报菜名。&lt;/p&gt;&#xA;&lt;p&gt;&amp;ldquo;缓存、分布式锁、排行榜、消息队列……&amp;rdquo; 这种回答和百度第一页搜索结果没区别。面试官听了几百遍了。&lt;/p&gt;&#xA;&lt;p&gt;试试这样回答：&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;&amp;ldquo;我不会去背 Redis 有几种用法。在做场景选型时，我会从 3 个维度判断：读写比、一致性要求、数据量级。&lt;/p&gt;&#xA;&lt;p&gt;举个例子——缓存场景，读多写少 + 最终一致 + 热数据可控，这是 Redis 的绝对甜蜜区，闭眼用。&lt;/p&gt;&#xA;&lt;p&gt;但分布式锁就不一样了。如果涉及资金安全，我不会用 Redis——因为主从异步复制在 failover 时有锁失效风险。这种场景我会建议 etcd。&lt;/p&gt;&#xA;&lt;p&gt;Redis Stream 做消息队列也是。日十万级以下、允许极端情况丢消息——可以。超过这个线就该上 Kafka。&lt;/p&gt;&#xA;&lt;p&gt;关键是知道每种用法的边界在哪、什么时候该退出。&amp;rdquo;&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;这段回答包含了决策框架、具体判断、边界认知和替代方案。&lt;/p&gt;&#xA;&lt;p&gt;比罗列场景更有说服力。&lt;/p&gt;&#xA;&lt;h3 id=&#34;最后一句&#34;&gt;&lt;a href=&#34;#%e6%9c%80%e5%90%8e%e4%b8%80%e5%8f%a5&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;最后一句&#xA;&lt;/h3&gt;&lt;p&gt;Redis 强在特定约束条件下，它是那个最优解。&lt;/p&gt;&#xA;&lt;p&gt;你的工作是判断：在你的具体场景里，它到底是不是最优解。如果不是，果断换。&lt;/p&gt;&#xA;&lt;p&gt;决策能力 &amp;gt; 记忆力。这是这篇文章想说的全部。&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;原文发布于 &lt;a class=&#34;link&#34; href=&#34;https://www.wujiachen.com.cn/posts/redis-engineering&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;止语Lab&lt;/a&gt;&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;</description>
        </item></channel>
</rss>
