Mysql可以做分布式锁吗?Mysql分布式锁的应用
文章目录
- 1. 引言
- 2. mysql为什么可以做分布式锁?
- 3. mysql分布式锁实现方式
- 4. 哪些场景下适合用mysql做分布式锁?
- 5. mysql分布式锁的局限性
- 6. 交易系统使用mysql做读写互斥锁的案例
- 6.1 业务背景
- 6.1 业务抽象
- 6.3 解决过程
1. 引言
在分布式系统中,分布式锁是一个常见的需求,用于在多个节点之间协调对共享资源的访问。通常情况下,使用 Redis
等内存数据库实现分布式锁是更流行的选择,因为它们通常提供更快的响应时间和更高的吞吐量。但 MySQL 也可以实现分布式锁,并且在某些业务场景下,使用 MySQL 实现分布式锁是合理的选择。
2. mysql为什么可以做分布式锁?
MySQL能够作为分布式锁的实现基础,主要依赖于其ACID特性(特别是原子性和持久性)和唯一键约束。以下从多维度分析
3. mysql分布式锁实现方式
在 MySQL 中,常见的实现分布式锁的方法有以下几种:
基于唯一约束和行锁
- 创建一个锁表,例如 distributed_lock,包含字段 lock_name(唯一索引)和 expire_time 等。
- 获取锁时,尝试插入一条记录(锁名作为唯一键)。如果插入成功,表示获取锁成功。
- 释放锁时,删除该记录。
- 为了避免死锁,可以设置一个过期时间,并使用定时任务清理过期的锁。
基于悲观锁(SELECT FOR UPDATE)
- 使用事务,通过 SELECT … FOR UPDATE 对锁表中的一行进行锁定。
- 成功获取锁后,执行业务逻辑,然后提交事务以释放锁。
- 其他事务在尝试获取同一把锁时会被阻塞,直到第一个事务提交。
乐观锁(版本号)
- 在表中增加版本号字段,更新时检查版本号。但这种方法不适用于锁,因为锁需要独占,而乐观锁通常用于避免冲突的更新,不适合互斥锁的场景。
4. 哪些场景下适合用mysql做分布式锁?
虽然 Redis 分布式锁(如 Redlock)在性能上更有优势,但以下场景可能更适合使用 MySQL 实现分布式锁:
对锁的可靠性要求非常高,且对性能要求不是极端敏感
- MySQL 的事务特性和持久性(ACID)确保了锁操作的可靠性和一致性。在需要严格保证锁的正确性且可以接受毫秒级延迟的场景下,MySQL 是更好的选择。
- 例如,金融系统中的某些关键操作,需要确保在分布式环境下操作的绝对正确性,而 Redis 锁可能因为节点故障或网络分区导致锁的不可靠(即使 Redlock 算法也存在争议)。
业务本身需要基于数据库事务进行
- 如果业务操作在一个数据库事务中,需要将锁的获取和业务操作放在同一个事务中,以保证原子性。
- 例如,在一个事务中需要锁定某个资源,然后更新多个表,这时使用 MySQL 的行锁或排他锁可以天然地保证锁和业务操作的原子性
在金融支付业务中,数据的一致性和可靠性是至关重要的。MySQL锁(基于数据库的分布式锁)提供了一些关键特性,这些特性在金融场景中非常有用:
强一致性
:MySQL作为关系型数据库,使用ACID事务,可以保证锁操作的原子性和一致性。在支付这种对数据一致性要求极高的场景中,能够确保锁的状态和业务数据的状态在同一个事务中提交,避免锁与业务数据不一致的情况。持久性
:MySQL的锁可以持久化到磁盘,即使数据库重启,锁状态也可以通过表中的记录进行恢复(或者通过redoLog
恢复)。而Redis锁在重启后可能会丢失(除非使用AOF持久化并且每次操作都fsync,但这样性能会下降)。可审计性
:锁的操作会记录在数据库的事务日志中或者自定义的锁记录表中,便于事后审计。在金融行业中,监管要求严格,需要能够追踪每一步操作。
5. mysql分布式锁的局限性
MySQL锁在性能上通常不如Redis锁,因此在低延迟、高并发的支付场景中可能会成为瓶颈。所以,在高并发支付场景下,通常会进行权衡,对于核心资金操作(如账户扣款、生成交易记录)使用MySQL锁,而并发控制(如库存扣减)可能会用Redis锁。
6. 交易系统使用mysql做读写互斥锁的案例
6.1 业务背景
举一个P2P的场景,这里先介绍一下 p2p 的业务
比如小明需要 10 万买车,但是手头上没钱,此时可以在 p2p 平台上申请一个 10 万的借款,然后 p2p平台会发布一个借款项目,开始募集资金。
其他网民可以去投资这个项目,每个月借款人小明会进行还款,投资人会拿到收益。
当投资人每次投资的时候,会产生一份债权,可以把债权理解为借款人欠你钱的一个凭证。
如果投资人急着用钱,但是此时投资还未到期,此时投资人可以发起债权转让,将投资人的债权卖给给其他人,这样投资人就可以及时拿到本金了。
这里面涉及到 2 个关键的业务:
借款人(小明)执行还款
:借款人执行还款的时候,会将资金发到投资人账户中,涉及到投资人账户资金的变动,还有债权信息的变化等,整个还款过程涉及到调用银行系统,过程比较复杂,耗时相对比较长投资人发起债权转让
:投资人发起债权转让,也涉及到债权的编号和投资人账户的资金的变动
由于这 2 个业务都会操作债权记录和投资人账户资金,为了保证资金的正确性,降低系统的复杂度,可以选择让这 2 种业务互斥
- 某笔借款执行还款的过程中,那么这笔借款关联的所有债权记录不允许发起转让
- 如果某笔借款记录当前没有在还款处理中,那么这笔借款记录关联的债权都可以同时发起债权转让
下文的代码也都是基于上述互斥业务进行的
6.1 业务抽象
上述业务所在应用如果是集群部署,那么java 中的 ReadWriteLock 读写锁就排不上用场了,把如上业务抽象成
X
:互斥的资源idW
:借款人还款操作R
:投资人债权转让操作
对 X 资源,可以执行 2 种操作:W 操作、R 操作,2 种操作需要满足下面条件
-
执行操作的机器分布式在不同的节点中,也就是分布式的
-
W(借款人还款) 操作是独享的,也就是说同一时刻只允许有一个操作者对 X 执行 W 操作
-
R (投资人债权转让)操作是共享的,也就是说同时可以有多个执行者对 X 资源执行 R 操作
-
W 操作和 R 操作是互斥的,什么意思呢?也就是说 W 操作和 R 操作不能同时存在
6.3 解决过程
mysql 中同时对一笔记录发起 update 操作的时候,mysql 会确保整个操作会排队执行,内部是互斥锁实现的,从而可以确保在并发修改数据的时候,数据的正确性,执行 update 的时候,会返回被更新的行数,这里我们就利用 mysql 这个特性来实现读写锁的功能
①:创建读写锁表 t_read_write_lock
create table t_read_write_lock(resource_id varchar(50) primary key not null comment '互斥资源id',w_count int not null default 0 comment '目前执行中的W(借款人还款)操作数量' ,r_count int not null default 0 comment '目前执行中的R(投资人债权转让)操作数量',version bigint not null default 0 comment '版本号,每次执行update的时候+1'
);
这里主要关注 3 个字段:
-
resource_id
:互斥资源 id,比如上面的借款记录 id -
w_count
:当前执行 W(借款人还款)操作的数量 -
r_count
:当前执行 R (投资人债权转让)操作的数量
下面来看 W 操作和 R 操作的实现。
②:借款人还款操作:W
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count==0 && lock_record.r_count==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下{3.1、开启事务3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)3.3、提交事务;}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作
6、释放锁,过程如下{6.1、开启事务6.2、update t_read_write_lock set w_count=0 where w_count = 16.3、提交事务}
整个过程有个问题,不知道大家发现没有,如果执行到 5 之后,系统挂了,会出现什么情况?
业务执行完毕了,但是 w 锁却没有释放,这种后果就是本次操作一直持有锁,后续 W 和 R 操作就没法执行了。
我们来看看,业务执行过程中,系统挂掉,锁未释放如何处理和优化
?方案如下:
- 创建锁日志表:业务执行需要获取锁,除了需要修改
t_read_write_lock
表中的加锁数据外,还要额外增加一张表t_lock_log
去记录当前互斥资源id获取锁 - 业务执行结果 - 释放锁
的过程(并记录上时间),这样即使系统挂了导致了锁占用,我们也知道业务是在那一步挂掉了。 - 同步记录锁日志表数据:然后需要修改 借款人还款操作过程:W 的业务流程,
获取锁 - 业务执行结果 - 释放锁
的每一步过程都要在同一个事务中,同步更新t_lock_log
中的进度,保证和t_read_write_lock
的修改操作同时成功,或者失败 - 旁路定时任务清理锁占用:最后再增加一个定时任务去根据加锁时间是否超出阈值,来扫描
t_lock_log
中已过期未释放的锁,如果锁超过阈值未释放,则修改t_read_write_lock
和t_lock_log
中的状态。这样即使在任务在执行过程中崩溃了,旁路的定时任务也会定时扫描清理过期的锁,这样就避免了系统挂掉导致的锁一直占有!
1、 创建锁日志表:需要添加一下上锁日志表,每次上锁成功,则记录一条日志,表结构如下
create table t_lock_log(id bigint primary key auto_increment comment '主键,自动增长'resource_id varchar(50) primary key not null comment '互斥资源id',lock_type smallint default 0 comment '锁类型,0:W锁,1:R锁',status smallint default 0 comment '状态,0:获取锁成功,1:业务执行完毕,2:锁被释放',create_time bigint default 0 comment '记录创建时间',version bigint not null default 0 comment '版本号,每次执行update的时候+1'
);
2、同步记录锁日志表数据:接下看 借款人还款操作过程:W 改进如下
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count==0 && lock_record.r_count==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下{3.1、开启事务3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)3.3、如果count==1,则插入一条上锁日志,锁类型是0,状态是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},0,0,'当前时间');3.4、提交事务;}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作,业务操作过程如下{5.1、业务库开启事务5.2、执行业务5.3、更新锁日志记录的状态为1,条件中必须带上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)5.4、if(updateLogCount==1){5.5、提交事务}else{5.6、回滚事务【走到这里说明更新锁日志记录失败了,说明t_lock_log的status被其他地方改掉了,被防止死锁的job修改了】}}
6、释放锁,过程如下{6.1、开启事务6.2、释放锁:update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}6.3、更新锁日志状态为2:update t_lock_log set status=2 where id = #{日志记录id}6.4、提交事务}
3、旁路定时任务清理锁占用:此时我们需要一个 job,通过这个 job 来释放长时间还未释放的锁,比如过了 10 分钟,锁还未被释放的,job 的逻辑如下
1、获取10分钟之前锁未释放的锁日志列表:select * from t_lock_log where status in (0,1) and create_time+10分钟<=当前时
间的;
2、轮询获取的日志列表,释放锁,操作如下{2.1、开启事务2.2、if(t_lock_log.lock_type==0){//lock_type为0表示是W锁,下面准备释放W锁//先将日志状态更新为2,注意条件中带上version作为条件,这里使用到了乐观锁,可以确保并发修改时只有一个count的值为1int count = (update t_lock_log set status=2 where id = #{日志记录id} and version = #{日志记录.version})if(count==1){//将w_count置为0update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}}}else{//准备释放R锁//先将日志状态置为2int count = (update t_lock_log set status=2 where id = #{日志记录id} and version = #{日志记录.version})if(count==1){//将r_count置为r_count-1,注意条件中带上r_count - 1>=0update t_read_write_lock set r_count=r_count-1 where r_count - 1>=0 and resource_id = #{resource_id}}}2.3、提交事务
}
③:投资人债权转让操作:R
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count ==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下{3.1、开启事务3.2、int count = (update t_read_write_lock set r_count=r_count+1 where w_count = 0)3.3、如果count==1,则插入一条上锁日志,锁类型是1【表示R锁】,状态是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},1,0,'当前时间');3.4、提交事务;}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作,业务操作过程如下{5.1、业务库开启事务5.2、执行业务5.3、更新锁日志记录的状态为1,条件中必须带上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)5.4、if(updateLogCount==1){5.5、提交事务}else{5.6、回滚事务【走到这里说明更新锁日志记录失败了,说明t_lock_log的status被其他地方改掉了,被防止死锁的job修改了】}}
6、释放锁,过程如下{6.1、开启事务6.2、释放锁:update t_read_write_lock set r_count=r_count-1 where r_count - 1 >= 0 and resource_id = #{resource_id}6.3、更新锁日志状态为2:update t_lock_log set status=2 where id = #{日志记录id}6.4、提交事务}
④:总结
使用 mysql 来实现读写锁,如何防止死锁,重点就是 2 张表,锁表和日志表,2 个表配合一个 job,就把问题解决了。
大家可以将上面代码流程转换为程序,结合 spring 的 aop 可以实现一个通用的 db 读写锁