对分布式锁说拜拜

前言

提到分布式锁,大家可能第一反应想到的,都是 Redis 和 Zookeeper,比如经典的秒杀场景,以逢年过节抢茅台的桥段为例,一大堆人通过低价抢购然后高价转售,赚取中间的差价以此牟利。秒杀是促销导致的一种用户行为,通常秒杀的对应商品都有一个库存值,在数据库中以数值存放,用户在抢购时,需要更新库存。典型的瓶颈在于对同一条记录的多次更新请求,然后只有一个或者少量请求是成功的,其他请求是以失败或更新不到告终。当然了,不要天真的以为靠手速,都是人肉,我们面对的还有很多脚本,自动化的外挂抢购系统,不然也不会有那么多黄牛了,压力可想而知。

秒杀的优化手段很多,就拿数据库来说,有用排队机制的,有用异步消息的,有用交易合并的。

分布式锁的缘由

在编程中,像Synchronized、ReentrantLock,在单进程多线程访问同一资源的情况下,可以用它们来保证线程的安全性。不过这种只对于单个服务器有效,只能锁住当前进程,仅适用于单体架构服务,一旦有多个服务器的话,就不行了,所以就引入了分布式锁。

对分布式锁说拜拜
对分布式锁说拜拜

很容易想到的方案是把共享变量(锁)抽取出来放在一个公共的数据库里(Redis、Memchhed)里,所有的服务器通过这个公共的资源实现数据的一致性,防止超卖。

基于Redis

用Redis实现分布式锁的核心是:

  1. setnx加锁,意思是只有当前key不存在才返回1,当前key存在返回0,这个key就是我们的”锁”,只有线程获得锁才能继续执行,执行完del这个key相当于解锁操作,还是很好理解的。
  2. del解锁,释放锁

当然了,使用Redis作为分布式锁,会有一些问题:

  1. 如果执行完set命令服务器宕机了,来不及del解锁,那么这个锁就变成死锁了,就永远占着茅坑不拉屎了,其他线程无法执行。解决方法是key必须设置一个超时时间,比如expire
  2. setnx设置好,正要去设置过期时间,不巧这个时候又宕机了,又死锁了,这个可以通过原子性来保证,Redis 2.6.12 的版本后,提供了一条组合命令,SET key value ex seconds nx,加锁的同时设置过期时间。
  3. 比如主从集群,主结点加了锁不幸宕机了,从节点此时还没来得及同步,当该从节点提升为主节点时就会出错,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock红锁,然后另一名分布式专家叫 Martin 的,又提出了质疑,两位大佬互相反驳,互相质疑,二人思路清晰,论据充分,双方都是分布式系统领域的专家,却对同一个问题提出很多相反的论断,感兴趣的可以自行搜索一下,可以学到不少东西。
  4. 在操作锁的过程中,假如因为某些原因被长期阻塞,导致锁过期,那么接下来访问共享资源也会变得不再安全
  5. ……

如果你是 Java 的coder,很幸运,已经有一个库把这些工作都封装好了:Redisson,Redisson 顾名思义,Redis 的儿子,本质上还是 Redis 加锁,不过是对 Redis 做了很多封装,它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。俗话说得好:面试造火箭,工作拧螺丝,得学会站在巨人的肩膀上前行!

对分布式锁说拜拜
对分布式锁说拜拜

基于Zookeeper

另外一个分布式锁的常客就是Zookeeper了,当然Zookeeper不仅限于分布式锁,比如命名服务、配置管理、服务发现等,很多网上的文章都写不推荐使用Zookeeper来实现”服务发现”,用 Eureka 来替代,在此表过不提。

回到分布式锁,基于Zookeeper的分布式是通过临时节点和 Watcher的机制来实现的,ZooKeeper 中存在如下命令:

create [-s] [-e] path [data]

-s 为创建有序节点,-e 创建临时节点。

假如有一个分布式系统,有三个节点 A、B、C,试图通过 ZooKeeper 获取分布式锁。

  1. 访问 /lock,创建带序列号的临时节点(EPHEMERAL) 。至于为什么是顺序节点而不是非顺序节点,可以试想,如果是非顺序的,每进来一个线程进来都会去持有锁的节点上注册一个监听器,容易引发“羊群效应”,你撑不撑得住不知道,反正Zookeeper表示不想撑了。
    对分布式锁说拜拜
    对分布式锁说拜拜
  2. 每个节点尝试获取锁时,拿到 /locks节点下的所有子节点(id_0000,id_0001,id_0002),判断自己创建的节点是不是最小的。
    对分布式锁说拜拜
    对分布式锁说拜拜
  3. 释放锁,即删除自己创建的节点。
    对分布式锁说拜拜
    对分布式锁说拜拜

图中,NodeA 删除自己创建的节点 id_0000,NodeB 监听到变化,发现自己的节点已经是最小节点,即可获取到锁。

可以看到,采用Zookeeper实现分布式锁,你要么就获取不到锁,一旦获取到了,必定节点的数据是一致(写可靠性)的,不会出现redis那种异步同步导致数据丢失的问题。同时,由于节点的临时属性,如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这样就避免了设置过期时间的问题。

当然,Zookeeper实现的分布式锁也存在问题:

  1. Zookeeper如果长时间检测不到客户端的心跳的时候(Session时间),就会认为Session过期了,那么这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
  2. 如果有较多的客户端频繁的申请加锁、释放锁,对于Zookeeper集群的压力会比较大。
  3. 如果在高并发场景中,会出现获取锁失败的情况,存在性能瓶颈。

基于Consul

基于Consul的分布式锁主要利用Key/Value存储API中的acquire和release操作来实现。acquire和release操作是类似Check-And-Set的操作:

  1. acquire操作只有当锁不存在持有者时才会返回true,并且set设置的Value值,同时执行操作的session会持有对该Key的锁,否则就返回false
  2. release操作则是使用指定的session来释放某个Key的锁,如果指定的session无效,那么会返回false,否则就会set设置Value值,并返回true

更多示例自行度娘。

基于PostgreSQL

其实在分布式系统中,为保证同一时间只有一个客户端可以对共享资源进行操作的方式除了Redis、Zookeeper、Consul等之外,还有基于数据库实现的分布式锁:

  • 基于数据库实现分布式锁
  • 基于Redis实现分布式锁
  • 基于Zookeeper实现分布式锁
  • 基于Consul实现分布式锁

那么为什么在设计的都不考虑使用数据库来实现呢?主要是高并发下数据库锁性能太差,更新库存的状态信息,在数据库中体现为行锁,因为同一条记录,只允许一个事务更新,其他事务会处于等待状态。比如,以秒杀iphone12为例,库存20台:

update flash_sale set sale_cnt=sale_cnt + 1 where id = xxx and sale_cnt + 1<20;

这种方式慢的原因在于,其他事务都要等待之前事务进行更新的结果,可能更新很久(网络延时等情况),那么造成的情况就是其他事务空等,TPS很低,对应到PostgreSQL里面,表现的形式就是等待给某个事务 transactionid 加上 share 锁。

在PostgreSQL里面,常见的优化方式有 nowaitskip locked

对分布式锁说拜拜
对分布式锁说拜拜

使用nowait

这种优化方式是很容易想得到的,假如我如果无法立马获得锁,那么就不等待,直接报错。

begin;
select 1 from flash_sale where id = xx for update nowait;
update flash_sale set sale_cnt=sale_cnt + 1 where id = xxx and sale_cnt + 1<20;
end;
postgres=# begin;
BEGIN
postgres=*# select 1 from test_lock where id = 1 for update nowait;
ERROR: could not obtain lock on row in relation "test_lock"

这种方法可以有效减少用户的等待时间,因为无法立马获得锁后就直接返回了。

使用skip locked

这个是 v9.5 引入的一个特性

Add new SELECT option SKIP LOCKED to skip locked rows (Thomas Munro)This does not throw an error for locked rows like NOWAIT does.

With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table.

试想一下这么一个场景,订机票,假如目前有4张机票

postgres=# create table airline_ticket as select * from generate_series(1,4) as id;
SELECT 4
postgres=# select * from airline_ticket ;
id 
----
 1
 2
 3
 4
(4 rows)

此时小明打开了App,已经看到了位置,就差给钱了,那么对应到数据库,逻辑如下

postgres=# begin;
BEGIN
postgres=*# select * from airline_ticket where id = 1 for update; ---小明预定了1号靠窗黄金座位,但是银行卡里没钱,还没付钱
id 
----
 1
(1 row)
-- * 刷微博中 *
-- * 看抖音中 *
-- * 想起要转钱进付款银行卡 *
-- * 转账成功 *
postgres=*# delete from airline_ticket where id = 1; ---转钱进付款银行卡并付款之后,App需要删除1号靠窗黄金座位

不巧若此时小张在小明刷微博的过程中,也打开了App,假如也是同样的逻辑,发现1号靠窗黄金座位还在,当然也想预定了,不过很不幸,会被小明夯住

postgres=# begin;
BEGIN
postgres=*# select * from airline_ticket; ---查询一下,小张也发现1号靠窗黄金座位还在
id 
----
 1
 2
 3
 4
(4 rows)

postgres=*# select * from airline_ticket where id = 1 for update;   ---也预定1号靠窗黄金座位
此处夯住

所以可以看到,在小明付款成功之前,小张都会处于夯住的状态,所以最理想的操作是直接指定skip locked,跳过获取不到锁的记录

postgres=# begin;
BEGIN
postgres=*# select * from airline_ticket for update skip locked; ---此时就看不到1号靠窗黄金座位了,就直接断了念想
id 
----
 2
 3
 4
(3 rows)

postgres=*# select * from airline_ticket where id = 2 for update; ---选择一个靠走廊的次席还是可以的
id 
----
 2
(1 row)

假如这里采用nowait的话,就会有点缺憾:

postgres=# begin;
BEGIN
postgres=*# select * from airline_ticket for update nowait; ---这样既看不到1号靠窗黄金座位,也看不到靠走廊的次席了
ERROR: could not obtain lock on row in relation "airline_ticket"

咨询锁

另外一个PostgreSQL中的神器,便是 advisory lock 咨询锁了,PostgreSQL允许用户创建咨询锁,该锁与数据库本身没有关系,比如多个进程访问同一个数据库时,如果想协调这些进程对一些非数据库资源的并发访问,就可以使用咨询锁。咨询锁是提供给应用层显示调用的锁方法,在表中存储一个标记位能够实现同样的功能,但是咨询锁更快;并且避免表膨胀,且会话(或事务)结束后能够被自动清理。

Here are some of the provided functions for session level locks:

  1. pg_advisory_lock(key bigint) obtains exclusive session level advisory lock. If another session already holds a lock on the same resource identifier, this function will wait until the resource becomes available. Multiple lock requests stack, so that if the resource is locked three times it must then be unlocked three times.
  2. pg_try_advisory_lock(key bigint) obtains exclusive session level advisory lock if available. It’s similar to pg_advisory_lock, except it will not wait for the lock to become available – it will either obtain the lock and return true, or return false if the lock cannot be acquired immediately.
  3. pg_advisory_unlock(key bigint) releases an exclusive session level advisory lock.

And here are some for transaction level locks:

  1. pg_advisory_xact_lock(key bigint) obtains exclusive transaction level advisory lock. It works the same as pg_advisory_lock, except the lock is automatically released at the end of the current transaction and cannot be released explicitly.
  2. pg_try_advisory_xact_lock(key bigint) obtains exclusive transaction level advisory lock if available. It works the same as pg_try_advisory_lock, except the lock is automatically released at the end of the transaction and cannot be released explicitly.

咨询锁,支持会话级的,也支持事务级的,照着德哥的例子测一下效果

postgres=# create table t1(id int,info text);
CREATE TABLE
postgres=# insert into t1 values(1,now());
INSERT 0 1
postgres=# select * from t1;
id | info 
----+-------------------------------
1 | 2021-07-11 16:30:28.145899+08
(1 row)

nowait的方式

create or replace function public.f1(i_id integer) 
returns void 
language plpgsql 
as $function$ 
declare 
begin 
    perform 1 from t1 where id=i_id for update nowait; 
    update t1 set info=now()::text where id=i_id; 
    exception when others then 
    return; 
end; 
$function$; 
[postgres@xiongcc ~]$ cat nowait.sql
\set id 1
select f1(:id);
[postgres@xiongcc ~]$ pgbench -M prepared -n -r -P 1 -f nowait.sql -c 20 -j 20 -T 60
transaction type: nowait.sql
scaling factor: 1
query mode: prepared
number of clients: 20
number of threads: 20
duration: 60 s
number of transactions actually processed: 68184
latency average = 17.597 ms
latency stddev = 8.835 ms
tps = 1135.753351 (including connections establishing)
tps = 1136.223142 (excluding connections establishing)
statement latencies in milliseconds:
0.001 \set id 1
17.596 select f1(:id);

advisory lock的方式

[postgres@xiongcc ~]$ cat adv_lock.sql 
\set id 1 
update t1 set info=now()::text where id=:id and pg_try_advisory_xact_lock(:id);
[postgres@xiongcc ~]$ pgbench -M prepared -n -r -P 1 -f adv_lock.sql -c 20 -j 20 -T 60
transaction type: adv_lock.sql
scaling factor: 1
query mode: prepared
number of clients: 20
number of threads: 20
duration: 60 s
number of transactions actually processed: 529700
latency average = 2.264 ms
latency stddev = 0.594 ms
tps = 8827.677476 (including connections establishing)
tps = 8831.122594 (excluding connections establishing)
statement latencies in milliseconds:
0.001 \set id 1 
2.263 update t1 set info=now()::text where id=:id and pg_try_advisory_xact_lock(:id);

我这个小破云主机配置太低,测不出太好的效果,虽然也有提升,参照德哥的样例:https://github.com/digoal/blog/blob/master/201509/20150914_01.md

对分布式锁说拜拜
对分布式锁说拜拜

通常数据库支持的最小粒度的锁是行锁,行锁相比LWlock,spinlock这些底层轻量级的锁来说,还是非常重的,所以传统的行锁在秒杀中会成为非常大的瓶颈,互相等待,互相阻塞,而咨询锁本身和数据库资源没有关系,锁住的对象可以简单理解成是一个“数字”,而不是row本身,如果未获得锁,直接返回。与skip locked类似,但是更加高效,因为不需要去检索row。同时,咨询锁支持事务级别和会话级别:

  1. 会话级别:该级别获得的咨询锁,没有事务特征,事务回滚或者取消,之前获得的咨询锁不会被unlock,一个咨询锁可以多次获得,相应的要多次取消。
  2. 事务级别:事务级别获得的锁,事务结束后会自动unlock。

即使是两个模式的咨询锁,也会互相阻塞,所以也要格外注意

[postgres@xiongcc ~]$ psql
psql (13.2)
Type "help" for help.

postgres=# begin;
BEGIN
postgres=*# select pg_advisory_xact_lock(1);
pg_advisory_xact_lock 
-----------------------

(1 row)

新开一个会话

postgres=# select pg_advisory_lock(1);
此处夯住

另外刚刚也提到了咨询锁锁住的是一个“数字”,那么使用过程中就要注意下:

  1. 各个业务锁住的ID空间要分离开,、例如A业务的LOCK空间是1-1000000, B业务的LOCK空间是1000001-2000000的空间,诸如此类等等。
  2. 使用咨询锁不会破坏ACID,单个请求单个事务,不影响其他的事务。
  3. 使用咨询锁时,如果有limit操作,要注意可能pg_advisory_lock在limit操作之前调用,那么如下的情况可能并不是锁了100个对象。
SELECT pg_advisory_lock(id) FROM foo WHERE id = 12345; ---ok
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100; ---danger
SELECT pg_advisory_lock(q.id) FROM
(
SELECT id FROM foo WHERE id > 12345 LIMIT 100
) q; ---ok

小结

PostgreSQL提供的咨询锁,可以用在业务需要强制串行化等场景中,比如秒杀场景,有效消除行锁冲突,而不需再引入一层额外的Redis、Zookeeper等,不要太香!还有一些其他的操作,就自行查询吧。

  1. PostgreSQL 变态需求实现 – 堵塞式读, 不堵塞写 – 串行读,并行写 – advisory lock
  2. PostgreSQL 用advisory lock 限制每一个分组中最多有多少条记录
  3. PostgreSQL 无缝自增ID的实现 – by advisory lock

参考

https://github.com/digoal/blog/blob/master/201610/20161020_02.md

https://github.com/digoal/blog/blob/master/201610/20161018_01.md

https://zhuanlan.zhihu.com/p/42056183

https://cdmana.com/2021/04/20210421231755196n.html

https://zhuanlan.zhihu.com/p/73807097

http://liuyangming.tech/05-2018/lock-PostgreSQL.html

发表评论

登录后才能评论
服务中心
服务中心
联系客服
联系客服
投诉举报
返回顶部