分布式锁的实现方式

1 前言导入

在单机应用中,如果需要对一个共享变量进行多线程同步访问,那么使用我们之前所学的 Java 多线程知识即可。但是随着业务的发展,渐渐从单机演变成了集群,一个应用可能被部署到几台机器上,我们原来的那一套可就不灵了。究其原因,是因为在单机上,共享变量是唯一的,存储在单机的 JVM 内部的一块独立的内存空间。而在集群中,该共享变量可能存储在集群每台机器的 JVM 上。在高并发环境下,多个请求来到集群每台机器上,同时对这个共享变量进行操作,其结果自然是错误的。

有错误自然要修正,我们急需一种方法来解决这个问题。在单机且高并发条件下,为保证共享变量同一时间内只能被同一个线程执行,我们可以考虑使用 Lock 或者 Synchronized 实现互斥控制。在集群中,分布式系统的多线程分布在不同机器上,单机条件的并发控制锁策略失效,究其原因还是共享变量存储在集群每台机器的 JVM 上的缘故。因此,我们可以使用一种跨 JVM 的互斥机制来控制共享资源的访问,这就是我们所说的分布式锁。

2 我们到底需要怎样的分布式锁

  1. 排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取
  2. 避免死锁:这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)
  3. 高可用:获取或释放锁的机制必须高可用且性能佳

3 关于分布式锁的讨论

任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。–CAP理论

在系统的设计上,鱼与熊掌不可兼得,由于分区容错性是必不可少的,我们被迫在一致性与可用性之间进行二选一。关于选择,我们一般选择放弃强一致性,而得到高可用性。在放弃强一致性之后,系统只需要保证最终一致性即可,也可满足大部分用户的需求。

据此,我们介绍三种分布式锁的实现方式,分别是:

  1. 基于数据库的实现方式
  2. 基于 Redis 的实现方式
  3. 基于 Zookeeper 的实现方式

4 基于数据库的实现方式

如果基于数据库使用分布式锁,有基于表记录,乐观锁,悲观锁三种实现方式。

4.1 数据库中使用基于表记录实现分布式锁

基于这种方式实现分布式锁,我们可以直接创建一张锁表,并通过操作锁表中的记录来实现分布式锁。需要获得锁时,在表中增加一条记录,需要释放锁时,将这条记录删除即可。

我们先创建一张表

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

需要获得锁时,在表中增加一条记录

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

我们注意,resource 是具有唯一性约束的,当多个请求同时提交到数据库,数据库可以保证只有一个请求可以操作成功,这样,我们可视为插入成功的那个请求获取了分布式锁。

需要释放锁时,将该记录删除即可。

DELETE FROM database_lock WHERE resource=1;

存在的问题:

  1. 我们可以看到,这种锁没有失效时间,若释放锁失败锁记录就会一直保存在数据库,导致其余线程无法获取锁
  2. 可靠性依赖数据库,数据库挂了就 GG
  3. 实现的分布式锁是非阻塞的,插入数据失败之后会直接报错,想要获得锁还需要再次操作
  4. 实现的分布式锁是非可重入的,同一个线程在没有释放锁之前无法再次获得锁,因为数据库已经有一份记录了

解决问题:

  1. 关于问题一,我们可以使用定时任务来进行清理。
  2. 关于问题二,我们可以考虑设置备库,避免单点
  3. 关于问题三,我们可以实现一个循环,直至插入记录成功再返回
  4. 关于问题四,我们可以在数据库增加字段,记录获得锁的主机信息、线程信息等,下次获得锁时先查询数据,若可以查询到当前的主机信息和线程信息,直接将锁分配给它即可。
4.2 数据库中使用乐观锁实现分布式锁

乐观锁,顾名思义,即系统认为数据的更新在大部分时间内不会产生冲突,只有在数据库更新提交时才对数据作冲突检测。若检测的结果出现了与预期不一致的情况,则返回失败信息。

主流的乐观锁是基于数据版本 (version) 的记录机制实现的,数据版本即是版本标识,一般在表中增加一个 version 的字段。在读取数据时,会将版本号一起读出,更新结束后会将版本号加一。在更新过程中会对版本号进行比较,若相同则执行成功,否则本次操作失败。

我们建一张表

CREATE TABLE `optimistic_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`version` int NOT NULL COMMENT '版本信息',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

使用乐观锁之前,我们先保证表中有相应的数据

INSERT INTO optimistic_lock(resource, version) VALUES(20, 1);

我们获取锁的操作如下:

1 获取资源以及版本号

SELECT resource, version FROM optimistic_lock WHERE id = 1

2 执行业务逻辑

3 更新资源,版本号

UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion

关于乐观锁实现的优点:

  1. 不依赖数据库本身的锁机制,不影响请求的性能
  2. 并发量较少时,只有少部分请求会失败

关于乐观锁实现的缺点:

  1. 对表的设计增加额外的字段,增加了数据库的冗余
  2. 并发量较大时,version 的值不断变化,大量请求失败
  3. 在秒杀情况下,大量请求同时请求同一条记录的行锁,给数据库带来压力

综上所述,乐观锁适合并发量不高且写操作不频繁的场景。

4.3 数据库中使用悲观锁实现分布式锁

悲观锁,即是悲观的,总是假设数据的更新在大多数情况下会有冲突的情况发生。关于使用悲观锁实现分布式锁,可以借助数据库中自带的锁,在查询语句增加 FOR UPDATE 字段,会给数据库表增加排他锁,即悲观锁。在记录被加上悲观锁后,其他请求无法获得该记录的悲观锁。

使用悲观锁的注意事项:

  1. 注意锁的级别,不要加上表锁。MySQL InnoDB 在加锁时只有明确指定主键或索引才会执行行锁,否则会直接加上表锁。
  2. 需要关闭 MySQL 的自动提交,否则 MySQL 会在你执行完一个更新之后将其直接提交,我们需要在 FOR UPDATE 获得锁之后执行业务逻辑再通过 COMMIT 释放锁。

悲观锁的优点:每一次对记录的访问都是独占的,可以严格保证数据的安全

悲观锁的缺点:

  1. 每次请求都会额外产生加锁的开销
  2. 未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞
  3. 使用不当会产生死锁

5 基于 Redis 的实现方式

由于 Redis 的高性能,我们也可以使用 Redis 来实现分布式锁。其经常用到的命令如下:

1 SETNX:key 不存在时 set 一个 key 为 val 的字符串并返回1,否则直接返回0。

SETNX key val

2 expire:给 key 设置一个超时时间,单位为秒,超过这个时间锁会自动释放,避免死锁。

expire key timeout

3 delete:删除一个 key

delete key

在我们获取锁的时候,使用 setnx 加锁,并通过 expire 为锁添加一个超时时间,超时之后会自动释放锁,锁的 value 为一个随机生成的 UUID。获取锁时还需要设置一个获取的超时时间,若超过这个时间则放弃获取锁。释放锁时,通过 UUID 判断是不是该锁,若是该锁,则执行 delete 进行锁释放。

使用 Redis 使用分布式锁的优点:高性能

使用 Redis 使用分布式锁的缺点:实现较为复杂,需要考虑超时,原子性,误删等问题。

6 基于 Zookeeper 的实现方式

ZooKeeper 用于为分布式应用提供一致性服务,内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。

Zookeeper 的节点分为四种,分别是持久节点,持久顺序节点,临时节点,临时顺序节点。

使用 Zookeeper 实现分布式锁步骤如下:

  1. 创建一个目录 mylock
  2. 想获取锁的线程在 mylock 下创建临时顺序节点,同时获取 mylock 目录下所有的子节点,获取比自己小的兄弟节点,若不存在则获得锁,否则监听排序仅比自己靠前的那个节点
  3. 线程处理完会删除自己的节点,排序仅比它靠后的那个节点监听变更事件,判断自己是否为最小的节点,如果是则获得锁

使用 Zookeeper 使用分布式锁的优点:

  1. 高可用
  2. 可重入
  3. 具有阻塞锁特性
  4. 可解决失效死锁问题

使用 Zookeeper 使用分布式锁的缺点:需要频繁创建和删除节点,性能不如 Redis 方式。

7 对三种实现方式的比较

  1. 理解难易程度(从低到高):数据库 > Redis > Zookeeper
  2. 实现的复杂性(从低到高):Zookeeper >= Redis > 数据库
  3. 性能(从高到低):Redis > Zookeeper >= 数据库
  4. 可靠性(从高到低):Zookeeper > Redis > 数据库

参考:基于数据库实现的分布式锁
三种实现分布式锁的方式
分布式锁简单入门以及三种实现方式介绍

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值