直接用INCR无法自动设过期,必须配合EXPIRE,但二者非原子执行易导致key永不过期;最可靠方案是用Lua脚本原子封装INCR与EXPIRE逻辑。

用 INCR 和 EXPIRE 组合实现带过期的计数器
直接用 INCR 无法自动设置过期时间,必须配合 EXPIRE 才行。但要注意竞态:如果 INCR 后服务崩溃、网络中断,EXPIRE 没执行,这个 key 就永远不过期了。
更稳妥的做法是先用 SET 命令一次性完成“设值 + 过期”,再用 INCR。但 SET 不能对不存在的 key 自增,所以得拆成两步:首次初始化时用 SET key 0 EX 60(假设 60 秒过期),之后统一走 INCR key。
Go 里用 redis.UniversalClient(如 redis.NewClient)时,推荐封装一个原子初始化函数:
func initCounter(ctx context.Context, client redis.Cmdable, key string, expire time.Duration) error {
_, err := client.SetNX(ctx, key, "0", expire).Result()
if err != nil {
return err
}
return nil
}
注意:SetNX 成功才写入,避免覆盖已有值;失败说明 key 已存在,可直接 INCR。
立即学习“go语言免费学习笔记(深入)”;
用 INCRBY 实现批量加减,避免多次往返
如果业务需要一次加多个(比如用户一次提交 5 条记录),别循环调 5 次 INCR,用 INCRBY key 5 一条命令搞定。性能差一个数量级,尤其在高延迟网络下。
常见错误是把数值拼进字符串再传给 INCR,比如 client.Incr(ctx, "counter:"+userID) 写成 client.Incr(ctx, "counter:"+string(userID)) —— 如果 userID 是 int 类型,string(123) 得到的是 ASCII 字符而非数字字符串,会报 ERR value is not an integer or out of range。
正确做法是用 strconv.Itoa 或 fmt.Sprintf:
key := "counter:" + strconv.Itoa(userID)client.IncrBy(ctx, key, int64(delta))
用 Lua 脚本保证“自增 + 过期”原子性
上面说的 SetNX + INCR 仍是两轮 Redis 请求,在极端并发下仍可能漏设过期时间(比如两个协程同时发现 key 不存在,都执行 SetNX,只有一个成功,另一个直接 INCR 却没设过期)。真正原子的方式是 Lua 脚本:
const incrWithExpire = `
if redis.call("EXISTS", KEYS[1]) == 0 then
redis.call("SET", KEYS[1], ARGV[1])
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
return redis.call("INCR", KEYS[1])
`
// 调用:
result, err := client.Eval(ctx, incrWithExpire, []string{key}, "0", "60").Int64()
脚本逻辑:key 不存在就设初值并设过期,然后无条件 INCR。Redis 保证整个脚本执行期间不被打断。
注意:EXPIRE 的第二个参数单位是秒,不是毫秒;Lua 里 redis.call("INCR", ...) 返回的是整数,Eval 的 .Int64() 才能正确解析。
过期时间选 EXPIRE 还是 PEXPIRE?
如果需要毫秒级精度控制(比如某些限流场景要求 300ms 后过期),必须用 PEXPIRE,对应 Go 客户端的 client.PExpire(ctx, key, 300*time.Millisecond)。但绝大多数计数器场景用秒级足够,且 EXPIRE 兼容性更好(老版本 Redis 不支持 PEXPIRE)。
另一个坑:不要在每次 INCR 后都重设过期时间(比如 EXPIRE key 60),这会导致“只要不断访问,key 就永不过期”,违背了“窗口计数”的本意。过期时间应在初始化或重置时设定,而不是每次递增都刷新。
如果你的计数器要支持滑动窗口(比如“最近 60 秒内请求量”),单靠 Redis 原生命令很难精确实现,得结合有序集合或时间分片,那已经是另一个问题了。


















