1. 什么是幂等

在系统设计中,“幂等”是一种非常重要的概念。

幂等性(Idempotency)指的是一个操作无论执行多少次,其结果都应保持一致,和只执行一次的结果相同。

这在分布式系统、网络请求和服务接口中尤为重要,因为它可以确保系统的数据一致性。

2. 为什么会出现重复请求

重复请求在系统设计中是一个常见的问题,尤其是在高并发和分布式环境中。

出现重复请求的原因有多种,包括用户操作、业务逻辑重试、以及消息队列的特性。下面从这几个角度来详细解释重复请求的原因及其对系统的影响。

2.1 用户重复点击

用户点击按钮后,页面或系统可能响应缓慢。用户可能因为没有立即收到反馈,认为操作未成功,从而重复点击。每次点击都会触发一个新的请求,导致服务器收到多次请求。

2.2 业务逻辑重试

在分布式系统和网络请求中,为了保证请求成功执行,通常会有业务逻辑上的重试机制。

  • 网络故障:在请求过程中,如果发生了网络超时、连接丢失或服务器未响应等问题,客户端可能会自动重试以确保请求最终成功。每次重试都将生成新的请求,导致服务器可能收到多个相同的请求。

  • 服务容错:有时服务器端在处理请求时,内部某个组件可能失败。为了提高可靠性,系统设计可能会重新触发该操作,使请求最终完成。这种设计用于容错,但也可能导致请求被多次重复。

2.3 消息队列消息重复

在分布式系统中,消息队列用于异步处理任务并解耦服务。由于消息队列的工作机制,可能会引发重复消息:

  • 消息确认机制:在一些消息队列系统(如 RabbitMQ 或 Kafka)中,消费者需要对消息进行确认。如果消费者处理完消息,但未能正确地向队列发送确认信息,队列会认为消息未被消费,因而重新发送。这种机制用于确保消息不会丢失,但可能会导致重复消费。

  • 消息回溯 :可能是服务出现了问题,需要手动回溯一段时间内的消息修复数据

  • 消息重新丢回消息队列: 消息消费时,业务逻辑处理失败,重新丢回队列等待下次处理,起到一个重试的作用

3. 如何判断请求是重复的-唯一标识

3.1 请求要有唯一标识

在处理上游请求时, 需带有唯一标识, 比如营销系统在用券过程中处理订单系统的请求中,订单号就是唯一标识

3.2 服务要记录唯一标识

只要记录才能判断。

4. 处理重复请求保证幂等的通用流程

幂等处理重复请求的逻辑基本如下

  1. 判断请求是否处理过:通过检查请求的唯一标识在系统中是否已存在。如果已存在,说明该请求是重复请求。

  2. 执行处理逻辑并记录结果:如果请求未处理过,系统会执行请求的处理逻辑(例如写入数据库、扣款等),然后将处理结果记录下来,同时保存该请求的唯一标识符,以便后续识别重复请求。

  3. 返回之前的处理结果:如果请求已经处理过,直接返回之前保存的处理结果,不再重复执行业务逻辑。这样可以防止由于重复请求导致的副作用,比如多次扣款或重复创建资源。

4.1 判断请求是否处理过

通过检查请求的唯一标识在系统中是否已存在。

注意这里的检查不一定非得是select, 毕竟在并发的情况下, 你直接用快照读select 大概率时无法保证幂等的。

所以其实在判断重复时最常见解决方式其实是用数据库唯一索引, 通过数据是否插入的结果来做“检查”工作。

选择唯一索引,是借助其唯一性约束的特性。在MySQL 中, 具有唯一性约束的索引有2个

  1. 主键索引
  2. 唯一索引

但是一般在处理重复请求保证幂等时,都是选择唯一索引

除唯一索引外,本文还将讨论Redis 分布式锁 + 快照读select 的方式如何保证幂等

4.2 如果请求未处理过

如果请求未处理过,系统会执行请求的处理逻辑,并记录下该请求供之后的重复请求判断

4.3 如果请求已经处理过

如果请求已经处理过, 则不再重复处理, 直接返回请求结果。

在这个步骤中需要探讨的是返回什么结果, 是直接返回失败,还是返回重复请求提示, 还是查询处理后的结果根据处理结果返回。

下面详细说明在不同业务场景下,可能的返回方式

4.3.1 直接返回失败

  • 适用场景:在某些需要严格保证每次请求都是独立处理的场景,比如数据库操作严格要求一次性完成。
  • 优点:这种方式简单直接,适合不需要告知重复请求或不关心前次请求结果的场景。
  • 缺点:这种处理方式可能在一些场景下引起用户疑惑,特别是用户不清楚请求已被处理时。

4.3.2 返回重复请求提示

  • 适用场景:适用于需要告诉用户请求已被处理,且不需要返回具体结果的场景,比如某些通知、确认请求。
  • 优点:这种方式能简洁地提示用户请求状态,避免重复处理。
  • 缺点:如果用户需要获取具体的处理结果(例如订单详情),则这种提示可能不够充分,影响体验。

4.3.3 查询并返回处理后的结果

  • 适用场景:最常用的方式,适用于用户需要知道请求具体处理结果的场景,比如支付、订单、操作确认等需要精确返回的业务。
  • 优点:这种方式可以有效避免重复请求给用户带来的疑惑,同时返回详细结果,让用户更清楚操作状态。
  • 缺点:若数据库读取操作频繁且请求量大,可能对数据库性能造成一定压力。

我觉得最好的方案应该是根据实际业务场景 确定返回值,而不是一概而论。

  • 对于关键业务场景(如支付、订单)推荐返回已处理的结果,以确保用户获得准确的反馈。
  • 对于简单的幂等操作(如确认请求),可以返回重复请求提示,快速回应用户。
  • 而对于更严格的操作需求,可以选择直接返回失败,提示用户请求冲突或错误。

下面将介绍具体的技术方案

5. 基于唯一索引-本地事务如何保证幂等

使用唯一索引的前提下, 以上处理重复请求的通用流程可以具体描述为,

  1. 将带有唯一标识的请求插入数据库
  2. 如果插入成功, 则代表数据未处理过, 可以继续执行处理业务逻辑
  3. 如果数据插入失败, 代表数据已经处理过, 直接返回结果即可

在很多情况下,唯一标识符的插入操作往往是业务操作的一部分,这使得唯一索引方案更加直接和高效。

但是,如果唯一标识符本身不是业务数据的一部分,而只是用于控制重复请求,则确实需要单独建立一张表来记录请求 ID 及其处理状态。

要注意一点的是, 以上看起来完美简洁的处理过程,其实需要建立在一个非常重要的前提下。

==那就是数据插入和业务逻辑需要用本地事务保证原子性。

如果数据插入和业务执行不用本地事务保证原子性的话,就有可能出现部分失败的情况, 即业务执行失败,但是唯一索引插入成功了, 这会引发数据不一致的问题。

如果业务选择重复请求直接返回error 或者重复请求提示, 那这个实际业务逻辑并未执行的请求将永远无法得到处理,则数据不一致问题一直存在。当然如果你有合理的对账机制还是可以发现问题的。

如果重复请求返回之前的处理结果, 那还是可以发现问题的, 可以重新处理,但是这无疑违背了数据存在则代表已经处理过的逻辑, 也增加了代码复杂度。

所以,在能够使用本地事务保证数据插入和业务逻辑 原子性的时候, 要注意务必使用。

不能使用本地事务保证原子性的时候, 看下面这种方案

6. 基于唯一索引-分布式事务下如何保证幂等

其实在分布式系统下,很容易出现唯一标识数据插入和 业务逻辑 无法用本地事务保证原子性的情况

比如, 业务操作的数据有分库分表的情况
再比如,业务逻辑有调用第三方服务的情况。

面对这种分布式事务, 可以使用2PC + 异步检查的方案

所谓2PC ,具体来讲就是 唯一标识数据表要增加一个状态字段(如 PENDINGPROCESSINGFAILEDCOMPLETED 等)。
初次请求插入时,标记为 PROCESSING。 插入时默认是初始态,然后执行业务逻辑, 根据业务逻辑执行结果,更改状态。

异步检查则用来处理部分失败的情况来保证数据的最终一致性

分布式事务下,数据插入和业务逻辑要分成 3 步处理,

  1. 把数据插入到唯一索引,但是这个时候状态被标记为初始状态。注意这一步一定要先执行,这是避免重复处理的关键。
  2. 执行业务操作。
  3. 将唯一索引对应的数据标记为完成状态。

出问题的地方就是第二步成功了,但是第三步失败了,即分布式事务部分失败的情况。这时候就需要使用一个异步检测逻辑,定时扫描唯一索引的表,然后再去扫描业务表, 判断对应的业务处理结果来更新唯一索引的状态。这个时候会有两种情况。

  1. 根据业务表数据判断对应请求已经处理完成, 可以将对应唯一索引更新为成功状态。
  2. 根据业务表数据判断对应请求处理失败,可以直接发起重试或者等待重复请求

6.1 如果请求已经处理过, 返回什么数据

在可以用本地事务保证数据插入和业务操作的原子性时, 查询到数据就默认业务逻辑已经执行成功了, 但是在分布式事务中, 数据插入增加了状态字段, 这时候该如何处理重复请求呢,一般有以下做法

1. 只要数据存在就按照业务场景返回值,不关心状态

  • 适用场景:对于非关键性业务,重复请求只需要返回之前处理的结果,而不关心操作是否真正成功。这种方式适合场景是:即使业务逻辑未完全执行完毕或存在部分失败,对用户体验的影响不大。
  • 实现逻辑:重复请求到来时,只要数据库中存在该请求的唯一标识记录,系统就返回之前的结果或默认提示(例如“请求处理中”)。
  • 优点:这种方式能快速响应重复请求,不必等到业务逻辑完全完成,适合对状态不敏感的操作,减少请求处理延迟。
  • 缺点:如果业务逻辑执行失败或未完成,可能会误导用户,以为请求已完全处理成功。

2. 只有标记为“业务执行成功”的数据返回结果,其他状态继续执行

  • 适用场景:适用于对请求结果准确性要求较高的场景,如支付、订单处理等。重复请求时,需要确保业务操作真正完成,才能返回相应的处理结果。
  • 实现逻辑
    • 请求初次到达时,将数据状态设置为“处理中”或“待完成”,并执行相应业务逻辑。
    • 若逻辑成功,更新状态为“成功”并返回结果。
    • 若重复请求到来,系统检查状态字段。若状态为“成功”,直接返回结果;若状态为“处理中”或“失败”,则继续尝试完成未执行的操作。
  • 优点:确保请求的业务逻辑完整执行,能准确返回最终处理结果,避免部分成功或未完成的情况影响用户体验。
  • 缺点:系统会对未完成的请求重复尝试,可能会导致重复执行,尤其是“处理中”的状态容易增加数据库负担。

3. 判断状态并执行相应操作

  • 在这种模式下,根据数据状态做不同的处理,实现更精确的控制逻辑:
    • 状态为“成功”:直接返回处理结果,避免重复执行。
    • 状态为“处理中”:根据业务场景,选择返回“正在处理中”的信息,或检查是否超过超时时间,决定是否重试。
    • 状态为“失败”或其他中间状态:可以自动重试,或者根据业务逻辑决定是否返回错误信息,让用户选择重试。
  • 适用场景:复杂业务场景,特别是有多种中间状态的操作(如支付、物流等),需要确保业务逻辑完成。
  • 优点:灵活性强,根据状态精准控制逻辑,提高处理的准确性。
  • 缺点:代码复杂度增加,并且可能需要额外的状态管理和清理逻辑。

最佳方案一般是 根据状态来判断如何处理,特别是在关键业务场景中(如支付、订单)。使用状态字段来明确请求的处理进度和结果状态,可以确保系统准确返回处理结果,同时避免因部分操作失败或未完成而影响业务一致性和用户体验。具体实现方案:

  1. 状态管理表设计
    • 为请求表增加状态字段(如 PENDINGPROCESSINGFAILEDCOMPLETED 等),记录每次请求的状态。
    • 初次请求插入时,标记为 PROCESSING
  2. 重复请求的状态判断
    • 成功状态(COMPLETED:直接返回最终处理结果,避免重复执行。
    • 处理中状态(PROCESSING:根据业务场景,选择返回“正在处理中”的信息,或检查是否超过超时时间,决定是否重试。
    • 失败状态(FAILED:可以尝试重试业务逻辑,也可以根据业务需求返回错误提示,避免用户等待。

适用场景:复杂业务场景,特别是有多种中间状态的操作(如支付、物流等),需要确保业务逻辑完成。

优点:灵活性强,根据状态精准控制逻辑,提高处理的准确性。

缺点:代码复杂度增加,并且可能需要额外的状态管理和清理逻辑。

6.2 异步补偿任务的重试策略

补偿任务的重试策略若未控制好,可能会导致大量重复操作,比如不断对同一条记录尝试补偿操作,从而对数据库造成不必要的压力。

补偿任务设定合理的重试次数和时间间隔,并在一定次数的重试后,应该告警通知人工处理,避免无限重试的情况。

6.3 重试下的并发

从前面的描述中可以看出, 在异步补偿机制中可能执行一个请求的业务逻辑,上游重复请求如果状态不是成功也可能触发业务逻辑执行,导致同一数据被并发处理,从而造成数据不一致或重复操作。 可以考虑以下方案

分布式锁:确保针对某条请求或记录的操作只会被单一实例处理。这样,即使前端请求和异步补偿同时触发操作,也能确保只有一个线程/实例在执行,避免重复处理问题。

乐观锁或版本号控制:在数据库记录中加入版本号或时间戳,通过比较版本号来确保只有最新的更新才会被接受。如果某个操作发现记录版本号已变更,则跳过操作或重新加载最新数据。

7. 使用 Redis 分布式锁控制重复请求-不用唯一索引

可以将唯一请求标识作为key ,使用setnx 来控制重复请求,只有获取到锁的请求,才能到数据库中判断请求是否处理过具体处理流程如下

  • 获取 Redis 锁:每次请求到达时,首先尝试获取 Redis 分布式锁(例如基于请求 ID 或业务 ID)。
  • 成功获取到锁:成功获取锁后,系统可以使用select快照读查询数据库,判断业务数据是否已存在。
    • 如果数据存在,说明请求已处理过,可以直接返回结果。
    • 如果数据不存在,执行数据插入和后续业务逻辑, 具体就是上文中本地事务和分布式事务的方案。
  • 未获取到锁:如果在尝试获取 Redis 锁时发现锁已被持有,可以直接根据场景返回合适的信息(例如“请求处理中”或上一次请求的结果),避免重复处理。

7.1 优点

  • 无需使用唯一索引:使用分布式锁可以有效防止并发重复请求,而不依赖数据库唯一索引来判断请求的唯一性。
  • 减少数据库压力:这种方式减少了对数据库唯一索引的依赖,不需要额外的索引检查,有助于在高并发场景下降低数据库的索引负载。

7.2 潜在问题和解决方法

  1. 锁的过期时间与任务执行时间的同步问题
    • 问题描述:如果业务逻辑执行时间较长,而 Redis 锁的过期时间较短,锁可能在业务逻辑执行完之前被释放。其他请求可能会在锁过期后重新获取锁,从而导致重复处理。
    • 解决方法:可以动态设置锁的过期时间,或者在长时间任务中定期刷新锁,确保锁在业务逻辑执行完毕前不会过期。
  2. 锁竞争导致延迟
    • 问题描述:在高并发场景中,多个请求可能会同时尝试获取锁,未能获取锁的请求会直接被返回处理结果或默认值。这可能导致某些请求延迟或未能成功触发业务逻辑。
    • 解决方法:可以在获取锁失败时增加简单的重试机制(例如短暂等待后再尝试获取锁),提高锁的获取成功率。不过,这会带来少量额外的延迟,需要根据业务场景衡量利弊。
  3. 锁的可靠性问题
    • 问题描述:在 Redis 实例重启或网络故障的情况下,锁有可能被意外释放或丢失,导致多个请求进入业务逻辑,从而导致重复处理。
    • 解决方法:为提升锁的可靠性,可以将锁的管理迁移到 Redis 集群中,使用多节点的 Redis 实例,增强锁的持久性。此外,可以在业务逻辑中实现幂等性,即使在锁失效的情况下,重复执行也不会影响最终结果。
  4. 锁释放与数据一致性
    • 问题描述:在极端情况下,业务逻辑执行成功但未能释放锁(例如服务器崩溃或进程被强制中断),可能导致锁保持被持有的状态,阻止后续请求正常执行。
    • 解决方法:可以设置锁的过期时间,并在锁释放之前检查操作是否已经完成,以确保状态一致。此外,可以通过 Redis 事务或 Lua 脚本确保锁的获取和释放都在 Redis 内部完成,避免锁释放的潜在不一致问题。

8. 唯一索引 vs Redis

8.1 原子性和一致性

  • 数据库唯一索引提供了天然的幂等性保障:对于同一个请求 ID,数据库在插入时会自动校验唯一性,这意味着一旦插入成功,后续重复插入将自动被拒绝。
  • 因此,原子性操作由数据库控制,避免了额外的状态检查和分布式锁逻辑,且不依赖于外部系统(如 Redis),从而保证操作在并发下的一致性。

    8.2 可靠性

  • 数据库的唯一约束是持久性的,避免了 Redis 分布式锁在极端情况下失效的问题。Redis 分布式锁的可靠性依赖于 Redis 的稳定性,而数据库的唯一约束则不受外部条件的影响。

  • 唯一索引依赖于数据库,且数据库天然具备持久性,适合需要保证操作绝对正确的关键业务(如订单生成、支付扣款等)。

8.3 简化的代码和逻辑

  • 使用数据库唯一索引方案能够减少代码复杂度,因为请求的判断和插入是一个原子性操作,不需要额外控制并发或同步机制。
  • 而在 Redis 分布式锁方案中,需要额外处理锁的获取、释放、超时和潜在的异常情况,增加了业务逻辑的复杂性和管理成本。

对于多数业务场景,数据库唯一索引是最佳选择,它更可靠、简洁、适合大多数数据库能够支撑的高并发操作。
在极端高并发场景或复杂业务场景下,可以考虑Redis 分布式锁方案,尤其是当业务需要灵活性时。

另外,如果希望充分利用二者的优点,可以结合数据库唯一索引和 Redis 锁:使用唯一索引作为主幂等保障,Redis 锁作为并发控制辅助,以提升系统的扩展性和稳健性。

9. 高并发高重复下 , 如何提升唯一索引的性能: Redis + 唯一索引

使用唯一索引时,性能瓶颈完全取决于数据库。

如果短时间重复请求非常多,可以使用 Redis 存储已处理过的请求来拦截短时间内的重复请求,以有效减少数据库层面的重复请求判断,避免数据库频繁执行唯一索引检查,从而提升系统性能。

Redis 的value 类型是 set,用户存储处理过的请求

注意:这里的redis 不再是第7小节中的分布式锁功能

注意:如果不是高并发或者重复率很高的情况下, 这种方案和直接使用唯一索引相比,并不会提高性能,甚至更慢, 因为多了一层判断。

处理流程如下

该方案需要考虑以下问题

9.1 Redis 更新逻辑 -辅助性组件

使用唯一索引的具体方案,依然是前面介绍过的分为本地事务、分布式事务2种情况。
Redis 在这个方案中确实作为一个辅助性工具,用于快速判断短时间内的重复请求,以减轻数据库负载并加快响应速度,而不是作为请求处理过程中的核心事务性组件。即Redis 操作均不在其事务范围内, 即使redis操作失败也不影响业务结果。

将 Redis 作为重复请求的辅助性判断工具,而不是事务的一部分,这种设计思路非常合理且符合分布式系统的最佳实践。具体表现为:

  • 提高了系统性能:通过 Redis 辅助缓存,减少了频繁的数据库访问量。
  • 保障了业务核心的一致性:核心事务逻辑依然依赖数据库,而不是 Redis,从而避免了因 Redis 操作失败导致请求失败的风险。
  • 增强了系统的容错性:Redis 操作失败时系统依然可以正常处理请求,确保了系统的高可用性。

本地事务后更新Redis

比较简单,成功后就就把对应唯一标识加入到Redis 中即可

分布式事务后更新Redis

分布式事务环境下的唯一索引存在多种状态和部分失败的情况,因此确实需要根据具体的业务场景来决定哪些状态下的唯一标识可以放入 Redis,并且在异步补偿逻辑中合理处理 Redis 相关逻辑。

在分布式事务下,业务操作的执行可能会导致请求的状态多样化,例如PENDINGPROCESSING、COMPLETED 等状态。将所有状态都放入 Redis 并不一定合适,需要基于业务场景做出选择。

可以考虑选择将“成功”或“最终完成”状态的请求放入 Redis,这样能够过滤掉短时间内的重复请求,而不影响系统的幂等性。如果请求仍在“处理中”或“部分成功”,则避免直接加入 Redis,以免误导其他请求判断。

9.2 Redis 的过期时间

可以根据业务需要设置 Redis 中记录的过期时间。例如,如果系统常见的重复请求会在几分钟内重新到达,则可以在 Redis 中为每个处理记录设置一个合理的过期时间(如 5 分钟)。这样,过期的请求自动失效,不会长期占用缓存空间。
注意过期时间应该做成可配置的, 方便根据业务的实际重复请求频率动态调整过期时间,以找到适合的平衡点,减少缓存失效对数据库的冲击。