一句话总结:生产环境出现诡异的消息"丢失",最终发现是K8S强制Kill进程 + Redisson看门狗续期 + 防并发设计在异常场景下的失效,三者共同作用下的完美风暴。
写作背景
最近在整理去年的工作笔记时,翻到了一个当时困扰了我们团队近4个月的线上问题。回过头看这个问题的排查过程,发现其中涉及到容器化环境下进程管理、消息队列消费机制、分布式锁等多个技术点的交互,具有一定的典型性和参考价值。因此整理成文,希望能给遇到类似问题的同学一些启发。
问题的最终解决方案非常简单——在启动脚本前加一个exec命令。但在找到根因之前,我们走了不少弯路。这个案例也再次印证了:在分布式系统中,看似简单的问题背后往往隐藏着多个组件交互的复杂时序。
问题现象
现象描述
去年3月开始,生产环境出现了一个诡异的问题:3月、5月和6月各出现了1笔订单的返利没有到账。运营团队多次反馈,但排查起来非常困难:
- 数据库事务执行正常
- 网络连接稳定
- 业务逻辑代码经过多轮Review未发现问题
- 因为返利业务的周期性缘故,用户报障时为订单产生的下个月,生产日志已无法追溯
这个问题持续了近4个月,每次发生后都找不到明确的原因。直到我们开始关注问题发生的时间规律,才找到了突破口。
- 影响范围:共三笔订单返利未到账
- 发生频率:偶发性问题,发生频率较低
- 业务影响:用户投诉,需要人工补录数据
- 技术表现:RocketMQ Console显示消息已被确认消费,但订单未返利
已排除的可能性
在排查初期,我们按照常规思路检查了以下几个方面:
- 数据库层面:事务执行正常,没有死锁或超时
- 网络层面:应用与消息队列、Redis的网络连接稳定
- 代码层面:业务逻辑经过多轮Code Review,未发现明显问题
- 中间件层面:RocketMQ和Redis运行正常,无异常告警
排查思路与过程
1. 时间规律分析
在反复出现几次问题后,我们开始记录问题发生的时间点:
# 消息丢失的时间点记录
2024-03-15 02:30:xx # 凌晨发版时间窗口
2024-05-24 03:15:xx # 凌晨发版时间窗口
2024-06-14 02:45:xx # 凌晨发版时间窗口
关键发现:所有问题都发生在K8S滚动更新期间。这个规律让我们将排查重点转向了容器重启场景
想象中的公司容器的停机流程
正常情况下,K8S的停机流程应该是这样的:
graph TD
A[K8S发送SIGTERM信号] --> B[Spring Boot接收信号]
B --> C[开始优雅停机]
C --> D[停止接收新请求]
D --> E[等待现有请求处理完成]
E --> F[关闭MQ消费者]
F --> G[释放资源]
G --> H[进程退出]
标准流程看起来很完美:
- 发送
kill -15(SIGTERM) 信号,通知应用优雅停机 - 等待优雅停机时间窗口
- 执行
kill -9(SIGKILL) 强制终止进程
现实的残酷真相
然而,通过与容器云同事的深入交流,发现了一个致命问题:
kubectl delete pod 时是向容器内 PID 1 发送 SIGTERM 也就是 kill -15 PID 1号进程
# 容器启动脚本
#!/bin/bash
java ${APM_SET} ${APOLLO_SET} -jar app.jar
问题分析:
- K8S发送
kill -15信号给1号进程 - 但是容器中的1号进程是
shell脚本,不是java进程 - Java进程成为了子进程,无法接收到SIGTERM信号
- 等待优雅停机超时后,K8S直接执行
kill -9强制杀死进程 - 正在处理的MQ消息直接丢失!
PID 1号进程的身份危机
让我们深入容器内部,看看进程的真实面貌:
# 在容器内执行 ps aux
# 预期的进程树(理想状态)
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 15.2 2847316 621312 ? Sl 09:30 0:05 java -jar app.jar
# 实际的进程树(问题根源)
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 11284 2816 ? Ss 09:30 0:00 /bin/sh -c startup.sh
root 42 0.1 15.2 2847316 621312 ? Sl 09:30 0:05 java -jar app.jar
问题一目了然:
- 1号进程:shell脚本 (
/bin/sh) - 42号进程:Java应用 (真正干活的)
当K8S发送SIGTERM信号时:
# K8S的操作
kill -15 1 # 只通知了shell进程
# shell进程收到信号后直接退出,没有转发给Java进程
# 然后30秒后...
kill -9 42 # 直接强杀Java进程!
这就像是层层代理的沟通问题:
- K8S (老板): "通知Java应用优雅下班"
- Shell (中间管理): 收到消息但没有传达
- Java应用 (员工): 正在专心工作,突然被保安拖走
到了这里,心中又浮现一个疑问,服务被强制kill掉,为什么会丢消息呢,带着这个关键线索,我又对消费者代码进行分析
2. 消费者代码分析
我们仔细审查了消息消费者的代码实现:
@Component
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "rebate-consumer-group"
)
public class OrderRebateListener extends BaseRocketmqConsumer<OrderMessage> {
@Resource
private RedisLockHelper redisLockHelper;
@Override
public void processMsg(OrderMessage msgDTO, MessageExt msg) {
RLock lock = null;
try {
// 看门狗模式:leaseTime = -1,自动续期
lock = redisLockHelper.tryLock(
"order:" + msgDTO.getOrderId(),
0, -1, TimeUnit.MINUTES
);
if (lock == null) {
// 获取锁失败,抛出异常
throw new MrpBusinessException(ResultCode.NOT_GET_REDIS_LOCK);
}
// 处理业务逻辑
processRebate(msgDTO);
} finally {
RedisLockHelper.unLock(lock);
}
}
}
代码看起来很标准,使用了分布式锁来防止相同订单的消息被并发处理。但问题可能就隐藏在这些看似正常的代码中。
3. 防并发逻辑分析
继续审查基类的实现,我们发现了一个特殊的异常处理逻辑:
public abstract class BaseRocketmqConsumer<T> implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt msg) {
try {
this.doProcess(msg);
} catch (Throwable e) {
// 关键点:NOT_GET_REDIS_LOCK异常被特殊处理
if (e instanceof MrpBusinessException
&& ResultCode.NOT_GET_REDIS_LOCK.getCode().equals(
((MrpBusinessException) e).getCode())) {
log.warn("未获取到锁,认为消息正在被其他线程处理");
// 设计初衷:防止相同key的消息并发处理
// 获取不到锁 = 另一个线程正在处理 = 不需要重复处理
return; // 直接返回成功,避免重复消费
} else {
throw e; // 其他异常才会重新抛出
}
}
}
}
这段代码的设计初衷:
这是一个防止消息重复消费的设计。正常情况下的逻辑是:
- 线程A获取锁并处理消息
- 线程B尝试获取同一个锁失败,认为线程A正在处理,直接返回成功
- 这样可以避免同一个订单的消息被并发消费
潜在的问题:
这个设计基于一个隐含假设——持有锁的线程一定会完成业务处理。但如果在容器重启场景下,持有锁的进程被强制Kill,这个假设就不成立了:
- 旧Pod的进程被Kill,锁没有释放
- 新Pod获取锁失败,误认为"正在处理"
- 实际上没有任何线程在处理这条消息
到这里,我们初步锁定了问题的方向:
可能的原因1:容器被强制kill + RocketMQ消费机制(如果异步+自动确认),就可能导致了消息丢失
可能的原因2:容器被强制kill + 分布式锁 + 防并发逻辑,三者在特定时序下的交互可能导致了消息丢失。
RocketMQ消费机制源码分析
为了理解问题的本质,我们需要深入RocketMQ的源码,了解消息确认的机制。
RocketMQ消费完整流程图(基于4.9.1)
flowchart TD
A[Broker推送消息] --> B[Netty接收]
B --> C[DefaultMQPushConsumerImpl<br/>消息拉取入口]
C --> D[PullCallback.onSuccess<br/>拉取成功回调]
D --> E[ProcessQueue<br/>本地消息队列]
E --> F{消费模式?}
F -->|并发消费| G[ConsumeMessageConcurrentlyService]
F -->|顺序消费| H[ConsumeMessageOrderlyService]
G --> I[提交到线程池<br/>ThreadPoolExecutor]
I --> J[ConsumeRequest.run<br/>消费任务执行]
J --> K[调用MessageListener.consumeMessage]
K --> L{Spring RocketMQ?}
L -->|是| M[DefaultRocketMQListenerContainer<br/>Spring适配层]
L -->|否| N[直接调用用户Listener]
M --> O[RocketMQListener.onMessage<br/>用户业务代码]
N --> O
O --> P{执行结果}
P -->|正常返回| Q[返回CONSUME_SUCCESS]
P -->|抛异常| R[返回RECONSUME_LATER]
Q --> S[processConsumeResult<br/>处理消费结果]
R --> S
S --> T{消费状态?}
T -->|SUCCESS| U[更新消费进度offset]
T -->|RECONSUME_LATER| V[发送消息回Broker重试]
U --> W[提交offset到Broker]
V --> X[消息进入重试队列]
W --> Y[消费完成]
X --> Y
style O fill:#ffeb3b
style Q fill:#4caf50
style R fill:#f44336
style U fill:#4caf50
style V fill:#ff9800
关键时序:消息确认机制
sequenceDiagram
participant Broker
participant Consumer as Consumer<br/>DefaultMQPushConsumerImpl
participant Thread as 消费线程池
participant Listener as 用户Listener
participant Queue as ProcessQueue
Broker->>Consumer: 推送消息批次
Consumer->>Queue: 存入本地队列
Consumer->>Thread: 提交消费任务
Thread->>Listener: 调用consumeMessage()
alt 正常执行
Listener->>Listener: 处理业务逻辑
Listener-->>Thread: 返回(无异常)
Thread->>Thread: status = CONSUME_SUCCESS
else 抛出异常
Listener->>Listener: 业务异常
Listener-->>Thread: throw Exception
Thread->>Thread: status = RECONSUME_LATER
end
Thread->>Consumer: processConsumeResult(status)
alt status == CONSUME_SUCCESS
Consumer->>Queue: 移除已消费消息
Consumer->>Broker: 更新offset
Broker-->>Consumer: 确认
else status == RECONSUME_LATER
Consumer->>Broker: sendMessageBack(重试)
Broker-->>Consumer: 消息进入重试队列
end
Note over Listener,Broker: 关键:只有正常返回才确认<br/>如果代码内部catch异常并return<br/>RocketMQ认为消费成功!
关键源码1:消息拉取与分发
RocketMQ Consumer端的核心流程:
// org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService
class ConsumeRequest implements Runnable {
@Override
public void run() {
MessageListenerConcurrently listener =
ConsumeMessageConcurrentlyService.this.messageListener;
ConsumeConcurrentlyContext context = new ConsumeConcurrentlyContext(
messageQueue);
ConsumeConcurrentlyStatus status = null;
try {
// 执行业务消费逻辑
status = listener.consumeMessage(msgs, context);
} catch (Throwable e) {
log.warn("consumeMessage exception", e);
// 异常情况下默认重试
status = ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
// 根据返回状态处理消息确认
if (status == null) {
status = ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
// 处理消费结果
ConsumeMessageConcurrentlyService.this.processConsumeResult(
status, context, this);
}
}
关键源码2:消费结果处理
// org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService
public void processConsumeResult(
ConsumeConcurrentlyStatus status,
ConsumeConcurrentlyContext context,
ConsumeRequest consumeRequest) {
int ackIndex = context.getAckIndex();
switch (status) {
case CONSUME_SUCCESS:
// 消费成功:计算成功消费的消息数
if (ackIndex >= consumeRequest.getMsgs().size()) {
ackIndex = consumeRequest.getMsgs().size() - 1;
}
// 统计消费成功数
int ok = ackIndex + 1;
int failed = consumeRequest.getMsgs().size() - ok;
// 更新统计数据
this.getConsumerStatsManager()
.incConsumeOKTPS(consumerGroup,
consumeRequest.getMessageQueue().getTopic(), ok);
break;
case RECONSUME_LATER:
// 消费失败:标记为需要重试
ackIndex = -1;
break;
}
// 处理失败的消息
for (int i = ackIndex + 1; i < consumeRequest.getMsgs().size(); i++) {
MessageExt msg = consumeRequest.getMsgs().get(i);
// 发送回Broker进行重试
boolean result = this.sendMessageBack(msg, context);
if (!result) {
// 发送失败,设置重新消费
msg.setReconsumeTimes(msg.getReconsumeTimes() + 1);
msgBackFailed.add(msg);
}
}
// 移除已消费的消息,更新offset
long offset = consumeRequest.getProcessQueue()
.removeMessage(consumeRequest.getMsgs());
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
// 提交消费进度到Broker
this.defaultMQPushConsumerImpl.getOffsetStore()
.updateOffset(consumeRequest.getMessageQueue(), offset, true);
}
}
核心机制总结:
- 只有正常返回
CONSUME_SUCCESS,消息offset才会被提交 - 如果抛出异常、返回
RECONSUME_LATER或消费者被kill,消息会重新投递 - 如果业务代码正常返回(如防并发设计中catch后return),RocketMQ会认为消息消费成功
Spring-RocketMQ适配层
// org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer
class DefaultMessageListenerConcurrently
implements MessageListenerConcurrently {
@Override
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
for (MessageExt messageExt : msgs) {
try {
// 调用用户定义的RocketMQListener.onMessage()
rocketMQListener.onMessage(messageExt);
// 如果没有抛异常,返回成功
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
log.warn("consume message failed", e);
// 只有抛异常才返回重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
结论:RocketMQ是基于业务返回值确认消息,不是自动确认!排查到这里,我们可以 排除容器被强制kill + RocketMQ消费机制,导致了消息丢失
Redisson看门狗机制源码分析
在理解了RocketMQ的消费机制后,我们需要继续深入Redisson的看门狗机制,这是问题的另一个关键环节。
Redisson分布式锁完整流程图(基于3.12.0)
flowchart TD
A[调用tryLock方法] --> B{leaseTime参数?}
B -->|leaseTime != -1| C[指定过期时间模式]
B -->|leaseTime == -1| D[看门狗模式]
C --> E[执行Lua脚本获取锁<br/>设置指定过期时间]
D --> F[执行Lua脚本获取锁<br/>设置默认30秒过期]
E --> G{获取锁结果?}
F --> G
G -->|成功 ttl=null| H{是否看门狗模式?}
G -->|失败 ttl>0| I{waitTime > 0?}
H -->|是| J[启动看门狗<br/>scheduleExpirationRenewal]
H -->|否| K[返回成功,不启动看门狗]
J --> L[创建定时任务<br/>internalLockLeaseTime/3<br/>默认10秒后执行]
L --> M[TimerTask.run<br/>续期任务执行]
M --> N[调用renewExpirationAsync<br/>Lua脚本续期]
N --> O{续期成功?}
O -->|成功| P[重新调度下次续期<br/>10秒后再次执行]
O -->|失败/锁不存在| Q[停止看门狗]
P --> M
I -->|是| R[订阅锁释放事件<br/>等待重试]
I -->|否| S[返回获取锁失败]
R --> T[收到锁释放通知或超时]
T --> E
style J fill:#ffeb3b
style M fill:#ff9800
style P fill:#4caf50
style Q fill:#f44336
看门狗在K8S Kill场景下的时序
sequenceDiagram
participant App as 应用进程
participant Lock as RedissonLock
participant Timer as 看门狗Timer
participant Redis
participant K8S
App->>Lock: tryLock(-1)
Lock->>Redis: SET锁,30秒过期
Lock->>Timer: 启动看门狗,10秒续期
Note over App: T=0秒<br/>开始处理业务
Note over Timer: T=10秒
Timer->>Redis: 续期至30秒
Redis-->>Timer: 成功
Note over Timer: T=20秒
Timer->>Redis: 续期至30秒
Redis-->>Timer: 成功
Note over K8S: T=25秒<br/>K8S发送SIGKILL
K8S->>App: 强制Kill进程
Note over App: 进程终止<br/>finally未执行<br/>unlock()未调用
Note over Timer: 看门狗Timer被Kill<br/>无法继续续期
Note over Redis: T=25-55秒<br/>锁仍然存在<br/>剩余5-35秒过期时间<br/>(取决于Kill时机)
Note over Redis: T=55秒<br/>锁自动过期释放
rect rgb(255, 200, 200)
Note over App,Redis: 危险窗口:25-55秒<br/>锁已失效但仍占用<br/>新Pod无法获取锁
end
看门狗续期实现源码
// org.redisson.RedissonLock
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP
.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
// 启动续期任务
renewExpiration();
}
}
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 创建定时任务,每10秒执行一次(默认30秒锁过期时间的1/3)
Timeout task = commandExecutor.getConnectionManager().newTimeout(
new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP
.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 执行续期:重置过期时间为30秒
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock expiration", e);
return;
}
if (res) {
// 续期成功,继续调度下一次续期
renewExpiration();
}
});
}
},
internalLockLeaseTime / 3, // 默认30秒/3 = 10秒
TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
// Lua脚本:重置锁的过期时间
return commandExecutor.evalWriteAsync(getName(),
LongCodec.INSTANCE,
RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
看门狗机制关键点:
- 默认锁过期时间:30秒(
lockWatchdogTimeout) - 续期间隔:10秒(过期时间的1/3)
- 续期策略:每次重置为30秒
- 停止条件:锁被释放或进程终止
问题根因分析
通过对RocketMQ和Redisson源码的深入分析,我们已经掌握了所有的技术细节。现在可以完整地还原问题发生的完整时序。
完整的问题发生时序图
sequenceDiagram
participant K8S
participant PodA as Pod-A(旧版本)
participant PodB as Pod-B(新版本)
participant RocketMQ
participant Redis
participant Business as 业务系统
Note over K8S,Business: 正常业务运行中
PodA->>RocketMQ: 拉取消息(订单A)
RocketMQ->>PodA: 返回消息
PodA->>Redis: tryLock(订单A, -1分钟)
Redis->>PodA: 获取锁成功
Note over PodA: 启动看门狗<br/>每10秒续期至30秒
PodA->>Business: 开始处理业务逻辑...
Note over K8S: 触发滚动更新
K8S->>PodB: 启动新Pod
PodB->>K8S: 新Pod就绪
K8S->>PodA: 发送SIGTERM(优雅停机)
Note over PodA: 但Shell脚本没有转发信号<br/>Java进程继续运行
Note over PodA: 30秒后...
K8S->>PodA: 发送SIGKILL(强制杀进程)
Note over PodA: 进程被强制Kill<br/>业务逻辑中断<br/>finally块未执行<br/>锁未释放
Note over Redis: 看门狗续期Timer也被Kill<br/>但锁还剩余10-30秒过期时间
RocketMQ->>RocketMQ: 未收到消费确认<br/>消息重新投递
RocketMQ->>PodB: 投递消息(订单A)
PodB->>Redis: tryLock(订单A, 0秒等待)
Redis->>PodB: 锁已被占用,获取失败
PodB->>PodB: throw MrpBusinessException(NOT_GET_REDIS_LOCK)
Note over PodB: BaseRocketmqConsumer捕获异常
PodB->>PodB: catch异常,log.warn()
PodB->>PodB: return (不抛异常)
PodB->>RocketMQ: 返回CONSUME_SUCCESS
RocketMQ->>RocketMQ: 提交offset,消息确认成功
Note over Redis: 10-30秒后锁过期释放
Note over Business: 订单A的业务逻辑永远不会执行<br/>消息已被确认"消费成功"
三个关键因素
1. K8S强制Kill:锁未释放
# 容器启动脚本
#!/bin/bash
java -jar app.jar # Java进程不是PID 1
# K8S的操作
kill -15 1 # SIGTERM发给Shell进程
# 30秒后
kill -9 42 # SIGKILL强杀Java进程,finally未执行
结果:分布式锁残留在Redis中,剩余10-30秒过期时间
2. Redisson看门狗:锁续期延长影响时间
// 看门狗配置
leaseTime = -1 // 启用看门狗
lockWatchdogTimeout = 30s // 锁过期时间30秒
renewInterval = 10s // 每10秒续期一次
最坏情况:
- Kill发生在续期后1秒:锁还有29秒才过期
- 消息重新投递间隔:通常3-5秒
- 消息多次重试都无法获取锁
3. 防并发设计的副作用:消息被误确认
// 业务代码
if (lock == null) {
// 设计初衷:获取不到锁,说明其他线程正在处理,无需重复处理
throw new MrpBusinessException(NOT_GET_REDIS_LOCK);
}
// BaseRocketmqConsumer的防并发逻辑
catch (MrpBusinessException e) {
if (e.getCode().equals(NOT_GET_REDIS_LOCK)) {
log.warn("未获取到锁,认为消息正在被处理");
return; // 防止重复消费,直接返回成功
}
}
正常场景:
- 线程A:持有锁,正在处理业务
- 线程B:获取锁失败,return成功(避免重复处理)
异常场景(K8S Kill):
- 旧Pod:持有锁,被Kill中断
- 新Pod:获取锁失败,return成功(误以为旧Pod在处理)
- 结果:锁释放后也没有线程处理了,业务逻辑永远不会执行
解决方案
在理解了问题的根本原因后,我们制定了完整的解决方案。
修复K8S优雅停机(最终采用方案)
1.1 使用exec启动Java进程(最终解决方案)
经过与K8S容器负责同事的协调,我们采用了最简单有效的方案:修改启动脚本,使用exec命令。
#!/bin/bash
# 启动脚本修改前
java ${APM_SET} ${APOLLO_SET} -jar app.jar
# 启动脚本修改后(最终方案)
exec java ${APM_SET} ${APOLLO_SET} -jar app.jar
exec的作用:
exec命令会用新的程序替换当前进程- Java进程直接成为1号进程
- 可以直接接收K8S的SIGTERM信号
# 不使用exec的进程关系
PID 1: /bin/sh docker-entrypoint.sh
└─ PID 42: /bin/sh startup.sh
└─ PID 108: java -jar app.jar
# 使用exec后的进程关系
PID 1: java -jar app.jar # Java直接替换了所有shell进程
如果使用容器云控制台,还需要:
# 容器云控制台启动命令
exec /path/to/start.sh
# start.sh内容
exec java ${APM_SET} ${APOLLO_SET} -jar app.jar
效果验证:
# 在容器内执行
ps aux
# 修改前的输出
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 11284 2816 ? Ss 09:30 0:00 /bin/sh -c startup.sh
root 42 0.1 15.2 2847316 621312 ? Sl 09:30 0:05 java -jar app.jar
# 修改后的输出(Java成为PID 1)
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 15.2 2847316 621312 ? Sl 09:30 0:05 java -jar app.jar
修复后的效果:
- Java进程直接接收SIGTERM信号
- Spring Boot优雅停机机制生效
- finally块正常执行,锁正常释放
- 消息消费完成后才关闭Consumer
- 问题彻底解决,后续发版未再出现消息丢失
1.2 配合Spring Boot优雅停机(可选增强)
虽然修改脚本已经解决了问题,但配置Spring Boot优雅停机可以让应用更健壮:
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
# 等待处理中的请求完成
timeout-per-shutdown-phase: 30s
1.3 添加PreStop Hook(可选增强)
如果需要更保守的优雅停机策略,可以添加PreStop Hook:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
# 给应用15秒时间完成当前请求
command: ["/bin/sh", "-c", "sleep 15"]
# 优雅停机总时长:60秒
terminationGracePeriodSeconds: 60
验证与测试
解决方案实施后,我们进行了充分的测试验证。
测试场景1:模拟K8S强制Kill
# 1. 启动应用并发送消息
curl -X POST <http://localhost:8080/send-message>
# 2. 查看进程ID
ps aux | grep java
# root 1234 ... java -jar app.jar
# 3. 模拟K8S强制Kill
kill -9 1234
# 4. 检查Redis锁状态
redis-cli
> KEYS order:*
> TTL order:123456 # 查看剩余过期时间
# 5. 观察消息重新投递后的处理情况
测试结果
- 修改前:消息被误认为消费成功,业务逻辑未执行
- 修改后:消息能够正常重试并最终被正确消费
最佳实践总结
基于这次问题的排查和解决经验,总结以下最佳实践。
应该遵循的实践
- 容器化应用(最关键)
- 强制要求:使用
exec启动Java进程,让其成为PID 1 - 上线检查:容器启动后执行
ps aux验证Java是否为PID 1 - 标准模板:团队统一启动脚本模板,避免重复踩坑
- 配置建议:合理的
terminationGracePeriodSeconds(建议60秒)
- 强制要求:使用
- 分布式锁策略
- 本案例经验:看门狗模式在异常场景下有风险,建议指定过期时间
- 性能权衡:评估业务真实耗时,设置合理的锁过期时间
- 消息消费设计
- 防并发设计:需要考虑进程被Kill等异常中断场景
- 幂等保护:使用消息ID作为幂等键
- 问题排查方法论
- 关注时间规律:100%在特定时间发生说明什么?
- 深入源码分析:理解底层机制才能找到根因
- 绘制时序图:梳理各组件交互,找出时序窗口
- 跨团队协作:容器、中间件、业务多方协同
快速自查清单
容器启动脚本检查:
# 错误示例
java -jar app.jar
# 正确示例
exec java -jar app.jar
验证方法:
# 在容器内执行
ps aux | head -2
# 期望看到
USER PID COMMAND
root 1 java -jar app.jar # PID 1 是 Java
# 而不是
USER PID COMMAND
root 1 /bin/sh startup.sh # PID 1 是 Shell
深度思考
为什么这个Bug如此隐蔽?
- 触发条件苛刻:需要K8S容器重启 + 正在消费消息 + 消息重试时锁未释放
- 影响范围小:只影响重启瞬间正在处理的消息
- 无明显报错:进程被Kill,日志来不及记录
- 时间窗口短:30秒内锁释放后问题消失
- 表象迷惑性:看起来像消息丢失,实际是消费逻辑未执行
- 设计初衷良好:防并发逻辑在正常场景下工作完美,只在异常场景下失效
防并发设计的隐含假设
原有的防并发设计基于一个隐含假设:
"获取不到锁 = 其他线程正在处理 = 该线程一定会完成处理"
这个假设在正常情况下是成立的:
- 线程A获取锁,处理业务,释放锁
- 线程B获取锁失败,认为A在处理,直接返回成功(避免重复消费)
但在K8S强制Kill的场景下:
- 线程A获取锁,被Kill中断,锁未释放
- 线程B获取锁失败,认为A在处理,返回成功
- 实际上A已经被Kill,业务逻辑永远不会完成
这就是典型的"假设在99.9%的场景下成立,但在0.1%的极端场景下失效"的案例。
系统设计的启示
- 防御式编程:假设任何外部依赖都可能失败
- 优雅降级:分布式锁获取失败应该重试而非放弃
- 可观测性:完善的日志、监控、告警体系
- 端到端测试:包含基础设施层面的混沌测试
分布式系统的复杂性
这个案例完美诠释了分布式系统的复杂性:
- 容器编排层(K8S)
- 进程管理层(Shell/Java)
- 消息中间件层(RocketMQ)
- 分布式协调层(Redis/Redisson)
- 业务逻辑层(应用代码)
任何一层的细微问题,都可能在特定时序下被放大成系统性故障。
总结
问题解决成果
经过与K8S容器团队的协调,我们采用了最简单有效的方案——在启动脚本中添加exec命令。
修复效果:
- 一行代码解决问题:只需在启动脚本前加
exec - 验证通过:后续发版,未再出现消息丢失
- 零业务代码改动:无需修改业务逻辑和分布式锁代码
- 治本之策:从根本上解决了优雅停机问题
深度思考与收获
这次深度剖析让我们认识到:
- 源码是最好的文档:理解RocketMQ和Redisson的实现原理,才能正确使用
- 细节决定成败:一个
exec命令,看似简单,却是问题的关键 - 系统性思维:容器化环境下,问题往往跨越多个技术栈(K8S + Shell + JVM + MQ + Redis)
- 防御式设计的边界:防并发设计在99.9%的场景下工作完美,但需要考虑极端场景
- 团队协作的重要性:与容器云团队的深入沟通是解决问题的关键
记住:在分布式系统中,任何看似"不可能"的问题,在特定的时序条件下,都可能成为现实。而解决方案,往往比想象的更简单——一个exec命令足矣。
参考资料
标签
#RocketMQ #Kubernetes #Redisson #分布式锁 #消息队列 #优雅停机 #源码分析 #故障排查 #微服务 #可靠性
如果这篇文章对你有帮助,欢迎点赞、收藏和分享!
作者:Mario
创作日期:2025-10-13