Skip to content

分布式锁的概述

各类型的锁

  • 线程锁:主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一 JVM 中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如 synchronized是 共享对象头,显示锁 Lock 是共享某个变量(state)。
  • 进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过 synchronized 等线程锁实现进程锁。
  • 分布式锁:当多个进程不在同一个系统中(比如分布式系统中控制共享资源访问),用分布式锁控制多个进程对资源的访问。

分布式锁的设计原则

Redis 的官网上对使用分布式锁提出至少需要满足如下三个要求:

  1. 互斥(属于安全性):在任何给定时刻,只有一个客户端可以持有锁。
  2. 无死锁(属于有效性):即使锁定资源的客户端崩溃或被分区,也总是可以获得锁;通常通过超时机制实现。
  3. 容错性(属于有效性):只要大多数 Redis 节点都启动,客户端就可以获取和释放锁。

在分布式锁的设计中还需要考虑:

  1. 加锁解锁的同源性:A 加的锁,不能被 B 解锁。
  2. 获取锁是非阻塞的:如果获取不到锁,不能无限期等待。
  3. 高性能:加锁解锁是高性能的。

参考 Redis 官网的『Distributed Locks with Redis』 文章

分布式锁的常见实现方案

  • 基于数据库实现分布式锁:
    • 基于数据库表(锁表,很少使用)
    • 乐观锁(基于版本号)
    • 悲观锁(基于排它锁)
  • 基于 redis 实现分布式锁:
    • 单个 Redis 实例:setnx(key, 当前时间+过期时间) + Lua
    • Redis 集群模式:Redlock
  • 基于 zookeeper 实现分布式锁
    • 临时有序节点来实现的分布式锁,例如:Curator
  • 基于 Consul 实现分布式锁

基于数据库实现分布式锁

基于表锁(很少使用)

最简单的方式可能就是直接创建一张锁表,利用数据库的表锁来实现分布式锁。例如:

sql
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 = '数据库分布式锁表';

当想要获得锁时,可以插入一条数据:

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

当需要释放锁的时,可以删除这条数据:

sql
DELETE FROM database_lock WHERE resource = 1;

基于悲观锁

悲观锁实现思路

  1. 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
  2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。(具体响应方式由开发者根据实际需要决定。)
  3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  4. 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

以 MySQL InnoDB 悲观锁为例

要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性,因为 MySQL 默认使用 autocommit 模式,当执行一个更新操作后,MySQL 会立刻将结果进行提交。输出命令:

sql
set autocommit = 0;

操作流程示例:

sql
-- 0.开始事务
begin;/begin work;/start transaction; (三者选一即可)
-- 1.查询出商品信息
SELECT STATUS FROM t_goods WHERE id = 1 FOR UPDATE;
-- 2.根据商品信息生成订单
INSERT INTO t_orders ( id, goods_id ) VALUES ( NULL, 1 );
-- 3.修改商品status为2
UPDATE t_goods SET STATUS = 2;
-- 4.提交事务
commit;/commit work;

上面的查询语句中,使用了 select ... for update 的方式,这样就通过开启排他锁的方式实现了悲观锁。此时在 t_goods 表中,id 为 1 的数据就被锁定,其它的事务必须等本次事务提交之后才能执行。这样就可以保证当前的数据不会被其它事务修改。但需要注意锁的级别,MySQL InnoDB 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,select ... for update 会使用表级锁把整张表锁住。

基于乐观锁

乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取的数据,是否被有其他事务修改了。如果其他事务有更新的话,正在提交的事务会进行回滚。

以使用版本号实现乐观锁为例:使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行 +1 操作,并判断当前版本号是不是该数据的最新的版本号。

sql
-- 1.查询出商品信息
select (status,status,version) from t_goods where id = #{id};
-- 2.根据商品信息生成订单
-- 3.修改商品status为2
update t_goods set status = 2, version = version+1 where id= #{id} and version = #{version};

需要注意的是,乐观锁机制往往基于系统中数据存储逻辑,因此也具备一定的局限性。由于乐观锁机制是在自身系统中实现的,对于来自外部系统的用户数据更新操作不受自身系统的控制,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,应该充分考虑到这些情况,并进行相应的调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开)。

这种方案的缺陷是,对数据库依赖、开销、行锁变表锁、无法解决数据库单点和可重入等问题。

基于 Redis 实现分布式锁

此部分内容详见《Redis 场景应用设计》笔记的相关章节

基于 zookeeper 实现分布式锁

此部分内容详见《Zookeeper 应用场景》笔记的相关章节

I know where dry desert ends, green grass grows · MooN