先看两个很像、其实责任完全不同的数字。
文章详情页每打开一次,article:42:views 就加一。它用于展示“这篇文章大概有多热”,短时间少几十次通常不会影响业务决策。短信接口 POST /sms/send 也有一个数字:用户在一分钟内已经请求了几次。这个数字一旦算少,攻击者就可能继续消耗短信费用;一旦算多,正常用户又会收不到验证码。
两处都可以写 INCR,但这只能回答一个很窄的问题:已经到达同一个 Redis 主节点的自增命令,不会彼此覆盖。 它没有回答请求是否重试、进程是否在发送命令前退出、Redis 是否在返回响应前断线、窗口有没有过期、谁应该共用一份配额,以及 Redis 中的数字怎样进入永久账本。
本章就从这条边界出发。我们会让 article_views 在高并发下可统计、可去重、可落库,也会给 POST /sms/send 做出能解释、能降级的分布式限流。重点不是记住四种算法,而是先定义数字代表什么,再决定 Redis 负责其中哪一段。
最容易出错的写法不是 GET 后在应用里加一。那种读—改—写在并发下会覆盖,问题很明显。大家换成 INCR article:42:views 后,反而容易过早宣布“计数准确了”。
INCR 确实是原子命令。key 不存在时,Redis 把它当作 0,再返回递增后的整数。50 个应用请求并发发出 50 次 INCR,Redis 会给出 1 到 50 这 50 个不同结果,最终值是 50。这里不需要应用层互斥锁。
const results = await Promise.all(
Array.from({ length: 50 }, () => redis.incr("article:42:views")),
);
console.log("INCR 并发结果", {
replies: results.length,
unique: new Set(results).size,
final: Number(await redis.get("article:42:views")),
});INCR 并发结果 { replies: 50, unique: 50, final: 50 }这段结果证明的是“50 个已执行命令没有互相覆盖”,并不证明“50 次真实浏览恰好记了 50 次”。端到端链路里至少还有两种失真:

还有一个更基础的问题:什么叫“一次浏览”?浏览器刷新、预加载、搜索引擎抓取、客户端超时重试、同一用户连续打开,都可能击中详情接口。若产品需要的是页面请求次数,允许重复的 INCR 很合适;若需要独立访客数,应该先按用户或设备定义去重周期;若数字参与作者结算,就需要能审计的事件,而不是只有一个无法解释来历的总数。
原子性是 Redis 服务器内部的并发性质,幂等性是业务事件重复到达时的处理规则,持久性是故障后还能不能恢复。三个问题互不替代。看到 INCR 是原子的,不能继续推导出“不会重复”“不会丢失”或“已经永久保存”。
对文章浏览量,我会先把业务目标定成“页面能快速看到接近实时的总数,重复重试不应无限放大,Redis 丢失后可以修复”。这允许 Redis 承担热路径,但数据库仍保存可恢复的累计结果。
一条更可靠的链路包含六步:请求携带由服务端生成或确认的 eventId;Redis 的短期防重集合判断它是不是第一次到达;第一次才给增量计数加一;后台按批次把增量写入数据库;成功后推进水位并清理已落库数据;每天再拿访问日志或事件明细对账。

防重不能只写成“先查 eventId 存不存在,再 INCR”。两个相同事件并发到达时,都可能在检查阶段看到“不存在”,随后各加一次。防重登记和增量更新必须合成一个原子操作。可以用一段很短的 Lua 完成“SADD 返回 1 才 INCRBY”,也可以让更可靠的事件系统先接住浏览事件,再由幂等消费者汇总。本章只使用 Lua 封住竞态;脚本缓存、版本管理、执行预算等细节留到后面的 Lua 专章。
落库也不能把 Redis 当前总数随手覆盖到数据库。假设两个后台任务同时读到增量 100:它们若都执行 views = views + 100,数据库会多记一遍;若先写数据库再清零 Redis,清零前进来的新浏览又可能被一起删掉。
更稳妥的做法是给每批增量分配唯一 batchId。数据库事务先向 article_view_batches(batch_id, article_id, delta) 插入批次记录,batch_id 有唯一约束;插入成功后再执行 article_stats.views = views + delta。任务重试同一批次时,唯一约束让它不会重复累加。Redis 侧使用可轮换的增量桶或明确水位,只清理已经提交的批次,不去删除一个仍在接收新写入的总数 key。
这套设计没有假装 Redis 和数据库组成一个跨系统事务。它接受中间状态可能暂时不一致,然后用幂等批次和对账把差异收回来。展示时可以使用“数据库累计值 + Redis 未落库增量”;结算时只认已落库、可审计的批次或原始事件。
先写清楚指标口径:请求次数、独立访客还是可结算浏览。没有口径,防重 key 和保留周期都无从设计。
为一次可重试的浏览生成稳定 eventId。重试沿用原 ID,新的浏览才生成新 ID;不能每次重试都换 ID。
在 Redis 中原子完成防重与增量累加,并给防重集合设置符合业务周期的 TTL,防止成员永久增长。
用带唯一 batchId 的数据库事务累加增量。提交失败保留批次继续重试,提交成功再推进 Redis 水位。
计数器是描述已经发生了多少,限流器则要在副作用发生前做决定。以 POST /sms/send 为例,顺序应该是:认证与参数校验、幂等检查、限流判定、调用短信供应商、记录发送结果。限流放到供应商调用之后就晚了,因为费用和下游压力已经产生。
“每分钟 5 次”仍然不够完整。至少要继续问:
Retry-After?验证码接口通常先按“发送尝试”消耗名额,而不是等供应商成功才计数。否则攻击者可以利用供应商超时反复制造调用。相同幂等键的重试应优先返回既有结果,不再触发供应商,也不重复消耗配额。429 表示当前主体超过策略;Redis 故障导致无法安全判定时,用 503 往往比伪装成用户超限更诚实。
真正的保护一般是多层的。例如同一手机号每分钟 1 次、每小时 5 次;同一用户每分钟 3 次;同一租户每秒 100 次;短信供应商全局并发不超过 200。它们保护的对象不同,不能拼成一个“万能 key”。

只按 IP 限流会误伤公司、学校和移动网络出口后面的正常用户;只按用户限流又挡不住未登录接口和批量注册账号。已登录请求优先使用用户 ID 或 API Key,未登录请求再结合可信客户端 IP、设备风险和手机号。X-Forwarded-For 只能由受信网关清洗后使用,不能把客户端随手传来的头当身份。
一个便于运维的固定窗口 key 可以写成:
rate:{tenant-7}:sms:user-42:28333333其中 tenant-7 是租户,sms 是接口组,user-42 是主体,28333333 是窗口编号。花括号还能为 Redis Cluster 的多 key 操作预留同槽约束,但不要为了“以后也许用得到”把所有租户都放进同一个花括号;那会主动制造热点。
固定窗口把时间切成等长区间。窗口长度为 1 秒时,bucket = floor(nowMs / 1000);同一主体在同一个 bucket 中共享一个计数器。它很便宜,适合允许边界突刺的粗限流、内部接口或前置的一层快速保护。
常见实现会分开发两条命令:
const count = await redis.incr(key);
if (count === 1) {
await redis.pexpire(key, windowMs);
}第一处坑是命令间竞态。若 INCR 成功后进程退出,PEXPIRE 没有执行,这个 key 就不会自动清理。后续请求看到 count > 1,也不会再补 TTL,于是该主体可能被永久限流。pipeline 只能减少网络往返,不能让依赖前一个返回值的判断自动获得原子性;事务可以把命令成组执行,但这段条件逻辑用一个短 Lua 更清楚。
redis.defineCommand("fixedWindow", {
numberOfKeys: 1,
lua: `
local count = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
if count == 1 or ttl < 0 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {count, ttl}
`,
});
async function fixedLimit(subject, nowMs
ioredis 的 defineCommand 会把脚本包装成普通方法,并在可用时复用脚本摘要。这里额外检查 ttl < 0,是为了让历史坏 key 或异常路径有机会恢复。TTL 比窗口略长,只负责回收旧 bucket;真正属于哪个窗口,由 key 末尾的 bucket 决定。
连续四次请求的结果符合“每秒最多 3 次”:
固定窗口连续请求 [
{ allowed: true, count: 1 },
{ allowed: true, count: 2 },
{ allowed: true, count: 3 },
{ allowed: false, count: 4 }
]第二处坑与原子性无关,而是固定窗口本身的边界语义。第 0 个窗口最后 3 毫秒通过 3 次,第 1 个窗口最前 3 毫秒又通过 3 次,每个 bucket 都没超额,但 6 毫秒内下游收到了 6 次请求:
固定窗口边界 [
{ allowed: true, bucket: 0 },
{ allowed: true, bucket: 0 },
{ allowed: true, bucket: 0 },
{ allowed: true, bucket: 1 },
{ allowed: true, bucket: 1 },
{ allowed: true, bucket: 1 }
]下面的实验把两批请求推到边界两侧。试着保持单窗口配额不变,只移动时间点,你会看到“规则都没超限”和“短时间流量翻倍”可以同时成立。
如果短信供应商无法承受这种突刺,修复方法不是继续打磨 INCR + EXPIRE。那只能修复永久 key 的竞态,不能改变算法边界。你需要滑动窗口、令牌桶、漏桶,或者在固定窗口前后叠加并发保护。
滑动日志不问“当前属于第几分钟”,而是每次都看 (now - windowMs, now] 这段区间。用 ZSet 实现时,score 保存服务端时间戳,member 保存唯一请求 ID。一次判定要原子完成三件事:删掉左边界外的记录、统计当前成员、未超限时写入新请求并刷新 TTL。
redis.defineCommand("slidingWindow", {
numberOfKeys: 1,
lua: `
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1] - ARGV[2])
local count = redis.call("ZCARD", KEYS[1])
if count >= tonumber(ARGV[3]) then
return {0, count}
end
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[4])
redis.call("PEXPIRE", KEYS[1], ARGV[2])
return {1, count + 1}
`,
});
async function slidingLimit(subject,
以 0、10、20、30、61 秒依次发请求,前三次通过,第 30 秒被拒绝;到第 61 秒,0 秒的记录离开窗口,第五次恢复通过:
滑动窗口 [
{ allowed: true, count: 1 },
{ allowed: true, count: 2 },
{ allowed: true, count: 3 },
{ allowed: false, count: 3 },
{ allowed: true, count: 3 }
]
滑动窗口成员 [ 'r2', 'r3', 'r5' ]
这里有三个容易藏进代码评审盲区的细节。
第一,member 不能只用毫秒时间戳。同一毫秒到达的两个请求会得到相同 member,后一次 ZADD 更新前一次,而不是新增成员,计数因此偏小。使用 timestamp:requestId 或真正唯一的请求 ID。
第二,时间不能相信客户端。让后端从统一可信来源取得时间,或者在 Lua 中读取 Redis TIME,避免客户端伪造时间把记录写到窗口外。多区域、多 Redis 分片仍要监控时钟偏差,因为“服务端时间”也不天然等于全球同一时刻。
第三,精确度要付内存。固定窗口每个主体通常只有一个整数;滑动日志需要为窗口内每次获准请求保存一个 ZSet 成员,清理成本也随成员数变化。阈值很高、主体很多时,它可能先把 Redis 内存和热点 key 打满。此时可以用两个相邻固定窗口加权估算滑动计数,牺牲一点精度换固定状态量。
下面的实验会逐步展示清理、计数、写入和 TTL,并专门复现“同毫秒 member 冲突”。先关闭唯一 ID,再让两个请求同时到达,观察 ZSet 成员数为什么少一个。
窗口算法主要回答“最近一段时间来了多少”。有些系统更关心流量形状:平时空闲积累的余量能不能在促销开始时使用?短信供应商是否要求我们始终匀速发送?这时更适合用桶来建模。
令牌桶有两个核心参数:补充速率 和容量 。每次请求到达时,不需要真的运行一个定时器逐枚放令牌,只要根据距离上次更新时间的间隔计算当前令牌:
令牌足够就立即扣除并放行,不够就拒绝。容量决定最多允许多大的受控突发,补充速率决定长期平均速度。公共 API 希望平时稳定、偶尔容许客户端短促并发时,令牌桶往往比硬窗口自然。一次昂贵导出也可以消耗 10 枚令牌,普通查询只消耗 1 枚,让“请求成本”进入限流,而不只是数 HTTP 次数。
漏桶把请求先放进有限队列,再按固定速度流向下游。队列满时拒绝,未满的请求要等待。它适合短信供应商、批处理写入这类需要平滑下游速率的场景,但必须同时定义队列容量、最大等待时间、取消和重试。验证码如果排队两分钟才发出去,技术上没有超速,业务上仍然失败。

令牌桶与漏桶的状态更新也包含“读旧状态、按时间补充或流出、判断、写新状态”多个步骤,分开发 Redis 命令同样会并发穿透。生产实现应把状态转移放进一个短脚本,或使用经过验证且语义清楚的限流组件。不要为了省一个依赖,临时写出一个没有时钟、溢出和故障测试的桶。
在下面的模拟器中,用同一波突发请求对比两种代价。选择“公共 API”时观察令牌桶如何立即放行一部分;选择“短信供应商”时观察漏桶如何把突发变成队列等待。把队列容量调小,还能看到“平滑”并不等于“永不拒绝”。
算法跑通后,生产事故往往从外围进来。
按用户限流时,登出再登录是否还是同一主体?按手机号限流时,号码是否先规范化国家区号?按租户限流时,子账号是否共用合同额度?同一个接口如果在网关和应用各限一次,两个阈值是否有明确分工?这些都要进入策略配置和日志,不能只藏在字符串拼接里。
还要关注热点。rate:global:sms 会让所有请求写同一个 key,Redis Cluster 也无法把单 key 拆到多个分片。可以先在各网关做很小的本地粗保护,再按租户或区域分摊 Redis 状态,把供应商全局容量交给队列和并发控制。分片会引入近似误差,需要把误差写进容量预算,而不是声称仍是绝对全局上限。
应用实例各自用 Date.now() 时,几百毫秒偏差就足以让边界请求落入不同窗口。固定窗口可以接受这种偏差时,至少使用统一校时并监控;严格滑动窗口可以在 Redis 内取时间。测试不要只跑整秒中间的正常流量,还要覆盖窗口左边界、右边界、时间回拨和长暂停。
文章浏览计数失败时,可以记录指标并暂时放弃这次展示增量;POST /sms/send 若失败开放,可能直接带来资损和供应商封禁。更合适的降级通常是:幂等查询仍可返回已有结果,新发送进入极低的进程内应急额度,无法确认时返回 503,并对调用方给出可重试指引。若业务把可用性放在首位,也可以失败开放,但必须有供应商侧额度、异常告警和熔断兜底。
ioredis 的重试行为也属于语义的一部分。连接在命令执行后、响应返回前断开时,客户端无法凭错误类型断定这次扣令牌是否已经发生。限流判定通常可以接受一次保守拒绝;短信发送本身却必须依赖幂等键,不能因为 Redis 或 HTTP 重试再发一条。
限流器不要只返回布尔值。至少返回 allowed、本次使用量或剩余额度、策略标识和建议重试时间。HTTP 层据此生成 429 与 Retry-After,日志记录命中了用户、手机号还是租户规则。运维指标要同时观察允许数、拒绝数、Redis 延迟、脚本耗时、key 数、ZSet 成员数和降级次数。拒绝率突然归零不一定是好消息,也可能是限流器已经绕过。
假设一个图片导出接口每秒只来 10 次,看起来远低于“每秒 100 次”的规则;但每次导出都要运行 30 秒,稳定下来后可能有 300 个任务同时占着 CPU、数据库连接和对象存储连接。请求速率没有超,工作池仍会被耗尽。反过来,一个只查内存、2 毫秒就结束的接口即使瞬间来了 200 次,也未必形成同样压力。
因此,昂贵接口通常还需要并发限制。请求开始前原子占一个槽位,结束时释放;租约必须有过期时间,防止进程崩溃后槽位永远不归还。超时任务在租约到期后仍可能继续运行,所以需要任务取消、执行超时或带 fencing token 的下游保护,不能把一个计数器当成完整的任务生命周期。
对 POST /sms/send,速率限制保护“单位时间内花多少钱、一个主体能尝试多少次”,并发限制保护“同时压给供应商多少连接”,幂等键保护“同一次业务意图不会重复发送”。三层可以同时存在,命中的 HTTP 状态和日志原因也应该区分。用户超额通常返回 429;整个供应商池已满时,可以返回 503 或把请求放进有时限的队列。
一个阈值不能靠拍脑袋上线。先让规则运行在只记录、不拒绝的观察模式,按用户、租户、接口和响应状态画出请求分布。重点看 P50、P95、P99,而不是只算平均值:平均每分钟 2 次的用户,也可能因为客户端批量恢复连接,在一秒内补发 20 次。阈值既要低于下游安全容量,也要覆盖正常业务的高分位突发。
观察日志还要能回答“如果今天开启拒绝,会挡住谁”。把内部任务、人工运维、合作方批处理和普通终端用户分开,不要拿一条规则覆盖全部流量。正式启用时先小比例放量,保留立即关闭或切回观察模式的开关;告警同时关注异常高拒绝和异常低拒绝。前者可能误伤用户,后者可能是策略未加载、Redis 失败后静默绕过或 key 维度被意外打散。
故障演练至少覆盖五条路径:INCR 成功但响应丢失、INCR 与过期设置之间进程退出、Redis 主从切换、应用时钟偏移、热点租户把单 key 打满。演练结果要落到可执行动作:重试是否复用业务 ID、失败时返回 429 还是 503、是否启用本地额度、谁收到告警、怎样核对供应商账单。没有这些答案,“Redis 限流可用”只是正常路径跑通了。
回到本章的两个对象,最终设计应该能经得住下面的追问。
article_views 的 Redis key 是近实时增量,不是永久账本。一次浏览有稳定 eventId,防重与加一原子执行;落库批次有唯一 batchId,失败可以重试;页面展示数据库累计值与未落库增量;每日对账能发现少计和多计。Redis 故障时允许暂时损失展示精度,但不会伪造结算事实。
POST /sms/send 在调用供应商前执行多层限流。已认证主体优先用用户或 API Key,手机号、租户和可信客户端 IP 各自承担不同防护;同一幂等键不会重复发送;固定窗口只用于能承受边界突刺的粗保护,供应商出流用有界队列或漏桶,公共 API 可用令牌桶保留受控突发。Redis 不可用时走明确的 503 或低额度降级,不把基础设施故障冒充成用户超限。
本章的验证使用 Node.js v25.2.1、ioredis 5.7.0 与 Redis 8.2.2,覆盖了 50 次并发 INCR、固定窗口正常拒绝、固定窗口边界双倍放行,以及滑动窗口中过期记录退出后重新放行。代码结果能证明实现符合这些局部语义;超时重试、故障转移、时钟偏差、热点与数据库幂等仍要作为独立故障场景压测。
对比访问事件、批次和文章累计值。发现差异时生成修正批次,不直接手工改一个无法追踪的总数。