计数是业务系统里出现频率极高的需求:接口调用次数统计、商品库存扣减、用户积分累加、验证码发送限流,这些场景背后都需要一个既能高效读写、又能保证并发安全的计数方案。Redis的INCRBY命令正是为此而生,它对存储在指定key中的整数值执行原子性的递增操作,一次网络往返就能完成读加写,天然规避了传统数据库先查询再更新的并发问题。本文将围绕INCRBY的语法细节、原子性原理、典型应用和容易踩的坑展开,帮你把这条命令用得明明白白。

一、INCRBY的基本语法与执行行为
INCRBY的命令格式非常简洁:INCRBY key increment。它将key中存储的数字值加上增量increment,然后返回递增之后的新值。如果key不存在,Redis会先把key当作0处理,执行完命令后key的值就等于increment本身,同时这个key会像执行SET一样正常设置TTL语义(默认不过期)。看一个最基础的例子:
127.0.0.1:6379> SET page_views 100 OK 127.0.0.1:6379> INCRBY page_views 20 (integer) 120 127.0.0.1:6379> GET page_views "120" 127.0.0.1:6379> INCRBY new_counter 5 (integer) 5
有几点行为需要特别留意。第一,increment参数本身可以是负数,比如INCRBY stock -1,效果等价于扣减库存,实际开发中这个用法比INCRBY的正向递增更常见。第二,increment的取值范围被限制在有符号64位整数之间,超出范围会直接报错。第三,INCRBY操作的对象必须是能被解析为64位整数的字符串,如果key里存的是"abc"或者"3.14"这样的内容,Redis会返回错误提示(integer or out of range相关异常),而不会做任何隐式转换。
与INCRBY关系最近的是INCR命令,它等价于INCRBY key 1,还有对应的DECR与DECRBY(等价于INCRBY key -n)。如果需要递增浮点数,则要改用INCRBYFLOAT,它和INCRBY是完全独立的两条命令,不能混用:对一个值为浮点数的key执行INCRBY会报错,反之亦然。选型时先想清楚计数的数值类型,整数一律用INCRBY系列,带小数的金额、权重才考虑INCRBYFLOAT。
二、为什么INCRBY是原子操作
很多刚接触Redis的同学会有疑问:先GET再SET,用应用层代码一样能实现计数,为什么非要INCRBY?关键差别就在原子性上。如果客户端先执行GET拿到当前值,在内存里加一,再执行SET写回去,这两步之间存在时间窗口。并发场景下,两个请求可能同时读到相同的旧值,各自加一后先后写入,最终结果只加了一次,计数就丢失了。这就是典型的读改写竞态条件,用数据库行锁或者分布式锁可以解决,但代价不小。
INCRBY把读和写合并成了一条命令,在服务端一次性完成。Redis的核心命令处理模块是单线程执行的(不考虑网络IO与持久化的多线程辅助),所有客户端发来的命令会排队依次执行,一条INCRBY在执行期间不会被其他命令插入。因此即便有一万个客户端同时向同一个key发起INCRBY,最终结果也一定是精确的一万次累加,不存在丢计数。这种由命令本身保证的原子性,比任何应用层的加锁方案都更简单可靠。
如果一次需要原子地修改多个key,INCRBY就无能为力了,这时候可以借助MULTI/EXEC事务或者Lua脚本。例如库存扣减同时要更新商品维度和用户维度的计数,用Lua脚本把多条INCRBY包在一起提交,Redis保证脚本内命令连续执行不被打断:
-- KEYS[1]为库存key,ARGV[1]为扣减数量
local stock = redis.call('GET', KEYS[1])
if stock == false then
return -1 -- key不存在
end
stock = tonumber(stock)
local qty = tonumber(ARGV[1])
if stock < qty then
return 0 -- 库存不足
end
redis.call('INCRBY', KEYS[1], -qty)
return 1 -- 扣减成功
三、INCRBY与key过期策略的配合
计数器往往不是永久有效的,比如限流器通常按分钟或按小时统计。INCRBY本身不涉及过期设置,但可以配合EXPIRE命令实现滑动窗口式的固定窗口限流:第一次INCRBY返回1时设置过期时间,后续请求判断计数值是否超过阈值。以Python为例:
import redis
r = redis.Redis(host='127.0.0.1', port=6379)
def allow_request(user_id, limit=100, window=60):
key = f"rate:{user_id}"
current = r.incrby(key, 1)
if current == 1:
r.expire(key, window) # 首次访问设置窗口期
return current <= limit
print(allow_request("user_1001")) # True表示放行
这种写法存在一个细节问题:如果进程在INCRBY之后、EXPIRE之前崩溃,这个key就会变成永不过期的计数器,慢慢堆积脏数据。更稳妥的做法是改用Lua脚本把两步合并,或者在低峰期用扫描手段清理无TTL的计数key。另外要注意,对已有TTL的key执行INCRBY不会清除过期时间,这一点与SET命令覆盖key时会移除TTL的行为不同,容易让人误解。
四、典型应用场景与代码示例
第一个场景是接口限流,上面的例子已经覆盖。第二个场景是秒杀库存扣减,直接用INCRBY stock -1配合Lua脚本判断返回值,比数据库行级锁的吞吐高出几个量级,Redis单机每秒可以承载十万级以上的INCRBY操作。第三个场景是积分排行榜:用INCRBY累加用户积分,再用ZINCRBY同步更新有序集合,即可同时获得精确计数与排名查询两种能力。
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 用户获得50积分
long newScore = jedis.incrBy("score:user:1001", 50);
// 同步更新排行榜
jedis.zincrby("rank:board", 50, "user:1001");
// 查询当前排名(分数从高到低)
Long rank = jedis.zrevrank("rank:board", "user:1001");
System.out.println("新积分:" + newScore + ",排名:" + (rank + 1));
还有一类容易被忽略的用法是生成全局序号。利用INCRBY的单调递增特性,可以为分布式系统生成趋势递增的业务ID,再拼接日期、机器标识等信息组成完整的订单号。相比UUID,这种方式生成的ID有序、占空间小,便于数据库索引。不过要注意INCRBY的返回值达到64位上限(2的63次方减1)后再递增会报溢出错误,长期增长的序号建议按天分key,例如seq:20250101,既避免溢出也方便归档清理。
五、使用中的常见坑与注意事项
第一个坑是操作非整数key。如果某个key被误写入了字符串或序列化对象,后续INCRBY会持续报错,排查时可以用TYPE key确认类型,Redis的字符串类型虽然能存任意字节,但只有合法整数字符串才能参与算术命令。第二个坑与集群模式有关:INCRBY只涉及单个key,本身没有跨槽问题,但如果把多个相关计数key分散设计,再用Lua脚本批量操作,就可能因为key落在不同槽位而报CROSSSLOT错误,建议对关联key使用hash tag,例如stock:{1001}:main和stock:{1001}:log,保证它们哈希到同一个槽。
第三个坑是持久化与丢失风险。INCRBY性能极高,但在极端故障场景下,比如异步RDB快照后的短暂窗口发生宕机,最近一段时间的计数可能丢失。如果计数涉及资金等强一致要求,要么开启AOF的everysec甚至always策略,要么接受最终对账机制,用Redis扛流量、用数据库做兜底校验。第四个坑是big key的误用:INCRBY本身不会产生大key,但把海量维度的计数都塞进哈希字段并用HINCRBY操作时,单个哈希可能膨胀到影响主从同步效率,建议按维度拆分或定期归档。
总结一下,INCRBY的核心价值在于用一条原子命令替代应用层的读改写流程,简单、快速、并发安全。使用时只要守住三个底线——操作对象必须是64位整数、注意与TTL的配合、在集群下合理设计key布局——它就能在限流、库存、积分、序号生成等场景中稳定发挥。理解了这些细节,你写出的计数功能才经得起高并发的考验。