TIL

Today I Learned。当下学到的小知识点,一条只讲一件事,短到可以随手记下。

Redis缓存

旁路缓存:删缓存失败了怎么办

旁路缓存(Cache-Aside)用人话说:Redis 和数据库互不认识,全靠你的代码两边跑腿。

  • 读:先查缓存,没有再去库里查,顺手放进缓存。
  • 写:只改库,然后把缓存里那份旧的删掉,等下次有人读再重新放。

两种失败的后果不一样:

  • 读库成功、写缓存失败:没关系,缓存还是空的,下次再查库就行。记个日志,别报错。
  • 改库成功、删缓存失败:缓存里留着脏数据,要兜底。

兜底一般分三层:

  1. 当场重试几次。
  2. 还失败就扔进消息队列,或者订阅 binlog(比如 Canal),异步重试直到删掉。
  3. 所有缓存都设过期时间,最坏情况下也会自己失效。

为什么是「删缓存」而不是「更新缓存」?两个并发写可能以相反的顺序更新缓存,留下旧值。删掉、等下次读时再填,问题就小得多。

Redis缓存

Lease 模式:给回填缓存发一张许可证

旁路缓存有个经典竞态:A 查库拿到旧值,还没写进缓存;这时 B 改了库、删了缓存;A 再把旧值写进去。缓存里就是脏的了。

Lease 的做法(来自 Facebook 的论文 Scaling Memcache at Facebook):

  • 缓存未命中时,发一张绑在这个 key 上的短时 token,回填时必须带上它。
  • 中途有人删除这个 key,token 随之作废,带着旧 token 的回填会被拒绝。
  • 同一段时间里只发一张 token,没领到的人原地等一会儿,防止一千个请求同时冲到数据库(缓存击穿)。

打个比方:给去仓库抄账本的人发一张「抄写许可证」。老板改账本、撕掉便签时,顺手把许可证作废;拿着旧数据慢慢走回来的人,验票不过,不准往便签上写。

Redis 没有现成的 lease 命令,可以用一个 lease key 加 Lua 脚本近似实现。

数据库

MVCC 和 WAL 不是一层的东西

两个缩写经常一起出现,但解决的是 ACID 里不同的字母:

MVCCWAL
全称多版本并发控制预写日志
做法改数据时留下旧版本,读事务读自己能看见的那一版先把改动顺序追加到日志并刷盘,数据页稍后再写
解决I(隔离):读写不互相阻塞D(持久):崩溃后重放日志;顺带把随机写变成顺序写
InnoDB 里undo 版本链 + Read Viewredo log

一个管并发时谁能看见什么,一个管崩溃后怎么恢复。

概念各家都有,实现不一样:PostgreSQL 没有 undo 版本链,新旧版本都留在表里,靠 VACUUM 清理;它的快照(xmin / xmax / xip)和 InnoDB 的 Read View 几乎能一一对上。

MySQL

改表的字符集,DEFAULT CHARACTER SET 改不动老列

sql
-- 只改表的默认值:以后新加的列用 utf8mb4,已有的列不动
ALTER TABLE t DEFAULT CHARACTER SET utf8mb4;

-- 把已有列和数据都转过去
ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4;

-- 改单列的类型
ALTER TABLE t MODIFY COLUMN v BIGINT;

后两种要改每一行的数据,只能走 COPY,期间不能写。

有个例外:VARCHAR 扩长度,只要长度前缀的字节数不变,就能 INPLACE。我在 utf8mb4 下试的:

sql
ALTER TABLE d MODIFY s VARCHAR(60), ALGORITHM=INPLACE;  -- 50 → 60:成功
ALTER TABLE d MODIFY s VARCHAR(70), ALGORITHM=INPLACE;  -- 50 → 70:报错

utf8mb4 一个字符最多 4 字节。VARCHAR(63) 以内最多 252 字节,长度前缀 1 字节;到 VARCHAR(64) 就超过 255 字节,前缀要变成 2 字节,行格式变了,只能 COPY。

MySQL

MySQL 8.0.18 的 hash join,救的是没索引的 JOIN

JOIN 本质上是嵌套循环:外层的驱动表每拿一行,就去被驱动表里找匹配。

  • 被驱动表的连接列有索引:每次是一次索引查找,本来就不慢。
  • 没索引:8.0.18 之前只能走 BNL,把一批驱动表的行放进 join buffer,再扫被驱动表两两比较,约 M × N 次。

hash join 分两步:

  1. 构建:把小表按连接列建一张内存哈希表。
  2. 探测:扫一遍大表,每行算哈希去表里找。

两张表各扫一遍,约 M + N。内存放不下时按哈希分片落盘,再一片片处理。

sql
EXPLAIN FORMAT=TREE SELECT * FROM o JOIN m ON m.code = o.member_code;
-- -> Inner hash join (o.member_code = m.code)
--     -> Table scan on o
--     -> Hash
--         -> Table scan on m

版本:8.0.18 只支持内连接等值,8.0.20 起外连接、半连接也用它,BNL 被删掉。只适合等值连接。

顺带一个常被误解的点:「小表驱动大表」比的是过滤后参与连接的行数,不是表的总行数。

MySQL

10 行的表、秒级的 INSTANT 加列,也能把服务堵死

sql
ALTER TABLE config ADD COLUMN remark VARCHAR(64), ALGORITHM=INSTANT;

表只有 10 行,INSTANT 也只改数据字典、不碰数据,结果服务还是开始报超时。

因为改表快不快和拿锁要不要排队是两回事。INSTANT 加列拿 MDL 的过程:

  1. 开头拿 SHARED_UPGRADABLE,不挡读写
  2. 改数据字典,毫秒级
  3. 提交时升级成 EXCLUSIVE,拿到后立刻改完立刻放

只要有一个没提交的事务占着读锁,第 3 步就要排队。它排在队头时,后面所有新查询都排在它后面,和表多大无关。

对比 INPLACE(比如在线加索引):SU → 短暂 X → 降回 SU 长时间执行(期间别人照常读写)→ 提交时再升 X。INSTANT 没有中间那段长时间执行,但头尾要锁的问题一样存在。

MySQL

Read View 怎么判断一个版本能不能看

事务拍快照时,InnoDB 记下四个值:

字段含义
m_ids拍快照时还没提交的读写事务
min_trx_idm_ids 里最小的
max_trx_id下一个要分配的事务 id
creator_trx_id自己

读一行时,从最新版本开始,看它的 trx_id:

  1. 等于 creator_trx_id:自己改的,可见
  2. 小于 min_trx_id:拍快照前就提交了,可见
  3. 大于等于 max_trx_id:拍快照后才开始的事务,不可见
  4. 在中间:查 m_ids,在里面就是当时还没提交,不可见,否则可见

不可见就沿 DB_ROLL_PTR 去 undo 里找上一个版本,直到找到第一个可见的。

RR 整个事务只拍一张快照,RC 每条语句拍一张,可重复读和读已提交的区别就从这里来。

数据库

脏读、不可重复读、幻读,一句话分清

按冲突类型来记:

冲突异常一句话
写 → 读脏读读到别人还没提交的值,对方一回滚,你读到的就不存在了
读 → 写不可重复读我读过 A,别人改了 A 并提交,我再读值变了
写 → 写丢失更新两人都读到 1000、各加 100 写回,最后只有 1100

幻读不在这张表里:同一个条件(如 WHERE age > 20)查两次,第二次多出了别人新插入的行。它和范围有关,单行的读写冲突描述不了。InnoDB 在 RR 下靠快照(普通读)和间隙锁(锁定读)防它。

两个容易混的点:

  • 写读冲突不一定是脏读。对方已经提交了你再读,就是正常读到新值。
  • 脏读在读的那一刻就发生了,和对方后来有没有回滚无关。
MySQL

主键、唯一、聚簇、二级、覆盖索引,不是一种分法

学了一堆索引名词之后,我发现它们根本不在同一层:

角度分类
叶子上存什么聚簇索引:叶子放整行,一张表一棵,一般就是主键。二级索引:叶子放索引列 + 主键,要靠主键回表
值能不能重复主键(唯一且非空)、唯一索引、普通索引
由几列组成单列索引、联合索引(列的顺序就是排序顺序)
这次查询怎么用覆盖索引:要的列叶子上都有,不用回表

所以「唯一索引」和「二级索引」不冲突:有主键的表里,唯一索引就是一棵二级索引。「覆盖索引」也不是一种要单独建的索引,而是某次查询碰巧用得上的一种用法。

工具

通配符和正则,* 和 ? 的意思正好不一样

通配符(shell 的 glob、SQL LIKE 的 % _)和正则看着像,其实是两套规则:

通配符正则
*任意一串字符把前一个重复 0 次或多次
?恰好一个字符前一个可有可无
匹配范围整个字符串默认只要包含就算

几个反直觉的例子:

  • file?.txt 匹配 file1.txt,不匹配 file.txt;正则 colou?r 两个都匹配。
  • 正则 /a*/ 能匹配空串,所以 "xyz" 也算命中。
  • 通配符 a*.log 翻成正则是 ^a.*\.log$:点要转义,两头要锚定。

很多库内部就是把通配符翻译成正则再跑的。

数据库

数据库里的 lock 和 latch 不是一回事

操作系统里说的「锁」(mutex、rwlock),在数据库里叫 latch。数据库说的 lock 是更高一层的东西。

locklatch
谁持有事务线程
保护什么逻辑数据:行、范围、表内存里的结构:B+ 树的页、哈希表、锁表本身
持有多久到事务提交临界区,几微秒
模式共享、排他、意向锁……读、写
死锁怎么办检测出来,回滚一个事务靠固定的加锁顺序避免,没有回滚

InnoDB 的一条 UPDATE 两种都拿:从根往下逐页拿页 latch,到叶子后加行 lock;改完就放页 latch,行 lock 留到 COMMIT。等行锁之前必须先放掉页 latch,否则整页都会被堵住。

这个区分在别处也适用:Go 里保护 map 的 sync.Mutex 是 latch;订单「处理中」的行锁、Redis 分布式锁是 lock。通用的一条规则:拿着短锁时别去等长锁,比如持着 Mutex 发 RPC,或者开着事务去调外部 HTTP。

注意 Java 的 CountDownLatch 是倒数闸门,和这里的 latch 不是一个意思。

数据库

wait-die 和 wound-wait 怎么记

两种都是死锁预防:按事务开始的时间戳分老少,申请锁被挡住时,按规则决定等还是回滚。

名字的读法是「老的去申请时怎么做 - 小的去申请时怎么做」:

老的申请、被小的挡住小的申请、被老的挡住
wait-die等(wait)自己回滚(die)
wound-wait把小的回滚掉(wound)等(wait)

两条共同点:被回滚的永远是年轻的;等待只朝一个方向,所以连不成环。被回滚的事务保留原来的时间戳重启,迟早会变成最老的,不会饿死。

InnoDB 两种都没用,它只做死锁检测(等待图)加超时兜底,发现死锁时挑改动行数少的事务回滚。wound-wait 在分布式数据库里更常见,Google Spanner 用的就是它。

MySQL

EXPLAIN 的 type 列:从 const 到 ALL

type 说的是「用索引怎么定位、最多拿几行」,从好到差:

type含义例子
const主键或唯一索引等值匹配常量,最多一行WHERE id = 1
eq_ref连接时被驱动表按主键/唯一索引匹配,每行最多配一行JOIN m ON m.id = o.member_id 里的 m
ref普通索引等值,可能多行WHERE name = 'zhangsan'
range索引上的一段区间>、BETWEEN、IN、LIKE 'x%'
index把整棵二级索引从头扫到尾覆盖索引但用不上最左前缀
ALL全表扫描没索引、对列用了函数、隐式类型转换

index 和 ALL 都是全扫,index 只是扫的树更小。判断全表扫描就看 type = ALL,再配合 key 为 NULL、rows 接近全表行数。

MySQL数据库

InnoDB 并发控制 = MVCC + 两阶段锁

课上讲并发控制有几条路线:两阶段锁(悲观,等锁)、时间戳和 OCC(乐观,冲突就回滚)、MVCC(多版本,读写不互堵)。InnoDB 是混着用的:

  • 普通 SELECT:走 MVCC,读快照,不加锁。
  • UPDATE、DELETE、FOR UPDATE、FOR SHARE:走两阶段锁,读最新版本并加锁,锁一直留到 COMMIT 或 ROLLBACK 才放,也就是强严格两阶段锁。

正因为普通读不加锁,RR 也不是可串行化,挡不住写偏斜。SERIALIZABLE 把普通 SELECT 也变成 FOR SHARE,才全部走两阶段锁。

几个不守两阶段锁的例外:RC 下 WHERE 不匹配的行判断完就提前放锁;自增锁语句结束就放。

MySQL数据库

写偏斜:RR 挡不住,SERIALIZABLE 靠死锁挡住

规则是至少一个医生值班,现在 alice 和 bob 都在岗。两人同时请假:

sql
-- alice 和 bob 各自执行
BEGIN;
SELECT SUM(on_call) FROM duty;                     -- 都读到 2
UPDATE duty SET on_call = 0 WHERE name = '自己';
COMMIT;

我实际跑了三种做法:

做法结果
RR两人都成功,最后 0 人值班
SERIALIZABLEbob 报死锁 1213 被回滚,最后 1 人值班
RR + SELECT ... FOR UPDATEbob 在读的时候就等,alice 提交后 bob 读到 1

RR 下两人改的是不同的行,行锁不冲突,这就是写偏斜。

SERIALIZABLE 把普通 SELECT 变成 FOR SHARE:两人都拿到对方那行的共享锁,再各自要排他锁,互相等成死锁,InnoDB 回滚一个。它不是让人排队,而是把写偏斜变成死锁再杀掉一个,所以应用要会重试。

实际更推荐第三种:在要做判断的读上直接 FOR UPDATE,第二个人在读的时候就排队,不会死锁。

数据库

用依赖图判断一个调度能不能串行化

「结果等价于某个串行顺序」没法直接检查。实际用的是更保守、但能机械判断的冲突可串行化:

  1. 找出所有冲突对:不同事务、同一个数据、至少一个是写。
  2. 每对画一条边,从先执行的事务指向后执行的。只看先后,不看是读还是写。
  3. 图里没有环,就是冲突可串行化,按拓扑顺序就是等价的串行顺序。有环就不是。

例子:

text
时刻 1  T1 读 A
时刻 2  T2 读 A
时刻 3  T1 写 A
时刻 4  T2 写 A
  • 2 → 3:T2 读、T1 写,画 T2 → T1
  • 3 → 4:两次写,画 T1 → T2

只看 A 就成环了,不可串行化。如果两个事务都是「读余额、加 100、写回」,结果就是 T2 用旧值覆盖了 T1 的修改:丢失更新。

我第一次画的时候两条边方向都画反了。记住一句:箭头永远从先发生的指向后发生的。

数据库

保证原子性的两种做法:日志和影子分页

事务做到一半崩溃了,改了一半的数据怎么还原?

  1. 日志:改之前先记下旧值(undo),回滚或崩溃后据此撤销。
  2. 影子分页:要改的页先复制一份(写时复制),在副本上改;提交时把页表的根指针切过去。崩溃了就丢掉没切过去的副本。

影子分页听起来更干净,但几乎没人用在通用数据库里:

  • 改过的页散落在磁盘各处,变成大量随机写
  • 提交时要刷所有改过的页和页表
  • 旧页要回收,也很难支持多个事务并发写

日志是顺序追加,提交时只要把日志刷盘。

容易混的一点:崩溃后撤销没提交的修改靠 undo,属于原子性;重做已提交的修改靠 redo,属于持久性。

数据库

比可串行化更严的:外部一致性

可串行化只要求等价于某个串行顺序,不管现实时间。

于是可能出现这种情况:你转完账,看到「成功」,打电话让朋友查,朋友查到的还是旧余额。数据库说「我把他的事务排在你前面了」,这也满足可串行化。

外部一致性(也叫严格可串行化)多了一条要求:T1 在现实中已经提交,之后 T2 才开始,那等价顺序里 T2 必须排在 T1 后面。

单机上这很容易:一个时钟、一个锁管理器,提交顺序天然就是真实顺序。

难在分布式:各节点的时钟有偏差,后开始的事务可能拿到更早的时间戳。Google Spanner 的做法是 TrueTime:用 GPS 和原子钟给出「误差不超过 ε」的时间区间,提交前先等过这段误差再宣布成功(commit wait),用一点延迟换来正确。

数据库

Oracle 的 SERIALIZABLE,其实不是可串行化

SQL 标准定义 SERIALIZABLE 的方式是:不出现脏读、不可重复读、幻读这三种现象。它没有要求调度真的可串行化。

Oracle 的 SERIALIZABLE 实际是快照隔离:每个事务读自己的快照,提交时只检查写写冲突。三种现象确实都不会出现,字面上合格。

但快照隔离挡不住写偏斜:两个医生都看到「2 人在岗」,各自请假,改的是不同的行,没有写写冲突,结果 0 人在岗。两种串行顺序都不会出现这个结果。

要挡住它,得追踪每个事务读过什么(读集)。PostgreSQL 9.1 起的 SSI(可串行化快照隔离)做了这件事,开销也更大。

所以不同数据库里,同一个名字 SERIALIZABLE,保证的东西可能不一样。

数据库

可串行化,不保证结果只有一种

我一开始的理解是:串行化就是保证「不管谁先谁后,结果都一样」。这是错的。

A、B 两个账户各 1000。T1:A 转 100 给 B。T2:两个账户各加 6% 利息。

顺序AB总额
T1 → T295411662120
T2 → T196011602120

两种结果不同,但都对。可串行化只要求:并发交错执行的结果,等价于某一种串行顺序。是哪一种,数据库不承诺。

错的是两种串行顺序都给不出的结果。比如给 A 计息时已经扣了 100、给 B 计息时还没加上那 100,总额就变成 2114。

为什么只要「某一种」?事务同时到达时本来就没有先后之分,不指定顺序,数据库才有调度的自由。

这个标准是定义而不是定理:假设每个事务单独跑都能把合法状态变成合法状态,那任何串行顺序都合法,等价于其中一种的交错执行自然也合法。好处是数据库不用懂业务规则就能判断对错。

MySQL

别的数据库默认 RC,为什么 MySQL 默认 RR

Oracle、PostgreSQL、SQL Server 默认都是读已提交(RC),只有 MySQL 默认可重复读(RR)。原因是历史包袱。

早年 binlog 只有 STATEMENT 格式,从库按提交顺序重放 SQL 原文。来看 RC 下会出什么事,表里有 id = 1 和 10:

text
A: DELETE FROM t WHERE id < 10;   -- 不提交
B: INSERT INTO t VALUES (5);      -- RC 不锁间隙,插得进去,B 先提交
A: COMMIT;
  • 主库:A 删除时 5 还不存在,所以 5 留下了。
  • binlog 按提交顺序是「先 INSERT、后 DELETE」,从库重放会把 5 删掉。

主从不一致了。RR 的间隙锁让 B 必须等 A 提交,执行顺序就和提交顺序一致了。

如今 binlog 默认是 ROW 格式(记每行改前改后的样子),这个理由已经不成立,所以不少公司改成 RC + ROW。RR 也不是 RC 的升级版:多了防幻读,代价是更多锁冲突和死锁。

Redis

用 INCR 做一个最简单的限流

INCR 原子地加 1 并返回新值;key 不存在时按 0 算。比「GET 出来加 1 再 SET 回去」安全:两个客户端不会都读到同一个旧值。

按分钟计数的限流:

shell
INCR rl:user1:202610150420     # 返回 1,说明是这一分钟第一次
EXPIRE rl:user1:202610150420 60
INCR rl:user1:202610150420     # 2
INCR rl:user1:202610150420     # 3,超过阈值就拒绝

只在返回 1 时设过期时间,后面的 INCR 不会改 TTL。

几个细节:值不是整数时 INCR 直接报错;类型还是 string;INCRBY、DECR、DECRBY 同理。

这种写法有个小缺口:INCR 成功、EXPIRE 之前崩了,key 就不会过期。要严格的话,把两步写进一段 Lua。

Redis

Redis 里一条普通的 SET,会清掉原来的过期时间

shell
SET k v EX 100
SET k v2
TTL k            # -1:过期时间没了,这个 key 永不过期

不带 EX 的 SET 是整个覆盖,连 TTL 一起覆盖掉。其他修改值的命令不会动 TTL:

shell
SET n 5 EX 100
INCR n
TTL n            # 还是 100

想覆盖值又保留过期时间,用 SET k v KEEPTTL(6.0 起)。

缓存刷新时不小心写成普通 SET,key 就再也不会过期了,内存慢慢被占满。

Redis

Redis 加锁用 SET NX PX,别用 SETNX + EXPIRE

shell
SET lock:order:42 token-A NX PX 30000   # OK:拿到锁
SET lock:order:42 token-B NX PX 30000   # (nil):已被占,不报错

NX 是不存在才写,PX / EX 是过期时间(毫秒 / 秒),一条命令原子完成。

老写法的问题:

shell
SETNX lock:order:42 token-A   # 返回 1 / 0,不能带过期时间
EXPIRE lock:order:42 30       # 要再补一条

两条命令之间进程崩了,这把锁就永远不会过期。

value 别写死成 1,要写一个能标明「是谁加的」的随机 token,释放时要用到它。

Redis

ZREVRANGE 0 2 返回的是 3 个

排行榜用有序集合:

shell
ZADD rank:game1 100 alice 250 bob 180 carol
ZINCRBY rank:game1 90 alice          # 190,不用先读出旧分数

查前 2 名,我第一反应写成 0 2:

shell
ZREVRANGE rank:game1 0 2 WITHSCORES  # 返回 3 个!
ZREVRANGE rank:game1 0 1 WITHSCORES  # bob 250, alice 190

start 和 stop 都是闭区间,而且从 0 开始。

查名次也一样:

shell
ZREVRANK rank:game1 alice            # 1,表示第 2 名,展示时要 +1
ZREVRANK rank:game1 nobody           # (nil),不在榜上

Redis 6.2 起推荐用 ZRANGE key 0 1 REV WITHSCORES 代替 ZREVRANGE,老版本没有这个写法。

数据库

N+1 查询:每条都很快,加起来很慢

先 1 条查出订单列表,再对每个订单各查 1 次用户,一共 1 + N 条。每条都快,所以慢查询日志里抓不到,但网络往返和解析开销叠起来很可观。

它不只是 ORM 的问题,根子是在循环里查询:手写 SQL 的 DAO 被循环调用、微服务里逐个调 RPC、GraphQL 的 resolver,都会这样。ORM 只是让它更隐蔽,order.user 看起来像读一个属性。

改法是批量取:

sql
SELECT * FROM user WHERE id IN (1, 2, 3, ...);

ORM 一般有三类办法:

  • 显式预加载:Django select_related / prefetch_related、Rails includes、SQLAlchemy selectinload、GORM Preload。
  • 让懒加载直接报错:Rails strict_loading、Laravel preventLazyLoading、SQLAlchemy lazy="raise"。开发环境打开,N+1 一出现就报错。
  • 设计上不提供隐式懒加载:比如 Prisma 必须显式写 include。

Redis 同理:循环里一个个 GET,改成 MGET 或 pipeline。

MySQL

FOR UPDATE 读到了新值,下一条 SELECT 却又读回旧值

RR 下,B 的快照里 k = 1,之后 C 提交把 k 改成 5:

sql
-- B
SELECT k FROM t WHERE id = 1 FOR UPDATE;   -- 5
SELECT k FROM t WHERE id = 1;              -- 1

FOR UPDATE / FOR SHARE 是当前读:读最新已提交的版本并加锁。但它们不产生新版本,快照也不会刷新,所以紧跟着的普通 SELECT 照旧按快照读到 1。

加锁只是挡住别人再改,不改变可见性。想在事务里一直拿最新值,后续读也要加 FOR UPDATE。

两种锁定读的区别:

别人 FOR SHARE别人 FOR UPDATE / 改行别人普通 SELECT
我 FOR SHARE可以要等可以
我 FOR UPDATE要等要等可以
MySQL

MVCC 的版本链存在哪

每一行在聚簇索引的叶子上只存最新版本,外加两个隐藏列:

  • DB_TRX_ID(6 字节):最后改它的事务 id
  • DB_ROLL_PTR(7 字节):指向 undo log 里上一个版本

旧版本不是整行拷贝,而是 undo log 里的记录,只记被改掉的列。读旧版本时,从最新行出发,倒着一条条套 undo,拼出当时的样子。

所以版本链是一条从新到旧的单向链表:最新的在 B+ 树叶子上,旧的在 undo 表空间里。

几个推论:

  • 没人再需要的旧版本,由后台 purge 线程清理。
  • 长事务会让 undo 越积越多:它的快照很旧,之后产生的 undo 都不能删。
  • 二级索引记录上没有 trx_id,只有页级别的 PAGE_MAX_TRX_ID,判断不了可见性时就要回表。
MySQL

同一个事务、同一张快照,为什么读到的值变了

RR 下,事务 B 的快照里 k = 1。之后 C 提交,把 k 改成 2。

sql
-- B
SELECT k FROM t WHERE id = 1;           -- 1,读快照
UPDATE t SET k = k + 1 WHERE id = 1;
SELECT k FROM t WHERE id = 1;           -- 3,不是 2

快照没变,变的是这一行多了一个新版本:

  1. UPDATE 是当前读:它读最新已提交的值 2,而不是快照里的 1。否则就会把 C 的修改盖掉(丢失更新)。
  2. 2 + 1 = 3,这个新版本是 B 自己写的。可见性规则里,自己改的永远可见,所以后面的普通 SELECT 读到 3。

两个推论,都实测过:

  • 只有真被 B 改到的行会这样。没改到的其他行,照样读快照里的旧值。
  • UPDATE 如果什么都没改(新值和旧值相同),InnoDB 不写新版本,后面读到的仍是快照。
MySQL

MySQL 表名一律用小写最稳

SQL 关键字大小写都能跑,惯例是关键字大写、表名列名小写 snake_case。

但表名的大小写和操作系统有关,由 lower_case_table_names 决定:

系统默认值表名区分大小写
Linux0区分
macOS2不区分
Windows1不区分

在 Mac 上写 SELECT * FROM User 能跑,部署到 Linux 上就报找不到 user 表。这个值只能在初始化数据目录时设置,之后改不了。

最省事的做法:表名一律小写。

Redis

Redis 说的 binary safe 是什么意思

Redis 把值当成一串带长度的原始字节,不解释内容,也不认 \0 结尾。

C 语言的字符串遇到 \0 就截断。Redis 自己的字符串结构(SDS)带着长度字段,通信协议也是先发长度再发内容,所以图片、protobuf、任意编码的文本都能原样存取。

shell
printf 'a\0b' | redis-cli -x SET bin
STRLEN bin       # 3

printf '你好' | redis-cli -x SET cn
STRLEN cn        # 6,按 UTF-8 字节数,不是字符数

代价是它不懂字符集:STRLEN 数的是字节,比较也按字节。类比 MySQL,更像 VARBINARY,而不是带排序规则的 VARCHAR。

Redis

JSON.SET 的字符串为什么要写两层引号

shell
JSON.SET product:1 $.name '"Striped Bowtie"'

两层引号各管各的:

  • 外层单引号给 shell / redis-cli:只是让空格不把参数拆开,发到服务器前就被剥掉了。
  • 内层双引号给 JSON:服务器收到的是 "Striped Bowtie" 这一串字节。

能不能省成一层?不能。Redis 的参数只有字节、没有类型,JSON.SET 规定这个参数是一段 JSON 文本。不加内层引号,服务器分不清 42、true、null 是数字、布尔、空值还是字符串:

shell
JSON.SET p $.n '42'      # 数字 42
JSON.SET p $.s '"42"'    # 字符串 "42"

如果规定「解析失败就当字符串」,写坏的 JSON(比如 {"a":1)会被悄悄存成一个字符串,更难排查。

MySQL 的 JSON_SET 只要一层引号,是因为 SQL 字面量本身就带类型。

MySQL工具

在 M 系列 Mac 上跑 MySQL 5.7

想在本地装一个和生产一致的老版本 MySQL,Homebrew 已经没有 5.7 了,8.0 也只给最新的小版本。用 Docker 最省事:

yaml
services:
  mysql57:
    image: mysql:5.7.44
    platform: linux/amd64   # 5.7 没有 arm64 镜像,靠模拟运行
    ports: ["3357:3306"]
    environment:
      MYSQL_ROOT_PASSWORD: root
  mysql80:
    image: mysql:8.0.20
    platform: linux/amd64
    ports: ["3380:3306"]
    environment:
      MYSQL_ROOT_PASSWORD: root

端口错开,用容器自带的客户端连就不会有版本不匹配的问题:

shell
docker compose exec mysql57 mysql -uroot -proot

比版本号更重要的是配置:隔离级别、sql_mode、binlog_format、lower_case_table_names 跟生产对齐,实验结果才可信。

MySQL

MDL 堵表时,用 metadata_locks 找出元凶

场景:A 开着事务不提交,B 的 ALTER 在等,C 这些查询排在 B 后面。

只看 SHOW PROCESSLIST 找不到 A:它的 Command 是 Sleep、Info 是空的,看起来就是个空闲连接。要查 MDL 的持有情况:

sql
SELECT m.LOCK_TYPE, m.LOCK_STATUS, t.PROCESSLIST_ID, t.PROCESSLIST_COMMAND
FROM performance_schema.metadata_locks m
JOIN performance_schema.threads t ON t.THREAD_ID = m.OWNER_THREAD_ID
WHERE m.OBJECT_SCHEMA = 'lab' AND m.OBJECT_NAME = 'cfg';

我本地复现时的输出(A 是一个开了事务、读完就闲着的连接):

text
LOCK_TYPE          LOCK_STATUS  ID  COMMAND
SHARED_READ        GRANTED      41  Sleep    ← A:拿着读锁
SHARED_UPGRADABLE  GRANTED      42  Query    ← B:ALTER 先拿 SU
EXCLUSIVE          PENDING      42  Query    ← B:在等写锁
SHARED_READ        PENDING      43  Query    ← C:排在 B 后面

认法:同时持有 SU、又在等 EXCLUSIVE 的是 ALTER;GRANTED 的读锁、线程在 Sleep 的是元凶。

5.7 要先打开 MDL 的记录开关(setup_instruments 里的 wait/lock/metadata/sql/mdl),开之前拿的锁记不到;8.0 起默认开。

MySQL

MDL 堵表应急:先 KILL 那条 ALTER

找到了元凶 A(不提交的事务)和 B(排在队头的 ALTER),先杀谁?

先杀 B。 队头的写锁请求一没,排队的查询立刻拿到读锁(和 A 的读锁兼容),马上止血。我试的时候,KILL B 之后被堵的查询一秒内就返回了。

杀 A 也能解堵,但会回滚别人的业务事务,而且 B 拿到写锁后还会继续跑一会儿。先止血,再回头查 A 为什么不提交。

下次改表前做两件事:

sql
-- 1. 看有没有长事务
SELECT * FROM information_schema.innodb_trx;

-- 2. 在同一个会话里给 ALTER 设个等锁上限
SET SESSION lock_wait_timeout = 5;
ALTER TABLE cfg ADD COLUMN remark VARCHAR(64);

lock_wait_timeout 管的是等 MDL,默认 31536000 秒,也就是一年,所以 ALTER 能一直等下去。设成几秒之后,拿不到锁就报 1205 放弃,过会儿重试,不会一直堵着后面的查询。

别和 innodb_lock_wait_timeout 搞混:那个管等行锁,默认 50 秒。

MySQL

没写 BEGIN 的一条 UPDATE,也是一笔事务

教材讲行锁时总写 BEGIN ... COMMIT,我一度以为不写就没有事务。

其实默认 autocommit = 1,每条语句自己就是一笔事务:执行完自动提交,行锁跟着放掉。教材写 BEGIN,只是为了把事务撑开,让你看得到锁还在。

反过来也要小心:一旦在事务里,锁就要等到 COMMIT / ROLLBACK 才放,不是语句执行完就放。

MySQL

ALTER TABLE 会悄悄提交你的事务

sql
BEGIN;
UPDATE d SET v = 100 WHERE id = 1;
ALTER TABLE d ADD COLUMN extra INT;
ROLLBACK;

SELECT v FROM d WHERE id = 1;   -- 100,UPDATE 没被回滚

DDL 执行前会先隐式提交当前事务,执行完自己也会提交。所以 ROLLBACK 时已经没有可回滚的东西了。

MySQL 的 DDL 不能放在事务里回滚。改表结构前,先把手上的事务处理干净。

MySQL

DESCRIBE 只给联合索引的第一列标 Key

加了联合索引 cb(c, b),DESCRIBE geek 里 b 那一行的 Key 却是空的,我还以为索引没建上:

text
Field  Type  Null  Key
a      int   NO    PRI
b      int   NO    PRI     ← 这是主键,不是 cb
c      int   YES   MUL
d      int   YES

Key 列只有 PRI / UNI / MUL 三种,而且只标在索引的第一列上。看索引要用:

sql
SHOW INDEX FROM geek;
-- 或 SHOW CREATE TABLE geek;

别和 EXPLAIN 的 key 列搞混:那一列是这条查询实际选中的索引名。

MySQL

Using index condition 不代表真的少回表了

索引下推(ICP,5.6 起)的意思是:在二级索引的叶子上,先用索引里已有的列判断一遍,不满足的就不回表。

但 EXPLAIN 里看到 Using index condition,不等于真省了回表。用 (name, age) 索引、10 万行数据试了两条:

sql
-- 1. name 和 age 都用来定扫描起止
SELECT * FROM tuser WHERE name = '张1' AND age > 30;
-- 2. 只有 name 能定起止,age 只能过滤
SELECT * FROM tuser WHERE name LIKE '张1%' AND age = 10;

分别开关 optimizer_switch='index_condition_pushdown=on/off',看 InnoDB 交给 Server 的行数:

查询ICP 开ICP 关
166
237522213

第 1 条扫到的每一行本来就满足条件,下推的判断次次为真,一行都没省。第 2 条里 age 定不了范围,下推才真正在叶子上丢掉了 2 万多行。

另外,不在这棵索引里的列(比如 gender)推不下去,只能回表后再判断。

MySQL

INPLACE 改表时一直有人在写,它怎么收尾

INPLACE 重建表的大部分时间里,别人照常读写。这期间的写入先记进 row log。

我原本担心:写入一直不停,row log 永远补不完,ALTER 岂不是结束不了?

它不等 row log 变空:

  1. 先在不挡写的状态下,把 row log 追到当前末尾。
  2. 拿 MDL 写锁,挡住新的写入。
  3. 剩下的尾巴是有限的,补完就把新表换上去。

如果写入速度比补的速度还快,row log 会一直变大,超过 innodb_online_alter_log_max_size 就报错回滚,ALTER 失败。所以大表改结构,最好挑写入少的时段。

MySQL

INSTANT 加列为什么不用改老数据

INSTANT 只改数据字典,原理是给行加了版本号:

  • 字典里记下每列是第几版加的、默认值是多少。
  • 每行的行头记着自己是按第几版写的。
  • 读老行时,按它的版本把后来加的列补上默认值。

我一开始以为这是「空间换时间」,其实更准确是推迟:逐行改格式的活被推到了以后。读老行时顺手翻译,更新这一行时顺手按新格式重写,下次重建表时一次做完。加列反而更省空间,因为老行不存默认值。

版本限制要注意:8.0.12 到 8.0.28 的 INSTANT 只能把列加在表的最后;任意位置加列、INSTANT 删列要 8.0.29 起。生产库版本较老时先确认一下。

MySQL

key_len 里的 131 是怎么算出来的

utf8mb4 的 VARCHAR(32)、允许 NULL,在 EXPLAIN 的 key_len 里是 131:

text
32 × 4  = 128   utf8mb4 一个字符最多 4 字节,按列定义的上限算
      + 2       长度前缀:VARCHAR 有长有短,索引键里记下实际多少字节,比较时才知道在哪结束
      + 1       NULL 标记:这列允许 NULL
      = 131

允许 NULL 的 INT 没有长度前缀,是 4 + 1 = 5。所以 (name, age) 两列都用上时 key_len 是 136。

key_len 表示「定位这段扫描用掉的索引前缀」,只用来过滤的列不算。不想心算,可以直接看列名:

sql
EXPLAIN FORMAT=JSON SELECT ...;
-- "used_key_parts": ["name", "age"]

新版本 MySQL 的 JSON 格式默认是第 2 版,没有这个字段,要先 SET explain_json_format_version = 1。

MySQL

一条 ALTER 在等锁,为什么后面所有 SELECT 都卡住了

MDL(元数据锁)是 MySQL 自动加在表上的锁:增删改查拿读锁,改表结构拿写锁。读锁之间能共存,写锁要独占,而且都要到事务结束才放。

复现了一下堵表现场:

text
A:BEGIN; SELECT * FROM cfg;   -- 拿到 MDL 读锁,不提交
B:ALTER TABLE cfg ADD COLUMN ...;   -- 要写锁,等 A
C:SELECT COUNT(*) FROM cfg;   -- 普通查询,也在等

C 明明和 A 一样只要读锁,为什么也等?因为 MDL 是排队的:在等的写锁请求优先,后来的读锁排在它后面。

于是 B 等 A,所有新查询等 B,整张表看起来被锁死。线上表现就是:一条 ALTER 卡住,接着连接池被占满,服务报超时。

MDL 和行锁是两套独立的锁:MDL 在 Server 层锁表结构,行锁在 InnoDB 锁具体的行。增删改查之间只在行锁上冲突,ALTER 和它们只在 MDL 上冲突。

MySQL

COPY、INPLACE、INSTANT:ALTER 期间别人还能干什么

算法怎么做期间别人能
COPY拷一张新表,再换过来只能读
INPLACE在引擎内部重建,或只建索引读写
INSTANT(8.0.12 起)只改数据字典,老数据不动读写,秒级完成

可以在语句里指定算法,做不到时 MySQL 会直接报错,而不是悄悄降级:

sql
ALTER TABLE d ADD INDEX iv(v), ALGORITHM=INSTANT;
-- ERROR: ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.

ALTER TABLE d MODIFY v BIGINT, ALGORITHM=INPLACE;
-- ERROR: Cannot change column type INPLACE. Try ALGORITHM=COPY.

所以在线上改表时,养成显式写上 ALGORITHM 的习惯:不支持就当场报错,不会意外变成锁表的 COPY。

大致规律:只改定义的(加列、改默认值)能 INSTANT;要动数据的(建索引、改主键、改可空、重建表)走 INPLACE;改列类型、改字符集至今只能 COPY。

MySQL

有索引不用,优化器不是在偷懒

建了 cb(c, b),WHERE c = 1 ORDER BY b 还是 Using filesort:

sql
EXPLAIN SELECT * FROM geek WHERE c = 1 ORDER BY b;
-- key: c    Extra: Using filesort

EXPLAIN SELECT * FROM geek FORCE INDEX (cb) WHERE c = 1 ORDER BY b;
-- key: cb   Extra: NULL

优化器按成本估算:表只有几行、全在一页里,读一页再在内存里排一下,比走 cb 再逐行回表还便宜。数据多了排序变贵,它自然会选 cb。

FORCE INDEX 很适合用来验证「是不是选错了索引」,但别随手写进业务 SQL,数据分布一变它就可能变成负优化。

顺带:Using filesort 只是说没借上索引的顺序、额外排了一次序,数据量小时在内存里排,不一定真用了文件。

MySQL

在 InnoDB 上用读未提交,什么也得不到

读未提交(RU)的卖点是「读不被写挡住」。但 InnoDB 的普通 SELECT 走 MVCC 读快照,本来就不会被写挡住。所以在 InnoDB 上选 RU,只额外换来了脏读。

SQL Server 是个例外:它默认的 RC 是靠锁实现的,读会被写挡住,所以 WITH (NOLOCK)(等于 RU)在那边很常见。

同一个隔离级别名字,在不同数据库里的实际代价可能完全不同。

MySQL

除了 UPDATE 和 FOR UPDATE,还有谁会拿行锁

  • DELETE:和 UPDATE 一样,扫到的行加排他锁。
  • INSERT:新行隐式加排他锁,插入前还要拿插入意向锁;撞了唯一键,会在已有的那行上加共享锁。
  • INSERT … ON DUPLICATE KEY UPDATE / REPLACE:撞键的那行加排他锁。
  • INSERT … SELECT、CREATE TABLE … SELECT:源表扫到的行加共享锁。
  • 多表 UPDATE / DELETE:只读不改的那张表,也加共享锁。
  • 外键检查:在父表或子表的行上加共享锁。
  • SERIALIZABLE 下的普通 SELECT:自动变成 FOR SHARE。

最容易忽略的是 INSERT ... SELECT:一条看起来只是「复制数据」的语句,会把源表扫过的行都锁住。

MySQL

BEGIN 之后,快照并没有马上建好

RR 隔离级别下做个实验:事务 B 开始后、第一次读之前,让 C 把 k 从 1 改成 7 并提交。

sql
-- B
BEGIN;
-- (这时 C 提交了 k = 7)
SELECT k FROM t WHERE id = 1;   -- 7

-- 换成这个
START TRANSACTION WITH CONSISTENT SNAPSHOT;
-- (这时 C 提交了 k = 7)
SELECT k FROM t WHERE id = 1;   -- 1

BEGIN 只是标记事务开始,执行到第一条普通 SELECT 时才建快照。WITH CONSISTENT SNAPSHOT 当场就建(只在 RR 下有意义)。

写业务代码一般不用关心这个,用驱动提供的 BeginTx 就行。但读教材里的时序例子时,这一点决定了结果。

MySQL

先查余额再扣钱,换什么隔离级别都不安全

sql
SELECT balance FROM acct WHERE id = 1;          -- 够 100
UPDATE acct SET balance = balance - 100 WHERE id = 1;

两步之间,别人可能已经扣过了。调高隔离级别解决不了这个问题。两种正确写法:

sql
-- 1. 一条带条件的 UPDATE,看影响行数是不是 1
UPDATE acct SET balance = balance - 100
WHERE id = 1 AND balance >= 100;

-- 2. 先锁住再判断
BEGIN;
SELECT balance FROM acct WHERE id = 1 FOR UPDATE;
-- 判断够不够,再 UPDATE
COMMIT;

更一般的原则:隔离级别是整个库的配置,不按业务调。业务正确性靠这些:

  • 防重复:唯一索引
  • 防并发改错:带条件的 UPDATE,或者先锁父行
MySQL

EXPLAIN 的输出变成树了?切回表格用 FORMAT=TRADITIONAL

我本机装的新版 MySQL,explain_format 默认是 TREE,熟悉的表格不见了(8.0 默认仍是表格):

text
-> Filter: (tuser.age = 10)  (cost=9846 rows=9774)
    -> Table scan on tuser  (cost=9846 rows=97741)

想看传统表格:

sql
EXPLAIN FORMAT=TRADITIONAL SELECT ...;            -- 单条
SET SESSION explain_format = 'TRADITIONAL';        -- 本连接

几种格式怎么选:

  • TRADITIONAL:一表一行,看 type / key / key_len / rows / Extra,教程和文章大多用它。
  • TREE:看执行顺序。从缩进最深的那行读起,上一层向它要一行、加工一行。
  • JSON:信息最全,给工具用。
  • EXPLAIN ANALYZE:真的执行一遍,给出实际行数和耗时。前三种都不执行,cost 和 rows 只是估算,对不上时用它。
MySQL

联合索引里,范围条件为什么要放最后

联合索引 (name, age) 的叶子先按 name 排,name 相同的再按 age 排。所以 age 只在 name 完全相同的那一小组里有序。

sql
-- 两列都能定扫描起止:name 固定后,age 是连续的一段
WHERE name = '张三' AND age > 10

-- 只有 name 能定起止:'张%' 本身是范围,这段里 name 各不相同,age 来回跳
WHERE name LIKE '张%' AND age > 10

-- 一列都用不上:age 在整条叶子链上不连续
WHERE age = 10

规律就一句:某一列用了范围,它后面的列只能逐条过滤,没法再缩小扫描范围。所以设计联合索引时,等值列放前面,范围列放最后。

B+ 树本身可以这样想:上面几层是目录,负责从根跳到起点;最底层的叶子页串成双向链表,从起点顺着往后读。下楼用树,翻范围用链。

MySQL

InnoDB 的二级索引里,藏着主键

我一直以为二级索引的叶子上只存索引列,其实每条记录后面都跟着主键。InnoDB 正是拿这个主键回表,去主键索引里取整行。

sql
CREATE TABLE geek (
  a INT, b INT, c INT, d INT,
  PRIMARY KEY (a, b),
  KEY c (c)
);
-- 索引 c 的叶子实际存的是 (c, a, b)

所以 WHERE c = 1 ORDER BY a 不用再建 (c, a):c 相同的记录本来就按 a 排好了。ORDER BY b 才需要另建 (c, b)。

两个细节:

  • 主键里已经出现在索引中的列不会存两遍。
  • 查询要的列在叶子上全都有(包括主键),就不用回表,这就是覆盖索引。
MySQL

binlog 默认记的不是 SQL

我一直以为 binlog 记的是执行过的 SQL。其实 binlog_format 默认是 ROW:记每一行改前和改后的样子。

格式记什么
ROW(默认)每行改前、改后的值
STATEMENTSQL 原文
MIXED一般记语句,可能不安全时改记行

ROW 的好处是从库重放结果确定,不怕 NOW()、UUID()、LIMIT 没有 ORDER BY 这类「同一条 SQL 两边结果不同」的问题。代价是改很多行时,日志会很大。

例外:CREATE、ALTER 这种 DDL,在 ROW 格式下仍然记语句。

MySQL

隔离级别设在哪一层:GLOBAL、SESSION、TRANSACTION

隔离级别属于一笔事务,不能写在某条 SELECT 上。改它的三种写法,影响范围不同:

sql
SET GLOBAL  TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 之后新建的连接
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 本连接之后的事务
SET         TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 本连接的下一笔事务

已经开始的事务改不了。

不同连接用不同级别也不冲突。隔离级别只管自己的普通 SELECT 读到哪一版,不管锁:改同一行照样排队,和对方是什么级别无关。

MySQL

JOIN ... USING(id) 和 ON 有什么不一样

sql
SELECT * FROM t1 JOIN t2 USING (id) WHERE t1.c = 10;
-- 等价于
SELECT * FROM t1 JOIN t2 ON t1.id = t2.id WHERE t1.c = 10;

区别在 SELECT * 的结果:用 USING 时 id 只出现一列,后面也可以直接写 id,不用加表名;用 ON 时两张表的 id 各出一列。

要求是两张表都有这个同名列。

MySQL

长事务为什么危险

一个开着不提交的事务,哪怕只读了一下,也会造成两类麻烦:

  1. 拖住 undo 清理:它的快照很旧,在它之后产生的 undo 都不能删,越积越多。5.5 时 undo 放在 ibdata 里,删了也只是文件内部空出来,文件只涨不缩,有人的数据才 20 GB,ibdata 却涨到了 200 GB。
  2. 占着锁:MDL 读锁、行锁都要到事务结束才放。想改表、想改这些行的人都得等。

MySQL 没有规定多长算长事务。排查时可以这样看谁开了很久:

sql
SELECT trx_mysql_thread_id, trx_started, trx_query
FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

trx_mysql_thread_id 就是 KILL 要用的连接号。

MySQL

redo log 的 checkpoint 什么时候往前走

redo log 是一圈循环写的文件:

  • write pos:当前写到哪,一边写一边往后挪,写到末尾绕回开头。
  • checkpoint:当前可以擦到哪。

我原本以为事务提交时 checkpoint 就会往前推。其实不是:它对应的脏页真正写进数据文件之后,checkpoint 才能往前推,在那之前这段 redo 还是崩溃恢复要用的。

平时后台线程慢慢刷脏页、慢慢推 checkpoint。如果更新太猛,write pos 追上了 checkpoint,redo 写满,新的更新就得停下来,先刷脏页把 checkpoint 推走。这时数据库会出现明显的卡顿。

顺带,redo 里记的是物理修改(哪个表空间、哪一页、页内哪个位置、改成什么字节),里面没有 SQL。

MySQL

RR 下的范围 UPDATE,会把还不存在的行也锁住

表里只有 id = 1 和 id = 5。事务 A 执行范围更新后不提交,B 来插入 id = 3:

sql
-- A
BEGIN;
UPDATE t SET c = c + 1 WHERE id BETWEEN 1 AND 5;

-- B
INSERT INTO t VALUES (3, 0);
隔离级别B 的 INSERT
REPEATABLE READ等锁,超时报 1205
READ COMMITTED立刻成功

RR 会把扫过的间隙锁到事务结束,挡住往这段范围里插新行(防幻读)。RC 只锁存在的行。

快照只管普通 SELECT;UPDATE、DELETE、FOR UPDATE 这些会加锁的语句,才是间隙锁的来源。RC 下也不是完全没有间隙锁:外键检查和唯一键冲突时仍会加。

MySQL

redo log 和 binlog 怎么保证一起成功

binlog 是 Server 层写的,redo log 是 InnoDB 写的,两个文件不可能一次写完。一次 COMMIT 内部走的是两阶段提交:

  1. InnoDB 把 redo 记成 prepare
  2. Server 写 binlog
  3. InnoDB 把 redo 记成 commit

两边靠事务的 XID 对账。崩溃后重启,碰到 prepare 了但没 commit 的事务:

  • binlog 里有这个 XID:提交
  • 没有:回滚

为什么以 binlog 为准?因为 binlog 可能已经被从库或备份拿走了,主库得和它保持一致。

这不是你能用 BEGIN 控制的另一笔事务,而是你那次 COMMIT 的实现细节。也没有任何 SQL 能直接往 redo 或 binlog 里写东西。

Go

Go 没有 map / filter / reduce

从 JS 过来,第一反应是找 arr.map(...).filter(...)。Go 标准库没有这种链式写法,惯用法就是 for range。

slices 包里有几个近亲:

go
evens := slices.DeleteFunc(slices.Clone(nums), func(n int) bool { return n%2 != 0 })
// 反过来的 filter:删掉满足条件的(注意它会改原切片,所以先 Clone)

flat := slices.Concat([]int{1}, []int{2, 3})   // 一层 flatten
biggest := slices.Max(nums)

Go 1.23 加了迭代器 iter.Seq,社区也讨论过官方的 Map / Filter / Reduce,但目前没有进标准库。

Go

fs.WalkDir 的第一个参数,不一定是 os.DirFS

go
fs.WalkDir(os.DirFS(root), ".", func(path string, d fs.DirEntry, err error) error {
	// ...
})

第一个参数的类型是 fs.FS,一个抽象的文件系统。os.DirFS 只是把磁盘目录包装成 FS,换成别的也行:

  • embed.FS:编译进二进制的文件
  • fstest.MapFS:测试用的内存文件系统
  • zip 包等

第二个参数才是这个 FS 里的起点,常写 "."。

回调里的 fs.DirEntry 是当前条目的轻量信息:Name()、IsDir()、Type() 都不用再 Stat。要大小、修改时间时才调 d.Info()。注意 err != nil 时 d 可能是 nil。

写测试时用 MapFS,不用真的在磁盘上建目录:

go
mfs := fstest.MapFS{
	"logs/app.log":     {Data: []byte("hello")},
	"logs/nginx/a.log": {Data: []byte("x")},
}
Go

Go 排序:slices.SortFunc 返回的不是 bool

包名是 slices(复数)。比较函数返回负数、0、正数,用 cmp.Compare 最省事:

go
slices.SortFunc(ps, func(a, b Person) int {
	return cmp.Compare(a.Age, b.Age)
})

// 多个字段:先按年龄,再按名字(cmp.Or 取第一个非 0 的结果)
slices.SortFunc(ps, func(a, b Person) int {
	return cmp.Or(cmp.Compare(a.Age, b.Age), cmp.Compare(a.Name, b.Name))
})

排序是原地的,没有返回值。想保留原顺序,先克隆:

go
c := slices.Clone(s)
slices.Sort(c)

b := a 不行:它只复制了切片头(指针、长度、容量),底层数组还是同一个,排 b 就等于排 a。

数组要先 arr[:] 变成切片再排。

MySQL

「超过三张表禁止 JOIN」,真正的原因

一些开发规范里有这条,但 JOIN 本身没有错。原因主要两个:

  1. 旧版本优化器弱:8.0.18 之前没有 hash join,连接列没索引时只能嵌套循环,行数一多就是 M × N 次比较。
  2. 分库分表:两张表不在同一个库里,数据库层面就 JOIN 不了。

所以在线接口常见的做法是单表查,再在应用层拼起来。

PostgreSQL 圈子里很少有这种规定:它很早就有 Nested Loop、Hash、Merge 三种连接,也很少拆分片。这条规范更像是「MySQL 5.x 加分库分表」那个时代的产物,不是 SQL 的真理。

不过有一点到今天也成立:点查和 WHERE 过滤仍然要靠索引,hash join 救不了全表扫描。

esc