关于分布式中的一些锁的了解
在高并发场景下,电商行业通常面临库存是否充足、是否超卖的问题。如下图

这个问题有很多种技术解决方案,如:
悲观锁,分布式锁,乐观锁,队列串行化,Redis原子操作,等等
接下来就对上述的几种技术方案的原理性及基础应用做说明。
悲观锁
-
采用一种悲观的态度来对待事务并发问题。
-
悲观锁的实现往往依靠数据库提供的锁机制。
-
我们认为系统中的并发更新会非常频繁,并且事务失败了以后重来的开销很大,
因此我们就需要采用真正意义上的锁来进行实现。 -
悲观锁的基本思想就是每次一个事务读取某一条记录后,就会把这条记录锁住,
这样其它的事务要想更新,必须等以前的事务提交或者回滚解除锁。
用法:SELECT … FOR UPDATE;
select * from tbl_user where id=1 for update;
获取锁的前提:结果集中的数据没有使用排他锁或共享锁时,才能获取锁,否则将会阻塞。
并且需要注意的是,for update 生效需要确保操作是在事务块儿
中。
当执行 select ... for update
时,将会把数据锁住,因此,我们需要注意一下锁的级别。MySQL InnoDB 默认为行级锁。当查询语句指定了主键时,MySQL会执行「行级锁」,否则MySQL会执行「表锁」。
常见情况如下:
- 若明确指明主键,且结果集有数据,行锁;
若明确指明主键,结果集无数据,则无锁;
若无主键,且非主键字段无索引,则表锁;
若使用主键但主键不明确,则使用表锁
缺点
悲观锁采用的是「先获取锁再访问」的策略,来保障数据的安全。但是加锁策略,依赖数据库实现,
会增加数据库的负担,且会增加死锁的发生几率。此外,对于不会发生变化的只读数据,
加锁只会增加额外不必要的负担。
适用场景
因为悲观锁会影响系统吞吐的性能,所以适合应用在写为居多的场景下。
乐观锁
-
乐观锁通常是通过在表中增加一个版本(version)或时间戳(timestamp)来实现,
其中,版本最为常用。 -
乐观锁不在数据库上加锁,任何事务都可以对数据进行操作,在更新时才进行校验,
这样就避免了悲观锁造成的吞吐量下降的劣势。 -
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,
但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。
用法
我们建一张测试表 hmcf_test_op_lock(使用version字段),并添加两条记录,然后开始执行更新操作。
建表语句:create table hmcf_test_op_lock(id int, name text, num int, version int);
id | name | num | version |
---|---|---|---|
1 | 葡萄干 | 2 | 0 |
2 | 香蕉 | 1 | 0 |
这里以id=1的数据作为测试案列
客户端A执行
update hmcf_test_op_lock set num=num-1, version=version+1 where id=1 and version=0
> Affected rows: 1
> 时间: 0.001s
此时影响的行数为一行,数据库中的数据更新了(select * from hmcf_test_op_lock where id=1)
id | name | num | version |
---|---|---|---|
1 | 葡萄干 | 1 | 1 |
此时客户端B再执行
update hmcf_test_op_lock set num=num-1, version=version+1 where id=1 and version=0
> Affected rows: 0
> 时间: 0s
可以看到,此时影响的行数为0,因为version已经发生了变化。
缺点
乐观锁因为是通过我们人为实现的,它仅仅适用于我们自己业务中,
如果有外来事务插入,那么就可能发生错误。
适用场景
因为乐观锁就是为了避免悲观锁的弊端出现的,所以适合应用在读为居多的场景下。
分布式锁
分布式锁所应具备的条件
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行。
- 高可用的获取锁与释放锁。
- 高性能的获取锁与释放锁。
- 具备可重入特性。
- 具备锁失效机制,防止死锁。
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
分布式锁的三种常见的实现方式
- 基于数据库实现分布式锁;
- 基于缓存(Redis等)实现分布式锁;
- 基于Zookeeper实现分布式锁;
分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”
所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,
都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,
只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
一、基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
(1)创建一个表:
DROP TABLE IF EXISTS method_lock;
CREATE TABLE method_lock (
id int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
method_name varchar(64) NOT NULL COMMENT '锁定的方法名',
desc varchar(255) NOT NULL COMMENT '备注信息',
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uidx_method_name (method_name) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
(2)想要执行某个方法,就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';
注意:这只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的玩法!
使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:
1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
二、 基于Redis 原子操作
[图片上传失败...(image-cdbdca-1573806720083)]
1、选用Redis实现分布式锁原因:
- Redis有很高的性能
- Redis命令对此支持较好,实现起来比较方便
2、使用命令介绍:
(1)SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;
若key存在,则什么都不做,返回0。(可以考虑将过期时间作为值设置上,避免客户端expire时报错,导致死锁问题)
(2)expire
expire key timeout:为key设置一个超时时间,单位为second,
超过这个时间锁会自动释放,避免死锁。
(3)delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
队列串行化
关于队列串行化,在分布式应用中,通常应用将需要控制的操作发送的任务队列里面,然后通过消费者去队列里面去读取对应的任务,进行处理。
常用的任务队列有:rabbitmq 、 rocket_mq、kafka 等。
其中各消息队列的使用方法,会在后续的内容中补上。