news 2026/9/18 21:11:22

Redis INCR原子性原理与高并发计数器实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis INCR原子性原理与高并发计数器实现指南

简介:PDF教程面向需要处理接口限流、防刷等场景的Java后端开发者,系统讲解如何利用Redis的INCR命令实现高并发计数器。内容从业务中的典型需求切入,如每个用户每天限制调用接口10次,并结合Jedis连接池给出完整可运行的Java代码示例,涵盖键设计、自增操作、过期时间设置与连接归还等关键细节,同时点明INCR的原子性对并发安全的作用。资源为单文件PDF,大小41KB,便于离线阅读,适合已掌握Redis基础、希望快速落地计数场景的读者。目前已有8108人学习下载,配套讲解可帮助理解计数器的唯一键拼接思路、服务拒绝逻辑编写方式以及连接池复用的性能收益,也能迁移到发送短信限制、统计点击量、实时监控等同类场景中。

1. 从短信验证码到接口限流:为什么Redis的INCR适合做高并发计数器

前两天在排查短信验证码通道时又看到一个老问题:同一个手机号在一分钟内被触发了二十多次验证码下发请求,而业务上线时定的规则是五分钟五次。这种场景在 IT 行业里太常见了,接口防刷、下单频控、违章查询次数限制,本质都是同一件事——在高并发下先把计数器算出来,再决定放行还是拦截。如果用数据库行锁去数数,扛不住瞬时流量;如果用 ConcurrentHashMap 存本地计数,又没法跨实例共享。Redis 的 INCR 命令恰好卡在中间:单线程执行保证自增不重不漏,加上过期时间天然支持“每天重置”这类窗口语义,所以它成了高并发计数器的默认选型。这篇博客不会只贴一段 setIncr 工具类,而是把原子性原理、键设计、连接池参数和并发边界都拆开讲,适合正在做限流或频控的初级到中级开发,也值得近期在调研计数器方案的从业者看一眼。

2. INCR的原子性原理:单线程事件循环与 GET+SET 方案的差距

2.1 为什么计数必须原子,不能先读后写

先看一个最容易被忽略的问题:计数器的核心操作是“读当前值、加一、写回”。这在单线程业务代码里没问题,一旦同时来了十个请求,线程 A 读出 5,线程 B 也读出 5,两个线程各加一写回 6,结果本该是 15,实际是 6。这就是竞态。很多新手第一次实现限制接口调用次数时,用的是 GET 拿值再 SET 回去,这种写法在并发压测下几乎必现计数丢失。

Redis 的 INCR 把“读、加、写”三步合并成一步,全部在服务端完成。Redis 本身是单线程的事件循环模型,每个命令从接收到返回都是串行执行的,天然不存在两个请求同时修改同一个键的问题。也就是说,无论多少个客户端同时发 INCR,最终拿到的结果都严格递增,不会出现覆盖写。

127.0.0.1:6379> INCR api:count (integer) 1 127.0.0.1:6379> INCR api:count (integer) 2 127.0.0.1:6379> INCR api:count (integer) 3
2.1.1 进程内并发的误区:synchronized 不是答案

单个 JVM 实例里用 synchronized 或 AtomicLong 可以保证计数准确,但生产环境服务基本都是多实例部署,Nginx 后面挂了四台应用,每台 JVM 的计数器是各自独立的,加在一起就会超过限制。Redis 把计数状态放到所有实例共享的存储层,才能做到全局精确。这也是“高并发计数器”必须依赖外部组件的原因,不是 Java 层面解决不了,而是分布式环境下本地变量没有共享语义。

2.2 INCR、GET+SET、Lua 脚本三者的边界

INCR 不是 Redis 里唯一能做计数的方案,但它是性价比最高的一个。为了把这层边界说清楚,下面给出三种实现的对比。

# 方案一:GET + SET,非原子,高并发下计数丢失 GET api:count SET api:count 6 # 方案二:INCR 原子自增,单条命令完成 INCR api:count # 方案三:Lua 脚本,适合自增后再做复杂判断 local c = redis.call('INCR', KEYS[1]) if tonumber(c) == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return c
实现方式原子性适用场景缺点
GET + SET单线程测试环境并发下计数不准
INCR命令级原子计数器、限流、频控只为自增设计
Lua 脚本脚本级原子自增并判断后执行更多操作增加运维和理解成本

INCR 对应的是“自增 + 返回结果”,如果自增之后还要根据结果做别的判断,例如“第一次自增时设置过期时间”,常见做法是把这块逻辑挪到 Lua 脚本里,保证判断和自增在同一原子上下文。但对大多数限流场景,INCR 配合 EXPIRE 就足够,不需要把脚本引入代码库。

2.3 INCR 的边界:数值过长与键的误用

INCR 操作的 key 值会被解析为 64 位有符号整数,范围是 -9223372036854775808 到 9223372036854775807。如果键的值不是整数,Redis 会返回 error;如果自增后会超出 64 位整型上限,一样会报错。实际项目里计数器到不了这个量级,但要注意别把字符串类型的键误拿来做 INCR,例如里面存了 JSON 序列化后的对象,自增时会得到ERR value is not an integer or out of range的报错。排查这类问题时可以先看一眼键的类型,TYPE命令比猜快得多。

3. Jedis连接池下的 setIncr 实现:键设计和过期时间怎么设

3.1 连接池为什么是刚需,不是优化项

每个 Redis 命令都需要一个 TCP 连接。如果请求每次现连现断,Redis 服务端需要不断接受新连接,四次挥手带来的 TIME_WAIT 很快会占满端口,吞吐量从每秒几千掉到几百。JedisPool 的作用就是预先初始化一批连接,请求时借出,用完后归还。这里面有两个关键参数需要在实际项目中显式配置:maxTotalmaxIdle

JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(200); config.setMaxIdle(50); config.setMinIdle(8); config.setMaxWaitMillis(3000); config.setTestOnBorrow(true); config.setTestOnReturn(false); JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 5000, "redis-pass");

maxTotal是连接池最多能同时持有的连接数,不是开得越大越好,要结合应用的最大并发数和 Redis 自身的 CPU 承载能力来估算。maxWaitMillis是请求从连接池借出连接的最大等待时间,超过这个时间还没拿到连接就会抛异常。

3.1.1 连接泄漏与 Jedis 版本差异

早期 Jedis 的规范写法是在 finally 里调用jedisPool.returnResource(jedis),就像项目正文里那样。Jedis 3.x 开始,连接对象实现了AutoCloseable,推荐用 try-with-resources,或者在 finally 里直接调用jedis.close(),它内部会判断连接是回到池里还是真正关闭。老代码里的returnResource在新版本中已经废弃,如果你的项目中引入了较高版本的 Jedis 依赖,编译时会看到 deprecation 警告,这不是小事,说明连接归还路径变了,继续沿用旧方法会有资源耗尽的风险。

3.2 setIncr 工具方法重写:把过期时间放进参数

原项目的 setIncr 方法是先自增再判断过期时间,能跑通但有瑕疵:每次请求都会执行 EXPIRE,即使 key 已经存在几十秒了,也会把过期时间重置为 86400 秒。这在短时间段内影响不大,但如果把 cacheSeconds 设成 60 秒,而请求频率是一秒一次,那么这个 key 永远不会过期,因为每次自增后过期时间都被刷新了。需要单独是不是要在自增时判断“只有第一次才设置过期时间”,这个问题放到下一章展开,这一节先看修正后的连接池用法。

public class RedisCounter { private final JedisPool jedisPool; public RedisCounter(JedisPool jedisPool) { this.jedisPool = jedisPool; } public long incr(String key, int cacheSeconds) { try (Jedis jedis = jedisPool.getResource()) { long count = jedis.incr(key); if (cacheSeconds > 0) { jedis.expire(key, cacheSeconds); } return count; } catch (Exception e) { // 生产环境这里应接入监控报警 throw new RuntimeException("counter incr failed", e); } } }

cacheSeconds > 0的判断意思是:0 表示不设置过期时间,正数表示自增后刷新 key 的存活时间。这种写法简单直接,但正如上面所说,第二次及之后的每次自增都会刷新过期时间,对“固定窗口”语义会产生影响。下一章给出解决方式。

3.3 键设计:日期、用户ID、业务名的组合方式

键设计决定了计数器的隔离粒度。原项目用了日期 + 用户ID + 接口名,这种做法是对的,但有几个细节可以优化。首先是分隔符,建议使用冒号:作为层级分隔符,Redis 官方规范也推荐这个习惯,例如2024-06-01:userId:queryCarViolation。冒号在 Redis 的 key 空间里没有特殊含义,只是可读性更好。其次是日期格式,建议固定为yyyy-MM-dd,不要用时间戳,否则每天产生的 key 无法直观排序和批量清理。最后是业务名前缀,建议带上一个项目名或模块名前缀,避免和其他业务共用 Redis 时撞 key。

键格式示例适用场景
接口名:日期sms:limit:2024-06-01全局限流,所有用户共享阈值
接口名:日期:用户IDapi:violation:2024-06-01:u12345按用户限流
接口名:日期:用户ID:IPapi:login:2024-06-01:u12345:192.168.1.1更细粒度限制

键越细,Redis 内存中的 key 数量越多,需要配合过期时间让 Redis 自动清理。这里有个容易忽略的性能问题:当 key 数量达到百万级时,用KEYS命令扫描会阻塞 Redis 主线程,应该用SCAN游标扫描。生产中排查 key 是否有残留时,我一般会这样操作:

# 使用 SCAN 而非 KEYS,避免阻塞 redis-cli --scan --pattern "api:violation:*" | head -20

SCAN 命令每次返回游标和数据,循环执行直到游标变为 0。它不保证一次返回所有匹配的 key,但在大 key 场景下不会卡住线上服务。

3.4 键的过期策略:过期时间从自增那一刻开始算

INCR 配合 EXPIRE 的常见坑在于:过期时间是从执行 EXPIRE 那一刻开始倒计时,而不是从当天零点开始。如果业务语义是“每天 0 点重置”,那么过期时间的秒数应该等于当前时间到次日零点的差值,而不是固定 86400。

LocalDateTime now = LocalDateTime.now(); LocalDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay(); long secondsUntilMidnight = Duration.between(now, midnight).getSeconds(); long count = redisCounter.incr(key, (int) secondsUntilMidnight);

计算到次日零点的剩余秒数后,传给 EXPIRE 就能保证每天的 key 都在零点前后自动消失。这里有个边界要注意:Redis 的过期删除是惰性的,key 到期后不会立刻物理删除,而是在被访问时发现已过期才删除。所以即使过期时间到了,理论上还存在极短时间窗口,但对计数限流场景来说,这个窗口的影响可以忽略。

4. 高并发下的边界处理:过期重置、连接池耗尽与 AOP 接入

4.1 固定窗口计数器与 EXPRE 刷新陷阱

上一章提到的“每次自增都执行 expire”在固定窗口场景下会引发一个实际线上问题:假设业务要求“一个用户一天最多查询 10 次违章”,第一天用户查了 10 次后被拦截。第二天零点,Redis 中该 key 的过期时间重置,新的一天可以继续查 10 次,这是符合预期的。问题出在高频调用场景,比如限制“一分钟 100 次”,如果每秒都有请求进来,那么 EXPIRE 会被反复刷新,这个 key 永远到不了 60 秒的存活期,窗口被无限延长,计数永远不会重置。

解决思路是只在第一次 INCR 时设置过期时间。Redis 里没有“只在键不存在时设置过期时间”的原生命令,但可以用 Lua 脚本把逻辑封装起来,保证原子性。

-- counter.lua local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return current
public long incrOnce(String key, int cacheSeconds) { try (Jedis jedis = jedisPool.getResource()) { String script = "local current = redis.call('INCR', KEYS[1]) " + "if current == 1 then " + " redis.call('EXPIRE', KEYS[1], ARGV[1]) " + "end " + "return current"; Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(cacheSeconds))); return ((Long) result).longValue(); } }

这个脚本的核心逻辑是:自增后判断返回值是否为 1,为 1 说明这个 key 是新建的,此时设置过期时间;如果返回值大于 1,说明 key 已经存在,不触碰过期时间。这样固定窗口的边界就准确了。

4.2 并发临界:同一毫秒内 INCR 返回值的准确度

从 Redis 服务端的角度看,INCR 是串行的,不管同一毫秒来多少个请求,返回值都会依次递增 1。Java 应用侧拿到返回值后判断是否超过阈值,这里有一个极小的竞争窗口:两个请求同时进来,都拿返回值 9 和 10,如果阈值是 10,两个请求一个放行一个拦截,这是合理的;如果阈值是 10,返回值 9 和 10 都算未超限,那第 11 个请求就会被拦截,符合预期。但如果拿返回值判断的是“小于等于 10 才放行”,注意 10 也放行了,第 11 次会被拦截,业务上没问题。

4.3 连接池耗尽时的降级策略

当 Redis 的连接池全部被占满时,getResource()会抛出JedisConnectionException。如果不做处理,限流接口会直接 500,比限流本身危害更大。常见的处理方案是降级为本地限流,或者直接放行。

private boolean denialOfService(String userId) { try { String key = DateUtil.getDate() + ":" + userId + ":queryCarViolation"; long count = redisCounter.incr(key, secondsUntilMidnight()); return count > 10; } catch (JedisConnectionException e) { // 保底策略:连接池不可用时报降级日志,放行请求,避免影响核心业务 logger.warn("redis counter degraded, cause: {}", e.getMessage()); return false; } }

降级策略要根据业务性质来定:短信验证码接口宁可多发一条也不能卡死,此时降级为放行;秒杀接口宁可拦截也不能超卖,此时降级为拒绝。这个决策要由业务方定,不能写在工具类里写死。

4.4 计数器与 AOP 注解结合,去掉重复代码

原项目里每次调用接口前手动调用一次 denialOfService,侵入性强,容易漏写。常见做法是定义一个注解,用 Spring AOP 拦截方法执行前做计数判断,业务方法里只留一个注解。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { String key() default ""; int limit() default 10; int expireSeconds() default 86400; }
@Aspect @Component public class RateLimitAspect { @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String userId = getUserIdFromRequest(); String key = rateLimit.key() + ":" + DateUtil.getDate() + ":" + userId; long count = redisCounter.incr(key, rateLimit.expireSeconds()); if (count > rateLimit.limit()) { throw new RateLimitException("exceed limit"); } return joinPoint.proceed(); } }

AOP 方案的优点是统一处理,不污染业务代码;缺点是如果同一个方法被多个线程并发调用,切面里先自增再执行方法,在方法执行前计数已经生效,即使方法后续抛异常,计数也不会回滚。这个语义对限流来说正是想要的:一次调用机会已经被消耗掉,就算执行失败也不能无限重试。

4.5 与分布式锁的边界:计数器不需要锁

有些开发者在实现计数器时,会考虑用 SETNX 或分布式锁来保证并发安全,但这是不必要的。INCR 本身就比分布式锁更轻量。分布式锁的作用是保证一段代码的互斥执行,而计数器只需要一个单调递增的返回值,INCR 已经保证了这一步。在 INCR 外面套一层 Redisson 锁,只是徒增网络开销和锁竞争,完全没有必要。

5. 压测验证与线上监控:用 JMeter 和 SCAN 确认计数器可靠

计数器代码上线前一定要做并发压测,不然 8 个线程同时跑可能就出现计数错乱。下面给出我常用的验证流程。

5.1 用 JMeter 构造高并发请求

JMeter 里配置一个 HTTP 请求指向查询违章接口,线程数设为 100,Ramp-Up 设为 1 秒,循环次数设为 50。这样会在 1 秒内发起 5000 个请求,全部打到接口上。

# jmeter -n -t counter-test.jmx -l result.jtl

跑完后看聚合报告里的 Error %。如果超过限流阈值后返回错误信息,Error% 应该接近 90%(5000 个请求中只有 10 个成功)。如果 Error% 是 0%,说明限流没有生效,多半是 key 设置错了,所有请求都落在不同 key 上。

5.2 验证计数器的单调性

压测结束后直接在 Redis 里查计数器的最终值,看是否等于你期望的放行次数。

redis-cli GET "2024-06-01:u12345:queryCarViolation"

如果最终值是 10,说明 INCR 在并发压测下没有丢失一次计数;如果值小于 10,说明存在覆盖写,检查代码里是不是用了 GET + SET。这个验证方式简单直接,建议写进自动化回归脚本里。

5.3 监控 key 的过期情况和连接池指标

线上运行一段时间后,用 INFO 命令检查 Redis 的内存和连接数:

redis-cli INFO stats | grep keyspace redis-cli INFO clients

如果connected_clients一直顶在 maxTotal 附近,说明连接池配小了。接着用 SCAN 找到业务前缀下残留的 key,确认过期时间是否生效:

redis-cli --scan --pattern "api:violation:*"

正常情况是凌晨跑完这个命令返回空,如果还有大量昨天的 key,说明 EXPIRE 没有被执行或者 cacheSeconds 传了 0。

5.4 临时手工修正和清除计数 key

线上排查时如果发现计数器被人为刷爆,需要手工清掉 key 恢复服务,不要用 DEL 之外的方式写回 0,直接删掉最干净:

redis-cli DEL "2024-06-01:u12345:queryCarViolation"

如果要临时调大限额,把过期时间重新设置成到当天零点的剩余秒数,注意错误的过期时间会导致提前失效或延迟失效,操作前先TTL看一眼存活时长再改。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 21:09:36

人大金仓KingbaseES实战指南:从Oracle兼容到数据库迁移部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:08:25

Canvas字体大小怎么调?从ctx.font到高分屏适配的完整指南

你搜“Canvas字体大小怎么调”&#xff0c;大概率会看到一堆让你改DBeaver界面字号、给Word批注调字号的教程。它们都很热&#xff0c;但不是你要的答案。这里的“Canvas”&#xff0c;指的是HTML5页面上用来绘制图形的那个<canvas>元素&#xff0c;以及它背后的Canvas 2…

作者头像 李华
网站建设 2026/9/18 21:08:04

AI获客系统终端客户实操指南:从代理协作到线索转化的全流程解析

我这段时间被同一个问题问得最多&#xff1a;AI获客系统代理的终端客户&#xff0c;到底怎么用&#xff1f;很多人从代理那边拿到账号后&#xff0c;打开后台就是一脸懵&#xff0c;不知道从哪里开始。这其实很正常&#xff0c;因为AI获客系统不像传统CRM那样&#xff0c;打开就…

作者头像 李华
网站建设 2026/9/18 21:08:02

基于迁移学习的CNN甲状腺结节超声分类:VGG19/InceptionV3/DenseNet161对比

简介&#xff1a;面向医学图像处理、深度学习与机器学习研究者&#xff0c;这份学术论著系统探讨了基于卷积神经网络的甲状腺结节超声图像良恶性分类方法。研究采用迁移学习策略&#xff0c;对在自然图像上预训练的VGG19、Inception V3及DenseNet 161三种模型进行微调和评估&am…

作者头像 李华
网站建设 2026/9/18 21:07:39

Ubuntu系统镜像制作与安装全流程:从ISO到定制化批量部署

很多朋友第一次接触 Ubuntu&#xff0c;第一反应都是去官网下个 ISO 然后拿 Rufus 写进 U 盘&#xff0c;装完就完事了。但真到了要批量部署、给多台机器统一环境、或者做一套离线安装包的时候&#xff0c;就会发现“装系统”这件事远没想象中那么简单。我自己前前后后折腾过很…

作者头像 李华
网站建设 2026/9/18 21:07:12

vue-print-designer 实战:轻量化 Web 打印模板设计与集成方案

做了几年后台管理系统&#xff0c;我最头疼的需求不是权限设计&#xff0c;不是表格导出&#xff0c;而是打印。说到web打印插件&#xff0c;很多人第一反应是 Lodop、JsPrintSetup 这些老牌方案&#xff0c;它们能力确实强&#xff0c;但动不动就要装控件、配 ActiveX&#xf…

作者头像 李华