咱们直接进入正题。今天这篇是《计算机系统基础知识》系列的第七篇软件篇里最硬核的一块——数据库系统。前面我们聊了操作系统、编译原理、网络协议这些底层软件数据库作为应用软件和数据之间的中间层位置非常特殊它既是基础软件又是业务系统的核心依赖。这篇我会把数据库系统从原理到实战拆开揉碎争取让读完的人既能应付面试笔试也能在实际项目里少踩几个坑。先说我为什么要把数据库单独拿出来讲。你随便打开一个互联网公司的招聘要求后端岗位基本都写着“熟悉MySQL、熟悉数据库原理”。但很多工作两三年的开发者你问他什么是第三范式他能背出来你问他为什么数据量到千万级查询就变慢他却只懂得加索引。这就是典型的“知其然不知其所以然”。数据库系统的知识恰恰是连接理论和实战的那座桥。如果你正在学计算机基础或者准备校招社招又或者是在项目里被慢查询、数据不一致折腾过这篇内容应该能帮你把知识体系补完整。1. 文件存储的三大痛点为什么数据库必须存在很多人学数据库的时候没有想过一个根本问题文件系统已经能存数据了为什么还要搞出数据库这么个复杂的东西这个问题想通了后面所有概念都有了立足点。试想一个最简单的场景你要做一个用户管理系统不用数据库直接用文本文件保存用户信息每行一条记录字段之间用逗号隔开。刚开始只有几十个用户读写文件都很流畅逻辑也简单。但系统跑起来之后你会陆续遇到下面三个坎。第一个坎是数据格式与逻辑耦合。假设你最初保存的是“用户名,年龄,手机号”后来业务需要增加“邮箱”字段怎么办老数据没有邮箱新数据有邮箱读文件的时候要判断每行有几个字段代码里全是兼容逻辑。再往后字段越来越多类型越来越复杂文本文件这种“扁平结构”根本表达不了嵌套数据。这个痛点本质上是数据格式和应用逻辑强耦合——改动数据格式就要改动应用代码改动应用代码就要处理老数据兼容开发和维护成本直线上升。第二个坎是并发写入和一致性问题。两个用户同时注册你的程序对同一个文件同时做追加写。操作系统层面两个进程同时open同一个文件并write最终结果可能是丢失更新一条记录覆盖了另一条。你可能说加文件锁不就行了读写锁、互斥锁加上之后吞吐量立刻掉下来而且锁粒度太粗一个用户写文件其他所有用户都得等着。更麻烦的是如果写了一半程序崩溃文件里可能残留半行数据下次读取直接解析失败。就算你写日志、做备份来恢复数据这套逻辑自己实现一遍工程量完全失控而且很难保证没有死角。到了一定规模你实际上就是在用业务代码重新实现数据库的雏形。第三个坎是查询灵活性。文件存储只有一种访问路径——按顺序扫描。你想找“年龄大于30的所有用户”就得遍历整个文件逐行解析然后过滤。字段多了之后你想按手机号查、按用户名查、按注册时间排序每种查询都要写不同的遍历逻辑索引根本无从谈起。数据量小的时候也许还能扛但一旦数据量上来这些顺序扫描的算法复杂度就是灾难。这三个痛点叠加在一起就是数据库存在的根本理由。数据库系统本质上干了三件事给你一种稳定的数据模型和查询语言SQL来解耦存储细节通过事务机制保证并发安全和故障恢复通过索引和优化器让查询尽量高效。后面所有章节都是围绕这三件事展开。从这个角度看学习数据库不能只背SQL语法。SQL只是一层壳壳底下是存储模型、并发控制、索引机制在支撑。后面我会逐个拆解。2. 数据模型的演化关系模型为什么笑到最后数据库系统发展了几十年早期其实不是关系模型的天下。如果你去看数据库教科书的序言通常会讲到三种经典数据模型层次模型、网状模型、关系模型。这个演化过程本身就是很好的理解线索。层次模型的典型代表是上世纪六七十年代的IMS系统数据像一棵树父节点下面挂子节点。组织清晰访问路径固定但表达多对多关系非常别扭。一个学生可以选多门课一门课有多个学生这种“多对多”在层次模型里要么冗余存储要么强行拆成多棵树查询的时候要靠程序员手工在多个层次结构之间跳转开发体验很差。网状模型用有向图表示数据通用图结构理论上能表达任意关系查询效率在当时也很高但代价是数据结构极其复杂程序必须非常清楚底层存储指针的走向才能写出正确的查询。说白了数据访问路径对程序员完全暴露数据库该帮你隐藏的复杂性全部摊销给了应用层。关系模型是科德E.F.Codd在1970年提出的。它做了一件影响深远的事把数据全部抽象成“关系”也就是我们常说的二维表。表与表之间用公共属性外键关联查询语言是声明式的——你只需要描述“我要什么数据”不需要描述“怎么去取”。数据的物理存储细节被彻底屏蔽了。为什么关系模型最终胜出因为它把数据逻辑独立性和物理存储独立性彻底分开了。逻辑独立性是说表结构变了应用可以尽量少改物理独立性是说数据文件怎么存、索引怎么组织对应用完全透明。这两种独立性正是文件存储方案的根本死穴。关系模型的强大之处还在于它的数学基础关系代数和关系演算。排序、投影、连接、选择这些操作都是严格定义的查询优化器可以在数学框架内重写查询计划这就给性能优化留下了空间。当然关系模型也不是没有代价。表结构是“扁平”的天然不擅长表达嵌套和递归的数据结构。你存一篇博客和它的所有评论要么拆成多张表多次join要么用json字段容纳嵌套结构。后来NoSQL运动兴起提出用文档模型或者图模型来填补这个空白——但这不意味着关系模型过时了。恰恰相反经过几十年工程打磨关系型数据库的事务能力、生态工具、运维体系已经极其成熟绝大多数核心业务系统依然建造在关系模型之上。学数据库关系模型是必修课中的必修课。3. SQL的底层逻辑声明式语言背后的执行真相SQL是全世界的通用数据库语言你得先搞清楚它的奇妙之处。一条SQL写出来你描述的是“结果长什么样”而不是“怎么算出来”。这个设计带来一个关键问题——数据库怎么把你的声明翻译成具体的执行计划这个翻译过程就是查询执行的全貌。3.1 一条查询语句的完整旅程拿“SELECT * FROM user WHERE age 30 ORDER BY create_time DESC LIMIT 10”举例。这条SQL到达数据库后大体要经历四个阶段。第一阶段是解析Parsing。解析器把SQL字符串拆成词法单元再根据语法规则组装成抽象语法树AST。这个阶段只是做语法检查发现拼写错误、缺少括号、关键字顺序不对都会在这里报错。第二阶段是语义分析Analysis。此时数据库要核对表名、字段名是否存在当前用户是否有权限访问这些表字段类型是否匹配。很多“unknown column”类错误就是在这里抛出来的。第三阶段是查询重写和优化Rewrite Optimization这是最核心的一步。我们一般认为SQL语句写出来就定了其实数据库大概率不会按你写的顺序执行。优化器会拿到语法树生成多个候选执行计划然后基于统计信息估算每个计划的代价选择代价最小的那个。统计信息包括表行数、列的基数不同值的个数、索引的区分度等。这也是为什么你有一条SQL写得“自认为很优化”但执行计划里实际走的索引跟你想的完全不一样——优化器有自己的判断。第四阶段是执行Execution。执行器按照选定的执行计划逐算子调用存储引擎接口从磁盘或内存中读取数据按流程完成过滤、排序、连接、聚合最终返回结果集。执行阶段还有一层“缓存”加速MySQL的查询缓存老版本或结果集缓存会尝试直接返回缓存结果。这就是一条查询的完整旅行。理解这个流程之后你就能明白很多优化建议背后的原理。比如为什么不建议在WHERE条件里对字段做函数运算因为这让索引列变成“非常规值”优化器很难直接使用索引往往要退化成扫描整表。又比如为什么SELECT *总是被诟病因为你需要把没用的列从存储层读出来、传输到客户端白白消耗IO和网络带宽。3.2 连接JOIN到底怎么做如果你研究过SQL执行计划的细节会发现连接算法是数据库面试的常客。因为多表关联是最常见也最容易出问题的操作。三种最基本的连接算法值得搞清楚。第一种是嵌套循环连接Nested Loop Join。外表每取一行就在内表里找匹配行。如果内表有索引这一步就是“索引点查”整体复杂度大致是外表的行数×内表单次查询代价。外表很小、内表有索引时这种算法很高效。第二种是哈希连接Hash Join。先把小表的所有连接字段放进内存哈希表再用大表的每一行去哈希表里探测匹配。关键在于只扫描两表各一次而不是嵌套循环。当两个表都比较大、而且没法用索引直接关联时哈希连接通常比嵌套循环快得多。第三种是排序合并连接Merge Join。先把两个表按连接字段排序然后像合并两个有序链表一样一次扫描完成匹配。它适合连接字段本身已经有序的场景或者需要有序输出结果的场景。实际数据库的优化器一般会结合索引情况、表大小、统计信息选择算法。为什么强调这件事因为我见过太多人只会说“加了索引就快了”而一旦遇到“为什么加了这个索引还是慢”就懵圈。看懂执行计划里的join算法你才能真正定位慢查询是卡在扫描、卡在连接还是卡在排序。3.3 GROUP BY和ORDER BY为什么会慢分组和排序是查询优化里最容易被忽略的两个算子。GROUP BY本质上是做“哈希分组”或者“排序分组”无论如何都需要把大量数据先装进内存或者临时文件。ORDER BY如果不能用索引的有序性索引的B树叶子节点本来就是有序链表就需要显式的排序操作数据量超过内存排序缓冲时会落盘做外部归并排序这一步IO开销是肉眼可见的。所以遇到“加了ORDER BY之后查询变慢”的情况不要先怀疑数据库抽风先看看有没有办法让排序走索引或者让数据量先经过WHERE过滤再排序。同理GROUP BY的优化之一是尽量让分组字段有索引这样分组本身利用索引的有序性完成省去一次显式的排序或者哈希。4. 范式理论从更新异常反推出来的实用设计准则很多开发者在学校学过范式第一范式、第二范式、第三范式、BCNF能背得滚瓜烂熟但真到自己设计表结构的时候完全想不起来范式为什么存在。这一节我想换个角度——不背定义从“更新异常”反推范式存在的理由。4.1 不拆表会有什么后果假设有一张表存订单信息订单号客户名称客户电话商品名称商品单价购买数量O001张三138001苹果510O001张三138001香蕉320O002李四139002苹果515这张表违反了第二范式部分依赖因为“客户名称”和“客户电话”只依赖于“订单号”不依赖“订单号商品名称”这个组合主键。只是看起来没什么大不了那我们看几个实际会被坑到的场景。用户改电话的时候如果只更新了其中一行另一行还是老电话这条客户数据就“不一致”了。这就是更新异常。再比如张三的订单记录全被删掉之后客户张三这个实体就彻底从系统里消失了以后想统计他都查不到——这叫删除异常。反过来还没有任何订单的新客户因为主键之一“商品名称”是空的他根本插不进这张表——这叫插入异常。三大异常的本质其实指向同一个词冗余。信息被重复存了多份改的时候不能保证同步改完删的时候牵连了不该删的内容插的时候因为有依赖项空缺而受阻。范式理论本质上就是一套系统化的“去冗余”规则。4.2 范式的本质拆表的合理出口**第一范式1NF**要求字段原子性也就是每个字段不可再分。这个要求在现代数据库设计中已经“低”得不能再低——原因是几乎所有主流关系数据库在物理存储层面就已经要求列是简单类型了。但要注意现代JSON字段的流行让“原子性”有了讨论空间一个JSON字段里可以塞一个对象数组它表面上满足1NF字段本身是单值却在实际业务上携带了嵌套结构。用不用JSON字段本质上是在灵活性和规范性之间做取舍没有绝对的对错。**第二范式2NF**解决的是部分依赖问题。所谓“部分依赖”只出现在联合主键里。一行记录的主键由多个列组成结果某些非主键列只依赖其中一部分主键列。解决办法就是拆表——把只依赖部分主键的那部分列拆出去单独成表用外键关联回来。**第三范式3NF**解决的是传递依赖。所谓“传递依赖”就是非主键列A决定非主键列B但B不直接依赖于主键。比如“商品名称”依赖“商品编号”“商品编号”又依赖“订单号”。这同样产生冗余和更新异常。拆法也简单——把商品编号和商品名称单独拆成商品表。范式的本质就是把“不合理的依赖关系”通过拆分转换成“每条数据只属于它真正依赖的那个实体”。学范式理论千万不要只背到“拆表”要能从更新异常、删除异常、插入异常这几个实际病症倒推回来这样设计表结构时你自然就会问“这个字段放在这个表里它的依赖方到底是不是这个表的主键”4.3 什么时候可以适当放开范式这里要说点反“教科书”的东西。范式不是越高越好过度拆表的代价是JOIN变多、查询变慢。一个典型的例子是订单表和用户表。理论上订单表里只需要一个用户ID就够了但为了查询方便把用户名称冗余到订单表里这显然违反了第三范式。在很多高并发读场景中这种“故意冗余”反而是性能优化手段——少一次JOIN少一次索引回表查询延迟明显下降。代价是你必须通过应用层的逻辑来保证冗余字段的同步更新或者落一个定时任务/消息队列来做最终一致性。这就是工程师面对的经典权衡规范化带来的数据一致性和查询灵活性与反规范化带来的查询性能和实现简单性。我的建议是核心交易数据、对一致性要求苛刻的数据严格按范式设计读多写少、对实时性要求不高、可接受异步同步的展示类数据可以适当反范式化。设计数据库表之前先把这张表的读写比、一致性要求和访问模式想清楚再决定范式的松紧而不是盲目追求“高级范式”或者“一张大宽表搞定一切”。5. 事务的四个特性数据库凭什么敢说“我不丢数据”数据库和文件系统最本质的区别就是事务机制。文件系统你写了一半断电文件可能损坏数据库你执行一半事务断电重启后数据要么全部生效要么全部不生效绝不会停在半中间。这个承诺来自ACID四个特性这里每一条都要真正理解。**原子性Atomicity**是最直观的一组操作要么全部完成要么全部不做。实现原子性的经典手段是日志。数据库在真正修改数据之前会先把“我要把A从1改成2把B从3改成4”这样的操作写入日志文件如果事务中途崩溃恢复时根据日志做UNDO回滚把已经改过的数据恢复原状。这里有个关键点日志本身的写入必须是“先落盘”的。很多教科书强调“Write-Ahead LoggingWAL先写日志再写数据”就是干这个用的。**一致性Consistency**很多人理解成“数据总量不变”这是错的。一致性说的是事务执行前后数据库始终满足所有约束条件主键唯一、外键有效、业务规则不冲突。它是原子性、隔离性、持久性之后水到渠成的结果。说白了如果事务内的每个操作都是有效的数据库状态也始终保持在合法状态里那整体就是一致的。隔离性Isolation解决的是并发事务互相干扰的问题。两个事务同时改同一行后写者会把先写者的修改覆盖掉这就有问题。隔离性通过加锁机制来保证。这就引出数据库的锁类型问题共享锁读锁和排他锁写锁。读读不冲突读写、写写都会冲突。如果两个事务想写同一行后面的必须等待前面释放锁。**持久性Durability**要求事务一旦提交结果必须永久保存。实现机制同样是日志——事务提交时把REDO日志刷到磁盘即使数据页还没来得及落盘崩溃恢复时根据REDO日志重放修改就能把数据找回来。我见过太多人对ACID的理解止步于“四个词能说出来”却搞不清楚为什么需要WAL为什么需要锁为什么崩溃恢复能保证不丢数据。原因其实都在上面这几段。理解ACID必须以“崩溃场景”为思考起点每次事务执行到某一步时突然断电数据库重启之后它是通过什么机制把数据恢复到正确状态的。把这条链路想通了ACID就不再是四个抽象名词而是四个工程机制。6. 索引的本质从B树到慢查询排查索引是数据库性能的第一大杀器但也是最容易产生误解的地方。很多人觉得索引就是“额外建的一张排序表”能加快查询就行了。真要深入优化你得理解索引底层的B树结构以及索引使用边界。6.1 为什么是B树而不是哈希或者二叉树先比较一下几种常见数据结构。哈希索引能实现精确点查O(1)效率但哈希表是无序的不支持范围查询不支持排序。普通二叉查找树虽然有序但极端情况下会退化成链表而且树高太高磁盘IO次数不可控。红黑树虽然平衡但同样树高较高且节点不存储连续数据对磁盘不友好。B树的优势在于单个节点页能存储大量键值对树的层数极低一般三层就能存上千万条数据。磁盘IO次数就等于树高三层就是最多三次随机IO性能非常稳定。此外B树的叶子节点是“有序双向链表”范围查询和排序直接在叶子节点上顺序扫描即可这对数据库的查询模式特别友好。为什么用“页”做单位因为磁盘和内存交换的最小单位就是页通常4KB或者16KB。B树的每个节点正好对应一个页一次IO就能载入一个节点这种设计让磁盘利用率最大化。这是B树能被数据库存储引擎广泛采用的根本原因。6.2 聚簇索引和非聚簇索引的区别MySQL InnoDB引擎的聚簇索引Clustered Index就是主键索引。它的叶子节点存储的是整行完整记录也就是说表数据本身就是一棵B树。查询走主键索引时找到叶子节点就直接拿到了整行数据无需二次回表。而二级索引非聚簇索引的叶子节点存的不是整行数据是主键值。你建了一个索引在“name”列上查询时先用name索引找到对应主键ID然后再“回表”去主键索引里查整行记录。这个过程叫回表。这个理解至关重要。如果你建的联合索引是(age, name)查询条件是“age 30”那age可以直接走索引条件变成“name 张三”联合索引里的name在age之后用不上最左前缀这个索引就排不上用场了。这就是面试常说的最左前缀原则。基于回表的概念就有了覆盖索引的优化思路如果查询只需要列age和name而联合索引包含了这两个字段查询就可以完全不用回表直接在索引上拿到结果这种索引就叫覆盖索引。设计索引时尽量让高频查询“被覆盖”省掉回表这一跳是实践中收益最明显的优化手段之一。6.3 索引失效的几个真实场景第一是对索引列使用函数或表达式。比如WHERE YEAR(create_time) 2025优化器基本无法利用create_time上的索引因为索引是按原始值排序的不是按函数结果排序。正确写法是WHERE create_time 2025-01-01 AND create_time 2026-01-01。第二是隐式类型转换。字符串列和数字比较或者数值列和字符串比较数据库会自动做类型转换一转换索引就失效。比如手机号列是varchar条件写成phone 13800138000这里数字会被转成字符串再比较还是反过来的取决于数据库规则但常常让索引失效。最稳的做法是保证字段类型和条件值类型一致。第三是LIKE前缀匹配。WHERE name LIKE %张三%这种前导通配符最左前缀原理决定了索引无法匹配。只有张三%这种后缀通配可以走索引。第四是OR连接非索引条件。WHERE a 1 OR b 2如果b上没有索引数据库可能放弃a上的索引转成全表扫描。改成两个查询UNION ALL反而能走索引。第五是NULL值判断。IS NULL、IS NOT NULL在多数情况下索引效果很差而且区分度高的索引列尽量用NOT NULL约束填充默认值。6.4 一次慢查询排查的完整链路我讲一个我在模拟项目里实际踩过的场景。某系统的订单列表页查询最近一个月订单SQL类似SELECT order_id, user_id, amount, create_time FROM orders WHERE create_time 2025-05-01 AND status PAID ORDER BY create_time DESC LIMIT 20;orders表有500万行数据create_time和status上分别有独立索引。第一次执行这条查询需要3秒多。看执行计划发现优化器选择了status这个索引因为statusPAID的选择率比较高大概30%的行都是PAID问题在于用status索引查出的30%行数还得回表读整行然后逐一过滤create_time最后排序IO开销巨大。这个问题的最优解是建一个联合索引(status, create_time)。这样索引本身就能同时过滤status和create_time而且create_time在联合索引中是有序的ORDER BY create_time DESC走索引顺序扫描LIMIT 20可以直接发挥效果。实践之后查询时长降到几十毫秒。这个案例告诉我们两件事第一单列索引和联合索引是按“组合”方式工作的你的查询条件里多个字段各自有单列索引不代表它们能自动组合成高效的联合检索第二执行计划会基于统计信息选“它认为最优”的计划不要凭直觉猜测一定要用EXPLAIN看实际计划。7. 存储引擎对比InnoDB如何成为默认选择说起MySQL绕不开存储引擎。InnoDB现在是MySQL默认引擎但它“默认”的地位不是白来的背后是功能和可靠性的长期打磨。这一节把InnoDB和MyISAM做一次深入对比你就明白为什么工程上几乎都选InnoDB。7.1 InnoDB和MyISAM的核心差异MyISAM是上古时代的默认引擎存储结构简单以表为单位存储表级锁成本低压缩率高全文索引支持早。但它的致命伤是不支持事务不支持外键崩溃恢复能力极弱。你写入一半进程挂了表文件可能直接损坏需要用repair table碰运气。InnoDB主打事务安全支持ACID、行级锁、外键、崩溃恢复。它的数据组织形式是聚簇索引也就是说表数据本身按主键排序存放在B树中。每个表不需要独立的索引文件因为索引和数据是一体的。支持行级锁意味着写并发能力远强于表级锁。另外InnoDB还实现了缓冲池Buffer Pool来缓存热点数据把随机IO尽可能转换成内存操作。早期MySQL性能测试文档里MyISAM号称“快”但那种快很大程度上建立在牺牲事务安全和并发控制的前提下。今天但凡是对数据一致性有要求的系统选InnoDB几乎是唯一理性选择。7.2 Buffer Pool和日志机制如何协同InnoDB的Buffer Pool是内存中的一块区域缓存数据页和索引页。写入数据时不会直接改磁盘文件而是先改Buffer Pool里的页同时写UNDO和REDO日志等到合适时机才把脏页刷回磁盘。这种“先写日志、延迟刷脏”的机制极大提升了写入吞吐量。这里要理解一个重要参数redo log的刷盘策略innodb_flush_log_at_trx_commit。设为1每次事务提交都要把redo log刷到磁盘保证持久性设为2每次提交只刷到操作系统缓存性能好一些但操作系统崩溃时可能丢最近1秒数据设为0完全依赖后台线程定时刷盘性能最好但可能丢更多数据。工程上默认就是用1牺牲一点吞吐换数据安全这也符合事务持久性的要求。7.3 为什么读多写少的场景也应该优先InnoDB曾经有很长一段时间很多人觉得MyISAM适合读多写少的报表场景。我的看法是除非你100%确定业务能接受突然崩溃丢表、写并发极低、不需要事务否则没有必要去碰MyISAM。InnoDB有行级锁、MVCC多版本并发控制、崩溃恢复这些能力即使读多写少它的读性能和并发能力也足够好。更别说现代InnoDB的全文索引Fulltext能力已经补齐几乎抹平了MyISAM独有优势。所以存储引擎选型上我已经很少见到正经业务还需要MyISAM的案例。默认InnoDB真遇到需要特殊处理的场景如临时表、日志表只读归档再单独评估这是最稳妥的策略。8. 隔离级别与并发控制读懂事务隔离的连锁机制隔离性这个词在工程里落地成四种隔离级别。很多人面试能背出“读未提交、读已提交、可重复读、串行化”这四个名字也知道脏读、不可重复读、幻读这三个概念但理解常常停留在名词层面。这一节我想把这些概念串成一条完整的因果链。8.1 三个并发问题的本质脏读是事务A读到事务B未提交的修改。B回滚了A之前读到的数据就成了“脏”的。不可重复读是同一个事务里两次读同一行结果不一样。两次读之间别的事务提交了对这行的更新。幻读是同一个事务里两次执行同一个范围查询第二次多出来了一些行。别的事务插入或删除了符合条件的新行这些行对第一次查询而言是“幻影”。这三个问题的严重程度递进解决它们的锁机制也递进。读未提交完全不隔离可能脏读读已提交解决脏读用“提交后才能被读到”的机制可重复读在事务开始时锁定读取范围解决不可重复读串行化直接让事务排队执行解决幻读。8.2 锁机制和MVCC是怎么配合的MySQL InnoDB对隔离级别的支持核心依赖两套机制锁Locking和多版本并发控制MVCC。MVCC的思路是数据行的每个版本都记录一个事务ID读操作读取“事务快照”版本而不是最新版本这样一来读操作不阻塞写操作写操作也不阻塞读操作并发能力大幅提升。可重复读的实现方式就是“快照读”——事务第一次执行查询时生成一个快照后面所有读都从快照里读所以两次读的结果是一样的。这解决不可重复读。但快照读在有些情况下解决不了幻读——如果事务里先执行了一次快照读之后别的事务插入了新行你又执行了一次当前读比如SELECT ... FOR UPDATE是可以看到新行的。这也是为什么InnoDB在可重复读下还会引入间隙锁Gap Lock和临键锁Next-Key Lock范围锁住索引区间阻止其他事务在该区间插入新记录从而压制幻读。8.3 咬文嚼字的隔离级别命名与并发权衡很多同学会困惑InnoDB默认隔离级别是可重复读但可重复读真能彻底防止幻读吗严格来说在完全快照读的场景下不会幻读在出现当前读加锁读的场景下靠间隙锁也能在大多数场景避免幻读但它不是标准SQL里那个严格定义的完全串行化。如果你需要百分百无幻读用串行化级别或者确保所有事务都走当前读并配合合理锁范围。这里引出一个工程问题隔离级别越高锁范围越大并发性越低。生产环境最常见的组合是普通业务用InnoDB默认的可重复读高并发系统如某些互联网业务会主动调成读已提交因为读已提交下不能脏读对绝大多数读多写少场景已经足够而且不加间隙锁写冲突更少吞吐更高。选择哪个级别要结合业务对“下一次读必须看到别的事务最新提交”这一点的容忍度来判断。9. 一点实操体会和备忘理论讲完了最后说几个我在实际项目里反复用到的判断方法和经验这些算是数据库这颗大树主干之外的分枝经验。第一写任何SQL之前先跑一遍EXPLAIN看执行计划。我见过很多“线上突发慢查询”案例一查都是少了EXPLAIN这一步。EXPLAIN那条额外输出的type、key、rows列足以告诉你SQL是不是在走全表扫描是不是回表次数过多。花半分钟看执行计划能省下面几个小时的排查时间。第二索引设计从业务查询出发不从表结构出发。表设计好之后先梳理核心查询的WHERE条件、ORDER BY字段、GROUP BY字段、JOIN字段然后基于这些高频访问模式建联合索引。有些刚入行的开发者喜欢“凭感觉给每列加索引”这样做不光浪费存储写入成本也会白白增加因为每次INSERT都要维护所有索引的B树。第三事务别开太大。一条事务里包含几百次更新操作会让Undo日志变得巨大锁持有时间过长死锁概率大增并发吞吐严重下降。事务的边界越短越好把不需要原子的操作移出事务事务内的SQL尽量精简。第四监控慢查询日志是排查问题的第一道城门。数据库最多慢SQL的地方往往就是最有优化价值的地方。超阈值慢查询不是诅咒而是数据库在告诉你这里的执行计划、索引设计、SQL写法有问题。定期或实时地看慢查询日志比整天看监控大盘更能定位瓶颈。第五删数据和归档数据要克制。DELETE大量数据导致Undo膨胀、回表压力大、锁竞争严重。要删大表的部分数据可以分批小事务删除或者新建表重建数据再原子地改名切换。同样的逻辑适用归档数据做分区表按时间分区删除旧分区是最高效的清理方式。数据库的知识体系很大这篇讲的是主干还有很多分支值得深入分布式事务、分库分表、主从复制、数据库中间件、NoSQL与关系型数据库的协同。这篇能帮你把地基打好后面遇到具体的架构问题才不会慌。我自己回头去看觉得当年如果早点把“数据库为什么存在”这个问题想明白很多知识都不用死记硬背自然就长在脑子里了。希望你也能从这篇文章里找到这种“原来如此”的感觉。