逐一执行、唯一执行。这是分布式锁使用的大致场景。
逐一执行
逐一执行顾名思义,就是多台服务器同一时间只有一台服务器执行,其他服务器等待,当执行服务器执行完成后其他服务器会继续抢占执行。
唯一执行
唯一执行类似于leader的概念,多台服务器同一时间只有一台执行,其他服务器放弃执行。

基于记录
适用于唯一执行场景
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们想要获得锁的时候,就可以在该表中增加一条记录,想要释放锁的时候就删除这条记录。
为了更好的演示,我们先创建一张数据库表,参考如下:
CREATE TABLE `database_lock` (`id` BIGINT NOT NULL AUTO_INCREMENT,`resource` int NOT NULL COMMENT '锁定的资源',`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',`updatetime` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),UNIQUE KEY `uiq_idx_resource` (`resource`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
其中update_time是用来判断加锁时间,用于后续定时任务解决使用!
当我们想要获得锁时,可以插入一条数据:
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');
注意:在表database_lock中,resource字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功(其它的会报错:ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’),那么那么我们就可以认为操作成功的那个请求获得了锁。
当需要释放锁的时,可以删除这条数据:
DELETE FROM database_lock WHERE resource=1;
这种实现方式非常的简单,但是需要注意以下几点:


乐观锁
适用于唯一执行场景

为了更好的理解数据库乐观锁在实际项目中的使用,这里就列举一个典型的电商库存的例子。一个电商平台都会存在商品的库存,当用户进行购买的时候就会对库存进行操作(库存减1代表已经卖出了一件)。我们将这个库存模型用下面的一张表optimistic_lock来表述,参考如下:
CREATE TABLE `optimistic_lock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resource` int NOT NULL COMMENT '锁定的资源', `version` int NOT NULL COMMENT '版本信息', `created_at` datetime COMMENT '创建时间', `updated_at` datetime COMMENT '更新时间', `deleted_at` datetime COMMENT '删除时间', PRIMARY KEY (`id`), UNIQUE KEY `uiq_idx_resource` (`resource`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
其中:id表示主键;resource表示具体操作的资源,在这里也就是特指库存;version表示版本号。
在使用乐观锁之前要确保表中有相应的数据,比如:

如果只是一个线程进行操作,数据库本身就能保证操作的正确性。主要步骤如下:
STEP1 - 获取资源:SELECT resource FROM optimistic_lock WHERE id = 1
STEP2 - 执行业务逻辑
STEP3 - 更新资源:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1

然而在并发的情况下就会产生一些意想不到的问题:比如两个线程同时购买一件商品,在数据库层面实际操作应该是库存(resource)减2,但是由于是高并发的情况,第一个线程执行之后(执行了STEP1、STEP2但是还没有完成STEP3),第二个线程在购买相同的商品(执行STEP1),此时查询出的库存并没有完成减1的动作,那么最终会导致2个线程购买的商品却出现库存只减1的情况。
在引入了version字段之后,那么具体的操作就会演变成下面的内容:
STEP1 - 获取资源:SELECT resource, version FROM optimistic_lock WHERE id = 1
STEP2 - 执行业务逻辑
STEP3 - 更新资源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion

其实,借助更新时间戳(updated_at)也可以实现乐观锁,和采用version字段的方式相似:更新操作执行前线获取记录当前的更新时间,在提交更新时,检测当前更新时间是否与更新开始时获取的更新时间戳相等。

缺点:不适合大量请求,会涉及大量事务回滚从而导致性能瓶颈
悲观锁
除了可以通过增删操作数据库表中的记录以外,我们还可以借助数据库中自带的锁来实现分布式锁。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。

这样在使用FOR UPDATE获得锁之后可以执行相应的业务逻辑,执行完之后再使用COMMIT来释放锁。
我们不妨沿用前面的database_lock表来具体表述一下用法。假设有一线程A需要获得锁并执行相应的操作,那么它的具体步骤如下:
STEP1 - 获取锁:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。 STEP2 - 执行业务逻辑。 STEP3 - 释放锁:COMMIT。

如果另一个线程B在线程A释放锁之前执行STEP1,那么它会被阻塞,直至线程A释放锁之后才能继续。注意,如果线程A长时间未释放锁,那么线程B会报错,参考如下(lock wait time可以通过innodb_lock_wait_timeout来进行配置):

上面的示例中演示了指定主键并且能查询到数据的过程(触发行锁),如果查不到数据那么也就无从“锁”起了。
如果未指定主键(或者索引)且能查询到数据,那么就会触发表锁,比如STEP1改为执行(这里的version只是当做一个普通的字段来使用,与上面的乐观锁无关):

或者主键不明确也会触发表锁,又比如STEP1改为执行:


缺点也很明显,要考虑上锁超时所导致的报错问题。

最近在更新过关斩将系列——java面试题,全力帮助小白突破面试大关,目前已经更新至第44篇感兴趣可以关注公众号IT枫斗者追更!
往期分享:




