4. 计数业务基础实现
在排行榜与计数业务中,最基础也最常用的能力,就是围绕一个整数做“加一、加 N、减一、减 N”。Redis 的 String 类型提供了 INCR、INCRBY、DECR、DECRBY 四个命令,它们把“读取当前值—计算新值—写回”这三步合并成一次服务端原子操作,天然适合文章阅读量、点赞数、库存扣减这类高频并发场景。
四个命令的语义与边界
先明确每个命令的行为,这是后续并发安全讨论的基础:
INCR key:把 key 的整数值加 1,返回加完之后的新值。INCRBY key increment:加指定整数 increment,返回新值。DECR key:把 key 的整数值减 1,返回减完之后的新值。DECRBY key decrement:减指定整数 decrement,返回新值。
四个命令有一个共同的关键特性:如果 key 不存在,会先按 0 处理,再执行加减。也就是说,对一个从未写入过的 article:1001:views 直接执行 INCR,结果是 1,而不是报错。这一点直接决定了“初始值问题”的处理方式——大多数计数场景不需要预先初始化,直接 INCR 即可。
但有两个边界必须记住。第一,值必须是能解析为 64 位有符号整数的字符串,如果 key 存的是 "abc" 或 "3.5",执行 INCR 会返回 ERR value is not an integer or out of range。第二,64 位有符号整数的范围是 -9223372036854775808 到 9223372036854775807,超出会报同样的错误。库存扣减如果写成负数扣减,或者阅读量被刷到接近上限,都会触发这个错误,需要在业务层做兜底。
并发安全:为什么 INCR 比 GET+SET 可靠
假设不用 INCR,而是用应用层代码实现“阅读量 +1”:先 GET 拿到 100,在内存里加 1 得到 101,再 SET 回去。两个请求同时到达时,可能都读到 100,都写回 101,最终只增加了 1 次,这就是典型的丢失更新。
Redis 是单线程执行命令的(指命令处理主线程),INCR 这类命令在执行期间不会被其他命令插入,因此“读—算—写”是一个不可分割的整体。无论多少客户端并发 INCR 同一个 key,每一次都会真实生效,最终值等于成功执行次数。这就是原子计数的核心价值。
需要区分的是:单条命令原子 ≠ 多条命令原子。如果业务是“先判断库存是否大于 0,再扣减”,用 GET 判断 + DECR 扣减,两步之间仍可能被其他请求插入,导致超卖。这种“判断 + 扣减”的组合,要么用 Lua 脚本,要么用 DECRBY 后检查返回值是否为负再补偿。本节先聚焦单命令计数,组合原子性在后续章节展开。
典型场景一:文章阅读量与点赞数
阅读量是最简单的计数:每打开一次文章详情页,执行一次 INCR article:{id}:views。点赞数则要区分“点赞”和“取消点赞”,分别对应 INCR 和 DECR。但要注意,点赞数不能只靠 DECR 兜底,因为用户可能重复取消,导致计数被减成负数。更稳妥的做法是用 Set 记录点赞用户,用 SADD 的返回值判断是否首次点赞,再决定是否 INCR;取消时用 SREM 的返回值判断是否真的移除,再决定是否 DECR。这样计数与明细集合保持一致。
# 阅读量 +1
INCR article:1001:views
# 返回 (integer) 1
# 点赞:先判断是否首次
SADD article:1001:liked_users 88231
# 返回 (integer) 1 表示首次点赞
INCR article:1001:likes
# 返回 (integer) 1
# 取消点赞
SREM article:1001:liked_users 88231
# 返回 (integer) 1 表示确实移除了
DECR article:1001:likes
# 返回 (integer) 0
如果业务允许“点赞数只增不减”的简化模型,也可以直接 INCR,但要在产品层面明确取消点赞不扣减,避免出现负数。选择哪种模型,取决于业务对“点赞数”的定义,而不是技术实现方便。
典型场景二:库存扣减
库存扣减用 DECRBY stock:{sku} n,一次扣减 n 件。它比循环 DECR 更高效,也更能保证一次扣减的原子性。但 DECRBY 本身不检查库存是否充足,扣完可能变成负数。常见处理方式有两种:
- 扣减后检查返回值:执行
DECRBY后,如果返回值小于 0,说明超扣,立即用INCRBY补偿回去,并告知用户库存不足。这种方式实现简单,但在高并发下会有短暂的负库存窗口,且补偿本身也可能失败,需要重试。 - Lua 脚本原子判断:在脚本里先
GET判断库存是否大于等于 n,再DECRBY,否则返回失败。整个判断和扣减在服务端一次执行,不会超卖。这是生产环境更推荐的方式。
# 方式一:扣减后检查并补偿
DECRBY stock:sku:2001 2
# 返回 (integer) -1,说明库存不足
INCRBY stock:sku:2001 2
# 补偿回 1
# 方式二:Lua 脚本(伪代码)
# if tonumber(redis.call('GET', KEYS[1]) or '0') >= tonumber(ARGV[1])
# then return redis.call('DECRBY', KEYS[1], ARGV[1])
# else return -1 end
初始值问题的三种处理策略
虽然 INCR 对不存在的 key 按 0 处理,但有些场景需要显式初始值。例如库存初始为 100,如果直接 DECRBY,第一次就会从 0 扣成负数。因此库存类计数必须在业务开始前初始化。常见策略:
- 依赖隐式 0:阅读量、点赞数这类从 0 开始的计数,直接 INCR,不初始化。
- SET 显式初始化:库存、余额等有明确起点的,用
SET stock:sku:2001 100写入初始值。注意 SET 会覆盖已有值,只应在初始化阶段执行。 - SETNX 防覆盖:用
SETNX stock:sku:2001 100,只有 key 不存在时才写入,避免重复初始化把已有库存重置。返回 1 表示初始化成功,返回 0 表示已存在。
并发安全与工程注意事项
使用 INCR/DECR 时,还有几个工程细节值得注意。第一,key 的命名要能区分计数类型,例如 article:1001:views、article:1001:likes、stock:sku:2001,避免不同业务共用一个 key 导致数值互相污染。第二,不要对同一个 key 混用字符串和整数操作,一旦用 SET 写入了非整数字符串,后续 INCR 会持续报错。第三,关注大 key 与热点 key,单个计数 key 的 QPS 可能非常高,虽然 INCR 本身很快,但热点 key 会集中打到单个分片,必要时可按维度拆分,例如按文章 ID 哈希分片后再汇总。第四,持久化与过期,计数 key 通常需要长期保留,如果设置了 TTL,过期后 INCR 会从 0 重新开始,要确认这是否符合业务预期。
最后,返回值是判断结果的关键。INCR/DECR 返回的是操作后的新值,业务代码应基于返回值做判断:库存扣减后返回负数说明超扣,点赞 INCR 后返回 1 说明是首次点赞。不要忽略返回值再单独 GET 一次,那样又引入了额外的往返和潜在的竞态。