掘金 后端 ( ) • 2024-04-19 18:11

如何保证服务幂等性?

幂等性原本是数学中的概念,引入到计算机软件设计中,指在多次请求同一资源的情况下,只有第一次请求会对资源产生影响,而后续的重复请求不会对资源造成进一步的影响。这意味着无论操作执行多少次,资源的状态都应该保持一致。

幂等性主要用于处理网络延迟、系统故障或用户重复操作等情况,确保数据的一致性和系统的稳定性,它是服务对外的一种承诺,即使外部调用失败并进行重试,系统的数据状态也不会因此发生变化。

  1. 基于标识实现
    • 唯一事务ID:为每个请求生成一个唯一标识符,用于检测和防止重复处理。
    • 令牌机制:使用一次性令牌来确保请求只被执行一次。
  2. DB层实现
    • 乐观锁:通过版本号或时间戳来控制数据的并发更新。
    • 悲观锁:在数据操作期间加锁,防止其他操作并发修改。
    • 数据库约束:利用数据库的唯一约束防止数据重复。
  3. 逻辑设计实现
    • 命令模式:封装操作,确保可以安全重复执行命令而不影响系统状态。
    • 备忘录模式:在操作前保存状态,支持恢复到操作前的状态。
  4. 其他技术
    • 时间戳和条件请求:基于时间戳和其他条件(如ETag)来处理或拒绝请求。
    • 分布式锁:使用外部系统(如Redis、Zookeeper)提供的锁机制来控制资源访问。

什么是幂等性?

定义

  1. 纯粹的幂等性:操作可以无限次重复执行,而结果总是不变的。例如,数据库查询通常是幂等的,因为查询不会改变数据状态。
  2. 有副作用的幂等性:初次操作可能会改变系统状态或资源,但之后重复的操作不会再进一步改变状态。例如,更新一个数据库记录的特定字段为固定值,无论这个操作执行多少次,该字段的值都是相同的。

举例

  1. HTTP GET 请求
    • 在网络协议中,HTTP GET 请求是幂等的。无论你请求一个网页多少次,结果应该总是显示相同的信息(假设网页内容没有变化)。
  2. 删除操作
    • 在一个RESTful API中,执行DELETE请求删除资源的操作通常是幂等的。第一次DELETE请求可能会删除一个资源,使其不再可访问。之后的DELETE请求将不会有任何效果,因为资源已经不存在了。
  3. 设置固定值
    • 在数据库操作中,假设有一个命令设置用户的账户状态为“已验证”。无论这个命令执行多少次,账户状态都会被设置为“已验证”,进一步的执行并不会改变状态。
  4. 支付操作
    • 在支付系统中,如果某一支付请求设计为幂等,即使因为网络问题导致请求被重发多次,系统也只会执行一次扣款。这通常通过检查支付操作关联的唯一事务ID来实现。

幂等性不仅指操作多次而不产生副作用,如查询数据库,还涵盖了那些初次请求可能改变资源状态,但后续重复请求不再产生进一步影响的场景。

这是服务对外的一种承诺,保证无论多少次执行,只要操作成功,其对系统的影响始终保持一致,从而在面对网络问题和必要的重试时维持数据的稳定性和一致性。

为什么需要保证幂等性?

在许多业务场景中,缺乏幂等性设计会带来严重后果,尤其是在涉及金融交易如支付和下单的系统中。

举例

假设一个用户在在线购物平台上购买商品。在支付过程中,用户点击了“支付”按钮提交订单,但由于网络延迟,用户没有立即收到任何反馈。

这种不确定性可能导致用户多次点击“支付”按钮。如果支付操作不是幂等的,每次点击都会触发一个新的支付请求。

那么就可能导致下面几种后果。

  1. 重复扣费
    • 用户的账户可能被重复扣款,导致超出实际购买金额的费用。这不仅会给用户带来经济损失,还会损害平台的信誉和用户信任。
  2. 订单处理混乱
    • 系统可能为每次点击生成新的订单。这将导致库存管理混乱,物流跟踪困难,以及客户服务问题。
  3. 客户服务压力
    • 重复的订单和扣费问题将导致大量客户投诉,增加客户服务压力和处理成本。

什么场景下需要幂等性保障?

  1. 网络应用和服务调用
    • 在任何涉及网络请求的应用中,网络延迟或不稳定可能导致客户端重试请求,这要求服务端的操作能够幂等,以防止重复处理。
  2. 分布式系统
    • 在分布式系统中,由于服务间的调用和资源共享,需要确保跨服务调用的幂等性,以防数据不一致和状态冲突。
  3. 金融交易处理
    • 在处理支付、转账、订单处理等金融操作时,幂等性是必须的,以避免如重复扣款或订单重复生成等严重问题。
  4. 资源的创建和修改
    • 当创建或更新资源(如数据库记录、文件等)时,确保操作如重复执行不会导致额外影响,例如在创建用户账户或更新用户设置时。
  5. 批处理和自动化任务
    • 对于定期执行或可能会因失败而重试的批处理作业和自动化任务,保证幂等性可以避免执行多次导致的数据问题或资源浪费。
  6. 用户界面操作
    • 对于用户界面中的任何操作,如表单提交或按钮点击,网络延迟或用户重复点击都应通过幂等设计来处理,确保操作结果的一致性。
  7. 系统集成和第三方服务调用
    • 在与外部系统或第三方服务集成时,尤其是在不可靠的网络环境下,需要考虑操作的幂等性,以确保数据同步和一致性。
  8. 容错和灾难恢复机制
    • 在设计容错机制和灾难恢复过程时,幂等性保证了在出现故障时系统能够安全地重启或回滚至稳定状态。

谁来实现幂等性保障?

主要就是后端来做实现,前端只能尽可能避免但是不能保证,当然,有的公司存在独立的网关或其他基础设施运维团队,那么也可以在这些方面做实现。

  1. 后端开发团队
    • 后端团队承担了幂等性保障的主要责任。他们需要在服务器端实现逻辑来处理重复的请求,确保无论一个操作被触发多少次,最终的系统状态都保持一致。这通常涉及到在数据层添加唯一标识符、使用锁机制、状态检查等方法。
  2. 前端开发团队
    • 前端开发者也可以通过界面设计和客户端逻辑减少重复提交的可能性。例如,他们可以在用户提交表单后禁用提交按钮,或者在数据正在提交过程中显示加载提示,避免用户因为响应延迟而多次点击。
  3. 基础设施和运维团队
    • 这些团队负责部署和管理应用程序的运行环境,如服务器、数据库和其他中间件。他们可以配置负载均衡器、API网关等技术支持幂等性,例如通过设置超时、重试策略和缓存机制来辅助幂等性措施的实现。

什么时候制定与关注幂等实现?

  1. 需求分析阶段
    • 在项目的早期阶段,当业务分析师和系统架构师在定义功能需求时,就需要考虑到幂等性。
    • 特别是对于涉及网络请求、数据交互或多用户操作的系统功能,明确是否需要幂等性,可以帮助后续设计和开发阶段更好地实现这一要求。
  2. 系统设计阶段
    • 在系统设计阶段,架构师和设计师需要详细规划如何实现幂等性。
    • 这包括选择适合的数据结构、数据库设计、接口定义等。此阶段决定了实现幂等性的核心策略,如使用唯一事务ID、令牌机制、适当的锁机制等。
  3. 开发阶段
    • 开发人员在编写代码时需要具体实现幂等性措施。这涉及到编写处理重复请求的逻辑,确保数据操作的原子性和一致性。
    • 在这一阶段,前端和后端开发者都应该采取措施防止重复提交和处理并发请求。

如何保障服务的幂等性?

基于标识实现

唯一事务ID

客户点击提交订单按钮,但由于网络延迟,客户未看到反馈而再次点击提交。服务器需要处理这种可能的重复提交,确保订单只被创建一次。

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class OrderService {
    // 假设这是存储已处理事务的数据库或内存结构
    Map<String, OrderResult> processedTransactions = new HashMap<>();

    public OrderResult submitOrder(OrderData order, String transactionId) {
        // 检查事务ID是否已存在
        if (processedTransactions.containsKey(transactionId)) {
            // 如果事务ID存在,直接返回之前的处理结果
            return processedTransactions.get(transactionId);
        } else {
            // 处理订单
            OrderResult result = processNewOrder(order);
            // 存储事务ID与处理结果
            processedTransactions.put(transactionId, result);
            return result;
        }
    }

    private OrderResult processNewOrder(OrderData order) {
        // 这里包含创建订单的逻辑
        // ...
        return new OrderResult("Success", "Order Created");
    }
}

class OrderResult {
    String status;
    String message;

    public OrderResult(String status, String message) {
        this.status = status;
        this.message = message;
    }
}

class OrderData {
    // 订单数据结构
    // ...
}

OrderService 类使用 submitOrder 方法接受订单数据和一个事务ID。它首先检查是否已处理过相应的事务ID。如果是,则直接返回之前的处理结果,从而防止重复处理订单;如果不是,它会处理订单,并将结果与事务ID关联存储起来。

这种方法确保了即使在多次提交相同事务ID的请求时,系统的行为也是幂等的,避免了重复创建订单等潜在问题。

令牌机制

  1. 生成令牌:在用户开始一个操作(如提交表单)之前,服务器生成一个唯一的令牌,并将此令牌发送给客户端(通常是作为表单的一部分)。
  2. 客户端提交令牌:用户提交表单时,令牌被一同发送到服务器。
  3. 服务器验证令牌
    1. 服务器检查接收到的令牌是否有效(即是否存在于服务器之前生成的令牌列表中,并且尚未被使用)。
    2. 如果令牌有效,服务器处理请求并标记该令牌为已使用,从而防止同一个令牌再次使用。
    3. 如果令牌无效(不存在或已被使用),服务器拒绝操作并返回错误。
  4. 令牌失效:操作完成后,令牌被设置为失效状态,确保同一令牌不能被用于另一次操作。

假设我们有一个在线商店的结账过程,使用令牌机制防止用户因点击结账按钮多次而多次扣款。

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class CheckoutService {

    // 存储生成的令牌
    Set<String> validTokens = new HashSet<>();

    // 请求结账时生成令牌
    public String generateToken() {
        String token = UUID.randomUUID().toString();
        validTokens.add(token);
        return token;
    }

    // 处理结账
    public PaymentResult processPayment(String token, PaymentData paymentData) {
        if (!validTokens.contains(token)) {
            // 如果令牌无效或已使用
            return new PaymentResult("Failure", "Invalid or expired token");
        } else {
            // 执行支付逻辑
            performPayment(paymentData);
            // 标记令牌为已使用
            validTokens.remove(token);
            return new PaymentResult("Success", "Payment processed");
        }
    }

    private void performPayment(PaymentData paymentData) {
        // 实际的支付处理逻辑
        // ...
    }
}

class PaymentResult {
    String status;
    String message;

    public PaymentResult(String status, String message) {
        this.status = status;
        this.message = message;
    }
}

class PaymentData {
    // 支付数据结构
    // ...
}

CheckoutService 类负责结账流程,包括生成令牌、验证令牌的有效性,并处理支付。

这种机制确保即使用户多次点击提交按钮,只要令牌已被使用,重复的请求就不会导致多次扣款,从而实现幂等性。

该方法需要配合前端实现哦!

DB层实现

在数据库层面实现幂等性时,乐观锁和悲观锁是两种常用的锁机制,用于控制数据的并发访问和修改。

这些锁机制能够防止数据冲突和不一致,特别是在高并发的应用场景中。

乐观锁

人如其名,非常乐观,乐观锁默认认为不会出现数据不一致问题。

乐观锁基于这样的假设:数据通常情况下不会发生冲突,因此,在数据库操作时,它先执行操作,然后在提交时检查数据在读取到提交期间是否被其他事务修改过。

假设有一个订单系统,在订单表中每个订单记录包含一个版本号字段,当更新订单信息时,先读取订单数据和其版本号,更新时检查版本号是否发生变化。

UPDATE orders SET status = 'processed', version = version + 1
WHERE order_id = 123 AND version = 1;

如果version不匹配,意味着另一个事务已经修改了数据,当前更新操作将失败。

乐观锁适用于冲突较少的场景,可以减少锁的开销,提高系统的并发能力。

悲观锁

人如其名,非常悲观,悲观锁默认为数据多半会出现不一致问题。

悲观锁假设数据很可能会被其他事务修改,因此在数据被读取时就锁定它,直到当前事务完成。

我们可以利用数据库提供的锁机制来实现,通常使用行级锁。

在处理订单支付时,为了防止订单被并发修改,可以在查询时锁定订单记录。

SELECT * FROM orders WHERE order_id = 123 FOR UPDATE;

这个查询会锁定ID为123的订单,直到事务完成,其他试图修改此记录的事务必须等待第一个事务完成。

悲观锁适用于高冲突环境,可以直接防止数据冲突,但可能降低并发性能。

唯一约束

这个不必多说,数据库基本都可以设置唯一约束,某一个字段不能重复,否则直接抛出异常。

逻辑设计实现

命令模式(Command Pattern)

命令模式是一种行为设计模式,它将一个请求或简单操作封装为一个对象,从而允许用户使用不同的请求、队列或日志请求,并支持可撤销操作。

应用场景

  • 支持撤销/重做操作。
  • 参数化对象根据请求行为。
  • 将请求排队执行。
  • 记录操作日志,以便重播请求或恢复操作。

命令模式可以通过精确控制何时何如何执行操作来保证幂等性,每个命令对象都确保其执行的操作可以安全地重复执行或撤销重做而不影响最终系统状态。

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
interface Command {
    void execute();
    void undo();
}

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class Light {
    public void turnOn() { System.out.println("Light is on"); }
    public void turnOff() { System.out.println("Light is off"); }
}

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class TurnOnCommand implements Command {
    private Light light;

    public TurnOnCommand(Light light) {
        this.light = light;
    }

    public void execute() {
        light.turnOn();
    }

    public void undo() {
        light.turnOff();
    }
}

// 使用命令对象
Light light = new Light();
Command switchOn = new TurnOnCommand(light);
switchOn.execute(); // 执行命令
switchOn.undo();    // 撤销命令

使用设计模式解决会带来更多的心智负担。

备忘录模式(Memento Pattern)

备忘录模式是一种行为设计模式,它允许在不违反封装原则的情况下捕获并外部化一个对象的内部状态,以便以后可以将该对象恢复到这个状态。

应用场景

  • 需要保存/恢复对象状态的应用。
  • 提供一个回滚操作,当操作失败或有问题时可以恢复到先前的状态。

备忘录模式通过保存状态快照来实现幂等性。如果多次执行相同操作,系统可以利用保存的状态快照恢复到初始状态,确保操作的幂等性。

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class Originator {
    private String state;

    public void setState(String state) {
        this.state = state;
    }

    public Memento saveStateToMemento() {
        return new Memento(state);
    }

    public void getStateFromMemento(Memento memento) {
        state = memento.getState();
    }
}

/**
 * 伪代码
 *
 * @author JanYork
 * @email <[email protected]>
 * @date 2024/4/19 下午4:51
 */
class Memento {
    private String state;

    public Memento(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }
}

// 使用备忘录对象
Originator originator = new Originator();
originator.setState("State #1");
Memento savedState = originator.saveStateToMemento();
originator.setState("State #2");
originator.getStateFromMemento(savedState); // 恢复到State #1

使用设计模式解决会带来更多的心智负担。

其他实现

时间戳和条件请求

时间戳和条件请求是一种确保幂等性和优化资源管理的策略,常用于缓存控制和减少不必要的服务器负载,这种方法通常通过HTTP协议中的条件请求头来实现,如If-Modified-SinceETag

工作原理

  • 时间戳:客户端发送请求时附加上次获得资源时的时间戳。服务器检查资源的最后修改时间;如果服务器上的资源自那时未被修改,服务器返回304 Not Modified状态,否则返回新资源和200 OK状态。
  • ETag(实体标签):ETag是资源的特定版本的标识符。当资源被请求时,服务器生成并发送ETag,表示资源的当前状态。客户端随后的请求将包括此ETag值(在If-None-Match头中)。如果ETag未改变,表明资源未修改,服务器返回304 Not Modified;如果ETag改变,表明资源已更新,服务器则发送新资源。
GET /image.png HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT

HTTP/1.1 304 Not Modified
Date: Sun, 23 Oct 2016 14:19:41 GMT

这种方法非常适合于处理Web资源,如图片、CSS文件或JavaScript文件,以减少不必要的数据传输。

虽然看上去和令牌机制相似,但是场景不太一样,这个在CDN对象存储相关领域用的较多。

分布式锁

分布式锁是在多个计算实例间同步访问共享资源的一种机制,用于在分布式系统中实现跨多个节点的操作的原子性和一致性。

一般是通过使用外部协调服务(如Redis、Zookeeper或数据库)来管理锁的状态。

当一个服务实例需要执行对共享资源的操作时,它首先必须从协调服务中获取锁。

如果获取锁成功,该实例执行操作;操作完成后释放锁。

如果锁已被其他实例持有,则当前请求可能需要等待或者直接失败。

// 假设使用Jedis库操作Redis
Jedis jedis = new Jedis("localhost");
String key = "resource_lock";
String token = UUID.randomUUID().toString();
String result = jedis.set(key, token, "NX", "EX", 30); // 尝试获取锁,设置30秒过期

if ("OK".equals(result)) {
    try {
        // 执行操作
        performCriticalTask();
    } finally {
        // 释放锁
        // 使用Lua脚本来确保只有持有锁的实例可以释放锁
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, Collections.singletonList(key), Collections.singletonList(token));
    }
} else {
    System.out.println("Failed to acquire lock");
}

幂等击穿

幂等性被击穿是分布式系统中的一种严重问题,特别是在涉及金融交易或其他关键数据操作的系统中。

幂等性保证一个操作无论执行多少次,结果都应该相同,但在实际情况中,由于系统的复杂性和环境的不可预见性,幂等性可能会被击穿。

发生原因

  1. 同步延迟
    • 在分布式系统中,节点间的数据同步可能存在延迟。如果在数据完全同步之前另一个节点接收到了相同的请求,这个节点可能无法识别该请求为重复,从而处理它为一个新的操作。
  2. 请求流量切换
    • 在多机房或多数据中心部署的系统中,流量可能因网络或配置问题被错误地路由到其他机房。如果备用机房的幂等性保护数据不完整,可能无法正确处理重复请求。
  3. 全局唯一ID生成故障
    • 如果依赖全局唯一ID来实现幂等性,ID生成算法的故障或配置变更可能导致重复或冲突的ID生成,从而导致幂等性保护失败。

预防措施

  1. 增强数据同步和一致性机制
    • 使用更强大的数据一致性协议和技术,如Raft或Paxos协议,确保数据在所有节点间快速、可靠地同步。
  2. 增强监控和告警系统
    • 实时监控关键操作和性能指标,快速识别并响应潜在的幂等性击穿问题。
  3. 全面的变更管理
    • 任何可能影响系统幂等性的变更(如ID生成规则变更)都需要全面的测试和跨部门的沟通,确保所有系统和团队都有相应的更新和准备。
  4. 使用高级幂等性保护机制
    • 除了基于ID的检查外,可以结合使用时间戳、令牌和其他逻辑确保操作的幂等性。

如何选择

1. 评估操作类型和频率

  • 读多写少的应用:如果应用主要涉及读操作,可能更多地依赖于时间戳和条件请求来优化性能和减少不必要的数据传输。
  • 写密集型应用:在频繁写入的场景中,使用乐观锁悲观锁可以更好地管理并发更新,防止数据冲突。

2. 考虑并发和数据一致性要求

  • 高并发系统:在高并发场景中,分布式锁(如利用Redis或Zookeeper)提供了一种跨多个服务或节点同步资源访问的有效方法。
  • 数据一致性需求严格:在金融或其他需要高度一致性的系统中,使用数据库约束悲观锁可以防止数据不一致。

3. 资源的独立性和共享性

  • 资源高度共享:在多个进程或服务需要频繁共享同一资源的情况下,采用悲观锁分布式锁以确保在操作期间资源的独占性。
  • 资源独立性高:对于独立资源的操作,乐观锁唯一事务ID可能更加适用,因为它们能减少锁的开销,提高系统性能。

4. 事务的复杂度和撤销需求

  • 需要支持撤销或重做的操作:在需要支持复杂事务管理的系统中,如编辑器或工作流系统,命令模式备忘录模式非常适用,它们支持操作的撤销和重做。

5. 系统架构和技术栈的适应性

  • 现有技术栈的支持:选择与现有系统技术栈兼容且易于集成的解决方案。例如,如果系统已经广泛使用Redis,那么使用Redis实现分布式锁可能更为便捷。

6. 性能和扩展性考虑

  • 性能敏感型应用:在性能敏感的应用中,避免使用可能导致较大性能开销的方法,如悲观锁。相反,可以考虑使用乐观锁唯一事务ID,这些方法通常对系统性能的影响较小。

其他说明

  • 重复提交的情况和服务幂等的初衷是不同的

    • 重复提交是在第一次请求已经成功的情况下 ,人为地进行多次操作, 导致不满足幂等要求的服务多次改变状态
    • 幂等更多使用的情况是第一次请求因为某些情况,不如超时,而导致不知道结果或者请求失败的异常情况下,发起多次请求
  • 幂等的目的是请求多次确认第一次请求成功,不会因为多次请求而出现多次的状态变化

  • 在SQL中,有以下三种场景,只有第三种场景需要保证幂等性

    • SELECT col1 FROM tab1 WHERE col2=2 : 无论执行多少次都不会改变状态,是天然的幂等
    • UPDATE tab1 SET col1=1 WHERE col2=2 : 无论执行成功多少次状态都是一致的,也是幂等操作
    • UPDATE tab1 SET col1=col1+1 WHERE col2=2: 每次执行的结果都会发生变化,这种不是幂等的,要采取策略保证幂等性
  • 幂等是为了简化客户端逻辑,但是增加了服务提供者的逻辑和成本

  • 幂等的使用需要根据具体场景具体分析

  • 增加了额外控制幂等的业务逻辑,复杂了业务功能

  • 将并行的功能转化为串行,降低了执行效率

参考文献

https://developer.aliyun.com/article/812075

https://zhuanlan.zhihu.com/p/676323280