在分布式系统中,有两个问题经常会遇到:
接口重复提交;
高并发流量冲击。
前者对应的是接口幂等性问题,后者对应的是接口限流问题。
这两个问题看起来不一样,但本质上都和“如何控制请求行为”有关:
幂等性关注的是:同一个业务操作重复执行时,结果不能出错;
限流关注的是:单位时间内请求量过大时,系统不能被打垮。
本文记录接口幂等性和分布式限流的常见处理方式,包括:
Update 操作如何保证幂等;
Token 机制如何处理重复提交;
限流常见维度;
令牌桶和漏桶算法;
Guava RateLimiter 单机限流;
Nginx 限流;
Redis + Lua 分布式限流。
一、什么是接口幂等性
接口幂等性指的是:
用户对同一个操作发起一次请求或多次请求,最终产生的结果应该是一致的,不应该因为重复请求产生额外副作用。
最典型的例子是支付。
用户购买商品并发起支付,服务端扣款成功,但返回结果时网络异常。
此时用户没有看到成功结果,于是再次点击支付按钮。
如果系统没有做好幂等控制,可能会发生第二次扣款,最终导致:
用户余额被多扣;
支付流水多出一条;
订单状态异常;
后续对账困难。
所以,对于支付、下单、退款、库存扣减、优惠券领取等接口,幂等性非常重要。
幂等性的核心思想是:
使用唯一业务标识识别一次业务操作。
如果该业务操作已经执行过,再次请求时不要重复执行核心逻辑。
在非并发场景下,可以通过查询业务单号判断是否处理过。
在并发场景下,还需要配合锁、唯一索引或状态机控制。
二、Update 操作的幂等性
对于更新操作,可以通过版本号控制幂等。
常见做法是:
用户查询数据;
后端返回数据时,同时返回版本号;
用户修改后提交;
后端更新时带上版本号作为条件;
更新成功后版本号加一。
SQL 示例:
UPDATE table_name
SET version = version + 1,
xxx = #{xxx}
WHERE id = #{id}
AND version = #{version};
如果第一次提交成功,版本号会发生变化。
后续重复提交时,原来的版本号已经失效,更新条件无法匹配,因此不会再次更新。
这种方式本质上是乐观锁。
它适合处理类似下面的场景:
用户编辑资料;
修改订单备注;
修改配置;
更新状态;
管理后台编辑数据。
需要注意的是,更新后要判断影响行数。
如果影响行数为 0,说明数据可能已经被别人修改过,或者重复提交了旧版本请求。
三、使用 Token 机制保证重复提交幂等
对于没有天然唯一业务号的 insert 或部分 update 操作,可以使用 Token 机制。
典型流程如下:
1. 用户进入页面
2. 后端生成一个唯一 Token
3. Token 返回给前端
4. 前端提交表单时携带 Token
5. 后端根据 Token 判断是否已经处理过
6. 第一次请求执行成功
7. 后续相同 Token 的请求直接拒绝或返回已处理结果例如注册、表单提交、创建订单等场景,都可以使用这种方式。
一个简单的处理思路是:
Token 不存在:拒绝请求
Token 存在且未使用:执行业务逻辑,并标记为已使用
Token 已使用:拒绝重复提交在分布式环境中,Token 状态通常可以存储在 Redis 中。
如果涉及并发提交,需要保证 Token 校验和状态变更的原子性。
例如使用:
Redis
SETNX;Redis Lua 脚本;
分布式锁;
数据库唯一索引。
进入注册页时,后台生成 Token 返回前端隐藏域;用户提交时携带 Token,后端使用 Token 获取分布式锁,完成 Insert 操作,执行成功后不主动释放锁,等待过期自动释放。
这个思路可以防止短时间内重复提交。
不过在实际项目中,需要根据业务选择更合适的策略:
如果希望 Token 只能使用一次,可以执行成功后删除或标记 Token;
如果希望短时间内防重复,可以设置较短过期时间;
如果希望重复请求返回相同结果,可以保存第一次处理结果。
四、什么是分布式限流
限流的目标是:
在一定时间窗口内限制系统资源访问量,避免流量过大导致系统不可用。
比如:
每秒最多处理 100 个请求
同一个 IP 每秒最多访问 10 次
单个用户每分钟最多发送 5 条短信
某个接口每秒最多允许 1000 次调用
在单机系统中,限流状态可以保存在本地内存中。
但在分布式系统中,请求会落到不同服务节点。
如果每个节点都各自统计,就会出现整体限流不准确的问题。
例如:
限制同一个 IP 每秒最多 10 次访问
系统有 5 台机器
如果每台机器各自限 10 次
那么整个集群实际可能允许 50 次访问
所以分布式限流通常需要一个统一的限流位置或统一的状态存储。
常见方案包括:
网关层限流;
Nginx 层限流;
Redis 统一计数限流;
中间件层限流;
应用内单机限流。
五、限流的常见维度
限流通常不是只按一个条件做,而是多个维度组合使用。
1. QPS 限流
限制单位时间内的请求数。
例如:
某接口每秒最多 100 次请求
某 IP 每秒最多 10 次请求
某用户每分钟最多 60 次请求2. 连接数限流
限制同时存在的连接数量。
例如:
同一个 IP 最多保持 5 个连接
单台服务最多保持 200 个连接3. 传输速率限制
常见于文件下载场景。
例如:
普通用户下载速度 100KB/s
会员用户下载速度 10MB/s4. 黑白名单
黑名单用于拒绝访问。
白名单用于绕过部分限流规则。
例如:
高频异常访问 IP 加入黑名单;
内部系统 IP 加入白名单;
重要合作方账号加入白名单。
5. 集群整体限流
把整个分布式集群作为一个整体进行限流。
例如:
整个订单服务集群每秒最多接收 5000 个请求
这种场景就需要中心化存储限流状态,例如 Redis。
六、令牌桶算法
令牌桶是限流中常见的算法。
它的核心元素有两个:
令牌;
桶。
请求只有拿到令牌,才能继续执行。
令牌会按照固定速率放入桶中。
如果桶满了,新生成的令牌会被丢弃。
处理流程可以理解为:
1. 系统按照固定速率生成令牌
2. 令牌放入令牌桶
3. 请求到来时尝试获取令牌
4. 获取成功,请求放行
5. 获取失败,请求等待或拒绝
令牌桶的特点是:
可以允许一定程度的突发流量。
因为桶中可以预存一部分令牌。
当突发请求到来时,只要桶里有令牌,就可以立即处理。
所以令牌桶比较适合既要限制平均速率,又允许短时突发的场景。
七、漏桶算法
漏桶算法和令牌桶类似,但处理对象不同。
漏桶中放的是请求。
请求进入桶后,系统按照固定速率从桶中取出请求并处理。
处理流程可以理解为:
1. 请求到来后进入漏桶
2. 如果桶未满,请求排队
3. 如果桶已满,新请求被拒绝
4. 系统按照固定速率处理桶中的请求
漏桶的特点是:
输出速率稳定。
无论请求进入桶的速度多快,最终流向后端服务的请求速率都是固定的。
它比较适合保护后端系统,避免突发流量直接打到下游。
八、令牌桶和漏桶的区别
两者都可以限流,但侧重点不同。
简单理解:
令牌桶限制的是“平均速率”,但允许突发;
漏桶限制的是“输出速率”,更平滑稳定。
九、Guava RateLimiter 单机限流
Guava 提供了 RateLimiter,它基于令牌桶思想实现。
先引入依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
示例 Controller:
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
@Slf4j
public class RateLimitController {
/**
* 每秒生成 2 个令牌
*/
private final RateLimiter limiter = RateLimiter.create(2.0);
/**
* 非阻塞限流
*/
@GetMapping("/tryAcquire")
public String tryAcquire(Integer count) {
if (limiter.tryAcquire(count)) {
log.info("允许通过,rate={}", limiter.getRate());
return "success";
}
log.info("请求被限流,rate={}", limiter.getRate());
return "fail";
}
/**
* 带超时时间的限流
*/
@GetMapping("/tryAcquireWithTimeout")
public String tryAcquireWithTimeout(Integer count, Integer timeout) {
if (limiter.tryAcquire(count, timeout, TimeUnit.SECONDS)) {
log.info("允许通过,rate={}", limiter.getRate());
return "success";
}
log.info("请求被限流,rate={}", limiter.getRate());
return "fail";
}
/**
* 阻塞式限流
*/
@GetMapping("/acquire")
public String acquire(Integer count) {
limiter.acquire(count);
log.info("允许通过,rate={}", limiter.getRate());
return "success";
}
}
三种方式区别如下:
Guava RateLimiter 适合单机限流。
如果服务部署多台,每台机器都有自己的 RateLimiter,无法保证集群整体限流准确。
十、Nginx 基于 IP 限流
Nginx 可以在流量入口层做限流。
例如按 IP 限制访问频率。
先在 hosts 文件中配置测试域名:
127.0.0.1 www.test.com
后端测试接口:
@RestController
@Slf4j
public class NginxController {
@GetMapping("/nginx")
public String nginx() {
log.info("Nginx success");
return "success";
}
}
Nginx 配置示例:
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s;
server {
server_name www.test.com;
location /access-limit/ {
proxy_pass http://127.0.0.1:8080/;
limit_req zone=iplimit burst=2 nodelay;
}
}
配置说明:
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s;表示:
按客户端 IP 限流;
使用名为
iplimit的共享内存区域;内存大小为 20MB;
限流速率为每秒 1 个请求。
limit_req zone=iplimit burst=2 nodelay;表示:
使用
iplimit限流规则;允许最多 2 个突发请求;
超出后不延迟处理,直接拒绝。
访问测试地址:
http://www.test.com/access-limit/nginx如果请求频率超过配置,就会被 Nginx 限制。
十一、Nginx 多维度限流
Nginx 也可以配置多个维度的限流。
示例:
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=10r/s;
limit_req_zone $server_name zone=serverlimit:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=perip:20m;
limit_conn_zone $server_name zone=perserver:20m;
server {
server_name www.test.com;
location /access-limit/ {
proxy_pass http://127.0.0.1:8080/;
# 基于 IP 的请求频率限制
limit_req zone=iplimit burst=2 nodelay;
# 基于 server_name 的请求频率限制
limit_req zone=serverlimit burst=2 nodelay;
# 单个 IP 最多保持 100 个连接
limit_conn perip 100;
# 当前 server 最多保持 1 个连接
limit_conn perserver 1;
# 限流时返回 504
limit_req_status 504;
limit_conn_status 504;
}
location /download/ {
# 前 100m 不限制速度
limit_rate_after 100m;
# 后续限制为 256k
limit_rate 256k;
}
}
这里同时使用了:
IP 请求频率限流;
服务级请求频率限流;
IP 连接数限制;
服务级连接数限制;
下载速率限制。
这种方式适合在网关层或反向代理层保护后端服务。
十二、Redis + Lua 分布式限流
Guava RateLimiter 适合单机限流。
Nginx 适合入口层限流。
如果希望在应用内部做分布式限流,可以使用 Redis + Lua。
原因是:
Redis 执行 Lua 脚本具有原子性。
这可以避免多个应用节点并发修改同一个限流计数时出现竞态问题。
十三、限流 Lua 脚本
在 resources 目录下创建:
rateLimiter.lua脚本内容:
-- 获取限流 key
local methodKey = KEYS[1]
-- 获取限流阈值
local limit = tonumber(ARGV[1])
-- 获取当前访问次数
local count = tonumber(redis.call('get', methodKey) or "0")
-- 判断是否超过阈值
if count + 1 > limit then
return false
else
redis.call('INCRBY', methodKey, 1)
redis.call('EXPIRE', methodKey, 1)
return true
end这个脚本的逻辑是:
1. 根据 key 获取当前访问次数
2. 如果 count + 1 超过阈值,返回 false
3. 如果没有超过阈值,计数加一
4. 设置过期时间为 1 秒
5. 返回 true它实现的是一个简单的固定窗口限流。
例如:
key = order:create
limit = 10表示 order:create 这个 key 每秒最多允许访问 10 次。
十四、引入 Redis 和 AOP 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
Redis 配置示例:
server.port=8080
spring.redis.database=0
spring.redis.host=localhost
spring.redis.port=6379
十五、加载 Lua 脚本
创建 Redis 配置类:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
@Bean
public DefaultRedisScript<Boolean> rateLimitLua() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("rateLimiter.lua"));
redisScript.setResultType(Boolean.class);
return redisScript;
}
}这里将 rateLimiter.lua 加载成一个 Spring Bean,后续可以通过 StringRedisTemplate 执行。
十六、封装限流组件
创建限流服务:
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class AccessLimiter {
private final StringRedisTemplate stringRedisTemplate;
private final RedisScript<Boolean> rateLimitLua;
public AccessLimiter(StringRedisTemplate stringRedisTemplate,
RedisScript<Boolean> rateLimitLua) {
this.stringRedisTemplate = stringRedisTemplate;
this.rateLimitLua = rateLimitLua;
}
public void limitAccess(String key, Integer limit) {
Boolean acquired = stringRedisTemplate.execute(
rateLimitLua,
Lists.newArrayList(key),
limit.toString()
);
if (!Boolean.TRUE.equals(acquired)) {
log.warn("访问被限流,key={}", key);
throw new RuntimeException("访问过于频繁,请稍后再试");
}
}
}
调用方式:
accessLimiter.limitAccess("ratelimiter-test", 1);
表示同一个 key 每秒只允许访问 1 次。
十七、Controller 测试限流效果
@RestController
@Slf4j
public class TestController {
private final AccessLimiter accessLimiter;
public TestController(AccessLimiter accessLimiter) {
this.accessLimiter = accessLimiter;
}
@GetMapping("/test")
public String test() {
accessLimiter.limitAccess("ratelimiter-test", 1);
return "success";
}
}如果短时间内重复访问:
GET /test第一次可能返回:
success后续请求会被限流,抛出异常:
访问过于频繁,请稍后再试十八、使用注解实现限流
为了避免每个接口都手动调用:
accessLimiter.limitAccess(...)可以封装一个限流注解。
1. 定义注解
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimiterAop {
/**
* 限流阈值
*/
int limit();
/**
* 限流 key
*/
String methodKey() default "";
}2. 定义切面
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Collectors;
@Slf4j
@Aspect
@Component
public class AccessLimiterAspect {
private final AccessLimiter accessLimiter;
public AccessLimiterAspect(AccessLimiter accessLimiter) {
this.accessLimiter = accessLimiter;
}
@Pointcut("@annotation(com.example.demo.annotation.AccessLimiterAop)")
public void cut() {
}
@Before("cut()")
public void before(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
AccessLimiterAop annotation = method.getAnnotation(AccessLimiterAop.class);
if (annotation == null) {
return;
}
String key = annotation.methodKey();
Integer limit = annotation.limit();
if (!StringUtils.hasText(key)) {
Class<?>[] parameterTypes = method.getParameterTypes();
key = method.getName();
if (parameterTypes.length > 0) {
String paramTypes = Arrays.stream(parameterTypes)
.map(Class::getName)
.collect(Collectors.joining(","));
key += "#" + paramTypes;
}
}
accessLimiter.limitAccess(key, limit);
}
}这里的逻辑是:
拦截带有
@AccessLimiterAop的方法;读取注解中的
limit和methodKey;如果没有配置
methodKey,则根据方法名和参数类型生成默认 key;调用 Redis + Lua 限流组件。
3. 在接口上使用
@RestController
@Slf4j
public class TestController {
@GetMapping("/test")
@AccessLimiterAop(limit = 1)
public String test() {
return "success";
}
}这样就完成了基于注解的接口限流。
十九、几种限流方案对比
实际项目中可以组合使用:
Nginx 做入口粗粒度限流;
网关做用户、IP、接口维度限流;
应用内用 Redis + Lua 做业务级精细限流;
单机内部用 Guava 做本地保护。
二十、使用时需要注意的问题
1. Redis + Lua 示例是固定窗口限流
上面的 Lua 脚本使用的是简单固定窗口计数。
它的特点是实现简单,但在窗口边界可能存在突刺问题。
例如:
第 1 秒末尾打进 10 个请求
第 2 秒开头又打进 10 个请求
从统计上看每秒都没有超过 10 次,但实际短时间内可能出现 20 次请求。
如果对平滑限流要求较高,可以考虑滑动窗口、令牌桶等更精细的实现。
2. 限流 key 要设计清楚
限流效果很大程度取决于 key 的设计。
常见 key 设计包括:
接口维度:rate:api:createOrder
用户维度:rate:user:10001
IP 维度:rate:ip:192.168.1.1
用户 + 接口维度:rate:user:10001:createOrder不同 key 代表不同粒度的限流。
3. 限流失败要有明确返回
不要直接把底层异常返回给前端。
建议统一转换成业务错误,例如:
访问过于频繁,请稍后再试如果是开放 API,可以返回标准错误码,例如:
429 Too Many Requests4. Redis 不可用时要有降级策略
如果限流依赖 Redis,当 Redis 不可用时,需要考虑系统行为:
直接放行;
全部拒绝;
使用本地限流降级;
根据接口重要性选择策略。
不同业务场景选择不同。
例如核心交易接口可能更保守,非核心查询接口可以选择临时放行。
5. 幂等和限流不是一回事
幂等用于防止重复业务结果。
限流用于控制请求频率。
两者经常同时出现,但不能互相替代。
例如支付接口既要限流,也要幂等:
限流防止高频请求压垮系统;
幂等防止同一订单重复扣款。
结论
接口幂等性和分布式限流,都是分布式系统中很常见的稳定性问题。
幂等性的核心是:
使用唯一业务标识识别一次业务操作,避免重复请求造成重复结果。
常见方式包括:
唯一业务单号;
乐观锁版本号;
Token 防重复提交;
数据库唯一索引;
分布式锁。
限流的核心是:
在一定时间窗口内限制访问频率,保护系统不被突发流量打垮。
常见方式包括:
Guava RateLimiter 做单机限流;
Nginx 做入口层限流;
Redis + Lua 做分布式限流;
注解 + AOP 简化业务接口限流接入。