news 2026/10/20 7:16:54

MySQL事务底层原理:ACID、MVCC与锁机制全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL事务底层原理:ACID、MVCC与锁机制全解析

1. 事务到底是怎么工作的:先从一个转账场景说起

先说个最朴素的例子。你去某电商平台买东西,账户扣款、订单生成、库存扣减这三件事必须同时成功或者同时失败。如果刚扣完钱,订单系统那边突然挂了,你的钱凭空消失,库存也没减——这不只是体验差,这是事故。事务这套机制存在的意义,就是把这几个操作捆绑成一个不可分割的单元,要么全做,要么全不做。

很多人面试的时候能把ACID四个字母背得滚瓜烂熟,真到了排查线上死锁、分析数据不一致的时候,脑子里还是浆糊。原因很简单:只记住了定义,没理解底层是怎么落地的。我写这篇的初衷,就是把MySQL里事务的操作方式、四大特性的真实实现原理、以及实操中最容易踩的坑一次说透。适合正在学数据库的后端开发者、准备面试的求职者,以及写过不少SQL但从来没认真看过事务日志和锁机制的同行。

MySQL中大家日常用到的基本都是InnoDB存储引擎,它的事务能力是完整的,后面的内容全部以InnoDB + MySQL 8.0为前提来讲。你看完至少能达到三个效果:能解释清楚每条SQL执行完底层发生了什么;能在业务代码里正确使用事务而不只是加个注解;遇到死锁、锁等待的时候知道怎么排查而不是只会重启。

2. 事务操作的完整骨架:开启、提交、回滚的细节

2.1 最小可用的事务操作流程

事务操作的SQL命令其实就三个核心关键字:BEGIN(或START TRANSACTION)、COMMIT、ROLLBACK。但在实际使用里,大部分人忽略了autocommit这个默认配置带来的影响。

MySQL默认每个单独的SQL语句都包在一个隐式事务里,也就是说你执行一条UPDATE,如果成功了,它自动就被提交了,执行一条SELECT也是自动开启又自动结束。想要手动控制多语句的事务,得先处理掉autocommit的干扰。

-- 查看当前autocommit状态 SHOW VARIABLES LIKE 'autocommit'; -- 临时关闭自动提交 SET autocommit = 0; -- 或者使用显式事务 START TRANSACTION;

我个人的建议是:别轻易改全局的autocommit配置,而是用显式的START TRANSACTION来开事务。原因后面会讲,隐式提交的坑比想象中多。

完整的手动事务流程应该是这样:

-- 第一步:开启事务 START TRANSACTION; -- 第二步:执行业务SQL UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 第三步:业务判断都成功后提交 COMMIT; -- 如果中间任何一步失败,执行回滚 -- ROLLBACK;

这里面有个很容易犯的错误思路:把BEGIN当成“开始一条新SQL”,忘了它真正标定的是一个事务边界。事务边界的意思是:从BEGIN到COMMIT/ROLLBACK之间的所有DML操作,被MySQL视为一个整体,这个整体在提交之前,外部会话是看不到这些修改的(除非你用的是最低隔离级别)。

2.2 保存点:事务里的分支回滚

如果事务里已经执行了10条SQL,结果第8条出问题了,你想要的是“只撤销第8条之后的操作,保留前7条”,而不是整个事务全部回滚。MySQL提供了SAVEPOINT来实现这种部分回滚能力。

START TRANSACTION; INSERT INTO orders (order_no) VALUES ('SO20240001'); SAVEPOINT sp1; INSERT INTO order_items (order_no, sku_id) VALUES ('SO20240001', 'SKU1001'); -- 如果这里出错了,不想让第二条插入生效 ROLLBACK TO SAVEPOINT sp1; -- 事务还活着,可以继续执行其他SQL INSERT INTO order_items (order_no, sku_id) VALUES ('SO20240001', 'SKU1002'); COMMIT;

注意,ROLLBACK TO SAVEPOINT之后,事务本身并没有结束,它只是撤销了保存点之后的修改,事务还是活跃状态,需要显式地COMMIT或ROLLBACK来收尾。我见过有人回滚到保存点后直接关了连接,结果事务长时间挂起,锁一直不释放,这个细节挺坑的。

2.3 警惕隐式提交:哪些语句会强制结束事务

这是我特别想强调的一个点,也是线上事务失效的高发原因。有一类SQL语句执行之后,MySQL会隐式地提交当前事务,不管你之前做了什么操作。常见的有:

  • DDL语句:CREATE、ALTER、DROP、TRUNCATE等
  • 管理语句:GRANT、REVOKE、SET PASSWORD等
  • 事务控制语句的嵌套:START TRANSACTION之后又执行START TRANSACTION
  • 某些锁相关语句

如果业务代码里在事务中间夹了一条ALTER TABLE,而且你没意识到它已经隐式提交了,就会出现诡异的问题:事务前半段的数据已经落盘不可回滚,后半段的SQL如果失败了,回滚只能撤销后半段,最终数据完整性被破坏。

提示:在实际项目里,事务块内尽量只放DML语句,任何DDL操作放到事务外面。这是规避隐式提交最简单有效的办法。

3. 四大特性不只是面试题:底层到底是怎么实现的

3.1 原子性:靠undo log实现“全有或全无”

原子性的定义是事务是一个不可分割的工作单位,要么全部执行成功,要么全部不执行。MySQL里这个保证是靠undo log(回滚日志)实现的。

当你执行一条UPDATE把余额从100改成80,InnoDB不只是改数据页,还会在undo log里记录一条“反向操作”日志:这里存着修改前的旧值100。如果事务中途失败或者主动执行ROLLBACK,InnoDB会根据undo log里的记录把数据页恢复到修改之前的样子。

为什么用日志而不是直接不修改数据?因为事务执行过程中,其他并发事务可能已经读取了中间状态(取决于隔离级别),直接把数据页回滚会造成连锁反应。undo log提供了一种顺序的、可逆的恢复机制。

我把undo log理解成拍电影时的“动作替身”:先记录好替身怎么演,万一主演出了问题,替身可以按照记录重新演一遍,保证片子能继续拍下去。实际去分析一个事务的undo log,可以通过information_schema和performance_schema里的一些表来观察,但日常开发中更常见的场景是:长事务导致undo log膨胀,磁盘空间被大量历史版本占满,这个问题放到后面排查章节详细说。

3.2 一致性:它不是一种技术,而是最终的目的

很多人把一致性理解为一种具体实现机制,其实在ACID里,一致性更像是另外三个特性的“结果”。原子性保证不出现半成品,隔离性保证并发下互不干扰,持久性保证宕机后不丢数据——三者合起来,数据才能始终处于合法、符合约束的状态。

业务上的一致性更直观的例子是银行转账:总金额必须守恒。A给B转100块,A少了100、B多了100,这个“总量不变”的约束就是一致性约束。MySQL层面的约束包括主键约束、唯一约束、外键约束、CHECK约束,如果事务过程中违反了这些约束,整个事务会被拒绝并回滚,这也是一致性在数据库层面的体现。

开发中容易忽略的一个问题是:数据库能保证它自己管理的数据满足约束,但无法保证你的业务逻辑是完全正确的。如果你在事务里算了错误的金额,比如扣了A 200却给B只加了100,数据库不会报错,因为它不知道这个差额是不合法的。所以事务开发的核心思路是:先把业务规则设计对,再用事务保证执行过程的安全,不能反过来指望事务拯救错误的业务逻辑。

3.3 隔离性:MVCC和锁是两套并行体系

隔离性的官方表述是:多个事务并发执行时,彼此之间应该隔离,一个事务不应当看到其他事务未提交的数据。MySQL InnoDB主要是靠两套机制来保证隔离性:MVCC(多版本并发控制)和锁。

MVCC解决的是“读写并发”的问题。它允许一个读操作和一个写操作同时进行而不互相阻塞——读操作读的是某个历史版本的数据,不需要等写操作完成。这个“历史版本”就是基于undo log里的版本链实现的。每一行数据不仅有当前值,还保存着一个指针链向之前的版本,事务读取的时候根据可见性规则找到自己该看到的版本。

锁解决的则是“写写并发”的问题。两个事务同时修改同一行数据时,必须串行执行。InnoDB的行锁、间隙锁、临键锁都是围绕这个目标设计的。MVCC和锁的分工非常明确:读读不阻塞、读写不阻塞(MVCC手段)、写写阻塞(用锁来排队)。

理解这个体系之后,你再看隔离级别就不会觉得它只是个抽象概念了。四种隔离级别实际上是MVCC可见性规则和锁粒度的不同组合,后面的实操章节我会给出一个对比表。

3.4 持久性:redo log和binlog的配合

持久性要解决的问题是:事务提交后,万一MySQL进程崩溃或者服务器断电,已提交的数据也不能丢。InnoDB给出的方案是redo log(重做日志)。

redo log的写入机制很巧妙,它遵循WAL(Write-Ahead Logging)原则,意思是先写日志,再写数据文件。事务提交的时候,InnoDB先把本次修改的记录追加到redo log buffer,然后刷到磁盘上的redo log文件,此时数据页还不一定已经刷盘。如果这时断电了,重启之后InnoDB会根据redo log把已提交事务的数据恢复过来。这样即使数据页没有及时落盘,也不会丢数据。

MySQL里其实有两套日志:InnoDB层的redo log和MySQL Server层的binlog。binlog是逻辑日志,记录的是SQL语句或行变更,主要用于主从复制和数据恢复。redo log是物理日志,记录的是数据页的修改。两套日志之间有协调机制,也就是两阶段提交。事务提交时,先写redo log的prepare状态,然后写binlog,最后把redo log改为commit状态。这个设计是为了保证主库崩溃恢复后,两套日志里记录的数据是一致的。

关于持久性,我常被问到的一个问题是:事务的COMMIT是不是一定意味着数据已经写到磁盘上了。答案是:log file写到了磁盘,但数据页不一定。所以MySQL官方有innodb_flush_log_at_trx_commit这个参数,取值1的时候每次事务提交都刷盘,安全级别最高但性能最差;取值2的时候只写到操作系统缓存,性能好一些但MySQL进程崩溃时可能丢数据。默认值是1,生产环境不建议改。

4. 实操:隔离级别的选择、锁行为与一套完整的演示

4.1 四种隔离级别横向对比

InnoDB支持的四种隔离级别,从宽松到严格依次是:

隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED可能可能可能直接读最新版本,无MVCC保护
READ COMMITTED不会可能可能每条普通SELECT语句生成新ReadView
REPEATABLE READ不会不会有手段避免事务内首次SELECT生成ReadView,之后复用;配合间隙锁防止幻读
SERIALIZABLE不会不会不会所有读都变成当前读,加共享锁串行执行

MySQL InnoDB的默认隔离级别是REPEATABLE READ(可重复读)。这里有个跟其他数据库不一样的细节:在RR级别下,InnoDB通过间隙锁/临键锁在很大程度上消除了幻读,但严格来说,只有在快照读的场景下才能完全避免。如果你用的是当前读(比如SELECT ... FOR UPDATE),在一定边界条件下还是可能出现幻读的。

ReadView是MVCC实现的核心数据结构,里面有活跃事务ID列表、最小ID、最大ID等信息。每条记录版本通过事务ID判断是否对当前事务可见。READ COMMITTED级别下每条普通SELECT都生成新的ReadView,所以能看到其他事务刚提交的数据;REPEATABLE READ级别下只在事务内第一次SELECT时生成ReadView,后续复用,所以事务内看到的是一致性的快照。

实际业务怎么选?简单来说:读写分离的场景、绝大多数在线交易系统用默认的REPEATABLE READ就好,不要随意改成READ COMMITTED,除非你非常清楚自己要什么。读多写少且能接受串行的场景才考虑SERIALIZABLE,它会把所有读都变成当前读,并发能力下降明显。

4.2 完整演示:用两个会话观察事务隔离和MVCC

下面用一组实际操作来展示事务隔离级别的行为差异。假设有张账户表,里面有一条数据id=1, balance=1000。两个MySQL会话分别叫会话A和会话B。

在REPEATABLE READ级别下:

-- 会话A开启事务并修改余额 START TRANSACTION; UPDATE account SET balance = 900 WHERE id = 1;

此时会话A未提交,在会话B里执行:

-- 会话B查询同一行 SELECT balance FROM account WHERE id = 1;

执行结果仍是1000,这就是MVCC在起作用:会话B的当前快照里看不到会话A未提交的修改。然后A提交事务,B再执行一次相同的SELECT,结果依然是1000。因为B的事务第一次读时已经生成了ReadView,整个事务内复用它,所以A提交后B也看不到变化。

换到READ COMMITTED级别,B同样先读一次得到1000,A提交后再读一次会发现变成900,因为每条SELECT都重新生成ReadView。这就是不可重复读和可重复读在实操中最直观的区别。

这个演示也解释了为什么RR级别的默认配置下,同一个事务里连续两次SUM查询结果可能不同——不,实际上RR级别下两次读是一致的,所以很多报表查询在RR级别下跑出来的数据和RC级别下可能不一样,这是一个设计选型时需要记住的特性。

4.3 行锁、间隙锁、临键锁的行为差异

在没有索引的情况下,InnoDB的锁会升级到表级锁?其实更准确地说,是在没有可用索引时,它会给多条记录甚至整个表加锁。所以给WHERE条件建立合适的索引,不只是查询性能问题,也直接影响锁的粒度。

行锁(Record Lock)只锁定单个行记录。比如上面UPDATE account WHERE id=1,如果id是主键,只会锁这一行。

间隙锁(Gap Lock)锁定一个范围但不锁定记录本身,主要为了防止其他事务在这个区间插入新记录导致幻读。比如执行了SELECT * FROM account WHERE balance BETWEEN 100 AND 200 FOR UPDATE,并且balance列上有索引,InnoDB会锁定这个区间内的间隙,其他事务就不能往这个区间插入balance=150的新行。

临键锁(Next-Key Lock)是行锁加上间隙锁的组合,锁定的范围包括记录本身以及记录前面的间隙,是InnoDB RR隔离级别下默认的加锁方式。它的实际效果是既不允许其他事务修改被锁的记录,也不允许在锁范围内插入新记录。

SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE是两种手工加锁的方式。前者加排他锁,后者加共享锁。共享锁之间兼容,但共享锁与排他锁互斥。实际业务里,如果要先查后改,并且要防止查询结果被别人抢先修改,就应该用FOR UPDATE把这个范围锁住,直到事务提交。

4.4 一套可复用的乐观锁和悲观锁写法

除了数据库内置的事务锁,业务开发里还有两种常见的并发控制风格:乐观锁和悲观锁。

悲观锁的做法是先锁定资源再操作。典型的SQL写法:

START TRANSACTION; SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 业务逻辑处理 UPDATE account SET balance = balance - 100 WHERE id = 1; COMMIT;

FOR UPDATE会阻塞其他事务对同一行的修改请求,这就是串行化的保证,适合并发冲突激烈的场景,比如库存扣减。

乐观锁则不锁资源,而是在更新时检查版本号或状态字段:

START TRANSACTION; -- 读取时拿到version SELECT balance, version FROM account WHERE id = 1; -- 更新时携带version条件 UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 0; -- 如果影响行数为0,说明版本已变化,需要重试或提示失败 COMMIT;

乐观锁适合并发冲突比较少、重试成本低的场景,比如更新个人资料。它的好处是读操作永远不阻塞、吞吐量高,坏处是冲突时需要业务层处理重试逻辑。这里顺便说一个容易被忽略的参数:记录行数多少、版本号是否必须整数、以及更新条件和乐观锁条件混在一起时,SQL的实际影响行数判断非常重要,写错条件会导致死锁或更新失败。

5. 常见问题与排查技巧实录

5.1 死锁:两个事务互相等待对方释放锁

死锁是事务开发中遇到最头疼的问题之一。典型的死锁场景是:事务A锁了行1,想要锁行2;事务B锁了行2,想要锁行1。两边互不相让,就僵住了。

MySQL有一种死锁检测机制,默认开启(innodb_deadlock_detect=on),它会主动回滚其中一个事务来打破僵局。被回滚的事务会拿到一个类似于Deadlock found when trying to get lock的错误码。但要注意,每次死锁检测触发都会消耗额外的CPU资源,当并发量极高且锁冲突严重时,死锁检测本身可能成为性能瓶颈。

定位死锁的常用手段是执行:

SHOW ENGINE INNODB STATUS;

重点看LATEST DETECTED DEADLOCK段,它会给出两个事务各自的持锁和等待锁的信息,包括涉及的表、索引、SQL语句。排查思路就是根据这些信息反推业务逻辑,看看是否两个事务以不同的顺序访问相同的资源。解决方案通常是把资源访问顺序统一,或者减少事务范围。

实际业务中,我发现很多死锁不是数据库配置问题,而是代码层拿锁顺序混乱导致的。比如一个事务里先更新订单再更新库存,另一个事务先更新库存再更新订单,这种场景加再多缓冲都没用,唯一的解法是统一顺序。

5.2 锁等待超时:事务迟迟不提交

锁等待超时跟死锁不一样,死锁是相互等待且被检测后回滚,锁等待是某个事务一直持锁不释放,其他事务只能干等,直到触发innodb_lock_wait_timeout参数指定的秒数(默认50秒)。

排查这种问题,第一步是找到持锁的事务和连接。MySQL 8.0里可以通过performance_schema.data_lock_waits查看锁等待关系。大致思路是:先确定当前有哪些事务正在运行,哪个事务在阻塞别人,阻塞方的具体SQL是什么。

常见原因有:

  • 程序中开启了事务,处理完业务后忘了COMMIT或ROLLBACK
  • 事务中执行了外部HTTP调用,整个事务被拖长
  • 长事务积压了大量未提交的修改,导致其他事务一直等

应对措施也很直接:给事务设置合理的超时时间,避免外部调用塞进事务,同时监控长事务,把超过阈值的会话及时发告警。

5.3 大事务和长事务对系统的影响

一个事务运行时间越长、修改的数据越多,它占用的历史版本就越多,undo log膨胀得越厉害,同时持有的锁范围越大,阻塞面越广。

对undo log的影响是:老版本数据需要保留到所有可能的快照不再使用它之后才能清理,这就是为什么长事务会让数据库的undo log持续增长,磁盘使用率快速攀升。很多时候磁盘快满了不一定是有大事务一直在写新数据,而是古老事务不让历史版本清理,间接拖垮了数据库。

对大事务的改进思路:把大事务拆成小事务,比如批量导入的数据,不要一个事务插入一百万行,而是每几千条一个事务提交。看起来每次都写了点日志、偶尔多几个commit,但整体对锁的持有时间、undo log的体积、主从复制延迟的影响都会小得多。

5.4 MySQL 8.0下的排查SQL速查表

下面整理几张我在实际排查中经常用到的查询语句,直接复制就能用:

-- 查看当前隔离级别 SELECT @@transaction_isolation; -- 查看正在运行的事务 SELECT * FROM information_schema.innodb_trx\G; -- 查看锁等待关系(8.0用这个) SELECT * FROM performance_schema.data_lock_waits\G; -- 查看每个连接正在执行的SQL SELECT * FROM sys.processlist WHERE command != 'Sleep' AND conn_id != CONNECTION_ID();

再补充一个经验性的操作:拿到阻塞源头的线程ID后,如果确认是某条SQL拖太久了,可以直接KILL掉那个连接,这样事务会被强制回滚,锁自然被释放。但这只是应急手段,根因还是要回到代码层面去优化事务范围和索引设计。

5.5 事务执行顺序不同导致主从数据不一致

主从架构下,事务的提交顺序和binlog记录顺序可能不等于事务在业务上的发起顺序。MySQL复制是异步的,从库按收到的binlog顺序执行,如果两个事务之间有先后依赖,但它们的binlog落盘顺序因为提交时机不同而发生变化,就可能导致从库先执行后一个事务再执行前一个事务,中间状态对从库查询来说就是错的。

这个问题的经典解法是:有依赖关系的写操作放在同一个事务里,或者使用单一写入节点保持严格的串行顺序。对于强一致读的场景,适当考虑把查询也路由到主库。如果不追求强一致,很多业务其实可以容忍秒级的读延迟差异,不需要过度设计。

6. 事务设计上的几条经验

写到这里,关于MySQL事务的技术细节基本都覆盖了。最后分享几个我踩过坑之后沉淀下来的实践原则,不一定是什么高深理论,但非常管用:

第一,事务越短越好。这个短不只是说SQL执行速度快,更关键的是业务处理时间短。一个事务里但凡有网络调用、文件读取、外部接口交互,这个事务基本就不能算是数据库事务了,它更像是分布式事务的雏形。数据库能保证的是单库内的一致性,跨服务、跨库的一致性需要另外一套方案(比如本地消息表、事务消息、SAGA),不能指望一个数据库事务去包揽全局。

第二,更新语句务必用索引。没有索引的更新会让锁范围扩大,而且会拖慢行版本链的查找效率。在账务、库存类的高频更新场景,这个影响是量级的。

第三,隔离级别的选择不能拍脑袋。默认RR级别并不意味着所有场景都要用RR,但任何修改都要经过充分测试。我在一个报表系统里把隔离级别从RR改成RC之后,并发提高了,但部分报表在事务内两次SUM结果不一致,后来是换了一种查询方式才解决问题。没有银弹。

第四,把孩子的大小事务都交给数据库事务并不现实。事务控制的不是逻辑上的业务操作完整性,而是数据库层面的数据操作完整性。一个“创建订单”的业务可能涉及订单表、商品表、库存表、优惠券表等四五张表,它的确应该放在同一个事务里,但如果业务链路还包括发短信、推送消息、调用支付回调,那就得把它们拆出去,否则事务会退化成一个性能黑洞。

最后一点纯个人体会:遇到事务问题,先查日志,再查锁,最后再碰参数。很多人上来就调innodb_lock_wait_timeout、关死锁检测,治标不治本。真正稳定的数据层,靠的是合理的事务边界、有索引的更新条件和统一的加锁顺序,这三点做到位,百分之八十的事务问题都会消失。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 12:12:26

sudo提权全解析:从sudo -l到root shell的实战路径

折腾CTF靶场这几年,我最喜欢的环节不是初始拿shell那一刻,而是从普通用户一路摸到root的过程。很多时候,你手里就一个低权限的用户,粗糙的命令都跑不了,但只要能读到sudo -l的输出,整个局面就可能彻底反转。…

作者头像 李华
网站建设 2026/10/20 7:16:54

旅游消费预测毕设全流程:基于Prophet算法与Flask可视化看板实战

每年这个时候,后台总会收到一批私信:毕业设计选什么题?要算法有算法、要界面有界面、出路还要宽的。我一般都会说,试试预测类系统。尤其“旅游消费预测”这种:有Python、有Prophet算法、有Flask框架、有可视化看板&…

作者头像 李华
网站建设 2026/10/15 1:43:23

影视数据可视化实战:从pandas清洗到pyecharts交互图表

简介:面向影视行业数据挖掘与可视化需求的Python实战项目,适合有一定编程基础、希望掌握数据分析和深度学习完整流程的学习者,可作为课程设计或入门项目的参考模板。项目覆盖数据清洗、特征工程、模型训练到可视化展示的完整环节:…

作者头像 李华
网站建设 2026/10/14 19:39:09

从“无标题”到成稿:一套可落地的项目命名与内容创作方法论

每个人电脑里都躺着几个“无标题”文件。有的是新建文档时忘了改名,有的是项目启动了几天却还没找到合适名字,有的干脆就是一张彻底空白的画布。盯着“无标题”三个字,很多人第一反应是焦虑:不知道写什么内容,不知道叫…

作者头像 李华
网站建设 2026/10/16 8:30:28

Python数据分析全流程实战:从数据清洗到可视化与脚本化交付

最近带了几个人做数据分析项目,发现一个普遍现象:大家学了不少Python库,跑通了几个练习案例,但一拿到真实数据就手足无措。要么不知道第一步该干什么,要么清洗完数据发现分析维度全丢了,要么图做得花里胡哨…

作者头像 李华
网站建设 2026/10/16 8:35:31

STM32F767BI与PCA9422协同实现预测性电源管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华