数据库分布式锁

概述

以前在单机时代, 并不需要分布式锁, 当多个线程需要访问同个资源时, 可以用线程间的锁来解决, 比如(synchronized);
但到了分布式系统的时代, 线程间的锁机制就没用了, 那是因为资源会被复制在多台机中, 已经不能用线程间共享了, 毕竟跨越了主机了, 应该属于进程间共享的资源;
因此, 必须引入分布式锁, 分布式锁是指在分布式的部署环境下, 通过锁机制来让多客户端互斥的对共享资源进行访问;

数据库分布式锁

对于选型用数据库进行实现分布式锁, 一般会觉得不太高级, 或者说性能不够, 但其实他足够简单, 如果一个业务没那么复杂, 其实很多时候, 减少复杂度是更好的设计;还是要基于场景来定
然后一般用数据库实现分布式锁, 有三种实现思路:
1.基于表记录;
2.乐观锁;
3.悲观锁;

基于表记录实现

要实现分布式锁, 最简单就是直接创建一张专门的表, 然后通过操作该表中的数据来实现了. 当我们想要获得锁的时候, 就增加一条记录, 想要释放锁时, 就删除这个记录;

1
2
3
4
5
6
7
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='数据库分布式锁表';
  • 想要获得锁的时候, 可以插入一条记录:
1
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

注意: 要在表中加入对resource进行唯一性约束, 如果有多个竞争, 那么其他的操作就会报ERROR 1062 (23000): Duplicate entry '1' for key 'uiq_idx_resource', 数据库确保了只会有一个成功; 那个成功的就获得了锁;

  • 释放锁的时候, 就删除数据即可;
1
DELETE FROM database_lock WHERE resource=1;

缺点:
1.这种锁没有失效时间, 一旦释放锁的操作失败就会导致锁一直没法释放, 不过这好说, 加个定时器;
2.这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性。
3.这种锁是非阻塞的,因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回.
4.这种锁也是非可重入的, 因为同一个线程在没有释放锁之前, 无法再次获得锁, 要实现可重入, 可以加一些字段, 记录主机信息, 线程信息等, 想获得锁, 可以先查询下, 命中即可;

乐观锁实现

系统认为数据的更新在大多数情况下是不会产生冲突的, 只在数据库更新操作提交的时候才对数据做冲突检测, 如果检测与预期数据不一致, 则返回失败信息;

乐观锁大多数是基于数据版本(version)的记录机制实现的; 一般用数据库实现, 就是在表中增加一个version字段来实现读取出数据时, 将此版本号一同取出, 之后更新时, 对此版本号加1; 更新过程中会对版本号进行比较, 如果是一致的, 没有发生改变, 就会成功执行操作, 否则, 更新失败, 报错;

下面举一个例子, 比如多线程对电商产品库存的减少, 当用户购买时, 会进行减一操作; 我们可以建立这样一张表;

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `optimistic_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '锁定的资源',
`version` int NOT NULL COMMENT '版本信息',
`created_at` datetime COMMENT '创建时间',
`updated_at` datetime COMMENT '更新时间',
`deleted_at` datetime COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

其中:id表示主键;resource表示具体操作的资源,在这里也就是特指库存;version表示版本号。
在使用乐观锁之前要确保表中有相应的数据,比如:

1
INSERT INTO optimistic_lock(resource, version, created_at, updated_at) VALUES(20, 1, CURTIME(), CURTIME());

如果只是一个线程进行操作, 数据库本身就可以保证其操作的正确性. 主要步骤如下:

  • STEP1获取资源:SELECT resource FROM optimistic_lock WHERE id = 1
  • STEP2执行业务逻辑
  • STEP3更新资源:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1

然而在并发的情况下就会产生一些意想不到的问题:比如两个线程同时购买一件商品,在数据库层面实际操作应该是库存(resource)减2,但是由于是高并发的情况,第一个线程执行之后(执行了STEP1、STEP2但是还没有完成STEP3),第二个线程在购买相同的商品(执行STEP1),此时查询出的库存并没有完成减1的动作,那么最终会导致2个线程购买的商品却出现库存只减1的情况。

在引入了version字段之后,那么具体的操作就会演变成下面的内容:

  • STEP1获取资源: SELECT resource, version FROM optimistic_lock WHERE id = 1
  • STEP2执行业务逻辑
  • STEP3更新资源:UPDATE optimistic_lock SET resource = resource - 1, version = version + 1 WHERE id = 1 AND version = oldVersion

其实,借助更新时间戳(updated_at)也可以实现乐观锁,和采用version字段的方式相似:更新操作执行前线获取记录当前的更新时间,在提交更新时,检测当前更新时间是否与更新开始时获取的更新时间戳相等。

乐观锁的优点比较明显,由于在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。

缺点:
缺点是需要对表的设计增加额外的字段,增加了数据库的冗余,另外,当应用并发量高的时候,version值在频繁变化,则会导致大量请求失败,影响系统的可用性。我们通过上述sql语句还可以看到,数据库锁都是作用于同一行数据记录上,这就导致一个明显的缺点,在一些特殊场景,如大促、秒杀等活动开展的时候,大量的请求同时请求同一条记录的行锁,会对数据库产生很大的写压力。所以综合数据库乐观锁的优缺点,乐观锁比较适合并发量不高,并且写操作不频繁的场景。

悲观锁实现

除了可以通过增删操作数据库表中的记录以外,我们还可以借助数据库中自带的锁来实现分布式锁。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。

悲观锁,与乐观锁相反,总是假设最坏的情况,它认为数据的更新在大多数情况下是会产生冲突的。

在使用悲观锁的同时,我们需要注意一下锁的级别。MySQL InnoDB引起在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则MySQL 将会执行表锁(将整个数据表单给锁住)。

  • 这里有一点要注意:

    很多网上的示例都说使用悲观锁要关闭自动提交属性, 其实未必的, 只不过begin和commit我们可以交给spring来弄, 最终可能没那么灵活罢了;

基本上一个线程上, 获得锁步骤如下:

  • STEP1获取锁:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;
  • STEP2执行业务逻辑
  • STEP3释放锁:COMMIT(这些在spring中其实方法结束就自动提交了)

如果另一个线程B在线程A释放锁之前执行STEP1,那么它会被阻塞,直至线程A释放锁之后才能继续。注意,如果线程A长时间未释放锁,那么线程B会报错,参考如下(lock wait time可以通过innodb_lock_wait_timeout来进行配置):

1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

使用悲观锁最最最最重要的一点就是要注意表锁, 要使用主键或者索引查询;

还有, 虽然我们可以显示使用行级锁(指定可查询的主键或索引),但是MySQL会对查询进行优化,即便在条件中使用了索引字段,但是否真的使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫描效率更高,比如对一些很小的表,它有可能不会使用索引,在这种情况下InnoDB将使用表锁,而不是行锁。

在悲观锁中,每一次行数据的访问都是独占的,只有当正在访问该行数据的请求事务提交以后,其他请求才能依次访问该数据,否则将阻塞等待锁的获取。悲观锁可以严格保证数据访问的安全。

但是缺点也明显,即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。另外,悲观锁使用不当还可能产生死锁的情况。

参考总结

如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于 3 次。

1.数据库分布式锁
2.https://blog.csdn.net/m0_37574566/article/details/86586847
3.https://blog.csdn.net/ctwy291314/article/details/82424055
4.https://blog.csdn.net/tianjiabin123/article/details/72625156
5.https://www.jianshu.com/p/39d8b7437b0b

hyhcoder wechat
扫码关注我的个人订阅号