ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

锁机制全家桶:从互斥锁到数据库锁与Redis分布式锁实战解析

锁机制全家桶:从互斥锁到数据库锁与Redis分布式锁实战解析 写代码这几年最绕不开的一个词就是“锁”。不管是业务系统里多个线程同时操作同一个变量、多个服务同时操作同一个订单还是数据库里多笔事务同时改同一行数据只要涉及“并发”锁就一定会出现。热搜词里“互斥锁”“数据库锁”“redis分布式锁”“mysql锁原理及面试题”这些高频词说明大家平时不是在学锁就是在面锁、查锁。这篇文章我干脆把常见锁整体盘一遍从编程语言里的线程锁到关系型数据库里的行锁表锁再到分布式场景下的Redis锁、ZK锁一次性说清楚它们各自锁的是什么、怎么用、有哪些坑。我自己的经验是很多人对锁的理解停留在“知道名字”的层面。面试用得上但真正上手排查一个锁等待问题、写一段正确的分布式锁代码时经常发现细节根本经不起推敲。所以这篇不是背概念而是把每个锁的原理、适用场景、经典实现和踩坑经验都摊开来写适合刚入门的读者建立体系也适合有几年经验的人重新审视自己平时用的方案。1. 锁到底是什么临界资源与四层战场锁这个机制核心就一件事保护临界资源。什么叫临界资源就是同一时刻只能被一个执行单元访问的资源。如果多个线程同时往同一个内存变量里写数据多个进程同时往同一个文件里写内容多个服务同时去扣同一个用户的余额数据就会乱。锁的作用就是给临界资源加一道门想进来的必须先拿到钥匙没有钥匙就在门外等着等里面的人用完出来了再把钥匙交给下一个。这个“门”在不同层级有完全不同的实现思路。我习惯把“锁”这件事分成四个战场CPU指令级比如CASCompare And Swap这是很多无锁编程的基石靠处理器的原子指令实现编程语言级Java里的synchronized、ReentrantLockC里的std::mutexPython里的threading.Lock都是给线程准备的数据库级MySQL InnoDB的行锁、表锁、间隙锁解决的是多个事务并发访问数据的问题分布式级Redis、ZooKeeper、etcd里的锁解决的是多个进程、多台机器之间协同访问共享资源的问题。这四个层级有一个共同的难点锁的粒度决定了并发的上限。锁得太粗性能烂锁得太细实现复杂还容易死锁。所以理解锁的第一步不是背API而是判断“我要保护的资源到底属于哪个层级需要哪种粒度的锁”。我在面试候选人的时候特别喜欢问一个问题“你在项目里为什么用锁”很多人回答“防止数据不一致”但再往深问一句“你锁的粒度是什么、粒度更粗会怎么样、更细会不会有问题”就答不上来了。其实这个问题背后考察的就是对临界资源和锁层级的理解深度。2. 编程语言里的经典锁互斥、自旋、读写、可重入2.1 互斥锁最简单的门禁互斥锁是大家最熟悉的。Java的synchronized和ReentrantLockC的std::mutexGo的sync.Mutex本质都是互斥锁。它的语义很简单同一时刻只有一个线程能拿到锁其他线程全部阻塞在原地等锁释放。这里有个关键词阻塞。线程拿不到锁操作系统会把它挂起进入等待队列让出CPU。这意味着互斥锁是有代价的——线程从运行态切换到阻塞态再切回来这个过程需要内核参与大概几微秒级别。如果临界区很小比如只执行一条指令这个切换代价比临界区本身的执行时间还要大得不偿失。实用场景非常广。保护一个计数器、保护一个队列、保护一段初始化逻辑基本都是互斥锁的活。我自己的习惯是能用互斥锁的地方就别自己造轮子因为它语义简单、调试性好、不容易出幺蛾子。2.2 自旋锁短小临界区的特供方案自旋锁和互斥锁最大的区别在于拿不到锁的时候它不睡而是原地转圈——不断重试获取锁直到拿到为止。就像你在餐厅门口排队不坐下等而是一直站着盯着里面有没有人出来。好处是省掉了线程调度切换的开销坏处是如果锁一直拿不到CPU会被白白烧掉。所以我自己的经验是自旋锁只适合临界区极短、锁竞争预期较低的场景。比如JDK里的LongAdder、ConcurrentHashMap的某些内部路径都用到了类似自旋的思想。要注意的是自旋锁在单核CPU上几乎没意义。单核同一时刻只有一个线程在执行拿不到锁说明持锁线程还没被调度上来这时候自旋纯属空转。所以自旋锁的使用必须结合运行环境来判断不能无脑上。2.3 读写锁读多写少的黄金选择很多场景下读操作的频率远高于写操作。比如一个配置表几千个线程在读偶尔才更新一次。如果全都用互斥锁那读和读之间也被互相排斥了白白损失并发度。读写锁就是为这种情况设计的。它把访问分成两种模式读模式共享锁和写模式独占锁。规则是多个线程可以同时持有读锁写锁只能被一个线程持有读锁和写锁互斥写锁和读锁互斥。一句话总结读读共享、读写互斥、写写互斥。Java的ReentrantReadWriteLock、Go的sync.RWMutex都是这种模型。但我必须提醒一个实际教训读写锁在“写频率比较高”时性能反而可能比互斥锁差因为写线程会阻塞所有读线程而且写线程还经常被持续到达的读线程饿死——读线程不断进入写线程一直拿不到锁。所以用读写锁前一定要确认读多写少的比例。我自己遇到过配置中心场景下写线程被读流量饿死导致配置更新延迟好几秒的线上事故。2.4 可重入锁避免自己锁自己可重入锁的语义是同一个线程可以重复获取已经持有的锁而不会导致死锁。比如你在同步方法A里调用了同步方法B如果锁是不可重入的那线程在进入B时发现锁被自己占着又无法释放直接卡死。Java的synchronized和ReentrantLock都是可重入的。C的std::recursive_mutex也是。而std::mutex默认不可重入如果在没释放的情况下再次lock行为是未定义的大概率死锁。C里有些老代码喜欢自己用标志位实现“伪重入”我见过不少因为忘记恢复标志位导致锁状态错乱的bug。我的建议是直接使用语言标准库的recursive_mutex或者更干脆地重构代码避免同一个锁在嵌套路径里反复加锁——重入本质上说明锁的粒度可能划错了需要审视方法边界。3. 数据库里的锁和事务从行锁到乐观锁3.1 InnoDB的行锁、表锁、间隙锁数据库的锁跟编程语言的锁是两码事。MySQL的InnoDB引擎最常用的锁粒度是行锁即事务修改某一行时只锁这一行其他行还能并发操作。行锁的实现靠索引准确地说是索引记录上加锁。如果SQL的WHERE条件没有走索引那InnoDB就没法精确定位到行只能退化为锁整张表——这是很多“莫名锁表”问题的根源。InnoDB的锁细分为几类记录锁Record Lock锁的是索引记录上的一条匹配行间隙锁Gap Lock锁的是记录之间的“间隙”即某个范围区间防止其他事务在这个区间插入新记录临键锁Next-Key Lock记录锁和间隙锁的组合锁住行以及行前面的间隙。间隙锁和临键锁的存在是为了解决幻读问题。默认隔离级别可重复读Repeatable Read下InnoDB通过临键锁把查询范围内的间隙也锁住另一个事务想往里面插新数据会被阻塞住从而保证同一个事务内两次查询结果一致。我自己常用的锁表排查SQL非常简单。线上应用突然出现大量请求超时第一反应就是查当前有哪些事务锁了哪些表SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM information_schema.INNODB_LOCKS; SELECT * FROM information_schema.INNODB_LOCK_WAITS;这三张表分别显示了当前事务、当前锁和锁等待关系。看到有事务长时间未提交时基本就能定位凶手——通常就是某个代码里忘了commit或者事务里跑了一个大查询。3.2 悲观锁我先锁了再说悲观锁的思路是总认为别人会来抢数据所以每次操作前先把数据锁住。数据库里最典型的就是SELECT ... FOR UPDATE。BEGIN; SELECT * FROM account WHERE user_id 123 FOR UPDATE; -- 业务逻辑处理 UPDATE account SET balance balance - 100 WHERE user_id 123; COMMIT;这个FOR UPDATE会把选中的行锁住直到事务提交或回滚。其他事务再想对同一行做FOR UPDATE就会被阻塞。悲观锁适合并发冲突比较严重的场景比如库存扣减、账户扣款这些场景下冲突是常态直接用锁更干脆。但悲观锁的代价是明显的锁等待时间越长系统吞吐量越低。事务里如果还夹杂了远程调用、外部API请求那锁的持有时间会被无限拉长很容易拖垮数据库连接池。我的经验是事务内绝对不要做网络调用这算是分布式系统的一条铁律。3.3 乐观锁用版本号避免锁乐观锁的思路正相反默认别人不会来抢只在更新时检查一下数据有没有被改过。最经典的方式是版本号控制UPDATE account SET balance balance - 100, version version 1 WHERE user_id 123 AND version 5;这条SQL执行后影响行数为0说明version已经不是5了有其他事务改过数据当前操作失败需要重试。这种思路在并发不高的业务里非常舒服没有锁等待、没有阻塞性能远好于悲观锁。Redis里的WATCH命令也是乐观锁思想。事务执行前用WATCH key监听如果事务执行前key被其他客户端修改事务直接失败需要重试。这和MySQL的版本号机制本质一样。乐观锁的命门在于重试策略。我见过很多项目乐观锁冲突了就抛异常让用户重新提交这种体验很差。更好的做法是在代码里做有限次数的重试比如3次超过仍然失败才返回错误。重试之间最好加一点随机退避避免多个请求在同一个时间点反复冲突——这个现象有点像一个班级同时抢一个选题大家都失败又都在同一秒重试反而再次撞车。4. 分布式锁单机锁解决不了的事4.1 为什么单机锁不够用你可能会问Java里用synchronized不就行了吗问题在于synchronized只能锁住同一个JVM进程内的线程。一旦业务部署成多实例前面挂了负载均衡请求随机落到三台机器上每台机器都有自己的锁那就等于没有锁。举个例子一个定时任务每5分钟扫一次待支付订单并执行超时关闭。如果部署了三台实例三台机器同一时刻都会扫描同一个订单可能被处理三次。这时候必须在所有实例之外找一个它们都能访问的地方来“放锁”——这就是分布式锁的意义。4.2 Redis分布式锁SETNX的写法与三个坑Redis实现分布式锁最常见的做法是用SET key value NX EX命令。NX保证只有key不存在时才能设置成功EX设置过期时间SET lock:order:123 1 NX EX 30执行成功说明拿到了锁执行失败说明锁已被别人持有。用完释放锁时不能直接DEL要先校验value是不是自己设置的防止误删别人的锁if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end这段Lua脚本是网上最经典的“校验删除”写法为什么不能省掉校验因为存在一种情况线程A拿到锁后执行时间过长锁已经过期自动释放了线程B拿到锁开始执行。这时线程A终于干完活直接DEL会把B的锁删掉导致C又进来——三个人同时临界区锁就失效了。我在真实项目里踩过三个特别典型的坑值得单独说坑一过期时间设置不合理。过期时间太长业务崩溃后锁迟迟不释放系统被阻塞太短业务还没跑完锁就自动过期锁形同虚设。合理的做法是给过期时间设一个“业务正常耗时的3到5倍”作为兜底同时配合续期机制——即业务快到期时自动延长锁的过期时间。Redisson里叫“看门狗”机制底层是定时任务在锁持有期间不断续期。坑二Redis主从切换可能导致锁丢失。如果对可靠性要求极高得考虑用RedLock算法向多个独立的Redis节点同时加锁超过半数成功才算成功。但RedLock在网络分区时仍有理论缺陷很多专家也讨论过所以现在领域内的主流建议是真对可靠性要求那么高直接上ZooKeeper或etcd。坑三锁和业务代码的边界。很多人把锁的操作写在try-finally里但把业务逻辑放进try块表面看没问题但如果业务里抛出的异常导致事务回滚锁的释放顺序可能和业务提交顺序不一致。我的习惯是锁释放一定要在finally里并且放在事务提交之后绝不能在事务提交前释放锁否则下一个线程拿到锁、读到旧数据锁相当于白加了。4.3 基于Java的Redis分布式锁实战Java里用RedisTemplate实现一个最简单的分布式锁核心代码就这些逻辑。加锁时要带唯一标识UUID过期时间释放前先比较标识public class RedisLock { private StringRedisTemplate redisTemplate; public boolean tryLock(String key, String requestId, long expireSeconds) { Boolean success redisTemplate.opsForValue() .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void unLock(String key, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), List.of(key), requestId); } }这段代码能应付大多数业务。但如果你用的框架里已经有Redisson我建议直接用RLock因为Redisson内置了看门狗续期已经帮我们处理了过期时间续期这种最容易出问题的细节。4.4 ZooKeeper和etcd方案对比除了RedisZooKeeper也是老牌的分布式锁实现。ZK锁利用的是**临时顺序节点ZNODE**机制多个客户端同时创建一个顺序节点序号最小的获得锁其他客户端监听自己前一个节点的删除事件当前一个节点消失后持锁客户端断开或完成自己再尝试获取锁。ZK锁最大的优势是没有过期时间误删的问题。客户端一旦会话断开ZK会自动删除临时节点锁自动释放不会出现Redis那种“拿着锁的人走了锁却留在那里直到过期”的尴尬。缺点是需要独立维护ZK集群性能也比Redis方案低频一些。etcd实现分布式锁用的是Lease租约和Revision版本号机制原理和ZK相似也属于强一致方案。微服务架构里如果已经用了etcd那锁直接用etcd的concurrency包即可不用额外引组件。我的选择标准一直是业务场景要的是“高吞吐”就选Redis要的是“强可靠”就选ZK/etcd。别一上来就纠结RedLock绝大多数业务的并发量根本到不了需要考虑主从切换丢锁的级别。5. 死锁与排查线上最容易翻车的场景5.1 死锁产生的四个条件死锁是锁机制里最经典的故障形态产生需要同时满足四个条件互斥资源被至少一个线程占用且线程间不能同时使用该资源持有并等待线程拿着一个资源不释放的同时又去等另一个资源不可剥夺资源只能由持有它的线程主动释放其他线程不能强行抢走循环等待两个以上的线程形成了等待环A等B、B等A。典型场景是双表更新线程A持有表1的锁在等表2线程B持有表2的锁在等表1。两者谁也不让就成了死锁。我排查线上死锁时第一件事是翻数据库的错误日志找到类似Deadlock found的内容。MySQL的SHOW ENGINE INNODB STATUS会输出最近一次死锁的详细记录包括每个事务持有什么锁、等待什么锁、执行了什么SQL。5.2 避免死锁的实操建议我在团队里立过几条规矩实践下来死锁和锁等待明显减少所有事务按固定顺序访问表。比如多表更新时都先更新ID小的表再更新ID大的表从源头打破循环等待缩小锁的覆盖范围。能锁一行不锁一片能锁一条记录就不要开一个大事务事务内避免用户交互和远程调用。这类代码执行时间不可控持有锁的时间越长碰撞面越大使用统一的超时机制。给锁等待设置超时时间比如innodb_lock_wait_timeout设成5秒而不是默认的50秒避免请求长时间阻塞让线程池打满。还有一个小坑是很多人写代码用SELECT ... FOR UPDATE但不走索引。字段没加索引时InnoDB的锁会上升为表锁两个事务互锁的概率直线上升。加锁相关的SQLWHERE条件必须能命中索引。这是数据库锁应用里最常被忽视的细节。5.3 锁等待排查实用命令如果线上出现接口变慢、数据库活跃连接数飙升我一般按这个顺序排查-- 1. 查看当前执行时间最长的SQL SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND ! Sleep ORDER BY TIME DESC LIMIT 10; -- 2. 查看未提交事务 SELECT * FROM information_schema.INNODB_TRX WHERE TRX_STATE RUNNING; -- 3. 查看锁等待关系 SELECT * FROM sys.innodb_lock_waits;如果怀疑应用层代码卡在锁上还可以用jstack导出Java线程栈搜索关键字waiting for lock和locked能直接看到哪个类、哪一行代码在竞争锁。这套组合拳在绝大多数锁问题上都够用。6. 面试里关于锁的高频问题聊完实战再把面试官常问的几个锁相关问题集中总结一下。网上热词里“mysql锁原理及面试题”“分布式锁面试题”“乐观锁悲观锁实现原理和适用场景”都在一个池子里这里给出我认为最核心的回答要点。第一题MySQL的乐观锁和悲观锁怎么实现怎么选悲观锁用SELECT ... FOR UPDATE在事务内显式锁行乐观锁用版本号或时间戳更新时在WHERE条件里带版本比对。冲突频繁、业务对一致性要求苛刻时用悲观锁冲突少、追求吞吐量时用乐观锁。答题时如果能补充“乐观锁需要重试补偿机制”这个点面试官会认为你有实战经验。第二题Redis分布式锁怎么做到安全可靠回答框架加锁用SET key value NX EXvalue用唯一ID释放用Lua脚本先比后删过期时间要配合续期看门狗单Redis节点有主从切换丢锁风险极端可靠场景可用ZK/etcd。这套回答能覆盖多数面试深度。第三题synchronized锁升级的过程。JVM的synchronized为了优化性能不再是一上来就上重量级锁。锁有一个升级轨迹偏向锁 → 轻量级锁 → 重量级锁。偏向锁针对单一线程反复进入同步块的情况一旦有第二个线程竞争升级为轻量级锁基于CAS自旋自旋达到一定阈值仍拿不到锁膨胀成重量级锁线程进入阻塞。能把这条链路说清楚面试官一般会认可你。第四题什么是可重入锁为什么需要可重入锁的语义就是同一线程可重复获取同一把锁避免嵌套调用时自己锁死自己。Java的synchronized、ReentrantLockC的recursive_mutex都支持。回答时可以举一个典型例子类的方法A加了锁方法A内部调用了同样加锁的方法B不可重入锁就会死锁。第五题除了技术锁还有什么“锁”容易踩坑其实热搜里还有很多别样的“锁”比如手机解锁BL锁、账户锁、Win10锁屏。这些属于设备安全锁本质上也是一种互斥机制——设备厂商在硬件层面放了一把锁不满足条件就无法解锁防止用户刷入非官方固件或绕过账户验证。做软件开发的人偶尔也会遇到比如测试机需要解BL锁才能刷特定版本系统。这里只提一句设备锁的应用场景和原理和分布式系统的锁没什么大区别都是一个“谁能访问、谁不能访问”的权限控制问题。面试中对“锁”的考察核心其实不是背API而是看你有没有判断什么时候该加锁、加哪类锁、锁的粒度怎么控制、出了问题怎么排查这四件事的思维。能把这四件事讲清楚比背十个锁的API都有用。7. 日常开发里我对锁的三个核心体会写了这么多锁最后分享几条实际编码中反复用到的体会。第一并发编程的第一原则不是“用锁”而是“尽量少用锁”。能用无锁数据结构的地方优先让数据不可变能把锁的粒度缩小到单条记录、单行数据就不要锁整个集合。锁是保证正确性的底线不是提升性能的工具锁用多了并发度一定下降。第二加锁之前先想清楚锁的是“什么资源”、是“哪个层级”的锁。线程里的同步和数据库里的行锁、分布式场景里的Redis锁服务于完全不同的问题域。把Redis锁当成万能药到处乱用的项目最终都会在极端场景下翻车。第三线上问题排查远比重写工具更重要。很多时候不是锁方案本身有问题而是事务写得太长、SQL没走索引、Redis锁过期时间不合理、finally里忘了释放锁这些问题叠加导致故障。这些细节往往就是面试和实战的分水岭面试能说出锁的名字是基础能把锁用得不出事故才算真正入门。
返回列表