本文的讨论基于MySQL InnoDB引擎。
互斥锁
锁的基本特性是互斥性,可以利用数据库的锁来实现这种互斥性。
- 创建数据库表
CREATE TABLE biz_lock
(
id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
biz_id varchar(64) NOT NULL COMMENT '业务单号',
biz_type varchar(64) NOT NULL COMMENT '业务类型',
gmt_create timestamp DEFAULT current_timestamp COMMENT '创建时间',
gmt_modified timestamp DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
PRIMARY KEY (id),
UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'
biz_type+biz_id唯一确定一把锁。
基于唯一索引
- 加锁
insert into biz_lock(biz_id, biz_type, locked_time) value ('1','ORDER','2021-07-26 12:00:00');
插入时,会获取唯一索引的记录锁。
如果此时有其它线程试图加锁,因为唯一索引约束会报错[23000][1062] Duplicate entry '1-ORDER' for key 'biz_lock.uk_biz_id_type',加锁失败。
- 解锁
delete from biz_lock where biz_type = 'ORDER' and biz_id = '1';
锁记录已经从数据库表中移除,此时其它线程请求加锁,将会有一个线程成功。
select...for update
基于一条已存在的记录获取记录锁。
- 加锁
begin ;
select * from biz_lock where biz_type = 'ORDER' and biz_id = '1' for update nowait ;
开启事务,使用select...for update获取记录锁,设置nowait获取锁失败时立即返回,不会阻塞。如果返回预期的行记录,则加锁成功。
此时,如果有另一个线程也在事务中执行该操作,则select语句返回错误信息[HY000][3572] Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.,加锁失败。
- 解锁
commit ;
rollback ;
事务提交/回滚后会自动释放事务持有的全部锁。
缺陷
select...for update必须在事务中使用,否则语句执行成功的同时,获取到的记录锁也会释放,达不到加锁的目的。- 由于第一点限制,锁的范围要尽可能小,且不应执行耗时的操作(例如
RPC)。 - 需要定时清理无用的锁记录。
综上所述,基于唯一索引的实现方式更为简单、灵活,适用范围更广。下文的讨论默认都是基于唯一索引的DB锁实现。
可重入
可重入的含义是:如果某个线程已经持有了一把锁,那么它可以再次获得这把锁且不会出现死锁。
实现可重入的基本思路:
-
加锁
- 首次加锁成功,记录锁的持有者线程为当前线程,重入次数=1;
- 如果锁已被某个线程持有,判断持有者是否为当前线程。如果是,则重入次数加1,加锁成功;否则,加锁失败;
-
解锁
- 判断锁的持有者是否为当前线程,如果不是,解锁失败;
- 否则,锁的可重入次数减1。如果剩余可重入次数大于0,则直接返回;否则,删除锁记录。
基于DB行记录
增加表字段
- owner:锁持有者
- reentrant:重入次数
修改后建表语句如下:
CREATE TABLE biz_lock
(
id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
biz_id varchar(64) NOT NULL COMMENT '业务单号',
biz_type varchar(64) NOT NULL COMMENT '业务类型',
owner varchar(64) not null comment '持有者线程',
reentrant int not null default 1 comment '重入次数',
gmt_create timestamp DEFAULT current_timestamp COMMENT '创建时间',
gmt_modified timestamp DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
PRIMARY KEY (id),
UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'
- 加锁
public boolean lock(String bizType, String bizId) {
// 线程ID + JVM实例在分布式环境下唯一定位一个线程
String owner = Thread.currentThread().getId() + "@" + ManagementFactory.getRuntimeMXBean().getName();
try {
// 插入锁记录, 设置持有者为当前线程, 重入次数=默认值1
jdbcTemplate.update("insert into biz_lock (biz_type, biz_id, owner) values (?, ?, ?)", bizType, bizId, owner);
return true;
} catch (DuplicateKeyException e) {
// 锁记录已存在, 如果持有者是当前线程, 则重入次数加1
int i = jdbcTemplate.update("update biz_lock set reentrant = reentrant + 1 where biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
// 更新失败代表持有者不是当前线程, 加锁失败
return i == 1;
}
}
- 解锁
public void unlock(String bizType, String bizId) {
// 线程ID + JVM 实例在分布式环境下唯一定位一个线程
String owner = Thread.currentThread().getId() + "@" + ManagementFactory.getRuntimeMXBean().getName();
// 如果持有者是当前线程, 可重入次数减1
int i = jdbcTemplate.update("update biz_lock set reentrant = reentrant - 1 where biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
// 更新失败代表当前线程未持有锁
if (i == 0) {
throw new IllegalMonitorStateException();
}
// 更新成功检查可重入次数
Integer reentrant = jdbcTemplate.queryForObject("select reentrant from biz_lock where biz_type = ? and biz_id = ? and owner = ?", new Object[]{bizType, bizId, owner}, Integer.class);
// 可重入次数等于0, 删除锁记录
if (reentrant == 0) {
i = jdbcTemplate.update("delete from biz_lock where biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
Assert.isTrue(i == 1);
}
// 以上的查询、判断、更新操作是安全的, 因为锁被当前线程持有, 其它线程无法更新锁记录
}
基于原生的JVM锁
可重入的关键是要能感知到持有锁的线程是否是当前线程。而JVM锁java.util.concurrent.locks.ReentrantLock 已经实现了该功能,因此只需要在基于唯一索引的DB锁实现上以装饰者模式增加可重入的职责即可。
死锁风险
唯一索引死锁
试想以下场景:
线程1:插入记录成功,事务提交; 线程2:插入失败,拿到唯一索引的独占锁; 线程3:插入失败,拿到唯一索引的共享锁; 线程2:请求更新,等待唯一索引的独占锁; 线程3:请求更新,等待唯一索引的独占锁;
线程2独占锁(持有) ← 线程3共享锁(持有)← 线程2独占锁(等待)← 线程3独占锁(等待)。
此时线程2持有独占锁,等待线程3释放共享锁;线程3持有共享锁,等待线程2释放独占锁。死锁形成。
因此,最好不要在同一个事务中对唯一索引执行插入和更新操作,否则并发场景下很容易形成死锁。
锁未释放引发死锁
如果获得DB锁的线程对应的服务器宕机了会发生什么?——这个锁将永远无法释放,其它服务器对该锁的请求也将失败。为了应对这种情况,需要给锁加上超时时间。
数据库表新增字段timeout:
CREATE TABLE biz_lock
(
id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
biz_id varchar(64) NOT NULL COMMENT '业务单号',
biz_type varchar(64) NOT NULL COMMENT '业务类型',
timeout timestamp not null comment '超时时间',
gmt_create timestamp DEFAULT current_timestamp COMMENT '创建时间',
gmt_modified timestamp DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
PRIMARY KEY (id),
UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'
加锁时,如果唯一索引冲突,继续检查锁记录:如果当前时间 > 锁的超时时间(已过期),则更新锁的超时时间为本次请求的超时时间,更新成功则加锁成功(更新时使用乐观锁保证并发更新安全)。
锁超时时间又会引入新的问题:如果业务逻辑因为GC或者网络延时等原因没有在预期的时间内执行完成,此时另一个线程判断锁已过期,成功获得了锁。那么锁的互斥性就被打破了,并发安全问题将会再次出现。
最佳实践
在事务中使用基于DB的锁
加锁的时机和位置是自由的,如果在事务中使用基于唯一索引的DB锁会发生什么呢?
假设线程1在事务中插入了锁记录,且无唯一索引冲突,那么该事务持有唯一索引的互斥记录锁; 此时线程2在事务中插入锁记录时,将会因获取唯一索引的记录锁而阻塞,直到线程1的事务结束释放记录锁。
大部分情况下,我们希望事务中不要发生阻塞,如果获取锁失败,直接抛出异常,事务回滚。
在Spring管理的事务中,可以将DB锁涉及的操作放在一个传播机制为PROPAGATION_REQUIRES_NEW的事务中:
<aop:aspectj-autoproxy/>
<aop:config>
<aop:advisor advice-ref="bizLockAdvice"
pointcut="execution(* com.alipay.pcloan.common.lock.repository.BizLockDao.*(..))"/>
</aop:config>
<tx:advice id="bizLockAdvice">
<tx:attributes>
<tx:method name="insert*" propagation="REQUIRES_NEW"/>
<tx:method name="delete*" propagation="REQUIRES_NEW"/>
<tx:method name="update*" propagation="REQUIRES_NEW"/>
</tx:attributes>
</tx:advice>
锁涉及的DB操作在一个独立的物理事务中,可以无视外层事务直接提交或回滚;外层事务也不会受锁操作事务状态的影响。
总结
| 特性 | 描述 |
|---|---|
| 排他性 | 支持 |
| 可重入 | 支持 |
| 死锁 | 可避免 |
| 阻塞和非阻塞 | 非阻塞;不支持阻塞 |
| 公平锁和非公平锁 | 非公平锁;不支持公平锁 |
| 高可用、高性能 | 高可用依赖DB的高可用主备;性能较基于缓存的分布式锁低 |