用RocketMQ和MyBatis实现下单-减库存-扣钱的事务一致性
问题描述
在分布式系统中,用户下单后需完成以下操作:
- 创建订单(支持多种货物)。
- 减库存(区分真实库存、冻结库存和可用库存)。
- 扣减账户余额。
这些操作需保证事务一致性。本文使用 RocketMQ 的事务消息和 MyBatis 实现分布式事务一致性。
解决方案
设计思路
- 订单支持多种货物:通过订单主表和明细表支持多商品。
- 库存管理:区分真实库存、冻结库存和可用库存。
- 事务一致性:RocketMQ 事务消息先执行本地事务(创建订单、冻结库存、预扣余额),再触发确认操作。
- ORM:使用 MyBatis 管理数据库操作。
具体步骤:
- 本地事务:订单服务创建订单,冻结库存和余额,发送事务消息。
- 事务消息:RocketMQ 收到半事务消息,等待提交确认。
- 事务提交/回滚:成功提交消息,触发确认;失败回滚消息,释放资源。
- 事务回查:RocketMQ 未收到确认时回查订单状态。
- 消费消息:库存和账户服务消费消息,确认操作。
数据库表设计
订单主表 (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);
}
}
优化说明
-
消费者消费位置:
- 将
CONSUME_FROM_FIRST_OFFSET
改为CONSUME_FROM_LAST_OFFSET
,确保消费者只处理启动后的新消息,避免处理无关历史数据。 - 在事务场景中,幂等性检查(订单状态)已足够保证一致性,无需从头消费。
- 将
-
MyBatis:
- 使用 MyBatis 的 Mapper 接口和实体类管理数据库操作。
运行流程
- 用户调用
OrderService.placeOrder
,传入多商品信息。 - 本地事务创建订单、冻结库存和余额,发送事务消息。
- RocketMQ 推送消息给库存和账户服务。
- 库存服务确认减库存,账户服务确认扣钱。
- 失败则回滚,释放资源。
满足一致性分析
- 多商品支持 :通过
order_items
表实现。 - 库存管理:区分总库存、冻结库存和可用库存。
- 事务一致性:RocketMQ 事务消息确保一致性。
- 幂等性:订单状态检查避免重复操作。
总结
通过 RocketMQ 事务消息和 MyBatis,我实现了一个支持多商品订单、精细化库存和余额管理的分布式事务系统。调整消费者为 CONSUME_FROM_LAST_OFFSET
提高了效率,MyBatis 增强了数据库操作的灵活性,适合高并发电商场景。