“幂等”不等于“分布式锁”,也不得不考虑返回值

news2025/8/16 15:23:26

. 概览

在分布式系统中,幂等是一个非常重要的概念,常常与“重试”一起出现。当调用一个远程服务发生超时,调用方并不知道请求是否执行成功,这就是典型的“第三态”问题。对于这个问题最常见的解决方案便是进行主动重试,假如该操作是一个数据库插入操作,重试将对系统产生副作用(创建多条记录),这时我们常常会说,被调用接口需要保障幂等。

1.1. 背景

幂等可以简单定义如下:任意多次执行所产生的影响均与第一次执行的影响相同。

【注】从幂等定义上看,重心放在了操作之后的影响,及多次操作不会破坏内部状态。但在实际工作当中,除了内部状态外,接口的返回值也是一个重要要素,多次重复操作返回相同的结果往往更符合使用者的预期。

举个例子,在订单系统中,用户使用优惠券下单后会调用冻结接口对优惠券进行冻结操作,站着订单系统的视角,当进行冻结重试时你期望:

  1. 优惠券抛出异常,告知订单优惠券已经冻结;
  2. 优惠券直接返回上次的结果 “冻结成功;

大家可以思考下两种方案在下游使用时的异同,当然最好提供两种机制,由使用方根据场景进行定制。

在不同的场景下,幂等保护的方案是不同的,常见幂等处理策略有:

  1. 天然具有幂等性,不需要保护。比如读操作、按主键数据删除等;
  2. 使用上游信息作为下游主键或唯一键的插入场景,由于有键的约束,可以保障幂等。比如以 userId 作为 Card(名片)的主键;
  3. 数据库字段的直接更新操作也具有一定的幂等性。比如用户修改姓名;
  4. 直接使用外部存储,以幂等键为标识,对方法执行情况进行记录,并以此为判断依据完成幂等保护;

由于其他策略与场景强绑定,idempotent 重心放在方案4上,已覆盖更多的业务场景。

1.2. 目标

快速为非幂等接口增加幂等保护。

  1. 基于“能力声明化”的方式,为接口快速添加幂等保护;
  2. 支持常见的两种幂等保护策略;
    1. 直接返回上次的执行结果;
    2. 抛出异常告知重复提交;
  3. 支持存储层的扩展,并提供常见的存储实现,包括:
    1. redis 实现,适用于一般通用场景,性能好但缓存过期后可能存在幂等不生效的情况;
    2. db 实现,适用于比较严格的场景,比如与订单、金额相关的业务;

2. 快速入门

2.1. 准备工作

首先,引入 lego starter,在 maven pom 中添加如下信息:

<groupId>com.geekhalo.lego</groupId>
<artifactId>lego-starter</artifactId>
<version>0.1.15-idempotent-SNAPSHOT</version>

然后,以 JpaRepository 为例实现对 IdempotentExecutorFactory 的配置,具体如下:

@Configuration
public class IdempotentConfiguration extends IdempotentConfigurationSupport {
    @Bean("dbExecutorFactory")
    public IdempotentExecutorFactory redisExecutorFactory(JpaBasedExecutionRecordRepository recordRepository){
        return createExecutorFactory(recordRepository);
    }
}

其中,
IdempotentConfigurationSupport 已经提供 idempotent 所需的很多 Bean,同时提供 createExecutorFactory(repository) 方法,用以完成 IdempotentExecutorFactory 的创建。

使用 Jpa 需要调整 EnableJpaRepositories 相关配置,具体如下:

@Configuration
@EnableJpaRepositories(basePackages = {
        "com.geekhalo.lego.core.idempotent.support.repository"
}, repositoryFactoryBeanClass = JpaBasedQueryObjectRepositoryFactoryBean.class)
public class SpringDataJpaConfiguration {
}

其中,
com.geekhalo.lego.core.idempotent.support.repository 为固定包名,指向 Jpa 默认实现 JpaBasedExecutionRecordRepository,Spring Data Jpa 会自动生成实现的代理对象。

最后,在数据库中增加 幂等所需表,sql 如下:

CREATE TABLE `idempotent_execution_record` (
   `id` bigint(20) NOT NULL AUTO_INCREMENT,
   `type` int(11) NOT NULL,
   `unique_key` varchar(64) NOT NULL,
   `status` int(11) NOT NULL,
   `result` varchar(1024) DEFAULT NULL,
   `create_date` datetime DEFAULT NULL,
   `update_date` datetime DEFAULT NULL,
   PRIMARY KEY (`id`),
   UNIQUE KEY `unq_type_key` (`type`,`unique_key`)
) ENGINE=InnoDB;

至此,便完成了基本配置。

【注】关于 Spring data jpa 配置,可以自行到网上进行检索。

2.2. 初识幂等保护

在方法上增加 @Idempotent 注解便可以使其具备幂等保护,示例如下:

@Idempotent(executorFactory = "dbExecutorFactory", group = 1, keyEl = "#key",
        handleType = IdempotentHandleType.RESULT)
@Transactional
public Long putForResult(String key, Long data){
    return put(key, data);
}

其中 @Idempotent 为核心配置,详细信息如下:

  1. executorFactory 为 IdempotentExecutorFactory,及在 IdempotentConfiguration 中配置的bean,默认为 DEFAULT_EXECUTOR_FACTORY
  2. group 为组信息,用于区分不同的业务场景,同一业务场景使用相同的配置;
  3. keyEl 为提取幂等键所用的 SpringEl 表达式,#key 说明入参的 key 将作为幂等键,group + key 为一个完整的幂等键,唯一识别一次请求;
  4. handleType 是处理类型,及重复提交时如何处理请求
    1. RESULT,直接返回上次的执行结果
    2. ERROR,直接抛出 RepeatedSubmitException 异常

编写简单的测试用例如下:

@Test
void putForResult() {
    BaseIdempotentService idempotentService = getIdempotentService();
    String key = String.valueOf(RandomUtils.nextLong());
    Long value = RandomUtils.nextLong();
    {   // 第一次操作,返回值和最终结果符合预期
        Long result = idempotentService.putForResult(key, value);
        Assertions.assertEquals(value, result);
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
    {   // 第二次操作,返回值和最终结果 与第一次一致(直接获取返回值,没有执行业务逻辑)
        Long valueNew = RandomUtils.nextLong();
        Long result = idempotentService.putForResult(key, valueNew);
        Assertions.assertEquals(value, result);
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
}

运行测试用例,测试通过,可得出如下结论:

  1. 第一次操作,与正常方法一致,成功返回结果值;
  2. 第二次操作,逻辑方法未执行,直接返回第一次的运行结果;

这是最常见的一种工作模式,除直接返回上次执行结果外,当发生重复提交时也可以抛出异常中断流程,只需将 handleType 设置为 ERROR 即可,具体如下:

@Idempotent(executorFactory = "dbExecutorFactory", group = 1, keyEl = "#key",
    handleType = IdempotentHandleType.ERROR)
@Transactional
public Long putForError(String key, Long data){
    return put(key, data);
}

编写测试用例,具体如下:

@Test
void putForError() {
    BaseIdempotentService idempotentService = getIdempotentService();
    String key = String.valueOf(RandomUtils.nextLong());
    Long value = RandomUtils.nextLong();
    { // 第一次操作,返回值和最终结果符合预期
        Long result = idempotentService.putForError(key, value);
        Assertions.assertEquals(value, result);
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
    { // 第二次操作,直接抛出异常,结果与第一次一致
        Assertions.assertThrows(RepeatedSubmitException.class, () ->{
            Long valueNew = RandomUtils.nextLong();
            idempotentService.putForError(key, valueNew);
        });
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
}

运行测试用例,测试通过,可以得出:

  1. 第一次操作,与正常方法一致,成功返回结果值;
  2. 第二次操作,直接抛出 RepeatedSubmitException 异常,同时方法未执行,结果与第一次调用一致;

2.3. 幂等与异常

异常是一种特殊的返回值!!!

如果将异常看做是一种特殊的返回值,那幂等接口在第二次请求时同样需要抛出异常,示例代码如下:

@Idempotent(executorFactory = "dbExecutorFactory", group = 1, keyEl = "#key",
        handleType = IdempotentHandleType.RESULT)
@Transactional
public Long putExceptionForResult(String key, Long data) {
    return putException(key, data);
}
protected Long putException(String key, Long data){
    this.data.put(key, data);
    throw new IdempotentTestException();
}

@Idempotent 注解没有变化,只是在 putException 方法执行后抛出 IdempotentTestException 异常。

编写简单测试用例如下:

@Test
void putExceptionForResult(){
    BaseIdempotentService idempotentService = getIdempotentService();
    String key = String.valueOf(RandomUtils.nextLong());
    Long value = RandomUtils.nextLong();
    {   // 第一次操作,抛出异常
        Assertions.assertThrows(IdempotentTestException.class,
                ()->idempotentService.putExceptionForResult(key, value));
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
    {   // 第二次操作,返回值和最终结果 与第一一致(直接获取返回值,没有执行业务逻辑)
        Long valueNew = RandomUtils.nextLong();
        Assertions.assertThrows(IdempotentTestException.class,
                ()->idempotentService.putExceptionForResult(key, valueNew));
        Assertions.assertEquals(value, idempotentService.getValue(key));
    }
}

运行测试用例,用例通过,可知:

  1. 第一次操作,与方法逻辑一致,更新数据并抛出 IdempotentTestException 异常;
  2. 第二次操作,直接抛出 IdempotentTestException 异常,同时方法未执行,结果与第一次一致;

2.4. 并发保护

如果上一个请求执行尚未结束,新的请求已经开启,那会如何?

这就是最常见的并发场景,idempotent 对其也进行了支持,当出现并发请求时会直接抛出
ConcurrentRequestException,用于中断处理。

首先,使用 sleep 模拟一个耗时的方法,具体如下:

@Idempotent(executorFactory = "dbExecutorFactory", group = 1, keyEl = "#key",
            handleType = IdempotentHandleType.RESULT)
@Transactional
public Long putWaitForResult(String key, Long data) {
    return putForWait(key, data);
}
protected Long putForWait(String key, Long data){
    try {
        TimeUnit.SECONDS.sleep(3);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return put(key, data);
}

putWaitForResult 方法调用时会主动 sleep 3 秒,然后才执行真正的逻辑。

编写测试代码如下:

@Test
void putWaitForResult(){
    String key = String.valueOf(RandomUtils.nextLong());
    Long value = RandomUtils.nextLong();
    // 主线程抛出 ConcurrentRequestException 
    Assertions.assertThrows(ConcurrentRequestException.class, () ->
        testForConcurrent(baseIdempotentService ->
            baseIdempotentService.putWaitForResult(key, value))
    );
}
private void testForConcurrent(Consumer<BaseIdempotentService> consumer) throws InterruptedException {
    // 启动一个线程执行任务,模拟并发场景
    Thread thread = new Thread(() -> consumer.accept(getIdempotentService()));
    thread.start();
    // 主线程 sleep 1 秒,与异步线程并行执行任务
    TimeUnit.SECONDS.sleep(1);
    consumer.accept(getIdempotentService());
}

运行单元测试,测试通过,核心测试逻辑如下:

  1. 创建一个线程,执行耗时方法;
  2. 等待 1 秒后,主线程也执行耗时方法;
  3. 此时,两个线程并发执行耗时方法,后进入的主线程直接抛出 ConcurrentRequestException;

2.5. Redis 支持

DB 具有非常好的一致性,但性能存在一定的问题。在一致性要求不高,性能要求高的场景,可以使用 Redis 作为 ExecutionRecord 的存储引擎。

引入 redis 非常简单,大致分两步:

  1. 在 IdempotentConfiguration 中注册 redisExecutorFactory bean;
  2. @Idempotent 注解中使用 redisExecutorFactory 即可;

添加 redisExecutorFactory Bean,具体如下:

@Configuration
public class IdempotentConfiguration extends IdempotentConfigurationSupport {
    @Bean("redisExecutorFactory")
    public IdempotentExecutorFactory redisExecutorFactory(ExecutionRecordRepository executionRecordRepository){
        return createExecutorFactory(executionRecordRepository);
    }
    @Bean
    public ExecutionRecordRepository executionRecordRepository(RedisTemplate<String, ExecutionRecord> recordRedisTemplate){
        return new RedisBasedExecutionRecordRepository("ide-%s-%s", Duration.ofDays(7), recordRedisTemplate);
    }
    @Bean
    public RedisTemplate<String, ExecutionRecord> recordRedisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String, ExecutionRecord> redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
        Jackson2JsonRedisSerializer<ExecutionRecord> executionRecordJackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(ExecutionRecord.class);
        executionRecordJackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        redisTemplate.setValueSerializer(executionRecordJackson2JsonRedisSerializer);
        return redisTemplate;
    }
}

@Idempotent 注解调整如下:

@Idempotent(executorFactory = "redisExecutorFactory", group = 1, keyEl = "#key",
        handleType = IdempotentHandleType.RESULT)
@Override
public Long putForResult(String key, Long data){
    return put(key, data);
}

这样,所有的幂等信息都会存储在 redis 中。

【注】一般 redis 不会对数据进行持久存储,只能保障在一段时间内的幂等性,超出时间后,由于 key 被自动清理,幂等将不再生效。对于业务场景不太严格但性能要求较高的场景才可使用,比如为过滤系统中由于 retry 机制造成的重复请求。

3. 设计&扩展

3.1. 整体结构

 

image

整体设计比较简单,运行流程如下:

  1. IdempotentInterceptor 会对 @Idempotent 注解标记的方法进行拦截;
  2. 当方法第一次被调用时,会读取 @Idempotent 注解上的配置信息,使用 IdempotentExecutorFactoris 为每个方法创建一个 IdempotentExecutor 实例;
  3. 在方法调用时,将请求直接路由到 IdempotentExecutor 实例,由 IdempotentExecutor 完成核心流程;
  4. 其中,IdempotentExecutorFactories 拥有多个 IdempotentExecutorFactory 实例,并根据 @Idempotent 上配置的 executorFactory 属性使用对应的实例完成创建工作;

从设计上看,系统中可以同时配置多个 IdempotentExecutorFactory,然后根据不同的业务场景设置不同的 executorFactory。

3.2. 核心流程

 

image

IdempotentExecutor处理核心流程如下:

  1. 通过 SpringEL 表达式从入参中提取 unique key 信息;
  2. 根据 group 和 unique key 从 ExecutionRecordRepository 中读取执行记录 ExecutionRecord;
  3. 如果 ExecutionRecord 为已完成状态,则根据配置直接返回 ExecutionRecord 的执行结果 或者 直接抛出 RepeatedSubmitException 异常;
  4. 如果 ExecutionRecord 为执行中,则出现并发问题,直接抛出 ConcurrentRequestException 异常;
  5. 如果 ExecutionRecord 为未执行,先执行方法获取返回值,然后使用 ExecutionRecordRepository 对 ExecutionRecord 进行更新,然后返回执行结果;

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/14874.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

20道前端高频面试题(附答案)

setTimeout 模拟 setInterval 描述&#xff1a;使用setTimeout模拟实现setInterval的功能。 实现&#xff1a; const mySetInterval(fn, time) {let timer null;const interval () > {timer setTimeout(() > {fn(); // time 时间之后会执行真正的函数fninterval()…

2022年NPDP新版教材知识集锦--【第三章节】(4)

【敏捷开发】 8.1敏捷开发模型的定义 门径和敏捷方法的特点&#xff1a;门径流程适用于开发硬件产品&#xff0c;而敏捷方法适用于开发软件产品。这两种方法是相对独立的。敏捷方法和门径流程不是互相取代的关系。相反敏捷方法是一种有效的微观规划工具或项目管理工具&#x…

如何使用远程控制软件并将用途最大化?4款国内外优质应用测评解析

说起远控软件&#xff0c;大家的第一印象是什么&#xff1f;是能实现电脑控制电脑、手机平板控制电脑、或手机电脑控制另外手机的操作需求&#xff0c;还是TeamViewer、ToDesk、向日葵、微软桌面这类的产品名称&#xff1f; 在三年疫情下&#xff0c;我们或多或少都经历了居家…

VMware安装Centos

此教程版本使用的是 VMware 16 、Centos 7 虚拟机安装 Centos安装 注: win10下vmware 15 可能会有蓝屏现象 排查:自行检查是否安装有 KB4601319 补丁,如果有请卸载,或者安装 vmware 16 官网下载地址 跳转 控制面板 — 程序与功能 — 查看已安装的更新 虚拟机安装 打开vmware…

【快速上手系列】使用阿里云发送测试短信超简单教程

【快速上手系列】使用阿里云发送测试短信超简单教程 步骤 一、阿里云配置 1、进入阿里云首页点击短信服务 2、短信服务界面 3、点击快速学习&#xff0c;然后绑定测试手机号&#xff0c;绑定好后点击调用API发送短信 4、左侧可以看到一些参数设置&#xff0c;右面是可以选择…

基于Ryu 防火墙的检测和解决异常入侵的流量

基于Ryu 防火墙的检测和解决异常入侵的流量基于Ryu 防火墙的检测和解决异常入侵的流量防火墙规则实验仿真环节&#xff1a;1.下载代码到本地2.安装相关依赖库3.设置openflow1.34.启动控制器异常检测&#xff1a;异常解决&#xff1a;规则合并&#xff1a;防火墙规则树&#xff…

(一)EasyExcel的使用(读取数据到实体类即绑定实体类)

最近遇到了一个excel简单的导入导出的需求&#xff0c;因此就对easyexcel第三方插件的使用做一点总结&#xff0c;大家可以看一看&#xff0c;可能会对你有点帮助。 目录 前言&#xff1a; 1、引入easyexcel相关依赖 2、创建对应excel的实体类 3、导入excel&#xff0c;并…

mac照片肖像美容ON1 Portrait AI 2023

人像照片怎么美容编辑呢&#xff1f;使用 ON1 Portrait AI 2023只需点击一下即可完美修饰。它使用机器学习来查找照片中的每一张脸&#xff0c;并自动使它们看起来很棒。它分析每张脸&#xff0c;并为皮肤、眼睛和嘴巴添加适量的修饰&#xff0c;立即为您提供专业的效果。 软件…

目标检测算法——工业缺陷数据集汇总1(附下载链接)

>>>深度学习Tricks&#xff0c;第一时间送达<<< &#x1f680;近期&#xff0c;小海带在空闲之余&#xff0c;收集整理了一批自动驾驶开源数据集供大家参考。 整理不易&#xff0c;小伙伴们记得一键三连喔&#xff01;&#xff01;&#xff01;&#x1f91e…

计算机毕业设计:基于html制作大学生网上报到系统响应式模板项目源码

&#x1f389;精彩专栏推荐 &#x1f4ad;文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业&#xff1a; 【&#x1f4da;毕设项目精品实战案例 (10…

UNet - 预测数据predict(多个图像的分割)

目录 1. 介绍 2. predict 预测分割图片 3. 结果展示 4. 完整代码 1. 介绍 之前已经将unet的网络模块、dataset数据加载和train训练数据已经解决了&#xff0c;这次要将unet网络去分割图像&#xff0c;下面是之前的链接 unet 网络&#xff1a;UNet - unet网络 dataset 数…

chineseocr测试具体部署步骤(不用web界面)

源项目地址&#xff1a; https://github.com/chineseocr/chineseocr 由于chineseocr需要在web上展示检测结果&#xff0c;还需要安装web相关内容&#xff0c;我的硬件是nvidia agx orin只需要在本地查看检测结果&#xff0c;做如下操作 找到源码项目中的test.ipynb,改写成test.…

天津教育杂志天津教育杂志社天津教育编辑部2022年第30期目录

卷首语 构建精准资助模式 保障经济困难学生安心求学 本刊编辑部; 1 本刊视线_关注 中学生行为习惯养成教育的策略——基于福州第十五中学学生行为习惯养成教育的实践 贺玉亮;林瑶; 4-6 本刊视线_特殊教育《天津教育》投稿&#xff1a;cn7kantougao163.com 面向听障…

最高薪15k!“转行软件测试后,我想要的生活,就应该是这样!”

​最近的低温天气 切切实实让汇智妹感受到了冬天 上下班路上骑个自行车都冻手手 &#xff08;成都的“湿冷魔法伤害”真不是吹的&#xff09; 好不容易等来了周末 只想待在家里哪儿都不去 裹着毛毯安逸地休息两天 周一再继续撸起袖子加油干 ​ ​“公司通知周末要加班”…

jenkins 中pipeline相关语法学习

jenkins基础 Jenkins介绍 持续集成&#xff0c;就是通常所说的CI&#xff08;Continues Integration&#xff09;&#xff0c;可以说是现代软件技术开发的基础。 持续集成是一种软件开发实践&#xff0c;即团队开发成员经常集成他们的工作&#xff0c;通常每个成员至少集成一…

Docker踩坑,又涨知识了

背景 新上线一个批处理功能&#xff0c;基于Docker发布的。上线之后出现一个问题&#xff0c;Docker批处理生成的文件目录&#xff0c;别的应用程序无法访问。 之前也在使用Docker&#xff0c;但并未涉及到文件共享的问题&#xff0c;还真没留意到。经过一系列排查&#xff0…

第二章 模型评估与选择(上)

2.1 经验误差与过拟合 通常我们把分类错误的样本数占样本总数的比例为”错误率”&#xff0c;精度1-错误率。模型的实际预测输出与样本的真实输出之间的差异称为“误差”&#xff0c;模型在训练集上的误差称为“训练误差”或“经验误差”,在新样本上的误差称为“泛化误差” 当…

uni-app框架

目录 什么是uniapp 为什么学uniapp uni-app目录结构 uniapp开发规范 pages.json页面的基本配置 pages页面配置 pages对象的属性 globalStyle全局配置 配置基本tabbar tabbar的基本属性 condition启动模式配置 具体属性 组件的基本使用 text文本组件 view组件 …

【历史上的今天】11 月 18 日:DNS 发明者出生;按键式电话问世;比尔·盖茨开始编程

整理 | 王启隆 透过「历史上的今天」&#xff0c;从过去看未来&#xff0c;从现在亦可以改变未来。 今天是 2022 年 11 月 18 日&#xff0c;在 1928 年&#xff0c;华特迪士尼&#xff08;Walt Disney&#xff09;和他终身的助手阿维尔克合作创造了后来闻名世界的“米老鼠”的…

MySQL (8)

MySQL (8) 前言 &#xff1a; 知识点回顾 &#xff1a; 上文我们已经了解到了我们的事务 , 知道了事务是将诺干个独立的操作打包成一个整体 &#xff0c; 如 1 1 此时想将这个结果写到纸上 &#xff0c;那么 1 1 计算 可以看做一个操作&#xff0c; 将答案 2 写 到纸上 也是…