本文主要介绍项目中怎么使用 MySQL 实现分布式锁的
背景
假如我们现在要做一个高性能、可扩展的分布式任务调度框架
,要怎么设计呢?下面是我之前自己设计的一个架构图。
为了方便后续的分布式锁的设计,我们大致描述下各个角色都做了哪些事情(这不是本篇文章的重点)
scheduler-client
做为一个 sdk,植入到各业务方系统,功能如下:
- 定期注册心跳信息
- 暴露 http 服务端口,监听 handler 请求
scheduler-web
与前端页面交互,保存任务调度信息
scheduler-trigger
该节点包含两个角色:master
和 slave
- master:读任务表,分配任务
- slave:按策略执行任务,调用 scheduler-client 的接口
分布式锁
从图中可以看到,scheduler-trigger
分为了两个角色master
和 slave
,那么角色是怎么确定的呢?就是通过去抢锁,谁抢到锁,谁就是 master。
所以,重点就在于该怎么设计抢锁这个动作?
锁表设计
CREATE TABLE ` lock ` (
` id ` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
` namespace ` varchar(128) NOT NULL DEFAULT '' COMMENT '锁名',
` owner ` varchar(128) DEFAULT NULL COMMENT '资源所有者(ip地址)',
` version ` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
` create_time ` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
` update_time ` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (` id `) USING BTREE,
KEY ` idx_namespace ` (` namespace `) USING BTREE
) ENGINE = InnoDB COMMENT = '锁信息表'
这就是基于 MySQL 的分布式锁表设计,其中namespace
代表锁的名称,owner
代表该锁被哪个 ip 节点所持有。
初始化锁
在系统启动的时候,我们需要去监听启动事件,然后去初始化锁信息。
@Component
public class LockerHandler implements ApplicationListener<ApplicationEvent> {
@Override
public void onApplicationEvent(ApplicationEvent event) {
try {
if (event instanceof ApplicationStartedEvent) {
//初始化锁
}
} catch (Exception e) {}
}
}
初始化锁的逻辑就是insert
一条锁信息,只不过此时锁还未被任何 owner
所持有。
INSERT INTO lock(`namespace`,`version`,create_time, update_time) VALUE ('master',1,NOW(),NOW())
抢锁&锁续约
在系统启动几秒后,有一个定时任务,每秒钟执行一次。
@Scheduled(initialDelay = 5000,fixedDelay = 1000)
public void lock() {
try {
LockTypeEnum lt = lock("master", InetTool.LOCAL_IP, 3);
/**
这里面是抢锁之后的业务逻辑,暂且不表
**/
return;
} catch (Exception e) {
}
}
@Override
public LockTypeEnum lock(String namespace, String owner, int ttl) {
//查询锁
LockDO lock = this.lockDao.get(namespace);
//如果锁过期或者无人持有锁,则触发DB抢锁
if (lock.isExpired(ttl)) {
return this.lockDao.lock(namespace, owner, lock.getVersion()) ? LockTypeEnum.PROMOTED : LockTypeEnum.UNKNOWN;
}
//如果锁未过期,且持有者是自己,则续约
if (owner.equals(lock.getOwner())) {
return this.lockDao.keepAlive(namespace, owner, ttl) ? LockTypeEnum.STAY : LockTypeEnum.LEAVE;
}
return LockTypeEnum.UNKNOWN;
}
下面来详细解剖下里面的代码逻辑
查询锁
通过namespace
去查询锁信息
SELECT *,
(UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(update_time)) AS remaining
FROM lock
WHERE namespace = #{namespace}
LIMIT 1
remaining
代表当前时间与上一次keepAlive更新时间的差值,可用来判断锁是否过期。
判断触发抢锁的条件
//触发抢锁的条件 1.锁过期 2.无人持有锁
public boolean isExpired(int ttl) {
return remaining - ttl > 0 || owner == null;
}
remaining > ttl
代表已经超过 3秒
没有更新了,满足该条件或owner 为空
,会触发抢锁。
抢锁
UPDATE lock
SET
update_time = NOW(),
owner = #{owner},
`version` = `version` + 1
WHERE `namespace` = #{namespace}
AND `version` = #{version}
抢锁涉及到并发操作,所以采用了版本号的方式来保证安全性
锁续约
如果锁未过期且持有者是自己,触发锁续约
UPDATE lock
SET
update_time = NOW()
WHERE namespace = #{namespace}
AND owner = #{owner}
AND update_time >= DATE_SUB(NOW(),INTERVAL 3 SECOND)
此处的where判断条件也再次校验了,update_time >= 当前时间 - 3秒
,代表锁还没有过期。
总结
以上就是基于 MySQL 实现锁机制的流程,至于抢到锁后的 master 和 slaver 怎么去执行业务逻辑,那又是另一件事情了,后面再单独说。