在多线程高并发场景下,为了保证资源的线程安全问题,
jdk
为我们提供了
synchronized
关键字和
ReentrantLock
可重入锁,但是它们只能保证一个
jvm
内的线程安全。在分布式集群、微服务、云原生横行的当下,如何保证不同进程、不同服务、不同机器的线程安全问题,
jdk
并没有给我们提供既有的
解决方案。此时,我们就必须借助于相关技术手动实现了。目前主流的实现有三种方式:
- 基于mysql关系型实现
- 基于redis非关系型数据实现
- 基于zookeeper实现
从减库存聊起
库存在并发量较大情况下很容易发生超卖现象,一旦发生超卖现象,就会出现多成交了订单而发不了货的情况。
场景:
商品
S
库存余量为
5
时,用户
A
和
B
同时来购买一个商品
S
,此时查询库存数都为
5
,库存充足则开始
减库存:
用户
A
:
update db_stock set stock = stock - 1 where id = 1
用户
B
:
update db_stock set stock = stock - 1 where id = 1
并发情况下,更新后的结果可能是
4
,而实际的最终库存量应该是
3
才对
环境准备
建表语句:
CREATE TABLE `db_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`product_code` varchar(255) DEFAULT NULL COMMENT '商品编号',
`stock_code` varchar(255) DEFAULT NULL COMMENT '仓库编号',
`count` int(11) DEFAULT NULL COMMENT '库存量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
表中数据如下:
1001商品在001仓库有5000件库存。
创建分布式锁demo工程:
创建好之后:
pom.xml
如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.lzx</groupId>
<artifactId>distributed_lock</artifactId>
<version>1.0</version>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
配置文件:
server:
port: 6000
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://172.16.116.100:3306/test
username: root
password: root
redis:
host: 172.16.116.100
DistributedLockApplication
启动类:
@SpringBootApplication
@MapperScan("com.lzx.distributedlock.mapper")
public class DistributedLockApplication {
public static void main(String[] args) {
SpringApplication.run(DistributedLockApplication.class, args);
}
}
Stock
实体类:
@Data
@TableName("db_stock")
public class Stock {
@TableId
private Long id;
private String productCode;
private String stockCode;
private Integer count;
}
StockMapper
接口:
public interface StockMapper extends BaseMapper<Stock> {
}
简单实现减库存
接下来代码实操一下
StockController:
@RestController
public class StockController {
@Autowired
private StockService stockService;
@GetMapping("/check/lock")
public String checkAndLock(){
this.stockService.checkAndLock();
return "验库存并锁库存成功!";
}
}
StockService
:
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
public void checkAndLock() {
// 先查询库存是否充足
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock != null && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
}
测试:
查看数据库:
在浏览器中一个一个访问时,每访问一次,库存量减
1
,没有任何问题。
演示超卖现象
接下来使用
jmeter
压力测试工具,高并发下压测一下,添加线程组:并发
100
循环
50
次,即
5000
次
请求。
给线程组添加HTTP Request请求:
填写测试接口路径如下:
再选择你想要的测试报表,例如这里选择聚合报告:
启动测试,查看压力测试报告:
测试结果:请求总数
5000
次,平均请求时间
202ms
,中位数(
50%
)请求是在
173ms
内完成的,
90%请求是在
344ms
内完成的,最小耗时
12ms
,最大耗时
1125ms
,错误率
0%
,每秒钟平均
473.8
次。
查看
mysql
数据库剩余库存数:还有
4870
此时如果还有人来下单,就会出现超卖现象(别人购买成功,而无货可发)。
jvm
锁问题演示
添加
jvm
锁
使用
jvm
锁(
synchronized
关键字或者
ReetrantLock
)试试:
重启
tomcat
服务,再次使用
jmeter
压力测试,效果如下:
查看
mysql
数据库:
并没有发生超卖现象,完美解决。
原理
添加
synchronized
关键字之后,
StockService
就具备了对象锁,由于添加了独占的排他锁,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会
one-by-one
执行下去,也就不会发生超卖现
象。
事务问题
修改库存扣减方法,在上面添加事务注解试试:
测试结果
又出现了问题
原因分析
由于springboot事务是基于aop实现的,mysql的默认事务隔离级别为RR,可重复读,假设当前有两个线程同时并发
最终导致库存更新出现问题。
下面修改事务隔离级别为读未提交
测试结果
并没有发生超卖现象,解决了问题;但是一般不会这么做,因为在真实的库存扣减中通常伴随着订单等多表的操作,不单单只是库存表,可能会引起其他的问题。
多例问题
测试结果
多例模式下,库存扣减也出现了问题,与下面的多服务问题类似。
多服务问题
使用
jvm
锁在单工程单服务情况下确实没有问题,但是在集群情况下会怎样?
接下启动多个服务并使用
nginx
负载均衡,结构如下:
启动三个服务(端口号分别8000 8100 8200),如下:
安装配置nginx
基于安装
nginx
:
#拉取镜像
docker pull nginx:latest
#创建nginx对应资源、日志及配置目录
mkdir -p /opt/nginx/logs /opt/nginx/conf /opt/nginx/html
#先在conf目录下创建nginx.conf文件,配置内容参照下方
#再运行容器
docker run -d -p 80:80 --name nginx -v /opt/nginx/html:/usr/share/nginx/html -v /opt/nginx/conf/nginx.conf:/etc/nginx/nginx.conf -v
/opt/nginx/logs:/var/log/nginx nginx
nginx.conf
配置如下:
在浏览器中测试:172.16.116.100是nginx服务器地址
经过测试,通过nginx访问服务一切正常。
压力测试
注意:先把数据库库存量还原到
5000
。
参照之前的测试用例,再创建一个新的测试组:参数给之前一样
配置nginx的地址及 服务的访问路径如下:
测试结果:性能只是略有提升。
数据库库存剩余量如下:
又出现了并发问题,即出现了超卖现象。
mysql
锁演示
除了使用
jvm
锁之外,还可以使用数据锁:
悲观锁
或者
乐观锁
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
乐观所:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新,一般在悲观锁的等待时间过长而不能接受时我们才会选择乐观锁。
悲观锁
在select的时候就会加锁,采用先加锁后处理的模式,虽然保证了数据处理的安全性,但也会阻塞其他线程的写操作。悲观锁适用于写多读少的场景,因为拿不到锁的线程,会将线程挂起,交出CPU资源,可以把CPU给其他线程使用,提高了CPU的利用率。
乐观锁
在select的时候不会加锁,是基于程序实现的,所以不会存在死锁的情况。
适用于读多写少的场景(写的并发量相对不高),可以提高系统的吞吐量。
因为如果写多的话,乐观锁会有很大机率更新失败,需要不断的自旋执行查找和更新操作。
自旋的时候会一直占用CPU,会耗费大量的CPU资源。
锁分类:
悲观锁:具有强烈的独占和排他特性,在整个数据处理过程中,将数据处于锁定状态。适合于写比较多,会阻塞读操作。
乐观锁:采取了更加宽松的加锁机制,大多是基于数据版本( Version )及时间戳来实现。。适合于读比较多,不会阻塞读。
独占锁、互斥锁、排他锁:保证在任一时刻,只能被一个线程独占排他持有。synchronized、ReentrantLock。
共享锁:可同时被多个线程共享持有。CountDownLatch到计数器、Semaphore信号量。
可重入锁:又名递归锁。同一个线程在外层方法获取锁的时候,在进入内层方法时会自动获取锁。
不可重入锁:不可重入锁不可递归调用,递归调用就发生死锁,不可重入锁也叫自旋锁。
公平锁:有优先级的锁,先来先得,谁先申请锁就先获取到锁。
非公平锁:无优先级的锁,后来者也有机会先获取到锁。
自旋锁:当线程尝试获取锁失败时(锁已经被其它线程占用了),无限循环重试尝试获取锁。
阻塞锁:当线程尝试获取锁失败时,线程进入阻塞状态,直到接收信号后被唤醒。在竞争激烈情况下,性能较高。
读锁:共享锁。
写锁:独占排他锁。
偏向锁:一直被一个线程所访问,那么该线程会自动获取锁。
轻量级锁(CAS):当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(10次),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。
以上其实是synchronized的锁升级过程。
表级锁:对整张表加锁,加锁快开销小,不会出现死锁,但并发度低,会增加锁冲突的概率。
行级锁:是mysql粒度最小的锁,只针对操作行,可大大减少锁冲突概率,并发度高,但加锁慢,开销大,会出现死锁。
具体锁实现:
jvm:
ReentrantLock悲观的独占的可重入的可公平可不公平锁
synchronized悲观的独占的可重入的非公平锁
无锁 --> 偏向锁(同一个线程再次获取锁) --> 轻量级锁(自旋) --> 重量级锁
ReentrantLock + synchronized(必要条件)
1.单个jvm实例 单机
2.必须单例
3.与事务并存问题(上面说到的事务问题)
总之,不适合于保证数据库数据可靠性
mysql:
select ... for update:悲观的独占的
select ... lock in share mode
1.直接更新时判断。在更新中判断库存是否大于0 update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
解决jvm锁多例模式锁失效问题及事务共存问题
锁范围控制:条件字段必须创建索引;查询条件必须具体的值
同一个商品有多个库存时,无法解决。
无法记录库存变化前后的状态
2.悲观锁:select ... for update
库存操作要统一:不能有的操作是select ... for update 而有的操作是普通的select
死锁风险:多条记录时,加锁顺序要一致
阻塞及性能问题
3.乐观锁:version 或者 时间戳(CAS思想)
ABA问题
失败需要重试,高并发情况下性能不高
读写分离情况下导致乐观锁不可靠
2.必须单例
3.与事务并存问题(上面说到的事务问题)
总之,不适合于保证数据库数据可靠性
mysql:
select ... for update:悲观的独占的
select ... lock in share mode
1.直接更新时判断。在更新中判断库存是否大于0 update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
解决jvm锁多例模式锁失效问题及事务共存问题
锁范围控制:条件字段必须创建索引;查询条件必须具体的值
同一个商品有多个库存时,无法解决。
无法记录库存变化前后的状态
2.悲观锁:select ... for update
库存操作要统一:不能有的操作是select ... for update 而有的操作是普通的select
死锁风险:多条记录时,加锁顺序要一致
阻塞及性能问题
3.乐观锁:version 或者 时间戳(CAS思想)
ABA问题
失败需要重试,高并发情况下性能不高
读写分离情况下导致乐观锁不可靠
mysql
悲观锁
在
MySQL
的
InnoDB
中,预设的
Tansaction isolation level
为
REPEATABLE READ
(可重读)
在
SELECT
的读取锁定主要分为两种方式:
SELECT ... LOCK IN SHARE MODE(共享锁)
SELECT ... FOR UPDATE
(悲观锁)
这两种方式在事务
(Transaction)
进行当中
SELECT
到同一个数据表时,都必须等待其它事务数据被提交(Commit)
后才会执行。
而主要的不同在于
LOCK IN SHARE MODE
在有一方事务要
Update
同一个表单时很容易造成死锁。
简单的说,如果
SELECT
后面若要
UPDATE
同一个表单,最好使用
SELECT ... FOR UPDATE
。
代码实现
改造
StockService
:
在
StockeMapper
中定义
selectStockForUpdate
方法:
public interface StockMapper extends BaseMapper<Stock> {
public Stock selectStockForUpdate(Long id);
}
在
StockMapper.xml
中定义对应的配置:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lzx.distributedlock.mapper.StockMapper">
<select id="selectStockForUpdate" resultType="com.lzx.distributedlock.pojo.Stock">
select * from db_stock where id = #{id} for update
</select>
</mapper>
压力测试
注意:测试之前,需要把库存量改成
5000
。压测数据如下:比
jvm
性能高很多,比无锁要低将近
1
倍
mysql
数据库存:
mysql
乐观锁
乐观锁(
Optimistic Locking
) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则重试。那么
如何实现乐观锁呢?
使用数据版本(Version
)记录机制实现,这是乐观锁最常用的实现 方式。一般是通过为数据库表增加
一个数字类型的
“version”
字段来实现。当读取数据时,将
version
字段的值一同读出,数据每更新一
次,对此
version
值加一。当我们提交更新的时候,判断数据库表对应记录 的当前版本信息与第一次取
出来的
version
值进行比对,如果数据库表当前版本号与第一次取出来的
version
值相等,则予以更新。
给
db_stock
表添加
version
字段:
对应也需要给Stock实体类添加version属性。
代码实现
重启后使用jmeter压力测试工具结果如下:
修改测试参数如下:
测试结果如下:
说明乐观锁在并发量越大的情况下,性能越低(因为需要大量的重试);并发量越小,性能越高。
mysql
锁缺陷
在数据库集群情况下会导致数据库锁失效,并且很多数据库集群的中间件压根就不支持悲观锁。例如:
mycat
在读写分离的场景下可能会导致乐观锁不可靠。
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
这就需要引入分布式锁来解决问题。