MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

基于消息队列的分布式事务解决方案与 2PC 对比

2023-04-137.5k 阅读

分布式事务概述

在分布式系统中,一个业务操作往往涉及多个服务节点,每个服务节点都有自己独立的数据库。例如,在电商系统中,下单操作可能涉及订单服务创建订单记录,库存服务扣减库存,支付服务处理支付等。如果这些操作不能保证原子性,就可能出现部分成功部分失败的情况,导致数据不一致。分布式事务旨在解决这类跨多个节点的数据一致性问题。

2PC(两阶段提交)

2PC 基本原理

2PC 是一种经典的分布式事务解决方案,它引入了一个协调者(Coordinator)角色,参与事务的所有节点称为参与者(Participants)。2PC 分为两个阶段:

阶段一:准备阶段(Prepare) 协调者向所有参与者发送事务预提交请求,询问是否可以执行事务操作。参与者接收到请求后,会执行事务的所有操作,但不提交事务,而是记录日志并向协调者反馈执行结果。如果参与者成功执行了所有操作,就返回 “同意”,否则返回 “中止”。

阶段二:提交/回滚阶段(Commit/Rollback) 如果协调者收到所有参与者的 “同意” 反馈,那么它会向所有参与者发送提交事务的请求。参与者在收到提交请求后,正式提交事务并释放资源。如果协调者收到任何一个参与者的 “中止” 反馈,或者在规定时间内没有收到所有参与者的反馈,它会向所有参与者发送回滚事务的请求,参与者接收到回滚请求后,回滚事务并释放资源。

2PC 代码示例(以 Java 为例,基于 JDBC 和 ZooKeeper 模拟实现)

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.concurrent.CountDownLatch;

public class TwoPhaseCommitExample {
    private static final String ZK_SERVERS = "localhost:2181";
    private static final String COORDINATOR_PATH = "/coordinator";
    private static final String PARTICIPANT_1_PATH = "/participant1";
    private static final String PARTICIPANT_2_PATH = "/participant2";

    public static void main(String[] args) throws Exception {
        ZooKeeper zk = new ZooKeeper(ZK_SERVERS, 5000, new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                // 简单处理事件
            }
        });

        // 模拟参与者 1
        Participant participant1 = new Participant(zk, PARTICIPANT_1_PATH, "jdbc:mysql://localhost:3306/db1", "user", "password");
        // 模拟参与者 2
        Participant participant2 = new Participant(zk, PARTICIPANT_2_PATH, "jdbc:mysql://localhost:3306/db2", "user", "password");

        // 模拟协调者
        Coordinator coordinator = new Coordinator(zk, COORDINATOR_PATH, new Participant[]{participant1, participant2});
        coordinator.startTransaction();

        zk.close();
    }

    static class Coordinator {
        private final ZooKeeper zk;
        private final String coordinatorPath;
        private final Participant[] participants;

        public Coordinator(ZooKeeper zk, String coordinatorPath, Participant[] participants) {
            this.zk = zk;
            this.coordinatorPath = coordinatorPath;
            this.participants = participants;
        }

        public void startTransaction() throws Exception {
            createNode(zk, coordinatorPath, "INIT".getBytes());

            // 准备阶段
            boolean allPrepared = true;
            for (Participant participant : participants) {
                if (!participant.prepare()) {
                    allPrepared = false;
                    break;
                }
            }

            if (allPrepared) {
                updateNode(zk, coordinatorPath, "COMMIT".getBytes());
            } else {
                updateNode(zk, coordinatorPath, "ROLLBACK".getBytes());
            }

            // 提交/回滚阶段
            for (Participant participant : participants) {
                participant.commitOrRollback();
            }
        }

        private void createNode(ZooKeeper zk, String path, byte[] data) throws KeeperException, InterruptedException {
            if (zk.exists(path, false) == null) {
                zk.create(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        }

        private void updateNode(ZooKeeper zk, String path, byte[] data) throws KeeperException, InterruptedException {
            Stat stat = zk.exists(path, false);
            if (stat != null) {
                zk.setData(path, data, stat.getVersion());
            }
        }
    }

    static class Participant {
        private final ZooKeeper zk;
        private final String participantPath;
        private final String jdbcUrl;
        private final String username;
        private final String password;

        public Participant(ZooKeeper zk, String participantPath, String jdbcUrl, String username, String password) {
            this.zk = zk;
            this.participantPath = participantPath;
            this.jdbcUrl = jdbcUrl;
            this.username = username;
            this.password = password;
        }

        public boolean prepare() throws Exception {
            createNode(zk, participantPath, "PREPARED".getBytes());
            // 模拟数据库操作
            Connection conn = DriverManager.getConnection(jdbcUrl, username, password);
            try {
                conn.setAutoCommit(false);
                // 执行具体 SQL 操作
                PreparedStatement ps = conn.prepareStatement("INSERT INTO some_table (column1) VALUES ('value1')");
                ps.executeUpdate();
                return true;
            } catch (SQLException e) {
                return false;
            } finally {
                conn.close();
            }
        }

        public void commitOrRollback() throws Exception {
            Stat stat = zk.exists(COORDINATOR_PATH, false);
            if (stat != null) {
                byte[] data = zk.getData(COORDINATOR_PATH, false, stat);
                String decision = new String(data);
                Connection conn = DriverManager.getConnection(jdbcUrl, username, password);
                try {
                    conn.setAutoCommit(false);
                    if ("COMMIT".equals(decision)) {
                        conn.commit();
                    } else {
                        conn.rollback();
                    }
                } catch (SQLException e) {
                    e.printStackTrace();
                } finally {
                    conn.close();
                }
            }
        }

        private void createNode(ZooKeeper zk, String path, byte[] data) throws KeeperException, InterruptedException {
            if (zk.exists(path, false) == null) {
                zk.create(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        }
    }
}

2PC 的优缺点

优点

  • 简单直观:2PC 的原理相对容易理解,实现起来也较为直接,对于简单的分布式事务场景能够快速搭建起事务处理机制。
  • 强一致性:在正常情况下,2PC 能够保证所有参与者要么同时提交事务,要么同时回滚事务,从而确保数据的强一致性。

缺点

  • 单点故障:协调者是整个 2PC 机制的核心,如果协调者出现故障,比如在准备阶段之后、提交/回滚阶段之前崩溃,那么参与者将无法得知最终的决策,可能导致数据不一致。
  • 性能问题:2PC 涉及多次网络交互,在准备阶段和提交/回滚阶段都需要协调者与参与者之间进行大量的消息传递。而且在准备阶段,参与者需要锁定资源直到事务结束,这会降低系统的并发性能。
  • 同步阻塞:在 2PC 的过程中,从准备阶段开始,参与者就处于同步阻塞状态,等待协调者的下一步指令。这期间参与者无法处理其他事务,严重影响系统的整体吞吐量。

基于消息队列的分布式事务解决方案

基于消息队列的分布式事务原理

基于消息队列的分布式事务解决方案通常采用最终一致性的思想。其核心思路是将分布式事务拆分成多个本地事务,并通过消息队列来异步传递事务执行状态,最终实现数据的一致性。

以电商下单场景为例,假设涉及订单服务、库存服务和支付服务。订单服务在创建订单成功后,向消息队列发送一条 “订单创建成功” 的消息。库存服务和支付服务监听该消息队列,接收到消息后分别执行扣减库存和处理支付的本地事务。如果某个服务执行本地事务失败,可以通过重试机制来保证最终一致性。

代码示例(以 RabbitMQ 为例,基于 Spring Boot 和 Spring AMQP)

  1. 引入依赖pom.xml 文件中添加以下依赖:
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>
  1. 配置 RabbitMQapplication.properties 文件中配置 RabbitMQ 连接信息:
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
  1. 订单服务发送消息
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Transactional
    public void createOrder(String orderInfo) {
        // 创建订单记录
        jdbcTemplate.update("INSERT INTO orders (order_info) VALUES (?)", orderInfo);
        // 发送消息到队列
        rabbitTemplate.convertAndSend("order-exchange", "order-routing-key", "订单创建成功");
    }
}
  1. 库存服务监听消息并处理
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StockService {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @RabbitListener(queues = "stock-queue")
    @Transactional
    public void handleOrderMessage(String message) {
        if ("订单创建成功".equals(message)) {
            // 扣减库存
            jdbcTemplate.update("UPDATE stocks SET quantity = quantity - 1 WHERE product_id = 1");
        }
    }
}
  1. 支付服务监听消息并处理
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentService {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @RabbitListener(queues = "payment-queue")
    @Transactional
    public void handleOrderMessage(String message) {
        if ("订单创建成功".equals(message)) {
            // 处理支付
            jdbcTemplate.update("INSERT INTO payments (status) VALUES ('PAID')");
        }
    }
}
  1. 配置消息队列和绑定关系
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {
    @Bean
    public Queue orderQueue() {
        return new Queue("order-queue");
    }

    @Bean
    public Queue stockQueue() {
        return new Queue("stock-queue");
    }

    @Bean
    public Queue paymentQueue() {
        return new Queue("payment-queue");
    }

    @Bean
    public DirectExchange orderExchange() {
        return new DirectExchange("order-exchange");
    }

    @Bean
    public Binding orderBinding() {
        return BindingBuilder.bind(orderQueue()).to(orderExchange()).with("order-routing-key");
    }

    @Bean
    public Binding stockBinding() {
        return BindingBuilder.bind(stockQueue()).to(orderExchange()).with("stock-routing-key");
    }

    @Bean
    public Binding paymentBinding() {
        return BindingBuilder.bind(paymentQueue()).to(orderExchange()).with("payment-routing-key");
    }
}

基于消息队列的分布式事务优缺点

优点

  • 异步解耦:消息队列的使用将各个服务之间的事务处理解耦,服务之间不需要直接同步调用,而是通过消息进行异步通信。这大大提高了系统的并发性能和响应速度,因为每个服务可以独立地处理消息,而不需要等待其他服务的响应。
  • 可靠性高:大多数消息队列都具备持久化机制,能够保证消息在传输过程中不丢失。即使某个服务出现故障,消息队列也可以保存消息,待服务恢复后重新处理,从而提高了整个分布式事务的可靠性。
  • 可扩展性强:由于各个服务通过消息队列进行通信,新增或修改服务相对容易。只需要调整消息的发送和接收逻辑,而不需要对其他服务的内部实现进行大规模改动,方便系统的扩展和维护。

缺点

  • 最终一致性:基于消息队列的分布式事务采用最终一致性模型,这意味着在事务执行过程中,可能会存在短暂的数据不一致情况。例如,在订单创建成功后,库存和支付服务可能因为网络延迟等原因未能及时处理消息,导致在一段时间内订单状态与库存、支付状态不一致。对于对数据一致性要求极高的场景,这种短暂的不一致可能无法接受。
  • 消息处理复杂:需要处理消息的重复消费、消息丢失、消息顺序性等问题。例如,如果消息处理过程中出现异常导致消息未成功处理,但消息已经从队列中删除,就需要引入重试机制;如果不处理消息的重复消费,可能会导致数据错误,如库存重复扣减等问题。

对比分析

  1. 一致性模型

    • 2PC:追求强一致性,在正常情况下,所有参与者要么同时提交事务,要么同时回滚事务,确保数据在任何时刻都是一致的。
    • 消息队列:采用最终一致性模型,允许在事务执行过程中有短暂的数据不一致,但通过重试等机制最终达到数据一致。
  2. 性能方面

    • 2PC:由于涉及多次网络交互和同步阻塞,在高并发场景下性能较差。协调者与参与者之间的大量消息传递以及参与者在准备阶段对资源的锁定,都会降低系统的并发处理能力。
    • 消息队列:异步解耦的特性使其在高并发场景下表现出色。各个服务可以独立处理消息,不需要等待其他服务的响应,大大提高了系统的吞吐量和响应速度。
  3. 可靠性

    • 2PC:协调者的单点故障是其可靠性的瓶颈。如果协调者在关键阶段崩溃,可能导致参与者无法得知最终决策,从而造成数据不一致。
    • 消息队列:消息队列本身的持久化机制保证了消息的可靠性。即使某个服务出现故障,消息也不会丢失,待服务恢复后可继续处理,整体可靠性较高。
  4. 实现复杂度

    • 2PC:原理相对简单,但实现过程中需要处理协调者与参与者之间复杂的通信逻辑,以及协调者的故障恢复等问题,实现复杂度较高。
    • 消息队列:虽然消息队列的使用相对简单,但需要处理消息的各种复杂情况,如重复消费、顺序性等,实现复杂度也不低。
  5. 适用场景

    • 2PC:适用于对数据一致性要求极高,并发量相对较低的场景,例如银行转账等金融业务。
    • 消息队列:适用于对并发性能要求高,对数据一致性可以容忍短暂不一致的场景,如电商的下单流程等。

综上所述,在选择分布式事务解决方案时,需要根据具体的业务场景和需求,综合考虑一致性、性能、可靠性等因素,权衡 2PC 和基于消息队列的分布式事务解决方案的优缺点,做出最合适的选择。