感觉 MySQL 的坑实在是有点多,记录一下这个 MySQL 的坑, 也记录一下这个教训吧, 下次在数据库中直接操作一定要多小心
现象回放
现在有两张表, 表结构如下, 无关字段已经省略
- team
+-------------+---------------------+
| Field | Type |
+-------------+---------------------+
| id | int(10) unsigned |
| status | tinyint(3) unsigned |
+-------------+---------------------+- player
+-------------+---------------------+
| Field | Type |
+-------------+---------------------+
| id | int(10) unsigned |
| team_id | int(10) unsigned |
| status | tinyint(3) unsigned |
+-------------+---------------------+容易理解, 这是简单的一对多的关系, 一个足球队 team 里面, 有多个球员 player
现在想取出有 player 的状态为 1 的 team
SELECT * FROM team WHERE id IN (SELECT id FROM (SELECT team_id FROM player WHERE status = 1) AS a);理论上, 这条语句是不能执行的, 注意这里
...... SELECT id FROM (SELECT team_id FROM ......
但是, 不知道为何, 这条语句是可以执行的, 而且等价于
SELECT * FROM team;如果单独把最外层的 IN 里面的 subquery 取出来, MySQL 会报错
SELECT id FROM (SELECT team_id FROM player WHERE status = 1) AS a;ERROR 1054 (42S22): Unknown column 'id' in 'field list'
再试着把 id 改为不存在的字段
SELECT * FROM team WHERE id IN (SELECT not_exist_field FROM (SELECT team_id FROM player WHERE status = 1) AS a);ERROR 1054 (42S22): Unknown column 'not_exist_field' in 'field list'
这样才能如预期的报错
问题排查
先 EXPLAIN 试试呢
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: team
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 2
filtered: 100.00
Extra: NULL
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: player
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 5
filtered: 20.00
Extra: Using where; FirstMatch(team); Using join buffer (Block Nested Loop)
2 rows in set, 2 warnings (0.001 sec)好像看不出来什么问题呢, 不过有 warnings, 看一下呢
SHOW WARNINGS\G*************************** 1. row ***************************
Level: Note
Code: 1276
Message: Field or reference 'test.team.id' of SELECT #2 was resolved in SELECT #1
*************************** 2. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`team`.`id` AS `id`,`test`.`team`.`status` AS `status` from `test`.`team` semi join (`test`.`player`) where (`test`.`player`.`status` = 1)
2 rows in set (0.001 sec)这里出现了一个 semi join, 没有见过呢, 查看一下文档, 大意就是, 比如使用 INNER JOIN 的时候, 会返回匹配次数个结果. 但是我们并不关注匹配的次数, 比如如下语句, 我想取出有球员的 status 为 0 的球队, 可以这样写
SELECT * FROM team INNER JOIN player ON team.id = team_id WHERE player.status = 0;如果一个球队里有多个 status 为 0 的球员, 那么就会出现多个记录, 比如这样
+----+--------+----+---------+--------+
| id | status | id | team_id | status |
+----+--------+----+---------+--------+
| 1 | 0 | 1 | 1 | 0 |
| 1 | 0 | 2 | 1 | 0 |
| 2 | 0 | 5 | 2 | 0 |
| 2 | 0 | 6 | 2 | 0 |
+----+--------+----+---------+--------+这样明显有些冗余的数据了. 当然我们可以用 DISTINCT 什么的再处理一遍, 但是这样效率会比较低. 那么, 就可以用类似的子查询就方便多了
SELECT * FROM team WHERE id IN (SELECT team_id FROM player WHERE status = 0);返回的结果也简洁多了
+----+--------+
| id | status |
+----+--------+
| 1 | 0 |
| 2 | 0 |
+----+--------+当然, 要这样优化还是有很多条件的, 林林总总的, 可以去官方文档查看
看了这么多, 感觉还是和这个问题没什么关系啊, 试着 EXPLAIN 一下正确的语句
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: team
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 2
filtered: 100.00
Extra: NULL
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: player
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 5
filtered: 20.00
Extra: Using where; FirstMatch(team); Using join buffer (Block Nested Loop)
2 rows in set, 1 warning (0.001 sec)再看看 warnings
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`team`.`id` AS `id`,`test`.`team`.`status` AS `status` from `test`.`team` semi join (`test`.`player`) where ((`test`.`player`.`team_id` = `test`.`team`.`id`) and (`test`.`player`.`status` = 1))
1 row in set (0.001 sec)对照着实际运行的语句的 Note, 发现错误的语句缺少了以下这个条件
((
test.player.team_id=test.team.id)
难道就是你! 但是为什么又有
Field or reference 'test.team.id' of SELECT #2 was resolved in SELECT #1
这个问题呢
破案
后来翻了翻文档, 发现这压根不是 MySQL 的 Bug, 而是 SQL 一条相当坑的作用域规则, 子查询里解析不了的列名, 会跑到外层查询去找.
关键还是在那个派生表 a
(SELECT team_id FROM player WHERE status = 1) AS a它只有 team_id 一列, 根本没有 id. 于是外面那句 SELECT id FROM a 里的 id, 在 a 里是找不到的. 按照作用域规则, 内层找不到就往外层找, 而外层的 team 表恰好有 id, 所以这个 id 就被解析成了 team.id. 这也正是那条 Note 想告诉我们的
Field or reference 'test.team.id' of SELECT #2 was resolved in SELECT #1
id 变成 team.id 之后, 子查询就成了「对当前这一行 team, 返回它自己的 team.id, a 里有几行就返回几次」, 整个条件相当于
team.id IN (SELECT team.id FROM a)只要 a 非空, 也就是全表里存在任意一个 status 为 1 的球员, 这个 IN 对每一行 team 都恒为真. 本该起作用的关联条件 player.team_id = team.id 从头到尾就没参与进来, 所以每支球队都被捞了出来, 等价于 SELECT * FROM team. 再对照前面两条 Note 里 semi join 的 where, 一个有 team_id = team.id, 一个没有, 也就说得通了.
回过头看之前那几个奇怪的现象, 也都顺理成章了
- 单独跑
SELECT id FROM a会报错, 是因为没有外层可以借,id真的无处可寻 - 嵌套里把
id换成not_exist_field也报错, 是因为这个名字在a和外层team里都不存在 - 唯独
id不报错, 只是因为它恰好是外层team的列, 被悄悄借走了, 报错被吃掉, 结果却全错
所以并不是 MySQL 抽风, 而是「外层恰好有个同名列」, 把一句本该报错的语句, 变成了能跑但结果完全不对的语句, 属实有点阴险.
教训还是开头那句, 在数据库里直接操作一定要多小心. 具体到这个坑, 一个好习惯是子查询里的列名都带上表名或别名, 写 a.team_id 而不是光秃秃的 id, 一旦写错会立刻报错, 而不会被外层悄悄借走. 另外, 这个规则在 DELETE FROM team WHERE id IN (SELECT id FROM ...) 里最要命, 列名一写错解析成外层的 id, 条件恒真, 整张表就没了.