在分布式场景上:
线程A和线程B同时读到临界资源Redis中的stock,其值为10;
线程A对其进行stock + 20的操作,并将修改结果更新回Redis;线程B同理,也进行类似操作;
在线程A读取数据还未完成更新之间,线程B抢先完成了修改,此后线程A也完成了修改。这将导致数据产生错乱。
互斥性:锁的基本特性,保证线程操作的原子性;
可重入性:同一节点的同一个线程能够对一个临界资源(共享资源)重复加锁;
支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut);
高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级;
锁超时:支持锁自动过期释放,防止死锁;
支持公平锁和非公平锁(可选):公平锁是指多个线程按照申请锁的顺序来获取锁;非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
基于数据库,如MySQL;
基于缓存,如Redis;
基于Zookeeper;
自研分布式锁:如谷歌的Chubby。
其详细实现可以概览如下:
本文首先基于数据库实现,基于缓存和分布式协调器的实现在后续篇章将会介绍。04基于MySQL的实现基于数据库(MySQL)的方案,一般分为3类:基于表记录、乐观锁和悲观锁;
第1类:基于表记录(唯一索引)我们知道,数据库的唯一索引约束可以保证有且仅有一条记录可以插入成功,基于此,我们可以将临界资源(共享资源)的标识设置为唯一索引,插入数据表成功,即代表获取到锁,删除记录代表释放锁。
数据库表设计如下:
CREATE TABLE `distributed_lock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resource` int NOT NULL COMMENT '锁定的资源', `node_info` varchar(128) NOT NULL DEFAULT NULL COMMENT '机器节点信息,可由机器IP和线程名组合', `reentry_count` int NOT NULL COMMENT '锁次数统计,统计可重入锁的加锁次数', `description` varchar(1024) NOT NULL DEFAULT NULL COMMENT '描述', PRIMARY KEY (`id`), UNIQUE KEY `uiq_idx_resource` (`resource`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
加锁:
INSERT INTO distributed_lock(resource, description) VALUES (1, 'lock');
解锁:
DELETE FROM distributed_lock WHERE resource = 1;
上述方式实现简单,但也面临一些问题:
锁没有过期失效时间:一旦释放锁的操作失败就会导致锁记录一直在数据库中,其它线程无法获得锁。这个缺陷也很好解决,比如可以做一个定时任务去定时清理;
可靠性依赖于数据库:建议设置备库,避免单点,进一步提高可靠性;
非阻塞:因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回;
性能低:在分布式场景中,往往需要高性能地加锁和解锁,数据库很容易成为性能瓶颈。
在基于数据库表的版本解决方案中,乐观锁可以分为如下几个步骤:
数据库表字段设计上新增version字段,以用于标识数据版本;
查询数据时候,读出version字段,更新时候核对版本号,并一同更新version标识;
更新过程中,会核对版本号,如果是一致的,则会成功执行本次操作;如果版本号不一致,则会更新失败。
UPDATE xxx_table SET resource = resource - 1, version = version + 1 WHERE id = 1 AND version = oldVersion;
乐观锁的优点比较明显,由于在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。
缺点是:
数据表新增version字段,增加数据库表的冗余;
过高并发情况下,请求不断尝试更新数据,导致数据库压力增大,甚至导致系统不可用;
综合数据库乐观锁的优缺点,乐观锁比较适合并发量不高,并且写操作不频繁的场景。
第3类:悲观锁悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。我们知道,共享锁和排他锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。值得庆幸的是,MySQL帮我们实现了,只需要编写相应SQL语句即可。
要使用悲观锁,我们必须关闭MySQL数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。
对于排他锁:
set autocommit = 0;# 设置完autocommit后,我们就可以执行我们的正常业务了。具体如下:# 1. 开始事务begin;/begin work;/start transaction; (三者选一就可以)# 2. 查询表信息select status from TABLE where id = 1 for update;# 3. 插入一条数据insert into TABLE (id,value) values (2,2);# 4. 修改数据为update TABLE set value = 2 where id = 1;# 5. 提交事务commit;/commit work;
对于共享锁:
共享锁又称读锁 read lock,是读取操作创建的锁。其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获得共享锁的事务只能读数据,不能修改数据。
set autocommit = 0;# 开始事务begin;/begin work;/start transaction; (三者选一就可以)# 开启共享锁select * from TABLE where id = 1 lock in share mode;
当一个事务对该行数据加上共享锁后,其他事务若要进行修改,需等待所有共享锁被释放,这个等待超时会抛出异常:
Lock wait timeout exceeded; try restarting transaction
lock wait time可以通过innodb_lock_wait_timeout来进行配置。
在查询语句后面增加 LOCK IN SHARE MODE,MySQL会对查询结果中的每行都加共享锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁,否则会被阻塞。其他线程也可以读取使用了共享锁的表,而且这些线程读取的是同一个版本的数据。加上共享锁后,对于update,insert,delete语句会自动加排它锁。
悲观锁注意事项:
要使用悲观锁,我们必须关闭MySQL数据库的自动提交属性;
MySQL InnoDB引擎在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则MySQL 将会执行表锁(将整个数据表单给锁住)。
至此,基于数据库的实现思路已经梳理完毕。
05引用[1] https://juejin.im/post/6844903688088059912
[2] https://blog.csdn.net/u013256816/article/details/92854794
[3] https://segmentfault.com/a/1190000015815061
[4] https://tech.meituan.com/2018/11/15/java-lock.html
发现“在看”和“赞”了吗,戳我试试吧