String 计数与 SETNX 家族
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 按字段偏移拆解,比整体反序列化更快。
常见坑 / 最佳实践
- INCR 与 EXPIRE 必须原子化。 用 Lua 脚本或"SET NX EX 先建带 TTL 的 key 再 INCR"的方式,否则进程在两条命令之间挂掉,会留下永不过期的计数 key,日积月累触发内存告警。
- 精度敏感的累加不要用 INCRBYFLOAT。 double 存在表示误差,金额、积分统一换成最小单位的整数 INCR,展示层再做换算。
- SET NX 失败返回 nil 不是报错。 客户端必须显式判断 nil,把 nil 当成功会导致互斥逻辑失效,多个请求同时进入临界区。
- INCR 的 key 一旦被写入非整数值,计数链路会持续报错。 写入方要约定值格式,初始化时用
SET key 0 NX建好初始值。 - 固定窗口限流在窗口边界会放大流量。 严格限流场景改滑动窗口,并且阈值评估要按"两倍窗口"做压测,而不是按单窗口标称值。
小结
- INCR 家族只接受 64 位整数,key 不存在按 0 起步,类型不符或溢出都会报错;INCRBYFLOAT 有浮点精度问题,金额累加走整数。
- 计数必须用 INCR 这类单命令保证原子性,GET + SET 存在丢更新的竞态。
- 新代码用 SET NX EX 代替 SETNX + EXPIRE;GET 选项、GETEX、GETDEL 能减少往返并简化逻辑。
- 计数限流的标准姿势是 Lua 内 INCR 加条件 EXPIRE,并评估固定窗口在边界处的两倍放大问题。
- 点赞
- 收藏
- 关注作者
评论(0)