1 前言导入
在单机应用中,如果需要对一个共享变量进行多线程同步访问,那么使用我们之前所学的 Java 多线程知识即可。但是随着业务的发展,渐渐从单机演变成了集群,一个应用可能被部署到几台机器上,我们原来的那一套可就不灵了。究其原因,是因为在单机上,共享变量是唯一的,存储在单机的 JVM 内部的一块独立的内存空间。而在集群中,该共享变量可能存储在集群每台机器的 JVM 上。在高并发环境下,多个请求来到集群每台机器上,同时对这个共享变量进行操作,其结果自然是错误的。
有错误自然要修正,我们急需一种方法来解决这个问题。在单机且高并发条件下,为保证共享变量同一时间内只能被同一个线程执行,我们可以考虑使用 Lock 或者 Synchronized 实现互斥控制。在集群中,分布式系统的多线程分布在不同机器上,单机条件的并发控制锁策略失效,究其原因还是共享变量存储在集群每台机器的 JVM 上的缘故。因此,我们可以使用一种跨 JVM 的互斥机制来控制共享资源的访问,这就是我们所说的分布式锁。
2 我们到底需要怎样的分布式锁
- 排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取
- 避免死锁:这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)
- 高可用:获取或释放锁的机制必须高可用且性能佳
3 关于分布式锁的讨论
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。–CAP理论
在系统的设计上,鱼与熊掌不可兼得,由于分区容错性是必不可少的,我们被迫在一致性与可用性之间进行二选一。关于选择,我们一般选择放弃强一致性,而得到高可用性。在放弃强一致性之后,系统只需要保证最终一致性即可,也可满足大部分用户的需求。
据此,我们介绍三种分布式锁的实现方式,分别是:
- 基于数据库的实现方式
- 基于 Redis 的实现方式
- 基于 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;
存在的问题:
- 我们可以看到,这种锁没有失效时间,若释放锁失败锁记录就会一直保存在数据库,导致其余线程无法获取锁
- 可靠性依赖数据库,数据库挂了就 GG
- 实现的分布式锁是非阻塞的,插入数据失败之后会直接报错,想要获得锁还需要再次操作
- 实现的分布式锁是非可重入的,同一个线程在没有释放锁之前无法再次获得锁,因为数据库已经有一份记录了
解决问题:
- 关于问题一,我们可以使用定时任务来进行清理。
- 关于问题二,我们可以考虑设置备库,避免单点
- 关于问题三,我们可以实现一个循环,直至插入记录成功再返回
- 关于问题四,我们可以在数据库增加字段,记录获得锁的主机信息、线程信息等,下次获得锁时先查询数据,若可以查询到当前的主机信息和线程信息,直接将锁分配给它即可。
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
关于乐观锁实现的优点:
- 不依赖数据库本身的锁机制,不影响请求的性能
- 并发量较少时,只有少部分请求会失败
关于乐观锁实现的缺点:
- 对表的设计增加额外的字段,增加了数据库的冗余
- 并发量较大时,version 的值不断变化,大量请求失败
- 在秒杀情况下,大量请求同时请求同一条记录的行锁,给数据库带来压力
综上所述,乐观锁适合并发量不高且写操作不频繁的场景。
4.3 数据库中使用悲观锁实现分布式锁
悲观锁,即是悲观的,总是假设数据的更新在大多数情况下会有冲突的情况发生。关于使用悲观锁实现分布式锁,可以借助数据库中自带的锁,在查询语句增加 FOR UPDATE 字段,会给数据库表增加排他锁,即悲观锁。在记录被加上悲观锁后,其他请求无法获得该记录的悲观锁。
使用悲观锁的注意事项:
- 注意锁的级别,不要加上表锁。MySQL InnoDB 在加锁时只有明确指定主键或索引才会执行行锁,否则会直接加上表锁。
- 需要关闭 MySQL 的自动提交,否则 MySQL 会在你执行完一个更新之后将其直接提交,我们需要在 FOR UPDATE 获得锁之后执行业务逻辑再通过 COMMIT 释放锁。
悲观锁的优点:每一次对记录的访问都是独占的,可以严格保证数据的安全
悲观锁的缺点:
- 每次请求都会额外产生加锁的开销
- 未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞
- 使用不当会产生死锁
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 实现分布式锁步骤如下:
- 创建一个目录 mylock
- 想获取锁的线程在 mylock 下创建临时顺序节点,同时获取 mylock 目录下所有的子节点,获取比自己小的兄弟节点,若不存在则获得锁,否则监听排序仅比自己靠前的那个节点
- 线程处理完会删除自己的节点,排序仅比它靠后的那个节点监听变更事件,判断自己是否为最小的节点,如果是则获得锁
使用 Zookeeper 使用分布式锁的优点:
- 高可用
- 可重入
- 具有阻塞锁特性
- 可解决失效死锁问题
使用 Zookeeper 使用分布式锁的缺点:需要频繁创建和删除节点,性能不如 Redis 方式。
7 对三种实现方式的比较
- 理解难易程度(从低到高):数据库 > Redis > Zookeeper
- 实现的复杂性(从低到高):Zookeeper >= Redis > 数据库
- 性能(从高到低):Redis > Zookeeper >= 数据库
- 可靠性(从高到低):Zookeeper > Redis > 数据库