高并发外卖霸王餐订单创建:Java后端如何用“本地消息表+定时任务”替代分布式事务保证数据不丢失
在美团外卖“霸王餐”业务场景下,核心流程涉及两个关键动作:用户下单(库存扣减)与发放奖励(优惠券/积分)。在高并发环境下,若强依赖Seata等分布式事务框架,虽然能保证强一致性,但往往带来性能瓶颈和复杂的运维成本。为了追求更高的吞吐量和最终一致性,业界普遍采用“本地消息表”模式来替代传统的分布式事务。本文将深入探讨如何在Java后端利用该模式,结合定时任务补偿,确保数据不丢失。
核心设计思想:本地消息表
“本地消息表”的核心理念是将分布式事务拆解为多个本地事务。关键在于引入一张中间表(如message_transaction),该表与业务表(如order)处于同一个数据库实例中。通过在同一个本地事务中同时操作业务数据和消息状态,利用数据库的ACID特性,保证“业务执行”与“消息记录”的原子性。随后由独立的消费者或定时任务异步处理该消息,通知下游服务(如奖励服务)进行数据同步。
数据库表结构设计
首先,我们需要在订单库中创建一张本地消息表。该表用于记录待处理的奖励发放任务。
CREATE TABLE `local_message` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_id` VARCHAR(64) NOT NULL COMMENT '关联订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`campaign_id` VARCHAR(64) NOT NULL COMMENT '活动ID',
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '状态:0-待处理,1-已发送,2-已完成,3-失败',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单创建与消息落库
在订单服务的业务逻辑中,我们将“创建订单”和“插入本地消息”放在同一个数据库事务中。只要订单创建成功,本地消息必然生成,从而保证了数据源的可靠性,避免了因网络抖动导致的消息丢失。
package baodanbao.com.cn.service;
import baodanbao.com.cn.dao.LocalMessageMapper;
import baodanbao.com.cn.dao.OrderMapper;
import baodanbao.com.cn.model.LocalMessage;
import baodanbao.com.cn.model.Order;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private LocalMessageMapper messageMapper;
/**
* 创建霸王餐订单
* 该方法在一个本地事务中完成,保证了业务与消息的一致性
*/
@Transactional(rollbackFor = Exception.class)
public void createOrderWithMessage(String orderId, Long userId, String campaignId) {
// 1. 插入订单记录
Order order = new Order();
order.setOrderId(orderId);
order.setUserId(userId);
order.setCampaignId(campaignId);
order.setStatus("CREATED");
orderMapper.insert(order);
// 2. 插入本地消息表(状态:待处理)
// 如果这一步失败,整个事务回滚,订单也不会被创建
LocalMessage message = new LocalMessage();
message.setOrderId(orderId);
message.setUserId(userId);
message.setCampaignId(campaignId);
message.setStatus(0); // 0: 待处理
message.setRetryCount(0);
messageMapper.insert(message);
}
}

异步处理与定时补偿
数据落库后,我们需要一个机制来驱动消息的消费。通常采用“直接发送+定时补偿”的双保险策略。
- 直接发送:在订单创建后,通过线程池异步尝试发送MQ或调用奖励服务。
- 定时补偿:若直接发送失败或超时,由定时任务扫描状态为“待处理”且超过一定时间的消息,进行重试。
package baodanbao.com.cn.task;
import baodanbao.com.cn.dao.LocalMessageMapper;
import baodanbao.com.cn.model.LocalMessage;
import baodanbao.com.cn.service.RewardServiceClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Component
public class MessageCompensationTask {
private static final Logger logger = LoggerFactory.getLogger(MessageCompensationTask.class);
@Autowired
private LocalMessageMapper messageMapper;
@Autowired
private RewardServiceClient rewardServiceClient;
/**
* 定时扫描未完成的消息并进行补偿
* 每隔30秒执行一次
*/
@Scheduled(fixedDelay = 30000)
@Transactional(rollbackFor = Exception.class)
public void compensateMessages() {
// 查询状态为“待处理”且重试次数小于3次的消息
// 这里可以加时间条件,如 create_time < now() - 1分钟,防止刚插入的数据被误扫
List<LocalMessage> messages = messageMapper.select待处理Messages(3);
for (LocalMessage message : messages) {
try {
// 调用奖励服务发放奖励
boolean success = rewardServiceClient.grantReward(message.getUserId(), message.getCampaignId());
if (success) {
// 更新消息状态为“已完成”
messageMapper.updateStatus(message.getId(), 2, null);
logger.info("补偿成功,订单ID: {}", message.getOrderId());
} else {
// 更新重试次数
int newCount = message.getRetryCount() + 1;
messageMapper.incrementRetryCount(message.getId(), newCount);
logger.warn("补偿失败,重试次数+1,订单ID: {}", message.getOrderId());
}
} catch (Exception e) {
logger.error("补偿任务执行异常,订单ID: {}", message.getOrderId(), e);
int newCount = message.getRetryCount() + 1;
messageMapper.incrementRetryCount(message.getId(), newCount);
}
}
}
}
消息消费与幂等性
在奖励服务端接收到消息后,必须保证接口的幂等性,因为定时任务可能会导致同一条消息被多次投递。
package baodanbao.com.cn.service;
import org.springframework.stereotype.Service;
@Service
public class RewardService {
/**
* 发放奖励(必须保证幂等)
*/
public boolean grantReward(Long userId, String campaignId) {
// 1. 查询该用户在该活动下是否已领取过奖励
// SELECT * FROM user_reward WHERE user_id = ? AND campaign_id = ?
// 如果已存在记录,直接返回成功(幂等性保证)
// 2. 如果不存在,则插入奖励记录
// INSERT INTO user_reward ...
// 3. 返回结果
return true;
}
}
本文著作权归 俱美开放平台 ,转载请注明出处!

546

被折叠的 条评论
为什么被折叠?



