很多人以为 MySQL 优化就是“加索引”“改 SQL”。其实,最大的坑,往往藏在你看不见的地方——比如一条看似无害的默认配置,一个 ORM 自动生成的 JOIN,一次“为了安全”的全表锁。
下面列举的我亲自见识过的32个误区,有些是我半夜被叫起来救火的元凶,有些是同事信誓旦旦“绝对没问题”结果炸了的“神操作”。希望你看完后,能少背几次锅,少熬几个通宵。
内容稍微有点长,建议大家收藏后慢慢看,以后用得着的时候能方便找到。
误区1:“只要加索引,查询就快”

某系统查询接口突然变慢。查看慢日志发现:
SELECT * FROM orders WHERE DATE(create_time) = '2024-11-10';开发说:“我给 create_time 加了索引啊!”
我说:“你加了也没用——因为 DATE() 函数让索引失效了。”
后来改成范围查询(MySQL8.0+版本也可以用函数索引,但是建议优先用范围查询替代):
WHERE create_time >= '2024-11-10 00:00:00'
AND create_time < '2024-11-11 00:00:00'QPS 从 30 直接飙到 5200。
有一次用户登录失败率飙升。排查发现:
-- user_id 是 VARCHAR(32)
SELECT * FROM sessions
WHERE user_id = 789012;注意:这里用了数字 789012,而不是字符串 '789012'。
MySQL 为了比较,会把表中所有 user_id 转成数字。结果是进行了全表扫描!800万行,每次登录都要扫一遍。改回字符串后,响应时间从 6.3 秒降到 8 毫秒。
另外,隐式转换的案例还有很多,例如字符集不同、排序规则不同等也会造成。所以需要谨记索引不是贴上去就生效,它很“娇气”——函数、类型不匹配、最左前缀破坏,都会让它“罢工”。
误区2:“查询字段都建索引,索引越多越好”

新来的实习生为了“保险”,给一张商品表(50个字段)建了30多个单列索引。上线第一天,商品上架接口从 200ms 涨到 3.5 秒。
为什么?每插入一条商品记录,InnoDB 要更新主键索引 + 30多个二级索引。磁盘IO直接拉满,写入队列堆积如山。
后来我们只保留了三个联合索引:
(category_id, is_on_sale, sort_order)
(brand_id, price)
(sku_code) —— 唯一索引写性能恢复,查询也没变慢。
某次凌晨全量备份失败,原因是磁盘空间不足。一查:一张日志表有 12GB 数据,但索引占了 28GB!
原来历史遗留问题:既有 (user_id) 索引,又有 (user_id, action) 联合索引。前者完全冗余!用 sys.schema_redundant_indexes 一查,删掉冗余索引后,备份时间缩短60%,磁盘省了16GB。
建议:定期用percona-tookits工具检查冗余、未使用索引,如查看性能视图 performance_schema.table_io_waits_summary_by_index_usage,别让索引变成“数字脂肪”。
误区3:“分页用 LIMIT OFFSET 就行,每次只返回一部分数据,高效方便”
后台有个“导出全部异常订单”功能,前端一页一页拉数据:
SELECT * FROM orders
WHERE status = 'failed'
ORDER BY id
LIMIT 1000000, 50;当翻到第2万页时,这条SQL跑了47秒,锁表导致其他订单更新超时。
后来我们改成基于游标的分页:
第一次:WHERE id > 0 ORDER BY id LIMIT 50
下一次:WHERE id > [上次最后一条id] ORDER BY id LIMIT 50导出时间从18分钟降到42秒,且不再影响线上业务。
一个应用的动态流用 OFFSET 实现无限下拉。用户刷到第500页时,APP卡死。
原因?每次都要跳过前25000条(50条/页 × 500页)。即使有索引,也要遍历这么多行。
解决方案:前端传“最后一条的 created_at + id”,后端用:
WHERE (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 50;完美支持深度分页,且性能稳定。
误区4:“COUNT(1) 比 COUNT(*) 快”
看到有个“性能规范”文档写着:“禁止用 COUNT(*),必须用 COUNT(1)”。
我问为什么?答:“网上都说更快。”
我当场在测试库跑对比:
表:1000万行,InnoDB,无WHERE条件 SELECT COUNT(*):平均 2.1 秒 SELECT COUNT(1):平均 2.1 秒 SELECT COUNT(id)(id为主键):也是 2.1 秒
完全一样!
MySQL 官方文档明确说:InnoDB 对 COUNT(*) 有特殊优化,且不会真的去数每一行(除非必须)。所谓“快慢”,是 MyISAM 时代的旧闻。
为了“加速首页统计”,开发在 Redis 里维护了一个 total_users 计数器,每次注册就 INCR。
结果?某天系统异常重启,部分注册请求成功但没触发 INCR,导致页面显示“10万用户”,实际数据库有10.3万。
后来我们建议: 放弃实时精确计数,改用定时任务每5分钟同步一次,前端展示“约10万用户”——既快又稳。
结论:别为微小的理论差异牺牲正确性和可维护性。
误区5:“SQL慢就是数据库的问题”
用户详情页加载慢。查数据库,单条SQL只要 15ms。但前端一次请求,要查:
一个用户有50个订单?那就是52次数据库查询!
我们改成:
用 JOIN 一次性拉取用户+订单+商品; 或者用 IN 批量查商品:SELECT * FROM products WHERE id IN (1,2,3,...)
页面加载从 3.2 秒降到 320 毫秒。
某个商品详情接口没做缓存,恶意用户用脚本刷不存在的ID(如 ?id=999999999)。
每次请求都穿透到数据库,执行:
SELECT * FROM products WHERE id = 999999999;虽然很快(主键索引),但QPS冲到5万,连接池耗尽,正常用户无法下单。
解决方案:
误区6:盲目相信“经验公式”
有团队看到用户表超过100万行,立刻启动分库分表,花了两个月改造。
结果呢?核心查询都是 WHERE user_id = ?,主键索引毫秒响应。分表后反而要跨节点查,复杂度飙升,故障率上升。
而另一张只有8万行的“配置变更日志表”,因为频繁
SELECT * FROM config_log ORDER BY created_at 且无索引,每次查都全表扫描,拖慢整个管理后台。
有人说:“TEXT字段不能建索引,所以别用。”
其实,InnoDB 支持对 TEXT 前缀建索引:
ALTER TABLE articles ADD INDEX idx_title (title(50));我们有个搜索热词表,keyword 是 TEXT 类型,建了前缀索引后,模糊匹配 LIKE '手机%' 速度从 1.8 秒降到 30 毫秒。
当然,如果是 LIKE '%手机%',那确实索引无效——但那是另一个问题了。
误区7:“用子查询更清晰,性能也更好”
有个报表需求:找出最近30天下单超过5次的用户。
开发小刘写了这样的SQL:
SELECT user_id
FROM orders
WHERE user_id IN (
SELECT user_id
FROM orders
WHERE create_time > NOW() - INTERVAL 30 DAY
GROUP BY user_id
HAVING COUNT(*) > 5
);看起来逻辑清晰?但在MySQL 5.7之前(甚至某些8.0场景),这种 IN (子查询) 会被物化为临时表且无法有效使用索引,导致外层查询对 orders 全表扫描。
实际执行计划显示:外层扫描了2800万行。
可以用JOIN 方式来改写:
SELECT o1.user_id
FROM orders o1
INNER JOIN (
SELECT user_id
FROM orders
WHERE create_time > NOW() - INTERVAL 30 DAY
GROUP BY user_id
HAVING COUNT(*) > 5
) o2 ON o1.user_id = o2.user_id;改写后,执行时间从 22 秒降到 0.4 秒。
子查询≠高效。在MySQL中,相关子查询(correlated subquery)尤其危险,可能被反复执行N次。
误区8:“MyISAM比InnoDB快,读多就该用它”
几年前,一个项目组坚持用 MyISAM 存文章表:“我们99%是读,MyISAM快,还支持全文索引!”
结果某天服务器意外断电,重启后发现:最近两小时的文章全部丢失。因为 MyISAM 没有事务、没有崩溃恢复机制。
更糟的是,他们用 LOCK TABLES 做“伪事务”,高并发时经常死锁,编辑后台卡死。
后来迁移到 InnoDB + 全文索引(MySQL 5.6+已支持),虽然写入略慢,但数据安全了,锁粒度细了,整体可用性反而提升。
现在的建议:除非你明确知道自己在做什么(比如只读日志归档),否则一律用 InnoDB。它的行级锁、MVCC、崩溃恢复,是现代应用的基石。
误区9:“批量操作用一条大SQL更快”
运营要导入100万条优惠券。开发为了“高效”,拼成一条超长SQL:
INSERT INTO coupons (code, user_id, ...) VALUES
('A001', 1001, ...),
('A002', 1002, ...),
...
('A1000000', 1999999, ...);结果?这条SQL在主库执行花了18秒,binlog 写了600MB。从库回放时,单线程SQL_THREAD 被卡住,主从延迟飙升到15分钟,导致用户领券成功但页面显示“未领取”。
正确做法:分批提交,每1000条一个事务:
for batch in chunks(data, 1000):
execute("INSERT INTO coupons VALUES " + batch)
commit()既避免长事务锁表,又防止主从延迟爆炸。
另外,还有一个风险,大事务还会导致:
误区10:“EXPLAIN 看 rows 少就一定快”
有次排查慢SQL,看到 EXPLAIN 结果:
type: ref
key: idx_user_id
rows: 1
Extra: Using where看起来完美?但实际执行要10秒!
深入一看:WHERE 条件里有个 LIKE '%关键词%',虽然走了索引定位到 user_id=123 的那一行,但还要对整行做全文模糊匹配,而那条记录的 content 字段有 20MB!
问题在于EXPLAIN 的 rows 只反映“索引扫描行数”,不反映“每行处理成本”。
后来我们对 content 建了 FULLTEXT 索引;也可以提前过滤掉大字段,只在必要时 SELECT。
其实Using filesort 或 Using temporary 即使 rows 很小,也可能很慢。一定要看 Extra 列!
误区11:ORDER BY 字段有索引就一定快
开发写了个排行榜:
SELECT user_id, score FROM user_rank
WHERE dt= '2025-12-20'
ORDER BY score DESC LIMIT 100;他在 score 上建了索引,但没注意 WHERE 条件是 dt。结果MySQL先用dt过滤出50万行,再对这50万行按score排序——Using filesort,耗时 8 秒。
更高效的做法:建联合索引 (dt, score desc),让过滤+排序一步到位。ORDER BY 能用索引的前提是——索引能覆盖 WHERE + ORDER BY 的完整路径,且方向一致。
误区12:SELECT * 写起来方便,没什么性能差异

一张用户表有 30 个字段,其中 avatar_data 是 MEDIUMBLOB(存头像二进制)。
前端只需要 user_id, name, avatar_url,但 DAO 层写了 SELECT *。
结果:每次查 1000 个用户,返回 200MB 数据,API 网关 OOM,CDN 回源流量飙升。
改成明确字段后,响应体积从 200MB → 120KB,接口成功率从 78% → 99.99%。
另外,SELECT * 还会有如下问题:
误区13:IN 比 OR 快,所以永远用 IN
有个批量查询接口:
SELECT * FROM customer_info WHERE id IN (1,2,3,...,50000);开发听说 “IN 比 OR 快”,就放心用了。
但 MySQL 对 IN 列表过长时,优化器可能放弃索引,转而全表扫描(尤其当列表无序、重复多时)。
实测:5 万个 ID 的 IN 查询,耗时 12 秒;拆成 50 次 1000 个 ID 的查询,总耗时 1.8 秒。
建议:IN 列表控制在 1000 以内(200以内更好),超长用临时表或分批查。
误区14:主键必须是自增 INT
微服务架构下,订单服务部署在多个实例,开发坚持用 AUTO_INCREMENT 主键,结果后期出现了问题:
后来改用 Snowflake 或 UUID(有序版),虽然主键变长,但解耦了服务与数据库,吞吐量翻倍。
TIPS:
误区15:TEXT/BLOB 字段不能放 InnoDB 表里
为“避免影响性能”,开发把文章内容(TEXT)单独建 MyISAM 表。结果出现了如下问题:
其实 InnoDB 从 5.6 起已优化大字段存储(off-page),只要不频繁 UPDATE 大字段,完全可用。如果项目已经引入了MongoDB等数据库,则也可以考虑将此内容存放在其他数据库中。
误区16:用 TRUNCATE 比 DELETE 快,所以清表都用它
运维脚本用 TRUNCATE logs 清日志表。但在某些 MySQL 版本(如 5.6), TRUNCATE在binlog 中记录为DDL,从库可能因权限或结构差异执行失败,导致主从中断。另外,TRUNCATE是DDL操作,如果表特别大,执行此操作时,也会全局影响数据库的性能。
安全做法:用分批删,既能走索引,又保证主从一致,性能也相对稳定。
DELETE FROM logs WHERE
create_time >=... and
create_time < ... 当然,也不是绝对的。 如果表特别大,delete删除会耗费很多时间,且产生大量的binlog,分批DELETE删需要有足够的空间,且控制休眠等,此时就需要考虑在业务低峰期用truncate方式清理。
误区17:开启 query_cache 能提升性能
某老系统开着 query_cache_type=ON。表面看“命中率高”,但高并发下,任何写操作都会导致相关缓存全部失效(Cache Invalidation),引发“缓存雪崩式锁竞争”。
MySQL 8.0 直接移除了 Query Cache,就是因为它在现代多核环境下弊大于利。
因此建议:除非是极低频更新的只读系统,否则关闭 Query Cache。
误区18:VARCHAR(255) 是默认“标准长度”,都用这个类型

开发习惯性写 VARCHAR(255),后来想给 email 字段建唯一索引,结果报错:
Specified key was too long; max key length is 767 bytes因为 utf8mb4 下,255 × 4 = 1020 字节 > 767。
解决方案:改为 VARCHAR(191)(191×4=764)或升级到 MySQL 5.7+ 并开启 innodb_large_prefix=ON。
更深层问题:过长的 VARCHAR 会浪费内存(排序、临时表)、增加行大小,影响页利用率。
误区19:EXPLAIN 显示 “Using index” 就一定高效
SELECT user_id FROM orders WHERE status = 'paid';idx_status_user (status, user_id) 是覆盖索引,EXPLAIN 显示 “Using index”。
但 status = 'paid' 有 120 万行!虽然没回表,但扫描 120 万索引项依然很慢。
优化方式:加时间范围 AND create_time > NOW() - INTERVAL 7 DAY,或业务上分页/聚合。
记住:“Using index” 只说明没回表,不代表扫描行数少。
误区20:MySQL 会自动选择最优索引
一张商品表有以下2个索引:
idx_category (category_id)
idx_price (price)查询:
SELECT category_id ,price FROM products
WHERE category_id = 10 AND price BETWEEN 100 AND 200;MySQL 估算 category_id=10 有 5 万行,price 范围有 8 万行,于是选了 idx_price。
但实际 category_id=10 AND price... 只有 200 行!
优化操作:强制使用联合索引 (category_id, price),或用 ANALYZE TABLE 更新统计信息(优化器依赖统计信息,而统计信息可能过期或不准)
误区21:用 NULL 节省空间
用户表 phone VARCHAR(20) DEFAULT NULL,查询“有手机号的用户”:
SELECT * FROM users WHERE phone != '';结果漏掉所有 phone IS NULL 的记录!正确写法应是 phone IS NOT NULL AND phone != ''。更糟的是,NULL 值在索引中特殊处理,<>可能导致范围查询失效。
建议:能不用 NULL 就不用,用空字符串或默认值代替,逻辑更清晰,索引更可靠。
误区22:批量 UPDATE 用一条 SQL 更快

运营要给 50 万用户发积分:
UPDATE users SET points = points + 10 WHERE status = 'active';结果InnoDB 行锁升级为间隙锁+临键锁,阻塞所有用户登录、下单操作。
推荐的做法应该是:因为当前系统不建议降级隔离级别,因此建议分批更新,每 1000 条提交一次,并加 ORDER BY id LIMIT 1000 避免锁跳跃。
误区23:备份用 mysqldump 就够了
凌晨 2 点跑 mysqldump --all-databases,没加 --single-transaction。
结果MyISAM 表被 LOCK,InnoDB 表虽不锁但一致性差,备份期间 API 超时率飙升。
生产环境备份建议:
误区24:连接池越大越好

应用配置连接池 2000(10个节点),数据库 max_connections=10000。
高峰期并发激增,大量连接 idle 占用内存,MySQL 内存耗尽,开始 swap,整体变慢。
原因:连接数 ≠ 并发能力。每个连接消耗 ~256KB 内存,还占用线程资源。
建议:用 thread_pool(MySQL企业版或percona分支)或中间件(ProxySQL)复用连接。
误区25:utf8 就是真正的 UTF-8
开发用 CHARSET=utf8 存用户昵称。结果用户输入 “👨👩👧👦”(家庭 emoji),MySQL 报错:Incorrect string value。
因为 MySQL 的 utf8 实际是 utf8mb3,最多 3 字节,而 emoji 需要 4 字节。
建议用utf8mb4并确保:
误区26:外键能保证数据一致性,所以要用
订单表加了外键指向用户表,结果每插入一条订单,都要查用户是否存在,高并发下外键检查成为瓶颈,且难以分库。
高并发场景建议:应用层保证一致性(如先查用户是否存在),放弃数据库外键,换取扩展性及性能。
误区27:SHOW PROCESSLIST 能看到所有慢查询
排查性能问题,SHOW PROCESSLIST 看不到慢 SQL,以为就是没有慢查询了。原来那些SQL已经执行完但很慢,而PROCESSLIST 只显示“正在执行”的。
正确做法:开启慢SQL监控slow_query_log,定期用pt-query-digest分析历史慢日志

误区28:GROUP BY 自动排序,所以不用 ORDER BY
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id LIMIT 10;开发按照以往经验以为GROUP BY会按 user_id 排序,但 MySQL 8.0 后默认不再隐式排序!结果每次分页数据顺序不一致,用户看到重复/跳过记录,因此如果需要按照指定字段排序时,必须显式加ORDER BY user_id。
误区29:临时表都是内存表,很快
复杂报表用子查询生成临时表,当临时表超过 tmp_table_size(默认 16MB),MySQL 会自动转成MyISAM磁盘表,引发大量磁盘 IO。
建议:
误区30:MySQL 8.0 新特性越多越好,赶紧升级
某项目从5.7 升级到 8.0,发现部分查询反而变慢了。
原因是MySQL 8.0 默认 sql_mode 更严格,且直方图统计、降序索引等新特性需手动启用,旧索引策略可能不再最优,从而导致原先可能不慢的SQL因为索引使用不当而变慢。
因此,在做数据库升级前务必做如下操作:
误区31:“默认配置够用”
很多人装完MySQL就直接跑生产。结果发现经常出现慢SQL,系统出现卡顿现象,检查参数发现都是默认值,尤其是:
建议至少检查并调整如下参数:
innodb_buffer_pool_size
innodb_buffer_pool_instances
innodb_log_buffer_size
tmp_table_size
max_heap_table_size
max_connections
thread_cache_size
table_open_cache
innodb_io_capacity
innodb_io_capacity_max
innodb_flush_log_at_trx_commit
sync_binlog
innodb_log_file_size
sort_buffer_size
join_buffer_size
read_buffer_size
slow_query_log
server_id
lower_case_table_names
optimizer_switch
time_zone误区32:“ORM框架不用管SQL”
用 Hibernate、MyBatis 的同学常说:“我不管SQL,框架会优化。”结果自动生成的 N+1 查询、SELECT *、无索引字段排序,比手写SQL还烂。
建议:定期抓取生产慢日志,反向推动开发优化 ORM 用法。比如:
结语:
最后说几句掏心窝的话:MySQL 优化,不是“知道多少技巧”,而是知道哪些事情绝对不能做。很多“优化”,其实是过度设计、盲目跟风、懒于验证的结果。
真正负责的DBA经常做的事是:
愿你写的每一行 SQL,都能在深夜安静地跑完,不惊醒任何一个战友。
如果你也曾被某个“常识”坑过,欢迎留言分享,点赞+在看,让更多战友绕开这些雷区!
也麻烦大家关注微信公众号“数据库干货铺”。