在分布式系统中处理百万级消息时,确保每条消息只被处理一次是个极具挑战性的问题。我曾负责过一个合同签署系统的消息处理模块,高峰期每天要处理超过200万条签署状态变更消息。在这个过程中,我们遇到了几个典型问题:
这些场景如果处理不当,就会导致订单状态被重复更新、合同签署时间被错误覆盖等严重问题。下面我将分享经过实战验证的全链路解决方案。
我们在MySQL业务库中设计了如下消息表结构:
sql复制CREATE TABLE `message_send_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(64) NOT NULL COMMENT '消息唯一ID',
`biz_id` varchar(64) NOT NULL COMMENT '业务ID(如订单号)',
`msg_content` text NOT NULL COMMENT '消息内容',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-待发送 1-已发送 2-发送失败',
`retry_count` int(11) NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_msg_id` (`msg_id`),
KEY `idx_status_retry` (`status`,`retry_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键实现要点:
我们的重试策略包含以下几个关键参数:
java复制// 重试配置类
public class RetryPolicy {
private int maxAttempts = 3; // 最大重试次数
private long initialInterval = 1000L; // 初始间隔1秒
private double multiplier = 1.5; // 间隔乘数
private long maxInterval = 10000L; // 最大间隔10秒
}
重试任务的核心逻辑:
java复制@Scheduled(fixedDelay = 60000) // 每分钟执行一次
public void retryFailedMessages() {
List<MessageSendLog> messages = messageSendLogMapper.selectRetryMessages();
for (MessageSendLog message : messages) {
try {
mqProducer.send(message);
message.setStatus(MessageStatus.SENT);
} catch (Exception e) {
message.setRetryCount(message.getRetryCount() + 1);
if (message.getRetryCount() >= retryPolicy.getMaxAttempts()) {
message.setStatus(MessageStatus.FAILED);
alertService.notifyAdmin(message);
}
}
messageSendLogMapper.update(message);
}
}
重要提示:重试间隔建议采用指数退避算法,我们的实现是:Math.min(policy.getMaxInterval(), (long)(policy.getInitialInterval() * Math.pow(policy.getMultiplier(), attempt-1)))
对于使用RocketMQ的场景,这些配置至关重要:
properties复制# 生产者配置
rocketmq.producer.group=contract_producer_group
rocketmq.producer.sendMessageTimeout=3000
rocketmq.producer.retryTimesWhenSendFailed=2
# 消费者配置
rocketmq.consumer.group=contract_consumer_group
rocketmq.consumer.consumeThreadMin=5
rocketmq.consumer.consumeThreadMax=20
rocketmq.consumer.consumeMessageBatchMaxSize=1 # 顺序消费必须设为1
rocketmq.consumer.maxReconsumeTimes=3 # 最大重试次数
如果是Kafka环境,这些配置值得关注:
properties复制# 生产者
acks=all
retries=3
enable.idempotence=true
max.in.flight.requests.per.connection=1
# 消费者
isolation.level=read_committed
enable.auto.commit=false
max.poll.records=100 # 批量消费数量
我们最初使用简单的Redis锁:
java复制public boolean tryLock(String key, long expireTime) {
String result = redisTemplate.opsForValue()
.setIfAbsent(key, "1", expireTime, TimeUnit.MILLISECONDS);
return "OK".equals(result);
}
但在高并发场景下发现了两个问题:
改进后的方案:
java复制public boolean tryLockWithLua(String key, String clientId, long expireTime) {
String luaScript = "if redis.call('exists', KEYS[1]) == 0 then " +
"redis.call('set', KEYS[1], ARGV[1]); " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 1; " +
"elseif redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 1; " +
"else return 0; end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
clientId,
String.valueOf(expireTime));
return result != null && result == 1L;
}
对于合同签署场景,我们在订单表添加了组合唯一索引:
sql复制ALTER TABLE order_contract
ADD UNIQUE INDEX uk_order_sign (order_id, sign_status);
处理重复消息时的代码逻辑:
java复制@Transactional
public void processSignMessage(SignMessage message) {
try {
orderDao.updateSignStatus(message.getOrderId(),
message.getSignTime(), message.getSignStatus());
} catch (DuplicateKeyException e) {
log.warn("Duplicate sign message detected: {}", message.getMessageId());
// 可以查询当前状态做进一步验证
OrderStatus current = orderDao.getStatus(message.getOrderId());
if (current == message.getSignStatus()) {
return; // 状态一致,直接返回
}
throw new IllegalStateException("Conflict status detected");
}
}
对于"MQ消费者监听类中批量添加对象数据,当达到100条时再进行入库"的场景,我们采用以下方案保证安全:
java复制public class BatchMessageProcessor {
private static final int BATCH_SIZE = 100;
private List<Message> buffer = new ArrayList<>(BATCH_SIZE);
@KafkaListener(topics = "contract_topic")
public void onMessage(Message message) {
buffer.add(message);
if (buffer.size() >= BATCH_SIZE) {
processBatch();
}
}
private void processBatch() {
// 1. 对这批消息按业务ID分组
Map<String, List<Message>> grouped = buffer.stream()
.collect(Collectors.groupingBy(Message::getBizId));
// 2. 对每个业务ID加分布式锁
grouped.forEach((bizId, messages) -> {
String lockKey = "batch_lock:" + bizId;
try {
if (redisLock.tryLock(lockKey, 30000)) {
// 3. 检查数据库状态
OrderStatus status = orderDao.getStatus(bizId);
if (status != null && status.isCompleted()) {
return; // 已处理过
}
// 4. 批量处理
orderDao.batchUpdate(messages);
}
} finally {
redisLock.unlock(lockKey);
}
});
buffer.clear();
}
}
我们为批量处理设计了多级回退策略:
java复制private void processBatchWithFallback() {
try {
// 尝试正常处理
processBatch();
} catch (BatchException e) {
// 单条失败处理
e.getFailedMessages().forEach(msg -> {
errorLogDao.insert(new ErrorLog(msg, e));
});
// 剩余消息处理
if (!e.getRemainingMessages().isEmpty()) {
processSingleMessages(e.getRemainingMessages());
}
} catch (DataAccessException e) {
// 数据库异常,整批重试
mqProducer.sendToRetryTopic(buffer);
buffer.clear();
}
}
完善的监控是保证消息可靠性的最后一道防线。我们的监控体系包含:
| 指标名称 | 计算方式 | 告警阈值 |
|---|---|---|
| 消息积压量 | MQ队列长度 | >5000 |
| 消息处理延迟 | 生产时间-消费时间 | >30秒 |
| 重试率 | 重试消息数/总消息数 | >5% |
| 死信队列增长速率 | 每分钟死信消息增加数 | >10条/分钟 |
我们在消息头中植入了traceId实现全链路追踪:
java复制public class MessageTracingInterceptor implements ProducerInterceptor {
@Override
public ProducerRecord onSend(ProducerRecord record) {
record.headers().add("traceId", TracingContext.getTraceId());
record.headers().add("spanId", TracingContext.newSpanId());
return record;
}
}
追踪数据可以帮助我们:
在高并发场景下,我们通过以下优化将处理能力从500TPS提升到3000TPS:
java复制@KafkaListener(
topics = "contract_topic",
concurrency = "10", // 分区数的1.5倍
containerFactory = "batchFactory"
)
public void onMessages(List<Message> messages) {
// 批量处理逻辑
}
java复制@Transactional
public void batchUpdate(List<Order> orders) {
jdbcTemplate.batchUpdate(
"UPDATE order_contract SET sign_time=?, sign_status=? WHERE order_id=?",
new BatchPreparedStatementSetter() {
public void setValues(PreparedStatement ps, int i) {
Order order = orders.get(i);
ps.setTimestamp(1, order.getSignTime());
ps.setInt(2, order.getSignStatus());
ps.setString(3, order.getOrderId());
}
public int getBatchSize() {
return orders.size();
}
});
}
我们采用多级缓存减少数据库压力:
java复制public boolean isMessageProcessed(String messageId) {
// 1. 检查本地缓存
if (localCache.getIfPresent(messageId) != null) {
return true;
}
// 2. 检查Redis
if (redisTemplate.opsForValue().get(messageId) != null) {
localCache.put(messageId, Boolean.TRUE);
return true;
}
// 3. 检查数据库
boolean exists = messageLogDao.existsByMessageId(messageId);
if (exists) {
redisTemplate.opsForValue().set(messageId, "1", 30, TimeUnit.MINUTES);
localCache.put(messageId, Boolean.TRUE);
}
return exists;
}
这套方案在线上环境稳定运行两年多,处理了超过5亿条合同签署消息,重复消息率控制在0.001%以下。最关键的经验是:幂等性设计必须贯穿整个消息链路,任何单一环节的防护都不足以应对分布式系统中的各种异常场景。