Appearance
MySQL 常见误区与细节澄清
很多 MySQL 知识点其实并不难,难的是:概念看起来熟悉,但真正落到表述和使用时,很容易混在一起。
最常见的情况包括:
- 把
count(*)和count(列)说成完全一样 - 把
char和varchar的区别讲成“一个定长一个变长”就结束 - 把
utf8当成真正完整的 UTF-8 - 把行锁理解成“只会锁这一行”
- 把主从复制、读写分离、分库分表讲成几个孤立名词
这篇文章不追求面面俱到,而是专门收口那些高频、易混、又特别容易混淆的小点。
1. 🌟 count(*)、count(1)、count(列) 到底有什么区别
这是 MySQL 里非常经典的一类误区。
1.1 count(*)
count(*) 的含义是:统计结果集总行数,不忽略 null。
它不关心某一列是不是 null,只关心最终有多少行符合条件。
1.2 count(1)
count(1) 的本质也是统计行数。
因为这里的 1 是常量,只要这一行进入结果集,它就会被计数。
所以在大多数语义上,它和 count(*) 都是在数“行”。
1.3 count(列)
count(列) 的含义是:只统计这一列不为 null 的行数。
例如:
sql
select count(email) from user;如果 email 有些行是 null,这些行不会被算进去。
1.4 更稳妥的理解
更稳妥的表述是:
count(*)和count(1)都是统计行数count(列)会忽略null- 真正写业务 SQL 时,优先关注语义清晰,而不是迷信某种写法一定更快
1.5 常见误区
不要把它简单理解成:count(1) 一定比 count(*) 快很多。
这类说法太绝对,现代 MySQL 优化器下通常不应该这样死背。
2. 🌟 char、varchar、text 分别怎么选
这一块最容易出现的误区,不是“完全不知道”,而是:只记住一个定长、一个变长,却没有把场景边界一起记住。
更稳妥的理解是:
char更适合长度稳定、内容较短的字段varchar更适合大多数普通字符串字段text及更大文本类型更适合正文、备注、富文本这类长内容
这里至少要注意 3 个点:
n表示字符上限,不是字节数- 实际占用存储空间还会受到字符集影响
- 长文本字段不适合随手塞进高频查询表的核心列
如果只是想把选型快速记住,可以直接压缩成一句话:固定且短,用 char;普通字符串,优先 varchar;明显偏长的正文类内容,用 text。
如果想系统看字符串类型、TEXT 家族、空间特点和选型表,可以直接看独立专题:
3. 🌟 utf8 和 utf8mb4 不是一回事
这是非常高频的一类误区。
3.1 utf8
MySQL 里的 utf8 历史上并不是真正完整的 UTF-8,而是最多支持 3 字节字符。
这意味着有些 4 字节字符,比如很多 emoji,可能存不进去。
3.2 utf8mb4
utf8mb4 才是更完整的 UTF-8 支持方案。
它能支持 4 字节字符。
3.3 更稳妥的表述
直接看成:MySQL 里如果要更完整地支持 Unicode 字符,通常更推荐 utf8mb4,而不是历史上的 utf8。
3.4 工程实践
如果没有特殊兼容包袱,新的业务系统通常更推荐统一使用:
utf8mb4- 合适的排序规则
4. int(11) 里的 11 不是存储字节数
这是老题,但很容易答错。
int(11) 里的 11 不是说这个字段占 11 字节,也不是说一定能存 11 位数字。
更稳妥的理解是:
int的存储大小是固定的(11)历史上更接近“显示宽度”这个概念
4.1 那这个 11 到底是什么
更贴近实际定义的说法是,int(11) 里的 11 指的是:显示宽度(display width)
它的含义不是“只能存 11 位”,而是 MySQL 在某些展示语境下,期望按多宽来显示这个整数。
但要注意:
- 它不影响
int真正的存储空间 - 它不决定
int的取值范围 - 它也不代表这个字段业务上只能放 11 位数字
例如:
int(4)int(11)
这两者本质上仍然都是 int,存储大小都一样,取值范围也一样。
4.2 为什么很多人会误解成“长度”
因为表结构里看起来像:
sql
age int(11)很多人会下意识把它类比成:
varchar(20)里的20- “这个字段长度是 11”
但这是错误类比。
因为:
varchar(20)的20确实和可存字符长度有关int(11)的11并不控制int能存多少数字
4.3 它什么时候才会看起来“有点作用”
这个概念历史上更多和 ZEROFILL 这类显示行为一起出现。
比如你可能见过类似:
sql
id int(5) zerofill这时查询结果展示时,可能会补零成类似:
text
00042这里的重点是“显示效果”,不是“存储能力”。
4.4 现在还需要关心它吗
大多数现代 MySQL 建模里,可以把这个结论记成:
int的真正存储大小看类型本身,不看(11)(11)不是业务长度设计依据- 新版本里不应该再把它当成一个很重要的建模参数
所以工程上真正该关心的是:
- 用
tinyint、int、bigint哪个更合适 - 它的取值范围是否够用
- 是否有符号 / 无符号
- 是否符合业务语义和索引成本
所以不要把它讲成:int(11) 比 int(4) 能存更多数据。
这类说法是错的。
5. 🌟 in、exists、not in、not exists 怎么讲更稳
5.1 in 和 exists
这两个并不是简单地谁一定更快,而是:
- 要看 SQL 结构
- 要看数据分布
- 要看执行计划
更稳妥的说法是:
in 和 exists 都可以实现存在性判断,具体谁更合适,要结合执行计划和数据特征看,不能脱离场景下绝对结论。
如果放在子查询语境里,可以这样区分:
in更偏“外层这个值,是否属于内层结果集”exists更偏“对当前外层这行,内层能否找到至少一条匹配记录”
例如要查“下过已支付订单的用户”,这两种写法都可以:
sql
select *
from user u
where u.id in (
select o.user_id
from orders o
where o.status = 'PAID'
);sql
select *
from user u
where exists (
select 1
from orders o
where o.user_id = u.id
and o.status = 'PAID'
);它们的语义都成立,但侧重点不同:
in更像集合包含判断exists更像相关存在性判断
从执行直觉上看:
in更容易理解成先得到一批候选值,再判断外层值是否在集合里exists更容易理解成对外层当前这行去做匹配,命中一条后就可以停止继续找
所以常见经验通常是:
- 如果内层子查询相对独立,最终就是想拿一批值做成员判断,
in往往更直观 - 如果语义本身就是“当前这行是否存在匹配记录”,
exists往往更自然 - 如果关联列索引合适,
exists在很多场景下也更容易快速命中后停止
但这仍然不是绝对结论,因为现代 MySQL 优化器可能会把它们改写成接近的执行计划,例如半连接、子查询展开或物化子查询。
所以更稳的做法是看 explain,重点关注:
- 外层表和内层表谁在驱动
- 是否命中了合适索引
- 扫描行数
rows是否明显过大 - 子查询有没有被优化成合理的执行方式
🌟 一句话总结:in 更偏“值是否属于某个集合”,exists 更偏“是否存在匹配记录”;到底谁更合适,最终还是要回到 SQL 结构、索引设计、数据分布和执行计划。`
5.2 not in 的 null 陷阱
这才是真正容易丢分的点。
如果子查询结果里出现了 null,not in 可能得到和你预期不一样的结果。
所以工程上很多时候会更谨慎地用:
not exists- 明确排除
null
6. union 和 union all 的区别
6.1 union
会合并结果并去重。
6.2 union all
会合并结果但不去重。
6.3 更常用的取舍原则
- 如果业务不需要去重,优先考虑
union all - 因为去重本身会增加额外开销
不要把它只说成语法区别,最好顺手补一句性能影响。
7. 🌟 where 和 having 的区别
7.1 where
where 是在分组前过滤原始行。
7.2 having
having 是在分组后过滤聚合结果。
7.3 容易答错的点
不要简单说:having 就是 where 的高级版。
更准确的说法是:
where过滤原始数据having过滤分组结果
8. 🌟 行锁不等于“只锁一行”
这是 MySQL 锁问题里最容易掉坑的地方之一。
很多人一听“行锁”,就以为:
where id = 1 for update 之外的东西完全不受影响。
这个理解太粗了。
8.1 行锁是加在索引上的
InnoDB 的行锁本质上是基于索引实现的。
这意味着:
- 命中合适索引时,锁范围更精准
- 没走索引或索引不合适时,锁范围可能扩大
所以“行锁”并不是一种脱离索引独立存在的神奇能力。
8.2 为什么会出现锁范围扩大
常见原因包括:
- 条件没有命中索引
- 范围查询触发了间隙锁或临键锁
- 优化器选择了你没预期到的执行路径
9. 什么是意向锁,为什么它存在
9.1 核心理解
意向锁不是给业务开发者直接拿来手写控制的,而是 InnoDB 内部为了协调表级锁和行级锁而设计的标记机制。
你可以把它理解成:先告诉别人:我准备在这张表的某些行上加锁。
9.2 它在解决什么问题
如果没有意向锁,数据库在判断表锁和行锁是否冲突时,成本会非常高,因为需要逐行检查。
有了意向锁后,可以更快判断:
- 这张表里有没有行级锁活动
- 当前表锁请求能不能拿到
9.3 不要把它理解成什么
不要把意向锁理解成:意向锁就是业务代码里手动加的一种特殊锁。
它更像存储引擎内部的并发协调机制。
10. 什么是插入意向锁
插入意向锁常出现在范围并发插入的语境里。
你可以把它理解成:事务准备往某个索引区间插入记录时,先表达一个插入意图。
它的价值不是让所有插入互相死等,而是帮助数据库更细粒度地协调并发插入。
理解这个概念时,不需要把实现细节背得太死,更重要的是抓住下面三点:
- 它和普通行锁不是一回事
- 它通常和间隙、范围并发控制相关
- 它是 InnoDB 优化并发插入行为的一部分
11. 🌟 主从复制如果只会说“主写从读”还不够
这一块最容易说偏的地方,是把它理解成一句“主写从读”就结束了。
更稳妥的理解应该补上:
- 复制的基础依赖
binlog - 真正难的是复制延迟、一致性治理和故障切换
- 一旦配合读写分离,就必须明确哪些读能走从库,哪些读必须回主
如果想系统看主从复制、读写分离、监控治理和可视化管理工具,可以直接看独立专题:
12. 🌟 分库分表不是“拆了就结束”
这块更容易出现的误区,是只看到“拆完之后容量上去了”,却忽略了复杂度也一起上来了。
更准确的理解是:
- 它通常是为了解决单表体量、单库写入或水平扩展问题
- 代价是路由、跨分片查询、事务、扩容迁移和运维都会变复杂
- 它不是常规优化动作,而是系统规模继续扩张后的架构选择
如果想系统看拆分方式、挑战和解决方案,可以直接看独立专题:
13. 读写分离不是天然提升一致性
读写分离更偏扩展读能力,而不是让一致性变得更强。
真正要记住的是:
- 它的收益通常是分担读压力、提升整体吞吐
- 它的代价通常是复制延迟、一致性治理和路由复杂度上升
如果要深入看这部分,应该回到独立专题,而不是在误区页里再次展开:
14. select * 为什么常被认为不好
不要只把这个问题理解成“显得不专业”。
更实在的原因包括:
- 可能读取不需要的列,浪费 IO
- 更难走覆盖索引
- 表结构变化后影响更大
- 代码可读性和维护性更差
15. 🌟 深分页为什么容易慢
深分页不是因为 limit 这个语法本身“有毒”,而是因为很多人只看到了:我要第 100001 条到第 100020 条数据。
却没有继续往下想:数据库通常没法直接跳到结果集里的第 100001 条,而是要先找到前面的大量记录,再把它们跳过。
例如:
sql
select id, title, created_at
from article
order by created_at desc, id desc
limit 100000, 20;这个 SQL 的业务语义很直观,就是“跳过前 100000 条,再取 20 条”。
但对数据库来说,真正麻烦的地方在于:
- 它通常仍然要先扫描或定位前面的很多记录
- 扫到之后,大部分记录并不是返回给用户,而是直接丢弃
- 如果排序、回表、临时表也一起出现,代价会继续放大
15.1 为什么业务里会出现深分页
深分页并不罕见,它往往来自下面这些真实场景:
- 后台列表支持“跳到任意页”
- 运营系统需要翻到很靠后的历史数据
- 按页导出大量记录
- 报表页面直接用普通分页方式查大表
所以真正的问题不是“为什么有人会写 limit offset”,而是:当数据量变大后,页码式分页的实现方式和数据库执行成本并不天然匹配。
15.2 它为什么会慢
可以把深分页的成本拆成 4 层来看。
15.2.1 先扫描,再丢弃
这是最核心的一层。
limit 100000, 20 的本质不是“只取 20 条”,而是“先处理前 100020 条,再丢掉前 100000 条”。`
所以 offset 越大:
- 扫描量越大
- 丢弃量越大
- CPU、Buffer Pool 和磁盘 IO 压力越明显
15.2.2 排序成本可能叠加
如果 order by 不能很好利用索引,就可能出现:
- 额外排序
- 临时表
- 更大的中间结果集
这时深分页慢的原因就不只是“跳过很多行”,还会变成:先排很多,再丢很多。
15.2.3 回表成本可能叠加
如果查询是:
sql
select *
from article
order by created_at desc, id desc
limit 100000, 20;并且排序或过滤依赖的是二级索引,而返回列又很多,就可能出现大量回表。
把这条执行路径拆开看:
- 先沿索引找到很多候选记录
- 再回到聚簇索引拿整行
- 最后前面大部分记录又被
offset丢弃
这就很容易变成:查了很多,回了很多,但真正返回给用户的只有最后 20 条。
15.2.4 结果还可能不稳定
如果排序字段不唯一,或者翻页期间有新数据插入、旧数据删除,页码分页还可能出现:
- 重复
- 漏数据
- 前后页结果漂移
所以深分页不只是“慢”,有时还会伴随:翻页体验不稳定。
15.3 哪些情况会让深分页更糟
最常见的放大因素包括:
select *,返回列很多order by没有命中合适索引- 排序字段不稳定,只按非唯一列排序
- 大表上直接支持任意页码跳转
- 一边分页,一边还要做复杂过滤或多表关联
所以工程上要注意的是:深分页慢,往往不是单一原因,而是 offset、大结果集、排序、回表、关联这些因素叠加出来的。
15.4 优化思路不是只有一种
这块不要只背“用游标分页”这一句,更实用的做法是按场景选方案。
15.4.1 第一类:游标分页或 Keyset Pagination
这是大数据量列表里最常见、也最有效的一类优化方式。
核心思路不是“跳到第 N 页”,而是:拿上一页最后一条记录的有序键,继续往后查。
例如:
sql
select id, title, created_at
from article
where (created_at, id) < ('2026-08-13 12:00:00', 9527)
order by created_at desc, id desc
limit 20;它更适合:
- 信息流
- 时间线
- 大列表“下一页”
- 按主键或时间顺序持续翻页
它的优点是:
- 不需要跳过前面大量记录
- 性能更稳定
- 翻页结果更容易保持连续
它的边界是:
- 不适合天然要求“随手跳到第 5000 页”的场景
- 需要稳定排序键
- 最好使用唯一或近似唯一的复合排序键,例如
created_at + id
15.4.2 第二类:基于有序索引的延迟关联
如果业务确实还想保留页码式分页,可以考虑先只查轻量字段或主键,再回表拿详情。
如果只写下面这段:
sql
select a.*
from article a
join (
select id
from article
order by created_at desc, id desc
limit 100000, 20
) t on a.id = t.id
order by a.created_at desc, a.id desc;这条 SQL 可以拆成两步理解:
- 内层子查询先按排序规则找到目标页的
id - 外层再根据这 20 个
id回表拿完整行
这种方式的核心价值是:把“大量扫描阶段”的数据尽量变轻,减少深分页过程中不必要的回表成本。
它更适合:
- 必须兼容页码分页
- 列很多、整行很重
- 排序索引比较明确
但要注意:它只能降低回表代价,不能从根上消除 offset 很大时的扫描和丢弃问题。
15.4.3 第三类:让排序和过滤尽量命中索引
这是基础优化,但非常重要。
如果分页 SQL 同时带有条件过滤和排序,索引设计至少要尽量贴近:
whereorder by- 返回列
真正要争取的是:
- 少扫
- 少排
- 少回表
否则就算最后改成别的分页方式,查询本身也可能还是慢。
15.4.4 第四类:限制产品形态,而不是只盯 SQL
很多深分页问题,最后并不是靠一条神奇 SQL 解决的,而是靠产品和架构一起收口。
常见做法包括:
- 限制最大可翻页数
- 超过一定页数后只允许按条件缩小范围
- 历史数据查询改成按时间区间检索
- 大批量导出走异步任务,而不是页面同步翻页
这类方案的本质是:不要把“任意深度随机翻页”当成数据库天然应该低成本支持的能力。
15.4.5 第五类:数据分层或结果预处理
如果是报表、归档、历史订单、日志检索这类场景,还可以考虑:
- 热冷数据分层
- 汇总表
- 搜索引擎
- 离线导出链路
因为有些需求表面上看是分页问题,实质上是:你正在拿 OLTP 明细表硬扛检索或分析型场景。
15.5 什么时候不该只回答“用游标分页”
这是很容易把问题讲窄的一点。
“游标分页”当然重要,但如果只停在这里,答案还是不完整。
更完整的判断应该是:
- 如果是连续翻页的大列表,优先游标分页
- 如果必须保留页码分页,优先优化索引、减少回表、必要时延迟关联
- 如果业务要查特别靠后的历史页,优先考虑限制交互方式、改成条件检索或异步导出
- 如果本质是分析型需求,考虑更适合的存储和查询链路
15.6 一段更稳的总结
深分页慢,不是因为 limit 语法有问题,而是因为 offset 很大时,数据库通常仍要先扫描、排序、定位甚至回表,再把前面大量结果丢弃。真正的优化思路也不止一种,而是要根据场景组合使用游标分页、有序索引、延迟关联、产品侧限制和数据分层等方案。真正要避免的,不是某个语法,而是:
把“任意深度随机翻页”当成大数据量明细表上的低成本能力。
16. 一段更实用的压缩说明
如果想把这篇文章压缩成一段说明,可以这样概括:
MySQL 里最容易说偏的点,往往集中在一些看起来很小、但细节边界很重要的地方,比如 count(*) 和 count(列) 的区别、char 和 varchar 的选型、utf8 和 utf8mb4 的差异、union 和 union all 的性能影响、where 和 having 的语义边界。并发控制里也很容易把行锁理解得过于简单,实际上 InnoDB 的行锁依赖索引实现,范围查询还会涉及间隙锁、临键锁、意向锁、插入意向锁等机制。再往上走,主从复制、读写分离、分库分表也不能只停留在定义层面,还要把复制延迟、强一致读、跨库查询和分布式事务这些实际问题一起放进来考虑。
17. 总结
如果把这篇文章压缩成最核心的记忆点,可以记下面这几句:
count(*)/count(1)统计行数,count(列)会忽略null- 新系统字符集优先考虑
utf8mb4 varchar是大多数业务字符串字段的默认选择- 行锁依赖索引,锁范围不一定永远只是一行
- 主从复制的重点不是“有从库”,而是“有延迟”
- 分库分表解决容量问题,同时也带来复杂度问题
真正能拉开差距的,不是你会不会背术语,而是:
你能不能把这些小点讲得准确、不绝对、带工程语境。