1. 传统锁回顾
1.1. 从减库存聊起
多线程并发安全问题最典型的代表就是超卖现象
库存在并发量较大情况下很容易发生超卖现象,一旦发生超卖现象,就会出现多成交了订单而发不了货的情况。
场景:
商品S库存余量为5时,用户A和B同时来购买一个商品,此时查询库存数都为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才对
1.2. 环境准备
建表语句:
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=1 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 https://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.2.11.RELEASE</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.atguigu</groupId><artifactId>distributed-lock</artifactId><version>0.0.1-SNAPSHOT</version><name>distributed-lock</name><description>分布式锁demo工程</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>5.1.46</version></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.Driverurl: jdbc:mysql://172.16.116.100:3306/testusername: rootpassword: rootredis:host: 172.16.116.100
DistributedLockApplication启动类:
@SpringBootApplication
@MapperScan("com.atguigu.distributedlock.mapper")
public class DistributedLockApplication {public static void main(String[] args) {SpringApplication.run(DistributedLockApplication.class, args);}}
Stock实体类:
@Data
@TableName("db_stock")
public class Stock {@TableIdprivate Long id;private String productCode;private String stockCode;private Integer count;
}
StockMapper接口:
public interface StockMapper extends BaseMapper<Stock> {
}
1.3. 简单实现减库存
接下来咱们代码实操一下。
StockController:
@RestController
public class StockController {@Autowiredprivate StockService stockService;@GetMapping("check/lock")public String checkAndLock(){this.stockService.checkAndLock();return "验库存并锁库存成功!";}
}
StockService:
@Service
public class StockService {@Autowiredprivate 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,没有任何问题。
1.4. 演示超卖现象
接下来咱们使用jmeter压力测试工具,高并发下压测一下,添加线程组:并发100循环50次,即5000次请求。
给线程组添加HTTP Request请求:
填写测试接口路径如下:
再选择你想要的测试报表,例如这里选择聚合报告:
启动测试,查看压力测试报告:
-
Label 取样器别名,如果勾选Include group name ,则会添加线程组的名称作为前缀
-
# Samples 取样器运行次数
-
Average 请求(事务)的平均响应时间
-
Median 中位数
-
90% Line 90%用户响应时间
-
95% Line 90%用户响应时间
-
99% Line 90%用户响应时间
-
Min 最小响应时间
-
Max 最大响应时间
-
Error 错误率
-
Throughput 吞吐率
-
Received KB/sec 每秒收到的千字节
-
Sent KB/sec 每秒收到的千字节
测试结果:请求总数5000次,平均请求时间37ms,中位数(50%)请求是在36ms内完成的,错误率0%,每秒钟平均吞吐量2568.1次。
查看mysql数据库剩余库存数:还有4870
此时如果还有人来下单,就会出现超卖现象(别人购买成功,而无货可发)。
1.5. jvm锁问题演示
1.5.1. 添加jvm锁
使用jvm锁(synchronized关键字或者ReetrantLock)试试:
重启tomcat服务,再次使用jmeter压力测试,效果如下:
查看mysql数据库:
并没有发生超卖现象,完美解决。
1.5.2. 原理
添加synchronized关键字之后,StockService就具备了对象锁,由于添加了独占的排他锁,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会one-by-one执行下去,也就不会发生超卖现象。
1.6. 多服务问题
使用jvm锁在单工程单服务情况下确实没有问题,但是在集群情况下会怎样?
接下启动多个服务并使用nginx负载均衡,结构如下:
启动三个服务(端口号分别8000 8100 8200),如下:
1.6.1. 安装配置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配置如下:
user nginx;
worker_processes 1;error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;events {worker_connections 1024;
}http {include /etc/nginx/mime.types;default_type application/octet-stream;log_format main '$remote_addr - $remote_user [$time_local] "$request" ''$status $body_bytes_sent "$http_referer" ''"$http_user_agent" "$http_x_forwarded_for"';access_log /var/log/nginx/access.log main;sendfile on;#tcp_nopush on;keepalive_timeout 65;#gzip on;#include /etc/nginx/conf.d/*.conf;upstream distributed {server 172.16.116.1:8000;server 172.16.116.1:8100;server 172.16.116.1:8200;}server {listen 80;server_name 172.16.116.100;location / {proxy_pass http://distributed;}}}
在浏览器中测试:172.16.116.100是我的nginx服务器地址
经过测试,通过nginx访问服务一切正常。
1.6.2. Jmeter压力测试
注意:先把数据库库存量还原到5000。
参照之前的测试用例,再创建一个新的测试组:参数给之前一样
配置nginx的地址及 服务的访问路径如下:
测试结果:性能只是略有提升。
数据库库存剩余量如下:
又出现了并发问题,即出现了超卖现象。
1.7. mysql锁演示
除了使用jvm锁之外,还可以使用数据锁:悲观锁 或者 乐观锁
-
一个sql:直接更新时判断,在更新中判断库存是否大于0
update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
-
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
select ... for update
-
乐观锁:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新进行重试。
version 或者 时间戳(CAS思想)。
1.7.1. 一个sql
略。。
1.7.2. 悲观锁
在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.atguigu.distributedlock.mapper.StockMapper"><select id="selectStockForUpdate" resultType="com.atguigu.distributedlock.pojo.Stock">select * from db_stock where id = #{id} for update</select>
</mapper>
压力测试
注意:测试之前,需要把库存量改成5000。压测数据如下:比jvm性能高很多,比无锁要低将近1倍
mysql数据库存:
1.7.3. 乐观锁
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则重试。那么我们如何实现乐观锁呢
使用数据版本(Version)记录机制实现,这是乐观锁最常用的实现 方式。一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录 的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新。
给db_stock表添加version字段:
对应也需要给Stock实体类添加version属性。此处略。。。。
代码实现
public void checkAndLock() {// 先查询库存是否充足Stock stock = this.stockMapper.selectById(1L);// 再减库存if (stock != null && stock.getCount() > 0){// 获取版本号Long version = stock.getVersion();stock.setCount(stock.getCount() - 1);// 每次更新 版本号 + 1stock.setVersion(stock.getVersion() + 1);// 更新之前先判断是否是之前查询的那个版本,如果不是重试if (this.stockMapper.update(stock, new UpdateWrapper<Stock>().eq("id", stock.getId()).eq("version", version)) == 0) {checkAndLock();}}
}
重启后使用jmeter压力测试工具结果如下:
修改测试参数如下:
测试结果如下:
说明乐观锁在并发量越大的情况下,性能越低(因为需要大量的重试);并发量越小,性能越高。
1.7.4. mysql锁总结
性能:一个sql > 悲观锁 > jvm锁 > 乐观锁
如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下。
优先选择:一个sql
如果写并发量较低(多读),争抢不是很激烈的情况下优先选择:乐观锁
如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试。
优先选择:mysql悲观锁
不推荐jvm本地锁。
1.8. redis乐观锁
利用redis监听 + 事务
watch stock
multi
set stock 5000
exec
如果执行过程中stock的值没有被其他链接改变,则执行成功
如果执行过程中stock的值被改变,则执行失败效果如下:
具体代码实现,只需要改造对应的service方法:
public void deduct() {this.redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {operations.watch("stock");// 1. 查询库存信息Object stock = operations.opsForValue().get("stock");// 2. 判断库存是否充足int st = 0;if (stock != null && (st = Integer.parseInt(stock.toString())) > 0) {// 3. 扣减库存operations.multi();operations.opsForValue().set("stock", String.valueOf(--st));List exec = operations.exec();if (exec == null || exec.size() == 0) {try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}deduct();}return exec;}return null;}});
}