S22

数据库入门

点击正文段落选定收藏或划线位置;未选定时使用屏幕内第一段。

课内目录

1 为什么应用不能只靠文件和缓存保存业务真相

订单服务收到“支付成功”以后,必须回答:哪个用户买了什么、应付多少钱、订单现在是什么状态?即使应用进程重启,答案也不能消失。这里保存的是业务真相:发生争议时,系统要以它为准继续处理、核对和恢复。数据库的价值由这些责任决定,不能只用某次读取有多快来判断。

文件适合保存配置、文档和顺序记录,对象存储适合图片、视频与下载附件。它们当然能够保存字节,也可以成为某些系统的权威存储;但若订单应用直接维护文件,就要自行解决并发修改、按条件查询、部分写入失败和恢复等问题。把所有订单写进 orders.json,最初很直观;当 2 个进程同时读出旧文件、各自增加订单再覆盖,后写者可能抹掉先写者的结果。问题不是文件不能持久保存,而是应用承担了越来越多的数据管理责任。

缓存用于让常用数据更快到达应用。缓存可以过期、被淘汰,也可能在重启后丢失;它通常应当能从权威数据重建。假设缓存读取耗时 5ms,权威数据库读取耗时 20ms,节省的 15ms 不能证明订单账本应该只留在缓存里。若缓存丢失后无法确定谁已付款,得到的是业务事实丢失,而不只是性能下降。即使某种缓存产品支持持久化,也仍需逐项判断其并发约束、故障恢复和业务语义,不能凭产品名称推断适合充当账本。

业务真相与可重建数据
业务真相与可重建数据

数据库把记录组织成可查询的数据,并提供约束、并发访问与恢复机制。业务可以明确指定:“订单状态以数据库为准,缓存只保存查询副本。”缓存未命中时查库,再按策略补缓存;缓存与数据库暂时不一致时,也知道应向哪里核对。但数据库并非写入后永远不会丢失,持久性依赖正确配置和可靠存储,误删还需要备份恢复。应当把数据库理解为承载权威状态的一组机制,而不是免除所有运维责任的盒子。

2 表、行与主键:如何定位一条业务记录

关系数据库中的表可以理解为同类实体的集合,行代表其中的记录,列说明记录包含哪些属性。例如 orders(id, user_id, amount, status) 表示订单集合:id 是订单标识,user_id 是所属用户,amount 是金额,status 是当前状态。列有明确类型和约束,能够拒绝一部分不合理输入。它与随意放入不同格式文档的文件夹相比,多了一份由数据库执行的数据约定。

假设有 2 行:订单 7301 属于用户 42,金额 12900 分,状态为 created;订单 7302 属于用户 57,金额 5800 分,状态为 paid。用整数最小货币单位只是本例的金额约定,实际系统还需要写明币种与精度。若界面把 12900 分显示为 129 元,显示形式变化不应改变数据库中的金额含义。字段命名与类型应当让读写双方共享这个约定。

主键负责唯一标识记录。用户昵称会改,金额会重复,状态也只有少数取值,都不适合作为订单的唯一身份。查询 id=7301,应该定位到同一笔订单;数据库的主键约束会拒绝另一行重复使用 7301。标识的生成规则可以不同,但必须保证约定范围内唯一,而且不应因为订单状态改变就换掉标识。主键不表示记录永远不会被删除,也不自动代表事件发生的先后顺序。

表、记录与唯一主键
表、记录与唯一主键

订单通常还有多条商品明细,因此可以另建 order_items,通过 order_id 关联 orders.id;外键的直觉就是约束这种关联,避免明细指向不存在的订单。本课不展开表设计流派,但需要分清身份与关系:订单 id 标识订单,明细 id 标识明细,明细中的 order_id 表示归属。用户 42 下了 2 笔订单,不意味着它们共用订单主键;每笔订单含 3 条明细,也不意味着要把订单金额复制成 3 笔独立交易。

3 读与写:查询在做什么

SQL 是表达数据操作的常用语言。SELECT 读取记录,可以通过过滤条件选择哪些行,再通过列清单选择哪些字段。前者常称为过滤,后者常称为投影。下面的语句只取订单 7301 的标识、金额和状态,不把整张订单表传回应用。示例采用常见写法,实际驱动中的参数占位符随接口而异。

SELECT id, amount, status
FROM orders
WHERE id = 7301;

INSERT 新增记录,UPDATE 修改已有记录,DELETE 删除记录。它们改变的是业务状态,所以条件和影响行数都很重要。把 created 改成 paid 时,可以同时要求 id 和原状态匹配,防止不符合预期的状态被覆盖。若预期更新 1 行,实际影响 0 行,应用应检查订单不存在、已经处理或状态冲突等原因,不能一律显示成功。SQL 能表达操作,并不替应用决定什么业务变化才合法。

读操作也有成本。假设用户只需看最近 20 笔订单,服务却读取他的全部 100000 笔订单再在应用中截取,数据库要处理更多记录,网络要搬运更多数据,应用也要分配更多内存。若每条结果按教学估算占 1KB,100000 条约为 100MB,而 20 条约为 20KB;这些只是便于理解的近似量,实际传输还包含协议与字段编码。过滤、选择必要列、明确排序并限制返回数量,能同时缩小多个环节的工作量。

“返回 20 行”和“只检查 20 行”是不同的事。没有适合的访问路径时,数据库可能先检查大量记录才能找出这 20 行。反过来,某些统计本来就需要处理大量数据,不能仅凭扫描多就断定实现错误。判断查询是否合适,要一起看业务需要的结果、检查的记录范围和执行频率。能查出正确结果只是起点,不代表应该在每次页面刷新时重复扫描整张表。

4 事务直觉:一起成功或一起回退

一次下单可能要减少库存、写订单、写明细,再写库存变动流水。若扣库存成功而订单写入失败,库存减少却找不到订单;若订单成功而流水缺失,后续核对又没有完整证据。事务把需要共同成立的多步数据库操作绑成原子单元:全部满足条件后提交,遇到错误则回滚。提交之前,应用不能把这些中间步骤当作已经完成的业务结果。

设商品库存为 5,订单购买 2 件。事务先执行带条件的扣减:“库存至少为 2 时减去 2”,检查影响行数为 1;接着写订单、明细和库存流水。若明细校验失败,就回滚到本次事务开始前的状态,库存仍为 5,订单与流水也不留下半套记录。若全部成功并提交,库存为 3,相关记录共同成立。这里的“一起”描述最终有效状态,操作在执行过程中仍然有先后。

事务提交与回滚
事务提交与回滚

ACID 可以沿着这个例子理解。原子性要求不能只留下扣库存;一致性要求提交后的数据遵守约束与业务规则,例如库存不能变成负数、明细必须属于有效订单。数据库能执行主键、外键等约束,但业务规则仍要正确表达在应用和数据库操作中。隔离性处理同时下单的事务如何互相影响,避免把未完成的中间状态随意当成事实。持久性表示提交成功后的数据应在数据库所承诺的故障条件下保留,依赖其日志、存储及配置保障。

隔离性先抓住脏读这个直觉:事务 A 把库存暂改成 3,尚未提交;事务 B 读到 3 并据此行动,随后 A 回滚,B 就使用了从未正式成立的结果。不同隔离级别对并发可见性的约定不同,本课只要求知道事务边界与读取规则必须配合,不能认为加了事务就自动消除所有并发错误。例如先读取库存再无条件写回新值,仍需检查并发更新是否符合预期。

再用一个可读到的异常把隔离性落到数字上。设账户余额表里 user_id=7 的余额为 100。事务 A 要转出 30:先读到 100,打算写成 70,但在写之前被调度挂起。事务 B 同时读到 100,写入 100−20=80 并提交。若 A 醒来仍按自己读到的 100 写成 70 并提交,最终余额变成 70,等于丢掉了 B 已提交的扣减——这是丢失更新。正确做法不是“感觉加了事务就安全”,而是在更新时带上条件或版本:例如 UPDATE account SET bal=bal-30 WHERE id=7 AND bal>=30,检查影响行数为 1;或先读出版本号,写回时要求版本未变。若 A 的条件更新影响 0 行,应重新读取并决定重试或失败,而不是盲目覆盖。

另一个常见异常是不可重复读。报表事务 R 开始时统计“已支付订单金额”,第一次读到合计 10000。其间事务 C 提交了一笔新的 500 元支付。R 在同一事务里再读一次,合计变成 10500。若业务要求报表数字在事务内稳定,就需要更高隔离或把统计改成一次查询/物化快照;若只是运营大盘允许短暂不一致,则应在方案里写明“允许延迟与重复读差异”,而不是假装读到的永远是同一瞬间。脏读、不可重复读、丢失更新,都说明:事务边界回答“哪些写必须一起成立”,隔离级别与写法回答“别人的提交何时可见、我的写是否覆盖别人”。

事务的边界通常是同一数据库能够协调的操作。发邮件、调用支付接口不会因为数据库回滚就自动撤销,因此不要把外部副作用直接套进“一起回滚”的承诺。事务也不宜夹住长时间等待:占用连接和锁越久,其他请求越可能排队。另一个实际边界是提交结果未知:提交后连接中断,应用没收到确认,并不代表数据库没有提交。重试需要依靠业务请求标识等规则识别重复,不能直接再创建一笔订单。

5 索引:加速查找,也带来维护成本

索引像书后的目录,为某些列维护额外的查找结构。按主键找订单时,可以先定位索引中的标识,再读取需要的记录;按时间范围查订单,也可能借助有序索引缩小范围。全表扫描则要检查整张表中的记录来判断是否符合条件。索引不是结果副本,也不是给列贴上“更快”的标签,而是数据库在写入时持续维护的数据结构。

考虑有 1000 万行的 orders 表,查询 status='pending'。没有可利用的索引时,可能需要检查约 1000 万行,即使最后只有 100 行匹配。若合适的状态索引确实被使用,就可能把候选范围缩到这 100 行附近,再读取所需数据。这里比较的是千万级检查与百级候选的数量级,不是某种引擎的实测耗时;缓存、数据分布、存储和并发都会改变实际表现。

全表扫描与索引定位
全表扫描与索引定位

把同一查询落到可阅读的执行计划上。教学设定:orders 约 1000 万行,其中 status='pending' 约 100 行;查询为 SELECT id, user_id, amount FROM orders WHERE status='pending'。没有可用索引时,计划里常见 Seq Scan(或全表扫描同类算子)打在 orders 上,估计会检查约 1.0×10⁷ 行,过滤后剩约 100 行。有可用的 (status) 或覆盖所需列的索引时,计划转为 Index Scan / Index Only Scan,估计检查约 100 行量级。数字来自优化器估计,不是承诺;要用 EXPLAIN(以及产品提供的实际执行统计)对照“估计行数 vs 实际行数”。

用一组教学耗时把数量级说清楚。假设机械盘式顺序读大约能维持每秒扫描 2×10⁵ 行的量级:全表扫描 1000 万行大约要 50 秒;若索引定位 100 行且每行回表代价按 0.5 毫秒计,大约 50 毫秒。同查询从“约 50 秒”到“约 50 毫秒”,差的是访问路径,不是“加一台更大的机器”就能自动解释的。若 pending 有 800 万行,索引扫描也要碰约 800 万候选,教学耗时可能仍落在约 30–60 秒,这时执行计划即使写着 Index Scan,也不等于用户可接受。阅读计划时优先看:算子是扫描还是索引、估计/实际行数、是否有大量 Filter 掉行、是否排序/临时文件。先改成能定位少量候选的条件与索引,再谈缓存命中与硬件。

如果 pending 有 800 万行,情况就不同了。沿索引找到大量候选,再读取大量记录,未必比顺序扫描更便宜。status 属于低基数列,即不同取值数量少,但低基数不等于索引永远无效:少数状态是否很稀少、查询还带哪些条件、要返回多少字段,都影响收益。应查看执行计划来确认数据库准备如何访问数据,再用实际执行表现判断,而不是背“状态列必须建”或“状态列不能建”。

订单列表常见条件是“某用户在某时间范围内的订单”,因此可以考虑 (user_id, created_at) 这样的组合索引,让查找先缩小到用户,再缩小到时间范围。它是否适合另一条只有时间条件的查询,需要重新判断。建立索引前应写出真实查询模式,避免给每列随手各建一个索引,以为组合使用时就一定同样有效。

每次新增订单,除了写表,还可能要更新多个索引;修改被索引的状态,也要维护索引。索引占空间,增加写入与维护成本,过多索引会让原本简单的写操作承担额外工作。合理目标是让关键查询以可接受的代价完成。先明确慢在哪里、哪些访问频繁、匹配记录占多少,再决定是否建索引;如果查询本来就要求导出 800 万行,增加索引并不会消除搬运结果的成本。

6 连接与超时:应用如何连上数据库

应用连接数据库,通常需要主机地址、端口、库名和账号等配置。主机与端口帮助找到服务,库名选择目标数据库,账号用于验证身份并约束可执行操作。地址能解析、端口可到达、身份验证通过、目标库存在,是不同层次的条件。连不上时要按失败位置取证,不能看到超时就断定 SQL 有问题。账号凭据不应硬编码进示例或随错误日志输出。

S20 已说明 TLS 如何保护传输,这里把同样的直觉延伸到应用与数据库之间:根据部署要求启用 TLS,并校验服务端身份。传输受到保护,不表示表结构、事务和备份就自动正确。连接建立还可能包含网络往返、TLS 与身份验证等步骤,因此每个 HTTP 请求都新建连接,会重复付出开销,并使短时请求高峰变成连接高峰。

连接池保存一组可复用连接。请求借出连接,执行操作,完成后归还;事务期间通常需要持续使用所借连接。异常路径也必须归还资源,否则连接泄漏会让池中的可用连接逐渐减少。数据库端有连接容量,应用池也有最大连接数,两者需要一起考虑。应用有 4 个实例、每个池最多 20 条连接,合计就可能达到 80 条,还应为管理和其他服务预留容量。

连接池与等待位置
连接池与等待位置

假设每秒到来 200 个请求,每个请求平均占用连接 0.05 秒,平稳情况下平均需要约 10 条正在使用的连接。这只是“到达率乘占用时间”的教学估算,突发和长查询仍会增加等待。若平均占用变成 0.5 秒,需求就升到约 100 条。把池从 20 调到 100 也许减少应用排队,却可能把更多压力送进数据库;池大小不能替代缩短连接占用时间和修复慢操作。

还要区分超时发生在哪里。连接超时限制建立连接的等待,查询超时限制语句执行的等待,获取连接的等待超时则发生在应用池中。收到查询超时后,要确认操作是否已被取消、事务是否需要回滚以及连接能否安全复用,不能只改成更大的数字。超时是防止请求无限等待的边界,也是一条故障证据;它不保证所有写操作都没有发生。

7 常见坑:把缓存当账本、无事务的多步写、缺索引的大扫描

把 Redis 或本地文件作为订单唯一账本,常见起因是“目前用户不多,先简单实现”。真正需要核对的是所选机制能否承受预期故障。缓存清空后订单丢失,本地文件随应用实例替换后找不到,多个进程各持有一份状态,这些都会让系统无法回答哪份事实有效。业务关键状态若没有明确持久化与恢复保障,就不应只保存在可淘汰缓存或应用实例的临时文件里。

多步写没有事务,往往在正常路径上看不出问题。例如先写 orders,再循环写 3 条 order_items,第 2 条明细失败后,应用虽返回错误,数据库却留着订单和第 1 条明细。下次重试又可能生成另一笔订单。检查这种实现时,不只问“用了事务吗”,还应问失败时是否回滚、影响行数是否检查、重试是否重复执行业务,以及所有相关写入是否确实使用同一事务连接。

索引误用也有两端:完全不建关键索引,导致每次查单都扫描大表;或看见慢查询就给每列建索引,增加写入成本却没有改变实际访问路径。低基数状态列尤其需要结合分布判断。诊断时先看查询条件、返回行数与执行计划中访问路径的直觉:是在定位少量候选,还是读取大量数据再过滤?无需研究优化器内部,也能提出有证据的改进假设。

最后,慢不等于机器一定太小。连接泄漏会让请求等不到连接,过大的结果集会耗费网络与内存,长事务会延长其他操作的等待。扩容可能暂时掩盖问题,却不解释数据量继续增长后会怎样。可靠做法是保留时间范围、请求标识和相关指标,先定位等待发生的位置,再调整查询、事务边界或资源。错误现象相似时,证据比直觉更能决定下一步。

8 案例:订单库的最小数据模型与读写路径

为订单服务设计最小模型,可以使用 orders(id, request_id, user_id, amount, status, created_at) 和 order_items(id, order_id, product_id, quantity, unit_price)。orders.id 与 order_items.id 分别是主键,order_items.order_id 关联订单;request_id 保存业务请求标识,可配合唯一约束识别重复下单。金额与单价使用一致的最小货币单位,数量必须为正数。订单明细保存成交时的单价,避免商品后来改价导致旧订单金额含义改变。

先把本例的事务边界限定为写订单与明细。用户 42 买 2 件单价 3000 分的商品和 1 件单价 6900 分的商品,总额为 12900 分。服务校验输入,开始事务,插入订单 7301,再插入关联 7301 的 2 条明细,确认金额与明细合计一致后提交。任意插入失败就回滚。若业务同时需要扣库存和记录库存流水,就应把这些必须共同成立的数据库操作纳入明确边界,而不是在提交后随意补写。

详情页先按 orders.id=7301 读取订单,再按 order_items.order_id=7301 读取明细。除订单主键外,还要考虑明细的 order_id 索引:外键约束表达关联正确性,不应假设所有数据库都会自动为它生成适合查询的索引。订单列表按 user_id 和时间范围读取,候选组合索引为 (user_id, created_at),返回必要字段和有限条目。涉及多条查询时,还应明确页面是否允许读取期间状态变化,避免把显示需求误认为天然共享同一时刻的快照。

验收应使用能暴露边界错误的操作。让用户 42 与用户 57 并发各下 1 单,核对主键不同、金额各自正确、明细只归属对应订单;在写完订单但尚未写完明细时制造失败,确认没有残留半套数据;完成提交后重启应用,确认仍可从数据库查询。再按主键定位指定订单,检查在约定测试规模和负载下能否秒级返回,并观察实际访问路径。秒级是本例的验收门槛,不能据此声称所有环境都应获得某个固定耗时。

9 排查案例:慢查询、连接耗尽、备份无法回到目标点

同一订单页面变慢,可能来自不同路径。先记录故障开始时间、影响的请求和响应阶段,再把应用耗时、慢查询日志、连接数与备份记录对齐。不要把所有症状压成“数据库不行”:查询需要多久、请求等连接多久、数据能恢复到哪里,分别需要不同证据。

路径 A:最近订单页原来较快,表增长到 1000 万行后明显变慢。慢查询日志显示某条列表查询耗时升高,执行计划显示大量扫描,返回结果却只有 20 行。检查条件发现按 user_id 与时间过滤却缺少适合的索引,于是评估组合索引,并在相近数据规模下比较扫描范围与耗时。另一种证据是数据库执行并不特别慢,应用却接收 100000 条结果后才取 20 条;这时优先缩小查询结果。两种修复都要复核页面结果正确性,不能以漏掉订单换取速度。

路径 B:部分请求报获取连接超时,数据库上的单条查询耗时并未同步增加。假设连接池上限为 20,监控显示已借出长期维持 20,等待队列持续增长。若异常请求没有归还连接,修复资源释放和事务清理才是重点;若连接能正常归还,但高峰并发持续超过容量,则结合占用时间、数据库余量和多实例总连接数调整池大小。还应检查是否在借出连接后等待外部接口。单看连接数高不能区分泄漏、长事务与正常繁忙,需要借出时长和请求证据配合。

路径 C:业务要求恢复时最多丢失 5 分钟已确认的数据,现有方案每天 02:00 做一次逻辑导出。14:00 发生数据破坏,最新导出只覆盖到约 02:00,潜在缺口约为 12 小时,显然不满足要求。RPO 即恢复点目标,表达允许丢失多少时间范围的数据;它不表示恢复过程只需 5 分钟。恢复需要多久属于另一个时间目标,不能混写。

备份基线与可恢复时间点
备份基线与可恢复时间点

若要恢复到更接近故障前的时间点,通常需要可用的备份基线,以及从基线延续至目标点的变更日志。PostgreSQL 的 WAL、MySQL 的 binlog 是可用来理解这一思路的例子,具体备份与日志组合必须符合所选数据库的恢复机制。仅开启日志还不够,必须确认日志被持续保存、传送延迟受控、没有缺段,并且保留周期覆盖备份基线与目标点。逻辑导出也不天然代表任意时间点的一致快照,应确认导出工具与模式的保证。

假设已有有效基线,但最后可用日志停在 13:52,14:00 故障时仍可能缺失约 8 分钟数据,依旧超过 5 分钟 RPO。若日志只保留 6 小时,而恢复所需的基线在 12 小时前,链路也可能断掉。验收证据因此不能只有“备份任务成功”:要查看备份覆盖的时间点、日志连续性、归档延迟和保留策略,并在隔离环境恢复到指定目标点,核对订单及明细。误删恢复还要选择误删之前的点,不能机械追求最新时间。

放进方案里

方案首先写清权威数据是什么,以及为什么必须落库。对订单服务,订单归属、金额、状态与明细共同构成业务事实,应用重启和缓存失效后仍必须可核对。缓存只保存可从数据库重建的查询副本;已确认订单、支付处理结果和库存变动等关键状态,禁止只留在可淘汰缓存或应用临时文件中。附件与图片可以保存在对象存储,数据库记录其业务关联。

接着列出核心表、字段含义与主键规则,说明明细如何关联订单、金额单位如何约定、重复请求如何识别。事务边界应具体到操作:订单与明细共同提交,失败共同回滚;如果扣库存和写流水也必须共同成立,就一起纳入设计。外部接口调用与数据库提交的边界要另行交代,避免把数据库事务写成包办全部业务动作的承诺。

索引从实际读取路径推导:订单详情按主键定位,明细按 order_id 查询,用户列表按 user_id 与时间范围读取。写明候选索引、返回条数限制以及验证方式,并承认维护索引会增加写入成本。连接配置应包含目标地址、端口、库名与传输保护要求;池容量要按实例数合计,分别约定获取连接、建立连接和查询的超时,以及异常后的回滚与归还规则。

最后给出备份和恢复目标。例如 RPO 为 5 分钟,表示可恢复数据与故障前状态的时间缺口不得超过该目标,不等于恢复必须在 5 分钟结束。方案应说明备份基线、连续日志、保留周期、延迟监控和恢复演练如何共同支撑它。验收材料至少覆盖并发不串账、失败无半套写入、重启后可查询、关键查询可定位,以及指定时间点恢复后的数据核对。把每项承诺连到证据,方案才有可检查的完成标准。

10 小结:从自建库概念走向云数据库(C13)

数据库让业务状态拥有明确身份、可查询的结构、可共同提交的变化,以及可设计和验证的恢复路径。表与主键回答“哪条记录”,事务回答“哪些变化共同成立”,索引回答“如何缩小查找范围”,连接池和超时回答“请求在哪里等待”,备份与 RPO 回答“能回到哪个时间点”。这些概念在 PostgreSQL、MySQL 等系统之间具有可迁移性。

进入 C13 前,可以用订单服务自查:缓存清空后事实还在吗?明细失败会不会留下订单?返回 20 行是否扫描了整张表?超时发生在取连接还是执行查询?恢复证据能否支撑 5 分钟 RPO?下一课转向云数据库,讨论托管、多 AZ 与自动备份如何改变运行责任;本课形成的数据模型和验证问题,仍是判断方案是否满足业务需要的基础。

同阶段相关课

AI 答疑

默认关闭。启用后,只发送本课标题、最近阅读的屏幕内正文摘录(最多 6000 字)与本次问题。

配置自己的服务