🛢️ 第3章 使用MySQL图形化管理工具
📄 一句话概括

增删改查四个动作,语法十分钟就能学完,坑全在「影响了几行」上。 UPDATE 和 DELETE 忘写 WHERE,动的是整张表。 查询慢不慢,取决于能不能用上索引;这一点在图形工具的搜索框里最容易踩。

📝 本章和前面几章的关系

上一篇把表的骨架搭好了,这一篇往里装数据。 四个动作对应四条语句,第 5 章到第 11 章会把它们逐条展开。 这一篇只讲「在图形工具里点的时候,背后发生了什么」。 它是全书用得最多的一篇。 后面所有章节的 Demo,九成时间都在写这四条语句的变体。

一、插入:一条、一批、一个表单

插入有三种粒度,性能差得远。

写法 一次往返发几条 典型场景
INSERT INTO 表 VALUES (...) 1 条 手工插几条
INSERT INTO 表 VALUES (...),(...),(...) 1 条语句多个值 批量导入
逐条 execute 循环 每条一次往返 最慢,别在大批量上用

图形工具的「插入」页对应第一种。 phpMyAdmin 还有一个「通过 form 表单插入数据」的功能,那是网页层面的事: 你在网页上填的表单,提交后由 PHP 拼成一条 INSERT。 所以那条 INSERT 里的值是用户输入,这就带出了 [SQL 注入] 的问题。 防的办法不是过滤字符,是参数化查询——第 22 章会专门讲。

二、修改:WHERE 是命门

UPDATE 表 SET 列 = 值; 不带 WHERE,改的是整张表。 而且不报错,只说「影响了 N 行」。 MySQL 的命令行客户端有个开关叫 --safe-updates。 带上它,不带 WHERE 的 UPDATE 会直接被拒绝。 这个开关默认是关的,很多人不知道它存在。

写法 动几行 风险
UPDATE 表 SET 列 = 值 全表 极高,一行写错全表遭殃
UPDATE 表 SET 列 = 值 WHERE 主键 = ? 1 行 低
UPDATE 表 SET 列 = 值 WHERE 条件 命中多少动多少 中,得先 SELECT 数一遍

一条实用习惯:动手改之前,先把 UPDATE 换成 SELECT COUNT(*) 跑一遍。 数出来的行数跟你预期一致,再改。

三、查询:浏览、搜索、分页

图形工具里的三种看法,代价完全不同。

看法 发出的 SQL 能不能用索引
浏览 SELECT * FROM 表 LIMIT 0,30 能(按主键顺序扫)
按主键搜索 WHERE id = ? 能
关键字搜索 WHERE 列 LIKE '%关键字%' 不能,全表扫
翻到第 N 页 LIMIT 20 OFFSET N OFFSET 越大越慢

第三行是最要命的:搜索框默认两头通配,看着方便,实际是全表扫。 表小的时候没感觉,一百万行就是几秒和几十毫秒的差别。 第四行也有讲究:OFFSET 的语义是「先数过前 N 行再扔掉」。 要翻到很后面,正确做法是「记住上一页最后一行的主键,用它当条件」。 而不是一路加大 OFFSET。

四、删除:删的是行,不是空间

DELETE FROM 表 WHERE 条件; 删掉的是行,文件不一定变小。 腾出来的位置会被后面的插入复用。 要真把空间还给操作系统,MySQL 上得 OPTIMIZE TABLE,SQLite 上是 VACUUM。 这两个动作都会重建文件,大表上要跑很久,还得留出等量的空闲磁盘。 删除还有一个隐形代价:索引里对应的项也要删。 索引越多,删一行越慢——这是「加索引换查询」的另一面。

五、动手 Demo:增删改查的四个动作,在一万行上各跑一遍

这一课用 10000 行数据把四个动作各跑一遍。 重点看两件事:影响行数、以及慢在哪。 六段:批量插入、WHERE 决定动几行、三种查法的执行计划、分页、删除。

import sqlite3

c.execute("CREATE TABLE tb_member (id INTEGER PRIMARY KEY,"
          " username TEXT, city TEXT, score INTEGER)")
rows = [(i, "用户%05d" % i, "城市%d" % (i % 20), i % 100)
        for i in range(1, 10001)]
c.executemany("INSERT INTO tb_member VALUES (?,?,?,?)", rows)

# 一条一条 vs 一次一批
for i in range(200):
    c.execute("INSERT INTO tb_member VALUES (?,?,?,?)", (...))
c.executemany("INSERT INTO tb_member VALUES (?,?,?,?)", [...])

# WHERE 决定动几行
c.execute("UPDATE tb_member SET score = score + 10 WHERE city = '城市3'")
print("带 WHERE 改了", c.execute("SELECT changes()").fetchone()[0])
c.execute("UPDATE tb_member SET city = city")
print("不带 WHERE 改了", c.execute("SELECT changes()").fetchone()[0])

# 执行计划:前缀匹配和两头通配差在哪
c.execute("CREATE INDEX i_name ON tb_member (username)")
for sql in ("SELECT * FROM tb_member WHERE username LIKE '用户0123%'",
            "SELECT * FROM tb_member WHERE username LIKE '%0123%'"):
    print(c.execute("EXPLAIN QUERY PLAN " + sql).fetchall()[-1][-1])

# 分页:OFFSET 越大越慢
for off in (0, 5000, 9900):
    c.execute("SELECT * FROM tb_member ORDER BY id "
              "LIMIT 20 OFFSET %d" % off).fetchall()

实跑输出:

--------------------------------------------------------------------
第十二课:增删改查的四个动作,在网页上长什么样
--------------------------------------------------------------------

  **3.4 节讲的是在图形工具里插数据、改数据、查数据、删数据。**
  **四个动作背后就是 INSERT、UPDATE、SELECT、DELETE。**
  **这一课用 10000 行数据把这四个动作各跑一遍,重点看两件事:影响行数、以及慢在哪。**
  **先灌 10000 行。**

  一、插入:一条一条 vs 一次一批
  --------------------------------------------------------------------

  写法              耗时       说明
  -----------------------------------------------------------
  execute 200 次    0.0006 秒  每条一次往返(这里是函数调用)
  executemany 一次  0.0004 秒  一次往返,200 条一起发
  **这是本沙箱的速度,别当基准。**
  **但方向是确定的:行数越多,批量插入的优势越大,因为省的是「往返次数」。**

  二、修改:WHERE 决定动几行
  --------------------------------------------------------------------

  mysql> UPDATE tb_member SET score = score + 10 WHERE city = '城市3'
  **带 WHERE 那条改了 500 行。**

  mysql> UPDATE tb_member SET city = city
  **不带 WHERE 那条「改了」10000 行——虽然值一模一样。**
  **这就是图形工具里「编辑」按钮为什么一定带主键条件。**
  **它要是忘了带,你点一次保存,全表一起变。**

  三、查询:三种看法,三种代价
  --------------------------------------------------------------------

  mysql> CREATE INDEX i_name ON tb_member (username)
  **先给 `username` 建个普通索引,再看三种查法各自的执行计划。**
  SELECT *
  FROM tb_member
  WHERE id = 12345
  SELECT *
  FROM tb_member
  WHERE username LIKE '用户0123%'
  SELECT *
  FROM tb_member
  WHERE username LIKE '%0123%'

  查法                  执行计划                                              耗时
  ---------------------------------------------------------------------------------------
  按主键 id 查          SEARCH tb_member USING INTEGER PRIMARY KEY (rowid=?)  0.000003 秒
  前缀匹配 '用户0123%'  SCAN tb_member                                        0.001170 秒
  两头通配 '%0123%'     SCAN tb_member                                        0.001404 秒
  **注意中间那两行的计划完全一样:都是全表扫。**
  **这说明「前缀匹配能用索引」这句话,在这台沙箱上不成立。**

  四、为什么前缀匹配也没用上索引
  --------------------------------------------------------------------
  **因为 SQLite 的 LIKE 默认不区分大小写,而索引是按字节顺序排的。**
  **比较规则对不上,索引就用不了。**

  mysql> PRAGMA case_sensitive_like = ON

  打开大小写敏感之后  执行计划                                                         耗时
  ------------------------------------------------------------------------------------------------
  前缀匹配            SEARCH tb_member USING INDEX i_name (username>? AND username<?)  0.000021 秒
  两头通配            SCAN tb_member                                                   0.001382 秒
  **第一行变成 `SEARCH ... USING COVERING INDEX i_name` 了,这就是走索引。**
  **第二行还是 `SCAN`:开头的 `%` 让数据库不知道从哪儿开始找。**
  **MySQL 在 `utf8mb4_general_ci` 这类排序规则下,前缀 LIKE 本来就能用索引。**
  **因为索引和比较用的是同一套规则。**
  **两头通配在哪儿都用不上,这一条两边一样。**
  **所以「搜索」功能最常踩的坑是两头通配。**
  **图形工具里的搜索框默认就是两头通配,数据一多就慢。**
  **一万行还看不出来,一百万行就是几秒和几十毫秒的差别。**

  五、分页:越翻到后面越慢
  --------------------------------------------------------------------

  翻到哪                    耗时
  -------------------------------------
  第 1 页(OFFSET 0)       0.000032 秒
  第 251 页(OFFSET 5000)  0.000147 秒
  第 496 页(OFFSET 9900)  0.000261 秒
  **`OFFSET` 的语义是「先数过前 N 行再扔掉」,所以越往后越慢。**
  **一万行上看不出来,是因为它本来就没多少行可数。**

  六、删除:删的是行,不是空间
  --------------------------------------------------------------------

  mysql> DELETE FROM tb_member WHERE score > 90
  **删了 1000 行,从 10000 行变成 9000 行。**
  **但文件不一定变小——删掉的空间会被后面的插入复用。**
  **要真把空间还回去,MySQL 上得 `OPTIMIZE TABLE`,SQLite 上是 `VACUUM`。**

第一段的两个耗时别当基准,看方向:一次一批比一条一条快,省的是「往返次数」。 换成真的 MySQL,往返要走网络,差距会比这里大得多。 第二段是这一节的重点:不带 WHERE 的 UPDATE「改了 10000 行」,而值一模一样。 数据库不会因为「值没变」就不算改动——它照样写了一遍。 第三段那两个执行计划,一开始都是一样的 SCAN。 这不是我写错了,是 SQLite 的 LIKE 默认不区分大小写,索引按字节排,规则对不上。 打开 PRAGMA case_sensitive_like 之后,前缀匹配才变成 SEARCH ... USING INDEX。 MySQL 上前缀 LIKE 本来就能用索引(排序规则一致),两头通配在哪儿都不行。 所以「搜索」这个功能,慢的根因永远是开头那个 %。 第四段分页:第 1 页 0.000032 秒,第 496 页 0.00026 秒,慢了一个数量级。 一万行上看不出疼,但它证明了「越翻越慢」是真的。

30 秒实验

  1. 把 Demo 里那句 UPDATE ... WHERE city = '城市3' 的 WHERE 删掉,再跑一遍。 它会告诉你改了 10000 行,而且一点都不卡。 这就是「误改全表」最可怕的地方:没有报错,没有警告。
  2. 在分页那一段把 LIMIT 20 OFFSET 9900 改成 LIMIT 20 OFFSET 9990,看耗时怎么变。 行数一样是 20,耗时却随 OFFSET 涨。
  3. 试着给 city 也建个索引,再跑一次「搜索 city=城市3」。 它会走索引了,但如果你写的是 LIKE '%城市3%',索引又用不上了。

常见误区

  1. 「UPDATE 不带 WHERE 只是慢一点,不会出事。」 它会把整张表都改掉,而且不报错。 动手之前先把 UPDATE 改成 SELECT COUNT(*) 数一遍,这是最便宜的一道保险。
  2. 「LIKE '%关键字%' 慢是因为数据库不行。」 是因为开头那个 %。 索引按顺序排,开头通配等于「从哪儿开始找都不知道」,只能一行行看。
  3. 「删了一半数据,文件应该小一半。」 不会。删掉的空间留在文件里等着复用。 要真还回去得 OPTIMIZE TABLE 或 VACUUM,这两个动作都会重建整个文件。
自测三问(点开对答案) 1. 不带 WHERE 的 `UPDATE` 会怎样? —— **整张表每一行都被写一遍。** **哪怕值没变,它也照样写,`affected rows` 就是全表行数。** 2. 为什么 `LIKE '%abc%'` 用不上索引? —— **因为不知道从哪个位置开始找。** **索引是按前缀排好序的,开头通配把这个顺序废掉了。** 3. 翻到很后面很慢,正确的改法是什么? —— **别加大 `OFFSET`,改成「记住上一页最后一行的主键,用它当条件」。** **`WHERE id > 上一页最后那个 id ORDER BY id LIMIT 20`,这样每页都是索引定位。**

下一篇讲数据的搬家:怎么把一整个库导出成脚本、怎么导回去, 以及为什么一定要包在一个事务里跑。

相关笔记