这是系列文章的第7篇。今天要聊的是后端同学几乎天天挂在嘴边、但一上生产就很容易翻车的三个词:事务、并发和锁。先给个范围提醒,这里的锁不是锁屏壁纸、手机BL锁、mac监管锁或者智能锁说明书里那种锁,而是数据库和分布式系统里的并发控制锁。理解这三件事,建议直接从最经典的业务场景入手,比如电商下单扣库存、转账、订单与库存对账。你会发现它们不是三个独立知识点:事务决定了操作的边界,并发决定了多个边界怎么交错,锁则保证了交错不出乱子。这篇我会从单机数据库讲到微服务下的分布式事务,再聊到分布式锁和压测实操,最后整理一些问题排查和面试答题思路,新手照着学,老手也能拿来当查漏补缺的清单。
1. 事务到底在管什么:从ACID到隔离级别,再到事务注解
1.1 以转账模型理解ACID和事务生命周期
理解事务,我始终觉得用转账模型最直接。A给B转了1000块,账务系统里至少要有两条SQL:一条从A的账户扣1000,一条往B的账户加1000。如果扣款成功之后、入账之前数据库突然崩溃,只扣不加,这账就平不了。事务要做的事,就是把这组操作包成一个不可分割的单元:要么全部成功,要么全部不生效。
传统关系型数据库对这个能力的定义被总结成ACID,也就是原子性、一致性、隔离性和持久性。原子性对应的是“要么都做要么都不做”,InnoDB里通过undo log来实现,操作中途失败时用undo log回滚到事务开始前的状态。一致性更偏业务层面,就是说事务执行前后,数据的完整性约束不能被破坏,约束包括主外键、唯一键以及你自己在业务代码里定的规则,数据库只是提供机制,规则还得靠应用把关。隔离性解决的是多个事务同时跑的时候互不干扰的问题,这里背后是锁和MVCC多版本并发控制在配合。持久性则依赖redo log,事务一旦提交,即使机器掉电,重启后也能把数据恢复回来,不会出现“提交成功但数据丢了”的情况。
生命周期就三步:开启事务、执行读写、提交或回滚。用命令行或者客户端时,有显式的BEGIN和COMMIT;走MyBatis这类ORM框架时,事务边界通常由Spring的@Transactional托管。很多线上问题恰恰出在边界不清晰:有人在一个事务里做大量远程调用,锁资源被握在手里好几个秒甚至分钟;有人事务执行完了长时间不回滚,导致连接池、undo日志全部被拖累。我的习惯是事务里只放必须保证原子性的写操作,查数据、调接口、组装消息这类能移出去的统统移出去。
1.2 隔离级别与脏读、不可重复读、幻读的对应关系
事务并发时会遇到三类经典问题:
- 脏读:一个事务读到了另一个事务还没提交的数据。如果对方回滚,你读到的数据就是“不存在”的脏数据。
- 不可重复读:事务内两次读取同一行,结果不一样。原因是有别的事务在这期间提交了修改。
- 幻读:事务内两次执行同一个范围查询,返回的行数变多了。原因是有别的事务插入了新行。
SQL标准按照隔离强度从低到高定义了四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能 |
| 串行化 | 不会 | 不会 | 不会 |
实际生产里,几乎没有正经系统会用读未提交。读已提交是很多互联网公司的默认选择,因为并发能力更好。MySQL默认是REPEATABLE READ可重复读,理论上还存在幻读的可能,但InnoDB通过next-key lock间隙锁把“当前读”场景下的幻读问题也基本堵住了。这里有一个很容易面试被问倒的点:可重复读的“读”一般指快照读,就是通过undo版本链读历史快照;如果你执行SELECT ... FOR UPDATE或者UPDATE这类当前读,InnoDB会在索引区间上加next-key lock,从而防止其他事务往这个区间插入新记录,所以RR下也能挡住幻读。代价是间隙锁的范围比行锁大,锁冲突和死锁的概率也上去了,所以很多要求高并发写入的系统会故意把隔离级别调到读已提交。
1.3 事务传播行为与事务模式,还有那些悄悄失效的事务注解
隔离级别管的是“事务之间互相怎么隔离”,事务传播行为管的是“多个事务方法互相调用时怎么合并或拆分”。企业开发最常见的事务注解是Spring的@Transactional,传播行为有7种:
- REQUIRED:默认,如果外层有事务就加入外层,没有则新建。
- REQUIRES_NEW:无论如何都新开一个事务,外层事务会被挂起。
- SUPPORTS:有事务就加入,没有就直接在非事务状态执行。
- MANDATORY:必须在一个已有事务里运行,否则抛异常。
- NOT_SUPPORTED:以非事务方式运行,已有事务会被挂起。
- NEVER:以非事务方式运行,如果外层有事务就抛异常。
- NESTED:嵌套事务,相当于在外层事务里再开一个保存点,内层回滚不影响已经提交的外层部分。
这些传播行为里,最常用的是REQUIRED和REQUIRES_NEW。我举一个真实业务例子:主业务更新订单状态后,要往审计日志表写入一条记录。审计日志一定不能被主业务回滚掉。这时如果直接用REQUIRED,审计和主业务共用一个事务,主业务异常回滚,日志也跟着没了。改成REQUIRES_NEW之后,日志在独立事务里先落库,哪怕外层业务失败,日志也留得下来。
还要注意一批很容易踩到的事务注解失效场景。第一类是同类内部方法调用,比如Service里的方法A没加@Transactional,方法A内部调用了加了事务注解的同类的B,这时候事务不生效,因为代理对象没生效,你是直接this调用的。解决办法是把调用改成通过注入的自身代理或拆分到另一个Bean。第二类是方法不是public,Spring默认只对public方法做事务代理。第三类是异常被自己在方法里catch了,事务感知不到异常,自然就不回滚。第四类是抛出的受检异常触发了默认回滚规则,@Transactional默认只对RuntimeException和Error回滚,对Exception类型的受检异常不会回滚。如果需要Checked异常也回滚,必须显式配置rollbackFor = Exception.class。
至于热搜里常说的“数据库事务模式有哪些”,通常有两种理解。一种是从编码方式讲,分为隐式事务、显式事务和声明式事务:隐式事务就是单条SQL自动提交,显式事务就是BEGIN/COMMIT,声明式事务就是注解或XML配置切面。另一种是从事务是否支持跨资源讲,分为本地事务和分布式事务。后面的章节我会重点展开分布式事务,因为它是现在微服务架构里绕不开的核心话题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并发失控现场:从并发/并行到数据库锁机制
2.1 并发和并行是两个东西,别再把连接数当并发能力
技术面试里有一个必问的基础题:并发和并行的区别。很多人的理解是模糊的。用生活场景来类比,一个人同时处理三件事,虽然每时每刻只做其中一件,但切换速度很快,宏观上看起来“同时在推进”,这叫并发。三个人各管一件事,同一时刻真的在同时做,这就叫并行。并发强调的是任务可以交替执行的能力,并行强调的是多核或多设备同时执行的能力。
放到数据库上,连接数和并发执行能力也不是一回事。MySQL默认最大连接数一般是151,这不代表数据库能同时跑151个查询,真正同时执行的可能只有几个,大量连接都在排队等锁、等CPU调度、等磁盘IO。Tomcat的连接线程池也一样,即使配置了500个线程,如果后端数据库只能扛50个并发查询,多出来的线程只会堆积成更长的阻塞时间。我在压测一些老系统时见过典型的过度配置案例:Tomcat线程数调到了1000,数据库连接池只有50,结果请求在连接池那里排队,应用线程全被占满,接口响应从50毫秒恶化到5秒以上。很多所谓的“并发数”指标,最后瓶颈都在连接资源和等待上,而不是CPU不够。
2.2 数据库并发锁的分类和使用场景
数据库里的锁,从粒度上可以分为表锁、页锁和行锁。InnoDB支持行锁,但如果查询条件用不上索引,行锁可能会退化成对整个索引范围的锁,范围一大就接近表锁。从模式上分为共享锁和排他锁:共享锁对应SELECT ... LOCK IN SHARE MODE,多个事务可以同时持有一行数据的共享锁,但谁都不能改;排他锁对应UPDATE、DELETE、INSERT,以及SELECT ... FOR UPDATE,同一时刻只能有一个事务持有排他锁,其他读写都要等待。
MySQL InnoDB的行锁实现其实是锁定索引记录。这个机制有两个直接推论:第一,如果表没有主键,InnoDB会选择唯一索引或隐藏的ROWID来做锁标记;第二,如果UPDATE的WHERE条件没有索引,MySQL只能把聚簇索引里扫描过的记录全加上锁,表现起来就是锁表。生产里经常遇到的“明明只更新一条数据,怎么把整张表锁死了”的问题,十有八九就是索引没建对。
从要不要提前加锁的角度,又分成悲观锁和乐观锁。悲观锁的思路是“我断定这行数据会被别人改,所以先上锁再用”,也就是事务里先SELECT ... FOR UPDATE,锁定行后再做业务判断和更新,事务提交时才释放。乐观锁的思路是“我不先锁,只是在更新时检查版本号”,即在表里加version字段,更新时执行UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?,影响行数为0说明数据已经被别人改过,需要重试或失败返回。两者的取舍我习惯这样判断:写冲突非常频繁的核心账户数据,用悲观锁,因为乐观锁的重试成本太高;写冲突很低的资料、配置类数据,用乐观锁,性能更好也不容易拖死事务。
2.3 秒杀扣库存与余额扣减的并发坑,以及主流的三种写法
很多系统在并发量上来后的第一个事故就是超卖。假设只剩一件库存,两个用户同时发起购买请求,如果代码写的是先查询库存再判断再更新:
sql复制SELECT stock FROM product WHERE id = 100;
-- 业务代码里判断 stock > 0
UPDATE product SET stock = stock - 1 WHERE id = 100;
两个事务都读到stock等于1,都判断可以买,都执行了扣减,最后库存变成-1。这就是典型的丢失更新问题。解决思路有三种。
第一种是数据库行锁配合事务。把SELECT加上FOR UPDATE,比如:
sql复制BEGIN;
SELECT stock FROM product WHERE id = 100 FOR UPDATE;
-- 如果读到 stock = 0,回滚退出
UPDATE product SET stock = stock - 1 WHERE id = 100;
COMMIT;
FOR UPDATE会在ID为100的这条记录上加排他锁,第二个事务必须等第一个事务提交了才能SELECT,天然把并发请求排队了。这种方式逻辑直观,能保证强一致,但缺点是并发能力弱,适合写多读少且必须强一致的场景。
第二种是条件更新,把库存判断挪到SQL里:
sql复制UPDATE product SET stock = stock - 1
WHERE id = 100 AND stock > 0;
这条SQL靠数据库自身的行锁保证并发安全:同一时间只有一条更新能成功,如果影响行数为0,说明库存已经被扣完。在实际秒杀系统里,这种写法是基础的兜底方案。需要注意的一点是,条件更新虽然能防超卖,但库存表上的行依然会成为热点,单行并发性能到一定程度会触顶,这就是后面章节说的“热点串行化”问题。
第三种是乐观锁版本号:
sql复制UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
如果影响行数为1,说明期间没人改过,下单成功;如果影响行数为0,说明版本已经被别人改过,业务层需要拉取最新版本重试。乐观锁在并发不激烈时体验很好,但并发一大,重试率升高,响应时间波动加剧,并不是万能的。
余额扣减的场景思路一致,更严谨的写法会带上余额校验:
sql复制UPDATE account
SET balance = balance - 100
WHERE id = 123 AND balance >= 100;
如果影响行数小于1,说明余额不足或账号不存在。这套SQL直接由数据库完成“比较和更新”的原子操作,比“先查询再写代码判断再更新”不知道稳了多少倍。回头排查线上超卖和余额负数事故时,先去代码里搜索手写的“先查后改”逻辑,大概率能找到根因。
3. 分布式场景:从分布式事务到Redis分布式锁的完整取舍
3.1 为什么单机事务到了微服务时代就不够用了
单体应用时代,订单、库存、账户都在同一个数据库里,一个本地事务就能把多张表一起搞定。到了微服务架构,订单库和库存库往往是独立的两个系统、两套数据库。下单后再去扣库存,如果订单创建成功、库存扣减失败,整个流程就处于“一半成功一半失败”的中间状态。
有人会说,做一个事务把两个库都包进来不就行了吗?技术上的做法是XA分布式事务或者基于XA实现的分布式事务中间件。但XA有一个绕不开的痛点:prepare阶段会占用数据库连接并锁定资源,直到全局事务提交前,其他本地事务都无法解锁对应记录,整个分布式系统的扩展性被拖得很差。大型互联网系统里,很少会为了强一致把所有写操作都包进XA事务里,性能代价太大,所以我更推荐按业务场景来选择不同强度的方案。
3.2 分布式事务的四种主流模式:2PC、TCC、Saga与Outbox
分布式事务的解决方案,按一致性强弱和业务侵入度,大致可以分成四类:
**2PC/两阶段提交。**协调者先向所有参与方发送prepare请求,各参与方执行本地事务但不提交,然后反馈“准备好了”或“失败”。如果所有人都准备好了,协调者再发commit指令;如果有人没准备好,则都回滚。它的一致性很强,但同步阻塞、协调者单点、prepare阶段资源占用久等问题,让它在高并发场景里显得笨重。Seata的AT模式虽然改良了实现,通过undo_log做补偿,核心思路仍然脱胎于两阶段思想,适合企业核心系统数据一致性要求极高的场景。
**TCC。**把一个业务拆成Try、Confirm、Cancel三个阶段。Try阶段做资源检查和预留,比如库存服务先把库存冻结到某个冻结字段;Confirm阶段执行真正的扣减,把冻结字段变成实际扣减;Cancel阶段释放冻结资源。TCC的好处是不依赖数据库本地事务,可以跨数据库、跨异构系统,灵活性高;缺点是要为每个参与方写三个接口,业务实现成本很大,而且Cancel要么幂等要么支持反查原始状态,否则补偿时很容易多扣钱。
**Saga。**把一个长事务拆成多个有业务含义的本地事务,分别执行,如果中间某一步失败,就逆序调用之前各步骤的补偿操作。比如旅行场景里先订机票、再订酒店、最后租车,订车失败后依次取消酒店和机票。Saga能解决长事务的问题,但它是最终一致性方案,过程中的中间状态对业务方可见,设计时要接受“先成功一部分再回滚到另一个结果”的模式。
**Transactional Outbox。**本地消息表方案更受异步化系统欢迎。把核心业务数据和消息记录放在同一个本地事务里写入:业务表更新成功,同时往outbox表插入一条状态为“待发送”的消息;后续由定时任务或CDC工具把outbox表的数据投递到MQ。因为业务表和消息表在同一库,本地事务能保证“业务变更”和“消息产出”同时发生。下游消费者得到消息后执行自己的操作,最后通过消息重试和幂等消费达成最终一致性。
举一个常见的“订单与库存分布式事务”选择实例:用户下单后创建订单和扣减库存,如果要求比较高的实时一致性,常用方案是TCC:库存服务提供冻结库存接口,订单状态变成“已完成”后调用确认扣减,如果用户取消订单则调用释放冻结库存。如果业务能接受异步化,比如订单创建后先发一个“库存扣减消息”,消费端用Outbox或MQ完成扣减,那系统吞吐量会大很多。不要指望一个分布式事务框架解决所有业务问题,越复杂的框架意味着越大的运行成本和故障面,能通过异步化缩短的一致性窗口才是高并发场景最需要的。
3.3 Redis分布式锁的正确姿势与常见坑
分布式锁在热搜词里的长盛不衰是有原因的:它看似简单,实际上坑非常多。最经典的错误写法是先用SETNX加锁,再用EXPIRE单独设置过期时间。这两步一旦中间发生Redis崩溃或Java进程被杀死,锁没有过期时间就会变成死锁。千万不要写成这样:
java复制Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, value);
if (ok) {
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
}
正确做法是在一条Redis命令里同时设置互斥条件和过期时间:
bash复制SET lock_order_10086 8f7a10ca-0d9e-4cf4-97ea-4c3bf9e2bb4e NX EX 30
加锁值不能是固定字符串,必须用每次请求唯一的随机值。原因很经典:万一线程A执行时间超过了锁的过期时间,锁自动释放,线程B拿到了锁。A执行完业务后如果只按key直接删除,就会把B刚加上的锁误删掉。删除时必须先比对value,匹配上才删除,这一步判断和删除操作必须是一个原子操作,所以推荐用Lua脚本:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
锁过期时间设多少,也是一个经常被拿出来问的细节。设得过短,长任务还没执行完锁就没了;设得过长,一旦持有者异常宕机,其他线程要等很久。成熟的做法是用Redisson这类客户端实现自动续期,它内部有看门狗机制:默认每10秒检查一次,只要锁还存在且当前线程还持有,就把过期时间续到30秒。这种方式能把“锁超时导致并发”的窗口降到很小,但并不是绝对安全,极端情况下主从切换仍然可能导致锁丢失。所以后续演化出来的Redlock多节点锁,在部分追求极致可靠性的系统里会被讨论,但它的代价也很高,普通业务不需要一上来就上Redlock。我自己的取舍标准是:缓存、秒杀容忍极短时间不一致的业务,用Redis锁配合看门狗;资金、账号等强一致敏感操作,尽量不用Redis锁,宁可排队或者引入数据库行锁以及更可靠的一致性协议。
4. 高并发业务实战:限流、Kafka削峰与压测记录
4.1 并发数的常见误判,以及如何限制并发请求
高并发的第一个常见误区,是把“并发数”简单理解为“系统的并发能力”。真实系统中,限制并发请求的环节很多:浏览器对同一个域名一般建立起约6个并发连接;Nginx配置了worker_connections;Tomcat线程池默认200;数据库连接池默认几十个。任何一个环节被打满,瓶颈就会出现在那里。
我见过很多项目,为了扛住接口峰值,把Tomcat的最大线程数调得巨大,连接池却没跟着调整,结果就是请求全堵在数据库连接池上,线程被长期占用,CPU飙高,最终所有接口雪崩。处理这种基础问题有一个建议:先梳理全链路组件各自的并发上限,再用压测找出每个环节的饱和点,而不是盲目调大某一个参数。
浏览器并发连接数限制也给前端并发控制提了醒:前端JS发请求时,尤其是上传或大量分片下载时,需要自己实现“并发请求限制”,比如控制最多同时5个请求,而不是一次性发100个。实现原理是一个请求队列加一组“当前执行数量”计数,请求完成后从队列取下一个任务。这个思路和后台开发里的信号量如出一辙,本质上都是在资源边界前做限流。
4.2 Kafka如何承接高并发消息处理和最终一致性
数据库秒杀场景再优化,单个热点数据行的更新能力也有限。如果要同时面对巨大的下单峰值,主流做法是把同步流程改成异步削峰。以订单系统为例,接口只负责创建订单并写入一条待处理消息,真正的库存扣减、积分赠送、物流通知由下游消费者慢慢处理。Kafka在这里就是最常用的消息中间件。
Kafka的消息处理能力和分区数强相关。一个Topic的分区数决定了它可以被多少个消费者并行拉取。Topic只有一个分区时,无论后面启动多少个消费者,同一个分区也只能被一个消费者处理,吞吐量上不去。所以创建Topic时,要根据预期吞吐量设置合理分区数,这个数通常也等于下游消费者实例数的上限。另一个重要设计是Partition Key:同一个订单的创建、取消、支付等消息,最好都使用同一个订单号作为Key,这样Kafka会保证同一订单的所有消息都进入同一个分区,顺序也就有保证了。Kafka只保证单个分区内的消息有序,跨分区顺序是无法保证的,强依赖顺序的消息如果分散到不同分区,很可能先消费到后发出来的消息,造成状态错乱。
消费端的幂等是Kafka摄入场景的另一个核心要求。因为消息可能是重复投递的,消费者处理消息时必须能识别重复。最简单的做法是业务表里建立唯一业务键去重,比如订单状态变更表以“订单号加事件类型”作为唯一索引,重复插入直接忽略。如果消费端不做幂等,投递一次重试就可能导致库存多扣、积分多发,到时候排查线上问题会非常痛苦。
4.3 用负载压力并发测试验证系统的真实水位
不要相信“我感觉系统能扛几千并发”,一切以压测结果为准。我习惯用JMeter做全链路压测,用wrk或ab做单接口快速摸底。一次完整的并发压测至少要看四项指标:TPS每秒事务数、平均响应时间、P99响应时间、错误率,同时要盯住数据库的连接数和锁等待。
拿一个下单接口举例,压测机与服务器在同一机房,排除网络波动干扰。从50个并发线程开始,压5分钟,记录数据;逐步提升到100、200、500。常见结果有两种,一种是TPS随着并发线程数增加而增加,到某个点后不再增长,说明系统资源已经饱和;另一种是TPS不但不涨反而下降,通常说明锁竞争严重。有一次我压测一个库存扣减场景,100并发时TPS约1800,P99只有120毫秒;提高到300并发后TPS反而掉到900,P99飙到两秒多。跑到数据库里一查,大量线程阻塞在innodb_lock_waits上,定位后发现UPDATE语句的WHERE条件里的商品编码字段没有索引,行锁被放大成全表锁。补上索引后重新压测,300并发下TPS稳定在3400左右。这就是压测的价值:它能让锁等待从“可能踩坑”变成“确定踩了个什么坑”。
| 并发线程数 | TPS | 平均RT | P99 | 错误率 | 数据库锁等待 |
|---|---|---|---|---|---|
| 50 | 1100 | 45ms | 90ms | 0.0% | 0 |
| 100 | 1800 | 55ms | 120ms | 0.0% | 3 |
| 300 | 900 | 330ms | 2200ms | 0.1% | 280 |
从这个表格可以看到,TPS下降往往不是并发线程压得不够,而是系统内部锁并发已经到了极限。压测不只是看“能不能扛住”,更是为了找到那个“并发线程数继续增加反而拖垮吞吐”的拐点。对于事务型系统,我建议把数据库慢查询日志、锁等待监控和应用层Trace同时打开,这样才能在压测报告出现问题时直接定位到是SQL慢、锁等待还是代码效率低。
5. 问题排查实录与面试高频点
5.1 线上“锁表”之后,我一般按这个流程定位
数据库锁表是很多生产事故的直接元凶。现象通常是:某个更新接口突然大面积超时,应用日志里报“Lock wait timeout exceeded”,数据库的活跃线程数飙升,CPU却不高。这个时候不要慌,按顺序来查。
第一步,确定有哪些事务正在执行。MySQL 5.7及以上可以用:
sql复制SELECT * FROM information_schema.innodb_trx;
重点查看trx_state为RUNNING的长期事务,尤其是trx_started时间最早的。很多锁表问题源头不是最新的那条UPDATE,而是很久之前开启却迟迟未提交的长事务。
第二步,查锁等待关系:
sql复制SELECT * FROM information_schema.innodb_lock_waits;
这张表会告诉你哪个事务在等待哪个事务释放的锁。结合上面innodb_trx的trx_mysql_thread_id,就能找到真正阻塞别人的“锁源事务”。
第三步,确认根因后,如果是长事务死锁,可以和业务方确认后杀掉阻塞源头:
sql复制-- 用trx_mysql_thread_id定位到连接
KILL 12345;
不过杀事务只是止血,根因还是要修。根据我处理这类问题的经验,引发线上锁表的常见原因排前三的是:
- 事务里混入了外部HTTP调用,事务开启后几秒甚至几十秒不提交,期间持有的行锁全都不放。
- UPDATE的WHERE条件没有走索引,比如在非索引字段上做范围更新,行锁放大成很多间隙锁,后台任务一跑就锁表。
- 长事务引发旧版本堆积,加上InnoDB的undo log膨胀,导致一个很小的行锁等待被放大成全局问题。
还有一个容易忽视的地方:批量更新大量数据时,如果按照某一非递增字段作为条件分批执行,相邻SQL之间的间隙锁范围互相重叠,就很容易出现死锁。安全的做法是按主键从小到大排序、每批固定数量更新,且每一批都独立提交,尽量不要做成一个超大事务。
5.2 Java锁面试题:自旋锁、互斥锁、synchronized与Lock
数据库锁只是并发控制的一部分,Java开发面试里经常还会聊到语言层面的锁。synchronized和ReentrantLock的区别是经典题,大致要能说到几个点:synchronized是关键字,由JVM负责锁的获取和释放,异常时自动释放;ReentrantLock是API,需要手动lock和unlock,一般配finally。ReentrantLock支持可中断获取锁、支持超时、支持公平锁和非公平锁切换,还可以绑定多个Condition实现精确唤醒,能力比synchronized更丰富。JDK 6以后,synchronized经过了锁升级优化,性能并不差,所以没有特殊需求不必特意换成ReentrantLock。
锁升级过程是另一个高频考点:无锁、偏向锁、轻量级锁、重量级锁。偏向锁假设只有一个线程访问临界区,在对象头里记录线程ID,省掉CAS;如果有人竞争,升级成轻量级锁,尝试用自旋CAS获取锁;自旋失败且竞争加剧,就膨胀成重量级锁,依赖操作系统互斥量,线程会阻塞唤醒。
自旋锁和互斥锁的区别其实可以一句话讲透:自旋锁等锁时不让出CPU,在用户态忙循环尝试获取;互斥锁等锁时会阻塞线程,由操作系统把CPU让给其他线程。所以自旋锁适合临界区非常短的场景,比如Java并发包AtomicInteger里的CAS循环,底层调用了CPU的原子指令,效率很高。如果临界区很长,自旋锁会白白消耗CPU,反而不如互斥锁。这个知识点之所以常被拿来面试,是因为它考察你有没有真正理解“等待是需要付出代价的”这一底层逻辑。
5.3 分布式锁面试题速查:12个高频问题的答题思路
分布式锁相关面试题几乎每年都是热点,这里梳理几个高频问题的答题思路。
-
分布式锁需要满足哪些条件?需要互斥性、可重入性、防死锁、可阻塞或可超时、高性能高可用、以及锁的公平性按需要取舍。
-
Redis实现分布式锁为什么不能用SETNX加EXPIRE两步?因为两步不是原子的,两步之间进程崩溃会导致锁永久不释放。正确方式是SET key value NX EX,一条命令完成。
-
为什么加锁的value要用唯一值?为了安全释放锁。释放时先GET比较value,匹配再DEL,否则可能把其他线程的锁误删。比较和删除操作要用Lua脚本保证原子性。
-
锁过期了但业务还没执行完怎么办?用看门狗自动续期。Redisson实现里默认会定期把锁过期时间续期。但要注意,续期只能缩短风险窗口,不能完全消除,如果Redis发生长时间暂停或者主从切换,还是可能出现两个客户端同时持有锁。
-
Redis分布式锁和ZooKeeper分布式锁怎么选?Redis强调高吞吐、低延迟,适合缓存和秒杀类场景;ZooKeeper用临时顺序节点加监听机制,锁释放时靠会话心跳检测,可靠性更好,但性能稍低,通常用于对一致性要求更高的配置、任务调度等场景。
-
什么是可重入锁?同一个线程可以重复获得同一把锁。Redis里给value存成“线程标识加重入次数”,重入时次数加1,释放时次数减到0才真正删key。ZooKeeper里因为每个线程创建的是不同顺序节点,判断当前线程是否拥有最小节点就是已持有锁。
-
锁的粒度是不是越小越好?不一定。锁粒度太小,需要声明的key就越多,管理复杂度上升;锁粒度太大,并发能力下降。比如扣减一个商品的库存,可以用“商品ID”加锁,但如果要批量扣减多个商品,就要提前把所有商品ID排序后依次加锁,防止死锁。
-
加了分布式锁就能保证数据绝对正确吗?不能,还要考虑数据库本身的幂等和约束。分布式锁解决的是“互斥”,不解决“补偿”和“重复请求”。下单接口已经扣了库存,如果用户没支付但订单一直挂着,库存会一直被占用。此时需要结合订单状态过期和库存释放的兜底策略来处理。
面试时最容易暴露水平的,不是背出多少种锁,而是你能否把一个方案说清边界和取舍。举例来说,聊到Redis锁主从切换导致锁丢失时,能自然说到“所以才出现了Redlock,以及它本身也有争议,所以对于强一致场景我会考虑数据库或ZooKeeper”,这就比简单背答案好很多。
最后分享一个我自己的感受。做后端开发这些年,事务、并发和锁从来不是三个孤立的知识点。很多线上事故,表象是“数据不一致”,深挖是“事务边界划错了”;表象是“接口慢”,深挖是“锁竞争太严重”;表象是“重复扣款”,深挖是“消息消费没做幂等”。所以我现在的习惯是:新设计一个接口时,先问自己三个问题——这个操作需要在多少个资源上保证原子性?多个请求同时到达时,哪行数据是共享写热点?如果中间过程失败,靠什么机制恢复?把这三个问题回答清楚,比盲目引一个分布式事务框架或Redis分布式锁更有用。这套思路用到今天,帮我避过了很多不该踩的坑,也希望对你有一点参考价值。
