服务中经常会出现一些只允许一次提交的场景,幂等模块可快速实现此类场景。无论集群环境还是单机开发,都能灵活适配。

概述

幂等模块通过 @Idempotent 注解 + AOP 拦截,自动判定重复请求:

  • 分布式推荐: 使用 Redis 做存储判定,实现应用级跨实例拦截

  • 内存兜底: 使用本地内存存储,无需 Redis 也能运行,适合开发 / 测试环境

仓库坐标

Maven

<dependencies>
    <dependency>
        <groupId>com.luamas.thinkbridge</groupId>
        <artifactId>idempotent-spring-boot-starter</artifactId>
    </dependency>
    <!-- 可选依赖:不加则默认使用内存实现 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

Gradle

implementation("com.luamas.thinkbridge:idempotent-spring-boot-starter")
// 可选依赖:不加则默认使用内存实现
implementation("org.springframework.boot:spring-boot-starter-data-redis")
spring-boot-starter-data-redis 为可选依赖。当项目中存在 RedisConnectionFactory Bean 时,自动使用 Redis 实现;否则自动降级为内存实现,无需任何额外配置。

自动配置

引入 idempotent-spring-boot-starter 后,Spring Boot 自动完成以下配置,无需手动声明任何 Bean:

内部配置类 触发条件 说明

RedisIdempotentKeyStoreConfiguration

存在 RedisConnectionFactory Bean

使用 Redis SET NX EX 实现分布式幂等

InMemoryIdempotentKeyStoreConfiguration

不存在任何 IdempotentKeyStore Bean

使用 ConcurrentHashMap + TTL 定时清理实现本地幂等

IdempotentAspect Bean

存在 IdempotentKeyStore Bean

AOP 切面,拦截 @Idempotent 注解方法

MvcBindingEvaluationContextConfiguration

Servlet Web 环境

将 #request 变量注入 SpEL 上下文

Key 结构

幂等 Key 由多层前缀和唯一标识拼接而成:

{全局前缀}:{注解前缀}:{唯一表达式}

全局前缀(应用级命名空间)

通过配置文件设定,对该应用下的所有 @Idempotent 生效:

think:
  idempotent:
    prefix: myapp

注解前缀(业务级命名空间)

在 @Idempotent 中通过 prefix 属性指定,支持 SpEL 表达式动态解析:

// 静态前缀
@Idempotent(prefix = "payment", uniqueExpression = "#orderId")
// 完整 key: myapp:payment:{orderId}

// SpEL 动态前缀
@Idempotent(prefix = "'order:' + #tenantId", uniqueExpression = "#orderId")
// 完整 key: myapp:order:{tenantId}:{orderId}
全局前缀和注解前缀互不冲突,二者同时存在时以 : 拼接。均不配置时,使用默认前缀 thinkbridge:idem。

@Idempotent 注解

基本用法

@Idempotent(uniqueExpression = "#userDto.id", duration = 5)
public void createUser(UserDto userDto) {
    // 业务逻辑
}

uniqueExpression 支持以下 SpEL 变量:

变量 说明 示例

#参数名

按方法参数名访问

#userDto.id

#p0, #p1…​

按参数索引访问

#p0

#request

当前 HTTP 请求(Web 环境)

#request.getHeader('token')

属性参考

属性 类型 默认值 说明

prefix

String

thinkbridge:idem

业务级 Key 前缀,支持 SpEL 动态解析

uniqueExpression

String

空

唯一标识的 EL 表达式,为空时仅用前缀作为 Key

value

String

空

存入的值,用于值匹配防止误删

duration

long

300(5分钟)

Key 的 TTL 过期时间,单位秒

message

String

idempotent error

重复请求时的提示消息

removeKeyWhenFinished

boolean

true

业务成功后立即清除 Key

removeKeyWhenError

boolean

true

业务异常时立即清除 Key

保留 Key 直到 TTL 过期

@Idempotent(uniqueExpression = "#req.orderNo", removeKeyWhenFinished = false, removeKeyWhenError = false)
public boolean pay(PayRequest req) {
    return paymentService.pay(req);
}

BindingEvaluationContext SPI

如需在 SpEL 中注入自定义变量,实现 BindingEvaluationContext 接口并注册为 Spring Bean:

@FunctionalInterface
public interface BindingEvaluationContext {
    void bind(@Nullable EvaluationContext evaluationContext);
}

示例:绑定自定义请求上下文

package com.luamas.thinkbridge.integration.demo.idempotent.evaluation;

import com.luamas.thinkbridge.common.idempotent.BindingEvaluationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.expression.EvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

/**
 * 绑定自定义表达式
 */
@Profile("idempotent")
@Component
public class RequestEvaluationContext implements BindingEvaluationContext {
    @Override
    public void bind(EvaluationContext evaluationContext) {
        // 如果在 sevlet 环境下,则将 request 信息放入上下文,便于获取请求参数
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        if (requestAttributes != null) {
            evaluationContext.setVariable("request", requestAttributes.getRequest());
        }
    }
}

注册后即可在 @Idempotent 中使用:

@Idempotent(uniqueExpression = "#requestContext.getTraceId()")
public Result handle(Request req) { ... }

存储实现原理

RedisIdempotentKeyStore(分布式推荐)

  • SET NX EX 保证原子性判断与设置

  • Redis TTL 自动过期,无需额外清理

  • Lua 脚本保证删除时的 CAS 语义

InMemoryIdempotentKeyStore(开发/测试兜底)

  • ConcurrentHashMap + putIfAbsent/replace 保证 CAS 原子性

  • ScheduledExecutorService 每 30 秒扫描清理过期 Key,防止内存泄漏

  • computeIfPresent 保证删除时的 CAS 匹配

  • Redis 不可用时自动生效,零配置

完整示例

基础配置:

package com.luamas.thinkbridge.integration.demo.idempotent.config;

import com.luamas.thinkbridge.common.idempotent.key.IdempotentKeyStore;
import com.luamas.thinkbridge.common.idempotent.key.RedisIdempotentKeyStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
 * 这里使用redis作为缓存
 */
@Profile("idempotent")
@Configuration
public class ThinkBridgeIdempotentConfiguration {
    @Bean
    public IdempotentKeyStore idempotentKeyStore(StringRedisTemplate stringRedisTemplate) {
        return new RedisIdempotentKeyStore(stringRedisTemplate);
    }
}

标准 SpEL 表达式使用:

package com.luamas.thinkbridge.integration.demo.idempotent.service;

import com.luamas.thinkbridge.common.idempotent.annotation.Idempotent;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Profile("idempotent")
@Component
public class UserService {
    /
     * 模拟保存用户
     * prefix 前缀
     * uniqueExpression 为el表达式,这里可以直接使用http请求的request对象
     * duration 幂等的控制时长(秒),必须大于业务的处理耗时
     *
     * @param userId
     /
    @Idempotent(prefix = "test:save1", uniqueExpression = "#userId", duration = 100, removeKeyWhenFinished = false)
    public String save1(String userId) {
        return "success";
    }

    /
     * 模拟保存用户
     * prefix 前缀
     * uniqueExpression 为el表达式,这里可以直接使用http请求的request对象
     * duration 幂等的控制时长(秒),必须大于业务的处理耗时
     * removeKeyWhenFinished 完成后是否取消锁定
     * @param userId
     */
    @Idempotent(prefix = "test:save2", uniqueExpression = "#request.getParameter('test')", duration = 100)
    public String save2(String userId) {
        try {
            // 延时10秒,模拟业务
            Thread.sleep(10000L);
        } catch (InterruptedException ignored) {
        }
        return "success";
    }

    /*
     * 模拟保存用户
     * prefix 前缀
     * uniqueExpression 为el表达式,这里可以直接使用http请求的request对象
     * duration 幂等的控制时长(秒),必须大于业务的处理耗时
     * removeKeyWhenFinished 完成后是否取消锁定
     * @param userId
     */
    @Idempotent(prefix = "test:save3", uniqueExpression = "#userId", duration = 100)
    public String save3(String userId) {
        return "success";
    }
}