性能文章>【译】如何使用MySQL来设计分布式锁?>

【译】如何使用MySQL来设计分布式锁?转载

3周前
317936

为什么我们需要分布式锁?

我们中的许多人可能遇到或听说过这些问题:

  1. 重试导致的数据损坏:从程序UI提交数据,该程序用户界面在你看不见的地方将数据插入后端MySQL数据库。偶尔用户界面没有响应,因此用户单击了几次提交按钮,这损坏了数据库中的数据。
  2. 缓存过期:使用Redis作为读透缓存,缓存中的密钥都同时过期。然后,所有流量都击中底层MySQL,该MySQL无法处理如此高的负载,并返回超时错误。
  3. 幽灵库存:假设我们是电子商务卖家,我们的库存中总共有4部iPhone。爱丽丝提交了购买3部iPhone的订单,鲍勃也订购了2部iPhone。理论上,只有第一个订单应该成功,第二个订单应该因为库存不足而失败。然而,实际上,两个用户都可能看到一个用户界面,显示大约同时还剩下4部iPhone并提交了订单,但实际上,第二个订单成功了,而第一个订单失败,或者更糟糕的是,两个订单都成功了,订购了5部iPhone!

所有这些问题都有一个共同的特点:多个用户试图在同一时间读取/更新同一资源。如果我们所有的服务和数据库都在一台大型整体超级计算机上运行,我们可以使用通常与编程语言一起附带的锁或互斥来序列化任何并发读/写操作。例如,Java中的synchronized关键字和ReentrantLock,或在Go中sync包。然而,分布式系统通常在数千台机器上运行数十个微服务,我们需要协调许多机器的读/写。这就是分布式锁的用途。

分布式锁的设计目标

锁定实体的存储

单台机器上的静音或锁定通常带有编程语言本身或广泛使用的库。在自然界中,它们在内存中作为整数实现。例如,Go中的MutexRWMutex使用不同的整数来指示不同的锁状态。由于分布式系统不共享内存,我们需要将此表示锁定状态的整数存储在存储中间件中。

分布式锁常用的存储中间件是:

  • MySQL
  • Redis
  • ZooKeeper
  • ETCD

你可以看到分布式锁没有魔力,因为它们也使用相同的熟悉数据存储系统,如MySQL、Redis等。

现在,让我们深入了解设计细节,特别是如何在这些不同的存储系统之上构建分布式锁,以及权衡和区别是什么。

功能要求

  1. 相互排斥。必要时,不同机器上许多进程/线程中只有一个可以访问特定资源,其他进程/线程应该等到锁被释放并可用。
  2. TTL或租赁机制。提供分布式锁和其他请求分布式锁定的服务位于许多不同的机器上。这些服务通过网络进行通信。从CAP理论来看,我们知道网络总是不可靠,任何服务器都可能停机一段时间。因此,当我们设计分布式锁定服务时,我们需要考虑持有锁的客户端可能关闭并且无法释放锁的可能性,从而阻止了等待获得相同锁的所有其他客户端。因此,我们需要一个“上帝”机制,在这种情况下可以自动释放锁,以解除对其他客户端的封锁。
  3. 锁定服务的API:
  • 锁定:获取锁定
  • 解锁:松开锁
  • TryLock(可选)。例如,更高级的API:客户端可以指定获取锁的最大等待时间。如果无法在窗口内获得锁定,请返回时出现错误,而不是继续等待。

   4.   高性能。 

  • 低延迟:在正常情况下,锁定和解锁应该会很快。例如,假设实际的业务逻辑只需要1ms来处理,但只需在处理每个请求时简单地获取和释放锁,只需再获得100ms,那么最大QPS只能达到10,这对于当今标准中的许多服务来说都非常低。在这种情况下,服务器可以处理的最大QPS受到锁定性能的限制。
  • 通知机制:分布式锁最好提供通知机制。如果服务器进程A由于被另一个服务器进程B持有而无法获得锁,那么A不应该继续等待并占用CPU。相反,A应该闲置,以避免浪费CPU周期。然后,当锁可用时,锁服务会通知A,A将加载到CPU并恢复运行。
  • 避免雷鸣般的牛群。假设有100个进程想要获得相同的锁。当锁可用时,理想情况下,只应通知排队的“下一个”进程,而不是突然调用所有100个进程来竞争锁,结果发现100个进程中有1个可以获得锁并继续,而其他99个需要回到他们之前正在做的事情。

   5.  公平。

先到先得。无论谁等了最久,接下来都应该拿到锁。如果是这样,锁被视为公平的锁。否则,这是一把不公平的锁。这两种类型的锁实际上都在现实中使用(嗯,生活不公平🤔)

   6.重新进入锁。

想象一下,一个节点或服务器进程获得了锁,开始处理业务逻辑,然后遇到了一个代码片段,要求再次获得相同的锁!在这种情况下,节点或进程不应该死锁,相反,它应该能够再次获得相同的锁,因为它已经持有锁。

使用MySQL的分布式锁

在实际生产环境中,MySQL通常在RC(已读提交)隔离级别配置。因此,我们随后的讨论将侧重于RC,而不是RR(可重复阅读)。

实施 1. 使用唯一的密钥约束

MySQL允许创建对键或索引具有唯一密钥约束的表。我们可以使用此内置的唯一性约束来实现分布式锁。假设我们在MySQL中创建了一个名为lock的表,那么分布式锁的代码路径应该是:

  • 客户端A正试图获得锁。目前没有其他客户端持有锁,因此客户端A成功获得了锁,并将一行插入MySQLlock表中。
  • 现在客户端B想要获得相同的锁。它首先查询数据库,并发现客户端A插入的行已经存在。在这种情况下,客户端B无法获取锁,并将返回错误。然后,客户端B将等待一段时间并重试。通常,我们将在这里使用带有TTL的重试循环。客户端B将在指定的TTL窗口内继续重试几次,最终要么在客户端A释放锁后成功获取锁,要么因TTL而失败。
  • 一旦客户端A完成任务,它只需删除DBlock表中的行即可释放锁。现在,其他客户可以获得锁。

以下是SQL片段示例,用于在lock_key列上创建一个名为lock的具有唯一密钥约束的表:

CREATE TABLE `lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(256) NOT NULL,
`holder` varchar(256) NOT NULL,
`creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);

在上面的SQL中,

  • lock_key是锁的唯一名称。我们可以在这里使用任何字符串。例如,我们可以使用串联字符串project_name + resource_id作为锁的名称。命名应该一致,并给出足够的粒度。
  • 持有人:当前持有锁的客户端的ID。例如,我们可以使用串联字符串service_name + IP地址+ thread_id来识别分布式环境中的客户端。

Once the table with unique key constraint on lock_key is defined, acquire & release lock are merely two additional SQL snippets:

Aquire锁:

INSERT INTO `lock`(`lock_key`, `holder`) VALUES ('project1_uid1',
 'server1_ip1_tid1');

释放锁:

DELETE FROM `lock` WHERE `lock_key` = 'project1_uid1';

这种简单的实现满足了基本的功能要求。现在,让我们考虑几种故障模式,看看它是否对分布式系统中的常见故障具有鲁棒性。

如果客户端A获取锁,将一行插入数据库,但后来客户端A崩溃,或者网络分区和客户端A无法到达数据库,该怎么办?在这种情况下,该行将保留在数据库中,并且不会被删除。换句话说,对其他客户端来说,就好像客户端A仍然持有锁(即使A已经崩溃了!)。其他客户端将无法获得锁,并将返回时出错。

常用的方法是为每个锁分配一个TTL。这个想法很简单:如果客户端A崩溃并且无法释放锁,那么其他人应该做这项工作,删除DB中的行,从而释放锁。假设客户端A通常需要3分钟才能完成任务。我们可以将TTL设置为5分钟。然后,我们需要构建另一个服务来不断扫描lock表,并删除超过5分钟前创建的任何行。然而,还有其他问题:

  • 如果A没有崩溃,它只需要比平时更长的时间来完成任务呢?
  • 如果我们为扫描lock桌本身而构建的新服务崩溃了怎么办?

第一个问题将很难用MySQL完全解决。然而,实际上,对于大多数业务案例,我们总是可以设置足够大的TTL,因此这种情况很少发生,以至于对公司业务的任何影响几乎不明显。或者我们可以使用ZooKeeper,它附带了另一组权衡,稍后将讨论。

现在,让我们解决实施2中的第二个问题。

实现2.使用时间戳+唯一密钥约束

我们可以在lock表中添加一列,以存储上次获取锁的时间戳。通过这种方式,我们可以继续使用MySQL的内置功能,而无需构建其他服务。

CREATE TABLE `lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(128) NOT NULL,
`holder` varchar(128) NOT NULL DEFAULT '',
`creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_lock_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);

首先,我们需要确定一个合适的TTL ${timeout},如前所述。TTL应该足够大。

Aquire锁:

当客户端B尝试获取锁时,我们可以添加`last_lock_time` < ${now} — ${timeout}作为过滤器。

UPDATE `lock` SET `holder` = 'server1_ip1_tid1', 
`last_lock_time` = ${now} WHERE `lock_key` = 'project1_uid1' and `last_lock_time` < ${now} - ${timeout};

在这种情况下,只有当`last_lock_time` < ${now} — ${timeout},客户端B才能获取锁,将持有人更改为其ID,并将last_lock_time重置为当前时间戳。假设后来的客户端B崩溃,无法释放锁,在等待${timeout}后,其他客户端将能够获得锁。

释放锁:

我们可以定义一个min时间戳,例如‘1970–01–01 00:00:01.000000’

UPDATE `lock` SET `holder` = '', `last_lock_time` = ${min_timestamp} WHERE `lock_key` = 'project1_uid1' and `holder` = 'server1_ip1_tid1';

WHERE语句中,除了lock_key我们还添加了`holder` = ‘server1_ip1_tid1’这是为了避免其他客户端意外释放当前客户端持有的锁。

成功释放锁后,holder设置为空,last_lock_time设置为最小虚拟时间戳,以便其他客户端可以轻松获取锁。

总之,基于MySQL的实现实现了以下设计目标:

  • 相互排斥
  • 避免死锁的TTL机制
  • 用于锁定和解锁的API界面。API的逻辑备份使用SQL实现。如果您对如何用Go等编程语言包装SQL感兴趣,请使用sqlc阅读我在Go ORM上的帖子。

如何实现我们功能要求中的项目4、5和6?在上面的实现中,如果持有锁,其他客户端需要循环重试,等待锁释放,然后获取锁。如果分布式锁定服务能够通知等待的客户端锁定可用,那就更好了。让我们在实施3中解决这些问题。

实现3.使用FOR UPDATE实现锁定释放通知

MySQL本身(也包括PostgresSQL)为每个表提供行级锁。在RC隔离级别下,当我们使用FOR UPDATE时,MySQL将为与过滤器条件匹配的所有行添加行级锁。MySQL中行级锁的实现支持功能要求5和6。当客户端会话获得锁时,所有其他客户端都将等待锁。此外,等待客户端唤醒并获得锁的顺序与他们首次尝试获取锁时相同。此外,只要持有锁的客户端在SQL事务中执行逻辑,FOR UPDATE就可以多次执行。换句话说,锁是再入锁。

此外,MySQL支持另外两个选项来指定FOR UPDATE的行为:NOWAIT和SKIP LOCKED。这两个选项的设计行为是:

  • NOWAIT:不要等待锁的释放。如果锁由另一个客户端持有,因此无法获得,请立即返回锁冲突消息。
  • 跳过锁定:读取数据时,跳过其他客户端持有行级锁定的行。

通过这两个选项,我们可以实现TryLock行为,即客户端尝试获取锁,并在无法获得锁时立即返回,而不是等待。

我们可以简化lock表,只包含两个字段:

CREATE TABLE `lock` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(128) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);

获取锁:

BEGIN;
SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE;
 

关于开始新交易的BEGIN的快速说明:只有在首次获取锁时才需要它。对于后续的重新进入,不要执行BEGIN,否则新事务将开始,现有事务将结束,从而在事务结束时实际释放锁。

非阻塞尝试锁定:

BEGIN;
SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE NOWAIT;

释放锁:

COMMIT;
 

实施3实现了所有设计目标:

  1. 相互排斥
  2. TTL机制:MySQL原生管理客户端会话。如果客户端因机器故障或网络故障而断开连接,MySQL将自动释放行级锁。
  3. 支持所有3个API:获取/尝试/释放锁定。
  4. 高性能:当锁被释放时,MySQL将只通知队列中等待的下一个客户端,而不是一次性通知所有客户端,从而避免雷鸣般的羊群问题。
  5. 公平。MySQL行锁原生支持。
  6. 重新进入。MySQL行锁也原生支持。请记住在首次获取锁时开始交易,并且不要在随后的重新进入中开始新的事务。

这种方法的权衡是,我们需要先将代表所有可能锁的行插入到lock表中,只有获取/尝试/释放锁的API才能正常工作。每个锁都需要在lock表中排一行。

原文作者:BB8 StaffEngineer

点赞收藏
willberthos

keep foolish!

请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

【全网首发】追求性能极致:Redis6.0的多线程模型

【全网首发】追求性能极致:Redis6.0的多线程模型

【全网首发】总结 mysql 的所有 buffer,一网打尽就这篇了!

【全网首发】总结 mysql 的所有 buffer,一网打尽就这篇了!

【全网首发】追求性能的极致:Redis6.0的客户端缓存

【全网首发】追求性能的极致:Redis6.0的客户端缓存

两个事务并发写,能保证数据唯一吗?

两个事务并发写,能保证数据唯一吗?

Redis源码简洁剖析14 —AOF

Redis源码简洁剖析14 —AOF

【全网首发】Redis系列8:Bitmap实现亿万级数据计算

【全网首发】Redis系列8:Bitmap实现亿万级数据计算

6
3