用RocketMQ和MyBatis实现下单-减库存-扣钱的事务一致性

用RocketMQ和MyBatis实现下单-减库存-扣钱的事务一致性

问题描述

在分布式系统中,用户下单后需完成以下操作:

  1. 创建订单(支持多种货物)。
  2. 减库存(区分真实库存、冻结库存和可用库存)。
  3. 扣减账户余额。

这些操作需保证事务一致性。本文使用 RocketMQ 的事务消息和 MyBatis 实现分布式事务一致性。

解决方案

设计思路

  • 订单支持多种货物:通过订单主表和明细表支持多商品。
  • 库存管理:区分真实库存、冻结库存和可用库存。
  • 事务一致性:RocketMQ 事务消息先执行本地事务(创建订单、冻结库存、预扣余额),再触发确认操作。
  • ORM:使用 MyBatis 管理数据库操作。

具体步骤:

  1. 本地事务:订单服务创建订单,冻结库存和余额,发送事务消息。
  2. 事务消息:RocketMQ 收到半事务消息,等待提交确认。
  3. 事务提交/回滚:成功提交消息,触发确认;失败回滚消息,释放资源。
  4. 事务回查:RocketMQ 未收到确认时回查订单状态。
  5. 消费消息:库存和账户服务消费消息,确认操作。

数据库表设计

订单主表 (orders)
sql 复制代码
CREATE TABLE orders (
    order_id VARCHAR(50) PRIMARY KEY,
    user_id VARCHAR(50),
    total_amount DECIMAL(10, 2),
    status ENUM('PENDING', 'SUCCESS', 'FAILED') DEFAULT 'PENDING',
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
订单明细表 (order_items)
sql 复制代码
CREATE TABLE order_items (
    item_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id VARCHAR(50),
    product_id VARCHAR(50),
    quantity INT,
    price DECIMAL(10, 2),
    FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
库存表 (inventory)
sql 复制代码
CREATE TABLE inventory (
    product_id VARCHAR(50) PRIMARY KEY,
    total_stock INT,
    frozen_stock INT DEFAULT 0,
    available_stock INT
);
账户表 (account)
sql 复制代码
CREATE TABLE account (
    user_id VARCHAR(50) PRIMARY KEY,
    balance DECIMAL(10, 2),
    frozen_balance DECIMAL(10, 2) DEFAULT 0
);

代码实现

1. MyBatis 配置和实体类

实体类

java 复制代码
public class Order {
    private String orderId;
    private String userId;
    private BigDecimal totalAmount;
    private String status;
    private Timestamp createTime;
    // Getters and Setters
}

public class OrderItem {
    private Long itemId;
    private String orderId;
    private String productId;
    private Integer quantity;
    private BigDecimal price;
    // Getters and Setters
}

public class Inventory {
    private String productId;
    private Integer totalStock;
    private Integer frozenStock;
    private Integer availableStock;
    // Getters and Setters
}

public class Account {
    private String userId;
    private BigDecimal balance;
    private BigDecimal frozenBalance;
    // Getters and Setters
}

Mapper 接口

java 复制代码
@Mapper
public interface OrderMapper {
    @Insert("INSERT INTO orders (order_id, user_id, total_amount, status) VALUES (#{orderId}, #{userId}, #{totalAmount}, #{status})")
    void insertOrder(Order order);

    @Insert("INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (#{orderId}, #{productId}, #{quantity}, #{price})")
    void insertOrderItem(OrderItem item);

    @Update("UPDATE orders SET status = #{status} WHERE order_id = #{orderId}")
    void updateOrderStatus(@Param("orderId") String orderId, @Param("status") String status);

    @Select("SELECT status FROM orders WHERE order_id = #{orderId}")
    String getOrderStatus(@Param("orderId") String orderId);

    @Select("SELECT available_stock FROM inventory WHERE product_id = #{productId}")
    Integer getAvailableStock(@Param("productId") String productId);

    @Update("UPDATE inventory SET frozen_stock = frozen_stock + #{quantity}, available_stock = available_stock - #{quantity} " +
            "WHERE product_id = #{productId} AND available_stock >= #{quantity}")
    int freezeStock(@Param("productId") String productId, @Param("quantity") Integer quantity);

    @Update("UPDATE inventory SET total_stock = total_stock - #{quantity}, frozen_stock = frozen_stock - #{quantity} " +
            "WHERE product_id = #{productId} AND frozen_stock >= #{quantity}")
    int confirmStock(@Param("productId") String productId, @Param("quantity") Integer quantity);

    @Select("SELECT balance - frozen_balance FROM account WHERE user_id = #{userId}")
    BigDecimal getAvailableBalance(@Param("userId") String userId);

    @Update("UPDATE account SET frozen_balance = frozen_balance + #{amount} WHERE user_id = #{userId} AND balance >= #{amount}")
    int freezeBalance(@Param("userId") String userId, @Param("amount") BigDecimal amount);

    @Update("UPDATE account SET balance = balance - #{amount}, frozen_balance = frozen_balance - #{amount} " +
            "WHERE user_id = #{userId} AND frozen_balance >= #{amount}")
    int confirmBalance(@Param("userId") String userId, @Param("amount") BigDecimal amount);
}

MyBatis 配置类

java 复制代码
@Configuration
@MapperScan("com.example.mapper")
public class MyBatisConfig {
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        return factoryBean.getObject();
    }
}
2. RocketMQ 配置

优化消费者启动位置为 CONSUME_FROM_LAST_OFFSET,只消费新消息。

java 复制代码
@Configuration
public class RocketMQConfig {

    @Bean
    public TransactionMQProducer transactionMQProducer() throws MQClientException {
        TransactionMQProducer producer = new TransactionMQProducer("orderProducerGroup");
        producer.setNamesrvAddr("localhost:9876");
        producer.setTransactionListener(new OrderTransactionListener());
        producer.start();
        return producer;
    }

    @Bean
    public DefaultMQPushConsumer inventoryConsumer() throws MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("inventoryConsumerGroup");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("orderTopic", "inventoryTag");
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); // 从最新消息开始消费
        return consumer;
    }

    @Bean
    public DefaultMQPushConsumer accountConsumer() throws MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("accountConsumerGroup");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("orderTopic", "accountTag");
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); // 从最新消息开始消费
        return consumer;
    }
}
3. 订单服务(事务消息生产)
java 复制代码
@Service
public class OrderService {

    @Autowired
    private TransactionMQProducer producer;
    @Autowired
    private OrderMapper orderMapper;

    public void placeOrder(String userId, List<Map<String, Object>> items) throws Exception {
        String orderId = UUID.randomUUID().toString();
        BigDecimal totalAmount = items.stream()
            .map(item -> new BigDecimal(item.get("price").toString()).multiply(new BigDecimal(item.get("quantity").toString())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        String msgBody = orderId + "," + userId + "," + new ObjectMapper().writeValueAsString(items);
        Message msg = new Message("orderTopic", "orderTag", orderId, msgBody.getBytes());

        TransactionSendResult result = producer.sendMessageInTransaction(msg, null);
        if (result.getLocalTransactionState() != LocalTransactionState.COMMIT_MESSAGE) {
            throw new RuntimeException("Order transaction failed");
        }
    }
}

@Component
public class OrderTransactionListener implements TransactionListener {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            String[] body = new String(msg.getBody()).split(",", 3);
            String orderId = body[0];
            String userId = body[1];
            List<Map<String, Object>> items = new ObjectMapper().readValue(body[2], new TypeReference<List<Map<String, Object>>>() {});

            BigDecimal totalAmount = items.stream()
                .map(item -> new BigDecimal(item.get("price").toString()).multiply(new BigDecimal(item.get("quantity").toString())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

            for (Map<String, Object> item : items) {
                String productId = (String) item.get("product_id");
                int quantity = (int) item.get("quantity");
                Integer availableStock = orderMapper.getAvailableStock(productId);
                if (availableStock == null || availableStock < quantity) {
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            }
            BigDecimal availableBalance = orderMapper.getAvailableBalance(userId);
            if (availableBalance.compareTo(totalAmount) < 0) {
                return LocalTransactionState.ROLLBACK_MESSAGE;
            }

            Order order = new Order();
            order.setOrderId(orderId);
            order.setUserId(userId);
            order.setTotalAmount(totalAmount);
            order.setStatus("PENDING");
            orderMapper.insertOrder(order);

            for (Map<String, Object> item : items) {
                String productId = (String) item.get("product_id");
                int quantity = (int) item.get("quantity");
                BigDecimal price = new BigDecimal(item.get("price").toString());
                OrderItem orderItem = new OrderItem();
                orderItem.setOrderId(orderId);
                orderItem.setProductId(productId);
                orderItem.setQuantity(quantity);
                orderItem.setPrice(price);
                orderMapper.insertOrderItem(orderItem);
                orderMapper.freezeStock(productId, quantity);
            }

            orderMapper.freezeBalance(userId, totalAmount);

            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            e.printStackTrace();
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        String orderId = msg.getKeys();
        String status = orderMapper.getOrderStatus(orderId);
        if ("SUCCESS".equals(status)) {
            return LocalTransactionState.COMMIT_MESSAGE;
        } else if ("FAILED".equals(status)) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
        return LocalTransactionState.UNKNOW;
    }
}
4. 库存服务(消息消费)
java 复制代码
@Service
public class InventoryService {

    @Autowired
    private DefaultMQPushConsumer inventoryConsumer;
    @Autowired
    private OrderMapper orderMapper;

    @PostConstruct
    public void initConsumer() throws MQClientException {
        inventoryConsumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
                    String[] body = new String(msg.getBody()).split(",", 3);
                    String orderId = body[0];
                    List<Map<String, Object>> items = new ObjectMapper().readValue(body[2], new TypeReference<List<Map<String, Object>>>() {});

                    String status = orderMapper.getOrderStatus(orderId);
                    if ("SUCCESS".equals(status)) {
                        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                    }

                    for (Map<String, Object> item : items) {
                        String productId = (String) item.get("product_id");
                        int quantity = (int) item.get("quantity");
                        orderMapper.confirmStock(productId, quantity);
                    }
                    orderMapper.updateOrderStatus(orderId, "SUCCESS");
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        inventoryConsumer.start();
    }
}
5. 账户服务(消息消费)
java 复制代码
@Service
public class AccountService {

    @Autowired
    private DefaultMQPushConsumer accountConsumer;
    @Autowired
    private OrderMapper orderMapper;

    @PostConstruct
    public void initConsumer() throws MQClientException {
        accountConsumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
                    String[] body = new String(msg.getBody()).split(",", 3);
                    String orderId = body[0];
                    String userId = body[1];
                    BigDecimal totalAmount = new ObjectMapper().readValue(body[2], new TypeReference<List<Map<String, Object>>>() {})
                        .stream()
                        .map(item -> new BigDecimal(item.get("price").toString()).multiply(new BigDecimal(item.get("quantity").toString())))
                        .reduce(BigDecimal.ZERO, BigDecimal::add);

                    String status = orderMapper.getOrderStatus(orderId);
                    if ("SUCCESS".equals(status)) {
                        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                    }

                    orderMapper.confirmBalance(userId, totalAmount);
                    orderMapper.updateOrderStatus(orderId, "SUCCESS");
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        accountConsumer.start();
    }
}
6. 主应用类
java 复制代码
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

优化说明

  1. 消费者消费位置

    • CONSUME_FROM_FIRST_OFFSET 改为 CONSUME_FROM_LAST_OFFSET,确保消费者只处理启动后的新消息,避免处理无关历史数据。
    • 在事务场景中,幂等性检查(订单状态)已足够保证一致性,无需从头消费。
  2. MyBatis

    • 使用 MyBatis 的 Mapper 接口和实体类管理数据库操作。

运行流程

  1. 用户调用 OrderService.placeOrder,传入多商品信息。
  2. 本地事务创建订单、冻结库存和余额,发送事务消息。
  3. RocketMQ 推送消息给库存和账户服务。
  4. 库存服务确认减库存,账户服务确认扣钱。
  5. 失败则回滚,释放资源。

满足一致性分析

  • 多商品支持 :通过 order_items 表实现。
  • 库存管理:区分总库存、冻结库存和可用库存。
  • 事务一致性:RocketMQ 事务消息确保一致性。
  • 幂等性:订单状态检查避免重复操作。

总结

通过 RocketMQ 事务消息和 MyBatis,我实现了一个支持多商品订单、精细化库存和余额管理的分布式事务系统。调整消费者为 CONSUME_FROM_LAST_OFFSET 提高了效率,MyBatis 增强了数据库操作的灵活性,适合高并发电商场景。

相关推荐
方圆想当图灵1 分钟前
关于 Nacos 在 war 包部署应用关闭部分资源未释放的原因分析
后端
Lemon程序馆12 分钟前
今天聊聊 Mysql 的那些“锁”事!
后端·mysql
龙卷风040514 分钟前
使用本地IDEA连接服务器远程构建部署Docker服务
后端·docker
vv安的浅唱18 分钟前
Golang基础笔记七之指针,值类型和引用类型
后端·go
陪我一起学编程29 分钟前
MySQL创建普通用户并为其分配相关权限的操作步骤
开发语言·数据库·后端·mysql·oracle
Heo1 小时前
调用通义千问大模型实现流式对话
前端·javascript·后端
Java水解2 小时前
RabbitMQ用法的6种核心模式全面解析
后端·rabbitmq
用户4099322502122 小时前
FastAPI的查询白名单和安全沙箱机制如何确保你的API坚不可摧?
前端·后端·github