第 14 章 数据层:业务的记忆
副题:索引、事务、缓存——数据层只回答两个问题:查得快吗?写得对吗?
第 13 章门禁装好,请求终于可以一路走到业务层,干真正的活了。但业务干的活,全都围着一样东西转:数据——内容要存、评论要存、用户的登录态要存。第 6 章建好了六张表,本章是它们的运行时:数据怎么存得下、查得快、写得对。
本章要建立的认知
- 数据层只操心两件事:查得快不快,写得对不对。 索引管快,事务管对,缓存是"更快"的进阶选项——有额外代价。
- 索引不是越多越好。每多建一个,写入时就要多维护一份,读快写慢是铁律。索引的本质是拿写的成本换读的速度。
- 事务是并发世界的裁判:要么全成,要么全回。
14.1 数据去哪了:为什么是数据库
先回答一个最基础的问题:业务数据为什么存在数据库里,而不是文件里?
阶段 1 的代码量,一个 JSON 文件其实装得下。很多新手项目确实这么干——把内容写进文件,读的时候加载解析。但文件方案有三个硬伤,正好是数据库的三个存在理由:
第一,查询要靠自己写。 "按作者找内容""按时间倒序取前 20 条"——文件里得自己遍历、自己排序、自己分页,每次写一遍;数据库一行 SQL 就搞定。第二,约束没人管。 "用户名必须唯一""内容不能为空"——文件里全靠代码自觉,漏一处检查,脏数据就进去了;数据库在写入那一刻强制执行约束。第三,并发读写没人协调。 两个请求同时写同一个文件,后写的直接覆盖先写的,没人拦着;数据库把并发纳入管理。
一句话:文件适合"自己写、自己读、不常变"的数据;数据库适合"多方读写、有规则、会变化"的数据。 业务数据属于后者。
数据在数据库里的完整生命周期也在这里展开:写入(业务层编排 → 数据访问层 INSERT → 约束检查 → 落盘)→ 读取(查询 → 索引定位 → 返回)→ 过期/删除——每一段都有本章的机制在管。
当时选 PostgreSQL 的理由——约束、事务、JSONB——现在对应到真实能力:
- 约束:建表时规定的
username唯一、content非空,写入时强制执行 - 事务:并发时保证数据一致
- JSONB:标签字段,支持 JSON 型查询
选型时写在纸上的理由,运行时都是真实的能力。
数据库为什么慢?内存读写是纳秒级,磁盘读写比它慢两三个数量级(SSD 微秒级、机械盘毫秒级)。所以数据库的一切加速手段——索引、缓存、内存表——本质都是少碰磁盘、多利用内存。看到"数据库慢",先别急着"换数据库",先问"它碰了几次磁盘"。
数据库是独立运行的进程,不是代码里的一个模块。 PostgreSQL 常驻在服务器上,应用通过连接池与它通信。它有自己的内存、磁盘和日志。
数据库的连接池:每个请求都新建数据库连接是浪费,建连有握手开销。连接池让一批连接反复借用——用的时候取,用完归还。阶段 1 的中间件 10 毫秒预算里,连接池省下的就是"借现成连接"和"新建连接"的差别。
备份是另一层地板:事务的持久性防的是进程崩溃,备份防的是磁盘损坏或手滑删库。阶段 1 可以朴素——每天一次全量导出,但不能没有。"数据不能丢"的地板,一半是事务,一半是备份。
14.2 查询慢在哪:执行计划
阶段 1 虽然只有你在开发,但用户不止一个——老周、小明、访客,加上浏览器的多标签页,只要有两个请求同时到达,并发就存在。流量小时只是撞上的概率低,不代表没有。
数据库怎么执行查询?
数据库拿到 SQL,先生成执行计划——决定怎么找数据、按什么顺序 JOIN、用什么方式过滤——再照着计划执行,并不按你写的字面顺序傻瓜式跑。要看它怎么计划的,用一条命令:
EXPLAIN SELECT c.* FROM content c
JOIN users u ON c.author_id = u.id
WHERE u.username = 'laozhou'
ORDER BY c.created_at DESC LIMIT 20;
输出里如果看到 Seq Scan(全表扫描),说明数据库正在一行一行翻完整张表。几百条数据翻一遍只要几毫秒,但组合起来就慢了:JOIN 用户表、按时间排序、分页取 20 条——排序要把全部行排完才能取前 20,JOIN 要反复查用户表。数据量再涨十倍,开销就是十倍以上。
排查慢查询,90% 的情况第一步都是同一个动作:EXPLAIN。 这比任何优化技巧都值钱。
看 EXPLAIN 输出,新手盯两列就够:
- type:
Seq Scan(全表扫)还是Index Scan(走索引)——第一眼就看它 - rows:数据库估计要扫描多少行——估 100 实际跑出 10 万,说明统计信息该更新了
两列加起来就是诊断的八成:type 不对 = 索引问题,rows 不对 = 统计信息问题。
JOIN 的本质是"嵌套查找"。
外层每拿到一行内容,都要去用户表找对应的作者。没有索引时,这个"去找"又是全表扫描——两个全表扫描叠在一起,慢是平方级的。所以 JOIN 的关联列(外键)必须建索引,这是数据库的黄金规则。
两个隐蔽的慢查询陷阱
N+1 查询。 列表接口查出 20 条内容,然后循环每条再查一次作者:1 次主查询 + 20 次子查询 = 21 次数据库往返。单次很快,但往返次数才是真正的成本。修法:一条 SQL 用 JOIN 带出来,或者 ORM 开预加载。
深分页。 LIMIT 20 OFFSET 10000,数据库要跳过前 10000 行才能拿后面的,OFFSET 越大越慢。修法:用游标代替 OFFSET,记住上一页最后一条的 id,下一页从那里继续。
还有,慢查询要被看见。数据库开慢查询日志,超过 200 毫秒的 SQL 自动记录。不记录,只有用户抱怨时才知道;记录了,每周扫一眼就是最便宜的巡检。
14.3 索引:读快写慢的交换
索引是什么?你可以把它理解为书的目录——不翻全书,先查目录定位页码,直接翻到那一页。
数据库里的目录结构叫 B+ 树。查询时从树根出发,逐层下钻,一般只需要访问三四层节点就能找到目标。100 万行的表和 100 行的表,走索引时速度差不多——这是索引最反直觉的地方。
为什么能快这么多?
全表扫描是 O(n),数据量翻一千倍,时间就翻一千倍。B+ 树是 O(log n),数据量从 100 涨到 100 万,树只多了一两层。更妙的是,B+ 树的叶子节点之间是有序链表——一旦定位到起点,范围查询(created_at > 某时间)和排序(ORDER BY)直接顺着链表往后读就行,连重新排序都省了。
但索引不是免费的。
每次写入(INSERT、UPDATE、DELETE),数据库除了改表本身,还要同步更新索引树,保持它有序。所以:
索引 = 用写的成本,换读的速度。
给每个列都加索引,写入会越来越慢,而且大部分索引根本用不上。加索引前先问自己:哪个查询会用到它?
第 6 章其实早就送你索引了。
主键就是索引。唯一约束(如 username 唯一)在数据库里也是唯一索引。所以"查询慢"常常不是"没索引",而是"没有对查询有用的索引"。
这也解释了为什么第 13 章的登录和 Session 查询不慢——username 有唯一约束自带索引,session_id 是主键自带索引。
什么时候该加索引?三条信号同时满足才加:
- 确实慢——EXPLAIN 确认是 Seq Scan(全表扫描),不是凭感觉
- 查询高频——列表接口天天被调,一年跑两次的管理报表不值得
- 写入不频繁——索引的代价在写入侧,如果写入比读取还频繁,这笔账可能不划算
加了索引却用不上的三个常见坑:
- 函数包着列:
WHERE year(created_at) = 2026——B+ 树存的是原始值,不是year()之后的值,目录对不上号 - 通配符开头:
WHERE title LIKE '%二手%'——目录按前缀排序,从中间开始匹配没法定位 - 复合索引跳列:按
(author_id, created_at)建的索引,单独查created_at用不上——复合索引的列序就是排序规则,跳过第一列等于查一本没按你想的顺序排的电话簿
复合索引的实用口诀:等值条件的列放前面,范围条件的列放后面。 先精确定位,再小范围扫描。
还有一个反直觉的点:小表不一定需要索引。
几百行的表,全表扫描也就 1 毫秒。数据库优化器自己会算账——数据量小的时候,它可能主动选择全表扫描,因为走索引反而要多绕两步。所以阶段 1 别急着给所有表加索引,等数据量上来、EXPLAIN 里出现 Seq Scan 且确实慢时,再加。
主键选择也影响索引性能。
B+ 树要保持有序。自增 id 永远插在树的最右侧,追加式写入,高效。UUID 是随机的,要往树中间插,可能引发节点分裂和重新平衡——写入更慢。这是第 6 章选"自增主键"在运行时的深层理由。
回到案例:列表接口的 150 毫秒预算怎么救回来?
查询条件是作者 + 时间 + 标签,EXPLAIN 显示 Seq Scan。建一个 (author_id, created_at) 复合索引——等值在前,范围在后,符合口诀。再查 EXPLAIN,变成 Index Scan,预算回到线内。
代价记在账上:发布内容时要多更新一棵树。但内容发布频率远低于列表查询,这笔交换明确划算。
14.4 并发写乱:事务
查询变快了,但数据开始"对不上"——问题出在两个人同时操作的时候。
老周在电脑上把一条内容的标题改成 A,小明在手机上同时把同一条内容改成 B。两个请求几乎同时到达,执行顺序变成了这样:
请求 1 读到旧标题 → 请求 2 也读到旧标题 → 请求 1 写入 A → 请求 2 写入 B
最后存下来的是 B,A 被无声无息地覆盖了。两个人都收到了"修改成功"的提示,但老周的改动静默丢失了。这就是丢失更新——并发环境下最经典、也最危险的问题。危险之处在于:它不报错,系统看起来一切正常,但数据已经错了。
为什么会这样?
因为数据库的读和写是分开的步骤:先读旧值,再算新值,最后写回去。这三步之间有时间窗口,另一个请求刚好能插进来。要解决这个问题,就得让"读-算-写"三步之间,别人插不进来。
这就是事务:
BEGIN; -- 开始:从这里起,这几步是一个整体
UPDATE content SET title = 'A' WHERE id = 10;
COMMIT; -- 提交:全部生效
-- 如果中间出错了:
ROLLBACK; -- 回滚:全部撤销,像没发生过
把第 12 章的业务编排放进事务里看:createContent 里"存内容 + 发通知"这两步,如果通知发失败了,事务回滚,内容也不存。用户看到的是干净的"发布失败",而不是"内容发了但通知没发"的半成品。业务层编排了几步,事务就包几步——事务是编排的纪律。
事务有四个特性,简称 ACID,但不用死记字母,记住它们管什么就行:
- 要么全成,要么全回:三步操作里任何一步崩了,前面已经做的全部撤销。不会出现"存了一半"的状态。
- 改之前和改之后,数据都得是对的:余额不能变负数,外键必须指向存在的行——事务提交前,数据库会检查所有规则。
- 两个人同时改,互相看不见中间状态:老周改的时候,小明要么等着,要么读到改完后的最终结果,不会读到"改到一半"的脏数据。
- 提交了就是提交了,断电也不丢:数据已经安全写进磁盘。
隔离性要单独说,因为它是"对"和"快"第一次正面冲突。
两个人同时改同一行,数据库会让后一个排队等前一个。排队保证了正确,但代价是吞吐下降——一个等另一个。数据层里每个"加强"都标好了价格。
隔离还有不同档位,记住"越严格越慢"就行:
- 最松:能读到别人还没提交的改动(别人可能最后会回滚,你读到的就是脏数据)
- 中等(PostgreSQL 默认):只能读已提交的,但同一事务里两次读之间,别人可能改了,两次结果不一样
- 最严:事务开始后,看到的数据视图保持一致,别人改了也看不见,直到事务结束
档位越高越"对",并发越差。阶段 1 用默认档位就够了,丢失更新能被挡住。更细的选择留到第 17 章下单场景再展开——那时扣库存、建订单、发起支付,三步必须同生共死,事务才是主角。
事务边界定在哪?——业务层。
BEGIN 和 COMMIT 之间的范围,由编排操作的业务层决定。数据访问层只管执行单条 SQL,不决定哪几步该打包。
边界定错有两种常见病:
- 范围太小:每条 SQL 一个事务,多步操作中间崩了,留下半成品
- 范围太大:把无关操作也塞进来,锁的范围无谓扩大,别人排队等更久
只有业务层知道"哪几步必须同生共死",所以事务的起止只能由业务层定。
只读查询不需要显式事务。 读不会破坏数据,读旧一点没关系,SELECT 用默认的自动提交就行。事务是写给"写"的。
锁是隔离性的实现方式。 老周改行 10,数据库给行 10 上锁,小明想改同一行时就等着,直到老周提交。直觉就是一句话:改同一行的人排队。 排久了就是"数据库变慢"的另一个原因——不是查询慢,是等锁慢。两个人互相等对方手里的锁,就会形成死锁。数据库会检测出来,强制让其中一个回滚重试。
还有一道保险叫乐观锁。
上面的锁是"悲观"的——先锁住,再操作。乐观锁的思路相反:先读再验。
UPDATE content
SET title='A', version=version+1
WHERE id=10 AND version=3
更新时带上版本号,如果这行已经被别人改过(version 不再是 3),更新就影响 0 行。业务层发现"没改到",决定是重读重试还是报错。乐观锁和第 17 章的幂等键是兄弟——都是"操作前先校验条件"的思路。现在知道有这道保险即可。
最后划清一个边界:事务和幂等管的是不同的事。
- 事务防的是并发写乱:两个人同时改同一行,数据库用锁保证顺序
- 幂等防的是重复请求:同一笔下单因为网络重试发了两次,服务端用唯一键挡掉第二次
一个管"同时",一个管"重复"——长得像,解法不同。
14.5 快与对:数据层的权衡
全局来看,数据层的地形可以总结为:索引管读,事务管写,缓存管热。
"快"有三个手段,各有代价。
- 索引:读快写慢,给查询投资,但写入要维护树
- 合理的表结构:设计时费心,运行时坐享其成
- 缓存:更快,但一致性要付新代价
"对"有两个保证,也各有代价。
- 约束:写入时检查,慢一点,但必须
- 事务:并发互等,吞吐下降,但数据不会乱
同一个接口就能感受到这种冲突:列表接口上缓存,读快了,但可能读到旧数据,"对"打了折;列表接口开事务,数据"对"了,但并发一高大家排队等锁,"快"打了折。同一个接口,要么在"快"上让步,要么在"对"上让步,没有两头都占的方案。 这就是数据层的地形。
日常运维时,每周各花十分钟:
- 看慢查询日志
- 看索引使用统计(常年 0 次的该删)
- 盯一眼磁盘空间(数据 + 索引都在涨)
数据层的健康,不看"出没出事",看"出事前有没有信号"。
数据层决策走一遍九步决策模型:
- 业务目标:列表接口 P95 < 200ms,数据不能错
- 约束:阶段 1,几百条数据,单人开发,单库
- 候选与选择:
- 索引:加复合索引。发布频率远低于查询频率,读快写慢的交换划算——做
- 缓存:阶段 1 查询本身几毫秒,缓存省下的时间不值它的一致性风险——不上
- 换存储:数据量没到,不动
- 演进条件:首页变慢信号出现(阶段 2,P95 飙到 2 秒+)再上缓存
决策记录又添一行: 数据层——索引按查询建,缓存阶段 2 上,演进条件 = 首页变慢。
结论不是"缓存不好",是"时机未到"。
阶段 1 查询毫秒级,缓存省不出可感知的收益;阶段 2 查询秒级,缓存的价值立刻兑现。时机,是数据层所有决策的共同变量。
14.6 热数据:缓存的形态
那什么时候用缓存呢?应用中不是所有数据都同等重要。首页热门内容一天被读一百次,老内容可能一次都没有。数据库每次查询都要走索引、碰磁盘,把高频读的数据复制一份放到更快的地方——内存里——就是缓存。
缓存不是单一的东西,而是一张谱系。
从用户到数据库,每一层都在做同一件事:让热数据离用的人更近一点。
- 浏览器缓存:离用户最近,HTML/JS/CSS 存在本地
- CDN:网络边缘,静态资源就近分发
- Redis:数据库旁边,动态数据存在内存里
Redis 是什么? 一个内存数据库。读写都在内存里完成,微秒级,比查 PostgreSQL 快两个数量级。
常见模式就两行:
// 先查缓存,命中就直接返回
let hot = await redis.get('hot_contents');
if (!hot) {
// 没命中,回数据库查
hot = await db.query('SELECT ...');
// 写回缓存,下次就不用再查库了
redis.set('hot_contents', hot, { ttl: 60 });
}
注意最后的 ttl: 60——缓存必须有过期时间。 数据库里的数据变了,缓存并不知道,TTL 到期后它自然失效,下次重新从数据库读取。没有 TTL 的缓存,等于永久性的旧数据。 TTL 是缓存一致性的第一道防线。
什么样的数据值得缓存?看两个指标。
一是读写比。 "读多写少"的数据才适合缓存——首页热门内容读一百次才更新一次,缓存命中率极高。写多读少的数据(如订单流水)命中率低,缓存反而添乱。读写比是缓存的入场券。
二是 TTL 长短。 TTL 短(5 秒),数据新鲜但命中率低,动不动就过期重查;TTL 长(10 分钟),命中率高但用户看到旧数据的窗口大。TTL 跟着"这份数据多旧算旧"走——热门内容 60 秒没人介意,库存价格 5 秒都嫌长。
上缓存之前,先回答三个问题:
- 存什么——哪份数据是热数据?首页聚合?热门内容?
- 存多久——TTL 多长?跟着业务容忍度走
- 存哪层——浏览器、CDN 还是 Redis?看上面的谱系
缓存多了份数据,就要多管一份一致性,也会出现失效、雪崩、穿透等问题。
第 13 章说过:多台服务器时要共享凭证表,这是 Redis 登场的第二个理由。为什么?
Session 表本质上就是一张"凭证 ID → 用户 ID"的键值对,查询方式是精确的 key 查找,生命周期短(7 天)——这正是 Redis 的舒适区。 把 Session 从 PostgreSQL 挪到 Redis:
SET session_id user_id EX 604800 // 604800 秒 = 7 天
多台服务器连同一个 Redis,会话共享立刻完成。这也是为什么"会话共享"会成为 Redis 的第二个触发点——不只是缓存需要它,认证系统也需要它。
14.7 SQL vs NoSQL:决策模型走一遍
数据层最后一个问题:什么时候该考虑 NoSQL?
案例阶段 1 定了 PostgreSQL,现在用决策模型验证这个选择。
业务目标:内容、评论、订单、支付——要存得下、查得快、写得对。 业务约束:几百条数据,结构明确(六张表),关系多(内容属于用户、订单包含商品),要事务(下单扣库存不能错)。 候选:SQL(PostgreSQL)vs NoSQL(MongoDB 等文档型)。 评价维度:结构匹配度、关系复杂度、事务需求。 结论:案例数据结构明确 + 关系复杂 + 必须算对——三个维度全指向 SQL。NoSQL 的优势(灵活 schema、水平扩展、海量吞吐)在几百条数据面前完全用不上。 演进条件:单库扛不住(阶段 5,亿级行或连接数告急),或出现"结构不确定的数据"(如埋点日志),再评估 NoSQL。
所以:SQL 管"有结构、有关系、要算对"的数据;NoSQL 管"没结构、海量、可以忍一点不一致"的数据。
案例全是前者,所以选 SQL。
同时我们可以先了解 NoSQL 的一些常见类型:
- 文档型(MongoDB):数据像 JSON,字段可以不一样,适合结构会变的数据
- 键值型(Redis):最简形态,内存快,适合缓存和会话
- 搜索型(Elasticsearch):全文检索和分析,适合"搜"而不是"查"
14.8 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 数据要存得下、能查询 | 关系型数据库(第 6 章六张表落地) | 文件方案三问题(查询/约束/并发) | 数据库本身要运维 |
| 查询慢(列表超预算) | 执行计划排查(EXPLAIN) | 慢查询第一步诊断都是它 | 要看懂输出 |
| 查询快 | 索引(B+ 树) | O(n) → O(log n),复合索引兑现第 7 章预告 | 读快写慢、占空间、不是越多越好 |
| 并发写不乱 | 事务(ACID) | 读-算-写三步之间别人插不进来 | 并发互等,吞吐下降 |
| 数据不能半途而废 | 原子性+回滚 | 发布内容=存内容+发通知,同生共死 | 多步操作要包成一个事务 |
| 高频数据更快 | 缓存(Redis 预热) | 热数据离用的人近(CDN 同思想) | 一致性风险,阶段 1 不上 |
| SQL 还是 NoSQL | 九步决策(轻量版) | 结构/关系/事务全指向 SQL | 海量+无结构场景再评估 |
本章小结
- 数据层只回答两个问题:查得快吗?写得对吗?——索引管快,事务管对,缓存是"更快"的进阶(有代价);
- 索引不是越多越好:读快写慢——每次写入都要更新索引,按查询建索引;
- 事务是并发世界的裁判:要么都成功,要么都回滚——隔离让并发互等,"对"与"快"的第一次正面冲突;
- 缓存、NoSQL 都不是"更好",是时机与匹配——阶段 1 不上,演进条件写进决策记录;
- 慢请求线三站走完:第 10 章网络段 → 第 12 章服务端层 → 第 14 章数据库环节——下一站,把它全部串起来。
下一章
浏览器、网络、服务端、数据层——四次请求旅程的每一段都走过了:DNS 问路、HTTPS 加密、中间件管道、鉴权门禁、索引与事务。但每一章都是"放大看一段",还没有完整地走完一次。
第 15 章,联调与全链路:把整条路串起来,走一次从老周点击到页面渲染的完整请求