打造Spring Boot接口护盾:防重提交与限流秘籍
Spring Boot 接口那些 “糟心事”
在当今高并发的互联网应用场景下,Spring Boot 作为主流的 Java 开发框架,被广泛应用于构建各类后端服务。然而,随着业务的不断发展和用户量的增长,接口面临的挑战也日益严峻,其中接口被重复提交和遭遇高并发流量便是常见的 “糟心事”。
以电商下单场景为例,当用户点击 “提交订单” 按钮时,由于网络延迟、前端交互设计不完善等原因,可能会导致用户多次点击该按钮。如果接口没有防重提交机制,这将导致在数据库中生成多条重复的订单记录。这不仅会占用数据库资源,还会给后续的订单处理、库存管理等环节带来极大的困扰,甚至可能引发业务逻辑错误,比如超卖现象,严重影响用户体验和企业的正常运营。
再看支付接口,当用户进行支付操作时,一旦接口被重复触发,就会造成用户重复扣费。这对于用户来说是极其糟糕的体验,可能会导致用户对平台产生信任危机,进而流失用户。而对于企业而言,不仅要处理用户的投诉和退款事宜,还可能面临法律风险。
在高并发流量场景下,问题同样不容忽视。当大量用户同时访问接口时,如果没有合理的限流措施,接口可能会因为负载过高而响应变慢,甚至直接崩溃。例如,在一些热门活动期间,如电商的 “双 11” 大促、限时抢购等,瞬间涌入的大量请求可能会使接口不堪重负。这不仅会导致正常用户的请求无法得到及时处理,还可能引发连锁反应,使整个系统陷入瘫痪状态,造成巨大的经济损失。
由此可见,接口防护对于保障系统的稳定运行、提升用户体验以及维护企业的利益至关重要。它是我们在开发 Spring Boot 应用时必须要重视和解决的问题,接下来,我们就一起深入探讨如何实现接口的防重提交和限流。
防重提交:给接口穿上 “防重铠甲”
(一)问题剖析:重复提交的 “破坏力”
在电商系统中,重复下单的问题尤为突出。当用户快速连续点击提交订单按钮时,若接口没有防重机制,数据库中会瞬间生成多条相同的订单记录。这不仅会使库存数据混乱,导致商品超卖或库存显示错误,还会让订单处理流程陷入混乱,增加人工核对和处理的成本,严重影响用户体验和商家的正常运营。
支付接口的重复提交则会引发更为严重的后果。用户在支付过程中,可能因网络波动等原因多次触发支付请求。如果接口不能有效防重,用户账户将被重复扣费。这不仅会导致用户资金损失,引发用户的不满和投诉,还会对平台的信誉造成极大损害,使用户对平台的安全性和可靠性产生质疑,进而流失大量用户。
(二)传统方案的 “短板”
前端防重是一种常见的方式,通常是在用户点击提交按钮后,通过 JavaScript 代码禁用按钮或添加 loading 状态,防止用户再次点击。然而,这种方式存在明显的局限性。对于一些熟悉技术的用户或者恶意攻击者来说,他们可以通过修改前端代码、使用浏览器插件等方式绕过前端的防重限制,直接向服务器发送重复请求,从而导致防重机制失效。
Token 标识方案则相对复杂一些。它的原理是在服务器端生成一个唯一的 Token,并将其发送给前端。前端在每次请求时携带这个 Token,服务器接收到请求后,验证 Token 的有效性并将其销毁。如果再次收到相同 Token 的请求,则判定为重复提交。这种方式虽然安全性较高,但它高度依赖前端的配合。如果前端代码出现漏洞,未能正确处理 Token,或者 Token 在传输过程中被窃取,都可能导致防重失败。此外,Token 的生成、验证和管理流程也增加了系统的复杂度,需要额外的代码和资源来维护。
(三)哈希算法:我们的 “秘密武器”
-
原理揭秘:我们提出的基于哈希算法的防重方案,通过对请求路径、方法和参数进行处理,生成一个唯一的哈希值。这个哈希值就像请求的 “指纹”,能够准确标识请求的唯一性。当请求到达服务器时,首先计算其哈希值,然后查询缓存中是否已存在相同的哈希值。如果存在,说明该请求是重复提交,直接拒绝;如果不存在,则将哈希值存入缓存,并处理该请求。这种方式实现了后端无依赖防重,无需前端进行特殊处理,大大提高了防重机制的可靠性和稳定性。
-
代码实战:首先,我们定义一个自定义防重注解
@PreventDuplicate,通过该注解可以灵活配置防重时间、参与生成哈希的字段以及提示信息等。
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicate {
// 防重复提交时间(单位:秒)
int expire() default 3;
// 时间单位,默认秒
TimeUnit timeUnit() default TimeUnit.SECONDS;
// 可选指定参与生成哈希的主要字段
String[] field() default {};
// 提示信息
String message() default "请勿重复提交!";
}
接下来,利用 AOP 切面实现防重逻辑。在切面中,获取请求的相关信息,拼接成唯一签名源,计算 SHA-256 哈希值,并与缓存中的值进行比对。
import cn.hutool.crypto.digest.DigestUtil;
import com.example.demo.annotation.PreventDuplicate;
import com.example.demo.storage.DuplicateStorage;
import com.example.demo.storage.DuplicateStorageFactory;
import com.example.demo.util.RequestParameterUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
@RequiredArgsConstructor
public class PreventDuplicateAspect {
private final HttpServletRequest request;
private final DuplicateStorageFactory storageFactory;
@Around("@annotation(preventDuplicate)")
public Object handle(ProceedingJoinPoint joinPoint, PreventDuplicate preventDuplicate) throws Throwable {
// 获取请求方法
String method = request.getMethod();
// 获取请求URI
String uri = request.getRequestURI();
// 获取请求参数
String params = RequestParameterUtils.getAllParamsAsString(joinPoint, preventDuplicate.field());
// 拼接唯一签名源
String signSource = method + ":" + uri + ":" + params;
// 计算哈希值
String key = DigestUtil.sha256Hex(signSource);
DuplicateStorage storage = storageFactory.getStorage();
if (storage.exists(key)) {
throw new RuntimeException(preventDuplicate.message());
}
storage.put(key, preventDuplicate.expire(), preventDuplicate.timeUnit());
return joinPoint.proceed();
}
}
在上述代码中,@Around("@annotation(preventDuplicate)")表示对标记了@PreventDuplicate注解的方法进行环绕增强。在增强逻辑中,首先获取请求的方法、URI 和参数,拼接成唯一的签名源,然后使用DigestUtil.sha256Hex方法计算签名源的 SHA-256 哈希值。接着,从DuplicateStorageFactory获取DuplicateStorage实例,检查缓存中是否已存在该哈希值。如果存在,抛出异常提示用户请勿重复提交;如果不存在,将哈希值存入缓存,并放行请求,执行目标方法。
我们还需要设计缓存存储抽象与实现,支持 Redis 和 Caffeine 等多种缓存方式,以满足不同的业务需求。
import java.util.concurrent.TimeUnit;
public interface DuplicateStorage {
boolean exists(String key);
void put(String key, int expire, TimeUnit timeUnit);
}
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Component
public class RedisStorage implements DuplicateStorage {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean exists(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
@Override
public void put(String key, int expire, TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, "1", expire, timeUnit);
}
}
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class CaffeineStorage implements DuplicateStorage {
private final Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
@Override
public boolean exists(String key) {
return cache.getIfPresent(key) != null;
}
@Override
public void put(String key, int expire, TimeUnit timeUnit) {
cache.put(key, "1");
}
}
在缓存存储抽象与实现部分,首先定义了DuplicateStorage接口,其中包含exists和put两个方法,分别用于检查缓存中是否存在指定键的缓存值,以及将键值对存入缓存并设置过期时间。然后,实现了RedisStorage类,通过注入RedisTemplate来操作 Redis 缓存,利用redisTemplate.hasKey方法检查键是否存在,使用redisTemplate.opsForValue().set方法将键值对存入 Redis,并设置过期时间。同时,实现了CaffeineStorage类,使用 Caffeine 缓存库创建一个本地缓存实例cache,通过cache.getIfPresent方法检查缓存中是否存在指定键的值,通过cache.put方法将键值对存入缓存。
- 性能验证:为了验证哈希算法防重方案的性能,我们搭建了一个模拟高并发的测试环境,使用专业的性能测试工具模拟大量用户同时发送请求。测试结果显示,即使在高并发场景下,生成哈希值的耗时极短,几乎可以忽略不计,对接口的响应时间和吞吐量影响极小。同时,由于哈希值的唯一性,能够准确识别重复请求,有效避免了重复提交问题的发生,证明了该方案在性能和可靠性方面的出色表现。
限流:为接口流量装上 “调节阀”
(一)限流的 “必要性”
在高并发场景下,系统面临的流量压力是巨大的。以电商 “双 11” 大促为例,在活动开始的瞬间,大量用户同时涌入平台,对商品查询、下单、支付等接口发起请求,流量可能会瞬间达到平时的数十倍甚至数百倍。如果没有限流措施,这些接口可能会因为无法承受如此巨大的流量而响应变慢,甚至直接崩溃。一旦接口崩溃,不仅会导致用户无法正常使用平台功能,造成糟糕的用户体验,还会给企业带来巨大的经济损失。此外,大量的无效请求或恶意请求也可能会耗尽系统资源,影响正常业务的运行。因此,限流对于保护系统资源、防止系统因流量过大而崩溃具有至关重要的作用。
(二)常见限流算法 “大比拼”
-
固定窗口算法:固定窗口算法是一种简单直观的限流算法。它将时间划分为固定长度的窗口,例如 1 分钟,在每个窗口内统计请求数量。当请求到达时,检查当前窗口内的请求数是否超过设定的阈值。如果未超过,则允许请求通过,并将请求数加 1;如果超过,则拒绝请求。例如,设定每分钟的请求阈值为 100,在第一个窗口内,前 99 个请求都能正常通过,当第 100 个请求到达时,该窗口内的请求数达到阈值,后续请求将被拒绝,直到下一个窗口开始。这种算法的优点是实现简单,易于理解和部署。然而,它存在明显的缺陷,当请求集中在窗口切换的临界点时,可能会出现两倍流量的突发情况。例如,在 0:59:59 到 1:00:00 这一瞬间,可能会有两个窗口的请求同时到达,导致系统在极短时间内承受巨大的流量压力。
-
滑动窗口算法:滑动窗口算法是对固定窗口算法的改进。它将时间窗口划分为多个更小的子窗口,每个子窗口都有自己的计数器。随着时间的推移,窗口像幻灯片一样向前滑动,每当有新的子窗口进入当前窗口范围,就将其计数器加入总请求数统计,同时移除过期子窗口的计数器。例如,将 1 分钟的时间窗口划分为 60 个 1 秒的子窗口,每个子窗口记录该秒内的请求数。当时间从 1 秒滑动到 2 秒时,将第 2 秒的子窗口计数器加入总请求数,并移除第 1 秒的子窗口计数器。这样可以更精确地控制流量,避免固定窗口算法在临界点的双倍流量问题。滑动窗口算法的优点是限流精度高,能够更平滑地控制流量,有效避免突发流量对系统的冲击。但它的实现相对复杂,需要维护多个子窗口的计数器,对系统资源的消耗也相对较大。
-
漏桶算法:漏桶算法的原理类似于一个底部有小孔的水桶。请求就像水一样流入漏桶,而漏桶以固定的速率将水(请求)流出。当桶满时,新流入的水(请求)将被丢弃。例如,设定漏桶的容量为 100,流出速率为每秒 10 个请求。当请求以每秒 20 个的速率流入时,漏桶将以每秒 10 个的速率处理请求,多余的 10 个请求将被丢弃。这种算法的优点是能够严格控制流量的输出速率,保证系统处理请求的稳定性,适用于对流量稳定性要求较高的场景,如数据库写入速率控制、网络传输速率限制等。然而,它的缺点是无法应对突发流量,因为无论请求流量如何变化,漏桶始终以固定速率处理请求,可能会导致突发流量下的请求被大量丢弃,影响用户体验。
-
令牌桶算法:令牌桶算法是目前应用较为广泛的一种限流算法。系统以固定的速率向令牌桶中生成令牌,每个请求在处理之前需要从桶中获取一个令牌。如果桶中有足够的令牌,则请求可以通过,并消耗一个令牌;如果桶中没有令牌,则请求被拒绝。例如,设定令牌生成速率为每秒 10 个,令牌桶容量为 100。当请求到达时,先检查桶中是否有令牌,若有则获取一个令牌并处理请求,若没有则拒绝请求。令牌桶算法的优点是允许一定程度的突发流量,因为在桶中有足够令牌的情况下,请求可以快速通过。同时,它也能保证系统在长期内的平均请求处理速率符合设定的限制,适用于大多数需要限制流量的场景,如 API 接口限流、用户行为控制等。不过,它的实现相对复杂一些,需要维护令牌桶的状态和令牌的生成逻辑。
(三)基于 AOP 和 Redis 的限流实现
- 技术选型理由:选择 AOP(面向切面编程)实现限流逻辑,是因为它能够以一种低侵入式的方式将限流功能切入到业务代码中。通过定义切面,我们可以在不修改业务方法核心逻辑的前提下,对方法的调用进行统一的限流处理。这使得限流逻辑与业务逻辑分离,提高了代码的可维护性和可扩展性。例如,当我们需要调整限流策略时,只需在切面中修改相关代码,而无需在每个业务方法中进行修改。
选择 Redis 实现分布式环境下的原子性计数和状态存储,主要是基于 Redis 的高性能和原子操作特性。在分布式系统中,多个服务实例可能同时处理请求,需要一个可靠的分布式存储来保证限流计数的准确性和一致性。Redis 的单线程模型保证了原子操作的特性,例如 INCR(原子递增)和 EXPIRE(设置过期时间)等命令,能够确保在高并发场景下,对限流计数器的操作不会出现竞态条件。同时,Redis 的高可用性和集群模式也能够满足分布式系统对稳定性和扩展性的要求。
- 代码实现步骤:首先,定义自定义限流注解
@RateLimiter,通过该注解可以灵活配置限流的相关参数,如限流唯一标识、时间窗口、允许请求次数等。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {
// 限流唯一标识(支持SpEL)
String key();
// 时间窗口(秒)
int time() default 60;
// 允许请求次数
int count() default 10;
}
接下来,实现 AOP 切面类RateLimiterAspect,在切面中通过@Around注解拦截标记了@RateLimiter注解的方法。在拦截逻辑中,首先解析 SpEL 表达式生成动态的限流 Key,然后使用 Redis 的opsForValue().increment方法对限流计数器进行原子递增操作。如果是首次访问(计数器为 1),则设置该 Key 的过期时间,以实现时间窗口的限流。如果当前请求数超过了设定的允许请求次数,则抛出限流异常,拒绝请求;否则,放行请求,执行目标方法。
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class RateLimiterAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ExpressionParser parser;
@Around("@annotation(rateLimiter)")
public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) throws Throwable {
// 解析SpEL动态生成Key(例如:user_123)
String key = parseSpEL(joinPoint, rateLimiter.key());
int time = rateLimiter.time();
int count = rateLimiter.count();
// Redis计数器自增
Long current = redisTemplate.opsForValue().increment(key, 1);
if (current == 1) {
// 首次设置过期时间
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
if (current > count) {
throw new RateLimitException("请求过于频繁,请稍后再试!");
}
return joinPoint.proceed();
}
private String parseSpEL(ProceedingJoinPoint joinPoint, String spEL) {
// 解析方法参数、注解等生成动态Key
StandardEvaluationContext context = new StandardEvaluationContext();
Object[] args = joinPoint.getArgs();
context.setVariable("args", args);
return parser.parseExpression(spEL).getValue(context, String.class);
}
}
在上述代码中,@Around("@annotation(rateLimiter)")表示对标记了@RateLimiter注解的方法进行环绕增强。在增强逻辑中,parseSpEL方法用于解析 SpEL 表达式,根据方法参数生成动态的限流 Key。然后,通过redisTemplate.opsForValue().increment(key, 1)对限流 Key 对应的计数器进行原子递增操作,并返回递增后的计数值。如果计数值为 1,说明是首次访问,使用redisTemplate.expire(key, time, TimeUnit.SECONDS)设置该 Key 的过期时间为time秒,实现时间窗口的限流。最后,判断当前计数值是否超过允许的请求次数count,如果超过则抛出RateLimitException异常,提示用户请求过于频繁;否则,通过joinPoint.proceed()放行请求,执行目标方法。
为了进一步优化性能,减少 Redis 的网络开销和保证操作的原子性,我们使用 Lua 脚本实现令牌桶算法。以下是 Lua 脚本的示例代码:
-- KEYS[1]: 限流Key
-- ARGV[1]: 时间窗口(秒)
-- ARGV[2]: 允许的最大请求数
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], 1, 'EX', ARGV[1])
return 1
else
local newCount = redis.call('INCR', KEYS[1])
if tonumber(newCount) > tonumber(ARGV[2]) then
return -1 -- 超出限制
else
return newCount
end
end
在 Java 中调用 Lua 脚本的代码如下:
import org.springframework.data.redis.core.DefaultRedisScript;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import java.util.Collections;
@Component
public class LuaRateLimiter {
private final StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
static {
RATE_LIMIT_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("rate_limiter.lua")));
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
public LuaRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean tryAcquire(String key, int time, int count) {
Long result = redisTemplate.execute(RATE_LIMIT_SCRIPT, Collections.singletonList(key), String.valueOf(time), String.valueOf(count));
return result != null && result != -1;
}
}
在上述代码中,首先定义了一个LuaRateLimiter组件,用于加载和执行 Lua 脚本。在静态代码块中,通过RATE_LIMIT_SCRIPT.setScriptSource方法加载位于类路径下的rate_limiter.lua脚本文件,并设置脚本的返回结果类型为Long。tryAcquire方法用于尝试获取令牌,它通过redisTemplate.execute方法执行 Lua 脚本,传入限流 Key、时间窗口和允许的最大请求数作为参数。如果脚本执行结果不为-1,说明请求未超出限制,可以获取令牌,返回true;否则,返回false,表示请求被限流。
- 注意事项和优化方向:在实际应用中,限流维度的选择非常重要。我们可以根据业务需求选择按 IP 地址、用户 ID、接口路径等维度进行限流。例如,对于一些公共接口,为了防止恶意用户频繁访问,可以按 IP 地址进行限流;对于一些需要保护用户隐私的接口,可以按用户 ID 进行限流。同时,要确保限流维度的唯一性,避免出现误判或绕过限流的情况。
缓存的高可用性也是需要关注的问题。在分布式环境中,Redis 可能会出现单点故障。为了提高缓存的可用性,可以采用 Redis 集群、哨兵模式或 Redis Cluster 等方案。这些方案能够实现自动故障转移,当主节点出现故障时,从节点能够自动升级为主节点,保证系统的正常运行。
还可以考虑结合监控系统对限流情况进行实时监控,以便及时发现和处理限流异常。通过监控,可以了解系统的实际流量情况,根据业务需求动态调整限流阈值,提高系统的性能和稳定性。例如,在业务高峰期,可以适当提高限流阈值,以满足用户的需求;在业务低谷期,可以降低限流阈值,节省系统资源。
总结与展望
在构建 Spring Boot 应用的过程中,接口防护是保障系统稳定运行、提升用户体验的关键环节。通过深入剖析接口重复提交和高并发流量带来的问题,我们探索了一系列行之有效的解决方案。
基于哈希算法的防重提交方案,利用其对请求路径、方法和参数的独特处理方式,生成唯一的哈希值,实现了后端无依赖防重,为接口穿上了一层坚固的 “防重铠甲”,有效避免了重复提交导致的数据不一致和业务逻辑混乱等问题。
而限流机制则为接口流量装上了 “调节阀”。从常见的限流算法,如固定窗口算法、滑动窗口算法、漏桶算法和令牌桶算法的对比分析中,我们了解到它们各自的优缺点和适用场景。基于 AOP 和 Redis 的限流实现,结合了 AOP 的低侵入性和 Redis 的高性能、原子操作特性,通过自定义注解和切面编程,灵活且高效地实现了接口限流,保护系统资源,防止系统因流量过大而崩溃。
在实际项目中,我们应根据业务需求和系统架构,合理选择和应用这些接口防护技术。同时,随着技术的不断发展,未来接口防护技术有望在智能化、自动化和精细化方向取得更大突破。例如,借助人工智能和机器学习技术,实现对接口流量的实时预测和动态调整限流策略;通过自动化工具和平台,简化接口防护的配置和管理流程;针对不同的业务场景和用户行为,提供更加精细化的防重提交和限流方案,进一步提升接口的安全性和稳定性。希望大家在今后的开发中,重视接口防护,让我们的 Spring Boot 应用更加健壮和可靠。