接手过一个开放API平台,用户每天有固定调用次数。最开始只有一台服务器,代码里放一个ConcurrentHashMap就能记录每个用户的请求次数,简单直接,谁也没觉得这算个事。后来接口需要扩容,从1台变成3台,前面再挂一个负载均衡,麻烦就来了。上线第三天就有大客户投诉:额度明明是每天10000次,上午调了8000次,下午怎么还能继续调?查日志发现,上午的请求落在A机,下午的请求落在B机,B机上根本没有这个用户今天的计数,于是又给放行了。还有一种更隐蔽的坏情况:同一个用户分散到三台机器,每台都只记录了三分之一,最终统计出来的使用量完全失真。
这个场景很多人都遇到过:API接口分布在多台服务器之后,"同步用户的每日请求次数"就不再是一个进程内的变量,而是一个必须跨节点共享的全局状态。这篇内容就把我梳理过的几种方案、踩过的坑、以及最终在中等规模下稳定运行的完整思路写出来。后端开发、运维,或者做开放平台的同学,都可以拿来当一份实战参考。
1. 从单机走向集群,计数问题为什么会跟着变复杂
1.1 单机时代的"伪同步"
先回看一下单机时代是怎么做的。绝大多数人最初的写法都差不多:一个Map,Key是"用户ID+日期",Value是计数。请求进来,先查Map判断是否超限,没超就加1放行,超了就抛异常。
java复制private final ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
public boolean tryAcquire(Long userId) {
String key = userId + ":" + LocalDate.now();
int count = counter.getOrDefault(key, 0);
if (count >= LIMIT) {
return false;
}
counter.put(key, count + 1);
return true;
}
这段代码在单机下完全够用。它的核心特征是:计数状态的读写和请求处理发生在同一个进程内,不存在"你看到的数据和我看到的数据不一样"的问题。但这里有个隐藏前提:全世界的请求都只有一个进程在接收。一旦你开始加服务器,这个前提就塌了。
1.2 多机环境下的三个本质变化
拆成多台服务器后,问题并不是"数据没同步",而是下面三件事同时变了:
第一,状态从单点变成多点。同一个用户的两次请求,经过负载均衡可能落到不同机器。A机上的计数器只在A机的JVM里存在,B机完全看不见。这样用户实际产生了多少请求,没有一个节点能给出全局答案。
第二,时间窗口的起点变得不一致。"每日"这个概念在单机上依赖服务器本地时间,今天零点清零,逻辑很清晰。多台机器的系统时钟只要有毫秒级的偏差,就会出现"A机已经清零、B机还没清零"的窗口期,用户在窗口期内可能被错误放行或错误拦截。
第三,"超限判断"需要一个全局视图。单机时"当前计数是多少"直接查Map就行;多机时,任何一个节点的本地判断都是片面的。你需要一个所有节点都认可的"事实源",要么是数据库,要么是Redis,要么是某种自研的协调机制。
1.3 想清楚要同步的到底是什么
很多人一上来就去找"数据库同步工具",想把多台服务器之间的数据打通,这个方向其实偏了。我们要同步的不是历史明细数据,而是"计数累加"这个动作的全局一致性。 换句话讲:无论这个用户这次请求打到哪台机器上,他消耗的那一次次数,必须累计到同一个总账里,并且任何一台机器在执行超限判断时,看到的总账必须是最新的。
所以"同步"这个目标的本质是:选择一个或多个节点都能访问的、具备原子自增能力的存储,让计数操作从"本地变量++"变成"全局原子++"。存储选型、原子性保证、以及性能取舍,才是这篇文章真正要讨论的内容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四条主流同步路线,我分别踩过的坑
2.1 中心数据库:不推荐直接扛流量
最朴素的思路:既然要全局一致,那就用关系数据库。建一张表,主键是(user_id, stat_date),每次请求来就执行一行UPDATE累加,然后SELECT判断是否超限。
sql复制CREATE TABLE user_daily_quota (
user_id BIGINT NOT NULL,
stat_date VARCHAR(10) NOT NULL,
req_count INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, stat_date),
KEY idx_date_user (stat_date, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
请求处理时执行:
sql复制INSERT INTO user_daily_quota (user_id, stat_date, req_count)
VALUES (10001, '2025-01-01', 1)
ON DUPLICATE KEY UPDATE req_count = req_count + 1;
SELECT req_count FROM user_daily_quota
WHERE user_id = 10001 AND stat_date = '2025-01-01';
这个方案几十个QPS的时候一点问题都没有,事务和行锁替你把并发安全都管好了。我早期一个内部系统就是这么做的,跑了好几个月没出过岔子。但是等到对外开放,日请求量到了十万级,问题就全出来了:每来一次请求至少要跟数据库交互两次(一次更新、一次查询),连接池很快被打满;同一行记录上的锁竞争会让同一用户的请求排队,响应时间呈线性恶化;数据库一旦有主从延迟,从库读到的计数可能不是最新的,直接导致超额放行。
所以要我说:数据库方案用于兜底校验、离线对账、阈值告警是可以的,但直接放在请求链路上扛用户流量,性价比很低。真要用,也应该配合批量合并写入,比如每个应用节点攒100条计数一次性落库,而不是每次都打到数据库。
2.2 Redis:分布式计数的默认答案
Redis几乎成了这类问题的事实标准。原因很直接:INCR命令本身是原子的,天然支持"多台服务器同时对一个key做自增而不会丢次数";内存操作,单机QPS可以到十万以上;自带的EXPIRE可以让"每日"这个窗口自动过期清理。
它的价值在于,把"多台服务器同步计数"这个问题,从应用层代码转移到了Redis的原子操作里。所有API节点不需要互相通信,只需要共同访问同一个Redis key。用户在A机消耗了一次,A机执行INCR;在B机消耗了一次,B机也执行INCR。只要两个请求都打到同一个Redis(或者同一分片),总次数就永远不会丢。
我后来把这个方案做成了一套通用组件,核心逻辑并不复杂,关键点在Key设计、原子脚本、以及高并发下的降级策略上。这些放在第3节和第4节详细讲。
2.3 本地缓存+异步上报:性能和精确性的折中
Redis方案也不是银弹。当请求量再往上走,每次请求都同步INCR一次Redis,即使Redis扛得住,网络RTT也白白消耗了。这时候可以考虑:每个API节点在本地维护一份计数,达到一定阈值后再批量同步到Redis。
具体做法是,每台服务器启动时从Redis读取当天的总计数作为基准,然后本地用AtomicLong累加;本地计数达到某个上限(比如总额度的一半,或者每10秒一次)就上报一次增量,刷新Redis里的总量。超限判断时,本地允许一定的超额缓冲,比如本地判断还没到上限就放行,到了上限之后强制回源查一次Redis确认。
这条路牺牲的是精确性,换来的是吞吐量。它适合那种"每天100万次请求但允许几十次误差"的业务。我的经验是,不要在项目初期就上这套,先把Redis方案跑稳,等监控数据显示Redis成了瓶颈再去改。
2.4 网关层统一计数:架构视角的另一种解法
还有一个容易被忽略的思路:如果API请求必须经过网关,那网关就是天然的集中计数点。我见过不少团队在业务代码里写死计数逻辑,而把限流、配额判断放在网关层反而更干净。
比如用Nginx+Lua、APISIX的limit-count插件、Spring Cloud Gateway的RequestRateLimiter过滤器,都可以实现按用户维度计数。网关层做这件事有一个好处:业务服务器完全不需要关心计数这件事,也从根本上避免了多机计数不一致,因为请求在到达业务服务之前就被网关拦截了。
但要注意,网关如果自身也部署了多实例,它依然面临和API服务器一样的问题:多个网关节点之间如何同步计数?所以网关层方案通常还是底下放一个Redis,并不是说上了网关就能摆脱分布式计数问题。它只是把问题收敛到了一个更可控的边界。
2.5 方案对比速查表
我把几个方案放在一起对比,这种场景下选型会直观很多:
| 方案 | 准确性 | 性能 | 复杂度 | 典型适用场景 |
|---|---|---|---|---|
| 数据库行更新 | 高 | 低(行锁+连接瓶颈) | 低 | 低QPS、对账、离线统计 |
| Redis原子计数 | 高 | 高 | 中 | 绝大多数在线额度控制 |
| 本地缓存+异步上报 | 中(有窗口误差) | 很高 | 高 | 超高QPS且容忍少量误差 |
| 网关层集中限流 | 高(若网关多实例仍需Redis) | 高 | 高 | 已有统一网关的开放平台 |
一句话总结选型:没到万级QPS之前,Redis原子计数是最优解;到了万级以上,再考虑本地缓冲+分片。
3. Redis计数器落地:Key设计、原子命令与Lua脚本
3.1 Key的设计比想象中讲究
实现Redis计数之前,Key怎么设计是个容易栽跟头的地方。最忌讳的写法是只把用户ID拼进去:
code复制user:10001
这个Key没有任何日期信息,那么"每日"的概念就只能靠EXPIRE来模拟,一旦某天Redis重启键丢失,或者过期时间计算有偏差,整个计数体系就乱了。我建议的Key格式是:
code复制api:quota:{userId}:{yyyyMMdd}
后面的日期是自然日的标识,不带小时分钟秒。这样有几个好处:第一,每天的计数天然独立,不需要和前一天的数据做切割;第二,恢复和排查问题时可以直接按日期统计;第三,即使某个Key的过期时间设置失败,也不会跟昨天、明天的数据混在一起。
还有一个细节:如果用户ID是纯数字,直接在Key里拼接即可;如果用户ID可能是字符串、甚至含特殊字符,建议先做一层规范化处理,避免出现奇怪的Key名。另外,日期字符串一定要统一时区,建议全局用UTC+8或者某个固定时区计算,不要用每台服务器本地的默认时区,否则时钟偏差会直接影响"每日"的边界。
3.2 INCR + EXPIRE的正确写法
很多人第一次写Redis计数器是这样写的:
java复制long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofHours(24));
if (count > limit) {
// 超限
}
这段代码有一个隐藏问题:每次请求都调用一次EXPIRE,等于每来一个请求就重置一次过期时间。如果某个用户持续活跃,这个Key的TTL会被无限续期,导致"每日重置"迟迟不来。正确做法是,只有第一次创建Key时才设置过期时间。因为INCR的返回值如果是1,说明这是Key被创建后的第一次自增,此时顺势设置过期时间就可以了:
java复制String key = buildQuotaKey(userId, LocalDate.now());
Long current = redisTemplate.opsForValue().increment(key);
if (current == 1) {
// 当天第一次请求,设置过期时间到今天结束后的一个小时,留缓冲避免跨日时旧Key残留
long expireAt = LocalDate.now().plusDays(1).atStartOfDay()
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
redisTemplate.expireAt(key, new Date(expireAt + 3600_000));
}
if (current > limit) {
// 拒绝请求
return false;
}
return true;
注意,过期时间不要简单设成"24小时",要设成"今天结束时刻的剩余秒数"。因为"每日"是自然日,用户在23:59发起的请求,如果TTL是24小时,那就意味着计数会一直存活到第二天23:59,造成第二天的数据里混入前一天末尾的计数。
3.3 带额度判断的Lua脚本
如果是单纯计数,INCR就够了。但判断"是否超限"往往要在同一个原子操作里完成,否则会出现并发穿透:两个请求同时INCR,一个返回2999,一个返回3000,如果限额是3000,两个都判断没有超限,等于多放行了一次。
正确的做法是用Lua脚本把"自增、设置过期、超限判断"合并成一个原子操作:
lua复制-- KEYS[1]:计数器Key
-- ARGV[1]:用户每日上限
-- ARGV[2]:过期时间戳(毫秒)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIREAT', KEYS[1], ARGV[2])
end
if current <= tonumber(ARGV[1]) then
return current
else
return -1
end
在Java里调用:
java复制String script = "local current = redis.call('INCR', KEYS[1]) "
+ "if current == 1 then redis.call('PEXPIREAT', KEYS[1], ARGV[2]) end "
+ "if current <= tonumber(ARGV[1]) then return current else return -1 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(key),
String.valueOf(limit),
String.valueOf(expireAt)
);
if (result == null || result == -1L) {
// 超限
}
Lua脚本在Redis中是原子执行的,所以这个方案可以做到"一次判断+一次自增+一次设置过期",中间不会被其他命令插入。这也是线上限流最常见的写法。
3.4 滑窗计数怎么实现
上面讲的都是"按自然日计数"。但日常请求控制往往还有更细的窗口,比如"60秒内最多请求5次"。这时INCR+EXPIRE就不够精确了,因为它只能做固定窗口,而固定窗口在边界会有双倍放行的问题。比如每1秒限流5次,用户在0.9秒请求5次、1.1秒又请求5次,固定窗口会全部放行。
滑动窗口的经典实现是用Redis的ZSET:
code复制ZADD user:window:{userId} 当前毫秒时间戳 当前毫秒时间戳:随机串
ZREMRANGEBYSCORE user:window:{userId} 0 当前毫秒时间戳-60000
ZCARD user:window:{userId}
EXPIRE user:window:{userId} 120
原理很直白:每个请求在ZSET里存一条带时间戳的记录,每次请求前先删掉窗口之前的旧记录,再数一下窗口内还有多少条,超过阈值就拒绝。为了节省内存,可以只保留最近几万个元素,或者对ZSET定期做瘦身。滑窗的精确性比固定窗口好很多,但内存消耗也更大,所以通常只对重点用户做,不会对全量用户开。
4. 真正上线后,我遇到的四个防不胜防的坑
4.1 热点用户拖垮Redis单Key
Redis本身性能很高,但单个Key的读写是串行的。当一个极端活跃用户(比如企业大客户,或者内部一个跑批任务的服务号)每秒产生上万次请求,所有请求都INCR同一个Key,这个Key所在的分片CPU会明显飙升,严重时会把整个Redis实例打爆。
我自己遇到过一次:某个数据同步服务的服务账号,一个小时内调用了上百万次API,直接把Redis某个节点的CPU拉到了90%以上,导致其他正常用户的计数请求也开始超时。
解决办法通常是"拆Key"。不要把一个用户的所有计数压在一个Key上,可以按用户ID哈希分成N个桶:
code复制api:quota:{userId}:{yyyyMMdd}:{slot}
其中 slot = userId % N,N取值根据单用户峰值QPS来定,一般在8到64之间。写入时直接定位到对应分片;读取总计数时用Lua把所有分片的值加一遍:
lua复制local total = 0
for i = 1, #KEYS do
total = total + tonumber((redis.call('GET', KEYS[i]) or 0))
end
return total
这个方案有一个代价:超限判断不再是原子精确的,因为求和本身和自增不是同一个原子操作,会有一定的时间差。但对于绝大多数业务来说,允许少量误差换取抗热点能力是划算的。
4.2 Redis抖动引发的全局雪崩
这是最容易被忽略的坑。计数器功能本身不是核心业务,但如果你把"判断是否超限"放在请求主链路上,那么Redis一旦抖动,所有API请求都会跟着抖动,甚至全部超时。我就经历过一次:Redis实例因为备份触发了一次短暂阻塞,结果整个API平台的接口全部变慢,吓得我以为是业务代码出了问题。
所以做Redis计数之前,必须先想好降级方案。我的做法是:为每个节点维护一份本地兜底计数,当Redis发生连接异常、超时、或者返回错误时,本地NodeCounter继续累加,同时把这个节点的状态标记为"降级模式"。
java复制public boolean tryAcquireInDegradeMode(Long userId, int localLimit) {
AtomicInteger localCount = localCounter.computeIfAbsent(userId, k -> new AtomicInteger());
return localCount.incrementAndGet() <= localLimit;
}
降级模式下,单节点允许的额度可以设置得比全局额度更宽一些,比如全局是10000次,降级节点本地放行上限是单机预估承载的120%。这样做的意思是:宁可短时间超限,也不能让整个接口不可用。等Redis恢复后,再把本地增量异步合并回去,或者直接丢弃本地缓存,重新从Redis读取权威计数。
4.3 主从切换导致计数"回退"
Redis的高可用部署一般会做主从。如果主节点宕机,哨兵会把从节点提升为主节点。这里有个风险:主从切换瞬间,部分未被同步的INCR操作可能丢失,计数会"回退"到之前的某个值,相当于给用户多放行了一部分请求。
能不能完全避免?理论上可以,比如开启WAIT命令强制同步到从节点再返回,或者直接用RedLock多点写入,但成本太高,日常业务根本没必要。我采取的策略是:允许Redis本身的高可用问题导致"计数偶尔回退",因为用户每日请求次数的统计,更怕的是"明明没超限被拦截",而不是"短暂超了一点点"。如果产品上对超限极其敏感,那就需要加一道每日对账任务,定时把Redis里的计数和数据库里的请求日志做比对,人工或自动修正。
4.4 并发扣减最终超卖
还有一个和计数器联动的问题:如果你的业务不只是计数,而是"按次扣减一批额度"(比如用户有1000次调用包,每次调用扣1次),那么扣减动作也必须是原子的。很多人写成"先查剩余次数,再减1,再写回",这在并发下一定会超卖。
最好的做法还是用Lua原子操作:
lua复制-- 扣减并返回剩余次数,余额不足返回-1
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current < tonumber(ARGV[1]) then
return -1
end
return redis.call('DECRBY', KEYS[1], ARGV[1])
或者直接用Redis的DECR命令,返回值如果小于0说明扣超了,需要回滚:
java复制Long remain = redisTemplate.opsForValue().decrement(key);
if (remain < 0) {
redisTemplate.opsForValue().increment(key); // 回滚
return false;
}
注意回滚也有原子性问题,最好还是DECRBY + 判断一次完成。总之,所有"读-改-写"模式在分布式环境里都必须想办法合并成原子的单个命令或Lua脚本。
5. 请求量再往上走:拆Key、本地预扣与网关整合
5.1 拆Key分片:用精确度换容量
前面提到热点用户拆Key,其实对于整体高并发场景,分片也是通用思路。假设一个应用日请求量到了千万级,所有用户的计数都打到Redis,即便Redis能扛,网络IO和序列化开销也会成为瓶颈。
更好的做法是给计数Key做分片。分片粒度可以按用户维度,也可以按日期维度。比如将用户ID哈希到64个桶,每个桶对应一个Redis实例分片。写入时直接定位到分片,读取时对所有分片求和。这里要注意:写入路径和读取路径都必须经过同一个分片算法,否则数据就找不到了。
分片会带来两个副作用:一是超限判断不精确(求和操作不是原子的),二是一次请求可能触发多分片查询。所以分片通常配合"先写后读"策略:写入时如果发现已经超过单机预估上限,就直接拦截;最终超限判断可以稍微放宽一点,靠后台任务做对账。
5.2 本地预扣+批量回写:削掉峰值
到了真正的超高并发场景(单机每秒几千上万个仅涉及计数的请求),每次请求都走一次Redis已经不太现实了。我建议的方案是"本地预扣+批量回写":
- 服务启动时,从Redis读取今天的总计数作为本地基准,加载到内存。
- 请求到达时,先扣本地基准里的额度,本地扣减是AtomicLong,性能可以做到极其轻盈。
- 本地每累积到一定数量(比如1000次)或者每隔5秒,批量把这批增量发给Redis,用INCRBY累加。
- 超限判断时,先用本地值判断。如果本地值已经接近上限,再同步回源一次Redis,拿全局最新值确认。
这个流程有点类似CPU的多级缓存:近似值给得快,权威值给得准。实际效果是,绝大多数请求根本不需要触达Redis,Redis的压力能下降一个数量级以上。代价是误差窗口,在批量回写之前的几秒内,其他节点可能看不到这个节点产生的最新计数。这里的取舍要提前和产品对齐,毕竟很多业务场景"允许每天有几十次误差"完全不影响用户体验。
5.3 与API网关限流能力整合
最后说网关层。如果团队维护了统一API网关,那么每日请求次数这件事完全可以下沉到网关。比如APISIX的limit-count插件,可以按consumer维度设置每日限额,底层存储支持Redis,多个网关节点之间天然共享计数。Spring Cloud Gateway也有RequestRateLimiter过滤器,用Redis做分布式计数器。
网关整合最大的好处是业务代码彻底干净了:所有计数、额度、拦截都在网关做,应用服务器不需要知道这些逻辑。但有个前提:必须保证所有需要计数的API都走这个网关。我见过一些平台,部分老接口没接入网关,用户换个接口路径就绕开了计数,最后统计数据漏了一大截。所以做网关整合时,第一步是把所有API都收敛到网关统一入口,再做限流计数。
如果你已经在用APISIX这类云原生网关,我建议先用它自带的limit-count,避免自己造轮子。它内部其实也是INCR+EXPIRE的逻辑,但省去了你维护Lua脚本和Key过期的工作。
最后再说一个个人体会:分布式环境下的计数同步,没有"既要又要"的方案。Redis原子计数是大多数业务的第一选择,但Redis的可用性和热点问题必须有预案;本地缓存方案性能和精确性不可兼得,但往往又是大规模业务的必经之路。我踩过最痛的一次坑,是把Redis当成"永远可用"的组件,没有做任何降级,结果一次小小的Redis抖动让整个平台接口大面积超时。从那以后,我给自己定了一条规矩:任何写入主链路的第三方依赖,都必须预设它挂掉时的行为。计数可以短暂不准,接口不能因此不可用。这个原则放在哪里都不会过时。
