1. 为什么我还在用数据库做分布式锁
先聊个现实问题。很多团队一提到分布式锁,第一反应就是上 Redis,第二反应是用 ZooKeeper。但你真的评估过吗?当时我接手某订单系统的库存扣减模块时,Redis 集群还没有搭建完毕,ZooKeeper 更是不存在的组件,引入任何新中间件都要过一轮架构评审。而手头这个需求明天就要上线,并发量不算极端,但错误率必须是零。那时候我翻出数据库这把老武器,认真测了一轮,发现它在特定场景下完全够用,而且比想象中稳。
分布式锁的本质,说穿了就是一句话:在一个共享的存储介质上,占用一个“互斥标识”,谁占到了,谁就拥有执行权。这个介质可以是 Redis,可以是 ZooKeeper,也可以是 MySQL。数据库做锁,没有额外的网络开销、没有新组件要运维、没有心跳超时的问题,它天然就带事务特性,锁的释放可以跟业务事务绑定在同一个生命周期里。这是 Redis 锁很难做到的事情——你见过多少个因为锁忘了删,导致线上“假死锁”事故的?
当然我不是说数据库锁能替代所有场景。它有自己的天花板,比如性能极限、单点故障、锁粒度控制。但如果你面临的是这几个问题:并发量在百级到千级 QPS、基础设施不想加中间件、业务本身强依赖数据库事务,那数据库分布式锁就是最务实的选择。这篇文章我把完整的实现方案、踩坑记录和排查思路都写出来,希望能帮你少走弯路。
2. 整体设计与方案选型:数据库锁的适用边界
2.1 什么场景适合数据库锁
我用一个对比维度来讲这件事。数据库锁的核心优势是“顺手”和“可靠”,核心劣势是“性能”和“可用性”。你在选型的时候,要从业务性质和基础设施两个角度去权衡。
从业务角度看,适合用数据库锁的场景有三个共同点:写多读少的短临界区、低并发下的强一致需求、以及业务本身就走数据库事务。典型的案例包括:订单防重复提交、库存精确扣减、优惠券发放数量控制、以及某些批处理任务的互斥拾取。这些场景的特点是临界区代码执行时间很短,撑死几十毫秒,不会长时间占着锁。
从基础设施角度看,数据库锁适合那些“不想为了一个锁引入一套新系统”的团队。Redis 如果用来做缓存,顺手做一下分布式锁当然没问题,但如果你的 Redis 是单节点或者主从复制延迟比较大,用它做锁反而会踩“锁丢失”的坑——因为主机宕机后从机还没同步到 SETNX 的结果,新主上任后锁就没了。这种场景下数据库的事务特性反而更让你安心。
2.2 为什么不用 Redis 和 ZooKeeper
这不是踩一个捧一个,我把三者的本质差异讲清楚,你可以自己判断。
Redis 分布式锁走的是 SET NX EX 的命令,核心问题是“锁的持有状态存在于内存里”。它的一致性模型是最终一致,主从切换时会丢锁。就算红锁 Redlock 算法想把这个问题绕过去,也需要部署五台以上的独立节点,复杂度一下子就上来了。在绝大多数国内中小团队里,我敢说真正正确实现 Redlock 的屈指可数。
ZooKeeper 的临时顺序节点做锁确实最稳当,它通过会话心跳维持锁的有效性,客户端崩了锁就自动消失,不需要设置超时时间。但它的成本在于:你得维护一套 ZooKeeper 集群,而且客户端连接还需要额外的网络交互,一次加锁可能要走两三次网络往返。对于追求“少一点组件、多一点安稳”的小团队来说,这门槛不低。
数据库锁的优点正好击中这两者的痛点:MySQL 的事务帮你把加锁和业务操作合并在一个原子单位里,要么都成功,要么都失败,不存在“锁加了业务没执行成功导致锁残留”的尴尬。网络模型也简单,应用连的是同一个数据库,锁状态看得见摸得着,出了问题直接查表就能看到。
2.3 数据库锁的三种核心实现路线
我在项目实践中总结出来,数据库分布式锁主要有三条实现路线,各有适合的场景。
第一条是基于唯一索引的插锁法。建一张独立的锁表,把“业务资源标识”设为唯一索引,获取锁就是 INSERT 一条记录,释放锁就是 DELETE 这条记录。谁 INSERT 成功了,谁就拿到了锁。这是最简单、最直白的方案,适合锁数量少、锁表规模可控的团队。
第二条是基于行锁的 SELECT FOR UPDATE。不需要额外的锁表,直接利用业务表或者一张通用锁表中的行记录,在事务里执行 SELECT ... FOR UPDATE,数据库会对命中的行加排他锁,其他事务再尝试锁同一行时就会阻塞等待。这是我推荐的主力方案,因为它是数据库引擎原生能力,不加锁表、不产生垃圾数据,锁的粒度也能精细控制到某一行。
第三条是基于乐观锁的版本号机制。表里加一个 version 字段,更新时带上“WHERE version = 上次读到的值”,如果影响行数为 0,说明版本被改过了,就重试或失败。严格来说这不算“锁”,但它是最轻量级的并发控制手段,完全无阻塞,适合读多写少、冲突概率低但不容有失的场景。
这三条路线不是互斥的。我在实际项目中通常是混合使用:行锁做核心资源的强互斥,唯一索引做幂等控制,乐观锁做高频但低冲突的更新保护。
3. 核心细节解析与实操要点
3.1 唯一索引锁表的设计与加解锁流程
先看我最常推荐的插锁法实现,因为它的代码最好理解,也最适合用来解释分布式锁的核心逻辑。
建表语句大概长这样:
CREATE TABLE `biz_lock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resource_name` VARCHAR(128) NOT NULL COMMENT '业务资源标识', `owner_token` VARCHAR(64) NOT NULL COMMENT '持有者标识,防止误删别人的锁', `expire_time` DATETIME NOT NULL COMMENT '锁的过期时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_resource_name` (`resource_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;加锁操作的 SQL 就是一个带唯一键去重的插入:
INSERT INTO biz_lock (resource_name, owner_token, expire_time) VALUES ('order:10001:deduct', 'instance-A-12345', DATE_ADD(NOW(), INTERVAL 30 SECOND));如果这条 INSERT 成功,说明你抢到了锁。如果报Duplicate entry错误,说明锁已经被别人持有,你就进入等待或者直接返回失败。这个思路和日常生活中的占座一模一样——桌上放着书包,说明位置有人占了,你想坐就得等别人把书包拿走。
释放锁的 SQL 也不能太随意,简单的 DELETE 有个隐患:如果当前持锁线程卡住了,锁实际上已经超时过期,另一个线程插入了自己的记录获得了锁,此时原线程恢复过来执行了 DELETE,会把别人的锁删掉。所以释放锁必须带上 owner_token 条件:
DELETE FROM biz_lock WHERE resource_name = 'order:10001:deduct' AND owner_token = 'instance-A-12345';这样即使删不到任何行,也不会误伤别人的锁。owner_token 我建议用随机 UUID 拼上实例 ID,保证全局唯一。
3.2 SELECT FOR UPDATE 行锁的正确打开方式
如果说插锁法是“从零占用量”,那 FOR UPDATE 行锁就是“直接在资源上锁门”。它的实现更优雅,不需要反复插数据删数据,对数据库的写入压力也小得多。
通用锁表的建表语句可以这样简化:
CREATE TABLE `sys_resource_lock` ( `resource_key` VARCHAR(128) NOT NULL COMMENT '资源唯一键', `owner_token` VARCHAR(64) NOT NULL, `lock_time` DATETIME NOT NULL, `update_time` DATETIME NOT NULL, PRIMARY KEY (`resource_key`) ) ENGINE=InnoDB;关键步骤来了。你在获取锁的时候,事务 A 打开,执行下面这条语句:
SELECT * FROM sys_resource_lock WHERE resource_key = 'resource-key-001' FOR UPDATE;如果记录不存在,先 INSERT 再 SELECT FOR UPDATE,或者直接用INSERT ... ON DUPLICATE KEY UPDATE把记录建好。因为 resource_key 是主键,InnoDB 会自动对这行记录加排他锁。此时事务 B 尝试执行同样的 SELECT FOR UPDATE,就会一直阻塞在那里,直到事务 A 提交或回滚。这里有个微妙的行为要提醒你:如果查不到记录,普通 SELECT 不加锁,所以先 INSERT 保证行存在是必要的,或者用SELECT ... FOR UPDATE配合gap lock,但那样锁的范围可能超出你的预期。
FOR UPDATE 方案最大的好处是阻塞等待,而不是立即失败。你的业务线程可以一直等着锁释放,不需要自己写轮询重试逻辑。这就像买票排队——后面的人不用反复跑到售票窗口看前面的人走没走,只要队伍前进,自然轮到你。
3.3 乐观锁版本号机制:无阻塞的轻量并发控制
乐观锁我理解成一种“实时对账”的思路。它不预先占用资源,而是在更新那一刻检查资源是否被改过。
假设你有一张库存表:
CREATE TABLE `inventory` ( `id` BIGINT NOT NULL, `stock_qty` INT NOT NULL, `version` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB;扣减库存时,你的 SQL 长这样:
UPDATE inventory SET stock_qty = stock_qty - 1, version = version + 1 WHERE id = 1 AND version = 7;这条 UPDATE 会返回受影响的行数,如果返回 1,说明更新成功,你读到的版本号 7 是当前最新的;如果返回 0,说明在你读数据之后、执行更新之前,已经有人把版本号改掉了,你需要重新读取最新数据再试一次。这个过程不需要任何数据库层面的锁——执行 UPDATE 时 InnoDB 本身会对命中行加锁,但这个锁在语句执行完就释放了,属于行级短事务锁,不是持有型锁。
乐观锁的核心是重试逻辑,有一个简单的最大重试次数限制:
max_retry = 3 for i in range(max_retry): item = session.query(Inventory).filter_by(id=1).first() result = session.execute( "UPDATE inventory SET stock_qty = stock_qty - 1, version = version + 1 " "WHERE id = 1 AND version = :version", {"version": item.version} ) if result.rowcount == 1: session.commit() break else: session.rollback() time.sleep(0.05) else: raise LockConflictError("重试次数耗尽,请稍后再试")3.4 锁的续期与防超时释放
数据库锁的实现绕不开一个经典难题:线程执行的时间超过了锁的过期时间,锁被清理掉了,另一个线程拿到锁进来了,结果两个线程同时在跑临界区代码——这就是分布式锁过期导致的并发失控。
处理这个问题有三个思路,我按推荐程度排序。
方案一是预估时间法:根据历史统计把锁的过期时间设置成最大执行耗时的 2 倍以上。这适合执行时间比较稳定的任务。缺陷是如果某次请求因为网络抖动或者 Full GC 停下来,这个预估就失效了。
方案二是续期看门狗法:加锁成功以后,起一个后台定时任务,每过锁过期时间的三分之一就自动把 expire_time 往后推。比如锁过期时间设 30 秒,每隔 10 秒就 UPDATE 一次 expire_time 加 30 秒。业务执行完就取消定时任务并释放锁。
方案三是逻辑校验法:在锁记录里维护一个自增的 lock_token,每个线程释放锁之前重新读一次数据库对比 token 是否还是自己的,如果不是就直接回滚业务事务,不做释放。这个方案从根本上防止超时后旧线程误删新锁,但没有防止旧线程继续跑完临界区代码。所以如果你真的关心“单线程独占”这件事,最好还是用续期看门狗,不能让锁过期。
4. 实操过程与核心环节实现
4.1 基于 FOR UPDATE 的完整代码骨架
我直接给出一段可以抄作业的 Java 代码骨架,使用的场景就是某订单系统里的库存扣减。这段代码我实际在生产环境跑了将近一年,稳定性可圈可点。
@Transactional public void deductStock(Long skuId, Integer quantity) { // Step 1: 锁定库存行 InventoryLock lock = inventoryLockMapper.selectForUpdate(skuId); if (lock == null) { // 该 SKU 对应的锁记录不存在,先初始化 inventoryLockMapper.insert(skuId); lock = inventoryLockMapper.selectForUpdate(skuId); } // Step 2: 检查库存 Inventory inventory = inventoryMapper.selectForUpdate(skuId); int remain = inventory.getStockQty() - lock.getLockQty(); if (remain < quantity) { throw new InsufficientStockException("库存不足"); } // Step 3: 扣减库存 inventoryMapper.decrease(skuId, quantity); // Step 4: 订单创建与明细写入 orderMapper.insert(order); // Step 5: 业务操作和锁释放都在同一个事务里提交 // 事务提交后,FOR UPDATE 的行锁自动释放,不需要显式解锁 }这里最关键的一点,也是我和很多同行聊的时候发现大家最容易犯错的一点:加锁和释放锁的时机必须跟随事务边界,不要手动在业务代码里调用 unlock。如果你在同一个方法里先手动拿到了行锁,然后业务操作,再手动释放行锁,最后事务才提交——那么其他事务会在你手动释放的瞬间就进来读数据,而此时你的事务还没提交,数据还没变成最新状态,并发问题就会在毫秒级的时间窗里出现。
正确的做法是让行锁的生命周期和数据库事务完全重合。这个过程像什么?就像你把办公室钥匙插在门锁里直到下班才拔出来,期间任何人都没办法把门锁上。方法首行执行 SELECT FOR UPDATE,然后所有业务操作,最后方法出了 @Transactional 的边界,数据库提交事务并释放锁,一气呵成。
4.2 插锁法的补偿定时任务设计
插锁法的释放依赖主动 DELETE,所以“锁残留”的问题比 FOR UPDATE 方案更突出——进程可能没来得及删锁就崩溃了。补偿定时任务就是给这种意外兜底的。
我的设计思路是这样的:定时任务每 5 秒扫描一次锁表,找出所有 expire_time 小于当前时间的记录,判断 owner_token 是否还活着。如果记录过期了,直接把过期的锁记录替换成自己的新记录。注意不是 DELETE 再 INSERT,而是用 UPDATE 把资源名和持有者信息一起改掉,这样可以避开唯一索引冲突的问题。实际执行 SQL 可以简化成:
UPDATE biz_lock SET owner_token = 'instance-B-67890', expire_time = DATE_ADD(NOW(), INTERVAL 30 SECOND) WHERE resource_name = 'order:10001:deduct' AND expire_time < NOW();如果这条 UPDATE 影响行数是 1,你就成功接管了过期的锁。如果影响行数是 0,说明这锁要么还没过期,要么在并发下被别人抢走了,那就放弃这一轮,等下一轮。整个补偿逻辑跟抢单很像——看到订单超时了,谁先点击“接管”按钮,这单就归谁。
4.3 锁粒度怎么选才合理
锁粒度是个容易被忽略但影响巨大的设计点。我遇过一个人,他把整个订单模块的锁设计成了一把全局锁,也就是说所有订单一串行执行,结果并发一上来,数据库连接池直接被打爆。实际上锁应该锁“最小必要资源”。
我建议锁粒度按资源标识分拆。拿订单状态机来说,不要把锁建在“订单模块”上,要建在order:{orderId}:status上。拿库存扣减来说,锁的 key 就应该是stock:{skuId}:deduct,不同的 SKU 之间完全不应该互相阻塞。再看转账场景,锁的 key 得做成根据账户 ID 排序后的拼接形式,比如transfer:{minAccountId}:{maxAccountId},避免死锁。
锁粒度设计得太粗,并发能力下降,太细又会增加锁表的数据量和管理复杂度。我的经验法则是:临界区代码越短,锁的粒度可以越细;业务之间共享的资源越多,锁的粒度就应该越粗。不是越细越好,而是越贴合业务越好。
4.4 超时时间的计算与验证方法
数据库锁的超时时间,很多人是拍脑袋定的,要么 30 秒,要么 1 分钟。我的做法是先统计再设定,分三步走。
第一步,在代码里给临界区加耗时埋点,跑一晚真实的业务流量,统计 P99 耗时。第二步,把 P99 耗时乘以 3 到 5 作为锁的默认过期时间。第三步,每周观察一次超时记录,如果连续多次业务还没执行完锁就过期了,说明阈值定得太低,需要上调。
举个例子,某库存扣减接口的 P99 耗时是 80 毫秒,那锁的过期时间我会设成 500 毫秒到 1 秒,而不是拍脑袋设 5 秒。太长的时间窗口会降低并发能力,因为一旦持有锁的实例挂了,其他实例要等很久才能在补偿任务里夺回锁。太短又容易误伤正常业务。这个取值要结合补偿任务的执行频率来调——补偿任务跑得越勤,过期时间就可以设得越短。
5. 常见问题与排查技巧实录
5.1 锁表记录堆积不清理
这是我见过最多的问题。插锁法能正常工作,但锁表只增不减,一台数据库的磁盘空间眼看着往下掉。原因很简单:业务代码里所有正常路径都执行了 DELETE,但总有几个异常路径——比如消息重试、定时任务中断、服务被强制杀死——导致 DELETE 没执行。
解决方案分两层。业务层是加强 finally 块,释放锁的操作必须放在 finally 里执行,绝不能放在正常流程的末尾。系统层是写一个周期清理任务,把过期时间远超当前时间的记录删掉。不过要小心,清理任务不能动那些还没过期的锁记录。还有一层更保险的方案:给锁表做分区表,按月份归档过期数据,查询和清理都方便。
5.2 连接池被打满的排查思路
FOR UPDATE 方案有个经典坑:某个业务事务里忘记提交事务,行锁一直不释放,后面的请求全部阻塞在 SELECT FOR UPDATE 上,数据库连接池被占满。出现这个问题的第一反应别去重启服务,而是先查数据库里的锁等待情况。
我常用的排查 SQL 有这几条:
-- 查看当前有哪些锁等待 SELECT * FROM information_schema.INNODB_TRX; -- 查看锁等待的关系 SELECT * FROM information_schema.INNODB_LOCK_WAITS; -- 查看当前所有连接及其运行状态 SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep';找到持有锁的那个事务 ID 之后,直接执行 KILL 把它干掉,业务就能恢复。之后再去代码层面看为什么那个事务没有正确提交。这种问题多半出在手动管理事务的代码上——有人在业务中间 catch 了异常却忘了调 rollback,或者把加锁方法写在了事务方法外面,导致锁被事务提前释放。
5.3 死锁怎么发生怎么避免
数据库分布式锁也会死锁?还真会。最典型的场景是两个事务各持一把锁,然后互相申请对方手里的锁。
以转账为例,事务 A 先锁账户 1 再锁账户 2,事务 B 先锁账户 2 再锁账户 1,两边都执行到一半,互相等待对方释放手中的锁,死锁就产生了。InnoDB 会自动检测死锁并回滚其中一个事务,但对于分布式锁来说,回滚同样意味着业务失败,所以最好还是在源头避免。
避免方法就是统一加锁顺序。所有涉及多个资源的锁操作,先把资源标识按哈希值或者字典序排序,再按这个顺序依次加锁。这样事务 A 和事务 B 的加锁顺序永远是一致的,谁也等不到谁手里的锁。
5.4 主从切换与强一致性问题
数据库锁存储在 MySQL 里,那 MySQL 本身的主从切换会不会带来锁状态不一致?会。如果你用的是普通的主从复制架构,锁数据写入主库,还没同步到从库的时候主库宕机,新主库顶上之后,锁信息就丢了。
对这个问题的正确态度是:接受它,然后规避它。规避方式就是锁表必须和业务表放在同一个主库上,让锁的读写和业务的读写走同一个数据源,这样只要业务数据不丢,锁数据也不丢。想彻底解决这个问题,需要用 MySQL 的 Group Replication 或者半同步复制来保证主从之间锁数据的强一致,但这已经超出了绝大多数业务团队愿意付出的运维成本。
我个人的判断标准是:如果你的业务对锁的一致性要求高到“主从切换也不能丢锁”,那数据库锁可能确实不适合你,你该早早上 ZooKeeper。如果你的业务能接受“切换窗口期短暂并发冲突”,数据库锁这种实用主义方案完全够用。
5.5 常见问题速查表
| 问题现象 | 大概率原因 | 解决思路 |
|---|---|---|
| 锁表数据只增不减 | 异常路径没执行 DELETE | finally 块释放锁,配合定时清理任务 |
| 并发请求大量超时 | FOR UPDATE 事务未提交 | 检查手动事务控制代码,查看 INNODB_TRX |
| 两个线程同时进入临界区 | 锁过期时间设置过短 | 统计 P99 耗时,扩大过期时间,或增加续期机制 |
| 数据库连接池耗尽 | 行锁持有时间过长 | KILL 阻塞事务,复查代码事务边界 |
| 死锁日志大量出现 | 多资源加锁顺序不一致 | 统一资源标识排序策略 |
| 实例重启后锁直接被抢 | 锁表无过期保护 | 锁超时时间加长,或接入看门狗续期 |
6. 性能调优与容量规划
6.1 锁表拆分与按业务隔离
锁表不要所有业务混在一张表里。混合存放的问题在于,某个业务的锁查询量一大,就会影响其他业务的加锁体验,因为 MySQL 的 InnoDB 行锁是基于索引的,如果两个业务的数据在同一张表的不同页上,互相之间也会产生缓冲池争用。
我的做法是按业务模块拆表,比如订单锁表、库存锁表、账户锁表。每个表只服务一个业务集群,一旦某个业务量暴涨,你只需要单独扩容那个锁表对应的数据库实例,而不影响其他业务。这比单表大而全的设计在运维上舒服很多。
更进一步,锁表还可以按业务量水平拆分。比如订单锁表按订单 ID 取模分 16 张表,biz_lock_0到biz_lock_15,写入和查询压力被分摊到 16 张表上。数据库的并发瓶颈很大程度上是页级别的锁竞争,拆表能显著缓解这个竞争。
6.2 短事务是数据库锁的生命线
做数据库锁,我最强调的一条铁律就是:临界区代码必须短,事务必须短。
为什么?因为 InnoDB 的行锁要在事务提交时才释放,事务执行越长,锁持有的时间越长,其他事务的等待就越久。这个等待像高速公路上的事故车道——一辆车停得越久,后面积压的车就越多,最终整个数据库连接池都可能堵死。
所以你要做的优化是:把耗时的外部调用挪到事务外面去。比如查库存、计算价格这些操作可以在事务前做,事务里只做数据变更和状态更新。如果实在有外部接口要调,可以先把状态置成“处理中”,提交事务释放锁,然后调用外部接口,再用独立的短事务更新最终状态。这样锁的占用时间就从原来的几百毫秒压缩到了几十毫秒。
6.3 数据库锁的监控指标
生产环境跑数据库锁,不能不做监控。我列几个最有价值的指标:加锁成功耗时、锁等待耗时、锁超时次数、持锁时间分布、锁表数据量变化。
锁等待耗时特别关键,它直接反映锁竞争的程度。正常情况 P99 应该在几十毫秒以内,如果你发现锁等待的 P99 超过 500 毫秒,说明有事务持锁时间过长,赶紧定位是哪个业务。锁超时次数则能暴露锁过期时间设计不合理的问题。持锁时间分布用来验证临界区是否足够短,如果经常出现持锁时间超过一秒的线程,多半是外部调用没有被移出事务。
监控的落地方式很简单,锁的工具类里做一个切面,把耗时埋点和结果状态打进日志系统。数据库侧可以通过慢查询日志发现持锁过长的 SQL。表数据量直接跑一条 SELECT COUNT 定时任务进监控系统。
6.4 数据库锁与缓存锁的搭配策略
在容量规划上,我最后想聊一个组合打法。数据库锁不是银弹,Redis 锁也不是银弹,但两者组合反而经常能打出非常好的效果。
我惯用的策略叫“Redis 挡流量,数据库保底”。高并发场景下,先用 Redis 的 SET NX 做一级快速过滤——大部分请求在 Redis 这一层就会失败返回,很快,不占用数据库连接。只有真正抢到 Redis 锁的少量请求,才继续走数据库的 FOR UPDATE 锁,确保最终一致性。这个组合把 Redis 的性能优势和数据库的事务可靠性结合起来了,既扛住了突发流量,又保证了极端情况下的正确性。
不过这属于后话了。如果你的团队还没引入 Redis,或者锁的并发量还在数据库能承接的范围内,先用好数据库锁就够了。等将来量级上来了,再按这套思路去演进,不用急着一步到位。
最后说点实践感悟
数据库分布式锁这个东西,我在不同项目里前前后后用过很多次,有成功的经验,也有踩坑的教训。坦白说,它在某些“纯粹的性能党”眼里显得不够高级,但工程上我们要的是“在当前条件下解决问题的最优解”,而不是“最完美的架构”。如果你的团队还没上 ZooKeeper,如果你不想为了一把锁引入一套全新的中间件,如果你们的并发规模仍然处于数据库能从容应对的范围,那数据库分布式锁值得认真考虑。
我最后给你一个落地顺序建议:先跑通 SELECT FOR UPDATE 的事务锁方案,这是错误率最低、最容易理解的路线;再加上唯一索引锁表做幂等控制;等业务量变大、连接池吃紧了,再引入 Redis 做前置拦截,数据库继续守着最后一道一致性关口。踩过几次坑之后你会发现,分布式锁用哪个中间件实现其实没那么重要,重要的是你想清楚了“这把锁锁的是什么资源、要锁多久、锁失效了怎么办”。把这个想明白了,任何存储介质都能被你用得服服帖帖。