用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 增强了数据库操作的灵活性,适合高并发电商场景。

相关推荐
Asthenia041240 分钟前
准备面试:Jenkins部署SpringCloudAlibaba微服务商城全攻略
后端
woniu_maggie1 小时前
SAP EXCEL DOI 详解
开发语言·后端·excel
uhakadotcom1 小时前
云计算与开源工具:基础知识与实践
后端·面试·github
Asthenia04121 小时前
零基础指南:在Linux上用Docker和Jenkins实现Spring Cloud微服务的CI/CD
后端
嘵奇2 小时前
深入解析 Spring Boot 测试核心注解
java·spring boot·后端
uhakadotcom2 小时前
BPF编程入门:使用Rust监控CPU占用
后端·面试·github
uhakadotcom2 小时前
GHSL-2024-252: Cloudflare Workers SDK 环境变量注入漏洞解析
后端·面试·github
uhakadotcom2 小时前
GHSL-2024-264_GHSL-2024-265: 了解 AWS CLI 中的正则表达式拒绝服务漏洞 (ReDoS)
后端·面试·github
Asthenia04122 小时前
Feign的协议和序列化是用的什么?
后端
uhakadotcom2 小时前
了解Chainlit:简化AI应用开发的Python库
后端·面试·github