故事的背景想必很多同行都经历过:我们的核心业务系统,在经历了从单体到微服务的“华丽转身”后,开发团队的幸福指数直线上升。服务拆分、独立部署、快速迭代……一切看起来都那么美好。

然而,这份美好并没有持续太久。随着用户量和业务复杂度的增加,我们开始收到来自监控系统的“红色警报”——核心API的P95响应时间从几十毫셔秒飙升到2-3秒,数据库服务器的CPU使用率也常驻在80%以上的高位。用户开始抱怨卡顿,运营团队也反馈后台操作越来越慢。

性能问题,这把悬在所有技术团队头上的“达摩克利斯之剑”,终究还是落了下来。经过初步排查,我们将矛头指向了最核心的瓶颈——数据库

第一步:定位问题,让数据说话

优化不能凭感觉。我们的第一步是打开MySQL的“慢查询日志”(Slow Query Log),并结合监控系统(Prometheus + Grafana)的DB监控面板,开始“缉拿真凶”。果不其然,几条平时看起来“人畜无害”的SQL语句,在高峰期成了拖垮整个系统的罪魁祸首。

下面,我将分享几个最典型、也最具迷惑性的“坑”,以及我们的填坑过程。

深坑一:索引失效的隐形“刺客”

我们发现一条查询用户订单的SQL执行得异常缓慢,它涉及的 created_at 字段明明建了索引。

原始SQL长这样:

code SQL

    SELECT * FROM orders WHERE DATE(created_at) = '2025-10-12';
  

问题出在哪?就出在 DATE() 这个函数上。对索引字段使用函数,会导致MySQL放弃使用索引,进行全表扫描。 这是一个非常基础但极其容易被忽略的知识点。对于几百万甚至上千万条数据的订单表来说,全表扫描的代价是毁灭性的。

解决方案:
改造SQL,避免在查询条件中对字段进行计算。将函数运算移到查询值的这边。

code SQL

    -- 改造后的SQL
SELECT * FROM orders WHERE created_at >= '2025-10-12 00:00:00' AND created_at <= '2025-10-12 23:59:59';
  

用 EXPLAIN 分析一下执行计划,type 从 ALL(全表扫描)变成了 range(索引范围扫描),rows 也从几百万骤降到几百。效果立竿见影。

小结: 永远要让索引字段以“最纯粹”的形式出现在 WHERE 子句中。

深坑二:深分页下的“死亡”查询

在一个后台管理系统中,有一个查看用户操作日志的功能,前端使用的是传统的分页。当运营同学翻到几万页之后,查询就会变得奇慢无比。

问题SQL:

code SQL

    SELECT * FROM user_logs ORDER BY id DESC LIMIT 100000, 20;
  

LIMIT offset, count 这种分页方式,在 offset 值非常大(即深分页)时,性能会急剧下降。因为MySQL需要先扫描 offset + count 条记录,然后丢弃掉前面的 offset 条,这个过程非常浪费资源。

解决方案:
采用“游标”或者叫“延迟关联”的方式进行优化。核心思想是先快速定位到起始ID,再进行查询。

code SQL

    -- 改造后的SQL,利用子查询先定位ID
SELECT t1.* FROM user_logs t1
INNER JOIN (SELECT id FROM user_logs ORDER BY id DESC LIMIT 100000, 20) t2 ON t1.id = t2.id;
  

这种方式利用了覆盖索引(如果 id 是主键),子查询 (SELECT id ...) 的速度会非常快,因为它不需要回表查数据。拿到20个ID后,再通过主键关联,效率大大提升。

小结: 对付深分页,避免使用大 offset 是关键。

深坑三:微服务拆分后的N+1查询“幽灵”

服务拆分后,原本在单体应用里一个简单的 JOIN 查询,现在可能需要调用多个服务来聚合数据。很多同事为了图方便,会写出类似这样的伪代码:

code Java

    // 1. 先查询出订单列表
List<Order> orders = orderSerivce.findOrdersByUserId(userId);
// 2. 循环调用商品服务,获取商品信息
for (Order order : orders) {
    Product product = productService.getProductById(order.getProductId());
    order.setProduct(product); // 组装数据
}
  

如果一次查询出100个订单,那么除了第1次查询,后面会跟着100次对商品服务的RPC调用。这就是典型的“N+1查询”问题。在微服务架构下,网络开销会让这个问题变得更加致命。

解决方案:
提供批量查询接口。将循环调用改为一次批量调用。

code Java

    // 1. 先查询出订单列表
List<Order> orders = orderSerivce.findOrdersByUserId(userId);
// 2. 提取所有商品ID
List<Long> productIds = orders.stream().map(Order::getProductId).collect(Collectors.toList());
// 3. 一次性批量查询商品信息
Map<Long, Product> productMap = productService.getProductsByIds(productIds);
// 4. 在内存中进行数据组装
for (Order order : orders) {
    order.setProduct(productMap.get(order.getProductId()));
}
  

只需要两次查询(1次查订单 + 1次批量查商品),就解决了问题。

小结: 在微服务设计中,要有意识地提供批量处理接口,从源头上避免N+1问题。

总结与思考

经过为期一周的集中优化,我们解决了上述几个核心问题,并对其他一些慢SQL也进行了调整。最终,核心API的P95响应时间稳定在了200毫秒以内,数据库的CPU使用率也回落到了30%左右的健康水平。

这次经历让我深刻体会到:

  1. 基础知识是根本:无论架构如何演进,像索引原理、SQL执行计划这些数据库基础知识,永远是性能优化的基石。

  2. 监控必须先行:没有量化的数据监控,性能优化就如同在黑暗中航行,无的放矢。

  3. 架构演进是取舍:微服务带来了开发的便利,但也引入了新的复杂性(如分布式事务、N+1查询)。我们需要在享受其优势的同时,正视并解决其带来的问题。

好了,第一篇作业就到这里。这只是数据库篇,接下来在“缓存篇”中,我还会分享我们是如何利用Redis进一步解放数据库压力的。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐