String 计数与 SETNX 家族

举报
yd_94907100 发表于 2026/11/05 00:38:29 2026/11/05
【摘要】 拆解 INCR 家族边界行为与 SET NX 占位语义,给出计数限流的原子写法

String 是 Redis 中使用频率最高的类型,但最容易踩坑的也是它:计数命令有一堆隐含的类型约束,SETNX 与 SET NX 看似等价实则行为不同,计数加过期两条命令之间还藏着原子性缺口。本文以 Redis 7 为基准,把这些边界行为讲透,并给出计数器与限流器可以直接落地的写法。

一、INCR 家族的边界行为

INCR、INCRBY、DECR、DECRBY 只能操作"能解析为 64 位有符号整数的字符串",对值和结果都有硬约束:

127.0.0.1:6379> SET pv 100
OK
127.0.0.1:6379> INCR pv
(integer) 101
127.0.0.1:6379> INCRBY pv 99
(integer) 200
127.0.0.1:6379> SET name redis
OK
127.0.0.1:6379> INCR name
(error) ERR value is not an integer or out of range

三条规则值得记住:key 不存在时按 0 起步;值不是合法整数直接报错,key 保留原值;结果超过 9223372036854775807 时报 increment or decrement would overflow。如果操作的不是 String 而是其他类型,报的则是 WRONGTYPE。做全局 ID 时单 key 逼近 2^63 - 1 就该切号段,不要等线上溢出报错才处理。

INCRBYFLOAT 处理浮点累加,key 不存在时按 0 起步,结果总是存成字符串并去掉多余的 0:

127.0.0.1:6379> INCRBYFLOAT price 0.1
"100.1"

它内部按 17 位有效数字输出,仍存在浮点表示误差,金额、积分这类精度敏感的累加应该以最小单位(分)转成整数走 INCR。

二、为什么计数必须用 INCR 而不是 GET + SET

"先 GET 读出来,加 1 再 SET 回去"是典型的读改写三步操作,两个请求可能同时读到 100,各自加 1 后都写回 101,丢掉一次计数。INCR 是单条命令,Redis 命令执行在主线程串行完成,天然原子,返回值就是自增后的新值,也不需要再 GET 一次确认。

需要一次性更新多个计数器时,用 pipeline 把多条 INCR 打包发送,减少网络往返;但注意 pipeline 只省往返,不提供事务语义,跨多个计数器的一致性要求高时改用 Lua 或 MULTI/EXEC。

全局 ID 生成是 INCR 的另一块主场。单 key INCR 有两个隐患:一是所有请求压在同一个 key 上形成热点,二是无限自增迟早触顶。工程上常用两种改良:key 按天分片,如 id:order:20261025,天然分散热点且自带业务日期;或号段模式,一次 INCRBY id:segment 1000 申请一千个号在应用内存里分发,Redis 的压力降为千分之一,号段用尽再取下一段,还能顺带实现双 buffer 预取,避免取号瞬间的延迟毛刺。

三、SET NX 家族与占位语义

围绕"不存在才写"这个语义,有一组命令容易混淆:

  • SETNX key value:不存在才写,存在返回 0。它没有过期参数,过期要再发一条 EXPIRE,两条命令之间进程崩溃就会留下永不过期的 key,新代码一律用 SET key value NX EX seconds 替代。
  • SET 的 NX/XX/GET 选项:NX 只在 key 不存在时设置,XX 只在已存在时更新,失败返回 nil 而不是报错;GET 选项(Redis 6.2+)在设置的同时返回旧值,实现"替换并留底"不用两次往返。
  • MSETNX:多 key 版本,所有 key 都不存在才写入,要么全部成功要么全部不动,适合一组关联配置的原子初始化。
  • GETEX/GETDEL(Redis 6.2+):读取的同时续期或删除,验证码"读后即焚"、会话"读时续期"都能少一次往返。
127.0.0.1:6379> SET lock:order:1001 1 NX EX 10
OK
127.0.0.1:6379> SET lock:order:1001 1 NX EX 10
(nil)

这是分布式锁的雏形,完整细节见同目录的分布式锁一文,这里只强调一点:NX 抢占与过期时间必须在同一条 SET 命令里完成。

四、计数限流的标准写法

常见的错误写法是先 INCR 再 EXPIRE:

# 错误示范:两条命令之间进程崩溃,key 永不过期
INCR rl:uid:1001
EXPIRE rl:uid:1001 60

正确做法是把判断塞进 Lua 脚本,只在计数从 0 变 1(即窗口第一次请求)时设置过期:

-- KEYS[1] 为计数 key,ARGV[1] 为窗口秒数
local v = redis.call('INCR', KEYS[1])
if v == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return v

Spring Data Redis 中的调用方式:

DefaultRedisScript<Long> script = new DefaultRedisScript<>("""
        local v = redis.call('INCR', KEYS[1])
        if v == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return v
        """, Long.class);
Long count = redis.execute(script, List.of("rl:uid:1001"), "60");
if (count != null && count > 100) {
    throw new RateLimitException();
}

这种固定窗口限流实现简单,但窗口交界处最多放过两倍阈值:上一个窗口末尾 100 次、下一个窗口开头又有 100 次。对平滑度要求高的接口改用滑动窗口(ZSet 记录时间戳)或多窗口加权,并配合本地缓存前置拦截,减少对 Redis 的依赖。

滑动窗口的轻量实现是用 ZSet:每次请求 ZADD rl:z uid:ts ts 记录时间戳,ZREMRANGEBYSCORE 清掉窗口外的旧记录,ZCOUNT 统计窗口内次数并与阈值比较。它精确但每次请求要写 ZSet,成本比 INCR 高一个量级,通常只用于关键接口;多数业务用"当前窗口计数 + 上一窗口按剩余时间加权"的近似算法就能满足要求,成本几乎与纯 INCR 相同。

五、切片与二进制安全

String 是二进制安全的,可以原样存储序列化字节、图片缩略图甚至带 \0 的内容,SDS 头部记录长度,所以 STRLEN 是 O(1)。配合切片命令还能做轻量级的随机读写:GETRANGE 按字节偏移取子串,SETRANGE 从指定偏移覆写。SETRANGE 覆写超出原长度时中间补零字节,可以用它做定长记录的原位更新;但频繁随机写会把 embstr 打回 raw 并加剧碎片,热点小对象不要这样用。读多写少的固定格式报文,用 GETRANGE 按字段偏移拆解,比整体反序列化更快。

常见坑 / 最佳实践

  1. INCR 与 EXPIRE 必须原子化。 用 Lua 脚本或"SET NX EX 先建带 TTL 的 key 再 INCR"的方式,否则进程在两条命令之间挂掉,会留下永不过期的计数 key,日积月累触发内存告警。
  2. 精度敏感的累加不要用 INCRBYFLOAT。 double 存在表示误差,金额、积分统一换成最小单位的整数 INCR,展示层再做换算。
  3. SET NX 失败返回 nil 不是报错。 客户端必须显式判断 nil,把 nil 当成功会导致互斥逻辑失效,多个请求同时进入临界区。
  4. INCR 的 key 一旦被写入非整数值,计数链路会持续报错。 写入方要约定值格式,初始化时用 SET key 0 NX 建好初始值。
  5. 固定窗口限流在窗口边界会放大流量。 严格限流场景改滑动窗口,并且阈值评估要按"两倍窗口"做压测,而不是按单窗口标称值。

小结

  • INCR 家族只接受 64 位整数,key 不存在按 0 起步,类型不符或溢出都会报错;INCRBYFLOAT 有浮点精度问题,金额累加走整数。
  • 计数必须用 INCR 这类单命令保证原子性,GET + SET 存在丢更新的竞态。
  • 新代码用 SET NX EX 代替 SETNX + EXPIRE;GET 选项、GETEX、GETDEL 能减少往返并简化逻辑。
  • 计数限流的标准姿势是 Lua 内 INCR 加条件 EXPIRE,并评估固定窗口在边界处的两倍放大问题。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。