接口被刷、任务重跑、通知重复——三个问题的同一根源
运营个人站的过程中,有一类问题特别烦人:短信验证码接口被人拿来当免费短信网关刷,成本噌噌往上涨;一个定时任务因为网络抖动重试了一次,结果订单被扣了两次款、或者文章被重复采集入库;支付回调因为对方重发,同一笔订单生成了两条记录。这三件事看起来毫不相干,其实指向同一个工程问题——系统在面对超量请求和重试时,缺少限流与幂等这两道防线。
限流(rate limiting)解决的是"请求来得太密",幂等(idempotency)解决的是"同一个操作被执行了多次"。个人站长往往觉得这些是大厂才需要的架构话题,但事实恰恰相反:小站资源有限,一次恶意刷接口或一次任务重跑,造成的相对损失比大厂更严重。这篇就讲清楚怎么用最朴素的工具——Nginx 的 limit_req、应用层的令牌桶、以及幂等键设计——给个人站补上这两道防线,并配上真实事故复盘。
限流第一层:Nginx 的漏桶限流
绝大多数个人站前面都有 Nginx,所以限流最省事的地方就在 Nginx 层。Nginx 的 limit_req 模块实现的是"漏桶算法":请求像水一样注入桶里,桶以固定速率往外漏(放行),桶满了就丢弃多余的水(返回 503)。
# 在 http 块里定义限流区
# key 是限流维度, zone 名 + 内存大小 + 速率
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
limit_req_zone $http_x_api_key zone=perkey:10m rate=20r/s;
server {
location /api/send_code {
limit_req zone=perip burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
}
location /api/ {
limit_req zone=perkey burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
}
}几个关键点必须讲透。rate=5r/s 是平均放行速率;burst=3 是允许的突发队列长度——也就是瞬时超过速率时,最多可以多积压 3 个请求等待处理;nodelay 表示这 3 个突发请求立即放行而不是排队慢慢发,这对接口体验很重要,否则用户会感觉"卡了一下才返回"。limit_req_status 429 把默认的 503 改成 429(Too Many Requests),语义更准确,前端也更好识别。
限流维度(limit_req_zone 的第一个参数)决定了拦谁。$binary_remote_addr 是按客户端 IP 限流,最常用但有个大坑:如果站点前面套了 CDN,$binary_remote_addr 拿到的是 CDN 节点 IP,会导致所有用户共用一个桶,要么误伤要么失效。这时必须用 CDN 回源时携带的真实 IP 头,配合 real_ip 模块还原:
# 信任 Cloudflare 的节点, 从 CF-Connecting-IP 还原真实 IP
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;还原之后 $binary_remote_addr 才是访客真实 IP。这个配置漏掉的话,限流要么形同虚设(按一个 IP 限不住),要么把全站用户当一个人限死(CDN 节点 IP 被限),是自建站限流最常见的翻车点。
限流第二层:应用层令牌桶
Nginx 层限流是粗粒度的"按 IP / 按 key",它管不了业务维度。比如"每个用户每天最多发 10 条验证码""每篇文章的评论接口每分钟最多 20 次",这类需要结合用户身份的限流,就得放到应用层。令牌桶算法比漏桶更适合这种场景:桶里预存 N 个令牌,每个请求消耗一个,令牌以固定速率补充,没令牌就拒绝,同时能允许一定程度的突发。
用 Redis 实现一个简单的分布式令牌桶(伪代码,用 Lua 保证原子性):
-- KEYS[1]=桶key ARGV: rate 补充速率/秒, capacity 桶容量, now 时间戳
local rate = tonumber(ARGV[1])
local cap = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
local delta = math.max(0, now - ts) * rate
tokens = math.min(cap, tokens + delta)
if tokens < 1 then
return 0 -- 无令牌, 拒绝
end
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 1)
return 1为什么必须用 Lua?因为"读令牌 - 判断 - 写回"这三步如果分开执行,高并发下多个请求会同时读到"还有令牌",出现超发。Redis 单线程执行 Lua 脚本,天然保证了这段逻辑的原子性。这个模式可以套用到任何业务限流上,key 换成 rate:uid:12345:sendcode 就是按用户限流,换成 rate:ip:1.2.3.4:comment 就是按 IP 限流。
应用层限流的另一个价值是可以给出比 429 更友好的响应:告诉用户"操作太频繁,请 60 秒后再试",而不是冷冰冰地拒绝。这在站长自己的业务里,直接影响用户体验和投诉率。
被刷接口的真实复盘
我运营的一个小站曾经开放了一个"文章 RSS 订阅邮件推送"接口,用户填邮箱即可订阅。上线两周后的一天早上,我收到邮件服务商的通知:账户余额在一夜之间被消耗了四千多元,原因是那个订阅接口被人用脚本以每秒几十次的速度灌入了上万个不同的邮箱地址,每一条都触发了订阅确认邮件。
复盘时发现的问题很典型。第一,接口完全没有限流,同一 IP 可以无限次调用;第二,接口没有验证码或邮箱真实性校验,填什么就发什么;第三,接口不幂等,同一个邮箱重复提交会重复发信。三个问题里,任何一个存在,损失都会被放大。修复措施也简单:给接口加了基于 IP 的 limit_req zone=perip burst=5;应用层加了"同一邮箱 24 小时内只发一次确认信"的幂等控制;再对订阅请求做发信前的邮箱域名白名单和频率审计。改造之后,同样的攻击再来,损失的邮件量从上万封降到个位数——因为绝大多数请求在限流层就被挡掉了。
这次事故的核心教训是:任何一个会触发"花钱"或"产生副作用"的接口,都必须假设它会被恶意刷。限流不是可选的优化,而是这类接口的必备属性。
幂等:让重试不再产生重复
限流挡住了"请求太密",但挡不住"同一个请求被合法地发了两次"。重试无处不在:用户手抖点了两次提交按钮、前端超时后自动重试、消息队列投递失败后重投、第三方支付回调因未收到我方 ack 而重发。如果这些操作不是幂等的,就会产生重复订单、重复入库、重复扣款。
幂等的核心思想是:给每一次"意图"分配一个唯一标识(幂等键),服务端记录这个键的处理结果,同一个键重复到达时直接返回第一次的结果,不再执行副作用。这个键通常由客户端生成并携带,比如一个 UUID:
POST /api/order
Header: Idempotency-Key: 7f3a9c21-4b8e-4d0a-9c7f-1a2b3c4d5e6f
Body: { "product_id": 42, "qty": 1 }服务端处理逻辑:先用这个 key 去 Redis 或数据库查有没有处理记录。如果有,直接返回记录里的结果;如果没有,加锁(用 SET key value NX EX 60 抢占),执行真正的业务逻辑,把结果写回。要特别注意并发情形——两个同样的请求几乎同时到达时,必须靠原子操作确保只有一个能"抢到"执行权,另一个要么等,要么直接返回"处理中"。
-- 抢锁: 抢到返回1, 没抢到返回0
local ok = redis.call('SET', KEYS[1], 'processing', 'NX', 'EX', 60)
if ok then return 1 else return 0 end幂等键的有效期要有讲究。太短(比如 10 秒),用户慢一点重试就会产生重复;太长(比如永久),键会无限堆积占用存储,而且同一用户可能真的需要多次相同操作(比如连续下两单同样的商品)。实践上,支付类操作一般保留 24 小时到几天,普通表单提交保留几分钟到一小时即可,并且要给幂等键设计"业务范围"(比如 order:{uid}:{idempotency_key}),避免不同用户用了相同 key 而互相串扰。
重试必须配退避,且只重试"安全"的操作
讲幂等就绕不开重试。很多站点的重试写得很粗暴:失败立刻重试,重试间隔固定,重试次数不限——这在对方服务出问题时,会变成一次正反馈的雪崩:你重试,对方更慢,你更重试,对方彻底挂掉。正确的做法是指数退避加抖动:
第 1 次重试等待: 1 秒 + 随机 0~0.5 秒
第 2 次重试等待: 2 秒 + 随机 0~1 秒
第 3 次重试等待: 4 秒 + 随机 0~2 秒
第 4 次重试等待: 8 秒 + 随机 0~4 秒
最多重试 5 次, 总等待上限约 1 分钟指数退避让重试间隔逐渐拉长,给下游恢复的时间;加随机抖动是为了避免大量客户端"整齐地"在同一时刻一起重试(惊群效应)。同时要设定"可重试窗口"——超过一定时间就放弃重试,转入人工或死信队列处理,而不是无限重试。
更关键的一条原则是:只重试幂等的操作。GET 查询天然幂等,随便重试;DELETE 通常幂等(删了再删还是"已删除");PUT 通常幂等(覆盖写);而 POST 创建资源默认不幂等,必须配合幂等键才能安全重试。如果不加区分地对所有失败请求盲目重试,等于亲手制造重复数据。这也解释了为什么"限流"和"幂等"要一起讲:限流保护下游不被压垮,幂等保证重试不产生副作用,缺一不可。
落地清单
把整套方案收敛成一份个人站可执行的清单。
第一,在 Nginx 层给所有 API 加上 limit_req,按 IP 或按 key 分维度;套了 CDN 的一定先配好 real_ip 还原,否则限流无效或误伤。同时用 limit_conn 限制单 IP 并发连接数,防止慢速连接耗尽工作进程。
第二,凡是触发花钱或产生副作用的接口(短信、发信、支付、下单),应用层必须加业务维度限流,用 Redis + Lua 令牌桶实现原子操作,并返回友好的提示。
第三,所有创建类接口都要支持幂等键,键由客户端生成,服务端原子抢占加结果回写,键的有效期按业务风险设定。
第四,所有跨网络调用(调用第三方 API、发消息、写远程资源)都要配指数退避加抖动的重试,并明确哪些操作可重试;不可重试的失败要进死信队列而不是丢弃。
第五,给限流和幂等加上监控:统计 429 的占比(突然飙升说明被刷或正常用户受影响)、幂等命中次数(说明有多少重复请求被正确挡下)、重试次数分布。这些指标比访问量更能反映系统的健康状况。
小结
限流和幂等,一个管"入口的流量",一个管"操作的效果",是个人站从"能跑"到"跑得稳"必须补的两课。Nginx 的 limit_req 用漏桶挡住粗粒度洪峰,应用层的 Redis 令牌桶处理业务维度限流,幂等键配合原子操作消除重复,指数退避加抖动让重试变得温和。这些东西不需要复杂架构,一台普通 VPS 加一个 Redis 就能落地。花半天时间把它们加上,能省下的可能是某个凌晨被刷爆的短信费,或者一次数据重复后漫长的对账——这是我用四千块钱的教训换来的建议。