很多朋友学数据库时,习惯从建表、写SQL、加索引开始,这些当然有用。但一旦线上出现慢查询、锁等待、主从延迟,如果你只能靠“重启”和“加索引”碰运气,那问题往往反复出现。我维护线上系统这些年,最深刻的体会是:数据库不能当黑盒用,你得先在脑子里建一张“地图”。这张地图就是数据库的基本架构。
这篇文章我准备从一条最简单的查询语句开始,讲讲它在数据库内部到底经历了什么。我尽量不说那种“对性能有一定提升”的空话,直接把分层、解析、优化、存储、日志、事务、索引、缓存这些模块拆开讲透,并且给一些排查问题时的实用视角。适合刚入门的后端开发,也适合刚转数据库方向的同学,准备面试的人同样能从这里拿到一条完整的知识线。
1. 一条SQL语句的完整旅程
1.1 你敲下查询后,数据库内部马上发生了什么
很多人拿到一条select * from user where id = 1,下意识觉得数据库就是一个会索引查找的盒子。实际上这条语句经过的路径非常长,只是速度快到你没有感知。
我一般会给新人画这样的路径,你也可以在脑子里过一遍:
- 客户端通过网络把SQL文本发给数据库服务端。
- 连接模块先校验身份和权限,并把这个连接分配到一个会话里。
- 解析器收到SQL后,做词法分析和语法分析,生成一棵内部语法树。
- 预处理器做语义检查,比如表名是否存在、字段是否存在。
- 优化器登场,它根据语法树生成多个执行计划,然后选择一个它认为代价最小的。
- 执行器拿着这个计划,调用存储引擎提供的接口。
- 存储引擎在磁盘和内存中读取数据页,通过索引定位记录,把结果返回给执行器。
- 执行器把结果格式化,再通过连接发送回客户端。
注意一个容易忽略的点:存储引擎返回数据时,并不是“一次性把整张表都取出来”,而是一行一行或者一批一批地往外吐。执行器在这个过程中决定是直接返回给客户端,还是继续做过滤、排序、分组。
这个流程看起来繁琐,但正是这种分工,让数据库可以支撑复杂的SQL、高并发事务,以及崩溃后的恢复。
1.2 为什么非要拆成这么多层
有同学问过:把所有逻辑揉在一起不是更快吗?省得绕一圈。
我的答案是:分层可以有弹性。就像一家餐厅,前台负责接待,服务员负责点单,后厨负责炒菜,仓库负责备货。如果你今天想换一个“后厨”,只要它仍然按照统一的菜单规范出菜,前台不需要大改。数据库也是这个道理。
分层之后,一个常见的开源关系型数据库就可以支持多种存储引擎,有的引擎擅长事务,有的擅长压缩,有的擅长列式分析。服务层不知道底层怎么存数据,它只面对统一的存储接口;存储层也不关心你的SQL语法是什么,它只管按主键或索引去读写行。这个思想几乎是所有关系型数据库的通用底座。
2. 数据库的宏观分层:连接层、服务层、存储引擎层
2.1 连接层:你的每一次握手都不是白费的
客户端和数据库打交道,第一步永远是“建立连接”。连接层做的事情远不止验证账号密码。
首先是协议解析。你要知道,数据库客户端与服务端通信不是直接传字符串那么简单,而是遵循某种半双工或全双工协议。客户端把SQL按照协议编码,服务端把结果再按协议编码返回。如果协议不匹配,你连老版本驱动和新版本数据库之间都会出现奇怪的问题。
其次是连接管理。数据库不会为每一个请求动态创建一个系统线程,因为线程创建和销毁都很贵。大多数数据库会采用线程池或类似机制,一个线程服务一个连接。如果你的应用频繁建立和断开连接,握手开销会非常明显,这也是为什么连接池成为后端标配。
我自己的排查经验是:很多新手看到“连接数打满”就以为是数据库扛不住,其实可能是应用侧连接池的最小连接数设置过大,或者某个服务没有及时释放连接。所以连接层的第一参数不是密码复杂度,而是连接生命周期和最大连接数。
2.2 服务层:SQL的翻译官和决策者
连接层之上就是服务层,它包含了我们常说的解析器、优化器、执行器,还有访问权限校验、函数计算、排序、分组等模块。
这一层不直接管数据怎么存储,它的核心价值是“理解SQL并安排干活的人”。你把一条复杂SQL丢进去,它解析出要读哪些表,用哪些条件过滤,表和表之间怎么关联,再决定使用哪个索引。所有存储引擎都共享这层逻辑,所以你在服务层可以看到统一的权限体系、统一的SQL语义。
我记得之前排查过一个慢SQL,问题出在字符集。两个表都用了不同字符集,导致优化器认为索引列之间需要做一次隐式转换,于是无法有效使用联合索引。如果不了解服务层和存储引擎层的边界,你很难想到问题出在哪一层的判断上。
2.3 存储引擎层:数据真正落脚的地方
存储引擎是数据库的“仓库”。它负责数据的物理存储格式、索引结构、事务支持、崩溃恢复等。
一个老生常谈的点:数据不是一行一行堆在磁盘里的,而是按页存储,每页默认16KB上下。这一层会把磁盘IO尽量变成页级别的读取,减少随机访问。事务型存储引擎还会维护redo log和undo log,分别解决持久化和回滚的问题。
还有一些非事务型存储引擎,结构更简单,写起来快,读起来也不慢,但一旦崩溃,可能丢失部分数据。所以选引擎从来不是“哪个快就选哪个”,而是看你的业务能不能接受丢失和回滚。服务层把接口抽象好了,恰恰给你留出了这种选择空间。
3. 核心模块:从解析到落盘的为什么
3.1 解析器:会拆句子还不够
解析器的工作分两步。
第一步是词法分析,SQL本质是一个字符串,数据库会把字符串拆成一个个“token”,比如关键字、表名、数值、运算符。第二步是语法分析,按照SQL语法规则把这些token组装成一棵解析树。这个树表达的是一句话的“结构”,而不是“怎么执行”。
比如你写select name from user where age = 18,解析器知道要查user表的name列,条件是age = 18,但它不知道user表在哪,也不知道怎么找更快。这些判断留给后面的优化器。
解析器只关心这句话“说得对不对”。它会暴露很多新手常见错误:where后面多了逗号,字段名写错,或者把保留字当成别名。等到预处理器阶段,数据库还会检查表名、字段名是否真实存在,以及当前用户是否有权限访问这些对象。
这里有个比较容易踩坑的点:如果你的表结构经常变,解析树每次都要重新生成,SQL执行计划在复杂场景下也会重新优化。所以现在很多中间件会做预处理语句,减少重复解析带来的开销。
3.2 优化器:一条SQL可以有多种执行方式
同样一条SQL,理论上可以有很多种执行方式。比如两个表关联,可以先查A表再关联B表,也可以先查B表再关联A表;可以走索引,也可以全表扫描。优化器就是要在这些“候选执行计划”里选一个代价最低的。
怎么算代价?数据库会参考表的行数、索引区分度、过滤条件、是否排序、是否产生临时表等,估算出需要扫描多少行、多少页、做多少次内存或磁盘操作。这里有统计信息的概念,如果一个表的统计信息过期,优化器就会“瞎猜”,从而选错索引。
我排查慢查询时,第一步不是看SQL本身,而是看EXPLAIN输出的type、rows、filtered和possible_keys。rows只是估算,不是真实扫描行数,但能反映出优化器的判断。如果估算和实际差很多,那大概率是统计信息陈旧,或者SQL写法让优化器无法使用索引。
一个典型的例子:where date(create_time) = '2024-01-01',这个写法看着没毛病,但因为在索引列上套了函数,索引的有序性被破坏,优化器只能放弃索引。不是数据库笨,而是索引里存的是原始值,不是函数结果。
3.3 执行器:真正干活的“项目负责人”
优化器选完路线之后,执行器就上场了。它调用存储引擎的接口,按计划去读数据、过滤条件、计算表达式、拼接结果。
执行器在做select时,会循环调用存储引擎的“取下一行”接口,直到拿不到数据为止。对update和delete来说,流程更复杂:它要先读出满足条件的行,再对这些行做修改或标记删除,同时写日志。这就意味着,一条更新SQL在同一时间会占用更多的锁和资源。
很多人不知道的是,执行器还会做权限检查。某些数据库的权限检查会发生在执行阶段,而不仅仅在连接阶段。所以你偶尔会看到一种奇怪现象:连接的时候有权限,执行的时候却报错,就是因为权限在校验时机上存在颗粒度差异。
3.4 存储引擎:物理读和逻辑读
存储引擎这层,最能体现“架构设计”的价值。
它以页为最小粒度管理数据,一页大小通常是16KB。读取时,如果目标页已经在内存缓冲池里,就是逻辑读;如果没有,就要发起磁盘IO,把页加载进来,这是物理读。磁盘IO的成本远高于内存,所以数据库架构里几乎所有优化都在避免物理读。
索引查找在主键索引上,一般会沿着B+树从根节点走到叶子节点。根节点和内部节点都可以被缓存,真正要读的往往只有叶子页。二级索引查找后,还需要根据主键回表再读一次聚簇索引,这时候就可能发生额外的物理读。
所以,“加了索引就一定会快”是不成立的。如果索引区分度太差,比如一个性别字段,每个值都匹配一半数据,那么走索引加回表的成本可能比全表扫描还高。优化器在计算代价时会把这种随机IO估算进模型,这也是我建议不要盲目加索引的原因。
3.5 日志模块:崩溃恢复靠什么
数据库最怕的不是慢,而是写到一半崩溃后数据对不上。这里靠的是日志模块。
最核心的是redo log,它记录的是“对数据页做了什么物理修改”。数据库采用WAL(先写日志再写数据)策略:事务提交时,并不需要立刻把内存里改过的数据页刷到磁盘,只要把重做日志刷下去,保证以后能恢复就行。这样做的理由是:顺序写日志比随机写数据页快得多。
还需要undo log,它记录的是“怎么改回去”。事务回滚时,可以根据undo日志恢复旧值。同时undo日志也是MVCC多版本链的重要组成部分。
在服务层,还有binlog,记录的是SQL语句或行级别的逻辑变化。binlog主要用于数据复制和时间点恢复。这里有一个关键设计:崩溃恢复时,redo log和binlog必须一致。所以架构上引入了“两阶段提交”,先把redo写到prepare状态,再写binlog,最后把redo改成commit状态。这样就算两个日志落盘时间不同,也能判断哪个事务是完整提交的。
4. 事务与并发控制:架构中的“安全气囊”
4.1 一个事务提交的完整动作
我们来细看一条最简单的更新语句update user set name='张三' where id=1,如果放在事务里,提交时大概经历这些事:
- 在内存中更新数据页,把旧值写入undo log。
- 在redo log buffer里记录这次数据页的新值。
- 事务提交,按照规则把redo log刷到磁盘。
- 写binlog,并且保证binlog和redo log一致。
- COMMIT完成,返回客户端。
如果计算机突然断电,数据库重启后,会根据redo log恢复已经提交的数据。如果一个事务没提交,它可能只写了内存页,没写redo log,那数据库就不会恢复它,甚至会使用undo log把已经开始但未完成的事务回滚掉。
这就是ACID中原子性和持久性背后的机制。隔离性则交给锁和MVCC,一致性则由约束、日志和业务逻辑共同保证。
4.2 锁的类别:全局锁、表锁、行锁
锁在架构中属于并发控制模块。不同粒度的锁,服务于不同场景。
全局锁一旦加上,整个实例只读。通常在备份时使用,但时间长了对业务影响很大,所以现在很多备份工具会优先使用快照方式。
表锁和元数据锁更轻一些。DDL操作会涉及到元数据锁,有时你会发现一条alter table会卡住后面所有读写,就是因为元数据锁没有释放。
行锁是主流事务型存储引擎最关注的。它不是在内存里锁“一条数据”这么简单,而是锁在索引记录上。比如where id=1走了主键索引,那它只锁主键上对应的那一条记录。但如果你的SQL因为没有索引而全表扫描,可能造成大量行锁,并发一高立刻出问题。
更复杂的是间隙锁和next-key lock。在可重复读隔离级别下,为了防止幻读,数据库不仅锁住记录,还会锁住记录之间的间隙。这个设计能保证范围查询稳定,但也会放大锁冲突。很多死锁都和间隙锁有关。
4.3 隔离级别与MVCC
事务隔离级别一共有读未提交、读已提交、可重复读、串行化四种。数据库不会傻到只用锁去实现隔离,那样并发太差。它使用MVCC(多版本并发控制),让读和写不互相阻塞。
MVCC的核心是版本链。一行数据被更新时,旧版本并不会立刻删除,而是通过undo log串成一个历史版本链。每个事务启动时,会生成一个自己的“快照视图”。它只能看到视图生成之前已经提交的事务做的修改,看不到之后的事务修改。
所以,在可重复读级别下,同一个事务里两次查询结果是一致的。这听起来像“数据库帮忙缓存了结果”,实际上不是缓存,而是每次查询都按自己的快照去读取可见版本。如果查询走的是普通读(快照读),它不需要加锁,所以性能很高。但你执行select ... for update或update,走的是当前读,必须读到最新版本,因此仍要加锁。
4.4 死锁:两个事务互相等待
死锁是并发事务里经典的“两边都以为对方会先让”的情况。比如事务A先更新id=1,事务B先更新id=2,然后事务A去更新id=2,事务B去更新id=1,此时两个事务各持一把锁,又等着对方的锁,死锁就出现了。
数据库处理死锁有两种方式:一种是死锁检测,发现循环等待后,选择回滚代价较小的事务;另一种是锁等待超时。前者更主动,后者更被动。
实践中的建议是:写业务代码时,多行更新要按照固定顺序来。比如总是先更新id小的,再更新id大的,这样能显著减少死锁概率。此外,一个事务里不要做太多无关操作,长事务持有锁的时间越久,冲突范围越大。
5. 索引架构:B+树和磁盘的相处之道
5.1 为什么非要B+树
很多人问:索引结构那么多,为什么常见的数据库索引都选B+树?
关键要在磁盘场景里回答。数据库数据量远超内存,必须依赖磁盘IO。B+树是“矮胖型”的多路搜索树,一个非叶子节点可以存很多key,树的高度一般只有3到4层。也就是说,哪怕表里有上千万行,从根节点到叶子节点也只需要几次磁盘IO。
叶子节点之间还有指针连接,形成有序链表。范围查询非常高效,可以顺着链表读下去,不需要反复回溯父节点。这一点是哈希索引做不到的,因为哈希索引只适合等值查找。
B+树在插入和删除时还会自动维护平衡,代价就是写操作可能会触发页分裂或页合并。页分裂是空间换时间,页合并则在删除数据时回收空间。这也是为什么字段长度太大会影响索引性能,因为一个页能存放的条目就变少了。
5.2 聚簇索引与二级索引
很多刚接触数据库的同学以为索引和数据是分开的两张表。实际上,在主流事务型存储引擎里,数据本身就是按主键索引组织的。
聚簇索引的叶子节点保存的是整行数据,主键就是它的排序键。所以通过主键查找,一次就能拿到所有列。二级索引(也叫辅助索引)的叶子节点保存的是索引列的值和主键值。如果你的SQL通过二级索引查找,先找到主键,再用主键去聚簇索引里回表,才能取到其他列。
这里有一个设计上的取舍:如果不用主键作为数据行的物理组织方式,每次插入都可能产生碎片;用主键后,建议主键用自增整数,这样新插入的数据基本都追加到B+树最右侧,显著减少页分裂。这就是为什么很多表中没有业务主键,也要加一个自增id。
5.3 覆盖索引与索引下推
当一个二级索引包含了查询所需的所有列时,数据库不需要回表,直接返回索引里的值,这叫覆盖索引。比如select name from user where name = '张三',如果name本身是索引列,索引里已经有name了,那就不必回表查整行。这种方式可以大幅减少随机IO。
还有一个容易被忽略的特性叫索引下推。它发生在联合索引上。比如联合索引(name, age),查询条件是name like '张%' and age=18。老版本数据库会把所有以“张”开头的记录的主键取出来,回到聚簇索引,再把age=18过滤掉。支持索引下推后,存储引擎在读取二级索引时直接判断age=18,过滤掉不合格记录,再回表。这就减少了回表次数。
看执行计划时,如果Extra列出现Using index,说明是覆盖索引;出现Using index condition,说明用上了索引下推。这两个都是值得优化的信号。
5.4 数据更新时索引怎么维护
索引不是免费的。每次更新一行数据,除了要改聚簇索引,还要改这张表上所有相关二级索引。这就造成“写放大”:一次逻辑上的更新,物理上可能产生多次页修改。
所以表上索引不是越多越好。索引太多,插入、删除、更新都会变慢,磁盘占用也变大。我见过一个表为了提高查询性能,加了十几个索引,结果每天大批量写入时延迟明显上升。最后只保留高频查询需要的索引,性能才恢复正常。
另外,如果你创建索引时没有考虑字段顺序,比如联合索引(a,b),那么查询条件只有b时,这个索引基本不能发挥作用。因为B+树先按a排序,再按b排序,不知道a的值,就无法直接定位b的范围。这不是索引坏了,而是索引的排序规则决定的。
6. 缓存架构:数据库不想每次都碰磁盘
6.1 Buffer Pool:数据库的“一级缓存”
数据库的查询再快,直接读磁盘也不可能快过内存。所以存储引擎里有个关键模块叫缓冲池,它把数据页、索引页缓存起来。
判断一个数据库实例能否扛住压力,很多时候就是看缓冲池能不能容纳下热点数据。如果缓冲池太小,每次查询都产生物理读,性能自然上不去;如果太大,又容易占用过多内存,触发操作系统层面的交换。
淘汰策略不是简单的LRU,而是需要防止“全表扫描污染缓存”。比如你一次性扫描几百万行,如果这些页全部进入LRU,可能把真正高频使用的热点页挤出去。改进方案是分层LRU:数据页第一次加载时进入“年轻区域”,只有被多次访问后才进入“热区域”。这样大扫描带来的新页就不容易淘汰掉真正的热数据。
6.2 脏页刷盘:内存和磁盘怎么最终一致
在缓冲池里被修改过但还没写回磁盘的页,叫脏页。数据库不可能每次修改都立刻刷盘,否则性能会很难看。它会记录日志,然后由后台线程在合适时机把脏页批量刷到磁盘。
这里引出一个概念叫checkpoint。checkpoint会推进一个全局位置,表示在这个位置之前的redo log已经没用,可以覆盖。如果崩溃,数据库只需要从最近的checkpoint开始恢复。所以刷脏页的频率,既影响崩溃恢复时间,也影响磁盘IO压力。
实操中你会发现,当内存中的脏页比例达到阈值时,可能出现周期性性能抖动。这时候不要简单粗暴调大阈值,而是先看磁盘IO能力。把刷盘参数和磁盘能力配合好,比盲目调内存更有效。
6.3 查询缓存:看上去很美,为什么后来默认关闭
老版本的数据库服务层还有一个查询缓存,可以缓存一条SELECT语句对应的结果集。听起来很好用,但实际很鸡肋。
原因是失效粒度太细。只要表上任何一个数据发生变化,和该表相关的缓存结果都会被整体失效。对于频繁更新的业务表,查询缓存不仅没有命中率,反而增加了维护成本。你甚至可能发现,开着查询缓存比关掉还慢,因为每次写入都要去清理对应缓存。
现在很多数据库已经默认关闭甚至移除查询缓存。实现层的经验是:缓存最好放在业务侧,而且要按业务语义设计,而不是让数据库误以为所有查询都是静态的。
6.4 从架构看索引失效与性能抖动
有时候你加了索引,但SQL还是慢。从架构角度看,不是索引不存在,而是服务层没能把它转到存储引擎的索引检索路径上。
常见的情况包括:索引列参与运算、索引列隐式类型转换、字符串比较时字符集不一致、左模糊查询like '%xx'。这些问题的共同点是:B+树只能根据有序的原始值定位,任何改变比较规则的操作,都会让优化器放弃索引。
性能抖动也一样,不要把锅都甩给“数据库抽风”。先看统计信息有没有过期,再看热点数据是否都被缓存,然后看是不是有大查询把Buffer Pool页面污染了。这几种原因分别对应优化器、缓存、存储引擎三个不同层次,定位准确后,解决办法完全不同。
7. 从架构视角排查那些“玄学”问题
7.1 慢查询:先定位是哪一层的锅
遇到慢查询,最忌讳一开始就看要不要加索引。我会先做分层判断:
- 客户端到服务端的网络延迟高不高?
- 解析和优化阶段有没有因字段过多、表关联复杂而耗时?
- 执行计划是否走了全表扫描?
- 存储引擎读取时是否产生大量物理IO?
- 回表次数是不是过多?
通常第一步就是看执行计划。如果type是ALL,说明全表扫描,优先找索引;如果rows估算和实际差很多,刷新统计信息或改SQL写法;如果已经走了索引还是慢,那要看是不是大量回表和随机IO。
还有一个容易被忽略的点:SQL本身不慢,但并发后整体变慢。这种情况要看等待事件和锁等待,不一定出现在单条SQL的执行计划里。
7.2 连接数打满:不一定是程序bug
数据库出现too many connections时,很多人第一反应是加大最大连接数。但连接数打满往往只是表象。
真正的风险点是:每个连接在工作时都会占有内存、线程、锁等资源。如果你把最大连接数调到很大,数据库可能还没被打挂,内存先被耗尽。所以正确思路是查一下有多少连接是活跃的,多少是空闲的,多少卡在慢查询上。
应用侧连接池也很关键。如果连接池的最小连接数过大,数据库空闲连接就会很多;如果最大连接数过小,瞬时流量一上来,连接请求就在应用侧排队。数据库层面的连接状态,要和业务侧的连接池配置一起看。
7.3 锁等待和死锁:结合事务架构分析
锁等待出现时,通常背后有一个长事务一直没提交,或者一次UPDATE锁了太多行。排查时先看当前正在执行的事务,再看锁等待关系,最后定位到源头事务。
对于死锁,数据库一般会立刻回滚某个事务,业务层会收到死锁报错。如果是偶发,可以尝试调整更新顺序;如果是高频死锁,更可能的原因是间隙锁范围过大,或者SQL没有走索引导致行锁范围被放大。
此时要关注事务隔离级别。可重复读虽然能避免幻读,但next-key lock带来的锁范围比较大。如果业务对读的重复性要求不高,可以降到读已提交,间隙锁会减少,并发能力通常会有提升。这类调整要结合具体业务场景,不能一概而论。
7.4 主从延迟:能不能从架构上找到放大器
数据库高可用架构里经常看到主从复制。主库把binlog发给从库,从库的IO线程把日志拉到本地,SQL线程再在从库上执行。主从延迟的意思就是SQL线程应用日志的速度跟不上主库产生日志的速度。
大事务是最典型的放大器。比如一条UPDATE修改了上百万行,主库可能耗时2秒,但从库为了保持一致性,也会执行一次超大事务,中间不能并行跑其他事务,后续日志只能排队。所以尽量拆小事务,比调整复制线程参数更有效。
另外,从库本身还在承担查询流量,如果查询很重,SQL线程能分到的CPU和IO就少了。这也是为什么从库延迟时,先看从库负载,而不是只看网络带宽。
7.5 问题排查速查表
| 现象 | 可能涉及模块 | 排查重点 | 常用措施 |
|---|---|---|---|
| 单条SQL慢 | 优化器、存储引擎 | 执行计划、实际扫描行数、回表次数 | 改SQL、加合适索引、更新统计信息 |
| 并发后整体变慢 | 缓存、锁、连接 | Buffer Pool命中率、锁等待、活跃连接 | 调整缓存参数、优化事务顺序、治理长事务 |
| 连接数打满 | 连接层 | 最大连接数、空闲连接比例、应用连接池 | 清理空闲连接、调整连接池参数 |
| 锁等待/死锁 | 事务、锁 | 持有锁事务、隔离级别、索引使用 | 拆分事务、固定更新顺序、降隔离级别 |
| 主从延迟 | 复制、日志 | 大事务、从库负载、并行复制参数 | 拆大事务、优化从库查询、调复制策略 |
| 崩溃后数据丢失 | 日志、恢复 | redo log刷盘策略、binlog同步 | 合理设置刷盘参数,确保日志落盘 |
8. 一点个人体会
数据库的基本架构不是让你背一串模块名,而是让你在故障现场能快速判断问题的方向。我每次排查线上问题,首先会在心里过一遍SQL路径:这条SQL是卡在连接,还是解析,还是优化,还是执行,还是存储?定位到大概层次后,再看具体指标,就很少会“乱枪打鸟”。
我也强烈建议你拿一条自己负责的慢SQL,去EXPLAIN,去看统计信息,再对照今天的架构图走一遍。有很多问题看似是SQL不会写,其实是你不清楚优化器怎么思考、存储引擎怎么读页、锁和日志怎么配合。等你把这些底层逻辑串起来,再回头处理慢查询、死锁、主从延迟,会发现它们其实都有清晰的破解路径。