2026年1月19日到25日这周,我的工作强度不算大,但信息密度很高。周一上来收到两拨告警,一波是订单接口 p99 从平时 200ms 涨到了 3 秒多,另一波是通知服务开始报连接池超时。刚开始我以为是运维变更引起的,后来查了一圈才发现,这两个告警其实是同一个根因:一条慢查询把数据库连接池拖死了。这周的大部分时间,我都在跟这类“看起来分散、实际上同源”的问题打交道。这篇周报,我不打算只放一个完成事项清单,而是把每个问题背后的定位过程、改动方式和经验沉淀都写出来,方便以后翻查,也希望给遇到类似情况的人一点参考。
1. 本周整体安排:三条线并行,主线是性能稳定
先说这周的总体节奏。我手上同时有三条线在走:一是订单查询接口的性能优化,二是发货通知模块从老单体服务里的拆分,三是零星穿插的线上问题排查。听起来事情不算多,但实际互相纠缠,如果没在周一就把优先级定清楚,后面几天大概率会变成“到处救火”。
1.1 为什么这周优先处理性能问题
订单查询慢这件事不是这周才发生的,上上周就有零星反馈,说用户订单列表页偶尔打不开。当时我判断是网络抖动,没深入跟。这周告警直接把我拉回现实:p99 到了 3.2 秒,数据库 CPU 冲到 75%,这在平常是不敢想的。更要命的是,慢查询导致连接池中的连接被长时间占用,通知服务那些正常请求反而拿不到连接,超时率一下就上来了。一条慢 SQL 引发连锁反应,是我评估优先级时最不愿看到的场景。
模块拆分这件事是早就计划好的。老订单服务里塞了太多东西,导致每次上线都要牵动一堆不相关模块。但这个改造风险高,不能抢在性能问题还没结论的时候做。万一性能问题根子在数据库连接层,拆模块折腾完发现地基还是漏的,那就白忙了。所以我把“线上稳定”放在第一位,拆模块放到慢查询问题有结论之后再推进。
1.2 目标拆解:从“感觉有问题”到“可以执行的任务”
周一上午我花了一个小时把各条线拆成了可执行任务,原则很简单:每个任务必须能回答“做完之后解决什么问题、怎么验证、花多久”。如果你也写周报,我对这段时间的建议是,不要只写“优化订单接口”这种话,要落到具体指标和动作上。
| 任务 | 目标 | 验证方式 | 预计时间 |
|---|---|---|---|
| 定位订单列表慢查询根因 | 找出 SQL 或索引问题 | p99 降到 500ms 以内 | 1 天 |
| 优化索引与 SQL 改造 | 降低数据库 CPU 和延迟 | 观察 24h 告警与监控曲线 | 1 天 |
| 设计发货通知模块拆分方案 | 输出灰度方案和回滚方案 | 评审通过 | 2 天 |
| 实现双写与幂等消费逻辑 | 新链路不丢消息、不重复通知 | 压测和灰度数据对比 | 2 天 |
| 处理连接池耗尽告警 | 恢复线上正常水位 | 告警消失 | 0.5 天 |
这样拆完,周五再回头看,进展非常清楚:哪些完成了,哪些延期了,延期原因是什么。我一直觉得,周报的价值不在于把工作写得多漂亮,而在于过程清晰、结果可验证。这也是下面几节我能把这周的事情讲得比较细的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询优化:从 APM 告警到 SQL 改写,完整走了一遍
这一节应该是本周最硬核的部分。一个订单列表查询,持续了几个星期没被认真对待,最终在周一早上给了我们一次“惊喜”。
2.1 现象与初步定位:告警先到,分析随后
周一早上 9 点 40 分左右,监控平台连续弹了三条告警:订单服务响应时间 P99 超过 2000ms、数据库 CPU 超过 70%、通知服务连接池等待超时。我当时第一反应是“是不是有人上线了没告诉我”,查了一圈发布记录,没有任何变更。这就说明问题大概率是埋在系统内部,平时不触发,等某个流量特征出现才暴露。
打开 APM 平台,订单服务里最耗时的操作是 OrderMapper.selectUserOrderList,平均耗时 780ms,p99 超过 3.2s。点击进去能看到对应的 SQL 和数据库实例。这里我的习惯是先把慢 SQL 捞出来,然后用 EXPLAIN 看执行计划,再看具体表的数据量和索引情况。
2.2 EXPLAIN 解码:索引存在,但没有用对
表结构大致是这样,我做了精简:
sql复制CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL,
`user_id` bigint(20) NOT NULL,
`status` tinyint(4) NOT NULL,
`total_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
慢 SQL 是这样的:
sql复制SELECT id, order_no, status, total_amount, create_time
FROM order_info
WHERE user_id = 100123
AND status IN (1, 2, 3)
ORDER BY id DESC
LIMIT 20;
一眼看过去,索引存在,条件也不复杂,为什么慢?我执行 EXPLAIN 之后发现,优化器选择了 idx_user_id,这没问题,问题是它先用 user_id 捞出了这个用户的所有订单,再在内存里做 filesort 排序,最后取 20 条。当用户订单数量达到几十万条时,这个排序过程就非常痛苦。Extra 列明确显示 Using index condition; Using filesort,这就是性能瓶颈。
更细节的原因是,InnoDB 的二级索引维护的是索引列和主键的映射,userId 索引无法同时覆盖 status 过滤和 id 排序。MySQL 先把符合条件的记录都拿出来,再通过主键回表读取完整行数据,然后在临时表里排序。这个过程随着数据量增长呈线性甚至更差的表现,所以平时几百毫秒,流量一高就变成几秒。
2.3 改造方案:联合索引加上 SQL 改写
根因清楚了,方案也顺理成章:建一个联合索引,让过滤和排序能同时走索引。
sql复制ALTER TABLE order_info ADD INDEX idx_user_status_id (user_id, status, id);
这里有个小细节,联合索引的顺序不能乱。user_id 等值条件放最前面,status 其次,id 最后。这样的索引可以直接返回按主键倒序排列的结果,省掉 filesort。为了验证效果,我改完索引后重新执行 EXPLAIN,Extra 变成了 Using index condition,没有再出现 filesort。
不过光改索引还不够。我在慢查询日志里还发现了另一个变体:
sql复制SELECT ...
FROM order_info
WHERE user_id = 100123
AND (status = 1 OR status = 2)
ORDER BY id DESC
LIMIT 20;
这种写法会把 OR 条件拉到索引选择层面,导致 MySQL 对 idx_user_status_id 无法直接应用。我把它改成:
sql复制SELECT ...
FROM order_info
WHERE user_id = 100123
AND status IN (1, 2)
ORDER BY id DESC
LIMIT 20;
IN 和 OR 在业务语义上等价,但在索引利用上差别很大。优化完成后,我又顺手查了订单导出功能,发现一个隐藏的 N+1 问题:导出时循环查每个订单的商品明细,500 个订单发出了 3000 多条 SQL。这个不在告警范围里,但它同样占用数据库连接,我改成了一次性传入订单 ID 列表批量查询,配合索引优化后,导出耗时也从 11 秒降到了 1.2 秒。
2.4 优化结果复盘:技术指标只是一部分
上线索引变更和 SQL 改写后,我观察了整整一个白天。结果还是比较满意的:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 订单列表 p99 | 3.2s | 112ms |
| 平均耗时 | 780ms | 41ms |
| 数据库 CPU | 75% | 28% |
| 通知服务超时率 | 4.7% | 0.1% |
技术指标过了,但我要说一句经验之谈:慢查询优化不是 DBA 或某个后端一个人的事。我建议在代码评审阶段就要求带上 EXPLAIN 结果,尤其是涉及订单、用户这类数据量大且索引敏感的表。我这次踩的坑,根源就是最初建表时只看“有没有索引”,没看“索引能不能支撑这条 SQL”。这条经验写进了我们团队的检查清单,后续新需求只要涉及查询列表页,必须说明索引方案和预估数据量。
3. 模块拆分:把发货通知从订单服务里平滑解耦
看标题你可能觉得“模块拆分”是很重的架构改造,其实我这次做的是个很小的范围:把“发货通知”这个动作从订单创建链路里解耦出来。它不改变业务功能,只改变调用方式,控制在可控范围内。
3.1 拆分的动机与边界选择
老订单服务创建订单后,会同步调用通知服务,通知服务再去调用物流、短信等外部接口。问题很明显:外部接口一慢,订单创建也跟着慢;外部接口不可用,订单创建直接失败。对用户来说,买完东西服务器还在等短信服务响应,这是完全不合理的。
拆分的边界我定在“通知动作”本身,不动订单状态机。拆完以后,订单服务只负责自己的事:写入订单、记录状态。通知服务通过消费事件完成后续动作。
我选的方案是 outbox 模式。为什么不用在事务里直接发 MQ?原因很简单:本地事务和外部消息发送无法保证原子性。万一事务提交成功但消息没发出去,或者消息发出去了事务回滚,都会造成数据不一致。outbox 的做法是,在同一个数据库事务里写入业务数据和一条待发送消息,然后由后台任务把消息可靠地投递到 MQ。这样只要事务提交,消息一定存在;事务回滚,消息也不会发。
3.2 落地过程:双写、灰度开关和回滚
第一步,我先在订单服务里新增一个 outbox 表:
sql复制CREATE TABLE `outbox_message` (
`id` bigint NOT NULL AUTO_INCREMENT,
`biz_type` varchar(64) NOT NULL,
`biz_id` varchar(64) NOT NULL,
`payload` json NOT NULL,
`status` tinyint NOT NULL DEFAULT '0', -- 0待发送 1已发送 2失败
`retry_count` int NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL,
`send_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status_create_time` (`status`, `create_time`)
) ENGINE=InnoDB;
第二步,订单创建逻辑里,在同一个事务中写入订单和 outbox 消息:
java复制@Transactional
public void createOrder(CreateOrderRequest request) {
Order order = buildOrder(request);
orderMapper.insert(order);
OutboxMessage message = OutboxMessage.create(
"ORDER_CREATED",
order.getId(),
JsonUtils.toJson(order)
);
outboxMapper.insert(message);
}
第三步,写一个后台任务,把 status=0 的消息投递到 Kafka。注意投递成功的标准是“已发送到 broker”,不是“消费端已处理”。投递后标记 status=1,失败则记录重试次数,超过阈值转人工。
第四步,消费端要设计幂等。Kafka 本身是至少一次语义,消息可能会重复。我在通知表里加了一个唯一键 (order_id, notify_type),消费前先查重,保证同一个订单不会重复发短信或重复调用物流接口。这个幂等设计是必须的,没想清楚就别上 MQ。
灰度上我用配置中心加了一个开关,先用 20% 的流量走新链路,持续观察一天后再逐步放开。回滚也很简单:把配置开关关掉,老链路继续走,数据不会乱。这个开关在切换期间我们频繁拨动过几次,基本没有副作用。
3.3 上线后的效果与一个坑
拆分上线后,订单创建的 p99 从 260ms 降到了 90ms,主要省掉了同步调用外部接口的时间。更重要的稳定性收益是,通知服务再怎么抖动,也不会影响下单主流程。这周没有双 11 那种流量,但从压测结果看,吞吐量提升了接近两倍。
踩过的一个坑值得说。灰度期间我发现,Kafka 里出现了少量消息延迟,排查发现是消费线程数不够,通知服务在调外部接口时是同步的,阻塞了消费。我本以为是消费端并发度越高越好,实际评估后把外部调用改成了线程池异步巡检,同时增加消费线程数,延迟问题才解决。这提醒我一个道理:消息队列异步化之后,瓶颈往往从“生产端”转移到了“消费端”,消费端的线程池策略和数据一致性都要重新考虑。
4. 线上问题排查实录:三个值得记录的故障
如果前面说的是主动优化,这一节就是被动救火。本周我实际处理了三个独立告警,每个都花了不少时间,但每个都沉淀了一条经验。我把过程和排查思路完整记录下来,方便以后直接查。
4.1 连接池被打满:根因是慢查询占着连接不放
先交代下背景,通知服务用的连接池是 HikariCP,原来配置 maximum-pool-size=10,正常情况下完全够用。慢查询爆发后,很多请求在等待数据库返回,连接被长时间占用,10 个连接很快被占满,其他请求只能等 30 秒超时,然后抛 Connection is not available。
排查步骤很经典。我先在 MySQL 里执行了 SHOW FULL PROCESSLIST,看到大量 Sleep 和 Query 状态的长连接,然后找到其中一条耗时 5 秒以上的 SQL,就是第 2 节里那个慢查询。再配合 jstack 看去服务的线程堆栈,发现大量线程阻塞在 getConnection 上。
处理时我做了三件事:把该慢查询优化上线;连接池调大到 30;同时把 connection-timeout 从 5 秒调到 3 秒,让等不到的请求尽快失败,避免拖垮整个服务。这里我想强调,连接池大小不是越大越好。连接池本质是一个池化的资源,过大会让 MySQL 端也堆积大量连接,反而拖垮数据库。正确的调整顺序是:先消灭慢 SQL,再调连接池参数。如果只调池子,就是拿更多资源掩盖问题。
4.2 缓存穿透:几个不存在的订单号把数据库打穿
另一个独立故障是缓存穿透。某一天下午,数据库连接数突然飙到接近上限,检查后发现某个接口在被固定参数反复请求,参数对应的订单号在数据库中根本不存在。也就是说,每次请求都没命中缓存,直接落在数据库上。
常规缓存逻辑是“先查缓存,查不到再查数据库”,这个逻辑在数据不存在时是无效的,因为不存在的数据不会写入缓存。于是恶意或异常请求只需要构造大量不存在的 ID,就能让数据库扛下所有流量。
我这次的解决措施有两层:
| 方案 | 说明 | 成本 |
|---|---|---|
| 空值缓存 | 查询结果为空时,在缓存里写一个短时间的空占位 | 低,立刻见效 |
| 参数校验 | 订单号格式做前置校验,非法请求直接拦截 | 低,但只拦非法格式 |
| 布隆过滤器 | 用内存或 Redis 布隆过滤器判断 ID 是否存在 | 中,适合存量 ID 集稳定场景 |
我最后选了空值缓存加参数校验。空值缓存的过期时间只设了 60 秒,防止数据库数据新增后缓存一直挡住正常查询。布隆过滤器我没有立刻上,因为它适合 ID 集几乎不变的情况,而订单表持续增长,维护成本高,收益不划算。不是每个方案都适合所有场景,这点我建议你在实际选型时想清楚。
4.3 TraceId 丢失:排查链路的隐形杀手
第三个问题不算故障告警,但在排查其他问题的时候发现的:线上日志里同一笔请求的 TraceId 居然不一致,这导致 APM 平台里没法把一次完整调用串起来。定位发现是异步线程池中 MDC 上下文没有传递,子线程执行时拿到的 TraceId 是空的,日志就打出了新的一串。
修复不复杂,在定义线程池时加一个 TaskDecorator,把主线程的 MDC 上下文拷贝到子线程:
java复制@Bean("asyncExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setTaskDecorator(runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
runnable.run();
} finally {
MDC.clear();
}
};
});
return executor;
}
这个修复看起来简单,但很值得写入团队的公共组件里。不然每个新创建的线程池都可能踩一遍这个问题。我在代码评审表里加了一项:新建线程池、@Async 异步方法,必须检查 MDC 传递。这也是周报里记录这个问题的价值,一次排查,长期避免。
5. 复盘:怎么把经验沉淀到下一次不再踩坑
这周的告警和优化告一段落后,我花了一个晚上写周报,也把几件事沉淀成了 checklist。你可能觉得周报是给领导看的任务清单,但我更愿意把它理解成“给未来自己的操作手册”。如果下次再遇到连接池超时,我希望可以直接翻到今天的记录,看到根因、排查步骤和处理方式,而不是重新经历一遍从 APM 到 SQL 的漫长路径。
5.1 从问题到检查清单
我把这周的经验整理成了一些可以执行的规则:
- 涉及列表查询的 SQL,上线前必须执行
EXPLAIN并确认没有filesort - 任何改动上线前,在监控平台看连续 15 分钟的 P95、CPU、GC 曲线
- 新增异步线程池时必须检查 TraceId 传递
- 所有缓存查询都要考虑“数据不存在”的情况,空值也要有兜底
- 模块拆分过程中,必须保持旧链路可用,开关切换要有回滚预案
- 连接池参数调整前,先确认是不是存在慢 SQL 占用连接,而不是直接加连接数
这些规则看起来零散,但每一条背后都对应一次真实的线上事故。以后做同类需求,我会直接在团队 Wiki 里引用对应的记录,而不是靠某个人记忆。
5.2 写周报时,我喜欢保留哪些内容
每次写周报,我不按简单的时间线罗列,而是把这三类内容放进去:
第一类是“关键数据和指标”。比如这周优化后的 p99、CPU 下降比例、连接池超时率,甚至包括压测吞吐量对比。这些数据能让看周报的人直观知道工作效果。
第二类是“问题的根因和解决过程”。不只是写“修复了连接池满的问题”,要写清楚是什么导致的连接池满、怎么定位出来的。因为真正有价值的是那个“为什么”,而不是“做了什么”这个动作。
第三类是“风险和后续动作”。拆分模块这件事虽然上线了,但灰度还没放量到 100%,下周还要继续观察;缓存穿透加了两层保护,但布隆过滤器要不要上,等数据量再大一些再评估。写清楚这些,能让下周的计划更有针对性。
5.3 一点小建议
如果你也遇到类似的多任务周,我给的建议是把“问题定位”和“问题解决”分开记录。定位过程往往充满了猜测、验证和修正,周报只需要把最终确定的根因和验证过的证据写进去,不要让读的人跟着你绕弯子。
我个人写周报还有一个习惯,就是留一个独立的“给下周的话”。不用写成长篇大论,几句话就行,比如“下周要关注通知服务消费线程池水位”“缓存穿透防护要加上正则校验”。这些话到下周再看,往往能快速帮你找回上下文。
回头看这周,我最大的体会是:很多线上问题都不是“突然出现”的,而是慢慢积累、在某个流量尖峰下被放大。慢查询不是一天慢的,连接池也不是第一天就满的。好在我这周没有选择“先加连接数压一下再说”,而是花时间把根因找出来。这种判断决定了同样的时间投入,是缓解问题还是解决问题。希望这篇记录也能在某个时刻帮到你。
