ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从MySQL行锁到Redis分布式锁:并发场景锁的完整指南

从MySQL行锁到Redis分布式锁:并发场景锁的完整指南 如果把并发编程比作开车那锁就是路口的红绿灯。上周进阶课正好讲到“锁章节”我从MySQL的行锁一路做到Redis分布式锁把之前零零散散的锁知识完整串了一遍。写这篇的目的很直接把锁章节里真正有用的东西沉淀下来遇到锁表、死锁、分布式锁失效时不至于抓瞎。适合正在学后端并发的人、准备面试的人还有天天被锁折磨的DBA。你可能会想锁有什么好讲的数据库有行锁表锁Java有synchronizedRedis不也有SETNX吗。但真到线上问题往往出在锁的粒度、加锁顺序和释放时机上。这一章节会讲透为什么锁会互相等待、为什么分布式锁会失效以及如何一步步定位。1. 锁章节到底在讲什么1.1 为什么我把锁单独拉出来讲并发场景下数据一致性不是靠运气而是靠同一时间只允许一个执行者进入关键区。锁是保证这条规则最基础的工具也是线上问题的高发区。这个章节之所以被标记为“进阶”是因为它踩在并发编程、数据库事务和分布式系统三个交叉点上缺少任何一块知识都会觉得锁难懂。我在实践中遇到过三个典型问题都和锁有关。第一个是下单扣库存时超卖因为两个应用实例同时读到库存都够各自扣减第二个是MySQL长事务导致整个表被锁住所有写操作堆积第三个是Redis分布式锁在主从切换后失效两个线程同时进入了临界区。这三个问题正好对应应用锁、数据库锁、分布式锁。所以只要做后端开发锁章节是绕不开的。适合谁如果你是刚接触后端并发的新人这篇能帮你建立锁的完整知识框架如果你已经在写业务代码这篇里的排查和避坑思路可以直接搬到线上。不是为了面试背题而是在真正出问题时知道该从哪里下手。1.2 一把锁无法打天下锁的分类地图很多人一提到锁就想到互斥但锁并不只有一种。从使用范围看锁至少分成三个层次单进程内的锁、单机多进程的锁、跨机器的分布式锁。单进程内我们用synchronized或Lock单机多进程可以用文件锁或数据库锁跨机器就得用Redis锁或ZooKeeper锁。三层没有优劣之分只有场景匹配问题。从实现思路上又分成悲观锁和乐观锁。悲观锁假设冲突一定会发生所以先加锁再操作数据库的SELECT FOR UPDATE就是典型乐观锁假设冲突很少发生通过版本号或CAS在提交时校验比如Java的AtomicInteger、数据库的UPDATE ... WHERE version ?。这两种方式没有绝对的对错并发冲突高的时候悲观锁更稳冲突低的时候乐观锁性能更好。还有一个维度是锁的粒度。数据库锁有表锁、页锁、行锁、间隙锁、临键锁Java锁有偏向锁、轻量级锁、重量级锁分布式锁也有全局锁和分段锁之分。锁粒度越小并发能力越高但管理和排查成本也越大锁粒度越大越容易实现但性能往往越差。锁章节的后半部分几乎都是围绕“粒度”和“持有时间”这两件事展开的。维度锁类型典型实现适用场景进程范围单进程锁synchronized、ReentrantLock单机多线程进程范围分布式锁Redis SETNX、ZooKeeper临时节点多实例集群数据库锁行锁、表锁、间隙锁InnoDB Record Lock等事务并发控制并发策略悲观锁/乐观锁SELECT FOR UPDATE / CAS根据冲突概率选择2. 数据库锁从行锁到间隙锁2.1 MySQL锁的分类与加锁对象先从最常用的MySQL InnoDB说起。InnoDB支持两种行级锁共享锁S和排他锁X。S锁之间可以共存S锁和X锁不能共存X锁之间也不能共存。简单说S锁是“所有人都能读但持有S锁的事务能阻止别人改”X锁是“只有自己能改其他读写都让路”。除了行级锁还有表级意向锁包括意向共享锁IS和意向排他锁IX。意向锁不是用来锁行而是表示“这个事务准备在表上加S/X锁”或者“已经对某些行加了S/X锁”。MySQL加锁时先加意向锁然后再加行锁。这样当一个事务想对整张表加锁时可以通过意向锁快速判断是否存在行锁冲突不用逐行检查。记录锁Record Lock锁的是索引记录本身间隙锁Gap Lock锁的是索引记录之间的间隙防止其他事务在这个间隙插入数据避免幻读临键锁Next-Key Lock是记录锁和间隙锁的组合既锁记录又锁间隙。InnoDB默认在REPEATABLE READ隔离级别下使用临键锁这也是它能防幻读的重要原因。为了看懂锁需要先知道一个规律InnoDB的锁全部加在索引上。如果更新条件没有索引InnoDB会全表扫描并对所有扫描到的记录加锁相当于把整个表锁住。这也是很多“锁表”问题的根源。所以判断一条SQL会不会锁表只要看它的WHERE条件能不能高效走索引即可。锁类型作用加锁位置记录锁 Record Lock锁住单条索引记录主键/唯一索引间隙锁 Gap Lock锁住记录之间的间隙索引排序后的间隙临键锁 Next-Key Lock记录锁间隙锁防幻读RR隔离级别默认表锁锁整张表表级DDL或无索引更新意向锁表示事务准备对表加锁表级2.2 常见加锁SQL背后的锁行为用几个具体例子演示。第一条UPDATE student SET score 100 WHERE id 1;这条更新通过主键定位只对id1这一行加X锁其他行不受影响。这是最理想的加锁方式。第二条UPDATE student SET score 100 WHERE name 张三;假设name上有普通二级索引InnoDB先锁住name张三对应的二级索引项再回表锁住主键索引对应的记录。加锁过程分两步二级索引项和主键记录都会被锁。第三条UPDATE student SET score 100 WHERE id 10 AND id 20;这是一个范围条件。由于是RR隔离级别InnoDB除了锁范围内的记录还会对边界之间的间隙加锁防止其他事务插入id在10到20之间的记录。第四条DELETE FROM student WHERE name 王五;如果name列没有索引MySQL只能全表扫描对每一条扫描到的记录都加X锁。虽然实际命中可能只有一行但锁的范围却覆盖了一整张表。生产环境最常见的“锁表”多半就是这种无索引条件更新导致的。这里面有个容易被忽视的点普通SELECT不加锁。只有SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE才会走当前读并加锁。默认SELECT是快照读依赖MVCC不会和其他写操作互相阻塞。很多人用客户端查一条慢SQL看起来锁表了其实真正的阻塞是那些写事务。2.3 二级索引更新引发交叉加锁的隐患热词里有一句很有价值的描述由于二级索引更新时先锁二级索引项再回表锁主键这个时间窗口容易形成交叉死锁。这句话值得展开。假设订单表orders有三个字段id主键、order_no唯一索引、status普通索引。两个事务并发执行不同的更新事务AUPDATE orders SET status 1 WHERE order_no A001;事务BUPDATE orders SET status 2 WHERE order_no A001;这两个事务都是先用order_no二级索引定位锁二级索引项然后回表锁主键。如果事务A已经锁住order_noA001的二级索引并准备回表锁主键事务B也已经锁住主键那一行并准备锁二级索引两个事务就会各持一把锁等待对方形成死锁。InnoDB检测到死锁后会自动回滚其中一方但报错信息往往会让业务代码一脸懵。更常见的场景是两个事务分别通过不同的二级索引更新同一行。事务A通过order_no锁二级索引事务B通过status锁另一个二级索引都要回表锁同一主键。加锁顺序不同交叉等待死锁就产生了。要避免这类死锁有几个实用手段尽量让更新走主键条件减少二级索引加锁和回表加锁的步骤多条更新语句在高并发下保持一致的访问顺序比如都先从低ID开始更新事务要短锁持有时间短死锁窗口就小如果死锁偶尔发生应用层加上重试机制比如捕获死锁异常后重跑一次。3. 锁等待与死锁排查实录3.1 用一张视图看清当前锁状态MySQL 8.0之后锁信息被整理进performance_schema的data_locks和data_lock_waits两张表。执行SELECT * FROM performance_schema.data_locks\G可以看到当前所有事务持有的锁和正在等待的锁字段包括ENGINE、ENGINE_LOCK_ID、ENGINE_TRANSACTION_ID、OBJECT_NAME、INDEX_NAME、LOCK_TYPE、LOCK_MODE、LOCK_STATUS等。LOCK_TYPE为RECORD说明是行锁LOCK_MODE里有X说明排他锁有S说明共享锁GAP或NEXT_KEY说明锁了间隙。再看锁等待关系SELECT * FROM performance_schema.data_lock_waits\G这张表能告诉你哪个事务在等哪个锁。结合processlist和events_statements_current可以定位到具体SQL。老版本MySQL可以用SELECT * FROM information_schema.INNODB_TRX\G SELECT * FROM information_schema.INNODB_LOCK_WAITS\G虽然不是那么直观但也能看到等待关系。建议把这几张表封装成一个查询语句线上出问题先跑一遍再决定要不要kill会话。3.2 死锁现场还原与日志解读死锁发生时InnoDB会把最近一次死锁的信息写到错误日志。可以通过SHOW ENGINE INNODB STATUS\G在输出里找到“LATEST DETECTED DEADLOCK”段落。里面会列出两个事务分别持有哪些锁、正在等待哪把锁以及导致死锁的SQL语句。我复现过这样一个案例事务A执行UPDATE t SET a 1 WHERE id 1;事务B执行UPDATE t SET a 2 WHERE id 2;事务A接着执行UPDATE t SET a 3 WHERE id 2;事务B接着执行UPDATE t SET a 4 WHERE id 1;这个读写顺序是教科书式的死锁。事务A先拿到id1的行锁事务B先拿到id2的行锁然后A想拿id2、B想拿id1谁也等不到谁InnoDB立刻检测并回滚其中一个事务。日志里能看到“TRANSACTION”两个区块分别标记了持有锁和等待锁还有一个“WE ROLL BACK TRANSACTION”的提示说明它选择了哪个事务回滚。遇到这种情况先别急着加索引或改配置。死锁本身不一定说明系统有问题只要不是频繁出现重试机制就能解决。如果是高频死锁重点检查业务代码里是否存在不同事务访问资源顺序不一致的问题。比如有多个增加积分的操作有的先扣后增有的先增后扣就很容易互相等。3.3 锁表后的标准处置流程线上发生“锁表”时最明显的表现是大量写请求堆积接口响应变慢甚至超时。我先跑一条SQL查当前事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;重点是找trx_started时间很早、trx_state为RUNNING的事务这种通常就是长事务。长事务会一直持有行锁或间隙锁别的写事务只能排队等待。找到事务后再看它对应的thread idSELECT * FROM performance_schema.threads WHERE PROCESSLIST_ID 事务的trx_mysql_thread_id;可以拿到这个会话当前执行的SQL、时间、连接来源。确认是可以断掉的事务后执行KILLKILL 会话ID;但kill之前要搞清楚这个事务是应用自动提交的短事务还是人为开启的长事务。如果是代码里面的问题开了事务忘记commitkill只是临时处理根本解法是查代码里的事务边界是否完整。锁表处置的思路可以总结成四步先看有没有长事务再看锁等待关系然后kill掉罪魁祸首最后优化SQL。单纯kill完不改代码问题一定会再出现。现象可能原因处置大量写阻塞长事务未提交查innodb_trxkill长会话频繁死锁加锁顺序不一致统一资源访问顺序分布式锁失效锁过期/主从切换设置合理TTL使用续期机制更新慢无索引全表扫描优化SQL加索引4. 分布式锁跨进程的互斥方案4.1 什么时候才需要分布式锁单机上的synchronized和Lock只能控制一个进程内的线程多实例部署后每个应用节点都有自己的锁互不相认。比如库存服务部署了三个实例扣减库存时每个实例都往Redis里读库存再写回三个实例同时操作就会超卖。这时候必须在多进程公共的地方放一把锁大家都能看到、都能承认这就是分布式锁。分布式锁的典型使用场景有秒杀和抢购防止同一个商品被并发扣减多次分布式定时任务同一时间只允许一个节点执行某个任务其他节点等待或放弃多节点操作同一批数据比如批量迁移或者账单生成避免重复执行幂等控制防止重复提交后的重复处理。但要注意分布式锁不是唯一手段。如果目标是防止库存负数可以在数据库里用UPDATE stock SET count count - 1 WHERE count 1这样的原子SQL效果可能更好。需要分布式锁的场景往往是不能用一条数据库语句解决的复杂事务或者跨资源操作。4.2 Redis分布式锁的落地实现Redis实现分布式锁最常用的方式是利用SET命令的NX和EX参数SET lock_key my_random_value NX EX 10NX表示只有key不存在时才设置成功EX表示过期时间为10秒。这条命令是原子性的不需要分开执行SETNX和EXPIRE。如果先SETNX成功再执行EXPIRE设置过期时间中间进程一旦崩溃锁就永远存在这是新手最容易踩的坑。拿到锁之后在finally块里释放锁。释放不能简单地DEL因为锁可能已经过期被其他线程拿到DEL会误删别人的锁。正确做法是用Lua脚本比较value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endvalue必须是一个全局唯一的随机数比如UUID用来标识当前线程。这样即使锁过期也只有持有对应value的线程能删除它。Redis保证Lua脚本原子执行所以检查value和删除之间不会插入其他命令。Java伪代码大致是String lockKey order:lock:1001; String requestId UUID.randomUUID().toString(); Boolean locked jedis.set(lockKey, requestId, NX, EX, 10); if (locked) { try { // 业务逻辑尽量控制在几毫秒到几百毫秒 } finally { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } }这个版本已经可以在大多数业务场景下用。如果公司要求更高可靠性再考虑RedLock或ZooKeeper实现。4.3 分布式锁的三个经典坑第一个坑是锁过期时间不够长。业务执行需要5秒锁设了3秒过期第三个线程就进来了。解决思路有两个一是尽量缩短临界区不要在持锁期间做网络调用、读慢查询二是给锁加续期机制每隔一段时间检查业务是否还在执行如果还在就续期。Redisson的watch dog就是做这件事的。第二个坑是主从切换导致锁丢失。客户端A在Redis主节点加锁成功主节点还没把数据同步到从节点就宕机从节点被提升为新的主节点。因为锁的数据没有同步过来客户端B到新主节点加锁也能成功两个客户端同时进入临界区。RedLock的思路是让客户端向多个独立的Redis节点申请锁超过半数成功才算拿锁成功从而降低单点风险。但对RedLock的争议也很多比如它依赖了同步时钟。如果对一致性要求极高建议用ZooKeeper的临时顺序节点实现分布式锁因为ZK是CP模型基本不会出现锁丢失。第三个坑是锁粒度太大。很多人习惯用一个全局锁比如所有订单操作都使用keyorder:lock结果是所有订单被串行化性能极其糟糕。合理做法是按业务维度拆锁比如把锁key设计成order:lock:{orderId}每个订单一把锁不同订单互不影响。更进一步的思路是分段锁例如把库存分成10个段每次扣减随机选择一段减少锁竞争。5. 应用层锁与面试常见追问5.1 synchronized与Lock怎么选在Java里最简单的是synchronized它有三个优点写法简单、异常自动释放、可重入。缺点是获取锁不能被中断也不能设置超时时间。所以对于临界区很短、竞争不激烈的场景synchronized足够用。Lock是一个接口最常用的是ReentrantLock。它的好处是支持tryLock(timeout)尝试获取锁支持lockInterruptibly响应中断还可以创建多个Condition做精确唤醒。缺点是必须在finally里手动unlock一不留神就会造成锁泄漏。还有一个StampedLock支持乐观读在读多写少的场景下性能更好但对调用方要求比较高。选型我的经验是能用synchronized就别用Lock只有在需要超时控制、可中断等待、多个条件队列时才升级到ReentrantLock。不要为了炫技而引入更复杂的锁复杂的锁意味着更高的出错概率。5.2 偏向锁、自旋锁、互斥锁到底什么意思偏向锁、轻量级锁、重量级锁是JDK对synchronized做的三级优化。偏向锁假设锁始终由同一个线程获得所以只在对象头记录线程ID不真正加锁一旦有第二个线程竞争就升级为轻量级锁。轻量级锁通过自旋等待也就是线程空转CPU循环检查锁是否释放适合临界区很短的情况如果自旋次数过多还拿不到锁就升级为重量级锁也就是真正的互斥锁线程阻塞进入操作系统内核态。这里要提醒一点偏向锁在JDK 15之后已经被正式废弃很多老材料还在讲偏向锁如何优化这在现代JDK上已经不适用了。理解这些概念更大的意义在于锁不是越高级越好而是要根据临界区长度选。自旋锁适合短任务因为自旋期间CPU被白白占用互斥锁适合长任务因为线程阻塞会切换上下文但等待时不占CPU。拿现实生活类比自旋锁就像在电梯门口原地来回踱步等电梯互斥锁就是坐在沙发上刷手机等电梯。如果电梯马上到原地等更快如果电梯还有十分钟原地等就是浪费体力。数据库中的行锁是类似思想都是在取舍时间和资源。5.3 分布式锁面试题速答这部分整理几个面试高频问题方便大家快速回忆。问分布式锁需要满足什么特性 答互斥性同一时刻只有一个客户端持锁可重入同一线程可再次获取高可用锁服务本身不能挂性能要好最好支持锁续期。问Redis和ZooKeeper实现的分布式锁有什么区别 答Redis是AP模型性能好但极端情况下可能丢锁ZooKeeper是CP模型通过临时顺序节点保证一致性一致性更可靠但性能略低且存在session过期与业务处理慢之间的时间差问题。问数据库能实现分布式锁吗 答可以。可以建一张锁表通过唯一索引约束实现互斥插入成功就是拿锁删除就是释放或者用SELECT FOR UPDATE锁行。但数据库锁性能较低通常只用于低频场景。面试里只要把互斥原理、过期时间、释放方式、主从切换问题讲清楚基本就能过关。更重要的是展示你踩过坑比如提到“我用Lua脚本释放锁防止误删别人的锁”这比背定义要好得多。6. 锁章节学习路线与避坑总结6.1 我的学习顺序如果完全从零开始我的建议是“单机锁→数据库锁→分布式锁”。先搞懂单机锁包括synchronized和ReentrantLock理解临界区、可重入、锁分级然后看MySQL锁知道行锁、间隙锁、死锁把information_schema和performance_schema用熟最后才碰分布式锁因为分布式锁建立在单机锁和网络知识之上不了解单机锁很难理解分布式锁为什么要设计过期时间和唯一标识。学的时候不要只看文章一定要动手复现。我建议自己建一张表插入几千行数据开两个终端事务手动构造一个死锁然后去看SHOW ENGINE INNODB STATUS的输出。经历过一次日志解读比刷十篇理论都有用。6.2 遇到锁相关问题的排查思路遇到“接口卡住、CPU不高但请求堆积”我会按下面这个顺序排查。第一步看processlist里有没有大量Waiting for lock或Updating的会话。如果有属于锁等待如果都是Sleep但事务没有提交属于长事务。第二步查innodb_trx找到trx_started最早的活跃事务看它持有什么锁。如果这个事务是ORM框架里忘记提交事务或者其他长时间运行的批处理基本锁定根因。第三步如果涉及分布式锁检查Redis key的TTL看是不是有线程锁没有释放出现“有人加锁但没人释放”的情况。可以临时用GET判断value再决定是否删除。第四步锁定问题后不要急着优化方案。先确认是锁的粒度问题、顺序问题还是锁的过期问题再针对性地改代码。排除的时候要用数据说话不要凭感觉。6.3 最后想提醒的几点我自己踩过几次坑之后现在写并发代码前都会先问三个问题这个共享资源是什么同一个时刻有多少个执行者能访问它如果两个执行者同时进入最坏结果是什么回答清楚这三个问题锁需要的粒度、范围和持有时间基本就定了。还有一点很实际所有锁都只是一种“协调手段”它不能替代业务设计。能通过幂等、版本号、数据库约束解决的不要把所有希望都押在一把锁上。锁用得太多、太广反而会把一个本来简单的业务拖垮。最后分享一个小技巧排查锁问题之前先把慢查询日志和错误日志打开尤其是deadlock相关日志。平时看着没用真出事的时候这些日志是定位问题的唯一抓手。养成先看日志、再动手的习惯能帮你省下一个晚上的时间。
返回列表