第 14 章 数据层:业务的记忆
副题:索引、事务、缓存——数据层只回答两个问题:查得快吗?写得对吗?
第 13 章门禁装好,请求终于可以一路走到业务层,干真正的活了。但业务干的活,全都围着一样东西转:数据——内容要存、评论要存、用户的登录态要存。第 6 章建好了六张表,本章是它们的运行时:数据怎么存得下、查得快、写得对。跟着第 10 章那条还没断的慢请求线走——这一站,轮到数据库。
本章要建立的认知
- 数据层只回答两个问题:查得快吗?写得对吗?——索引管快,事务管对,缓存是"更快"的进阶(有代价);
- 索引不是越多越好:读快写慢——每次写入都要更新索引,索引是"用写的成本换读的速度";
- 事务是并发世界的"裁判":要么都成功,要么都回滚——下单扣库存那一幕,第 17 章它当主角。
14.1 数据去哪了:为什么是数据库
先回答一个最基础的问题:**业务数据为什么存在数据库里,而不是文件里?**阶段 1 的代码量,文件其实装得下——第 6 章之前的很多新手项目,就是把内容写进一个 JSON 文件。文件方案的三个问题,正好是数据库的三个存在理由:
先交代第 6 章的六张表在运行时的状态(它是本章的地图):content/comment/user/product 四张表已经活跃(阶段 1 的内容社区),order/payment 两张先建好、后启用(第 6 章说过"表先建好,功能后启用",阶段 3 交易上线时它们才被写入)——数据层的地基在第 6 章已经打好了,本章讲的是地基上的运行规则。
问题一:查询要靠自己写。"按作者找内容""按时间倒序找前 20 条"——文件方案要自己遍历、自己排序、自己分页,每次写一遍;数据库用一行 SQL 回答(第 6 章建的表,现在要查了)。问题二:约束没人管。"username 唯一""内容不能为空"——文件方案里这些全靠代码自觉,漏一处检查,脏数据就进去了;数据库的约束(第 6 章建表时加的)在写入那一刻强制执行。**问题三:并发读写没人协调。**两个请求同时写文件——后写的覆盖先写的,没人拦着;数据库把并发写纳入管理(本章 14.4 的主角)。
一句话:文件适合"自己写的、自己读的、不常变的"数据;数据库适合"多方读写、有规则、会变化的"数据——业务数据属于后者。第 6 章建表时讨论的"表结构设计",在运行时就是这一层:数据访问层(第 12 章)把 SQL 发给数据库,数据库负责存储、查询、约束、并发——第 12 章的 150ms 预算,大头就花在这里。
数据在数据库里的完整生命周期(图 6-2 的数据流在这里放大):写入(业务层编排 → 数据访问层 INSERT → 约束检查 → 落盘)→ 读取(查询 → 索引定位 → 返回)→ 过期/删除(第 13 章说过的会话记录清理、将来要做的内容下架)——每一段都有本章的机制在管(写入=约束+事务,读取=索引,清理=生命周期管理)。
第 5 章的选型也在这里兑现:当时选 PostgreSQL 的理由(约束、事务、JSONB)——现在一一对应到运行时的能力:约束(第 6 章的 username 唯一、内容非空,写入时强制执行);事务(14.4 的主角);JSONB(第 6 章的标签字段,JSON 型查询本章 14.7 收尾时提到)——选型时写在纸上的理由,运行时都是真实的能力。
数据库"为什么慢"的物理直觉也补一句(性能优化时用得上):数据库的慢,主要是磁盘的慢——内存读写是纳秒级,磁盘读写比它慢两三个数量级(SSD 微秒级、机械盘毫秒级)。所以数据库的一切加速手段(索引、缓存、内存表),本质都是少碰磁盘、多利用内存。这个直觉比任何优化清单都耐用:看到"数据库慢",第一反应不是"换数据库",是"它碰了几次磁盘"。
数据库的"进程形态"也顺带一句(连接池那句的地基):数据库是独立运行的进程/服务(PostgreSQL 常驻在服务器上),应用通过连接池与它通信——数据层不是"代码里的一个模块",是"一台机器上的另一个程序",它有自己的内存、磁盘和日志(第 25 章可观测性会看到它的监控指标)。
备份也补一句(它是"持久性"之外的另一层地板,第 24 章高可用会展开):事务的持久性防的是进程崩溃(WAL 自动恢复),备份防的是磁盘坏了/手滑删库——两件事,都要有。阶段 1 的备份可以朴素(每天一次全量导出),但不能没有——"数据不能丢"(第 4 章验收标准)的地板,一半是事务,一半是备份。
连接池也预告一句(第 12 章的中间件 10ms 预算里有它的位置):每个请求都新建数据库连接是浪费(建连有握手开销)——连接池让一批连接反复用(用的时候借、用完还)。阶段 1 的 10ms 中间件预算里,连接池省下的就是"每次借现成的连接"和"每次新建连接"的差别;阶段 5 的"数据库连接数告急"(五阶段故事线里的单体痛点),本质就是连接池不够用的信号。
数据访问层长什么样(第 12 章的 ORM 兑现延续):第 12 章说过 ORM 做"数据库行 ↔ 程序对象"的翻译——运行时它把业务层的调用变成 SQL:contentRepo.findByAuthor(1) 背后就是 SELECT * FROM content WHERE author_id = 1。你写的是函数调用,数据库收到的是 SQL——所以排查查询慢时,要知道函数背后对应的 SQL 是什么(ORM 的日志会打印)。
14.2 查询慢在哪:执行计划
现在进入正题。第 10 章那条慢请求线,走到这一站:
第 10 章:TTFB 慢 → "服务端慢(第 12 章)、数据库慢(第 14 章)都体现在这里"; 第 12 章:服务端内部定位 → "先看数据访问层(查库慢?第 14 章)"; 本章:数据访问层也慢了——列表接口 P95 超过 200ms 预算(第 7 章),层内预算里数据层占 150ms(第 12 章),现在超了。数据量其实不大(几百条内容+评论),问题出在查询本身。
并发不是大公司的专利——先立住这个前提(14.4 的丢失更新事故需要它):阶段 1 虽然是单人开发,但用户不止一个——老周、小明、路过的访客,加浏览器自己(多标签页就是多个"用户")——只要有两个请求同时到达,并发就存在。并发问题不是"流量大了才遇到",是"从第一个请求开始就存在",只是流量小时撞上的概率低。
数据库怎么执行一条查询?它不是"按你写的顺序傻瓜式跑",而是生成一个执行计划(怎么找数据、按什么顺序 join、用什么方式过滤),然后按计划执行。执行计划可以显式看(EXPLAIN 一行命令),这是排查"查询为什么慢"的第一工具:
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 on content c ← 全表扫描:一行一行翻
-- Filter: author_id = 某值
关键看这一行:Seq Scan(全表扫描)——数据库把 content 表从头到尾翻了一遍。几百条数据翻一遍其实只要几毫秒,但慢在组合:JOIN 用户表 + 按时间排序 + 分页取 20 条——排序要把全部行排完(不能提前停止),JOIN 要反复查用户表。数据量再涨十倍,全表扫描就是十倍以上的开销——预算爆掉只是数据量还没大时的预警。
预算的思维也要立住(第 4 章验收标准的延续):150ms 是预算,不是目标——预算是"超了就要查"的警戒线,不是"压到越低越好"的 KPI。回线内(120ms)就够了,剩下的预算留给未来的数据量增长;为"再快 30ms"去动表结构,是拿第 6 章的"改表贵"冒险——预算管理的目的是"知道什么时候该管",不是"永远在管"。
JOIN 的直觉补一句(它决定你对慢查询的理解):JOIN 就是"嵌套查找"——外层每拿到一行内容,都要去用户表找对应的作者(第 6 章的外键在这里起作用)。没有索引时,这个"去用户表找"又是全表扫描——两个全表扫描叠在一起,慢就是平方级。索引把每次"找"变成 O(log n)——这也是为什么"JOIN 的关联列(外键)必须建索引"是数据库的黄金规则。
排查顺序(第 12 章定位法的延续):先 EXPLAIN 看执行计划 → 找到 Seq Scan → 问"为什么没走索引"——答案通常是:没有合适的索引,或者查询写法让索引用不上(14.3 会讲)。慢查询 90% 的第一步诊断都是同一个动作:EXPLAIN——这个动作比任何"数据库优化技巧"都值钱。
慢查询还要"被看见"(第 25 章可观测性的一个伏笔):数据库日志里开慢查询记录(超过 200ms 的 SQL 打一条日志)——不记录,慢查询只会在用户抱怨时才知道;记录了,每周扫一眼日志就是最便宜的巡检。排查从"被记录"开始——这是第 25 章"可观测性第一原则"在数据层的预演。
EXPLAIN 的输出只看两列就够起步(不用全懂):type(执行方式——Seq Scan 全表扫 vs Index Scan 走索引,第一眼就看它)和 rows(数据库估的行数——估 100 实际跑出 10 万,说明统计信息该更新了)。两列加起来就是诊断的八成:type 不对 = 索引问题,rows 不对 = 数据分布/统计问题——剩下的两成才是调 SQL 写法。
N+1 查询(数据访问层最隐蔽的慢来源,和第 12 章 ORM 直接相关):循环里查库——列表接口查出 20 条内容,然后循环里每条内容再查一次作者:1 次主查询 + 20 次子查询 = 21 次数据库往返。单次都很快,但往返次数才是真正的成本(每次往返都有网络+解析开销)。修法:一次查询 JOIN 出来(JOIN users),或者 ORM 的预加载(一条 SQL 把关联数据一起取)。排查口诀:慢接口先数数它发了多少条 SQL——数出来是 1 条还是 21 条,一半的问题当场就清楚了。
深分页(分页查询的隐藏坑):LIMIT 20 OFFSET 10000——数据库要跳过前 10000 行才能拿到第 10001 行,OFFSET 越大越慢(每页都重新扫前面的行)。翻到第 500 页时,这个查询和全表扫描差不多了。修法一句:用"上一页最后一条的 id"做游标(WHERE id < 上次的 id ORDER BY id DESC LIMIT 20),而不是 OFFSET——翻页和翻书一样,记住上次翻到哪,比每次从头数页快。
14.3 索引:读快写慢的交换
索引是什么?它是一份"目录"——像书的目录:不翻全书,先查目录定位页码。数据库的目录结构是 B+ 树(图 14-1):一个有序的树,查询从根出发逐层下钻,一次查询只需要访问树上的少数几层节点(而不是全表所有行)。
B+ 树回答"为什么索引能加速":全表扫描是 O(n)(翻每行),B+ 树是 O(log n)(树有几层就访问几次)——数据量从 1 千涨到 100 万,全表扫描慢一千倍,B+ 树只多访问两层。这就是索引的价值:它把"查询成本随数据量线性增长"变成"对数增长"。还有一个隐藏福利:ORDER BY 排序——叶子链表已经有序,按索引列排序的查询直接顺着链表读,连排序都省了(执行计划里的 Sort 步骤消失)。
树的细节在脑子里搭一次(图 14-1):根节点在顶,叶子节点在最底层,而且叶子之间是有序链表——查询从根逐层下钻到叶子;"有序"让两件事变快:等值查找(直接定位)和范围查询(created_at > 某时间——找到起点后顺着叶子链表往后读,不用重新查)。这也是"为什么索引列的顺序就是查询的顺序"的物理来源。100 万行的表,B+ 树通常只有三四层——查 100 万行和查 100 行,走索引时几乎一样快,这是索引最反直觉也最有用的地方。
第 6 章的约束,其实已经建了索引:唯一约束(username 唯一)在数据库里就是唯一索引(不重复的 B+ 树);主键也是索引。所以"给表加索引"不是新概念——你建表时主键和唯一约束已经送了你两个索引;本章加的是"按查询需求"的额外索引。这也解释了为什么"查询慢"常常不是"没索引",是"没有对查询有用的索引"——主键索引帮不了按 username 查。
回看第 13 章的两个高频查询,它们已经偷偷用上了索引(现在知道为什么登录不慢):登录按 username 查——username 唯一约束自带索引;Session 按 session_id 查——主键索引。第 13 章的"不慢"不是运气,是第 6 章建表时约束的红利——约束和索引在数据库里是同一件事的两面。
但索引不是免费的——每次写入(INSERT/UPDATE/DELETE)都要同步更新索引:插入一行内容,除了写 content 表,还要把它的 key 插进 B+ 树(保持有序)。所以:
索引 = 用写的成本,换读的速度。这就是"读快写慢"的完整含义:加索引让查询变快,但让写入变慢(还要占磁盘空间)。所以索引不是越多越好——给每个列都加索引,写入会越来越慢,而大部分索引根本不会被查询用到。加索引前问一句:"哪个查询会用到它?"(UPDATE 的代价再具体一点:更新一行 = 删旧 key + 插新 key——两棵树的动作,这就是"更新"在索引世界里比想象中贵的原因。)
主键的选择也影响索引(第 6 章决策的运行时后果):B+ 树保持有序,顺序插入(自增 id)永远插在树的最右叶子——高效;**随机插入(UUID)**要往树的中间插,可能引发节点分裂——写入更慢。这是"自增 id 当主键"在运行时的理由(第 6 章选自增时图的是"简单、快",现在看到了机制)。建表时的主键决策,决定了索引树的日常行为——第 6 章的每一个决定,都在本章有回声。
什么查询用不上索引(三个最常见的坑):
- 函数包着列:
WHERE year(created_at) = 2026——列被函数改过,B+ 树里的顺序没用了(树的 key 是原始值,不是 year 之后的值); - 以通配符开头:
WHERE title LIKE '%二手%'——目录按前缀排,从中间开始匹配没法用目录; - 复合索引的列序:
(author_id, created_at)复合索引——按"作者+时间"建目录,WHERE author_id = 1 ORDER BY created_at能用上;但单独按时间查(WHERE created_at > ...)用不上——复合索引的列序就是目录的排序规则,跳过第一列等于查一个没按你想的顺序排的目录。列序的实用规则一句:等值条件的列放前面,范围条件的列放后面(WHERE author_id = 1 AND created_at > ...——先按作者精确定位,再在作者的时间段里扫范围)。
小表不一定要索引(一个反直觉但要说的点):几百行的表,全表扫描也就 1ms——优化器自己会算账:数据量小的时候它可能主动选全表扫描(走索引反而多两步)。所以"表小就不慢"也是真的——索引的收益随数据量增长,数据量小的时候别急着加(这也是 14.5 决策记录里"阶段 1 按需加"的依据)。
第 7 章预告过:"筛选条件多了要建复合索引(第 14 章)"——现在兑现:列表接口的查询(作者+时间+标签),建一个 (author_id, created_at) 复合索引,EXPLAIN 从 Seq Scan 变成 Index Scan,150ms 预算回到线内。代价登记在账:写入多更新一棵树——内容发布频率不高,这笔交换划算。
顺手练一个"哪列该建索引"的小场景(把检查单用一遍):评论接口 WHERE content_id = 5——评论按内容查是高频查询,content_id 是外键(第 6 章),写入频率(发评论)远低于读取——三条信号全中,评论表的 content_id 建索引。这个场景第 15 章全链路走查时会再出现,先记住判断过程。
加索引的检查单(什么时候该加、什么时候不该,三个信号):① 查询确实慢(EXPLAIN 确认是 Seq Scan,不是凭感觉);② 这个查询是高频的(列表接口被反复调,一个一年跑两次的管理报表用不着);③ 写入频率不高(索引的代价在写入侧,发布内容的频率远低于读取)。三条都满足才加——加索引是给查询投资,投资要投在常走的路上。
两个进阶细节混个脸熟(性能优化时会用到):覆盖索引——查询要的列全在索引里,就不用再回表读整行(快一步);索引使用统计——数据库会记录每个索引被用了多少次(pg_stat_user_indexes),常年 0 次使用的索引就是该删的:索引和代码一样,也有死代码。
14.4 并发写乱:事务
查询变快了,数据却开始"对不上"——事故发生在两个浏览器同时操作的时候。
老周在电脑上把一条内容的标题改成 A,小明在手机上同时把同一条内容改成 B。两个请求几乎同时到达:请求 1 读到旧标题 → 请求 2 读到旧标题 → 请求 1 写入 A → 请求 2 写入 B——最后存的是 B,A 被覆盖了。两个人改的都"成功"了,但有一个人的修改静默丢失——这就是丢失更新(并发读改写最经典的问题)。注意:两个请求都返回成功,没有报错——最危险的数据错误,是不报错的错误。
为什么并发会乱?因为数据库的读和写是分开的步骤:读旧值 → 算新值 → 写回,三步之间,另一个请求插了进来。要解决,得让"读-算-写"三步之间别人插不进来——这就是事务:
BEGIN; -- 开始事务:从这里起,我的操作别人插不进来
UPDATE content SET title = 'A' WHERE id = 10;
COMMIT; -- 提交:让 A 生效
-- 出错时:
ROLLBACK; -- 回滚:所有改动撤销,像没发生过
把第 12 章的编排放进事务里看一次(它俩是一对):createContent 的"存内容 + 发通知"——通知发失败(第三方接口超时),事务回滚,内容也不存——用户看到的是"发布失败"(干净的错误),而不是"内容发布了但通知没发"(半成品状态)。业务层编排几步骤,事务就包几步骤——第 12 章说业务层是"动作的指挥",事务就是指挥的"纪律"。
事务的四个性质(ACID,图 14-2):
- 原子性(Atomicity):事务里的操作要么全成功,要么全撤销——发布内容 = 存内容 + 发通知,第 12 章说过这是业务层的编排;如果存完内容、发通知前崩了,事务回滚,内容也不存——不会出现"存了一半"的状态;
- 一致性(Consistency):事务前后,数据满足所有规则(约束、余额不为负……)——从"对的状态"到"对的状态";
- 隔离性(Isolation):两个事务同时跑,互相看不见对方的中间状态——解决丢失更新:请求 1 改的时候,请求 2 等着(或读到的是改动后的值);
- 持久性(Durability):提交成功的数据,断电也不丢(写进了磁盘)。
术语澄清一句(它经常混):第 13 章的"一致性"(密码哈希、凭证防伪)和第 14 章的"一致性"(事务 ACID 的 C)是两个概念——前者是"数据可信"(业务层的事),后者是"事务前后数据都满足规则"(数据库的事)。同一个词,两层含义——本书用到时都跟着上下文走,读者不用死记,知道它们不同即可。
隔离性的代价要诚实:并发事务互等,吞吐会降(一个等另一个)——"对"和"快"在这里第一次正面冲突(14.5 展开)。隔离还有不同级别(读已提交/可重复读……),阶段 1 记住直觉:默认级别下,丢失更新被挡住,但"读到别人改到一半的数据"这类更细的问题要靠更高级别——具体选择在第 17 章下单时遇到再展开(下单扣库存是事务的教科书场景:扣库存 + 建订单 + 发起支付,三步必须同生共死——第 17 章它当主角)。
隔离级别的三档直觉(不背定义,记住"问题逐级变细"):读未提交——能看到别人没提交的改动(脏读,最乱);读已提交——只能看已提交的(PostgreSQL 默认就是这档),但同一事务里两次读之间别人可能改了(不可重复读:同一条数据两次读到不同值);可重复读——事务开始时的视图一致(更稳,并发代价也更大)。档位越高越"对"、并发越差——和索引的"读快写慢"一个形状:数据层的每个"加强",都标好了价格。
还有一个"不需要事务"的场景要分清:只读查询不需要显式事务——读不会互相破坏(读旧一点没关系),SELECT 用默认的自动提交就行。事务是写给"写"的——"读多写少"的系统事务负担小,"写多"的系统才需要在事务上精打细算。
一个容易的误解要澄清:事务不是"慢的数据库功能",是**"对"的默认保证**——PostgreSQL 默认每条语句就是一个小事务(自动提交),你写 UPDATE 时它已经在一个事务里了;显式 BEGIN 只是把多步操作包成一个更大的原子单元。事务的思维是"一组操作当成一个整体"——第 12 章业务层的编排,落到数据层就是事务。
事务的边界定在哪?——业务层(这是第 12 章分层的一个细节兑现):BEGIN 和 COMMIT 的起止,由编排这些操作的业务层决定(createContent 里:存内容 + 发通知,包在一个事务里);数据访问层只执行单条 SQL,不决定边界。边界定错了的常见症状:事务开太小(每条 SQL 一个事务,多步操作中间崩了还是半成品)或开太大(把无关操作也包进来,锁的范围无谓扩大,并发更堵)——编排层(业务层)是唯一知道"哪几步必须同生共死"的地方。
事务和错误处理也有一层配合(第 12 章说过的两种错误风格在这里分岔):返回错误对象的风格,事务要自己判断"出错就 ROLLBACK";抛异常的风格,框架/ORM 通常约定"事务里抛异常自动回滚"——两种风格对事务的写法不同,选一种写进团队规范(12.1 的话在这里回收)。
**隔离性靠什么实现?锁。**数据库用锁让"读-算-写"三步之间别人插不进来:请求 1 改了行 10,行 10 被锁住,请求 2 改行 10 时等待,直到请求 1 提交。锁的直觉就一句话:改同一行的人排队——排队等久了就是"数据库变慢"的另一个来源(不是查询慢,是等锁慢)。两个事务互相等对方持有的锁,就叫死锁(数据库会检测并让其中一个回滚重试)——阶段 1 几乎不会遇到,第 17 章下单场景再展开。
丢失更新还有一道保险:乐观锁。悲观锁是"先锁再读"(数据库行锁),乐观锁是"先读再验":UPDATE content SET title='A', version=version+1 WHERE id=10 AND version=3——更新时校验版本号,版本对不上(别人已经改过)就更新 0 行,业务层发现"没改到"再决定重读重试。乐观锁是第 17 章幂等键的兄弟(都是"操作前先校验条件"的思路)——现在知道有这道保险即可。
事务与幂等的边界也提前划清(第 17 章会精确展开):事务防的是"并发写乱"(两个请求同时改同一行,14.4 的丢失更新);幂等键防的是"重复请求"(同一笔下单请求被重复发送——用户双击、网络重试,第 7 章的 order_no)。一个管"同时",一个管"重复"——两个问题长得像,解法不同:事务靠锁,幂等靠唯一键。
14.5 快与对:数据层的权衡
索引和事务都到位了,站远一点看数据层的地形:
"快"的三个手段各有代价:索引(读快写慢,14.3);合理的表结构(第 6 章,已经付过了);更快的存储(14.6 的缓存——快,但一致性要付新代价)。"对"的两个保证各有代价:约束(写入时检查,慢一点但必须);事务(并发互等,吞吐下降)。
"快"和"对"的冲突还有一个具体实例(同一个接口的两种"慢",感受一下):列表接口上缓存——读快了,但可能读到旧数据(缓存没失效,"对"打了折);列表接口开事务——数据"对"了,但并发一高大家排队等锁("快"打了折)。同一个接口,要么在"快"上让步,要么在"对"上让步——没有两头都占的方案,这就是数据层的地形。
数据层的地形一句话总结(图 14-3 的注脚):索引管读(快)、事务管写(对)、缓存管热(更快但有价)——三个工具,三种价格,全章的内容都能归到这九个字里。
日常运维的直觉也立一个(第 25 章可观测性的数据层清单,先记三件):每周看慢查询日志(14.2 开的记录)、每月看索引使用统计(14.3 的 pg_stat)、磁盘空间盯一眼(数据+索引都在涨)——三件事各花十分钟,数据层的"意外"大部分会在变成事故前被发现。数据层的健康不是"不出事",是"出事前有信号"——第 25 章这句话,本章先埋下。
决策记录(第 5 章模型的又一处应用,轻量版):业务目标——列表接口 P95 < 200ms(第 7 章预算)、数据不能错;业务约束——阶段 1 数据量几百条、单人开发、单数据库;技术问题——要不要加索引、要不要上缓存、要不要换存储?候选与评价:索引(加复合索引,读快写慢——发布频率低,划算);缓存(阶段 1 数据量小,查询本身几毫秒,缓存省下的时间不值它的一致性风险——不上);换存储(数据量没到,不动);方案选择——索引做、缓存不做、存储不动;演进条件——首页变慢信号出现(阶段 2)再上缓存——这正是第 16 章 Redis 登场的第一个触发点(第 13 章说过第二个:会话共享)。决策记录又添一行:数据层——索引按查询建,缓存阶段 2 上,演进条件=首页变慢。
这张决策记录和前面七张放在一起,就是第 5 章那张表的数据层视图:语言/数据库/框架/凭证/分层/索引/缓存——每一行的理由都写在约束里,每一行的演进条件都写在数字里(阶段 2 的 P95 2s+ 会同时触发缓存决策的"重选")。
这一段的结论不是"缓存不好",是"缓存要等收益大到盖过一致性风险再上"——阶段 1 的查询是毫秒级,缓存省不出可感知的收益;阶段 2 的查询是秒级(P95 2s+),缓存的价值立刻兑现。时机,是数据层所有决策的共同变量。
14.6 热数据:缓存的形态(认知预热)
缓存"为什么存在",现在可以完整说了:不是所有数据都同等重要——首页的热门内容被读一百次,老内容被读零次。数据库每次查询都走索引、走磁盘,把高频读的数据放一份在更快的地方(内存),就是缓存。第 11 章的 CDN 是"静态资源的缓存"(放在离用户近的网络边缘),第 16 章要上的 Redis 是"动态数据的缓存"(放在离数据库近的内存里)——同一个思想:热的东西离用的人近一点。
Redis 的基本形态(第 16 章之前先混个脸熟):一个内存数据库——读写都是内存操作(微秒级),比查 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 的缓存,是"永久性的旧数据"。
把本书出现的缓存放在一张谱系里(它们是同一个思想的三次实例):浏览器缓存(第 8 章,离用户最近)→ CDN(第 11 章,网络边缘)→ Redis(第 16 章,数据库旁边)——从客户端到数据库,每一层都有"热数据离用的人近一点"的部署。第 16 章做性能优化时,这张谱系就是"哪一层该加缓存"的检查清单。
缓存的价值看命中率:命中率 90% 的缓存(十次请求九次不用查库)和命中率 30% 的缓存(大部分请求还是打到数据库)完全不是一回事——适合缓存的数据是"读多写少"的(首页热门内容:读一百次写一次);写多读少的数据(订单流水)缓存命中率低,缓存反而添乱。所以"要不要缓存"先问"这个数据的读写比是多少"——读写比是缓存的入场券。
TTL 长短是新鲜度与命中率的权衡:TTL 短(5 秒),数据新鲜但命中率低(动不动就过期重查);TTL 长(10 分钟),命中率高但用户看到旧数据的窗口大。TTL 的选择跟着"这份数据多旧算旧"走——热门内容 60 秒没人介意,价格库存 5 秒都嫌长(第 17 章下单时这个权衡会咬人)。
缓存的全套代价(失效/雪崩/穿透)第 16 章展开——本章只需要建立三个认知:缓存为什么存在(热数据)、缓存的基本形态(内存+TTL)、缓存的本质代价(多了一份数据,就要多管一份一致性)。上缓存之前还有三个问题要回答(第 16 章会精确展开,先记着):存什么(哪份数据是热数据——首页聚合?热门内容?)、存多久(TTL 多长——跟着"多旧算旧"走)、存哪层(浏览器/CDN/Redis——14.6 的谱系)。三个问题都有答案,缓存才不是"先加上再说"。
把第 13 章的伏笔在这里接上:第 13 章说"多台服务器时要共享凭证表——第 16 章 Redis 登场的第二个理由"。机制上怎么看?Session 表(13.4)本来就是一张"凭证↔用户"的 KV 表——键值查询、短生命周期、天然适合放内存——这正是 Redis 的舒适区:把 Session 表从 PostgreSQL 挪到 Redis(SET session_id user_id EX 604800),多台服务器共享同一个 Redis 就完成了会话共享。第 13 章的表,在第 16 章的缓存里找到了第二个家——这就是为什么"会话共享"会是 Redis 的第二个触发点:不只是缓存需要它,会话也需要它。
14.7 SQL vs NoSQL:决策模型走一遍
数据层的最后一个决策:什么时候不用关系型数据库?——NoSQL(非关系型)这些年名声很大(MongoDB、Redis、Elasticsearch……),但第 5 章选型时案例定了 PostgreSQL。为什么?走一遍九步(轻量版,第 5 章模型第 9 次应用):
① 业务目标:内容/评论/订单/支付的数据要存得下、查得快、写得对;② 业务约束:阶段 1 数据量几百条、结构明确(六对象)、关系多(内容属于用户、订单包含商品)、要事务(下单扣库存);③ 技术问题:用关系型还是非关系型?④ 候选:SQL(PostgreSQL)/ NoSQL(文档型 MongoDB 一类);⑤ 评价维度:结构匹配度(数据有没有明确结构)、关系支持(要不要 JOIN/外键)、事务需求(要不要 ACID)、生态熟悉度;⑥ 打分:案例数据的结构明确+关系复杂+要事务——三个维度全指向 SQL;NoSQL 的优势(灵活 schema/水平扩展/海量数据)在几百条数据面前全部用不上;⑦ 选择:PostgreSQL(维持第 5 章决策);⑧ 收益:约束/事务/查询能力全套;⑨ 演进条件:数据量大到单库扛不住(阶段 5 规模增长),或出现"结构不确定的数据"(比如将来要存的埋点日志),再评估 NoSQL——NoSQL 不是 SQL 的替代品,是"结构不确定+海量+不要事务"场景的专用工具。(阶段 5 还有另一条路也要知道:读写分离/分库分表——那是 SQL 体系内的扩容,第 26 章架构演进再展开,比直接换 NoSQL 常见得多。)
一句话定位(记住它,比记住 NoSQL 产品名有用):SQL 管"有结构、有关系、要算对"的数据;NoSQL 管"没结构、海量、可以忍一点不一致"的数据——案例的数据全是前者,所以选 SQL 不是保守,是匹配。
一个边界提前划清(避免将来上 Redis 时显得自相矛盾):第 16 章要上的 Redis 也是 NoSQL(键值型),但那是"缓存工具",不是"主存储"——主数据还在 PostgreSQL,Redis 只是它前面的加速层。"选 SQL 做存储"和"用 Redis 做缓存"不冲突:存储管"对",缓存管"快",各管各的——本章的决策(存储=PostgreSQL)没有被第 16 章推翻,只是旁边多了一层。
第 6 章的 JSONB 标签也在这里收个尾(第 7 章预告过"标签筛选走 product_tag 关联表"):标签筛选这种"数组里找值"的查询,PostgreSQL 用 GIN 索引(专为数组/JSON 设计的索引类型)——"按标签筛内容"在第 14 章的实现 = 关联表 + GIN 索引,两个都在第 6/14 章出现过,性能优化时它们是一对搭档。
NoSQL 的三个主流形态也混个脸熟(每个一句话,够讨论用):文档型(MongoDB——数据像 JSON,字段可以不一样,适合"结构会变"的数据);键值型(Redis——最简形态,内存快,适合缓存/会话,第 16 章主角);搜索引擎(Elasticsearch——全文检索/分析,适合"搜"而非"查")。三个形态对应三类诉求:灵活、快、搜——都不是"数据库的替代品",是"特定诉求的专用工具"。
数据量直觉("什么时候算大"的标尺):阶段 2 的规模(十万用户、百万内容),PostgreSQL 单库完全扛得住(配合索引和缓存);一般到亿级行或单库连接数告急(阶段 5 的信号),才需要认真评估分库/换存储——阶段 5 之前换存储,大概率是给不存在的瓶颈付钱。第 5 章选 PostgreSQL 时说的"数据量没到别折腾",在数据层得到了第二次验证——选型时的克制,在数据量面前显得越来越对(第 26 章架构演进时,这个判断会第三次被检验)。
14.8 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 数据要存得下、能查询 | 关系型数据库(第 6 章六张表落地) | 文件方案三问题(查询/约束/并发) | 数据库本身要运维 |
| 查询慢(列表超预算) | 执行计划排查(EXPLAIN) | 慢查询第一步诊断都是它 | 要看懂输出 |
| 查询快 | 索引(B+ 树) | O(n) → O(log n),复合索引兑现 ch07 预告 | 读快写慢、占空间、不是越多越好 |
| 并发写不乱 | 事务(ACID) | 读-算-写三步之间别人插不进来 | 并发互等,吞吐下降 |
| 数据不能半途而废 | 原子性+回滚 | 发布内容=存内容+发通知,同生共死 | 多步操作要包成一个事务 |
| 高频数据更快 | 缓存(Redis 预热) | 热数据离用的人近(CDN 同思想) | 一致性风险,阶段 1 不上 |
| SQL 还是 NoSQL | 九步决策(轻量版) | 结构/关系/事务全指向 SQL | 海量+无结构场景再评估 |
每一行都在本章正文里有完整论证:数据库为什么在 14.1,执行计划在 14.2,索引在 14.3,事务在 14.4,权衡在 14.5,缓存在 14.6,NoSQL 在 14.7。先看"业务诉求"列——这一章的每一行,都是从"快与对"两个问题里长出来的。
本章对两类读者的收益(与前几章同款):前端读者看到了"接口背后的最后一公里"——列表接口那 200ms 到底花在哪(第 12 章说数据层 150ms,本章让它具体到索引和事务);后端读者把"加索引/写事务"从"照做"升级成"为什么"(索引是读快写慢的交换,事务是并发世界的裁判)。慢请求定位线(第 10→12→14 章)在这里走完三站——下一章,把整条路串起来。
本章小结
数据层速查(第三部分回指本章时翻回这里):
- 数据层两问:查得快吗(索引/执行计划)、写得对吗(事务/约束);
- 慢查询第一步:EXPLAIN 看执行计划,找到 Seq Scan 再问"为什么没走索引";
- 索引 = 读快写慢的交换(B+ 树 O(log n) vs 全表扫描 O(n))——按查询建索引,不是越多越好;
- 事务 ACID:原子/一致/隔离/持久——丢失更新的解药,多步操作包成一个整体(下单扣库存第 17 章主角);
- 缓存 = 热数据放近处(内存+TTL)——阶段 1 不上,首页变慢信号(阶段 2)再上;
- SQL 管"有结构有关系要算对",NoSQL 管"没结构海量可忍不一致"。 (六条速查对应认知结构:1 是总纲"快与对",2-3 是"快",4 是"对",5 是"更快",6 是"存储的边界"——第 15 章全链路、第 16 章性能优化回指本章时,重点翻 3(索引)和 5(缓存)。)
- 数据层只回答两个问题:查得快吗?写得对吗?——索引管快,事务管对,缓存是"更快"的进阶(有代价);
- 索引不是越多越好:读快写慢——每次写入都要更新索引,按查询建索引;
- 事务是并发世界的裁判:要么都成功,要么都回滚——隔离让并发互等,"对"与"快"的第一次正面冲突;
- 缓存、NoSQL 都不是"更好",是时机与匹配——阶段 1 不上,演进条件写进决策记录;
- 慢请求线三站走完:第 10 章网络段 → 第 12 章服务端层 → 第 14 章数据库环节——下一站,把它全部串起来。
下一章
浏览器、网络、服务端、数据层——四次请求旅程的每一段都走过了:DNS 问路、HTTPS 加密、中间件管道、鉴权门禁、索引与事务。但每一章都是"放大看一段",还没有一次完整的走完。
第 15 章,联调与全链路:把整条路串起来,走一次从老周点击到页面渲染的完整请求——图 8-1 那张旅程图第一次完整点亮,也是第三部分的收官。