关系型数据库核心原理:事务、隔离级别与存储引擎深度剖析

发布时间:2026/10/9 3:48:25
关系型数据库核心原理:事务、隔离级别与存储引擎深度剖析
关系型数据库核心原理深度剖析事务、隔离级别与存储引擎有个很经典的面试题为什么MySQL的默认隔离级别是REPEATABLE READ而Oracle和PostgreSQL默认是READ COMMITTED如果你只能回答因为MySQL官方就是这么设置的那说明你对关系型数据库背后那套并发控制机制的理解还停留在背概念的阶段。天天写CRUD事务注解一行就搞定但真要回答数据库是怎么保证崩溃后数据不丢的为什么加了索引SQL还是慢订单和库存扣减到底该不该上分布式事务很多人就开始含糊了。这篇文章想做的事是把事务、隔离级别、存储引擎这三件事串起来讲透。它们不是三个独立的知识点而是环环相扣的一条线存储引擎决定数据怎么落地事务机制决定并发读写怎么协调隔离级别是事务机制对外暴露的策略选项。你在业务代码里写的那行事务注解底层撑起来的是undo log、redo log、锁、MVCC这一整套东西。搞懂这条线之后再看索引失效、SQL优化、分布式事务这些问题视角会完全不一样。这篇内容适合处于会用但不理解阶段的开发者事务注解用过、隔离级别背过、索引失效的坑踩过但还不清楚背后的运行机制。看完你至少能说清楚为什么InnoDB是默认存储引擎、RR隔离级别下幻读到底还会不会发生、一条慢SQL应该从哪个层面去排查、分布式事务和单机事务的本质差异在哪。1. ACID不是四个单词是四条底层机制的承诺1.1 事务回滚为什么能做到回到过去先看一条更新语句从执行到提交数据库内部发生了什么UPDATE accounts SET balance balance - 100 WHERE user_id 1;这条语句不是直接改磁盘上的记录它至少会经过这几个阶段从Buffer Pool里找到或加载这条记录、在undo log里写一条反向操作日志、修改Buffer Pool中的内存副本、在redo log buffer里记录修改、提交时把redo log刷盘。整个过程里undo log和redo log是两个极易混淆但职责完全不同的东西。undo log负责撤销。在修改之前先把旧值写入undo log这样如果事务回滚MySQL可以通过undo log里的反向操作把数据恢复到修改前的状态。它和redis里的RDB/AOF备份不是一回事undo log不是备份而是演出前录好的退路。redo log负责重做。它记录的是修改后的新值当数据库意外崩溃时内存里还没来得及落盘的数据可以通过redo log重放回来。这里有一个特别容易误解的点事务回滚不是把磁盘数据物理恢复到旧值而是通过undo log逻辑地构造出旧版本。所谓逻辑恢复是说磁盘上的数据行可能已经被改成了新值但MVCC的版本链允许你顺着指针找到事务开始时的那个版本。这也是为什么回滚和备份恢复在底层实现上有本质区别——一个是数据结构层面的操作一个是文件层面的覆盖。1.2 持久性依赖的是redo log不是数据本身很多人以为commit之后数据一定已经写进磁盘的数据文件了。实际上commit只保证redo log落盘了数据页本身可能还在Buffer Pool的内存里。MySQL靠redo log的先写日志、后刷数据策略把随机写盘转换成了顺序写盘这是性能大幅提升的关键。redo log是固定大小的环形缓冲区通过innodb_log_file_size控制。刷盘时机由innodb_flush_log_at_trx_commit控制这个参数有三个取值需要根据业务对数据安全的要求来选择参数值行为崩溃时可能丢失的数据适用场景0commit时只写内存日志缓冲区不主动刷盘最多丢失最近1秒的事务性能测试、可容忍极小概率丢失数据的日志类业务1commit时强制刷盘redo log不丢失已提交事务支付、交易等金融级场景默认值2commit时写入OS缓存由系统定时刷盘操作系统宕机时可能丢失一般互联网业务性能与安全的折中实际操作中我在压测场景见过有人把innodb_flush_log_at_trx_commit从1改成0TPS从三千直接翻到八千但代价是最近一秒内提交的事务可能在崩溃时全部丢失。对交易系统来说这不叫优化叫事故隐患。这个参数改不改取决于你的业务能不能承受这个级别的丢失而不是纯看性能。1.3 事务注解失效你可能一直在假事务里运行事务原理落到代码层面最常见的坑就是事务注解用完但根本没生效。表面上看Spring的Transactional加上去就完事了实际上它依赖的是Spring AOP的动态代理有四类经典失效场景第一方法被同类内部调用。A方法调B方法两个方法都在同一个类里且B标了Transactional这时候走的是this调用而不是代理调用事务注解直接失效。解决办法是拆分Bean或者在调用时通过代理对象调用。第二异常被吞掉。事务默认只对RuntimeException和Error回滚如果你catch住异常后没重新抛出Spring不知道这个事务要回滚结果就是看起来提交了实际数据是半截的。所以要么别在事务方法里catch异常要么catch后抛出一个运行时异常标记回滚点。第三非public方法。Spring默认用CGLIB或JDK动态代理拦截public方法private或protected方法上的事务注解不生效。第四传播行为设置错误。REQUIRES_NEW会挂起外层事务开启新事务外层抛异常回滚时内层已提交的部分不会跟着回滚。订单主流程和积分流水如果放在一个事务里积分那部分失败了整个订单也被拖回滚——你可能需要重新评估这里到底该不该共用一个事务。我踩过一次最隐蔽的坑同一个类里方法A调方法B其中B自带了Transactional(propagation Propagation.REQUIRES_NEW)当时以为B肯定会单独开事务结果B实际是在A的事务里跑的排查了整整半天最后通过日志里的事务id才看明白。从那时起我在代码里只要遇到同类内部调事务方法一律重构掉。2. 隔离级别背后的并发困局与MVCC解法2.1 四种隔离级别到底解决了什么问题新闻学有句话叫狗咬人不是新闻人咬狗才是隔离级别也是同样逻辑——它要解决的是那些并发读写导致的反常现象。四个级别从宽松到严格排列分别压制不同的问题READ UNCOMMITTED读未提交一个事务可以读到另一个事务尚未提交的修改指向的问题叫脏读。数据是半成品可能随即被回滚你读到的是个幻觉。READ COMMITTED读已提交只允许读已提交的数据脏读消失但出现了不可重复读——同一个事务里两次SELECT相同条件查出来结果不一样因为中间别的提交把数据改了。REPEATABLE READ可重复读同一事务内多次快照读取结果一致不可重复读解决但还有个幻读问题——事务A查询某些行时事务B插入了一批新行A再次查询时发现记录数变了仿佛出现了幻影行。SERIALIZABLE可串行化所有事务串行执行彻底避免所有并发问题代价是并发度降到最低。MySQL在REPEATABLE READ级别下不仅解决了不可重复读还通过间隙锁和MVCC的组合在绝大多数场景下解决了幻读。这是MySQL和标准SQL定义不一致的关键点。下面用实际SQL演示一下读已提交和可重复读两种级别下的行为差异。-- 会话session1 START TRANSACTION; SELECT balance FROM accounts WHERE user_id 1; -- balance 100 -- 会话session2 START TRANSACTION; UPDATE accounts SET balance 200 WHERE user_id 1; COMMIT; -- 会话session1再次查询 SELECT balance FROM accounts WHERE user_id 1; -- READ COMMITTED下结果变为200每次SELECT都生成新的读取视图 -- REPEATABLE READ下结果仍是100读取视图在第一次SELECT时固定这是理解隔离级别最重要的一个区别READ COMMITTED下每次普通SELECT都会生成一个新的读取快照REPEATABLE READ下事务内第一次普通SELECT生成快照后后续所有普通SELECT都复用这份快照。所以同样的SQL两个隔离级别下看到的结果可能完全不同。2.2 MVCC的三个隐藏列和版本链MVCC全称多版本并发控制核心思路是一条记录在物理上可以同时存在多个历史版本每个版本有自己对应的事务ID。读操作通过版本可见性判断哪些版本对当前事务是可见的写操作只负责创建新版本读写之间不加互斥锁于是读和写可以并发进行。具体实现依赖数据行上的三个隐藏列DB_TRX_ID记录最后修改这条行版本的事务IDDB_ROLL_PTR指向undo log中更早版本的指针DB_ROW_ID是隐藏主键如果表没有显式主键。每次UPDATE不是直接覆盖旧值而是生成一个新版本行旧版本行通过DB_ROLL_PTR串在undo log版本链上。SELECT读到的是哪个版本靠ReadView来判断。ReadView可以理解为当前事务启动那一刻的快照边界里面维护了活跃事务ID列表和两个关键阈值事务ID小于该阈值且不在活跃列表中的版本可见事务ID大于等于该阈值的版本不可见。判断规则可以简化为一句口诀事务只能看到自己启动之前已提交的数据和自己修改的数据看不到其他事务还没提交和启动之后才提交的数据。2.3 当前读和快照读同一条SQL结果不同的根源MVCC只对快照读普通SELECT生效。如果SQL是SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE这类操作叫当前读它们读取的是记录的最新版本而且必须加锁。这就是为什么RR隔离级别下一个事务用普通SELECT查不到某行但UPDATE却可能更新成功——快照读走了版本链当前读直接锁住并读取最新数据。INSERT和UPDATE还存在一个特殊机制在更新或删除时会先对涉及的记录进行当前读加锁然后检查快照如果发现该行自事务启动以来已被其他事务修改过则发生半一致性读或直接报锁等待超时。实际操作里最常见的现象是事务A开启后先SELECT一次构建ReadView事务B插入并提交了一行新数据事务A再用SELECT查还是看不到。但如果A执行UPDATE操作触发了当前读且恰好命中了B插入的行A就可能引发幻读。所以MySQL的RR级别下幻读并非100%被消除而是快照读完全消除当前读依赖锁来消除。对普通查询场景来说已足够安全但如果你在RR隔离级别下还用到SELECT ... FOR UPDATE仍然需要自己评估幻读风险。3. 存储引擎怎么选InnoDB凭什么是默认答案3.1 MyISAM和InnoDB的关键分水岭存储引擎是MySQL中数据如何组织、索引如何建设、崩溃如何恢复的具体实现者。MyISAM是MySQL 5.5之前的默认引擎在今天仍然被很多人在读多写少的场景里怀念但其实它的缺陷决定了它只适合少数几类场景。两者最关键的差异有三处。第一事务支持。MyISAM不支持InnoDB支持这直接决定数据一致性的底线。第二锁粒度。MyISAM只支持表级锁任何写操作都会锁整张表读和写互相阻塞InnoDB支持行级锁并发度高一个量级。第三崩溃恢复。MyISAM崩溃后可能直接损坏数据文件需要repair table碰运气恢复InnoDB基于redo log自动崩溃恢复基本不用担心手工修复。为什么MyISAM在特定场景仍然有存在感因为它索引结构更简单叶子节点直接存数据行地址BTree高度通常更矮全表扫描和只读查询场景下MyISAM的固定表格式效率甚至可能高于InnoDB。但问题在于今日硬件条件下这些优势已经微乎其微而事务和崩溃恢复的短板是实打实的。只要你的业务有并发写或者数据重要MyISAM就不该出现在候选列表里。3.2 聚簇索引为什么主键必须是那个索引InnoDB的默认索引结构是BTree但真正理解InnoDB存储方式的关键在于聚簇这两个字。聚簇索引的意思是数据行本身就直接存放在BTree的叶子节点上。主键索引就是聚簇索引表数据就是主键BTree。非聚簇索引或者叫二级索引的叶子节点不存数据行只存主键值。当你用二级索引查询数据时先通过二级索引BTree找到符合条件的主键值然后用这个主键回聚簇索引里再查一次完整的数据行这个过程叫回表。如果查询的列恰好都包含在二级索引里就不需要回表这叫覆盖索引。聚簇索引带来的一个实际问题如果你没有为InnoDB表设置显式主键InnoDB会选第一个非空唯一索引作为聚簇索引如果也没有就自动生成一个隐藏的DB_ROW_ID六字节整数作为主键。这意味着你在建表时偷懒不加主键InnoDB就替你生成一个完全随机的、与应用无关的聚簇索引键所有二级索引的回表路径都在这个隐藏键上。写操作和查询路径都受影响日志和监控里也看不见这个隐藏键。所以建表时给每张表设计一个有业务意义或至少稳定增长的主键不是规范洁癖是性能选择。3.3 Buffer Pool与Change Buffer性能差异的隐藏来源Buffer Pool是InnoDB在内存中维护的缓存区域逻辑上就是磁盘数据页在内存中的副本集合。所有读写操作优先作用在Buffer Pool上后台线程按条件把脏页刷回磁盘。如果Buffer Pool过大会导致内存紧张触发换页抖动过小则磁盘读频繁。经验上纯数据库服务器给到物理内存的60%-70%是常见配置通过innodb_buffer_pool_size设置。Change Buffer则是针对二级索引页的写优化。当一条UPDATE修改了二级索引列如果目标索引页不在Buffer Pool里InnoDB不立刻去磁盘读这个页完成索引更新而是把这个变更记录下来暂存在Change Buffer中等后续访问该索引页或后台刷盘时再合并进去。这能把随机写盘变成批量顺序写盘提升写吞吐。但Change Buffer不是越大越好。它占的是Buffer Pool的一部分比例由innodb_change_buffer_max_size控制。在高并发随机写场景下如果合并速度跟不上变更速度Change Buffer本身会成为瓶颈。我在实际运维中见过一个案例批量导入一百万行带唯一索引的数据Change Buffer满负荷运行写入性能反而比关闭Change Buffer更差。原因就是订单表这种高并发随机写入场景下变更积压过多合并回索引页时要做大量随机I/O。所以线上配置不能照抄默认值要看业务的随机写比例。3.4 崩溃恢复的最后一环doublewrite和两阶段提交InnoDB虽然靠redo log做崩溃恢复但还有个隐患如果数据页本身在写盘过程中发生部分写入比如写了16KB页的前8KB、断电这个页就损坏了redo log无法从损坏的页上恢复。解决方式是doublewrite在写数据页之前先把页的副本写入系统表空间的一块连续区域。这样即使数据页写一半断电也可以从doublewrite区域拿完整的副本修复。这和redo log两阶段提交是两套互补机制。两阶段提交解决的是redo log和数据文件不是同一次原子写的问题它让redo log先进入prepare状态等待binlog写入成功后再进入commit状态。为什么需要这个流程因为MySQL的binlog是复制和误删恢复的关键redo log和binlog如果不一致主从就分叉了。以崩溃分析为例崩溃发生在redo log prepare后、binlog写入前那么事务回滚崩溃发生在binlog写入后、redo log正式commit前那么事务提交并重放。这个设计保证了binlog和InnoDB存储引擎的日志在逻辑上强一致也是分布式系统中两阶段提交思想在单机数据库内部的经典应用。4. 索引优化与SQL优化从执行计划反推失效原因4.1 主键索引、唯一索引、普通索引的差异不是唯一性而已面试高频问题主键索引和唯一索引有什么区别很多人只会答主键不能为NULL。从存储引擎层面讲差异更本质主键索引是聚簇索引数据行物理上就挂在主键BTree的叶子上一张表只能有一个。唯一索引是二级索引叶子节点存的是主键值一张表可以有多个。两者都保证逻辑唯一性但主键可以不为人知地身兼数据存储结构。唯一索引比普通索引多一步每次插入或更新时需要额外做一次唯一性冲突检测这个检测会先在索引页里查询如果目标页不在Buffer Pool里还需要先读入内存。高并发写入场景下这一步的开销会被放大。所以如果业务不需要唯一约束就不该为了顺手加个唯一而牺牲写性能。关于NULL值的影响很多人也有误解。主键不允许NULL唯一索引允许多个NULL——这不是缺陷而是SQL标准里的语义NULL不等于任何值多个NULL互不相等因此唯一约束不阻止多行NULL。如果业务上有类似手机号这样同一时间只能绑定一个用户的唯一需求但同时允许NULL那么用唯一索引配合业务逻辑判断即可。4.2 明明有索引却没用上索引失效的五个经典场景索引失效的根源只有一句话优化器判定走索引的成本比全表扫描还高或者SQL写的条件让索引树根本无法有序查找。展开说最常见五种情况第一种对索引列做了函数或运算。WHERE DATE(create_time) 2024-01-01让create_time上打了函数的滤镜BTree的有序性直接作废。解决办法是改成范围条件WHERE create_time 2024-01-01 AND create_time 2024-01-02。第二种隐式类型转换。字段是varchar你传了数字或者反过来。MySQL会先把字段转为目标类型再做比较等于隐藏了一层函数。比如phone字段是varcharWHERE phone 13800138000实际上变成了WHERE CAST(phone AS SIGNED) 13800138000索引作废。我干过这事一个线上列表页查询从30ms涨到2s排查了几轮才发现是参数类型不统一。所以写SQL前确认字段类型和参数类型一致是最容易忽略的优化。第三种前导模糊匹配。LIKE %abc导致BTree无法从根节点定位起点只能全表扫。但LIKE abc%可以用索引。业务上实在需要后缀匹配考虑存一份反转字段或者交给搜索引擎。第四种OR连接的多个条件中有一个没索引。优化器为了执行OR必须同时拿到两侧结果做并集如果右侧无索引可用它只能放弃整个索引路径。正确做法是把其中一个条件也加索引或者改成UNION ALL。第五种范围条件后面的字段失效。联合索引(a, b, c)SQL写成WHERE a 1 AND b 10 AND c 5c只在b的范围扫描结束后做过滤无法用于定位。这就是最左前缀法则的下半句——范围之后的列等于断了。联合索引的字段顺序要有意识地把等值条件放在前面把范围条件放在后面。4.3 从一条慢SQL到执行计划完整的排查路径生产环境里定位慢SQL建议按这条链路走打开慢查询日志找到具体的慢SQLEXPLAIN分析执行计划基于计划反推优化器决策逻辑最后造数据验证。-- 开启慢查询日志 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; -- 分析某条SQL EXPLAIN SELECT o.order_no, o.amount, u.name FROM orders o JOIN users u ON o.user_id u.id WHERE o.status PAID AND o.create_time 2024-01-01;EXPLAIN输出里优先看四列type列从const到eq_ref到ref到range再到ALL越靠左说明访问路径越好possible_keys显示可能用的索引key列是优化器实际选的rows是优化器估算扫描行数Extra列里如果出现Using filesort、Using temporary说明排序或去重没有走索引是典型的优化信号。这里有个很容易踩的误解typeALL不代表没加索引就一定全表扫也可能是加了索引但优化器认为无效。比如一张只有一百行的字典表全表扫描的成本远低于走索引回表的成本EXPLAIN就会选ALL。所以在小表上的ALL并不一定是问题大表上的ALL才是大问题。4.4 线上SQL优化的一手经验从多个真实生产案例中提炼出三条高频优化策略覆盖索引是性价比最高的一招。一个高频查询只需要几个固定字段把这几列建成联合索引SELECT直接命中的索引页里就有全部数据无需回表。我曾经把一个包含SUM、AVG的报表SQL从每个页面请求都回表改成在索引页内完成聚合计算响应时间从900ms降到60ms。注意联合索引的字段顺序按查询的等值条件优先。分页深翻页优化。LIMIT 100000, 20看似简单实则数据库先取出100020行再丢弃前100000行。解决办法是使用延迟关联先通过覆盖索引快速定位主键再用主键回表取完整数据。-- 低效写法需要回表取100020行后丢弃 SELECT * FROM big_table ORDER BY id LIMIT 100000, 20; -- 高效写法先用覆盖索引定位主键再批量回表 SELECT t.* FROM big_table t INNER JOIN (SELECT id FROM big_table ORDER BY id LIMIT 100000, 20) AS tmp ON t.id tmp.id;关于查询习惯有一条容易被忽略的SELECT *在线上大表中非常浪费I/O。显式写出需要的字段既方便数据库设计覆盖索引也避免无谓的BTree读取。优化不一定是加索引规范本身的收益也很大。5. 分布式事务从单机到多机的本质跨越5.1 同样是事务分布式难在哪单机数据库的事务协调的是同一个存储引擎中的多个数据页可以通过undo log、redo log、锁机制在一个进程内完成。分布式事务要协调的是多个独立的服务、多个独立的数据库它们分属不同进程、不同机器甚至不同网络分区。最经典的问题来自订单系统下单要扣用户余额、扣库存、记录订单、发积分每一步都落在不同的库里。需要保证这N个操作要么全部成功要么全部不生效。难点在于没有全局的undo log可以回滚别人没有全局的锁来防止并发网络还有延迟和不可靠。这就是分布式系统里CAP理论的本源背景——在分区容错性无法回避的前提下一致性和可用性只能二选一。5.2 强一致方案2PC和TCC的设计逻辑与代价2PC两阶段提交协议把事务协调拆成两个阶段第一阶段协调者向所有参与者发送prepare请求参与者把自己的本地事务执行到待提交状态并锁住资源返回OK第二阶段协调者收到全部OK后广播commit所有参与者正式提交如果任何参与者prepare失败协调者广播abort让所有人回滚。听起来完美但2PC有两个致命弱点第一是同步阻塞prepare之后参与者的资源全程锁着其他事务只能等待长事务场景下系统吞吐急剧下降第二是协调者单点如果协调者在广播commit前崩溃所有参与者的资源锁都会挂起直到协调者恢复。真实生产里2PC很少用于跨服务调用更多用在同库或同机多库的场景比如XA事务。TCCTry-Confirm-Cancel是一种业务层面的分布式事务方案不依赖数据库的全局锁。它把每个参与服务的操作拆成三个动作Try阶段做资源预检和预占Confirm阶段把预占资源正式使用Cancel阶段释放预占资源。它的优势是资源锁控制的粒度由业务自己决定不像2PC那样全程持有锁代价是每个服务都要为幂等、补偿编写额外代码实现成本高。以订单业务为例TCC三步可以设计为Try阶段将用户余额冻结、库存预扣Confirm阶段创建正式订单并扣减冻结余额和预扣库存Cancel阶段释放冻结余额和预扣库存。这套方案能落地的前提是每个参与方都必须保证Try、Confirm、Cancel的幂等性——同一操作的重复调用不产生副作用。5.3 订单与库存场景最终一致性怎么设计强一致方案性能代价高实际互联网订单场景里更务实的做法是最终一致性。核心思路是允许短暂不一致但通过事件驱动在最终时间点达到一致。本地消息表是经典实现订单服务在本地事务里同时写入订单表和一张消息表然后异步将消息投递到MQ。库存服务消费消息先做库存扣减扣减成功后回调更新消息状态。如果库存扣减失败消息被重新投递或者由定时任务扫描消息表重试。最终一致性方案有一个必须解决的问题重复消费。MQ投递语义是at-least-once消费端必须靠幂等来保证重复消息不破坏数据。常见做法是消费端维护一张已处理消息表用消息唯一ID做约束处理前先插ID插入失败说明重复直接返回成功。按时间维度维护处理记录过期清理避免表无限膨胀。订单与库存还有一个细节库存在订单创建阶段就扣还是支付成功后才扣前者适合库存有限的热门商品能防止超卖但会有无效订单长时间占库存后者用户体验好但支付高峰可能库存已经没了用户支付失败体验更差。折中方案是预扣状态机下单时预扣超时未支付自动释放支付成功正式扣减取消订单时立即释放。这个状态机本身就是一种小型的业务事务控制。5.4 什么时候真的需要分布式事务这是我认为最重要的一节分布式事务是成本极高的方案不是所有多服务写入都需要它。判断的准则是一致性窗口能不能被业务容忍。订单主流程和库存扣减之间隔了几秒的短暂不一致用户是无感知的最终一致就足够。但账户余额扣减和金融流水记录如果不同步哪怕一秒也不可接受这时需要强一致的本地事务或2PC/TCC。很多团队迷信上了分布式事务框架就万事大吉忽略了补偿逻辑和幂等设计才是落地重点。框架只解决了消息分发和重试业务上的每个补偿方法还得自己写测试成本也成倍上涨。我个人的设计习惯是能收敛进单机事务的数据就不拆库必须拆的优先用本地消息表幂等消费达成最终一致对一致性要求高且范围可控的小事务才考虑TCC。每次决定用分布式事务之前先问自己这个一致性的等待时间窗口业务真的不能容忍吗最后分享一个从底层看数据库的方法不要孤立地背索引原理、背隔离级别定义、背存储引擎参数。试着沿着一条SQL的执行路径走一遍——解析器怎么理解它优化器为什么选这个执行计划存储引擎怎么定位数据事务系统怎么控制并发崩溃时怎么恢复。你会发现这些知识点天然串成一张网面试能答得深入线上排查问题也更有方向。我自己的感受是把MVCC和redo log真正搞明白之后再去理解分布式事务和分库分表难度降了一大截。数据库这行越往底层学后面的路越宽。