接口幂等性与分布式限流

作者:old wang 发布时间: 2023-04-22 阅读量:2 评论数:0

在分布式系统中,有两个问题经常会遇到:

  1. 接口重复提交;

  2. 高并发流量冲击。

前者对应的是接口幂等性问题,后者对应的是接口限流问题。

这两个问题看起来不一样,但本质上都和“如何控制请求行为”有关:

  • 幂等性关注的是:同一个业务操作重复执行时,结果不能出错;

  • 限流关注的是:单位时间内请求量过大时,系统不能被打垮。

本文记录接口幂等性和分布式限流的常见处理方式,包括:

  • Update 操作如何保证幂等;

  • Token 机制如何处理重复提交;

  • 限流常见维度;

  • 令牌桶和漏桶算法;

  • Guava RateLimiter 单机限流;

  • Nginx 限流;

  • Redis + Lua 分布式限流。

一、什么是接口幂等性

接口幂等性指的是:

用户对同一个操作发起一次请求或多次请求,最终产生的结果应该是一致的,不应该因为重复请求产生额外副作用。

最典型的例子是支付。

用户购买商品并发起支付,服务端扣款成功,但返回结果时网络异常。

此时用户没有看到成功结果,于是再次点击支付按钮。

如果系统没有做好幂等控制,可能会发生第二次扣款,最终导致:

  • 用户余额被多扣;

  • 支付流水多出一条;

  • 订单状态异常;

  • 后续对账困难。

所以,对于支付、下单、退款、库存扣减、优惠券领取等接口,幂等性非常重要。

幂等性的核心思想是:

使用唯一业务标识识别一次业务操作。
如果该业务操作已经执行过,再次请求时不要重复执行核心逻辑。

在非并发场景下,可以通过查询业务单号判断是否处理过。

在并发场景下,还需要配合锁、唯一索引或状态机控制。

二、Update 操作的幂等性

对于更新操作,可以通过版本号控制幂等。

常见做法是:

  1. 用户查询数据;

  2. 后端返回数据时,同时返回版本号;

  3. 用户修改后提交;

  4. 后端更新时带上版本号作为条件;

  5. 更新成功后版本号加一。

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 次访问

所以分布式限流通常需要一个统一的限流位置或统一的状态存储。

常见方案包括:

  1. 网关层限流;

  2. Nginx 层限流;

  3. Redis 统一计数限流;

  4. 中间件层限流;

  5. 应用内单机限流。

五、限流的常见维度

限流通常不是只按一个条件做,而是多个维度组合使用。

1. QPS 限流

限制单位时间内的请求数。

例如:

某接口每秒最多 100 次请求
某 IP 每秒最多 10 次请求
某用户每分钟最多 60 次请求

2. 连接数限流

限制同时存在的连接数量。

例如:

同一个 IP 最多保持 5 个连接
单台服务最多保持 200 个连接

3. 传输速率限制

常见于文件下载场景。

例如:

普通用户下载速度 100KB/s
会员用户下载速度 10MB/s

4. 黑白名单

黑名单用于拒绝访问。

白名单用于绕过部分限流规则。

例如:

  • 高频异常访问 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";
    }
}

三种方式区别如下:

方法

是否阻塞

说明

tryAcquire()

拿不到令牌直接返回 false

tryAcquire(timeout, unit)

最多阻塞指定时间

超时仍拿不到令牌则返回 false

acquire()

一直等待,直到拿到令牌

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);
    }
}

这里的逻辑是:

  1. 拦截带有 @AccessLimiterAop 的方法;

  2. 读取注解中的 limitmethodKey

  3. 如果没有配置 methodKey,则根据方法名和参数类型生成默认 key;

  4. 调用 Redis + Lua 限流组件。

3. 在接口上使用

@RestController
@Slf4j
public class TestController {

    @GetMapping("/test")
    @AccessLimiterAop(limit = 1)
    public String test() {
        return "success";
    }
}

这样就完成了基于注解的接口限流。

十九、几种限流方案对比

方案

适用范围

优点

缺点

Guava RateLimiter

单机应用

使用简单,性能好

不能做集群统一限流

Nginx 限流

网关入口层

靠近流量入口,配置简单

业务维度不够灵活

Redis + Lua

分布式应用

支持集群统一限流,业务 key 灵活

依赖 Redis,存在网络开销

注解 + AOP

应用接口层

使用方便,代码侵入小

本质仍依赖底层限流实现

实际项目中可以组合使用:

  • 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 Requests

4. Redis 不可用时要有降级策略

如果限流依赖 Redis,当 Redis 不可用时,需要考虑系统行为:

  • 直接放行;

  • 全部拒绝;

  • 使用本地限流降级;

  • 根据接口重要性选择策略。

不同业务场景选择不同。

例如核心交易接口可能更保守,非核心查询接口可以选择临时放行。

5. 幂等和限流不是一回事

幂等用于防止重复业务结果。

限流用于控制请求频率。

两者经常同时出现,但不能互相替代。

例如支付接口既要限流,也要幂等:

  • 限流防止高频请求压垮系统;

  • 幂等防止同一订单重复扣款。

结论

接口幂等性和分布式限流,都是分布式系统中很常见的稳定性问题。

幂等性的核心是:

使用唯一业务标识识别一次业务操作,避免重复请求造成重复结果。

常见方式包括:

  • 唯一业务单号;

  • 乐观锁版本号;

  • Token 防重复提交;

  • 数据库唯一索引;

  • 分布式锁。

限流的核心是:

在一定时间窗口内限制访问频率,保护系统不被突发流量打垮。

常见方式包括:

  • Guava RateLimiter 做单机限流;

  • Nginx 做入口层限流;

  • Redis + Lua 做分布式限流;

  • 注解 + AOP 简化业务接口限流接入。

评论