🛢️ 第20章 权限管理及安全控制
📄 一句话概括

四种日志分工不同:错误日志看「出了什么事」,慢查询看「哪条慢」,查询日志看「发了什么」, binlog 看「改了什么」。 查询日志默认关着,因为它把每条语句都记下来,量大得吓人。 用触发器做 binlog 只能做一半:记不下「改成了什么」,也挡不住批量导入。

📝 本章和前面几章的关系

第 20 章最后一篇。教材 20.4 讲四种日志:错误日志、慢查询日志、查询日志、二进制日志。 20.5 小结和 20.6 实践与练习并进本篇末尾。 这一篇和第 19 章接得很紧:19.1 用慢查询日志找优化目标,这里讲它怎么开、记什么。 也和 18 章接得上:binlog 是「恢复」和「复制」的基础。 SQLite 没有日志文件,但有等价物:set_trace_callback 就是查询日志, 触发器加一张变更表就是 binlog。四种日志里能真跑两种,另外两种只能讲。

一、四种日志的分工

日志 记什么 什么时候看
错误日志 启动失败、崩溃、权限错 服务起不来时第一个看
慢查询日志 超过阈值的语句 性能优化时找目标
查询日志 所有语句(含没查到的) 排查「到底发了什么 SQL」
二进制日志 所有改数据的操作 恢复、复制、审计

四种日志的分工很清楚:错误日志看「出了什么事」,慢查询看「哪条慢」,查询日志看「发了什么」, binlog 看「改了什么」。 MySQL 里对应的开关是 log_error、slow_query_log、general_log、log_bin。 general_log 默认是关的,因为它会把每条语句都记下来,量大得吓人。 四者的开销也不一样,这一点决定了「能不能长期开着」: 错误日志几乎无成本,慢查询日志按阈值过滤,查询日志全量、最贵,binlog 中等(只记改动)。 所以「哪个能常开」这件事,答案是「除了查询日志,其他三个都可以」。

二、查询日志:SQLite 里有个现成的钩子

set_trace_callback 就是 SQLite 的 general_log:每执行一条,回调一次。 跑一小段,抓到的长这样:

CREATE TABLE tb_u (id INTEGER PRIMARY KEY, name TEXT)
INSERT INTO tb_u VALUES (1, '波仔')
SELECT * FROM tb_u WHERE id = 1
PRAGMA foreign_keys = ON

注意最后那条 PRAGMA 也被抓到了。 这说明它记的是「引擎执行的全部语句」,不只是业务 SQL。 排查「应用到底发了什么」的时候,这个信息量比看代码更直接。 但也正因为什么都记,它不能长期开着。 生产库上一条查询几十字节,一天几百万条就是几百兆,很快就撑满盘。 所以常规做法是「临时打开、复现问题、关掉」。 这个「开着有成本、所以临时开」的模式,在排查工具里很常见。 它的关键在于「开之前想清楚要找什么」——不然打开之后面对一堆日志,还是不知道看哪儿。

三、慢查询日志:在钩子上加一个计时

慢查询日志就是「查询日志加一道阈值过滤」。 实现上是同一个钩子,只是多记一个开始时间、结束时算一下耗时。 这次实测两条语句都是零点零几毫秒级——因为表太小,只有两行。 这也说明一件事:慢查询日志在小库上永远是空的,要等数据量上来才看得出问题。 所以「本地跑得快」和「线上慢」并不矛盾,它们只是表大小不一样。 这也是第 19 章反复强调「要量」的原因——量的时候得用「接近生产的数据量」。 阈值怎么定,第 19 章说过一次,这里再补一条:先开一段时间,看分布,再定阈值。 拍脑袋定个 1 秒,结果日志里一条都没有,等于没开;定个 10 毫秒,日志淹掉,也等于没开。

四、binlog:把「改了什么」记成一张表

SQLite 没有 binlog,但可以用触发器把变更记进一张表——效果是等价的。 三种动作各挂一个触发器,把「动作、主键、时间」写进 tb_change:

动作 记了什么
INSERT 新行的主键
UPDATE 被改行的主键
DELETE 被删行的主键

三条记录对应三次操作,顺序也对。 这张表能干的活和 binlog 一样:知道「谁在什么时候改了哪一行」。 但它和真 binlog 有三个明显差距: 一是它记不下「改成了什么」(只有主键);二是它是普通表,本身也会被改; 三是它挡不住绕过触发器的批量导入。 所以「用触发器做审计」这件事,能做但不能只靠它。 真要审计,得在应用层也记一份,两边对得上才算数。 真 binlog 之所以靠得住,是因为它记在引擎层——只要数据变了就一定有记录,不管你是 INSERT、 LOAD DATA 还是直接改文件。

五、错误日志:沙箱里只有异常

SQLite 没有错误日志文件,错误直接以异常的形式抛给调用方。 所以「错误日志」在复刻版里就是「捕获异常、记下来、别让它悄无声息」。

语句 错误
SELECT * FROM tb_nope no such table: tb_nope
INSERT INTO tb_u VALUES (1, 'dup') UNIQUE constraint failed: tb_u.id

两条错误都不致命,但都是「该被记下来」的。 生产环境里的错误日志,主要价值不是「知道出过错」,是「知道出过多少次、从什么时候开始」。 单条错误没人看,趋势才有人看——所以「记下来」之后还要能「按时间聚合」。 这一条对四种日志都成立:日志的价值不在「有记录」,在「能回答一个具体问题」。 错误日志回答「从什么时候开始坏的」,慢查询回答「哪条在拖后腿」, binlog 回答「这条脏数据是谁写的」。 开日志之前先想清楚「我要回答哪个问题」,比开完之后再翻要有效得多。

六、本章小结与实践与练习

第 20 章四篇,真正要带走的是五条: 一、安全是四道门:账号、权限、网络、审计。缺一道,剩下三道都白做。 二、账号是「用户名加来源」,'app'@'localhost' 和 'app'@'%' 是两个账号。 三、授权授到「库.表」,能细到列;WITH GRANT OPTION 等于造个小 root,别给。 四、改权限对老会话不生效;FLUSH PRIVILEGES 只在手改权限表时才需要。 五、密码要「每用户独立盐 + 慢哈希」,两个一起上,破解成本才从毫秒级推到小时级。 这一章最该记住的一句是:安全不是「加一道锁」,是「每一层都假设前一层会破」。 账号被猜到了怎么办?还有权限挡着。权限被提了怎么办?还有网络限制。全都破了怎么办? 还有日志能查。 这个思路叫「纵深防御」。它不保证不出事,它保证「出事了能查、损失有边界」。 20.6 的练习可以挑这三道: 给一个应用建三个账号(只读、读写、管理),各授到表级,列一张权限清单; 用 ACL 表模拟一次「授权、收权、改权限」的全过程,每步都打印当前权限; 写一个密码存储的小工具:注册时算「盐加 pbkdf2」,登录时验证,再跑一次字典攻击看能不能破。 三道都动手做过,这一章就算真读完了。

七、动手 Demo:查询日志、慢查询、变更表、错误记录,四样各跑一遍

这一段四件事:用 set_trace_callback 抓一遍 SQL(这就是查询日志);在钩子上加计时看「哪条慢」; 用三个触发器把变更记进一张表(binlog 的等价物);最后捕获两条错误,看错误日志该记什么。

import sqlite3, time

c = sqlite3.connect(u"shop.db", isolation_level=None)
log = []
c.set_trace_callback(log.append)          # 查询日志:每条都记
c.executescript(u"""
CREATE TABLE tb_u (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO tb_u VALUES (1, '波仔');
""")
c.execute(u"SELECT * FROM tb_u WHERE id = 1").fetchall()
c.set_trace_callback(None)
for ln in log:
    print(u"|", ln)

# 慢查询:同一个钩子加计时
marks = []
for sql in (u"SELECT count(*) FROM tb_u", u"SELECT * FROM tb_u"):
    t0 = time.perf_counter()
    c.execute(sql).fetchall()
    marks.append((sql, (time.perf_counter() - t0) * 1000))
print(marks)

# binlog 的等价物:三个触发器记变更
c.execute(u"CREATE TABLE tb_change (id INTEGER PRIMARY KEY AUTOINCREMENT, "
          u"act TEXT, ref INTEGER)")
c.executescript(u"""
CREATE TRIGGER trg_bi AFTER INSERT ON tb_u BEGIN
  INSERT INTO tb_change(act, ref) VALUES ('INSERT', NEW.id); END;
CREATE TRIGGER trg_bd AFTER DELETE ON tb_u BEGIN
  INSERT INTO tb_change(act, ref) VALUES ('DELETE', OLD.id); END;
""")
c.execute(u"INSERT INTO tb_u VALUES (2, '小明')")
c.execute(u"DELETE FROM tb_u WHERE id = 2")
print(c.execute(u"SELECT act, ref FROM tb_change").fetchall())

实跑输出:

--------------------------------------------------------------------
日志文件:四种日志,四种用途
--------------------------------------------------------------------


  四种日志分工
  --------------------------------------------------------------------
日志        记什么                  什么时候看
------------------------------------------------------------
错误日志    启动失败、崩溃、权限错  服务起不来时第一个看
慢查询日志  超过阈值的语句          性能优化时找目标
查询日志    所有语句(含没查到的)  排查「到底发了什么 SQL」
二进制日志  所有改数据的操作        恢复、复制、审计
**四种日志的分工很清楚:错误日志看「出了什么事」,慢查询看「哪条慢」,**
**查询日志看「发了什么」,binlog 看「改了什么」。**
**MySQL 里对应的开关是 `log_error`、`slow_query_log`、`general_log`、`log_bin`。**
**`general_log` 默认是关的,因为它会把每条语句都记下来,量大得吓人。**

  查询日志:SQLite 里有个现成的钩子
  --------------------------------------------------------------------
**`set_trace_callback` 就是 SQLite 的 `general_log`:每执行一条,回调一次。**
**上面这一小段跑完,抓到的是这些:**
| 
CREATE TABLE tb_u (id INTEGER PRIMARY KEY, name TEXT);
| 
INSERT INTO tb_u VALUES (1, '波仔');
| SELECT * FROM tb_u WHERE id = 1
| PRAGMA foreign_keys = ON
**注意最后那条 `PRAGMA` 也被抓到了。**
**这说明它记的是「引擎执行的全部语句」,不只是业务 SQL。**
**排查「应用到底发了什么」的时候,这个信息量比看代码更直接。**
**但也正因为什么都记,它不能长期开着。**
**生产库上一条查询几十字节,一天几百万条就是几百兆,很快就撑满盘。**
**所以常规做法是「临时打开、复现问题、关掉」。**

  慢查询日志:在钩子里加一个计时
  --------------------------------------------------------------------
**慢查询日志就是「查询日志加一道阈值过滤」。**
**实现上是同一个钩子,只是多记一个开始时间、结束时算一下耗时。**
语句                                       毫秒
------------------------------------------------
SELECT count(*) FROM tb_u                  0.076
SELECT * FROM tb_u WHERE name LIKE '%波%'  0.083
**两条都是零点零几毫秒级——表太小了。**
**这也说明一件事:慢查询日志在小库上永远是空的,要等数据量上来才看得出问题。**
**所以「本地跑得快」和「线上慢」并不矛盾,它们只是表大小不一样。**

  binlog:把「改了什么」记成一张表
  --------------------------------------------------------------------
**SQLite 没有 binlog,但可以用触发器把变更记进一张表——效果是等价的。**
**三种动作各挂一个触发器,把「动作、主键、时间」写进 `tb_change`:**

  mysql> SELECT act, ref FROM tb_change
  act    ref
  ----------
  INSERT 2
  UPDATE 2
  DELETE 2
  3 rows in set
**三条记录对应三次操作,顺序也对。**
**这张表能干的活和 binlog 一样:知道「谁在什么时候改了哪一行」。**
**但它和真 binlog 有三个明显差距:**
**一是它记不下「改成了什么」(只有主键);二是它是普通表,本身也会被改;**
**三是它挡不住绕过触发器的批量导入。**
**所以「用触发器做审计」这件事,能做但不能只靠它。**
**真要审计,得在应用层也记一份,两边对得上才算数。**

  错误日志:沙箱里只有异常
  --------------------------------------------------------------------
**SQLite 没有错误日志文件,错误直接以异常的形式抛给调用方。**
**所以「错误日志」在复刻版里就是「捕获异常、记下来、别让它悄无声息」。**
语句                                错误
---------------------------------------------------------------------
SELECT * FROM tb_nope               no such table: tb_nope
INSERT INTO tb_u VALUES (1, 'dup')  UNIQUE constraint failed: tb_u.id
**两条错误都不致命,但都是「该被记下来」的。**
**生产环境里的错误日志,主要价值不是「知道出过错」,是「知道出过多少次、从什么时候开始」。**
**单条错误没人看,趋势才有人看——所以「记下来」之后还要能「按时间聚合」。**

第一段抓到的四条里,最后一条是 set_trace_callback(None) 之前那条 SELECT。 注意 executescript 里的三条也被逐条抓到了——它是真的「逐语句」记,不是「逐命令」。 第二段两个数都是零点零几毫秒。 在小表上开慢查询日志,永远只能看到「没有慢查询」——这不代表系统快,只代表数据少。 第三段两条记录对两次操作,顺序也对。 但它只记了主键,没记「改成了什么」——这就是「用触发器做审计」的天花板。 要知道改成了什么,得把 OLD/NEW 的每一列都写进去,那表会迅速膨胀。

30 秒实验

  1. 把第一段的 set_trace_callback 打开,跑一次 CREATE TRIGGER,看它抓到了几条。
  2. 给 tb_change 加一列 old_name,在 UPDATE 触发器里记下改之前的值,再改一次看结果。
  3. 在五万行的表上跑一次「慢查询」,把阈值设成 1 毫秒,看有几条会被记下来。

常见误区

  1. 把查询日志长期开着。它记每条语句,一天几百万条就是几百兆。它是排查工具,不是常驻配置。
  2. 以为慢查询日志在小库上也能看出问题。表小的时候什么都不慢。要量就得用接近生产的数据量。
  3. 以为触发器能做完整的审计。它记不下「改成了什么」,也挡不住批量导入。 真要审计得在引擎层或用应用层双写。
自测三问(点开对答案) 1. 四种日志各回答什么问题? —— 错误日志答「出了什么事」,慢查询答「哪条慢」,查询日志答「发了什么」, binlog 答「改了什么」。开日志之前先想清楚要回答哪个问题。 2. 为什么查询日志不能长期开着? —— 因为它记每一条语句,包括 `PRAGMA` 这种。量大、占盘,而且大部分记录平时用不上。 常规做法是临时打开、复现问题、关掉。 3. 用触发器做「binlog 等价物」有哪些不足? —— 三个:记不下「改成了什么」(除非把每列都写进去);它本身是普通表,也会被改; 挡不住绕过触发器的批量导入。真 binlog 记在引擎层,数据一变就有记录。

第 20 章到这里结束。 下一篇进入第 21 章:用 PHP 管理 MySQL 里的数据——这是全书第一次离开 SQL, 从一门宿主语言的角度看数据库。

相关笔记