背景脱敏:一个基于 gin-vue-admin 搭建的海外运营管理后台,Go 服务端,按客户端 IP 做接口限流,计数存 Redis。2026-09-04 全站 API 被拒,提示语里出现了一个不可能的数字。
现象
首页静态资源能打开,所有 /api 请求一律返回:
请求太过频繁, 请 -1ns 秒后尝试
「-1ns 秒后」不是倒计时。等多久都没用,第二天依然如此。
取证
Redis 在 VPC 内,本机不可达,用一个临时 Job 跑 redis-cli 扫限流 key:
GVA_Limit<出口IP> ttl=-1 count=15001 # 阈值 15000
GVA_Limit<另一IP> ttl=1220 count=5 # 正常
同库另一个 IP 的 key 有正常的过期时间和计数,可见不是配置问题。出问题的 key 有两个特征:没有过期时间,计数刚好越过阈值 1。
根因
gin-vue-admin 的限流中间件核心逻辑如下(精简):
func SetLimitWithTime(key string, limit int, expiration time.Duration) error {
count, _ := redis.Exists(ctx, key).Result()
if count == 0 {
pipe := redis.TxPipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, expiration) // 只有这一处设过期
_, err := pipe.Exec(ctx)
return err
}
times, _ := redis.Get(ctx, key).Int()
if times >= limit {
t, _ := redis.PTTL(ctx, key).Result()
return errors.New("请求太过频繁, 请 " + t.String() + " 秒后尝试")
}
return redis.Incr(ctx, key).Err() // 只加计数,不续 TTL
}
三件事叠在一起:
- 过期时间只在 key 首次创建时设一次,此后每次请求只
Incr,从不补救。 - 只要那一次
Expire没生效(pipeline 部分失败、主从切换、被人工清过),key 就永远没有 TTL,计数只增不减。 - 越过
limit之后每次请求都进拒绝分支,连Incr都不再执行,但 key 也不会消失。这个 IP 从此永久锁死,唯一的恢复手段是人工连 Redis 删 key。
而「-1ns」的来历:Redis PTTL 对「key 存在但无过期时间」返回 -1,go-redis 把它原样映射成 time.Duration(-1),也就是 -1 纳秒;Duration.String() 打印出来就是 -1ns,再被拼进「请 X 秒后尝试」的模板。提示语把一个内部哨兵值当成倒计时展示了出来。凑巧的是,正是这个不合常理的数字直接指向了根因:它说的就是「这个 key 没有过期时间」。
这段代码自 2025 年 5 月随框架整体导入后从未改动,缺陷来自上游,一年多才第一次踩到。
修复
最小改动:在拒绝分支里判断 PTTL 是否为 -1,是则补设过期并打一条 Warn。最坏情况该 IP 只被锁一个限流周期,之后自愈。
func repairExpireIfLost(key string, ttl, expiration time.Duration) {
if ttl != -1*time.Nanosecond { // -2 表示 key 不存在,不该补设
return
}
redis.Expire(ctx, key, expiration)
}
-1 与 -2 必须分开:-2 是 key 已不存在,下一次请求会走首次创建分支重新设上 TTL,补设反而会制造一个新问题。判定提纯成一个纯函数,用五个用例钉死(-1ns 补、-2ns 不补、正常剩余不补、0 不补、-1 秒不补),后一条是防止把纳秒与秒的量级搞混。
更彻底的写法不止一种:SET key 1 NX EX <n> 加 INCR 保证「创建与设过期」原子;或每次 INCR 后都 EXPIRE,让 TTL 随写续期;或用一段 Lua 把判断和写入合成一次往返。本次选最小改动,是因为上游代码结构不宜大动,且补设已能把「永久」变成「一个周期」。
教训
- 计数类 key 的过期时间必须随每次写入一起设,或者用原子命令一次到位。「首次创建时设一次」等于把整个系统的可用性押在那一次
Expire上。 - 超限分支也是写路径。它不写计数,但它是唯一能观察到「TTL 丢了」的地方,补救逻辑就该放在这里。
- 提示语不要直接格式化内部值。
Duration.String()拼进用户文案,正常时显示20m0s 秒后,异常时显示-1ns 秒后,两种都不是给人看的。哨兵值要在展示前被翻译或拦截。 - 静态可开、API 全挂,先看限流。这是最便宜的排查入口,一条
redis-cli --scan --pattern 'GVA_Limit*'配ttl就能定性。 - 从上游整体导入的中间件也要审。一年多没改动不代表它是对的,只是它的失败条件还没出现过。