目录
- 1.1 从减库存聊起
- 1.2 环境准备
- 1.3 简单实现减库存
- 1.4 演示超卖现象
- 1.5 jvm锁
- 1.6 三种情况导致Jvm本地锁失效
- 1、多例模式下,Jvm本地锁失效
- 2、Spring的事务导致Jvm本地锁失效
- 3、集群部署导致Jvm本地锁失效
- 1.7 mysql锁演示
- 1.7.1、一个sql
- 1.7.2、悲观锁
- 1.7.3、乐观锁
- 1.7.4、mysql锁总结
- 1.8 redis乐观锁
- 1.8.1 引入redis
- 1.8.2 redis乐观锁原理
- 1.8.3 redis乐观锁解决超卖问题
- 1.8.4 redis乐观锁的缺点
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;
表中数据如下:
创建分布式锁demo工程:
目录结构
pom.xml
<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.3.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
application.yml配置文件:
server.port=10010
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.239.11:3306/atguigu_distributed_lock
spring.datasource.username=root
spring.datasource.password=houchen
DistributedLockApplication启动类:
@SpringBootApplication
@MapperScan("com.atguigu.distributed.lock.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> {
}
1.3 简单实现减库存
@RestController
public class StockController {
@Autowired
private StockService stockService;
@GetMapping("stock/deduct")
public String deduct(){
this.stockService.deduct();
return "hello stock deduct!!";
}
}
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
public void deduct(){
// 先查询库存是否充足
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次请求。
启动测试,查看压力测试报告:
- 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 每秒收到的千字节
查看mysql数据库剩余库存数:还有4818
1.5 jvm锁
使用jvm锁(synchronized关键字或者ReetrantLock)试试:
/**
* 使用jvm锁来解决超卖问题
*/
public synchronized void deduct() {
// 先查询库存是否充足
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock != null && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
重启tomcat服务,再次使用jmeter压力测试,效果如下:
可以看到,加锁之后,吞吐量减少了一倍多!
查看mysql数据库:
并没有发生超卖现象,完美解决。
原理
添加synchronized关键字之后,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会one-by-one执行下去,也就不会发生超卖现象
1.6 三种情况导致Jvm本地锁失效
1、多例模式下,Jvm本地锁失效
原理:StockService有多个对象,不同的对象持有不同的锁,所以还是会有多个线程进入到 临界区 中
演示:
@Service
@Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockService {
@Autowired
private StockMapper stockMapper;
/**
* 使用jvm锁来解决超卖问题
*/
public synchronized void deduct() {
// 先查询库存是否充足
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock != null && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
}
重启tomcat服务,再次使用jmeter压力测试,查看数据库,发现库存确实没有减到 0 ,发生超卖
2、Spring的事务导致Jvm本地锁失效
在加锁的地方加上 @Transactional 注解
@Transactional
public synchronized void deduct() {
// 先查询库存是否充足
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock != null && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
重启tomcat服务,再次使用jmeter压力测试,查看数据库,发现库存确实没有减到 0 ,发生超卖
造成超卖的原因:
Spring事务默认的隔离级别是可重复读
解决办法
扩大锁的范围,将开启事务,提交事务也包括在锁的代码块中
@GetMapping("stock/deduct")
public String deduct(){
synchronized (this) {
this.stockService.deduct();
}
return "hello stock deduct!!";
}
3、集群部署导致Jvm本地锁失效
使用jvm锁在单工程单服务情况下确实没有问题,但是在集群情况下会怎样?
接下启动多个服务并使用nginx负载均衡
1)启动两个服务(端口号分别10010 10086),如下:
2)配置nginx 负载均衡
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
upstream distributed {
server localhost:10010;
server localhost:10086;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://distributed;
}
}
}
3)在post中测试:http://localhost/stock/deduct (其中80是nginx的监听端口)
请求正常,说明nginx负载均衡起作用了
4) Jmeter压力测试
注意
- 先把数据库库存量还原到5000
- 重新配置访问路径 http://localhost:80/stock/deduct
两台机器时,吞吐量明显大于单个机器
查看数据库,库存不为0,表示多服务时,Jvm锁失效
5) 原因
每个服务都有自己的本地锁,所以无法锁住临界区,导致多线程的安全问题
1.7 mysql锁演示
除了使用jvm锁之外,还可以使用mysql自带的锁:悲观锁 或者 乐观锁
1.7.1、一个sql
update db_stock set count = count - 1 where product_code = '1001' and count >= #{count}
public void deduct() {
this.stockMapper.updateStock("1001", 1);
}
public interface StockMapper extends BaseMapper<Stock> {
@Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count}")
int updateStock(@Param("productCode") String productCode, @Param("count") Integer count);
}
这种方式可以解决上述Jvm锁失效的三个问题
缺点:
1、确定好锁范围
当使用的是表锁时,会导致系统的吞吐量直线下降
什么情况下会使用行级锁
1)锁的查询或者更新条件必须是索引字段
2) 查询或者更新条件必须是具体值
2、一件商品多个仓库问题无法处理
3、无法记录仓库变化前后的状态
1.7.2、悲观锁
SELECT ... FOR UPDATE (悲观锁)
代码实现
改造StockService: 添加事务注解,去掉synchronized关键词
@Transactional
public void deduct() {
Stock stocks = this.stockMapper.queryStockForUpdate("1001");
if (stocks != null && stocks.getCount() > 0) {
stocks.setCount(stocks.getCount() - 1);
this.stockMapper.updateById(stocks);
}
}
在StockeMapper中定义selectStockForUpdate方法:
public interface StockMapper extends BaseMapper<Stock> {
@Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count}")
int updateStock(@Param("productCode") String productCode, @Param("count") Integer count);
@Select("select * from db_stock where product_code = #{productCode} for update")
Stock queryStockForUpdate(@Param("productCode") String productCode);
}
压力测试
注意:测试之前,需要把库存量改成5000。压测数据如下:比jvm锁性能高很多
mysql数据库存:
【注意】使用MySQL乐观锁时,也需要注意锁的粒度,尽量使用行级锁,否则系统吞吐量会降低
1.7.3、乐观锁
乐观锁是相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则重试。
使用数据版本(Version)记录机制实现,这是乐观锁最常用的实现 方式。一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新。
给db_stock表添加version字段:
改造 StockService
/**
* 使用MySQL乐观锁来解决库存超卖问题
*/
public void deduct() {
// 先查询库存是否充足
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock != null && stock.getCount() > 0){
// 获取版本号
Long version = stock.getVersion();
stock.setCount(stock.getCount() - 1);
// 每次更新 版本号 + 1
stock.setVersion(stock.getVersion() + 1);
// 更新之前先判断是否是之前查询的那个版本,如果不是重试
if (this.stockMapper.update(stock, new UpdateWrapper<Stock>().eq("id", stock.getId()).eq("version", version)) == 0) {
deduct();
}
}
}
重启后使用jmeter压力测试工具结果如下:
并发度比较低,说明乐观锁在并发量越大的情况下,性能越低(因为需要大量的重试);并发量越小,性能越高。
乐观锁存在的问题
- 高并发情况下,性能较低
- ABA问题
- 读写分离的情况下,可能会导致乐观锁不可靠
1.7.4、mysql锁总结
性能:一个sql > 悲观锁 > jvm锁 > 乐观锁
- 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下。
优先选择:一个sql
-
如果写并发量较低(多读),争抢不是很激烈的情况下优先选择:乐观锁
-
如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试。
优先选择:mysql悲观锁
- 不推荐jvm本地锁。
1.8 redis乐观锁
1.8.1 引入redis
见我的博客 https://blog.csdn.net/hc1285653662/article/details/127564372 中的SpringDataRedis客户端
改造StockService
/**
* 为了提高请求响应的速度,将库存放在redis中进行操作
*/
public void deduct() {
// 先查询库存是否充足
String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
Long stock = Long.parseLong(stockStr);
if (stock != null && stock > 0) {
redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
}
}
演示redis库存超卖
设置redis库存为 5000
jmeter启动测试,可以看到并发比无锁时候的mysql库存要高
查询redis库存,发现剩余库存不为0,所以发生超卖现象
1.8.2 redis乐观锁原理
使用watch命令监视某个key,如果在监视的过程中该key被某个客户端修改后,那么自身对于key的修改将会失败
1.8.3 redis乐观锁解决超卖问题
改造StockService
/**
* 为了提高请求响应的速度,将库存放在redis中进行操作
*/
public void deduct() {
// 监听 stock:1001
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.watch("stock:" + "1001");
String stockStr = (String) operations.opsForValue().get("stock:" + "1001");
Long stock = Long.parseLong(stockStr);
if (stock != null && stock > 0) {
operations.multi();
operations.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
List exec = operations.exec();
// 如果减库存失败,代表key别其他客户端修改了,则进行重试
if (exec == null || exec.size() == 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
deduct();
}
return exec;
}
return null;
}
});
}
查看测试结果:发现并发很低(可能因为我redis部署在阿里云上的docker里,网络开销导致并发很低),但是确实解决超卖问题
1.8.4 redis乐观锁的缺点
- 性能问题