MySQL + Redis 分布式事务消息中间件:保证最终一致性
摘要
在分布式系统中,MySQL 和 Redis 的数据一致性是一个经典难题。本文将深入探讨如何通过自研消息中间件,基于本地消息表和事务消息机制,实现 MySQL 事务和 Redis 缓存的最终一致性,并提供生产级的完整解决方案。
目录
- 分布式一致性问题
- 技术方案对比
- 核心架构设计
- 本地消息表方案
- 事务消息中间件实现
- 生产级代码实现
- 异常场景处理
- 性能优化
- 监控与运维
- 实战案例
- 最佳实践
- 总结
1. 分布式一致性问题
1.1 典型场景
css
┌─────────────────────────────────────────────────────────────┐
│ MySQL + Redis 数据不一致典型场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景 1:先更新 MySQL,再更新 Redis │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 更新MySQL │ ───▶ │ 提交成功 │ ───▶ │ 更新Redis│ │
│ │ │ │ │ │ 失败 │ ❌ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ 结果:MySQL 已更新,Redis 未更新,数据不一致 │
│ │
│ 场景 2:先删除 Redis,再更新 MySQL │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 删除Redis │ ───▶ │ 删除成功 │ ───▶ │ 更新MySQL│ │
│ │ │ │ │ │ 失败 │ ❌ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ 结果:Redis 已删除,MySQL 未更新,数据不一致 │
│ │
│ 场景 3:并发读写问题 │
│ 线程 A:更新 MySQL ───▶ 删除 Redis │
│ 线程 B:读取 Redis(旧数据)───▶ 写入 Redis(覆盖新数据) │
│ 结果:Redis 中是旧数据,数据不一致 │
│ │
└─────────────────────────────────────────────────────────────┘
1.2 核心问题
问题本质:
- 跨系统操作无法保证原子性
- 网络异常、服务宕机导致部分操作失败
- 并发场景下的竞态条件
解决目标:
- ✅ 保证 MySQL 和 Redis 的最终一致性
- ✅ 可靠的消息投递(至少一次)
- ✅ 幂等性处理
- ✅ 异常可回滚或重试
- ✅ 性能影响最小化
2. 技术方案对比
2.1 常见方案
scss
┌──────────────────────────────────────────────────────────────┐
│ 分布式一致性方案对比 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 方案 优点 缺点 适用场景 │
│ ───────────────────────────────────────────────────────── │
│ 延迟双删 简单易实现 不保证一致性 低一致性要求 │
│ 并发问题 │
│ │
│ Canal监听 解耦性好 延迟较大 数据同步 │
│ Binlog 侵入性小 需要额外组件 日志场景 │
│ │
│ 本地消息表 强一致性 代码侵入性强 高一致性要求 │
│ 可靠性高 性能有影响 订单/支付 │
│ │
│ TCC 强一致性 实现复杂 金融场景 │
│ 可控性好 性能开销大 核心业务 │
│ │
│ Saga 适合长事务 补偿逻辑复杂 跨服务流程 │
│ 灵活性好 不保证隔离性 工作流 │
│ │
│ 消息中间件 解耦性强 依赖MQ组件 异步场景 │
│ (本文方案) 可靠性高 学习成本 高并发 │
│ 扩展性好 │
│ │
└──────────────────────────────────────────────────────────────┘
2.2 本文方案选型
我们采用 本地消息表 + 事务消息中间件 的混合方案:
- 本地消息表保证消息可靠性
- 定时任务扫描待发送消息
- MQ 保证消息投递
- 消费端幂等性处理
- 支持消息补偿和重试
3. 核心架构设计
3.1 整体架构
scss
┌──────────────────────────────────────────────────────────────┐
│ 消息最终一致性中间件架构 │
└──────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ 业务服务层 │
│ (Controller) │
└────────┬────────┘
│
┌────────▼────────┐
│ 业务逻辑层 │
│ (Service) │
└────────┬────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌────────▼────────┐ ┌───▼────────┐ ┌───▼────────┐
│ 事务消息发送器 │ │ MySQL 操作 │ │ 业务处理 │
│ TransactionMsg │ │ │ │ │
└────────┬────────┘ └───┬────────┘ └────────────┘
│ │
│ ┌────────▼────────┐
│ │ MySQL 事务 │
│ │ ┌────────────┐ │
│ │ │ 业务数据表 │ │
│ │ └────────────┘ │
│ │ ┌────────────┐ │
│ │ │ 消息表 │ │ ← 本地消息表
│ │ │ (msg_log) │ │
│ │ └────────────┘ │
│ └─────────────────┘
│ │
└───────────────┤
│ 提交成功
┌───────▼────────┐
│ 消息扫描器 │
│ (Scheduler) │
│ • 扫描待发送 │
│ • 重试失败 │
└───────┬────────┘
│
┌───────▼────────┐
│ 消息队列 │
│ (RabbitMQ) │
└───────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────────▼────────┐ ┌───▼────────┐ ┌───▼────────┐
│ 缓存更新消费者 │ │ 其他消费者 │ │ 其他消费者 │
│ │ │ │ │ │
│ • 幂等性检查 │ │ │ │ │
│ • 更新Redis │ │ │ │ │
│ • ACK确认 │ │ │ │ │
└────────┬────────┘ └────────────┘ └────────────┘
│
┌────────▼────────┐
│ Redis Cache │
│ │
└─────────────────┘
3.2 核心流程
markdown
【生产者流程】
开始业务事务
│
├─ 1. 执行业务操作(更新用户信息)
│
├─ 2. 插入本地消息表(状态:待发送)
│ - 消息ID(全局唯一)
│ - 业务数据
│ - 消息类型
│ - 状态:PENDING
│
提交 MySQL 事务
│
├─ 成功 ──▶ 3. 异步发送到 MQ
│ │
│ ├─ 成功 ──▶ 更新消息状态为 SENT
│ │
│ └─ 失败 ──▶ 定时任务重试
│
└─ 失败 ──▶ 回滚(消息表也回滚)
【消费者流程】
接收 MQ 消息
│
├─ 1. 幂等性检查
│ - 检查消息是否已处理
│ - 使用分布式锁
│
├─ 2. 执行业务逻辑
│ - 更新 Redis 缓存
│ - 其他业务操作
│
├─ 3. 记录消费状态
│ - 插入消费记录表
│ - 防止重复消费
│
├─ 成功 ──▶ ACK 消息
│
└─ 失败 ──▶ NACK 消息(触发重试)
4. 本地消息表方案
4.1 数据库设计
sql
-- 本地消息表
CREATE TABLE `transaction_message` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`message_id` VARCHAR(64) NOT NULL COMMENT '消息唯一ID(UUID)',
`business_key` VARCHAR(128) NOT NULL COMMENT '业务唯一键',
`message_type` VARCHAR(50) NOT NULL COMMENT '消息类型',
`message_body` TEXT NOT NULL COMMENT '消息内容(JSON)',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '消息状态:PENDING/SENT/CONSUMED/FAILED',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
`max_retry` INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
`next_retry_time` DATETIME NULL COMMENT '下次重试时间',
`error_msg` TEXT NULL COMMENT '错误信息',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_id` (`message_id`),
KEY `idx_status_retry` (`status`, `next_retry_time`),
KEY `idx_business_key` (`business_key`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地事务消息表';
-- 消息消费记录表(消费端)
CREATE TABLE `message_consume_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`message_id` VARCHAR(64) NOT NULL COMMENT '消息ID',
`consumer_name` VARCHAR(100) NOT NULL COMMENT '消费者名称',
`consume_status` VARCHAR(20) NOT NULL COMMENT '消费状态:SUCCESS/FAILED',
`consume_time` DATETIME NOT NULL COMMENT '消费时间',
`error_msg` TEXT NULL COMMENT '错误信息',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_consumer` (`message_id`, `consumer_name`),
KEY `idx_consume_time` (`consume_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息消费日志表';
-- 业务表示例(用户表)
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`email` VARCHAR(100),
`phone` VARCHAR(20),
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 实体类定义
java
package com.example.transaction.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 事务消息实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionMessage {
private Long id;
/**
* 消息唯一ID
*/
private String messageId;
/**
* 业务唯一键(用于幂等性)
*/
private String businessKey;
/**
* 消息类型
*/
private String messageType;
/**
* 消息内容(JSON格式)
*/
private String messageBody;
/**
* 消息状态
*/
private MessageStatus status;
/**
* 重试次数
*/
private Integer retryCount;
/**
* 最大重试次数
*/
private Integer maxRetry;
/**
* 下次重试时间
*/
private LocalDateTime nextRetryTime;
/**
* 错误信息
*/
private String errorMsg;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
/**
* 消息状态枚举
*/
public enum MessageStatus {
PENDING("待发送"),
SENT("已发送"),
CONSUMED("已消费"),
FAILED("失败");
private final String description;
MessageStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
}
/**
* 消息消费日志实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageConsumeLog {
private Long id;
/**
* 消息ID
*/
private String messageId;
/**
* 消费者名称
*/
private String consumerName;
/**
* 消费状态
*/
private ConsumeStatus consumeStatus;
/**
* 消费时间
*/
private LocalDateTime consumeTime;
/**
* 错误信息
*/
private String errorMsg;
public enum ConsumeStatus {
SUCCESS, FAILED
}
}
/**
* 用户实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Long id;
private String username;
private String email;
private String phone;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
5. 事务消息中间件实现
5.1 Maven 依赖
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
</parent>
<groupId>com.example</groupId>
<artifactId>transaction-message-middleware</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot AMQP (RabbitMQ) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Redisson (分布式锁) -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.25.0</version>
</dependency>
<!-- Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Apache Commons Lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
5.2 配置文件
yaml
spring:
application:
name: transaction-message-middleware
# MySQL 配置
datasource:
url: jdbc:mysql://localhost:3306/transaction_msg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root123
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
# Redis 配置
data:
redis:
host: localhost
port: 6379
password: redis123
database: 0
timeout: 60s
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# RabbitMQ 配置
rabbitmq:
host: localhost
port: 5672
username: admin
password: admin123
virtual-host: /
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
retry:
enabled: true
max-attempts: 3
initial-interval: 3000
# MyBatis Plus 配置
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.example.transaction.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 事务消息配置
transaction:
message:
# 消息扫描配置
scanner:
enabled: true
cron: "0/10 * * * * ?" # 每10秒扫描一次
batch-size: 100 # 每次扫描数量
# 重试配置
retry:
max-attempts: 3
initial-interval: 5000 # 首次重试间隔(毫秒)
multiplier: 2.0 # 重试间隔倍数
# 消息过期时间
expire-hours: 72 # 72小时后自动清理
# 日志配置
logging:
level:
root: INFO
com.example: DEBUG
com.rabbitmq: DEBUG
5.3 RabbitMQ 配置
java
package com.example.transaction.config;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
// 交换机名称
public static final String TRANSACTION_EXCHANGE = "transaction.exchange";
// 队列名称
public static final String CACHE_UPDATE_QUEUE = "cache.update.queue";
public static final String CACHE_UPDATE_ROUTING_KEY = "cache.update";
// 死信队列
public static final String DLX_EXCHANGE = "dlx.exchange";
public static final String DLX_QUEUE = "dlx.queue";
/**
* 交换机
*/
@Bean
public DirectExchange transactionExchange() {
return ExchangeBuilder
.directExchange(TRANSACTION_EXCHANGE)
.durable(true)
.build();
}
/**
* 缓存更新队列
*/
@Bean
public Queue cacheUpdateQueue() {
return QueueBuilder
.durable(CACHE_UPDATE_QUEUE)
.deadLetterExchange(DLX_EXCHANGE)
.deadLetterRoutingKey("dlx")
.build();
}
/**
* 绑定
*/
@Bean
public Binding cacheUpdateBinding() {
return BindingBuilder
.bind(cacheUpdateQueue())
.to(transactionExchange())
.with(CACHE_UPDATE_ROUTING_KEY);
}
/**
* 死信交换机
*/
@Bean
public DirectExchange dlxExchange() {
return ExchangeBuilder
.directExchange(DLX_EXCHANGE)
.durable(true)
.build();
}
/**
* 死信队列
*/
@Bean
public Queue dlxQueue() {
return QueueBuilder
.durable(DLX_QUEUE)
.build();
}
/**
* 死信绑定
*/
@Bean
public Binding dlxBinding() {
return BindingBuilder
.bind(dlxQueue())
.to(dlxExchange())
.with("dlx");
}
/**
* 消息转换器(使用 JSON)
*/
@Bean
public Jackson2JsonMessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
/**
* RabbitTemplate 配置
*/
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
// 消息发送确认
template.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) {
System.err.println("消息发送失败: " + cause);
}
});
// 消息返回确认
template.setReturnsCallback(returned -> {
System.err.println("消息被退回: " + returned.getMessage());
});
return template;
}
/**
* 监听器容器工厂
*/
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(messageConverter());
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
factory.setPrefetchCount(10);
return factory;
}
}
6. 生产级代码实现
6.1 事务消息管理器
java
package com.example.transaction.manager;
import com.example.transaction.entity.TransactionMessage;
import com.example.transaction.mapper.TransactionMessageMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 事务消息管理器
*/
@Slf4j
@Component
public class TransactionMessageManager {
private final TransactionMessageMapper messageMapper;
private final RabbitTemplate rabbitTemplate;
private final ObjectMapper objectMapper;
public TransactionMessageManager(
TransactionMessageMapper messageMapper,
RabbitTemplate rabbitTemplate,
ObjectMapper objectMapper) {
this.messageMapper = messageMapper;
this.rabbitTemplate = rabbitTemplate;
this.objectMapper = objectMapper;
}
/**
* 发送事务消息(在业务事务内调用)
*
* @param businessKey 业务唯一键
* @param messageType 消息类型
* @param messageBody 消息内容(对象)
* @param exchange 交换机
* @param routingKey 路由键
* @return 消息ID
*/
@Transactional(rollbackFor = Exception.class)
public String sendTransactionMessage(
String businessKey,
String messageType,
Object messageBody,
String exchange,
String routingKey) {
try {
// 1. 生成消息ID
String messageId = UUID.randomUUID().toString();
// 2. 序列化消息体
String messageBodyJson = objectMapper.writeValueAsString(messageBody);
// 3. 插入本地消息表(在业务事务内)
TransactionMessage message = TransactionMessage.builder()
.messageId(messageId)
.businessKey(businessKey)
.messageType(messageType)
.messageBody(messageBodyJson)
.status(TransactionMessage.MessageStatus.PENDING)
.retryCount(0)
.maxRetry(3)
.nextRetryTime(LocalDateTime.now())
.build();
messageMapper.insert(message);
log.info("本地消息表插入成功 - messageId: {}, businessKey: {}", messageId, businessKey);
// 4. 业务事务提交后,异步发送消息到MQ
// 注意:这里需要在事务提交后执行
sendToMQ(messageId, messageBodyJson, exchange, routingKey);
return messageId;
} catch (Exception e) {
log.error("发送事务消息失败", e);
throw new RuntimeException("发送事务消息失败", e);
}
}
/**
* 发送消息到 MQ
*/
public void sendToMQ(String messageId, String messageBody, String exchange, String routingKey) {
try {
rabbitTemplate.convertAndSend(exchange, routingKey, messageBody, message -> {
message.getMessageProperties().setMessageId(messageId);
return message;
});
// 更新消息状态为已发送
messageMapper.updateStatus(messageId, TransactionMessage.MessageStatus.SENT);
log.info("消息发送到MQ成功 - messageId: {}", messageId);
} catch (Exception e) {
log.error("消息发送到MQ失败 - messageId: {}", messageId, e);
// 更新错误信息
messageMapper.updateError(messageId, e.getMessage());
// 计算下次重试时间(指数退避)
TransactionMessage msg = messageMapper.selectById(messageId);
if (msg != null && msg.getRetryCount() < msg.getMaxRetry()) {
long delaySeconds = (long) Math.pow(2, msg.getRetryCount()) * 5;
LocalDateTime nextRetryTime = LocalDateTime.now().plusSeconds(delaySeconds);
messageMapper.updateNextRetryTime(messageId, nextRetryTime);
}
}
}
/**
* 标记消息已消费
*/
public void markAsConsumed(String messageId) {
messageMapper.updateStatus(messageId, TransactionMessage.MessageStatus.CONSUMED);
log.info("消息标记为已消费 - messageId: {}", messageId);
}
/**
* 增加重试次数
*/
public void incrementRetryCount(String messageId) {
messageMapper.incrementRetryCount(messageId);
}
}
6.2 消息扫描器
java
package com.example.transaction.scheduler;
import com.example.transaction.config.RabbitMQConfig;
import com.example.transaction.entity.TransactionMessage;
import com.example.transaction.manager.TransactionMessageManager;
import com.example.transaction.mapper.TransactionMessageMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* 事务消息扫描器
* 定时扫描待发送和失败的消息,进行重试
*/
@Slf4j
@Component
public class TransactionMessageScanner {
private final TransactionMessageMapper messageMapper;
private final TransactionMessageManager messageManager;
@Value("${transaction.message.scanner.batch-size:100}")
private int batchSize;
@Value("${transaction.message.retry.max-attempts:3}")
private int maxRetryAttempts;
public TransactionMessageScanner(
TransactionMessageMapper messageMapper,
TransactionMessageManager messageManager) {
this.messageMapper = messageMapper;
this.messageManager = messageManager;
}
/**
* 扫描待发送的消息
*/
@Scheduled(cron = "${transaction.message.scanner.cron:0/10 * * * * ?}")
public void scanPendingMessages() {
log.debug("开始扫描待发送消息...");
try {
// 查询待发送的消息(状态为PENDING,且重试时间已到)
List<TransactionMessage> messages = messageMapper.selectPendingMessages(
LocalDateTime.now(),
batchSize
);
if (messages.isEmpty()) {
return;
}
log.info("找到 {} 条待发送消息", messages.size());
for (TransactionMessage message : messages) {
try {
// 检查是否超过最大重试次数
if (message.getRetryCount() >= maxRetryAttempts) {
log.warn("消息超过最大重试次数,标记为失败 - messageId: {}", message.getMessageId());
messageMapper.updateStatus(
message.getMessageId(),
TransactionMessage.MessageStatus.FAILED
);
continue;
}
// 增加重试次数
messageManager.incrementRetryCount(message.getMessageId());
// 重新发送到 MQ
messageManager.sendToMQ(
message.getMessageId(),
message.getMessageBody(),
RabbitMQConfig.TRANSACTION_EXCHANGE,
RabbitMQConfig.CACHE_UPDATE_ROUTING_KEY
);
log.info("消息重新发送成功 - messageId: {}, retryCount: {}",
message.getMessageId(), message.getRetryCount() + 1);
} catch (Exception e) {
log.error("消息重新发送失败 - messageId: {}", message.getMessageId(), e);
}
}
} catch (Exception e) {
log.error("扫描待发送消息失败", e);
}
}
/**
* 清理过期消息
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void cleanExpiredMessages() {
log.info("开始清理过期消息...");
try {
LocalDateTime expireTime = LocalDateTime.now().minusHours(72);
int deletedCount = messageMapper.deleteExpiredMessages(expireTime);
log.info("清理过期消息完成,删除 {} 条记录", deletedCount);
} catch (Exception e) {
log.error("清理过期消息失败", e);
}
}
}
6.3 消息消费者
java
package com.example.transaction.consumer;
import com.example.transaction.config.RabbitMQConfig;
import com.example.transaction.entity.MessageConsumeLog;
import com.example.transaction.manager.TransactionMessageManager;
import com.example.transaction.mapper.MessageConsumeLogMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 缓存更新消费者
*/
@Slf4j
@Component
public class CacheUpdateConsumer {
private final StringRedisTemplate redisTemplate;
private final RedissonClient redissonClient;
private final ObjectMapper objectMapper;
private final MessageConsumeLogMapper consumeLogMapper;
private final TransactionMessageManager messageManager;
private static final String CONSUMER_NAME = "CacheUpdateConsumer";
private static final String LOCK_KEY_PREFIX = "msg:lock:";
public CacheUpdateConsumer(
StringRedisTemplate redisTemplate,
RedissonClient redissonClient,
ObjectMapper objectMapper,
MessageConsumeLogMapper consumeLogMapper,
TransactionMessageManager messageManager) {
this.redisTemplate = redisTemplate;
this.redissonClient = redissonClient;
this.objectMapper = objectMapper;
this.consumeLogMapper = consumeLogMapper;
this.messageManager = messageManager;
}
/**
* 消费缓存更新消息
*/
@RabbitListener(queues = RabbitMQConfig.CACHE_UPDATE_QUEUE)
public void handleCacheUpdate(Message message, Channel channel) throws IOException {
String messageId = message.getMessageProperties().getMessageId();
long deliveryTag = message.getMessageProperties().getDeliveryTag();
log.info("接收到缓存更新消息 - messageId: {}", messageId);
RLock lock = null;
try {
// 1. 幂等性检查(使用分布式锁)
String lockKey = LOCK_KEY_PREFIX + messageId;
lock = redissonClient.getLock(lockKey);
if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
log.warn("获取锁失败,消息可能正在处理 - messageId: {}", messageId);
// NACK 消息,稍后重试
channel.basicNack(deliveryTag, false, true);
return;
}
// 2. 检查消息是否已处理
if (isMessageConsumed(messageId)) {
log.info("消息已处理,跳过 - messageId: {}", messageId);
// ACK 消息
channel.basicAck(deliveryTag, false);
return;
}
// 3. 解析消息内容
String messageBody = new String(message.getBody());
JsonNode jsonNode = objectMapper.readTree(messageBody);
String businessKey = jsonNode.get("businessKey").asText();
String operation = jsonNode.get("operation").asText();
log.info("处理缓存操作 - businessKey: {}, operation: {}", businessKey, operation);
// 4. 执行缓存操作
switch (operation) {
case "UPDATE" -> updateCache(businessKey, jsonNode.get("data"));
case "DELETE" -> deleteCache(businessKey);
default -> log.warn("未知操作类型: {}", operation);
}
// 5. 记录消费日志
saveConsumeLog(messageId, MessageConsumeLog.ConsumeStatus.SUCCESS, null);
// 6. 标记消息已消费
messageManager.markAsConsumed(messageId);
// 7. ACK 消息
channel.basicAck(deliveryTag, false);
log.info("消息处理成功 - messageId: {}", messageId);
} catch (Exception e) {
log.error("消息处理失败 - messageId: {}", messageId, e);
// 记录失败日志
saveConsumeLog(messageId, MessageConsumeLog.ConsumeStatus.FAILED, e.getMessage());
// NACK 消息,触发重试
channel.basicNack(deliveryTag, false, true);
} finally {
// 释放锁
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 检查消息是否已消费
*/
private boolean isMessageConsumed(String messageId) {
return consumeLogMapper.existsByMessageIdAndConsumer(messageId, CONSUMER_NAME) > 0;
}
/**
* 更新缓存
*/
private void updateCache(String key, JsonNode data) {
try {
String value = objectMapper.writeValueAsString(data);
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
log.info("缓存更新成功 - key: {}", key);
} catch (Exception e) {
log.error("缓存更新失败 - key: {}", key, e);
throw new RuntimeException("缓存更新失败", e);
}
}
/**
* 删除缓存
*/
private void deleteCache(String key) {
Boolean deleted = redisTemplate.delete(key);
log.info("缓存删除{} - key: {}", deleted ? "成功" : "失败", key);
}
/**
* 保存消费日志
*/
private void saveConsumeLog(String messageId, MessageConsumeLog.ConsumeStatus status, String errorMsg) {
try {
MessageConsumeLog log = MessageConsumeLog.builder()
.messageId(messageId)
.consumerName(CONSUMER_NAME)
.consumeStatus(status)
.consumeTime(LocalDateTime.now())
.errorMsg(errorMsg)
.build();
consumeLogMapper.insert(log);
} catch (Exception e) {
log.error("保存消费日志失败", e);
}
}
}
6.4 业务服务层
java
package com.example.transaction.service;
import com.example.transaction.config.RabbitMQConfig;
import com.example.transaction.entity.User;
import com.example.transaction.manager.TransactionMessageManager;
import com.example.transaction.mapper.UserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 用户服务
*/
@Slf4j
@Service
public class UserService {
private final UserMapper userMapper;
private final StringRedisTemplate redisTemplate;
private final TransactionMessageManager messageManager;
private final ObjectMapper objectMapper;
private static final String USER_CACHE_PREFIX = "user:";
public UserService(
UserMapper userMapper,
StringRedisTemplate redisTemplate,
TransactionMessageManager messageManager,
ObjectMapper objectMapper) {
this.userMapper = userMapper;
this.redisTemplate = redisTemplate;
this.messageManager = messageManager;
this.objectMapper = objectMapper;
}
/**
* 更新用户信息(带缓存同步)
*/
@Transactional(rollbackFor = Exception.class)
public void updateUser(Long userId, String email, String phone) {
try {
log.info("开始更新用户信息 - userId: {}", userId);
// 1. 查询用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 2. 更新用户信息
user.setEmail(email);
user.setPhone(phone);
userMapper.updateById(user);
log.info("MySQL 更新成功 - userId: {}", userId);
// 3. 构建消息体
String businessKey = USER_CACHE_PREFIX + userId;
Map<String, Object> messageBody = new HashMap<>();
messageBody.put("businessKey", businessKey);
messageBody.put("operation", "UPDATE");
messageBody.put("data", user);
// 4. 发送事务消息(在同一事务内)
messageManager.sendTransactionMessage(
businessKey,
"USER_UPDATE",
messageBody,
RabbitMQConfig.TRANSACTION_EXCHANGE,
RabbitMQConfig.CACHE_UPDATE_ROUTING_KEY
);
log.info("用户更新完成 - userId: {}", userId);
} catch (Exception e) {
log.error("更新用户失败 - userId: {}", userId, e);
throw new RuntimeException("更新用户失败", e);
}
}
/**
* 查询用户(先查缓存,缓存未命中查数据库)
*/
public User getUser(Long userId) {
String cacheKey = USER_CACHE_PREFIX + userId;
try {
// 1. 查询缓存
String cachedUser = redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
log.info("缓存命中 - userId: {}", userId);
return objectMapper.readValue(cachedUser, User.class);
}
// 2. 缓存未命中,查询数据库
log.info("缓存未命中,查询数据库 - userId: {}", userId);
User user = userMapper.selectById(userId);
if (user != null) {
// 3. 写入缓存
String userJson = objectMapper.writeValueAsString(user);
redisTemplate.opsForValue().set(cacheKey, userJson, 1, TimeUnit.HOURS);
}
return user;
} catch (Exception e) {
log.error("查询用户失败 - userId: {}", userId, e);
// 缓存失败,降级到数据库查询
return userMapper.selectById(userId);
}
}
/**
* 删除用户(带缓存清理)
*/
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long userId) {
try {
log.info("开始删除用户 - userId: {}", userId);
// 1. 删除数据库记录
userMapper.deleteById(userId);
log.info("MySQL 删除成功 - userId: {}", userId);
// 2. 发送缓存删除消息
String businessKey = USER_CACHE_PREFIX + userId;
Map<String, Object> messageBody = new HashMap<>();
messageBody.put("businessKey", businessKey);
messageBody.put("operation", "DELETE");
messageManager.sendTransactionMessage(
businessKey,
"USER_DELETE",
messageBody,
RabbitMQConfig.TRANSACTION_EXCHANGE,
RabbitMQConfig.CACHE_UPDATE_ROUTING_KEY
);
log.info("用户删除完成 - userId: {}", userId);
} catch (Exception e) {
log.error("删除用户失败 - userId: {}", userId, e);
throw new RuntimeException("删除用户失败", e);
}
}
}
6.5 Mapper 接口
java
package com.example.transaction.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.transaction.entity.TransactionMessage;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface TransactionMessageMapper extends BaseMapper<TransactionMessage> {
/**
* 查询待发送的消息
*/
@Select("SELECT * FROM transaction_message " +
"WHERE status = 'PENDING' " +
"AND next_retry_time <= #{now} " +
"AND retry_count < max_retry " +
"ORDER BY created_at ASC " +
"LIMIT #{limit}")
List<TransactionMessage> selectPendingMessages(
@Param("now") LocalDateTime now,
@Param("limit") int limit
);
/**
* 更新消息状态
*/
@Update("UPDATE transaction_message " +
"SET status = #{status}, updated_at = NOW() " +
"WHERE message_id = #{messageId}")
int updateStatus(
@Param("messageId") String messageId,
@Param("status") TransactionMessage.MessageStatus status
);
/**
* 更新错误信息
*/
@Update("UPDATE transaction_message " +
"SET error_msg = #{errorMsg}, updated_at = NOW() " +
"WHERE message_id = #{messageId}")
int updateError(
@Param("messageId") String messageId,
@Param("errorMsg") String errorMsg
);
/**
* 更新下次重试时间
*/
@Update("UPDATE transaction_message " +
"SET next_retry_time = #{nextRetryTime}, updated_at = NOW() " +
"WHERE message_id = #{messageId}")
int updateNextRetryTime(
@Param("messageId") String messageId,
@Param("nextRetryTime") LocalDateTime nextRetryTime
);
/**
* 增加重试次数
*/
@Update("UPDATE transaction_message " +
"SET retry_count = retry_count + 1, updated_at = NOW() " +
"WHERE message_id = #{messageId}")
int incrementRetryCount(@Param("messageId") String messageId);
/**
* 删除过期消息
*/
@Delete("DELETE FROM transaction_message " +
"WHERE created_at < #{expireTime} " +
"AND status IN ('SENT', 'CONSUMED', 'FAILED')")
int deleteExpiredMessages(@Param("expireTime") LocalDateTime expireTime);
}
@Mapper
public interface MessageConsumeLogMapper extends BaseMapper<MessageConsumeLog> {
/**
* 检查消息是否已消费
*/
@Select("SELECT COUNT(*) FROM message_consume_log " +
"WHERE message_id = #{messageId} " +
"AND consumer_name = #{consumerName} " +
"AND consume_status = 'SUCCESS'")
int existsByMessageIdAndConsumer(
@Param("messageId") String messageId,
@Param("consumerName") String consumerName
);
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
7. 异常场景处理
7.1 异常场景分析
┌──────────────────────────────────────────────────────────────┐
│ 异常场景及处理方案 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 场景 1:MySQL 提交失败 │
│ ──────────────────────────────────────────────────────── │
│ 现象:业务操作和消息表都回滚 │
│ 处理:无需处理,数据一致 │
│ 结果:✅ 数据一致 │
│ │
│ 场景 2:MySQL 提交成功,MQ 发送失败 │
│ ──────────────────────────────────────────────────────── │
│ 现象:消息表状态为 PENDING │
│ 处理:定时扫描器重试发送 │
│ 结果:✅ 最终一致 │
│ │
│ 场景 3:消息发送成功,消费失败 │
│ ──────────────────────────────────────────────────────── │
│ 现象:MQ 消息未 ACK │
│ 处理:RabbitMQ 自动重新投递 │
│ 结果:✅ 消费重试直到成功 │
│ │
│ 场景 4:消息重复消费 │
│ ──────────────────────────────────────────────────────── │
│ 现象:MQ 消息重复投递 │
│ 处理:消费端幂等性检查(消费记录表 + 分布式锁) │
│ 结果:✅ 重复消息被过滤 │
│ │
│ 场景 5:Redis 宕机 │
│ ──────────────────────────────────────────────────────── │
│ 现象:缓存操作失败 │
│ 处理:消费失败,消息重新入队,等待 Redis 恢复后重试 │
│ 结果:✅ Redis 恢复后最终一致 │
│ │
│ 场景 6:服务重启 │
│ ──────────────────────────────────────────────────────── │
│ 现象:可能有消息处理到一半 │
│ 处理:MQ 未 ACK 的消息重新投递,分布式锁防止重复处理 │
│ 结果:✅ 重启后继续处理 │
│ │
└──────────────────────────────────────────────────────────────┘
7.2 补偿机制
java
package com.example.transaction.compensation;
import com.example.transaction.entity.TransactionMessage;
import com.example.transaction.mapper.TransactionMessageMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* 消息补偿处理器
*/
@Slf4j
@Component
public class MessageCompensationHandler {
private final TransactionMessageMapper messageMapper;
public MessageCompensationHandler(TransactionMessageMapper messageMapper) {
this.messageMapper = messageMapper;
}
/**
* 人工补偿失败的消息
*/
public void compensateFailedMessages() {
log.info("开始补偿失败消息...");
// 查询失败的消息
List<TransactionMessage> failedMessages = messageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TransactionMessage>()
.eq("status", TransactionMessage.MessageStatus.FAILED)
.ge("created_at", LocalDateTime.now().minusHours(24))
);
log.info("找到 {} 条失败消息待补偿", failedMessages.size());
for (TransactionMessage message : failedMessages) {
try {
// 重置消息状态,允许重新发送
message.setStatus(TransactionMessage.MessageStatus.PENDING);
message.setRetryCount(0);
message.setNextRetryTime(LocalDateTime.now());
message.setErrorMsg(null);
messageMapper.updateById(message);
log.info("消息已重置为待发送 - messageId: {}", message.getMessageId());
} catch (Exception e) {
log.error("补偿消息失败 - messageId: {}", message.getMessageId(), e);
}
}
log.info("消息补偿完成");
}
}
8. 性能优化
8.1 批量处理优化
java
package com.example.transaction.service;
import com.example.transaction.entity.User;
import com.example.transaction.manager.TransactionMessageManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 批量操作服务
*/
@Slf4j
@Service
public class BatchOperationService {
private final UserService userService;
private final TransactionMessageManager messageManager;
public BatchOperationService(
UserService userService,
TransactionMessageManager messageManager) {
this.userService = userService;
this.messageManager = messageManager;
}
/**
* 批量更新用户
*/
@Transactional(rollbackFor = Exception.class)
public void batchUpdateUsers(List<User> users) {
log.info("开始批量更新用户,数量: {}", users.size());
for (User user : users) {
userService.updateUser(user.getId(), user.getEmail(), user.getPhone());
}
log.info("批量更新用户完成");
}
}
8.2 性能监控
java
package com.example.transaction.monitor;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
/**
* 消息性能监控
*/
@Component
public class MessageMetrics {
private final Counter messageSentCounter;
private final Counter messageConsumedCounter;
private final Counter messageFailedCounter;
private final Timer messageSendTimer;
private final Timer messageConsumeTimer;
public MessageMetrics(MeterRegistry meterRegistry) {
this.messageSentCounter = meterRegistry.counter("message.sent.total");
this.messageConsumedCounter = meterRegistry.counter("message.consumed.total");
this.messageFailedCounter = meterRegistry.counter("message.failed.total");
this.messageSendTimer = meterRegistry.timer("message.send.duration");
this.messageConsumeTimer = meterRegistry.timer("message.consume.duration");
}
public void recordMessageSent() {
messageSentCounter.increment();
}
public void recordMessageConsumed() {
messageConsumedCounter.increment();
}
public void recordMessageFailed() {
messageFailedCounter.increment();
}
public Timer.Sample startSendTimer() {
return Timer.start();
}
public void recordSendDuration(Timer.Sample sample) {
sample.stop(messageSendTimer);
}
public Timer.Sample startConsumeTimer() {
return Timer.start();
}
public void recordConsumeDuration(Timer.Sample sample) {
sample.stop(messageConsumeTimer);
}
}
9. 实战案例:电商订单系统
9.1 订单服务
java
package com.example.transaction.demo;
import com.example.transaction.config.RabbitMQConfig;
import com.example.transaction.manager.TransactionMessageManager;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/**
* 订单服务示例
*/
@Slf4j
@Service
public class OrderService {
private final TransactionMessageManager messageManager;
public OrderService(TransactionMessageManager messageManager) {
this.messageManager = messageManager;
}
/**
* 创建订单
*/
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
log.info("开始创建订单 - userId: {}, productId: {}",
request.getUserId(), request.getProductId());
// 1. 创建订单记录(省略具体实现)
Long orderId = saveOrder(request);
// 2. 扣减库存(省略具体实现)
deductStock(request.getProductId(), request.getQuantity());
// 3. 发送消息通知(更新缓存、发送通知等)
Map<String, Object> messageBody = new HashMap<>();
messageBody.put("orderId", orderId);
messageBody.put("userId", request.getUserId());
messageBody.put("productId", request.getProductId());
messageBody.put("quantity", request.getQuantity());
messageBody.put("amount", request.getAmount());
messageManager.sendTransactionMessage(
"order:" + orderId,
"ORDER_CREATED",
messageBody,
RabbitMQConfig.TRANSACTION_EXCHANGE,
"order.created"
);
log.info("订单创建成功 - orderId: {}", orderId);
return orderId;
}
private Long saveOrder(CreateOrderRequest request) {
// 省略实现
return System.currentTimeMillis();
}
private void deductStock(Long productId, Integer quantity) {
// 省略实现
}
@Data
public static class CreateOrderRequest {
private Long userId;
private Long productId;
private Integer quantity;
private BigDecimal amount;
}
}
10. 最佳实践
10.1 设计原则
css
┌──────────────────────────────────────────────────────────┐
│ 事务消息系统设计最佳实践 │
├──────────────────────────────────────────────────────────┤
│ │
│ 1. 消息设计 │
│ ✓ 消息体包含完整业务数据 │
│ ✓ 消息 ID 全局唯一(UUID) │
│ ✓ 业务键用于幂等性判断 │
│ │
│ 2. 事务管理 │
│ ✓ 消息表操作必须在业务事务内 │
│ ✓ MQ 发送在事务提交后异步执行 │
│ ✓ 使用 @Transactional 注解 │
│ │
│ 3. 可靠性保证 │
│ ✓ 消费端幂等性处理(消费记录表 + 分布式锁) │
│ ✓ 消息重试机制(指数退避) │
│ ✓ 死信队列处理异常消息 │
│ │
│ 4. 性能优化 │
│ ✓ 批量扫描消息(分页) │
│ ✓ 合理设置重试间隔 │
│ ✓ 异步处理,避免阻塞 │
│ │
│ 5. 监控告警 │
│ ✓ 监控消息发送成功率 │
│ ✓ 监控消息消费延迟 │
│ ✓ 告警失败消息 │
│ │
└──────────────────────────────────────────────────────────┘
11. 总结
11.1 方案优势
✅ 强一致性保证:利用本地消息表,保证 MySQL 事务和消息的原子性
✅ 高可靠性:消息不会丢失,支持重试和补偿
✅ 可扩展性:易于添加新的消费者和业务场景
✅ 幂等性:通过消费记录表和分布式锁保证幂等
✅ 可观测性:完整的日志和监控
11.2 适用场景
- 订单系统(订单创建、支付、发货)
- 用户系统(注册、登录、信息变更)
- 库存系统(库存扣减、补货)
- 营销系统(优惠券发放、积分变更)