一个限流 key 没有 TTL

整个后台被永久锁死

Posted by walikrence on September 8, 2026

背景脱敏:一个基于 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
}

三件事叠在一起:

  1. 过期时间只在 key 首次创建时设一次,此后每次请求只 Incr,从不补救。
  2. 只要那一次 Expire 没生效(pipeline 部分失败、主从切换、被人工清过),key 就永远没有 TTL,计数只增不减。
  3. 越过 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 就能定性。
  • 从上游整体导入的中间件也要审。一年多没改动不代表它是对的,只是它的失败条件还没出现过。