事务调度这个东西,大学数据库原理课上都讲过,但很多人是考完试就还给老师了。直到某天线上系统突然卡死,监控里全是“锁等待超时”,日志里躺着一条“detected a deadlock”,你才会意识到:课堂上那几页纸,其实是数据库并发问题的全部答案。我最早接触“事务调度与封锁”这两个词的时候,也觉得它们就是理论概念,离工程很远。后来排查过几次慢查询和死锁事故,才明白调度决定了并发事务的执行顺序是否正确,封锁则决定了系统用什么代价换取正确性。这篇文章我想把事务调度与封锁从头到尾讲透,不只是背定义,而是把它们和数据库实际行为一一对应起来。无论你是在准备面试,还是正在处理线上锁问题,应该都能从中捞到点能直接用的东西。
1. 并发事务一多,为什么“调度”就成了绕不开的问题
1.1 三个经典事故:脏读、不可重复读和幻读
先看一个最简单的例子。假设有两个事务:T1把账户A的余额从1000改成800,T2读账户A的余额。如果T2在T1提交之前去读,它可能读到800,也可能读到1000,取决于T1的写入是否已经生效。这个“读到别人还没提交的数据”就是脏读。脏读的危险在于:如果T1后面回滚了,A的实际余额还是1000,但T2已经基于800做了后续动作,整条业务链就全乱了。相当于你根据一条还没盖章的合同做了决策,对方转头把合同撕了。
比脏读更隐蔽的是不可重复读。T1在同一个事务里先读了一次商品价格,得到100元;T2这时把价格改成了120元并提交;T1再去读同一行,得到120元。同一个事务里两次读的结果不一样,这就叫不可重复读。它的问题在于事务的“一致性视图”被破坏了。想象你在结账时先看了小票金额,收银员偷偷改了个数字,再让你看第二眼,你会不会觉得这家店不可信?事务也是这样,说好的隔离,结果内部数据还在变。
还有幻读。它不是针对某一行,而是针对一个范围。T1执行SELECT COUNT(*) FROM orders WHERE status = 'pending',得到10条;T2插入了一条新订单并提交;T1再数一遍,变成11条。多出来的那一条就像幽灵一样,凭空出现,没法用行锁挡住,因为它本来就不在T1锁定的行集合里。幻读和不可重复读很容易混,区分方式很简单:不可重复读是同一行内容变了,幻读是查询结果集合的行数变了。
1.2 正确的并发结果等于串行执行:可串行化
如果并发事务会带来这一堆问题,最省事的办法是什么?不要并发,一个接一个地执行。这就是串行调度:T1跑完,T2再跑,大家排队。串行调度的结果一定是正确的,因为每个事务都看到了它应该看到的一致状态。可问题是数据库不可能真的把所有事务都排成一队,那样吞吐量会低到没法用。
于是有了可串行化的概念:如果一个并发调度执行完后的数据库状态,和某个串行调度执行完的状态一致,那么我们就认为这个并发调度是正确调度。换句话说,并发执行只是“看起来像”串行执行,但谁先谁后不需要真的排队。这个定义非常重要,它把正确性从“结果必须符合某种业务逻辑”简化为“结果必须等价于某种串行顺序”。数据库不需要理解你的业务,只需要保证这个等价关系成立,就能给你一个正确的并发环境。
但是,“和某个串行调度结果一致”这个问题,直接验证起来是极其困难的。事务的操作那么多,组合爆炸,数据库总不能每次执行前把所有可能性都试一遍。所以理论家们又往前走了一步,把判断条件从“结果等价”弱化成更容易验证的“冲突等价”。
1.3 调度只是看上去公平,真正的判定标准是冲突
两个操作之间什么时候才会互相影响?不是所有操作都值得关注。如果两个操作访问的是不同的数据项,它们谁先谁后都无所谓。如果两个操作来自同一个事务,它们的执行顺序本来就是事务内部规定好的,也不用管。只有当两个操作来自不同事务,又访问同一个数据项,并且至少有一个是写操作时,顺序交换才会改变结果。这种情况就叫冲突。
冲突判定里有三种组合:读读不冲突,读写冲突,写写冲突。读读随便并发,因为大家看到的是同一个值,没人改变它。读写冲突是因为先读后写和先写后读,读到的内容完全不同。写写冲突更严重,最后保留的是谁的值,完全取决于执行顺序。
有了冲突的概念,调度理论突然就变得清晰了。一个调度如果能通过交换相邻的非冲突操作,变换成某个串行调度,那它就是冲突可串行化的。这种可串行化比“结果等价”更容易检查,而且范围更实用。后续我们提可串行化,默认指的都是冲突可串行化。数据库里的封锁协议、MVCC隔离级别,本质上都是在朝这个目标努力。
2. 串行化的判定工具:冲突可串行化与优先图
2.1 什么样的操作才算“冲突”
先把冲突的定义抠细一点。假设两个事务T1和T2,它们都访问数据项A。T1写A,T2写A,这两个操作冲突,因为谁后执行,A的最终值就归谁。T1读A,T2写A,也冲突,因为读操作看到的是旧值还是新值,完全取决于先后。T1读A,T2读A,不冲突,两个读操作谁先谁后,结果都一样。这里有一个很容易忽略的细节:同一事务内部的操作不参与冲突判断。T1先写A再读A,这个顺序是T1自己的逻辑,调度器不允许把它拆开,所以不用考虑。
判断两个操作是否冲突,其实就是问一个问题:如果把它们交换执行顺序,结果会不会变?会变,就冲突;不会变,就随便。这个判断方法在构造优先图的时候特别有用。你不需要记住复杂的定义,只要拿着调度序列,逐对检查操作,能换就换,不能换就画一条有向边,最后看能不能得到一个无环图。
2.2 用优先图判定一个调度是否可串行化
优先图(precedence graph)是检查冲突可串行化的标准工具。构造方法很机械:每个事务是一个节点,如果T1的某个操作和T2的某个操作冲突,而且T1的那个操作在调度序列中排在T2前面,那就从T1画一条有向边到T2。等所有冲突关系都画完,看这个图有没有环。无环,则调度是冲突可串行化的;有环,则不是。
我举个具体的例子。调度序列是:T1写A,T2写B,T1写B,T2写A。先找冲突:T1写A和T2写A冲突,T1在前,所以T1指向T2。T1写B和T2写B冲突,但序列里T2写B在前,T1写B在后,所以T2指向T1。两个节点之间形成了双向边,这就是环。结论是该调度不是冲突可串行化的。实际执行中,这种交叉写会造成什么结果?T2写B后T1覆盖了B,T1写A后T2覆盖了A,两个事务互相踩脚,最终状态既不像先执行T1再执行T2,也不像先执行T2再执行T1。
如果只有一个方向,比如T1写A,T1写B,T2写A,T2写B,冲突关系只会产生T1指向T2,没有环,那这个调度就冲突可串行化,等价于先跑完T1再跑T2。优先图的意义在于:它把“正确性”变成了一个图论问题,而图论里判环的算法成熟得不能再成熟。数据库内部判断调度是否安全,用的就是这类方法,只不过包装在锁和MVCC机制里。
2.3 不可串行化调度的典型形态与预防思路
不可串行化最常见的一种形态,就是前面提到的交叉写,事务T1和T2互相覆盖对方访问过的数据项,形成一个环。还有一种形态是“读了过期数据”:T1读A,然后T2写A并提交,之后T1再写B,而B的计算依赖它最开始读到的A。这样一来,T1的写入其实是基于一个已经被更新掉的值,结果自然和任何串行调度都不一样。
怎么预防?理论上可以设计调度器在运行时就做冲突检查,发现会产生环就阻止某个操作执行,这在实际系统中几乎不可行,因为代价太高。现实中的做法是用封锁协议来限制事务获取资源的方式,让产生环的调度在结构上就被排除掉。下一章讲封锁,就是把“检查调度结果”这一步前置成“提前阻止危险操作”,代价是牺牲一部分并发度。
3. 封锁的实现路径:从读写锁到两阶段封锁协议
3.1 共享锁与排他锁:封锁操作的最小集合
封锁是数据库用来控制并发访问的基本手段。它的思路很简单:事务在访问某个数据项之前,先申请这把数据的锁,拿到锁以后才能操作。锁分两类:共享锁(S锁,也叫读锁)和排他锁(X锁,也叫写锁)。共享锁的意思是“我允许别人和我一起读,但不允许别人改”;排他锁的意思是“这块地盘我独占,谁也别想读,谁也别想写”。
两种锁之间的兼容关系可以用一张小表概括:
| 当前持有的锁 | 新请求共享锁 | 新请求排他锁 |
|---|---|---|
| 共享锁 | 兼容 | 不兼容 |
| 排他锁 | 不兼容 | 不兼容 |
也就是说,读读可以共存,读写和写写都不能共存。这个设计背后的道理很直接:多个人同时读同一个值不会出错,但只要有一个人写,读到的内容就可能前后不一致,更不用说两个写同时发生了。
值得注意的是,传统锁模型要求事务在读之前申请S锁,写之前申请X锁,而不是等到真正访问时才临时判断。这套规则看着简单,可如果只规定“访问前要加锁”,还是无法保证可串行化,因为可能出现一个事务先释放了锁再申请新锁,另一个事务趁机插进来,导致调度不可串行。真正的保障来自锁的获取和释放顺序规则,也就是两阶段封锁。
3.2 两阶段封锁为什么有效,又为什么会有级联回滚问题
两阶段封锁(2PL)规定:每个事务的加锁操作必须集中在一个阶段,解锁操作必须集中在另一个阶段,中间不能交替。具体来说,事务进入“增长阶段”后只能加锁不能解锁,到达某个点后进入“收缩阶段”,只能解锁不能再加锁。这个规则保证了什么?它保证了如果两个事务的锁有交集,那么它们在时间上不会交错得太乱。一个事务开始释放锁时,说明它的核心操作已经完成,另一个事务此时才能拿到锁开始工作,整体执行顺序就有了清晰的先后。
2PL解决了可串行化问题,但它有一个很头疼的副作用:级联回滚。假设T1写A后释放了X锁,T2立刻申请到锁并读了A,接着T1因为某种原因回滚,A恢复原值,那T2读到的就是一个“从未存在过”的中间值。为了保持一致性,T2也必须回滚,而T2可能已经做了大量后续操作,回滚成本一路传导下去,像多米诺骨牌一样。这个场景在工程上非常致命,尤其是长事务多的系统,一个事务回滚可能拖垮一整批事务。
3.3 严格两阶段封锁:让“未提交数据”不再被别人看见
为了避免级联回滚,人们把2PL稍微改了一下,变成严格两阶段封锁:事务持有的写锁(X锁)一直保持到事务结束(提交或回滚)才释放,而不是在进入收缩阶段就释放。这样一来,其他事务不可能读到另一个事务未提交的修改,也就不会因为别人的回滚而被迫回滚。这个改动看起来只是“写锁晚一点释放”,却能从根本上消除级联回滚。
还有更严的版本叫强两阶段封锁:所有锁,包括读锁,都保持到事务结束。它的隔离性最强,但并发度最低,因为读锁也会挡住别人的写。大多数主流数据库不会一刀切地采用强两阶段封锁,而是配合多版本并发控制(MVCC)来缓解读锁带来的阻塞。这一点到第6章再展开。
两阶段封锁里面还有一个容易被人忽略的点:锁释放的时机决定了隔离级别。如果读锁读到就释放,事务内部两次读之间就可能被别人改数据,这就是读已提交;如果读锁要等到事务结束才释放,就能保证事务内部反复读到一致的结果,可重复读也就成立了。隔离级别的实现细节,本质上就是把锁的持有时间调整成不同档位。
4. 封锁粒度与多粒度封锁:表锁、行锁和意向锁如何协同
4.1 加锁力度越大越安全,代价是并发度下降
锁不只是分读写两种,还要分力度。数据库里可以锁整个表,可以锁一个数据页,也可以只锁一行。粒度越大,管理越简单,冲突概率越大,并发度越低;粒度越小,并发度越高,但管理成本也越高。表锁的实现很直接,一个事务锁住整张表,其他事务对该表的任何操作都排队。行锁则精细得多,两个事务可以同时操作同一张表的不同行,只要不碰同一行就行。
很多人以为行锁是“万灵药”,其实行锁挑场景。如果业务请求经常批量更新同一个范围内的数据,行锁的维护开销会让性能反而变差。如果事务持有行锁的时间很长,其他事务哪怕只是读相邻一行被阻塞的概率也不大,但一旦有索引失效之类的意外,行锁可能退化成全表锁定,那是另一场灾难。
4.2 意向锁:为什么需要在表级别提前声明
行锁和表锁同时存在时,会出现一个尴尬的问题:事务A锁住了表里的某一行,事务B想锁整张表,那B怎么知道表内有没有行锁?如果B逐个去扫描每一行有没有锁,成本高到无法接受。意向锁就是来解决这个问题的。意向锁不是真正的资源锁,它更像一个“路标”,告诉后来者:这个表里已经有行被加锁了,或者即将有行被加锁。
意向锁分意向共享锁(IS)和意向排他锁(IX),还有更复杂的SIX锁。规则是:事务要往某一行加共享锁,必须先给所在表加意向共享锁;要往某一行动加排他锁,必须先给表加意向排他锁。其他事务想对整个表加排他锁时,只需要查看表上的意向锁,发现有IX或S锁之类的存在,就知道不能加,从而避免了逐行扫描。这套“表级意向锁 + 行级普通锁”的层级组合,叫作多粒度封锁协议。它给数据库提供了一种能力:既支持精细的并发控制,又不会让表级操作变得低效。
4.3 从锁定粒度看不同隔离级别底层的锁策略
隔离级别是事务调度与封锁在工程层面的直接体现。SQL标准定义了四个级别,每个级别挡住的并发问题不一样:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能(视实现而定) |
| 可串行化 | 不会 | 不会 | 不会 |
读未提交级别下,事务读数据时不加S锁,写数据时加X锁,所以能读到别人未提交的修改。读已提交级别下,读操作加S锁但读完立刻释放,写锁保持到事务结束,所以脏读被挡住,事务内部两次读之间还是可能被改。可重复读级别下,读锁保持到事务结束,挡住了不可重复读,但如果只锁行而不锁范围,仍可能出现幻读。可串行化级别下,需要对查询涉及的范围加范围锁(如间隙锁),把可能新增数据的缝隙也封死,才能真正挡住幻读。
实际数据库很少完全按这个标准模型走,有的在可重复读级别下已经通过间隙锁或MVCC消除了幻读,有的在读已提交级别下依靠“一致性读”实现无锁快照。理解标准锁模型的意义在于,当你去查某款数据库的隔离级别说明时,你能看懂它到底是在哪个环节做了改动,不会因为文档里“没有锁”三个字而对并发安全掉以轻心。
5. 死锁和活锁:封锁方案制造的新麻烦
5.1 死锁的四个必要条件与典型场景
封锁机制保证了正确性,却也引入了新的问题:死锁。死锁的经典定义需要四个条件同时满足:互斥、持有并等待、不可剥夺、循环等待。放在数据库场景里翻译一下:资源不能被多个事务同时排他访问;事务拿着自己的锁不放,又去要别人的锁;别人不能强行把锁抢走;大家都按各自的顺序申请锁,结果你等我、我等你,形成闭环。
最典型的发生场景是交叉更新。事务T1先更新表A的某一行,再更新表B的某一行;事务T2刚好反过来,先更新表B,再更新表A。两个事务同时提交,T1拿着A行的锁等B行,T2拿着B行的锁等A行,谁也没法继续,僵住了。数据库引擎很快就会监测到这种状态,但业务代码往往一脸懵:明明两个独立操作,怎么就能互相卡死?原因就在于加锁顺序不一致。这个例子也引出了一个非常实用的经验:在业务代码里让所有事务按照相同的顺序访问资源,死锁概率会大大降低。
5.2 死锁预防、死锁检测与超时机制的取舍
处理死锁有三条路线:预防、检测和超时。
预防的思路是从源头破坏死锁条件。比如要求所有事务按固定顺序加锁,或者规定事务在等待锁时主动放弃已有锁,回滚重来。这种方案在分布式系统里比较常见,因为协调成本高,宁可让事务多失败几次,也不愿做全局检测。坏处是误伤率高,一个事务等锁等久了就白干一次,系统整体开销偏大。
检测的思路是维护一张“谁在等谁”的等待图,每次出现新的等待关系就往图里加边。如果在图中发现了环,就说明死锁发生,数据库会从环里挑一个事务作为牺牲者,回滚它,释放它的锁,让其他事务继续跑。现代主流数据库大多采用这种方式,优点是精确,不会误杀正常的等待事务,缺点是每个锁请求都要更新等待图,高并发下有一定开销。
超时机制最粗暴:每个事务等待锁的时间超过阈值,就判定为死锁并报错。实现成本最低,但容易误判。慢事务正常持有锁、另一个事务等得久一点,超时机制就可能把一个没死锁的场景当成死锁处理。实际工程里,有些系统把检测和超时结合起来用:检测负责抓真死锁,超时负责处理检测链路本身出问题或等待时间异常的情况。
5.3 活锁与公平调度:为什么不是每次等待都能等到锁
死锁之外还有一个容易忽略的兄弟:活锁。活锁里的事务并没有被卡死,而是永远等不到锁,因为它每次快要获得资源时,都被另一个事务抢先。好比你去排队买限量商品,每次排到队首,总有一个人插队抢先买走最后一件,你永远买不到,但队伍也一直在动。
活锁的根源在于加锁的优先级策略不公平。比如数据库为了减少长事务饿死,可能会偏向让持有锁时间长的事务优先,结果短事务反复被挤掉。解决活锁最常用的方法是先来先服务(FIFO),让锁的等待队列严格按申请顺序出队。别看这个方案朴素,它保证了每个事务最终都能拿到锁,消除了无限期等待的可能。在实际数据库运维中,活锁比死锁少见得多,但一旦遇到,症状表现为“某个事务长时间处于锁等待,但数据库中并没有死锁环”,排查起来容易走弯路。
6. 现代数据库的调度实践:MVCC与锁的配合
6.1 多版本并发控制:用历史版本换读写的并行空间
如果完全依赖S锁和X锁,读写之间必须互斥,读操作会阻塞写,写操作会阻塞读。这对绝大多数以读为主的业务来说是灾难。于是现代数据库普遍引入MVCC,核心思想是:写事务在修改数据时,不直接覆盖旧值,而是生成一个新版本;读事务看到的是某个一致性快照里的旧版本。这样一来,读和写可以同时进行,互不阻塞。
MVCC和封锁并不是二选一,它们是配合关系。写操作之间仍然需要排他锁来互斥,因为同一时间只能有一个事务修改同一个数据项并产生新版本。读操作则多半可以走快照,不需要加锁。这种“写用锁、读用版本”的设计,让可重复读的隔离级别在保证一致性的同时,也保住了很高的并发度。
但要注意,MVCC不是万能的。快照读读到的历史版本,无法感知并发事务尚未提交的修改,这是一致性读的代价。对于某些业务,比如“读后再决定写”的逻辑,不能盲目依赖快照,因为在快照读之后到真正写入之前,其他事务可能已经改了同一数据,你的更新可能覆盖别人的提交结果。这种场景需要走当前读加锁机制。
6.2 快照读与当前读:两种读操作对锁的需求完全不同
很多人在数据库并发问题上栽跟头,都是因为没分清快照读和当前读。普通SELECT在MVCC环境下是快照读,从头到尾不加锁,读的是一个一致性的历史快照。而SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT这些操作是当前读,必须读取最新的已提交版本,并且要加锁保护。为什么更新要加锁?因为你要修改的行可能正在被别人修改,不加锁就会出现“最后提交者覆盖”或“两个人同时判断条件都通过”的典型竞态。
这个区分解释了一个很常见的现象:两个事务同时读同一行都没有问题,但一旦都试图更新这行,其中一个就会被阻塞。不是数据库“故意找麻烦”,而是更新操作从快照读切换到了当前读,当前读就需要X锁,X锁不兼容另一个事务持有的锁。以此类推,如果一个事务先在快照读里看到某个状态,再根据这个状态决定是否更新,它必须手动用SELECT ... FOR UPDATE提前加锁,否则判断和更新之间就会被人插队。这个坑我在好几个线上事故里都见过,看起来像业务Bug,根子其实是隔离级别和锁语义没对应上。
6.3 锁等待监控与典型死锁排查思路
工具能帮我们把调度问题从理论拉回现实。我在处理一次线上卡顿的时候,第一件事不是看慢SQL,而是查当前有哪些事务、各自持有哪些锁、谁在等待谁。常见的排查路径是:先看锁等待超时的报错和死锁日志,日志里通常会记录两个事务分别执行了哪几条SQL;再把这两条SQL还原到业务代码里,看它们是否以不同顺序访问了同一组资源。
锁定范围过大的问题,往往要从执行计划入手。如果一条UPDATE语句在WHERE条件里用了函数、隐式类型转换或非前缀列,索引可能失效,优化器只能走全表扫描,加锁范围也跟着从一行扩展到全表。这时候你看到的表象是“整个表都被锁住了”,但你一开始只是改了某一行的数据。找到执行计划里的全表扫描,再通过改写SQL或建合适索引让锁精确落到目标行上,很多“锁问题”其实是索引问题。监控锁等待的时候,尤其注意那些“持有锁时间特别长”的事务,它们在等待图里往往不是受害者,而是锁的源头。
7. 我自己在项目里处理锁问题的一些经验
7.1 大事务是锁问题的根源,先拆事务再查锁
处理了好几次锁相关的事故后,我形成了一个判断顺序:遇到锁等待或死锁,先查有没有大事务,再查具体的锁竞争。一个事务如果涉及几千行甚至几万行的更新,它持有的锁数量多、时间长,别人被阻塞的概率自然指数上升。更麻烦的是大事务回滚成本极高,一旦成为死锁牺牲者,回滚期间几乎能把整个数据库拖住。
拆事务不是简单地把一个大事务拆成几个小事务,而要结合业务语义。比如批量更新就可以分批提交,每批几百条,把单次事务的锁持有时间从分钟级压到秒级。注意分批后要自己处理“一半成功一半失败”的补偿问题,不能因为拆了事务就丢掉了原子性。这个权衡很多时候没有标准答案,需要根据业务对一致性的容忍度来定。
7.2 索引失效会让行锁变成“伪表锁”
行锁听起来比表锁精细,但它有个前提:数据库必须能通过索引定位到要锁的那些行。如果索引失效,执行计划走了全表扫描,数据库只能把扫描过程中碰到的所有行都锁住,甚至在整个表上加强锁。结果就是,你只想锁一行,实际效果却和锁全表差不多。这个问题在数据量大、并发高的表上暴露得特别明显。
我排查过一个案例:一条UPDATE语句在WHERE条件里对字段做了字符串拼接,导致该字段的索引不被使用,数据库只好全表扫描并锁定了大量行,所有针对这张表的写操作都被堵住。解决办法很简单,改写成不破坏索引结构的等价条件,锁的范围立刻缩小了几个数量级。这个经验告诉我们,分析锁问题不能只看锁本身,执行计划才是根源。
7.3 配置项与业务降级:把调度问题纳入系统设计
最后谈谈配置和业务层面的配合。数据库通常提供了几个关键参数来缓解锁风暴,比如锁等待超时时间、死锁检测周期、死锁牺牲者的选择策略等。改这些参数要非常谨慎,锁等待超时调大一点,能减少无辜事务被杀,但也会让真正的死锁更长时间得不到释放;调小一点,系统响应变快,但误杀率上升。比较稳妥的做法是,先用默认参数跑,再根据监控数据微调,而不是一开始就把时间设成很大的值。
高于参数层面,我觉得真正的解法是把调度问题纳入系统设计。比如热点行的更新,可以通过异步化、排队、合并写等手段降低并发争抢;跨行更新尽量保持一致的资源访问顺序;重试机制要设计成“检测到死锁后整个事务重来”,而不是只重试那条失败的SQL。事务调度与封锁学到深处,你已经不是在背数据库原理了,而是在培养一种并发思维:看到任何两个可以并发执行的操作,先问它们会不会冲突,冲突了拿什么锁,锁拿不到了怎么办。这套思维方式,才是这两章内容留给工程现场最值钱的东西。
我在实际项目里的另一个体会是,尽量不要等到线上出了死锁再翻书复习。平时做压测的时候,把锁等待和死锁日志的开关打开,模拟一下并发更新同一批数据的场景,用不了多长时间就能摸清系统在极端争抢下的脾气。掌握了这些底层的调度和封锁逻辑,再去看数据库报错日志,你会觉得每一个错误都变得有理可循,连带着写业务代码的时候也会更敏感:这里会不会有并发问题?是不是需要加锁?加锁会不会造成死锁?多问自己几个为什么,很多线上事故根本走不到上线的哪一步。