这 4 种 SQL 写法,数据量一大就是生产事故(SQL 性能篇)

SQL 性能问题有一个共同特征:在数据量小的时候完全正常,数据量增长后突然崩。这让很多人误以为是数据库配置问题,其实根子在 SQL 写法本身。本篇 4 个坑,每个都附上定位工具和根治手段。

环境说明

项目 版本
MySQL 5.7.x / 8.0.x
操作系统 Ubuntu 20.04/22.04

坑 1:没有索引,百万数据秒变全表扫描

现象: 某个查询在数据量 1 万行时响应 10ms,数据增长到 100 万行后响应超过 10 秒,CPU 飙满,甚至把整个数据库拖垮。

根本原因: 查询字段没有索引,MySQL 必须逐行扫描整张表(type: ALL),数据量和扫描时间线性正比。

第一步:用 EXPLAIN 定位问题

sql 复制代码
EXPLAIN SELECT * FROM orders 
WHERE user_id = 100 AND status = 'paid' 
ORDER BY created_at DESC;

重点看这几列:

列名 危险信号 含义
type ALL 全表扫描,最差
rows 数字很大 预计扫描行数,越大越糟
Extra Using filesort 排序没用上索引,额外排序操作
Extra Using temporary 用了临时表,GROUP BY / ORDER BY 常见

第二步:加正确的索引

sql 复制代码
-- 复合索引:把等值查询列放前面,区分度高的列放前面
-- 这条 SQL 的最优索引:(user_id, status, created_at)
ALTER TABLE orders 
  ADD INDEX idx_user_status_time (user_id, status, created_at);

-- 加完之后再 EXPLAIN 验证
EXPLAIN SELECT * FROM orders 
WHERE user_id = 100 AND status = 'paid' 
ORDER BY created_at DESC;
-- type 应该变成 ref 或 range,rows 大幅减少

复合索引的设计原则(最左前缀法则)

css 复制代码
-- 假设索引是 (a, b, c)
-- ✅ 能用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3

-- ❌ 不能用索引(跳过了最左列 a)
WHERE b = 2
WHERE c = 3

-- ⚠️ 只能用 a 部分(遇到范围查询后面的列失效)
WHERE a = 1 AND b > 5 AND c = 3  -- c 的索引失效

索引失效的常见写法

sql 复制代码
-- ❌ 对索引列做函数运算,索引失效
WHERE DATE(created_at) = '2024-01-15'
-- ✅ 改成范围查询
WHERE created_at >= '2024-01-15 00:00:00' 
  AND created_at < '2024-01-16 00:00:00'

-- ❌ 隐式类型转换,索引失效
-- user_id 是 INT,却传了字符串
WHERE user_id = '100'
-- ✅ 类型匹配
WHERE user_id = 100

-- ❌ LIKE 前缀通配,索引失效
WHERE name LIKE '%张%'
-- ✅ 只有后缀通配才能走索引
WHERE name LIKE '张%'
-- 全文搜索场景用 FULLTEXT 索引或 Elasticsearch

坑 2:SELECT * 把带宽和内存撑爆

现象: 接口响应慢,数据库服务器内存高,网络监控显示流量异常,但 SQL 看起来很简单。

根本原因: SELECT * 把表里所有列的数据全部拉到应用层,包括 TEXT、BLOB 等大字段,即使业务逻辑只用其中 2~3 个字段。当表有图片、富文本、JSON 大字段时,这个问题尤为严重。

解决方案:

sql 复制代码
-- ❌ 不要这样,尤其是有大字段的表
SELECT * FROM articles WHERE category_id = 5 LIMIT 20;

-- ✅ 只查需要的列
SELECT id, title, author, published_at 
FROM articles 
WHERE category_id = 5 
LIMIT 20;

N+1 查询:SELECT * 的升级版灾难

ini 复制代码
# ❌ N+1 查询:查 1 次订单列表,再查 N 次用户信息
orders = db.query("SELECT * FROM orders LIMIT 100")
for order in orders:
    user = db.query(f"SELECT * FROM users WHERE id = {order['user_id']}")
    # 循环 100 次,发了 101 条 SQL

# ✅ 用 JOIN 一次查完
orders = db.query("""
    SELECT o.id, o.amount, o.status,
           u.name AS user_name, u.email
    FROM orders o
    JOIN users u ON o.user_id = u.id
    LIMIT 100
""")

-- 或者用 IN 批量查(适合不方便 JOIN 的场景)
-- 先查订单列表
SELECT id, user_id, amount FROM orders LIMIT 100;

-- 把 user_id 列表收集起来,一次查所有用户
SELECT id, name, email FROM users WHERE id IN (1, 2, 3, ...);

坑 3:事务没提交没回滚,锁住整张表

现象: 某个操作之后,数据库写入全部 hang 住,SHOW PROCESSLIST 里大量线程显示 Waiting for table metadata lockLock wait timeout exceeded,重启应用或 kill 某个进程才恢复。

根本原因: 一个事务开启后,因为代码 bug(异常未捕获、连接未关闭)导致没有提交或回滚,它持有的锁无法释放,后续所有涉及同一行或同一表的写操作只能等待。

第一步:定位 blocking 事务

sql 复制代码
-- 查看当前所有活跃事务
SELECT * FROM information_schema.INNODB_TRX\G

-- 查看锁等待关系(谁在等谁)
SELECT * FROM sys.innodb_lock_waits\G

-- 找到 blocking 线程 ID,强制终止
KILL 线程ID;

第二步:代码层面根治

php 复制代码
# Python - 用 try/finally 确保事务一定关闭
conn = get_connection()
try:
    cursor = conn.cursor()
    cursor.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    cursor.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
    conn.commit()          # 成功时提交
except Exception as e:
    conn.rollback()        # 任何异常都回滚
    raise e
finally:
    conn.close()           # 无论如何都要释放连接

// Java - 用 try-with-resources 自动管理连接
try (Connection conn = dataSource.getConnection()) {
    conn.setAutoCommit(false);
    try {
        // 执行业务 SQL
        conn.commit();
    } catch (SQLException e) {
        conn.rollback();
        throw e;
    }
    // conn 会被自动关闭
}

配置层面的辅助防护

ini 复制代码
[mysqld]
# 锁等待超时时间,超过后自动报错而不是永久 hang(默认 50s,可以调短)
innodb_lock_wait_timeout = 10

# 开启死锁检测日志
innodb_print_all_deadlocks = 1

坑 4:AUTO_INCREMENT 在 MySQL 5.7 重启后 ID 复用

现象: 删除了几条记录后重启 MySQL,新插入的数据 ID 和已删除记录的 ID 重合,导致业务数据关联错乱(外键、日志、缓存全部对不上)。

根本原因: MySQL 5.7 把 AUTO_INCREMENT 计数器存在内存里,重启后从表中最大 ID 重新推算。如果最大 ID 的那条记录恰好被删了,计数器会从更小的值开始,就会复用已被删除记录的 ID。

sql 复制代码
-- 复现步骤:
INSERT INTO test (name) VALUES ('a'),('b'),('c');  -- ID: 1,2,3
DELETE FROM test WHERE id = 3;                      -- 删掉 ID=3
-- 重启 MySQL
INSERT INTO test (name) VALUES ('d');               -- 5.7 里 ID 是 3(复用了!)
                                                    -- 8.0 里 ID 是 4(正确)

MySQL 8.0 的修复: 计数器写入 redo log 持久化,重启后准确恢复,不再复用。

解决方案(5.7 环境):

sql 复制代码
-- 方法一:手动设定 AUTO_INCREMENT 起始值(适合迁移/恢复场景)
ALTER TABLE your_table AUTO_INCREMENT = 10000;

-- 方法二:查看当前 AUTO_INCREMENT 值
SHOW TABLE STATUS LIKE 'your_table'\G

根本解法是升级到 MySQL 8.0。如果暂时不能升级,业务逻辑必须对 ID 复用做防御:不能假设历史 ID 一定对应历史数据,每次操作前先验证记录是否存在。

快速自检清单(SQL 性能)

上线前必查:

x 所有 WHERE 条件列是否有索引(用 EXPLAIN 确认 type 不是 ALL)

x 是否消除了 EXPLAIN 结果里的 Using filesortUsing temporary

x代码里是否存在循环内执行 SQL(N+1 查询)

x 所有查询是否指定了具体列,没有 SELECT *(尤其是有大字段的表)

x事务代码是否有 try/catch/finally 保证提交或回滚

x生产 MySQL 是否已升级到 8.0(规避 5.7 的 AUTO_INCREMENT 复用问题)

x 慢查询日志是否开启(long_query_time = 1),用于持续发现问题

小结

这 4 个坑有一个共同点:开发时因为数据量小完全感知不到,上线后随着数据增长逐渐暴露 。它们不是低级错误,而是在不了解 MySQL 执行机制的情况下自然写出来的代码。EXPLAIN 是每个开发者都要会用的工具,建议把它变成写 SQL 的习惯动作。

相关推荐
小旭95271 小时前
分布式事务 Seata 详解 + 链路追踪 SkyWalking 实战
java·分布式·后端·信息可视化·skywalking
曹牧1 小时前
Spring:@RequestMapping 注解匹配顺序
java·后端·spring
AI攻城狮1 小时前
DeepSeek 的 Vision 能力要来了吗?
人工智能·后端·openai
用户622475758461 小时前
面试官问我:"如何实现你项目中的这块代码."我说:"看好了."
后端
空中海1 小时前
Nacos 2: Spring Boot Demo 实战
java·spring boot·后端
阿丰资源2 小时前
基于Spring Boot的美容院管理系统(附源码+数据库+文档)
数据库·spring boot·后端
TE-茶叶蛋2 小时前
Spring自动配置分析
java·后端·spring
北风toto2 小时前
SpringBoot 获取配置文件值、获取环境变量的方式
java·spring boot·后端
凤山老林2 小时前
Spring Boot 集成国产开源图库 HugeGraph 实现图谱分析的技术方案
spring boot·后端·开源·hugegraph·图谱分析