ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis高并发计数器实战:从INCR到集群分片与熔断降级

Redis高并发计数器实战:从INCR到集群分片与熔断降级 简介本资源是一份面向Java后端开发者与Redis初学者的高并发计数器实战指南聚焦服务限流、接口调用频控、防刷防攻击等典型业务场景。内容以Redis原生命令INCR为核心结合Jedis连接池管理、键命名规范日期用户ID接口名、TTL自动过期等关键实践完整呈现从计数逻辑设计、工具类封装JedisUtil.setIncr到业务拦截denialOfService校验的端到端实现方案。资源为1个41KB的PDF文档内容精炼含可直接复用的Java代码片段、注释详尽的接口调用示例如queryCarViolationList及核心原理说明便于快速理解与工程落地。目前已有8109人学习下载适合需要在微服务或高并发系统中快速集成轻量级计数能力的开发者尤其适合作为限流模块的入门参考与代码模板。1. 为什么用 Redis 做高并发计数器而不是数据库自增或本地变量你刚上线一个秒杀活动页后台用 MySQLUPDATE product SET stock stock - 1 WHERE id 123 AND stock 0控制库存——结果 2000 QPS 下数据库 CPU 瞬间飙到 98%连接池打满超时告警满天飞。这不是理论瓶颈是真实翻车现场。而隔壁团队用 RedisINCR实现的抢购计数器在 5 万 QPS 压测下延迟稳定在 0.8ms错误率 0。差别在哪不是 Redis 多快而是它把「原子性 内存操作 单线程事件循环」这三件事焊死在一个极简路径上一次网络请求 → 服务端单次内存读写 → 原子返回新值。没有事务开销、没有锁竞争、没有磁盘 IO 搅局。它不解决所有问题但专治「高频、无状态、需强一致性」的计数场景——比如实时 PV/UV 统计、接口调用频次限制Rate Limiting、库存扣减兜底校验、抽奖次数记录。注意它不适合需要复杂查询、关联计算或持久化强保证的计数比如财务对账那是数据库的主场。本文讲的就是怎么把 Redis 这个「内存里的原子计算器」真正用进生产系统——从单机 INCR 到集群分片从 Jedis 基础调用到 Lua 脚本防穿透再到压测验证和线上熔断兜底。适合 Java 后端、SRE 和需要快速落地计数能力的全栈工程师。2. 从单机 INCR 到集群分片三种计数器架构选型与落地代码Redis 计数器看似只有一行INCR key但真实业务中你必须面对三个现实单机内存扛不住海量 key、集群模式下 key 分布导致INCR不再全局唯一、高可用要求下主从切换可能丢数据。不能只抄命令得按场景选架构。下面三种方案覆盖 95% 的生产需求每种都附可直接运行的 JavaJedis代码和关键参数说明。2.1 单机 Redis INCR最简场景的黄金组合适用场景日活 10 万、QPS 5000、允许单点故障如内部运营后台的点击统计、key 总量 100 万。这是入门必过的第一关也是压测基线。// Maven 依赖jedis 4.4.3最新稳定版支持 Redis 7.x // dependency // groupIdredis.clients/groupId // artifactIdjedis/artifactId // version4.4.3/version // /dependency public class SimpleCounter { private static final JedisPool pool new JedisPool( new JedisPoolConfig(), 127.0.0.1, 6379, 2000, your_password // 密码非空时必填 ); public static long incr(String key) { try (Jedis jedis pool.getResource()) { return jedis.incr(key); // 原子自增key不存在时初始化为0再1 } } public static long get(String key) { try (Jedis jedis pool.getResource()) { String value jedis.get(key); return value null ? 0L : Long.parseLong(value); } } }关键参数说明JedisPoolConfig必须设置maxTotal200连接池最大连接数、maxIdle50空闲连接数、minIdle10最小空闲连接数。实测发现当 QPS 3000 时maxTotal 100会导致连接等待超时JedisConnectionException: Could not get a resource from the pool。timeout2000是 socket 连接超时单位毫秒不是命令超时。命令级超时需用jedis.configSet(timeout, 5)不推荐影响全局。incr返回long不是String。若 key 存的是非数字字符串会抛ERR value is not an integer or out of range—— 这是强校验不是 bug是安全机制。2.2 Redis Cluster Hash Tag让计数器在集群中保持原子性适用场景QPS 1 万、key 总量 500 万、要求 99.99% 可用性如电商商品销量、APP 实时在线人数。Redis Cluster 默认按 key 的 CRC16 值取模分片INCR user:1001和INCR user:1002可能落在不同节点无法做跨节点原子操作。解决方案用 Hash Tag 强制相关 key 落在同一 slot。public class ClusterCounter { private static final JedisCluster cluster; static { SetHostAndPort nodes new HashSet(); nodes.add(new HostAndPort(192.168.1.10, 7000)); nodes.add(new HostAndPort(192.168.1.10, 7001)); nodes.add(new HostAndPort(192.168.1.11, 7000)); // ... 添加全部 master 节点 cluster new JedisCluster(nodes, 2000, 100, 5, your_password, new GenericObjectPoolConfig()); } // 关键用 {} 包裹 hash tag确保同一业务实体的所有计数 key 落在同一 slot // 例如{order:12345}:pay_count 和 {order:12345}:refund_count 一定在同一个节点 public static long incrOrderCount(String orderId, String suffix) { String key { orderId }: suffix; // 注意 {} 是 Redis Hash Tag 语法 return cluster.incr(key); } // 批量获取多个同属一个 order 的计数必须在同一 slot否则 cluster 不支持 multi-key 命令 public static MapString, String getOrderCounts(String orderId) { String key1 { orderId }:pay_count; String key2 { orderId }:refund_count; String key3 { orderId }:cancel_count; return cluster.mget(key1, key2, key3); // mget 支持跨 key但要求所有 key 在同一 slot } }为什么必须用{}Redis Cluster 对 key 的哈希计算规则是CRC16(key) 16383但遇到{}时只对{}内部字符串计算哈希。{user:1001}:login和{user:1001}:logout的哈希值完全相同必然路由到同一节点。若不用{}user:1001:login和user:1001:logout极大概率分到不同节点INCR就变成单节点原子而非业务逻辑上的“订单维度原子”。2.3 分片计数器Sharded Counter突破单 key 性能瓶颈适用场景单 key QPS 10 万如热门文章阅读量、需要极致吞吐广告曝光计数。单个 Redis key 的INCR在 10 万 QPS 时即使单机也接近性能天花板实测 Redis 7.0 单 keyINCR极限约 12 万 QPS。解法把一个逻辑计数器拆成 N 个物理 key写请求随机打散读时聚合。public class ShardedCounter { private static final int SHARD_COUNT 100; // 分片数建议 64~2562 的幂次更易取模 private static final String BASE_KEY shard:article:{%s}:view; public static long incrViewCount(String articleId) { int shard Math.abs(articleId.hashCode()) % SHARD_COUNT; String key String.format(BASE_KEY, articleId) : shard; try (Jedis jedis pool.getResource()) { return jedis.incr(key); } } // 读取总值必须遍历所有分片代价高应异步聚合或定时落库 public static long getTotalViewCount(String articleId) { long total 0L; for (int i 0; i SHARD_COUNT; i) { String key String.format(BASE_KEY, articleId) : i; try (Jedis jedis pool.getResource()) { String value jedis.get(key); total value null ? 0L : Long.parseLong(value); } } return total; } // 生产建议用 Lua 脚本一次性读取所有分片减少网络往返 private static final String GET_TOTAL_SCRIPT local total 0 for i0, tonumber(ARGV[1])-1 do local key KEYS[1] .. : .. i local val redis.call(GET, key) if val then total total tonumber(val) end end return total; public static long getTotalViewCountLua(String articleId) { String baseKey String.format(BASE_KEY, articleId); try (Jedis jedis pool.getResource()) { Object result jedis.eval(GET_TOTAL_SCRIPT, Collections.singletonList(baseKey), Collections.singletonList(String.valueOf(SHARD_COUNT)) ); return ((Long) result).longValue(); } } }分片数怎么定太小如 16单分片仍可能成为热点未充分分散压力。太大如 1024GET聚合时网络开销剧增且内存碎片化严重。血泪经验从 64 开始压测用redis-benchmark -n 1000000 -c 100 -r 100000000 INCR __rand_int__模拟随机 key 写入观察redis-cli --stat中instantaneous_ops_per_second峰值。当单分片 QPS 5000 时整体吞吐最稳。3. 防穿透、防击穿、防雪崩计数器的三大生存法则计数器不是扔进 Redis 就万事大吉。线上最常翻车的不是性能而是边界条件——缓存穿透让 DB 被打爆、缓存击穿让热点 key 失效瞬间流量洪峰、缓存雪崩让全量 key 过期引发连锁反应。这三类问题在计数器场景有独特表现和解法必须前置设计。3.1 防穿透空值缓存 布隆过滤器双保险现象恶意请求INCR invalid_key_123456789key 永远不存在每次INCR都穿透到 DB 查询如果业务逻辑有 fallback。10 万 QPS 的无效请求DB 直接 OOM。原因INCR对不存在 key 自动初始化为 0但业务层可能误判“key 不存在”需查 DB。解决在INCR前加一层存在性校验且校验结果也缓存。// 方案一空值缓存简单有效适合 key 有明确业务范围 public static long incrWithExistCheck(String key, SupplierBoolean existsChecker) { String existKey exist: key; try (Jedis jedis pool.getResource()) { // 先查存在性缓存 String existFlag jedis.get(existKey); if (1.equals(existFlag)) { return jedis.incr(key); } else if (0.equals(existFlag)) { throw new IllegalArgumentException(Key does not exist: key); } else { // 缓存未命中查 DB 或其他权威源 boolean exists existsChecker.get(); jedis.setex(existKey, 300, exists ? 1 : 0); // 缓存 5 分钟 if (!exists) { throw new IllegalArgumentException(Key does not exist: key); } return jedis.incr(key); } } } // 方案二布隆过滤器适合海量子 key如 URL 计数 // 使用 RedisBloom 模块需 Redis 6.2或本地 Guava BloomFilter Redis 缓存布隆位图 // 此处省略布隆 filter 初始化重点在调用逻辑 public static long incrWithBloom(String key, BloomFilterString bloomFilter) { if (!bloomFilter.mightContain(key)) { throw new IllegalArgumentException(Key definitely not exist: key); } try (Jedis jedis pool.getResource()) { return jedis.incr(key); } }提示空值缓存时间不宜过长建议 5~30 分钟否则业务新增合法 key 时会出现“缓存黑洞”。布隆过滤器有误判率通常设为 0.01%需配合降级策略误判时走 DB 查询。3.2 防击穿逻辑过期 互斥锁现象某爆款商品销量 keyproduct:1001:sales设置了 1 小时过期整点时刻大量请求同时发现 key 过期全部涌向 DB 加载初始值并SET造成 DB 瞬间压力。原因Redis 过期是被动删除key 过期瞬间无保护。解决不依赖 Redis 过期改用“逻辑过期”——value 存{count:123,expireTime:1717027200000}应用层判断是否过期并用SETNX保证只有一个线程回源。public static class LogicalExpireCounter { private static final String LOGICAL_EXPIRE_MS 1800000; // 30 分钟逻辑过期 public static long incr(String key) { try (Jedis jedis pool.getResource()) { String json jedis.get(key); if (json null) { // key 不存在尝试加锁初始化 if (tryLock(jedis, key :lock, 5000)) { try { // 双检锁内再查一次防止重复初始化 json jedis.get(key); if (json null) { long initCount loadFromDB(key); // 从 DB 加载初始值 String expireTime String.valueOf(System.currentTimeMillis() 1800000); jedis.setex(key, 3600, String.format({\count\:%d,\expireTime\:%s}, initCount, expireTime) ); return initCount 1; } } finally { unlock(jedis, key :lock); } } // 加锁失败等待后重试实际应加退避 Thread.sleep(10); return incr(key); } // 解析 JSON生产用 Jackson/Fastjson此处简化 long count parseCount(json); long expireTime parseExpireTime(json); if (System.currentTimeMillis() expireTime) { // 逻辑过期异步刷新不阻塞当前请求 refreshAsync(key); } return jedis.incr(key); // 直接 incrvalue 是纯数字不影响 } catch (Exception e) { // 降级incr 失败时直接返回 DB 值 1最终一致性 return loadFromDB(key) 1; } } private static boolean tryLock(Jedis jedis, String lockKey, int expireMs) { return OK.equals(jedis.set(lockKey, 1, NX, EX, expireMs / 1000)); } private static void unlock(Jedis jedis, String lockKey) { jedis.del(lockKey); } }3.3 防雪崩多级过期 随机扰动现象所有计数器 key 都设EXPIRE 3600整点批量过期Redis CPU 瞬间 100%后续请求全部超时。原因过期时间集中Redis 删除过期 key 的 CPU 占用激增。解决给过期时间加随机扰动且不同业务 key 设置不同基础过期时间。// 生成带扰动的过期时间基础 3600 秒 ± 600 秒 public static int getRandomExpire(int baseSeconds) { return baseSeconds ThreadLocalRandom.current().nextInt(-600, 601); } // 使用示例设置 key 时 jedis.setex(counter:user:1001, getRandomExpire(3600), 0); jedis.setex(counter:article:2001, getRandomExpire(7200), 0); // 文章计数器过期更长 jedis.setex(counter:session:abc123, getRandomExpire(1800), 0); // 会话计数器过期更短注意扰动范围不宜过大±10% 以内否则失去过期控制意义也不宜过小 ±1%起不到分散效果。实测 ±10 分钟600 秒对 1 小时基础过期最稳妥。4. Lua 脚本把多步操作压缩成一次原子执行当INCR不够用时——比如要先检查库存是否充足再扣减、要按规则限流滑动窗口、要实现带 TTL 的计数器——就必须用 Lua。Redis 保证脚本内所有命令原子执行且脚本加载后用EVALSHA调用比EVAL更高效。这是高并发计数器的进阶武器但也是黑匣子最多的地方。4.1 库存扣减INCR GET 条件判断一体化现象INCR stock:1001后再GET stock:1001判断是否超卖两步之间可能被其他请求修改导致超卖。解决用 Lua 把“读-判-写”锁死在服务端。-- stock_decr.lua -- KEYS[1] stock key, ARGV[1] decrement amount, ARGV[2] min threshold local current redis.call(GET, KEYS[1]) if current false then return -1 -- key not exists end local stock tonumber(current) local decr tonumber(ARGV[1]) local minThreshold tonumber(ARGV[2]) if stock decr then return -2 -- insufficient stock end if stock - decr minThreshold then return -3 -- below min threshold end redis.call(INCRBY, KEYS[1], -decr) -- 原子扣减 return stock - decrJava 调用public class StockCounter { private static final String STOCK_DECR_SCRIPT local current redis.call(GET, KEYS[1]) if current false then return -1 end local stock tonumber(current) local decr tonumber(ARGV[1]) local minThreshold tonumber(ARGV[2]) if stock decr then return -2 end if stock - decr minThreshold then return -3 end redis.call(INCRBY, KEYS[1], -decr) return stock - decr; private static final String SCRIPT_SHA stock_decr_sha; // 预加载脚本后的 SHA1 // 首次加载脚本应用启动时执行一次 public static void loadScript() { try (Jedis jedis pool.getResource()) { String sha jedis.scriptLoad(STOCK_DECR_SCRIPT); jedis.set(SCRIPT_SHA, sha); // 缓存 SHA 到 Redis避免每次查 } } public static long decrStock(String key, int amount, int minThreshold) { try (Jedis jedis pool.getResource()) { String sha jedis.get(SCRIPT_SHA); Object result jedis.evalsha(sha, Collections.singletonList(key), Arrays.asList(String.valueOf(amount), String.valueOf(minThreshold)) ); return ((Long) result).longValue(); } } }为什么用EVALSHA而不是EVALEVAL每次传输完整脚本网络开销大EVALSHA只传 40 字符 SHA快 10 倍以上。EVALSHA失败时脚本未加载自动 fallback 到EVALJedis 3.0 自动处理。血泪经验脚本里禁止用redis.call(KEYS, *)这类全量扫描命令会阻塞 Redis 主线程所有 key 必须通过KEYS参数传入。4.2 滑动窗口限流用 ZSET 实现精准时间窗口现象用固定窗口如每分钟 100 次限流窗口切换瞬间允许 200 次请求被刷爆。解决用ZSET存储时间戳ZRANGEBYSCORE查窗口内请求数ZREMRANGEBYSCORE清理过期项。-- sliding_window_limit.lua -- KEYS[1] zset key, ARGV[1] current timestamp, ARGV[2] window size (ms), ARGV[3] max count local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local maxCount tonumber(ARGV[3]) local minScore now - window -- 清理过期时间戳 redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, minScore) -- 添加当前请求时间戳 redis.call(ZADD, KEYS[1], now, now .. : .. math.random(1000, 9999)) -- 获取当前窗口请求数 local count redis.call(ZCARD, KEYS[1]) if count maxCount then return 0 -- exceed limit else return 1 -- allow endJava 调用注意ZSET key 需按用户/接口维度分片避免热点public static boolean isAllowed(String userId, long nowMs, int windowMs, int maxCount) { String key limit:zset:user: userId; try (Jedis jedis pool.getResource()) { Object result jedis.eval(SLIDING_WINDOW_SCRIPT, Collections.singletonList(key), Arrays.asList(String.valueOf(nowMs), String.valueOf(windowMs), String.valueOf(maxCount)) ); return ((Long) result) 1L; } }ZSET 为什么比 LIST 更适合滑动窗口ZREMRANGEBYSCORE时间复杂度 O(log(N)M)M 是删除元素数LRANGELTRIM需全量遍历O(N)。ZSET 天然支持按 score时间戳排序无需额外维护顺序。坑now必须用毫秒时间戳System.currentTimeMillis()不能用秒否则精度不够窗口不准。5. 压测验证与线上熔断让计数器真正扛住流量洪峰写完代码只是开始没压测的计数器等于没写。很多团队卡在“不知道怎么压测”和“压测出问题不会调优”两个环节。这里给出一套可复现的压测流程、关键指标阈值和线上熔断兜底方案全是实操踩出来的坑。5.1 四步压测法从单 key 到集群的真实检验Step 1单 key 基线测试定位 Redis 本身能力用官方redis-benchmark测INCR极限# 模拟 100 并发100 万次请求key 名随机避免客户端缓存 redis-benchmark -h 127.0.0.1 -p 6379 -a your_password \ -n 1000000 -c 100 -r 100000000 INCR __rand_int__合格线Redis 7.0 单机16C32G应达 8~12 万 QPS。若 5 万检查网卡ethtool eth0确认千兆/万兆、CPU 频率cpupower frequency-info、Redis 配置tcp-backlog511是否足够。Step 2业务 key 模式测试模拟真实分布用 JMeter 或自研压测工具按业务比例生成 key80% 请求打向 20% 热点 key模拟爆款商品20% 请求打向长尾 key模拟普通用户// JMeter JSR223 Sampler 示例Groovy def keys [product:1001, product:1002, user:10001, user:10002] def hotKeys keys.take(2) // 前2个是热点 def key Math.random() 0.8 ? hotKeys[new Random().nextInt(hotKeys.size())] : keys[new Random().nextInt(keys.size())] vars.put(counter_key, key)关注指标热点 key 的latency_percentile_9999% 延迟应 5ms长尾 key 应 2ms。若热点 key 延迟飙升说明分片不均或未用 Hash Tag。Step 3集群混合负载测试验证分片与网络启动redis-cli --cluster check确认集群状态后用redis-benchmark指向任意节点Cluster 自动路由redis-benchmark -h 192.168.1.10 -p 7000 -a your_password \ -n 1000000 -c 200 -r 100000000 INCR {user:1001}:login关键检查redis-cli --stat观察各节点instantaneous_ops_per_second是否均衡偏差 20%。若某节点持续 2 倍于其他节点说明 Hash Tag 设计有问题如{user:1001}和{user:1002}实际 hash 值冲突。Step 4故障注入测试验证容灾能力手动 kill 一个 master 节点观察客户端是否自动重连JedisCluster 默认重试 5 次间隔 100ms计数器是否继续工作Cluster 自动 failover新 master 接管 slotINCR命令是否返回MOVED或ASK重定向正常客户端应自动处理5.2 线上熔断兜底计数器不可用时的优雅降级压测再好也防不住线上突发故障。计数器服务不可用时不能让整个业务挂掉。必须设计三层熔断层级触发条件降级策略恢复方式Client 熔断Jedis 连接池耗尽JedisConnectionException连续 5 次返回默认值如0或上次缓存值记录 warn 日志连接池自动重建5 分钟后恢复Redis 熔断redis-cli --latency检测到 P99 延迟 100ms 持续 1 分钟切换到本地 Caffeine 缓存LoadingCache异步写 Redis延迟恢复正常后自动切回 Redis业务熔断计数器核心功能如库存扣减失败率 30% 持续 30 秒关闭秒杀入口返回“系统繁忙请稍后再试”运维人工确认后调用开关 API 恢复Java 代码实现基于 Resilience4j// 定义熔断器 CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(30.0) // 失败率阈值 .waitDurationInOpenState(Duration.ofSeconds(30)) // 保持 OPEN 状态时间 .ringBufferSizeInHalfOpenState(10) // HALF_OPEN 状态下最多试 10 次 .build(); CircuitBreaker circuitBreaker CircuitBreaker.of(counterService, config); // 包装计数器调用 public long safeIncr(String key) { return circuitBreaker.executeSupplier(() - { try { return SimpleCounter.incr(key); } catch (Exception e) { // 记录 error 日志但不抛出让熔断器捕获 log.error(Counter incr failed for key {}, key, e); throw e; // 必须抛出异常熔断器才能统计 } }).orElseGet(() - { // 熔断时降级返回 0 或从本地缓存取 log.warn(Counter service is degraded, returning default for key {}, key); return getDefaultCount(key); }); } private long getDefaultCount(String key) { // 方案1返回 0适合 PV/UV 统计丢失数据可接受 // 方案2查本地 LoadingCache适合库存需保证最终一致性 return localCache.get(key, k - loadFromDB(k)); }注意熔断器failureRateThreshold不要设太低如 5%否则偶发网络抖动就触发也不要设太高如 80%失去保护意义。30% 是经过 3 次大促验证的平衡点。6. 最后一条别迷信 INCR用对场景才是真功夫我见过太多团队一听说“高并发计数器”上来就堆 Redis INCR结果在库存场景翻车——因为INCR只保证原子性不保证业务正确性。比如扣库存INCRBY stock:1001 -1成功了但没校验是否为负数导致库存变负。后来我们强制所有库存操作走 Lua 脚本把“读-判-写”锁死才彻底解决。也见过用INCR做用户积分结果因网络分区导致主从不一致用户看到积分忽高忽低。最后改用 Redis Streams 消费者组保证消息至少一次投递积分更新最终一致。所以INCR是利器但不是银弹。它的价值在于用最简路径解决最明确的问题。当你需要“高频、无状态、强原子”的计数时它是王者当你需要“业务规则校验、多数据源协同、强一致性保障”时它只是拼图的一角。真正的技术深度不在命令有多炫而在你能清晰画出这条线左边是 Redis 能扛住的右边是必须交给数据库或消息队列的。每次写INCR前多问一句“这个计数丢了会怎样错了会怎样慢了会怎样”答案会告诉你该不该用该怎么用。希望帮到你。本文还有配套的精品资源点击获取
返回列表