Redis 集群限流不生效?读写 key 落点算法不一致,8 次请求全 200 一次没拦
日期:2026-09-22 16:00 | 第 76 题 关键词:Redis 限流不生效、限流形同虚设、Redis Cluster 分片、CRC16 % 16384、客户端分片、 读写 key 落点不一致、RedisCluster 落点算法、Flask 限流 429 不生效、 redis-py NoScriptError、Lua INCR 原子限流、gunicorn 多 worker 计数不一致、 docker compose 复现限流问题、滑动窗口/固定窗口限流 技术栈:Flask 3.0 + redis-py 5.0 + Redis 7 + docker compose(3 redis 分片 / 3 worker / 1 网关)
一、问题背景
一个按会员 ID 做「60 秒窗口内计数」的限流网关服务:
free档 60 秒最多 3 次,basic10 次,pro50 次;- 线上形态是「1 个网关 → 轮询到 3 个 gunicorn worker → 3 个 redis 分片(自称 Redis Cluster)」;
- 网关轮询分发,不保证同一用户落同一 worker。
线上投诉:「同一个账号 1 秒内刷了几百次接口,一次都没被拦。」
但所有能看的指标都是正常态:
/rate全程 HTTP 200,/healthz200,没有任何 5xx;- 3 个 worker 的
/stats都打cluster-mode enabled、version: 1.4.0-era; - redis
INFO没有报错、没有MOVED、没有CROSSSLOT,连接数正常。
服务看起来一切正常,限流就是不起作用。 这是最典型的「静默失效」—— 没有任何报错,只有业务语义错。
复现输出(问题态):
member_id = 10000000201 tier = free limit = 3 / 60s
# worker HTTP status used read@ write@
0 app_1 200 OK 1 2 1
1 app_2 200 OK 1 2 1
...(8 次全是 200)
redis_1 type=list value_or_len=3
HTTP 200 = 8 HTTP 429 = 0 (limit=3,期望 429 >= 5)
RESULT: LIMITED-NOT-ENFORCED ← 限流形同虚设
眼睛要抓的 3 个反常细节:
used永远是1—— 每次读到的都是「还没开始计数」;read@与write@不是同一个号(2 和 1)—— 读一个地方、写另一个地方;- 计数落成
list类型,不是字符串计数器。
二、复现步骤
git clone <repo> && cd <repo>
git checkout 2026-09-22-1600
cd scenario/2026-09-22-1600
docker compose up -d --build # 3 redis + 3 app + 1 网关
./run.sh repro # 看现象:全 200,无限流
./run.sh health_report # 摊开看分片/埋点/网关
./run.sh verify # 判定:PASS=3 FAIL=10
直接用一条命令核对「key → 实例」落点。
注意:不要 import naive_key_hash —— 它在修复后已被删除,
那样写会直接报 ImportError: cannot import name 'naive_key_hash' from 'routes'。
下面这条命令在问题态和修复态都能跑,用的是读写两条路径各自的真实入口:
读走 crc16_slot(),写走 RedisCluster.shard_of_naive()(修复后它已收敛到同一套算法)。
docker compose exec -T edge python -c "
import sys; sys.path.insert(0,'/srv/app')
from routes import crc16_slot, RedisCluster
r = RedisCluster(['redis://redis_0:6379','redis://redis_1:6379','redis://redis_2:6379'], {})
for m in ['10000000201','10000000202','10000000204','10000000209']:
k = 'rate:' + m
print(k, '读%3=' + str(crc16_slot(k) % 3), '写%3=' + str(r.shard_of_naive(k)))
"
问题态输出(读、写落在不同实例 —— 这就是 bug):
rate:10000000201 读%3=2 写%3=1 ← 分叉
rate:10000000202 读%3=0 写%3=2
rate:10000000204 读%3=1 写%3=1
rate:10000000209 读%3=2 写%3=0
修复态输出(同一 key 的读写落点全部相等 —— 这就是修好了):
rate:10000000201 读%3=2 写%3=2
rate:10000000202 读%3=0 写%3=0
rate:10000000204 读%3=1 写%3=1
rate:10000000209 读%3=2 写%3=2
若想直接看问题态的老函数,先回退代码再执行:
git checkout cc6fc7a -- scenario/2026-09-22-1600/app/routes.py, 此时from routes import naive_key_hash才可用。
三、根本原因
根因 1(核心):同一个 key,读和写用了两套不同的落点算法
代码里有两个「key → redis 实例」的函数,读路径用 A,写路径用 B:
| 函数 | 算法 | 用在哪 |
|---|---|---|
shard_of_cluster() | CRC16(key) % 16384,再 % 3 | 读路径 |
shard_of_naive() | 逐字节 ASCII 求和,再 % 3 | 写路径 |
于是每个请求变成:
- 读:按 CRC16 落点去
redis_2查rate:10000000201→ 不存在 →used = 0; - 判定:
0 >= 3不成立 → 放行(永远放行); - 写:按 naive 落点把计数写到
redis_1。
每次请求都从 0 开始。计数器在另一个实例上自己涨自己,判定路径永远看不到它。used 恒为 1、read@≠write@ 就是这么来的。
一句话记住:「读一个分片、写另一个分片」= 限流永远不触发,且没有任何报错。
根因 2:读 STRING、写 LIST,数据类型对不上
写路径用 lpush + ltrim 维护一张 LIST 当「剩余配额表」,
读路径用 get 读 STRING。即使落点修对了,两边类型也对不上,照样读不出计数。
根因 3:/stats 的 counter 是进程内局部变量
state = {"counter": 0} 是每个 worker 一份,/stats 报的是「本进程处理了几个请求」。
3 个 worker 各报各的(12/12/13),汇总口径不自洽 ——
正是这个「看起来正常」的埋点,掩盖了真实的限流失效。
四、解决办法
只改 app/routes.py 一个文件。三步对应三个根因。
① 落点算法收敛成唯一一份
def shard_of(self, key: str) -> int:
"""读、写路径共用的落点算法:CRC16(key) % 16384 再 % 实例数。"""
return crc16_slot(key) % self.n_pools
def shard_of_cluster(self, key): # 兼容旧调用点,指向同一份算法
return self.shard_of(key)
def shard_of_naive(self, key): # 老算法已删除,防止再分叉
return self.shard_of(key)
关键原则:「key → 分片」只能有一份实现,读和写都必须调它。
顺手补上 Redis Cluster 的 {hash tag} 语义(CRC16 只算 {} 内的内容),
保证同一业务实体的多个 key 能落同槽:
def crc16_slot(key: str) -> int:
if "{" in key:
start = key.index("{") + 1
end = key.find("}", start)
if end != -1 and end > start:
key = key[start:end]
return crc16(key.encode()) % 16384
② 计数器统一 STRING + 原子 Lua 脚本
-- 先自增、再判定、超限回补
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if n > tonumber(ARGV[1]) then
redis.call('DECR', KEYS[1])
return {0, n}
end
return {1, n}
调用:
key = f"rate:{member_id}"
idx = r.shard_of(key) # 读写同一落点
conn = r.get_client(idx)
admitted, used = admit(keys=[key], args=[limit, window], client=conn)
为什么不用「先 GET 再 INCR」:在 3 个 worker 之间不是原子的。
并发下会出现「两个请求都读到 2、都判定放行」,实际放行数超过配额。
Lua 脚本让 INCR + 判定 + DECR 成为一个原子操作;
超限时 DECR 回补,使存储值恰好等于被放行的次数(total == ok200 == limit)。
③ 全局计数放 redis
stats_conn = _new_redis(settings["redis_urls"][0])
STATS_KEY = "stats:rate_requests"
stats_conn.incr(STATS_KEY) # 固定 key,不参与分片,3 个 worker 读同一个值
五、验证
for i in 0 1 2; do docker compose exec -T redis_$i redis-cli FLUSHALL; done
./run.sh verify
10000000201: HTTP 200=3 429=5 total=3 kinds=['none','none','string']
10000000202: HTTP 200=3 429=5 total=3 kinds=['string','none','none']
basic(12次) : 2 个 429
single worker: 5 个 429
worker 埋点 : [36, 36, 36]
PASS=13 FAIL=0
RESULT: OK
read@ == write@、key 只有一种 STRING 类型、3 个 worker 计数一致。
并发下不超发(verify 是顺序的,测不出竞态)
docker compose exec -T edge python -c "
import sys, concurrent.futures as cf
sys.path.insert(0,'/srv/app/ops')
from _common import send, wait_ready
wait_ready()
with cf.ThreadPoolExecutor(max_workers=40) as ex:
codes=list(ex.map(lambda _: send('10000000999','free')[0].status_code, range(40)))
print('200 =', codes.count(200), ' 429 =', codes.count(429))
"
200 = 3 429 = 37 ← 配额 3,40 线程并发下恰好放行 3 个
六、经验与避坑
排查思路
- 「没有 429」不等于「服务正常」。限流这类业务语义失效可以全程 200 且零报错, 不能只看 HTTP 状态码和 5xx 告警。
- 先核对「读的 key」和「写的 key」是否在同一个地方。
在多分片/多节点架构里,返回体里同时打印
read@与write@是极高性价比的埋点。 - 警惕「看起来正常」的自报指标。
cluster-mode enabled只是代码里的一句字符串,counter是进程内变量 —— 埋点自己算错,比没有埋点更危险。 - 直接看存储层:key 落在哪个实例、是什么数据类型、值是多少。
type=list而代码读get,一眼就能看出类型不匹配。
通用的坑
- 客户端分片最大的陷阱就是「落点算法复制粘贴出两份」。 一旦读写分叉,故障模式是「静默不生效」而非报错。 修法不是「把两个函数改成一样」,而是删掉一份,只留一个入口。
READ+INCR不是原子操作,多 worker 下必然超发。 计数型限流要用INCR判定,并用 Lua 保证原子性。 若业务要求「存储值 = 放行数」,就INCR后超限DECR回补。redis-py的Script手动evalsha会踩NoScriptError:
redis.exceptions.NoScriptError: No matching script. Please use EVAL.
# ❌ 手动 evalsha:脚本未加载就 500
conn.evalsha(script.sha, 1, key, limit, window)
# ✅ 调 Script 对象:内部自动 EVAL / EVALSHA 回退
script(keys=[key], args=[limit, window], client=conn)
- 改完代码要
--build,不是restart。docker compose restart不会重新COPY源码进镜像,改动不生效会让人怀疑人生。 - 判定/测试前先
FLUSHALL。窗口计数器有 60s TTL, 上一轮的残留会串到下一轮,used不是从 1 开始,结论就跑偏了。 - 顺序测试测不出竞态。修完限流务必补一个并发用例
(
ThreadPoolExecutor打同一用户),否则「原子性」只是纸面结论。
可复现命令速查
cd scenario/2026-09-22-1600
docker compose up -d --build
./run.sh repro # 问题态 RESULT: LIMITED-NOT-ENFORCED
./run.sh verify # 问题态 PASS=3 FAIL=10
# --- 应用修复后 ---
docker compose up -d --build app_0 app_1 app_2 edge
for i in 0 1 2; do docker compose exec -T redis_$i redis-cli FLUSHALL; done
./run.sh verify # PASS=13 FAIL=0
docker compose down -v
完整操作过程见同分支 scenario/2026-09-22-1600/SOLUTION.md。
