数据库实战
目录 · 21 题
题目与答案
1.一条 SQL 很慢,你怎么排查 🔴
展开答案
排查有固定顺序,第一步是 EXPLAIN,不是猜、也不是先加索引试试。四列就能定性:
| 列 | 危险信号 | 想看到的 |
|---|---|---|
type |
ALL(全表扫描) |
ref / range 及以上 |
key |
NULL(没用上任何索引) |
实际命中的索引名 |
rows |
预估扫描行数比结果集大几个量级 | 两者同量级 |
Extra |
Using filesort、Using temporary |
Using index(覆盖索引,免回表) |
每列的详细含义和 rows 估算原理在 03-sql.md 第 5 题,这里说拿到结果之后往哪走:
key是NULL→ 区分「没建索引」和「建了但写法让它用不上」(本章第 2 题)- 走了索引但
rows很大 → 索引选择性差(给gender建索引没意义,一个值覆盖半张表),或者复合索引列顺序不对(本章第 3 题) Using filesort→ 让ORDER BY的列进索引,排序在索引里天然完成;大表上的 filesort 加临时表,是「查询本身不复杂但就是几秒」的最常见组合- 单条 SQL 都不慢,接口就是慢 → 大概率 N+1,这时候要数的不是单条耗时而是 SQL 条数(本章第 5 题)
具体现象长这样:开发库 5 万行,一条没走索引的 WHERE status = ? ORDER BY created_at DESC 全表扫只要 20ms,你根本感觉不到;上线后表涨到 800 万行,同一条 SQL 变成 3-4 秒,并且因为它占着连接不放,整个接口组一起超时。表小的时候全表扫和走索引的差别被硬件掩盖了,所以不能靠本地体感判断,要看 EXPLAIN 的 rows 和真实数据量的比例。
排查完一条之后再看它是不是唯一一条:SHOW FULL PROCESSLIST 看当下有没有堆积的同类查询,有的话说明这条慢 SQL 已经在拖池子了(本章第 16 题)。
追问「线上怎么发现慢 SQL,不能等用户报」:两个来源,慢查询日志(slow_query_log = ON 配上 long_query_time,阈值先设 1 秒摸清基线,再往 200ms 收紧)和 performance_schema.events_statements_summary_by_digest——后者按 SQL 模板聚合,能看到「平均 80ms 但被调用了两万次」这种日志抓不到的类型,N+1 只在这张表里现形。再补一句生产意识:不要在生产直接对大查询跑 EXPLAIN ANALYZE,它会真的把查询执行完(MySQL 8.0.18 起支持,PostgreSQL 一直如此),排查线上问题用普通 EXPLAIN,要 ANALYZE 就去从库或者影子库。
2.索引在什么情况下会失效 🔴
展开答案
这是纯记忆题,答不全没借口,而且必须能当场举出 SQL。六种:
-- 1. 索引列上套函数或参与运算
WHERE DATE(created_at) = '2026-09-01' -- 失效
WHERE created_at >= '2026-09-01'
AND created_at < '2026-09-02' -- 改成范围,走索引
-- 2. 隐式类型转换:phone 是 varchar,字面量却是数字
WHERE phone = 13800138000 -- 失效,列被转成数字
WHERE phone = '13800138000' -- 加引号就走
-- 3. 前导模糊
WHERE title LIKE '%退款' -- 失效
WHERE title LIKE '退款%' -- 走
-- 4. 违反最左前缀:索引 (a, b, c)
WHERE b = 2 -- 用不上
WHERE a = 1 AND c = 3 -- 只用到 a
-- 5. OR 两边不都有索引
WHERE indexed_col = 1 OR no_index_col = 2 -- 整体全表扫
-- 6. != / NOT IN / IS NOT NULL 在选择性差时被优化器放弃
WHERE status != 'done' -- 常见于状态只有两三种的表
最左前缀那条的原理(B+ 树按列依次排序、范围之后的列失去有序性)在 03-sql.md 第 2 题,那边还讲了索引下推怎么减少回表,不重复。
第 6 条要单独拎出来讲,这是拉分点:它不是真失效,是优化器的选择。优化器基于统计信息估算「走索引回表 N 次」和「顺序全表扫」哪个成本低,status != 'done' 命中 90% 的行时全表扫确实更快。由此推出两个可观察后果:一是统计信息不准会让优化器选错索引——大批量增删之后直方图和基数还是旧的,现象是「昨天还好好的查询今天突然变慢,SQL 和索引都没动」,ANALYZE TABLE t 重新收集统计信息就恢复;二是可以用 FORCE INDEX 临时验证「优化器是不是选错了」——如果强制之后快了,问题在统计信息,不在索引本身。
索引失效和优化器不用索引是两回事,能把这两个词分开说,面试官就知道你不是纯背的。
追问「WHERE status != 'done' 你说是优化器选择,那怎么让它必须走索引,或者说这需求根本不该这么写」:先给判断依据——看目标行占全表的比例,超过大约 20%-30% 优化器就倾向全表扫,这个比例是决定性因素,不是 != 这个符号。所以三种走法:一是把否定改成肯定的枚举,status IN ('pending','running'),命中比例小了优化器自然走索引;二是如果「未完成」是高频查询而未完成行永远只占极少数,建部分索引(PostgreSQL 的 CREATE INDEX ... WHERE status <> 'done',MySQL 没有部分索引,用「只给未完成行写值、完成后置 NULL 的辅助列」模拟,因为 B+ 树索引不存全 NULL 条目);三是承认这是队列型查询,本来就该用 (status, created_at) 复合索引配合明确的状态白名单。FORCE INDEX 是排查手段不是解法——它把优化器的成本判断永久钉死,数据分布一变就成了新的慢查询源。
3.复合索引的顺序怎么定 🔴
展开答案
排序规则是「先按 a 排,a 相同的按 b 排,b 相同的按 c 排」,像电话簿按「姓、名」排——知道姓能快速定位,只知道名就得整本翻。所以查询条件必须从最左列开始连续匹配。这部分和索引下推的细节见 03-sql.md 第 2 题。
定顺序按这个优先级:
- 等值列在前,范围列在后。
(a, b, c)上跑a = 1 AND b > 5 AND c = 3,c完全排不上用场——因为b是范围,c只在b的每个具体值内部才有序。现象是EXPLAIN的key_len明显短于索引全长,说明只用了前面几列,剩下的列在回表后逐行过滤。 - 选择性高的在前。选择性 = 不同值数量 / 总行数。
user_id选择性接近 1,status可能只有 3 个值,(user_id, status)一次定位到几十行,(status, user_id)先定位到几百万行再筛。可以直接量:SELECT COUNT(DISTINCT user_id) / COUNT(*) FROM t。 - 考虑复用。
(a, b)天然覆盖了只查a的场景,别再单独建一个(a)——纯浪费写入性能和存储,每次INSERT都要多维护一棵 B+ 树。反过来(b)是要单独建的,(a, b)覆盖不了它。
第 1 条和第 2 条冲突时(选择性最高的列刚好是范围查询),等值优先。典型例子是 WHERE tenant_id = ? AND created_at > ?:created_at 选择性更高,但它是范围,索引必须是 (tenant_id, created_at)。
追问「WHERE b = 2 AND a = 1 能走 (a, b) 吗」:能。SQL 里条件的书写顺序完全不影响,优化器会按索引定义重排匹配。最左前缀说的是索引列的定义顺序,不是 WHERE 子句的文本顺序——这题问的人很多,答错的更多,因为「最左」这个词容易被理解成「写在最左边」。补一句怎么自证:把两种写法分别 EXPLAIN,key、key_len、rows 三列完全一样。真正会受书写顺序影响的是另一件事——OR 和 AND 混写时不加括号导致的优先级错误,那是语义错,不是索引问题。
4.聚簇索引和二级索引的区别,什么是回表 🔴
展开答案
InnoDB 的聚簇索引就是主键索引,叶子节点直接存整行数据,按主键查一次就拿到全部字段。二级索引的叶子节点存的是「索引列 + 主键值」,所以按二级索引查到之后还要拿主键回聚簇索引查一次才能拿到其他字段——这一步叫回表。回表和覆盖索引的执行细节见 03-sql.md 第 4 题。
由此推出两个工程结论,比定义本身更值钱:
一、SELECT * 让覆盖索引几乎不可能生效。 索引是 (name),SELECT id, name FROM t WHERE name = ? 不用回表,EXPLAIN 的 Extra 显示 Using index;把它写成 SELECT *,立刻退化成「扫索引 + 逐行回表」。现象很具体:一个列表接口从 30ms 变成 400ms,SQL 的 WHERE 条件一个字没改,只是有人为了省事把字段列表换成了 *。这也是为什么 ORM 默认行为需要警惕——大多数 ORM 的 findAll() 就是 SELECT *(本章第 21 题)。
二、主键要短且单调递增。 每个二级索引的叶子都存了主键值,主键从 8 字节 bigint 换成 36 字符 UUID,所有二级索引都跟着膨胀,缓冲池能装的索引页变少,命中率下降。更糟的是 UUIDv4 完全无序,插入位置随机分布在整棵树里,导致页分裂——写入 TPS 明显下降,表空间还会因为页填充率低而虚胖。这才是「为什么用自增 id 而不是 UUID」的真正原因,不是「UUID 太长不好看」。
必须做分布式 ID 时,选时间前缀有序的方案(雪花 ID,或 UUIDv7——它在 2024-05 随 RFC 9562 正式标准化,把时间戳放在高位以保证单调),别用 UUIDv4。
追问「那你为什么不干脆把所有查询要的字段都加进索引,全都变成覆盖索引」:因为索引不是免费的,判断依据是读写比和字段宽度。每加一列,索引体积变大、缓冲池里能缓存的页变少、每次 INSERT/UPDATE 都要多维护这棵树——写多读少的表上,加宽索引换来的读收益会被写放大吃掉。具体的界限:只把「WHERE / ORDER BY 用到的列 + 一两个窄的高频返回列」放进去,正文、JSON、TEXT 这类大字段绝不进索引(TEXT 还只能建前缀索引,本来就没法覆盖)。判断方法是看这个查询的 QPS 和它省下的回表次数:一天调十次的报表查询不值得为它加索引,每秒几百次的列表页才值得。「哪些查询值得为它建专门的覆盖索引」是个成本题,不是技术题。
事务与并发
5.什么是 N+1 查询,怎么解决 🔴
展开答案
查一次列表拿到 N 条记录(1 次查询),然后在循环里为每条记录查关联数据(N 次查询)。一个列表页打出上百条 SQL,每条都不慢,加起来是几百毫秒的网络往返——问题不在 SQL 本身,在 SQL 的条数。
ORM 特别容易写出来,因为触发查询的那行代码看起来完全不像数据库操作:
// 反例:orders.length 有多少,就多打多少条 SQL
const orders = await Order.findAll({ limit: 20 });
for (const o of orders) {
o.userName = (await User.findByPk(o.userId)).name; // 第 N 次查询藏在属性访问后面
}
// 解法一:JOIN / 预加载,ORM 的 include 内部通常就是批量查询
const orders = await Order.findAll({ limit: 20, include: [User] });
// 解法二:手动批量 —— 一次 IN 查完,内存里组装
const orders = await Order.findAll({ limit: 20 });
const users = await User.findAll({ where: { id: [...new Set(orders.map(o => o.userId))] } });
const byId = new Map(users.map(u => [u.id, u]));
orders.forEach(o => { o.userName = byId.get(o.userId)?.name; });
批量往往比 JOIN 好,两个理由:一对多时 JOIN 会让主表字段重复传输(一个订单十个明细,订单信息传十遍);更要紧的是分页会算错——LIMIT 10 限的是 JOIN 之后的行数,不是订单数,页面上会出现「第一页只显示了 3 个订单」。
怎么发现:开 ORM 的 SQL 日志,刷一次页面数条数。开发环境数据量小、关联表也小,N+1 完全感觉不到(20 条记录 × 2ms = 40ms,看不出来),上线后数据涨起来、数据库不在本机、网络多了 1ms 往返,同样的代码就是两三秒。所以这件事必须在开发阶段靠日志条数判断,不能靠体感。前端出身在这里有个天然优势:你熟悉网络面板里「200 个请求 vs 2 个请求」的差别,N+1 是同一个问题换了个位置。
追问「预加载就一定对吗——一个列表页 include 了五张关联表,你觉得会发生什么」:会从「N+1 条小查询」变成「1 条谁都读不懂的巨型查询」,而且更难救。判断依据有三个:一是行数爆炸,两个一对多关联同时 JOIN 会做笛卡尔积(10 个明细 × 5 个日志 = 50 行),返回的数据量是乘法而不是加法;二是 ORM 为了修正分页会自作聪明地拆成子查询,EXPLAIN 里出现 DEPENDENT SUBQUERY 就是这种,实际执行仍是逐行;三是大多数关联字段列表页根本不显示。所以我的做法是分两层:主表 + 一两个一对一关联用 JOIN,一对多的集合走批量 IN,真正只有点开详情才要的关联根本不在列表接口里查。「把 N+1 修成一条慢 SQL」不算修好,判断标准始终是返回的数据量和 SQL 条数一起下降。
6.事务的四个隔离级别,MySQL 默认是哪个 🔴
展开答案
| 级别 | 解决 | 遗留问题 | 谁的默认 |
|---|---|---|---|
| 读未提交 | — | 脏读(读到别人未提交的数据) | 基本没人用 |
| 读已提交 | 脏读 | 不可重复读(同一事务两次读结果不同) | Oracle、PostgreSQL |
| 可重复读 | 脏读、不可重复读 | 标准里遗留幻读,MySQL 用间隙锁基本消除 | MySQL |
| 串行化 | 全部 | 并发极低 | 只在对账类场景用 |
MySQL 默认可重复读,靠 MVCC 实现:事务第一次读时确定一个快照,之后只看这个快照。幻读(两次范围查询之间别人插入了新行)靠间隙锁解决——锁的不只是已存在的行,还有行与行之间的间隙,让插入等待。MVCC 的快照读/当前读区别、间隙锁的加锁范围在 03-sql.md 第 6、7、8 题,那边讲得更细。
代价要说出来:间隙锁的锁定范围远大于你写的条件,这是「明明只更新一行却和别人死锁」的根因(本章第 8 题)。所以 MySQL 默认级别比别人高一档不是白拿的。
AI 应用场景一定要补这句,它比隔离级别本身更能决定评价:模型调用绝对不能放在数据库事务里。一次生成几秒到几十秒,事务全程不提交,连接被占着、锁不释放;并发一上来连接池瞬间耗尽,挂的不是这一个接口,是整个服务所有走同一个池子的接口。正确结构是:事务外准备好数据 → 调模型 → 再开事务只写数据库。这条在纯后端候选人里都有一半答不出来(连接池视角见本章第 16 题,长耗时请求的架构选择见 02-node-java.md 第 16 题)。
追问「读已提交比可重复读弱,为什么很多团队反而把 MySQL 改成读已提交」:因为锁范围小、死锁少、主从延迟下的表现更可预测,这是拿一致性换并发。判断依据是业务里有没有「同一事务内两次读同一批数据并依赖它们相同」的逻辑——绝大多数 CRUD 接口一个事务里只读一次,可重复读的保证用不上,白背间隙锁的代价。金融对账、批量结算这类要在一个事务里反复扫同一范围的,必须留在可重复读。还有一条实操依据:读已提交下 UPDATE ... WHERE 非索引列 仍然会锁很多行,改隔离级别不能替代加索引。要点是「改隔离级别是并发优化手段,得先说清放弃了什么」,直接答「都用默认的」会被认为没做过容量压测。
7.分页到第一万页很慢,怎么优化 🔴
展开答案
根因是 LIMIT 100000, 20 会真的把前十万行扫过去再丢掉,只留最后二十条;偏移越大越慢,而且浪费的是「回表拿完整行再扔掉」的 IO。03-sql.md 第 9 题讲了这个扫描过程和覆盖索引怎么减少回表,这里给两种改法和取舍。
-- 原始写法:扫 100020 行,回表 100020 次,丢掉 100000 行
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 改法一:延迟关联 —— 先在覆盖索引上只取 id(不回表),再拿 20 个 id 回主表
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 20) t USING (id);
-- 改法二:游标分页 —— 不用 offset,直接定位,性能与页码无关
SELECT * FROM orders WHERE id > 上一页最后一个id ORDER BY id LIMIT 20;
取舍必须说清,只答游标分页会被认为没做过中后台:游标分页是最优解但不能跳页,只有上一页/下一页,适合信息流、导出、定时批处理。中后台表格通常要显示「共 3821 条,第 15 页」并且允许直接跳,那就只能用延迟关联,或者反问产品「真的需要跳到第一万页吗」——大多数深分页需求是伪需求,用户真正要的是更好的筛选和排序。这句反问本身就是产品意识。
游标分页还有个必须处理的细节:排序字段不唯一时(比如按 created_at 排,同一秒有多条)游标会漏数据或重复,要用「排序字段 + 主键」的复合游标:WHERE (created_at, id) < (?, ?),索引也要跟着建成 (created_at, id)。
追问「COUNT(*) 也很慢怎么办」:大表精确计数本身就贵——InnoDB 没有维护全表行数,COUNT(*) 要扫一遍索引(至少是最窄的那个二级索引)。三条路,按代价从低到高:产品上改成「只显示有没有下一页」(多查一条,返回 hasMore,成本几乎为零);用估算值(EXPLAIN 的 rows,或 PostgreSQL 的 reltuples,误差通常在个位数百分比,列表页显示「约 380 万条」完全够用);真要精确就把总数缓存起来定期刷新,或用触发器/异步任务维护计数表。判断依据是这个数字的用途:驱动分页控件的总数可以估算,对外报表和对账的数字必须精确——精确的那些通常也不需要实时,跑离线任务算。
8.死锁是怎么产生的,怎么排查和避免 🔴
展开答案
两个事务以相反的顺序锁同样的资源:A 锁了行 1 要行 2,B 锁了行 2 要行 1,互相等。MySQL 有死锁检测,会主动回滚其中一个(报 Deadlock found when trying to get lock),所以死锁不会把服务挂死,但会让一部分请求随机失败。
排查用 SHOW ENGINE INNODB STATUS,LATEST DETECTED DEADLOCK 段里有完整信息:两个事务分别持有什么锁、在等什么锁、执行到哪条 SQL。要注意它只保留最近一次,要长期留证据得开 innodb_print_all_deadlocks = ON 打到错误日志里,否则线上偶发死锁根本抓不到现场。
避免的四条,按收益排序:
- 统一加锁顺序——批量更新前先按 id 排序再逐个更新。这一条能消掉大部分死锁,因为绝大多数死锁来自「两个事务处理的是同一批数据,只是顺序不同」。
- 缩短事务——锁持有时间越短碰撞概率越低。推论就是第 6 题那句:事务里不能有网络调用,尤其是模型调用。
- 减小锁范围——用精确的等值条件,别让间隙锁覆盖一大段区间(间隙锁的范围见
03-sql.md第 8 题)。 - 业务上接受死锁并重试——死锁是并发系统的正常现象,不可能完全消除;对死锁错误做有限次重试(2-3 次,带随机退避)比追求零死锁现实得多。重试要保证幂等,否则重试本身会造成重复写(幂等设计见
02-node-java.md第 13 题)。
最容易被忽略的死锁来源是没有索引:UPDATE ... WHERE 非索引列 会逐行加锁扫全表,两个本来毫不相干的更新(改的是不同的行)也会撞在一起。现象很有辨识度——死锁日志里两条 SQL 的 WHERE 条件根本不重叠,看起来毫无道理。加索引不只是性能优化,也是并发正确性的前提,这个关联答出来很有分量。
追问「你说重试,那哪些操作重试是安全的,哪些一重试就出事」:判断依据是这个事务在被回滚之前有没有产生数据库之外的副作用。死锁回滚只回滚数据库的改动,事务里发出去的东西一个都收不回来——扣款调用已经发到支付网关、消息已经进了 MQ、模型已经生成并计了费,这时候整体重试就是重复执行。所以规则是:纯数据库操作的事务可以直接重试;一旦有外部副作用,要么把副作用挪到事务之外(先本地落库标记,事务提交后再由异步任务发出,即本地消息表),要么给外部调用带上幂等键,让下游自己去重。还有一条容易忽略:重试次数必须有限且带退避,无限重试遇到持续热点会把连接池占满,从「少量请求失败」升级成「服务整体不可用」。
9.并发更新同一行,乐观锁还是悲观锁 🟡
展开答案
按冲突概率选,不是按「哪个更高级」选。
冲突少用乐观锁:加 version 字段,更新时把版本号带进条件,靠影响行数判断有没有被别人改过。
-- 读:SELECT id, balance, version FROM account WHERE id = 1; -- 拿到 version = 7
UPDATE account SET balance = 90, version = 8
WHERE id = 1 AND version = 7;
-- 影响行数 0 → 已被别人改过,重读重试或提示用户;不阻塞,吞吐好
关键是必须检查影响行数。ORM 里 update() 返回的那个数字被忽略是最常见的 bug:代码一路正常走完、日志一片干净,数据其实没写进去,最后表现为「用户改了两次,只有一次生效」,而且没有任何报错。
冲突多、且重试代价高用悲观锁:SELECT ... FOR UPDATE 直接把行锁住。但要控住锁范围和持有时间,绝对不能在持锁期间调外部接口——尤其是模型接口,一次几十秒足够把这一行的所有请求全排死,连带耗尽连接池。
能绕开锁的优先绕开,这是本题最值钱的部分:
- 计数类用数据库原子操作:
UPDATE t SET n = n + 1 WHERE id = ?,一条语句天然安全,不需要读-改-写 - 去重类用唯一索引让数据库挡:应用层「先查再插」在并发下必然有漏洞(两个请求都查到不存在,然后都插进去),唯一索引是唯一可靠的保证
- 累加/插入合一用 upsert:MySQL 的
INSERT ... ON DUPLICATE KEY UPDATE,PostgreSQL 的INSERT ... ON CONFLICT DO UPDATE
「让数据库的约束来保证正确性,而不是应用层的判断」是这题最值钱的一句话。
追问「乐观锁重试失败了怎么办——你总不能让用户一直点重试」:判断依据是这次冲突丢掉的是用户的意图还是一个可重算的中间值。可重算的(库存扣减、计数、状态机推进)就在服务端重读重算重试,有限次数加退避,用户完全无感;承载了用户输入的(编辑一篇文档、改一份配置)绝对不能自动重试——自动重试等于拿旧的表单数据覆盖别人刚提交的内容,这是静默数据丢失,比报错严重得多。这种场景要把冲突抛回前端,告诉用户「这条记录已被他人修改」,并且给出对方改了什么、让用户选择覆盖还是合并。做得再好一点就是字段级合并:只比对本次实际修改过的字段,双方改的不是同一个字段就自动合并,撞上同一个字段才提示。能区分「可以自动重试」和「必须让用户决策」,比会写 version 字段更能证明做过并发场景。
10.JOIN 有哪几种,大表 JOIN 慢怎么办 🟡
展开答案
INNER JOIN 取交集,LEFT JOIN 保留左表全部(右表无匹配则补 NULL),RIGHT JOIN 反之。类型本身没什么可考的,考点在驱动表:优化器会挑结果集小的那张做驱动表,逐行去另一张表按索引查(Nested Loop Join)。所以被驱动表的关联字段必须有索引——没有的话就是「驱动表每一行都全表扫一遍被驱动表」,EXPLAIN 里能看到那个可怕的 rows 乘积(比如 5000 × 800000)。MySQL 8.0.18 起对无索引的等值 JOIN 会用 Hash Join 兜底,比逐行全表扫好,但仍然要把整张表读进内存,不是免罚。
优化顺序:
- 关联字段两边都有索引,且类型一致。
varchar关联int会隐式类型转换导致索引失效,这个坑很隐蔽——两边都建了索引,EXPLAIN却显示type: ALL。字符集/collation 不一致也是同样的效果,跨库迁移后特别容易撞上。 - 先过滤后关联。把
WHERE条件下推到子查询里,让参与 JOIN 的行数先变少;判断依据是看EXPLAIN里驱动表的rows有没有降下来。 - 拆成两次查询在应用层组装。这不是妥协:微服务架构下跨库根本没法 JOIN,本来就得这么写,做法和第 5 题的批量 IN 完全一样。
LEFT JOIN 有个纯语义的坑,写错了不会报错只会算错:条件写在 ON 里还是 WHERE 里语义不同。
-- 保留所有用户,只关联已支付的订单(无订单的用户 o.* 全为 NULL)
SELECT u.id, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';
-- LEFT JOIN 退化成 INNER JOIN:没有已支付订单的用户被 WHERE 过滤掉了
SELECT u.id, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';
现象是「统计页面上的用户数比实际少了一批」,而且少的刚好是没有订单的新用户。要判断 LEFT JOIN 是不是被退化了,看 WHERE 里有没有对右表非空字段的条件(IS NULL 除外,那是刻意的反连接写法)。
追问「三张以上的表 JOIN,你怎么判断优化器的连接顺序选对了没有」:看 EXPLAIN 输出里每一行的 rows 相乘的量级,和最终结果集的行数差几个数量级——差三个量级以上就是中间结果爆炸了,说明顺序选错。判断依据是优化器只能基于统计信息估算,多表 JOIN 的候选顺序是阶乘级的,MySQL 还有 optimizer_search_depth 的搜索上限,表一多它就只能贪心。所以实际操作是:先 ANALYZE TABLE 保证统计信息新鲜(这一步能解决相当一部分「顺序离谱」的情况),再用 EXPLAIN FORMAT=JSON 看每一步的 rows_examined_per_scan 和 filtered 百分比,filtered 很低说明大量行被读进来又被丢掉。确认优化器确实选错了,才用 STRAIGHT_JOIN 或者 JOIN_ORDER hint 钉住顺序——但要在注释里写清为什么钉,因为它会随数据分布变化而过期。更根本的判断是:需要三张以上大表 JOIN 的查询,多半属于报表,该走离线预聚合而不是在线优化。
AI 场景的数据设计
11.对话历史的表怎么设计 🔴
展开答案
两张表起步:
CREATE TABLE conversations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(200),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_user_active (user_id, updated_at DESC) -- 会话列表按最近活跃排序
);
CREATE TABLE messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT NOT NULL,
role ENUM('user','assistant','system','tool') NOT NULL,
content LONGTEXT,
token_count INT,
status ENUM('streaming','done','aborted','error') NOT NULL DEFAULT 'done',
meta JSON, -- 工具调用、检索依据、模型名、耗时
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_conv_time (conversation_id, created_at) -- 拉一个会话的历史 = 一次范围查
);
两个索引对应两个最高频的查询,没有第三个:拉某会话的全部消息(idx_conv_time)、拉某用户的会话列表(idx_user_active)。updated_at DESC 的降序索引在 MySQL 8.0 起是真降序,8.0 之前的版本写了 DESC 也会被忽略。
真正的设计点在这四条:
- 消息只追加不修改。重试就插新记录并标记它替换了哪条,不要覆盖——排障时要能看到完整轨迹,「用户说答错了」的工单没有轨迹根本查不了。
- 流式的写入时机:先插一条
streaming状态的空壳记录,流结束再更新内容和最终状态(done/aborted/error)。这样进程崩了也能知道哪条断在中途,重启后能把僵死的streaming记录扫出来收尾。反过来「等流结束才写库」的做法,一崩就什么都不剩。 - 工具调用和检索依据存 JSON 列,作为消息的结构化附属字段。不存的话,日后想回答「引用准不准」「这个答案是基于哪个文档版本」时无从下手(引用溯源见
04-ai.md第 11 题)。 - 长会话的摘要单独一张表,带上「覆盖到哪条消息 id」,下次直接复用不重算。没有这个字段就没法判断摘要是否过期,只能每次重算。
追问「content 用什么类型」:TEXT / LONGTEXT,别用 VARCHAR 卡长度——模型输出长度不可控,VARCHAR(2000) 迟早会截断或报错,而改列类型在大表上要重建表(本章第 18 题)。更值钱的是补这一层:大字段不要和高频查询的字段放在一张表。会话列表只要标题和时间,但 SELECT * 会把所有正文都读出来——InnoDB 里超长的 TEXT 虽然会溢出到额外的页,行内只留指针,可一旦被 SELECT 到就要额外读那些页,缓冲池被正文挤占,其他查询的命中率一起掉。判断依据是「这个字段在列表页要不要显示」:不显示就别 SELECT,正文量特别大时把它拆到 message_contents 副表,用 message_id 一对一关联。
12.MySQL 和 PostgreSQL 怎么选 🟡
展开答案
先说实话:大多数业务场景两者都能胜任,选型主要看团队现状和运维能力,不是功能对比表。会真正影响决策的差异只有几条:
| PostgreSQL | MySQL | |
|---|---|---|
| 类型与扩展 | JSONB 可建 GIN 索引、数组类型、丰富扩展生态 | JSON 有函数索引但不如 JSONB 灵活 |
| 复杂查询 | 窗口函数、递归 CTE、物化视图更成熟 | 8.0 起补上窗口函数和 CTE,够用 |
| 运维与人才 | 相对少 | 主从复制方案成熟、云厂商支持完善、会的人多 |
| 向量检索 | pgvector 直接在业务库里做 | 需要外挂向量库(8.0.32+ 有向量类型但生态远不及) |
AI 场景有一个决定性的点:pgvector。 已经用 PostgreSQL 的话,向量检索可以直接在业务库里做,三个实际收益:少一个组件要运维;能和业务表 JOIN(「查这个租户最近三个月的文档里最相关的五块」一条 SQL 搞定,用独立向量库得先查向量、再回业务库查元数据、再在应用层求交集);还能吃到事务一致性——文档删除和向量删除在同一个事务里提交,不会出现「索引里还留着已删文档」这种状态(这个状态在 RAG 里是合规问题,见本章第 20 题)。百万级向量以内,pgvector 比引入专用向量库划算得多。
pgvector 0.8.0(2024-10-30 发布)补上了一个之前很痛的点:迭代索引扫描(hnsw.iterative_scan)。在这之前,带 WHERE 过滤的向量查询会「过滤过头」——ANN 索引先取回 ef_search 个候选,再被 WHERE 条件筛掉大半,最后返回的结果数少于你要的 LIMIT,而且是静默的,看起来只是「召回不够好」。0.8.0 之后索引会继续扫直到凑够结果或触及 hnsw.max_scan_tuples 上限。预过滤和后过滤对召回的影响见 03-sql.md 第 14 题,pgvector 与专用向量库的索引能力对比见 03-sql.md 第 12 题。
追问「什么时候该上专用向量库」:三条判断依据——上千万级向量(pgvector 的 HNSW 索引构建时间和内存占用在这个量级会成为运维负担)、需要多租户物理隔离(不只是逻辑过滤,是每个租户独立的索引和资源配额)、或者需要 pgvector 尚不支持的索引类型和量化方案(更激进的乘积量化、磁盘索引、GPU 构建)。反过来更值钱的是说清什么时候不上:几百到几千块向量,连数据库都不一定需要——全部加载进内存做一遍暴力点积是亚毫秒级,而且召回率是 100%,比任何 ANN 索引都准。「能说清什么时候不上」比堆组件更能体现判断力,也是这题面试官真正在等的那句。
13.Redis 在架构里做什么 🟡
展开答案
四类用途,AI 应用里每一类都用得上:
- 缓存:热数据、字典表,以及 AI 场景特有的「请求指纹 → 结果」——同一个 prompt 加同样的参数直接返回上次结果,既做幂等也省钱(语义缓存见本章第 15 题和
04-ai.md第 25 题) - 限流:令牌桶的状态存 Redis,必须用 Lua 脚本保证读-改-写原子,否则多实例下会超发(限流算法选型见
02-node-java.md第 14 题) - 分布式锁:
SET key value NX PX 30000,value 存唯一标识,释放时用 Lua 先比对再删——不比对就会释放别人的锁(自己超时后锁已经被别人拿到) - 会话和临时状态:可撤销的 refresh_token、长任务的进度条、流式生成的中间态
数据结构对应场景:String 做缓存和计数、Hash 存对象的部分字段更新、ZSet 做排行榜和延迟队列(score 存到期时间戳,ZRANGEBYSCORE 取到期项)、List 做简单队列、Set 做去重和标签、Stream 做需要消费组的队列。
过期策略要主动说,这条能筛人:Redis 不是到点立即删,而是惰性删除(访问到才检查)加定期抽样删除。所以内存占用会明显高于「所有未过期 key 的总和」,现象是监控里 used_memory 一直缓慢上涨、和你算出来的理论值对不上。必须配 maxmemory 和淘汰策略(一般 allkeys-lru),不配的话内存打满后写入直接报错(默认 noeviction)——这是「缓存挂了导致主流程挂了」的经典原因,缓存本该降级而不是报错。
追问「分布式锁真的可靠吗」:不绝对可靠,而且不可靠的点不在实现细节上。主从架构下客户端在主节点拿到锁、主节点还没把这个写同步给从就挂了,哨兵切换后新主上没有这把锁,第二个客户端能立刻拿到——两个持有者同时在跑。Redlock 靠多个独立节点投票缓解,但它依赖各节点时钟推进速率大致一致,这个假设在 GC 长暂停或虚拟机被挂起时不成立,业界对它有明确争议。还有一层与实现无关的问题:锁过期了但业务还没跑完,锁已经被别人拿走,而你的代码毫不知情地继续往下写。所以结论是:关键业务不能只靠锁,必须有幂等兜底——锁只是降低冲突概率、减少无用功,唯一约束和幂等键才是最终保证(正确性边界见 02-node-java.md 第 15 题)。判断依据很简单:如果「锁失效导致两个进程同时执行」会造成不可接受的后果,那这个场景就不能只用 Redis 锁。
缓存与容量
14.缓存和数据库怎么保持一致 🟡
展开答案
这里没有完美解,答题的重点是能说出每个方案的漏洞在哪。
主流是 Cache Aside:读的时候缓存没有就查库并回填;写的时候先更新数据库,再删缓存。
两个「为什么」要能解释:
- 为什么是删缓存而不是更新缓存:删除更简单,而且能避免并发写导致的脏数据——两个请求分别把缓存更新成各自的值,最后留下的可能是先算出来的那个旧值。删除让下一次读去库里拿权威值。
- 为什么是「先更库再删缓存」而不是反过来:先删缓存的话,在「删完」到「库更新完」之间有个空档,此时进来的读请求会查到旧数据并把它回填进缓存,之后就一直是旧的,直到 TTL 到期。这个空档虽然短,但高 QPS 下必然被撞上。
它仍然有两个漏洞,主动说出来比被问出来好:一是删缓存这一步失败了就不一致(网络抖动、Redis 短暂不可用);二是即使顺序正确,极端时序下还是会写入旧值——读请求查到旧值 → 写请求更新库并删缓存 → 读请求这时才把它查到的旧值回填。
所以工程上的兜底是:给缓存加一个不长的 TTL。不追求强一致,只保证「最多不一致 N 秒」。需要更强就上延迟双删(更新库后隔几百毫秒再删一次,覆盖上面那个时序),或者订阅 binlog 异步失效缓存(用 Canal / Debezium 这类,把「失效缓存」从业务代码里彻底移出去,顺带解决了删除失败没人重试的问题)。「用 TTL 兜住一致性」这句话本身就说明你知道这里没有银弹,比背出五种方案更能得分。
追问「你说 TTL 兜底,那这个 TTL 该设多少——有没有一个方法能算出来,而不是拍脑袋填 5 分钟」:判断依据是这个数据不一致时业务能承受多久,倒过来推,而不是从技术侧猜。三类分开定:价格、库存、权限这种不一致就出事的,TTL 要短到秒级,甚至不该用 Cache Aside,改成写时同步更新加版本号校验;用户昵称、头像、配置这种「晚几分钟没人受伤」的,几分钟到几十分钟都行;纯静态字典(省市区、枚举)可以设几小时甚至只在发布时清一次。还有两条实操细节:TTL 必须带随机抖动,否则同一批回填的 key 会同时过期(本章第 15 题的雪崩);不一致的窗口要可观测——加一个对账任务定期抽样比对缓存和库的值,把不一致率打成指标。能把 TTL 从「拍一个数」变成「由业务容忍度反推 + 用指标验证」,是这题的最高分答案。
15.缓存穿透、击穿、雪崩分别怎么处理 🟡
展开答案
三个词经常被混用,区分点是「缓存里为什么没有这个值」:
| 缓存为什么没命中 | 现象 | 处理 | |
|---|---|---|---|
| 穿透 | 数据根本不存在 | 库被反复查同一批不存在的 key,缓存命中率一直很低 | 缓存空值(短 TTL)或布隆过滤器 |
| 击穿 | 单个热 key 刚好过期 | 某一瞬间大量请求同时回源,数据库出现尖刺 | 互斥锁做单飞,只放一个请求回源,其余等结果 |
| 雪崩 | 大批 key 同时过期,或缓存整体挂 | 数据库 QPS 瞬间涨几十倍,然后连接池耗尽 | TTL 加随机抖动、多级缓存、下游限流兜底 |
穿透最容易被恶意利用:随机生成不存在的 id 打接口,每次都落库。缓存空值时要注意空值的 TTL 必须短(几十秒),否则真实数据后来被创建了却一直返回空。
AI 场景多一层语义缓存:把问题的 embedding 存下来,相似度超过阈值就复用旧答案。省钱效果很直接,但有两个坑,答不出来就说明没做过:
- 阈值太松会答错。「能退货吗」和「不能退货吗」的向量非常近,「iPhone 15 保修多久」和「iPhone 16 保修多久」也几乎一样,但答案完全不同。所以阈值要卡高(余弦相似度 0.95 以上),并且带具体实体或数字的 query 倾向于直接不走语义缓存——那些 token 在向量里权重很低,恰恰是决定答案的部分。
- 带权限的答案绝不能跨用户复用。缓存键必须包含权限维度(租户、角色、可见范围),否则就是越权:A 问出来的内部数据被缓存,B 问一个相似问题直接拿到。这是安全事故,不是缓存失效。
语义缓存的完整设计和风险在 04-ai.md 第 25 题。
追问「语义缓存上线后命中率只有 3%,你怎么判断是阈值定得太严,还是这个场景根本不适合」:判断依据是看未命中的 query 分布,而不是调阈值。做法是把线上 query 按 embedding 聚类,看头部簇覆盖了多少流量:如果 top 20 个簇能覆盖三成以上请求,那是阈值太严或 embedding 模型不够贴合领域,可以先把阈值放到 0.93 同时加一层 rerank 或精确实体比对来兜错;如果 query 长尾到几乎没有重复簇(每个用户问的都是自己的数据、自己的文档),那这个场景本身就不适合语义缓存,命中率再怎么调也上不去,该省钱的地方是 prompt 缓存和上下文裁剪。还有一个必看的指标是命中后的坏答案率——命中率从 3% 调到 30% 但错答率上升,是净亏,因为一次错答的代价远高于一次模型调用的钱。先量「命中带来的错误」再谈「提高命中」,顺序反了就是拿正确性换成本。
16.连接池怎么配,配大了会怎样 🟡
展开答案
连接池不是越大越好,这条能筛掉很多只写过 CRUD 的人。
池子大小的上限由数据库决定,不由应用决定。每个连接在数据库侧要占内存(排序缓冲、连接缓冲)和一个处理线程/进程;连接太多,数据库会把大部分时间花在上下文切换和内部锁竞争上,吞吐反而下降,而且是过了拐点之后断崖式下降。经验量级是「CPU 核数 × 2 + 有效磁盘数」,也就是几十,不是几百。多实例部署时还要乘上实例数——8 个 Pod 各配 50,数据库看到的是 400 个连接。
更该关注的是「连接被占多久」。池子耗尽通常不是池太小,而是有慢操作长期占着连接:慢 SQL、事务里做了耗时的事、或者事务里调了外部接口。AI 应用这条特别要紧——一次模型调用几十秒,如果它在事务里,二十个并发就能把一个五十连接的池打满。原则是「拿连接晚一点、还连接早一点」:先把需要的外部数据准备好,再开事务,事务里只做数据库操作(同一条原则在第 6、9 题各出现一次,这不是巧合)。
三个必配的超时,缺一个就会出现「不报错但一直卡着」:获取连接的等待超时(拿不到就快速失败,别无限等)、连接空闲回收时间(要小于数据库侧的 wait_timeout,否则会拿到已被服务端关闭的死连接)、语句级超时(兜住失控的慢查询)。Node 侧的池配置和 LLM 网关的超时联动见 02-node-java.md 第 6 题。
追问「怎么发现池耗尽了」:监控两个指标——等待获取连接的时间(P99,正常应该接近 0)和活跃连接数 / 池大小的比值(持续贴近 1 就是已经饱和)。关键是它的现象和「单个接口慢」完全不同:池耗尽时所有接口一起变慢并超时,包括那些只读一张小表的健康检查接口,因为它们也要排队等连接。所以排障时先看「是一个接口慢还是全部接口慢」——全部慢就往连接池、数据库整体负载、下游依赖这些共享资源上找,单个慢才去看那条 SQL。这个区分能让你在排障题里几句话定位到层,比逐个接口翻日志快得多。
17.什么时候该反范式化 🟡
展开答案
范式化(拆表消除冗余)让写入一致、存储省,代价是查询要 JOIN;反范式化(冗余字段)让查询快,代价是更新时要维护多处。判断标准是读写比:读远多于写、且 JOIN 已经被证明是瓶颈时才反范式——「被证明」指的是 EXPLAIN 和真实耗时,不是感觉。
有两类冗余,混在一起谈是这题最常见的失分点:
一类是快照式冗余,它不是性能优化,是业务正确性。 订单表里存下单时的商品名和价格:商品之后改价或改名,历史订单必须显示当时的值。这里就算 JOIN 一点都不慢,也必须冗余——因为关联表里的「当前值」根本不是这个业务需要的值。现象很直接:不存快照的话,商家把商品从「A 套餐 99 元」改成「B 套餐 199 元」,所有历史订单的金额和商品名全部跟着变,对账立刻错乱,用户看到自己三个月前买的东西变成了另一个。能区分「快照冗余」和「性能冗余」,说明你理解数据建模,不只是背范式。
另一类是纯性能冗余。 列表页要显示的计数(评论数、点赞数、会话的消息数)常冗余在主表,否则每行都要 COUNT,一页二十行就是二十次聚合查询——这其实是 N+1 的一种(本章第 5 题)。
踩坑:冗余字段的更新一定要有兜底。 靠应用层「记得同时更新两处」早晚会漏——漏的通常不是主路径,而是某个后台批量脚本、某次数据修复、某个新同事加的旁路接口。要么把两处更新放在同一个事务里,要么有定时任务对账修正(甚至两者都要)。「冗余就要有对账」是做过的人才会说的。
追问「你冗余了一个评论数,一个月后发现它和实际条数差了 37,怎么定位是谁写漏的」:判断依据是先分清偏差方向和分布,再去找代码。第一步跑一次全量对账,把差值按记录列出来:如果全都是冗余值偏大,说明删除路径漏了扣减(软删除尤其容易,删除标记改了但计数没动,本章第 20 题);如果偏小,说明某条插入路径没走计数逻辑;如果正负都有且随机,那多半是并发下的读-改-写覆盖,代码用了 SET n = 计算出来的值 而不是 SET n = n + 1(本章第 9 题)。第二步用 created_at / deleted_at 把偏差记录的时间戳分布画出来,集中在某几天就去查那几天的发布记录和运维脚本,均匀分布就是主路径的并发问题。第三步是把对账变成常态:定时任务算出差值不仅要修正,还要打成告警指标——差值突然抬头就能对应到某次发布。答「重新 COUNT 一遍修正」只答了一半,面试官要听的是「怎么让它下次自己暴露」。
演进与治理
18.数据库变更怎么管,线上加字段要注意什么 🟡
展开答案
变更脚本进 Git 走版本管理(Flyway、Prisma Migrate、TypeORM migration、Alembic 都行,工具不重要)。每个变更有版本号、可重放、能对应到某个 commit——手动在生产库执行 SQL 是最该消除的操作,出了问题查不到是谁改的、改了什么,回滚也无从下手。
线上加字段的注意点:
- 大表 DDL 的锁行为要先确认。MySQL 5.6 起有 online DDL,8.0.12 起加列支持
ALGORITHM=INSTANT(只改元数据、不重建表,8.4 里 INSTANT 已经是加列的默认算法),但不是所有操作都免锁:加字段、加索引通常可以在线,改字段类型、改字符集往往要重建整表,那期间的写入会被阻塞。安全做法是先在从库或影子表上验证耗时,业务低峰执行,或者直接用gh-ost/pt-online-schema-change做影子表切换。INSTANT 加列还有一个上限:每次 INSTANT 加/删列都会产生一个新的行版本,上限 64(MySQL 9.1.0 起放宽到 255),达到上限后必须重建表才能继续。 - 新字段一定要给默认值或允许 NULL。否则旧版本的应用代码插入时会失败(它的 INSERT 语句里没有这一列),这是发布顺序问题:正确顺序是先改数据库让它同时兼容新旧两版代码,再发应用,最后清理。反过来先发应用就会有一段时间里新代码在读写还不存在的列。
- 加索引也要评估。大表加索引即使在线也会占大量 IO 和 CPU,可能把本来正常的查询拖慢——它和「锁不锁表」是两个独立的风险。
追问「怎么回滚」:每个 migration 要有对应的 down 脚本,但要老实说清一件事:删列、改类型这类操作实际上无法回滚,数据已经没了,down 脚本只能把列加回来,加回来的是空列。所以真正的保障不是 down 脚本,而是把破坏性变更拆成多次发布:删字段走两步——先发一个版本让代码不再读写它(这个版本可以随便回滚),观察一到两个发布周期确认没有任何调用,下个版本才真删。改类型同理:加新列 → 双写 → 回填历史数据 → 切读 → 删旧列,每一步单独发布、单独可回滚。「down 脚本是形式,两步发布才是回滚能力」是这题的分水岭。
19.什么时候需要分库分表 🟡
展开答案
先给结论:大多数场景不需要。单表几千万行、有合适索引的情况下 MySQL 完全撑得住,优先级更高的手段依次是:加索引、读写分离(读多写少时收益最大)、归档冷数据(把三年前的订单挪到历史表,热表立刻瘦一半)、上缓存。这些做完还不行才考虑拆。张口就上分库分表,面试官会认为你没算过成本。
真要拆的话:
- 垂直拆分先做——按业务拆库(订单库、用户库)、把大字段拆到副表。它比水平拆分简单得多,不引入分片键问题,也不破坏单表内的事务。
- 水平分表要选好分片键,选错代价极大。按
user_id分,那么按订单号查就要扫所有分片;按订单号分,那么「查某用户的所有订单」就要扫所有分片。常见的折中是给非分片键建一张全局索引表(订单号 → 分片位置),但那又多了一处要维护一致的数据。 - 跨分片的 JOIN、聚合、排序分页都得在应用层做,分布式事务也跟着来了。深分页在分片下尤其难受——要从每个分片各取前 N 条再归并。
「分库分表是把数据库的复杂度转移到应用层」,这个代价必须说清。转移之后你会发现原来一条 SQL 能干的事变成了几百行归并逻辑,而且每个新需求都要重新评估「这个查询带不带分片键」。
追问「怎么平滑迁移」:双写(新旧库同时写,新库写失败只记日志不影响主流程)→ 历史数据搬迁 → 数据校验对账 → 读流量按比例灰度切到新库 → 观察一段时间 → 停旧库写入。关键是每一步都可回滚,而且有对账工具能证明数据一致——没有对账工具就不敢切读,只能靠祈祷。对账要比全量比对更细:既要比总量,也要按时间窗口抽样比字段值,因为双写期间最容易出的问题是「条数对得上但某几个字段的值不一样」(两边的默认值、时间精度、字符集不同)。还有一条容易漏的:双写期间旧库仍是权威源,所有回滚路径都必须能只依赖旧库跑通。
20.软删除还是硬删除,RAG 场景有什么特别的 🟡
展开答案
业务表一般用软删除(deleted_at),因为要留痕、要能恢复、还有外键依赖。代价是每个查询都得带 WHERE deleted_at IS NULL——漏一处就是数据泄露,用户能看到本该消失的记录。所以必须在 ORM 层统一处理(大多数 ORM 有 paranoid / soft-delete 支持),而不是靠每个人手写。
还有一个必踩的坑:唯一索引要带上删除标记。否则删了一条 email = a@b.com 的记录再注册同一个邮箱会撞唯一约束——记录还在表里,索引也还在。做法是把唯一索引改成 (email, deleted_at),删除时给 deleted_at 写具体时间(不是 0);MySQL 里 NULL 不参与唯一性判断,所以未删除的行 deleted_at IS NULL 之间仍然互斥,正好是想要的语义。
RAG 场景反过来:知识库的向量索引必须真删。 不能只在业务层隐藏——否则检索会把已下线的政策召回来当依据,模型照着它答,用户拿到的是一份已经作废的规定。这是合规问题,不是体验问题:过期的价格、已撤回的公告、离职员工的内部文档,答错一次的代价和「列表里多了一行」完全不是一个量级。
做法是两套语义并存:业务表软删除留痕,索引和向量真删,两者通过 document_id 对齐。删除流程要保证顺序——先删索引再标记业务表,反过来的话中间那段时间里业务上已经看不到文档、检索却还能召回它,而且没人会注意到。这个区分是 RAG 特有的,能答出来说明你理解「检索到的东西会直接进 prompt 影响输出」这个特性(索引增量同步见 04-ai.md 第 12 题)。
连带的坑是语义缓存也要一起失效。文档更新了但缓存里还存着基于旧内容的答案,用户会一直拿到旧政策,而且这种问题极难发现——日志上看是缓存命中,一切正常。做法是给缓存条目打上它依据的文档 id 和版本号,索引变更时按 id 批量清除。
追问「文档没删,只是改了一段——你怎么知道哪些缓存和哪些向量块需要失效」:判断依据是以块为单位算内容哈希,而不是以文档为单位判断「变了没变」。文档级的 updated_at 只能告诉你「这个文档动过」,重切全部块再重新 embedding 是可行但很贵的做法(大文档改一个错别字要重算几百块)。所以入库时就给每个块存 content_hash 和它在文档里的序号,更新时重新切块、逐块比哈希:哈希没变的块连 embedding 都不用重算,新增和变更的块才重新向量化,消失的块删掉。缓存那边同理,缓存条目记录它引用了哪些块的 id 和哈希,任一块的哈希变了就失效这条缓存。必须承认一个边界:分块边界会移动——在文档中间插入一段,后面所有块的内容都会偏移,哈希全变,这时候批量重算是躲不掉的,能做的是用「按标题/段落等语义边界切分」把偏移的影响限制在一个小节内(分块策略见 04-ai.md 第 5 题)。答「文档变了就全部重建」不算错但会被追一层成本,能说清「块级哈希 + 边界偏移是硬成本」才是做过增量同步的样子。
21.用 ORM 有什么坑,什么时候该写原生 SQL 🟡
展开答案
ORM 的价值是类型安全、自动参数化防注入、减少样板代码,日常 CRUD 用它没问题。坑主要三个:
- N+1——最常见,而且是「代码看起来完全正常」的那种(本章第 5 题)。
- 生成的 SQL 不可控——一个链式调用生成出带多层子查询的怪 SQL,性能很差但从代码上完全看不出来。典型是分页 + 一对多预加载,ORM 为了保证
LIMIT限的是主表行数,会自动拆成「先子查询取主表 id,再 IN 查关联」,多数时候是对的,偶尔会退化成DEPENDENT SUBQUERY。 - 批量操作退化成逐条——循环里
save()是 N 条 INSERT,一千条数据就是一千次往返。要用批量插入 API(bulkCreate/insertMany/createMany),一次INSERT ... VALUES (...), (...), (...)。
什么时候写原生 SQL:复杂报表和多层聚合、需要数据库特有能力(窗口函数、递归 CTE、ON CONFLICT / ON DUPLICATE KEY 这类 upsert、pgvector 的距离运算符)、以及明确要优化的热点查询。
写原生 SQL 时参数必须走绑定参数,不要字符串拼接——这是 SQL 注入的唯一根源,ORM 平时帮你挡住了,手写就得自己守:
// 危险:字符串拼接,userInput 里带一个引号就能改写整条语句
db.query(`SELECT * FROM users WHERE name = '${userInput}'`);
// 正确:参数绑定,驱动负责转义,userInput 永远只是一个值
db.query('SELECT * FROM users WHERE name = ?', [userInput]);
注意能被绑定的只有值,表名、列名、ORDER BY 的方向都不能用绑定参数——那些必须用白名单校验,从枚举里映射,绝不能把前端传来的字符串直接拼进去。这一点是原生 SQL 里最容易漏的,因为「动态排序」的需求看起来太无害了。
一句能体现判断力的话:「我的习惯是先用 ORM 写,但一定要能看到它生成的 SQL(开日志)——看不见生成的 SQL 就等于不知道自己在对数据库做什么。」
追问「你说要看生成的 SQL,那开发环境开日志上线就关掉了,线上怎么持续知道 ORM 在干什么」:判断依据是日志不是线上的观测手段,指标和采样才是。三层做法:一是把慢查询交给数据库侧(performance_schema 按 SQL 模板聚合,本章第 1 题),它天然按模板归并,能直接看到「哪个 ORM 调用生成的模板被调了两万次」;二是在应用侧接 tracing,让每条 SQL 成为请求 trace 里的一个 span,这样看到的是「这个接口打了 84 条 SQL」而不是散在日志里的 84 行——N+1 在 trace 瀑布图上一眼就能看出来,这是日志给不了的视角;三是在 CI 里加断言,对关键接口跑一次集成测试并断言 SQL 条数不超过阈值,条数涨了就让流水线红——这是唯一能防止 N+1 被重新引入的手段,靠人 review 挡不住。补一句:线上确实可以按比例采样打完整 SQL(比如 1%),但要注意 SQL 参数里可能有个人信息,采样日志得脱敏。