分布式锁
代码已同步至GitCode:https://gitcode.net/ruozhuliufeng/distributed-project.git
在应用开发中,特别是Web工程开发,通常都是并发编程,不是多进程就是多线程。这种场景下极其容易出现线程并发性问题,此时不得不使用锁来解决问题。在多线程高并发场景下,为了保证资源的线程安全问题,jdk为我们提供了synchronized关键字和ReentrantLock可重入锁,但是它们只能提供一个工程内的线程安全。在分布式集群、微服务、云原生横行的当下,如何保证不同进程、不同服务、不同机器的线程安全问题,JDK并没有给我们提供既有的解决方案。此时,我们就必须借助于相关技术手动实现了。目前主流的实现有以下方式:
- 基于MySQL关系型实现
- 基于Redis非关系型数据实现
- 基于Zookeeper/etcd实现
问题引入
从减库存说起
多线程并发安全问题最典型的代表就是超卖现象。
库存存在并发量较大情况下,很容易发生超卖现象,一旦发生超卖现象,就会出现多成交了订单而发不了货的情况。
场景:
商品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才对。
环境准备
- 数据库:MySQL 5.7
- JAVA版本:1.8
- 工程构建工具:Maven
- 框架:SpringBoot、SpringMVC、MyBatis-Plus、SpringDataRedis
- 开发工具:IDEA
- 缓存服务:Redis
- 负载均衡工具:Nginx
- 接口与压测工具:Jmeter
创建基础数据表
- 创建数据库表:db_stock
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;
- 新增测试数据
INSERT INTO `distributed_lock`.`db_stock` (`id`, `product_code`, `stock_code`, `count`) VALUES (1, '1001', '001', 5000);
创建分布式锁demo工程
- 使用IDEA新建SpringBoot项目,本次测试项目名:distributed-lock
- 更新pom.xml文件,新增相关依赖
<dependencies><!-- Spring --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><!--springboot默认使用内置tomcat,需要手动排除然后引入undertow(各方面性能更好,更稳定) --><exclusions><exclusion><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-tomcat</artifactId></exclusion></exclusions></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-undertow</artifactId></dependency><!-- MySQL --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.28</version></dependency><!-- MyBatis Plus --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.2</version></dependency><!-- Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency></dependencies>
- 创建application.yml文件,配置项目信息
server:# 端口port: 8001
spring:# 数据库datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/distributed_lock?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghaiusername: rootpassword: root# Redis配置redis:host: localhostdatabase: 0port: 6379
- 启动类新增Mapper包扫描
@SpringBootApplication
@MapperScan("tech.msop.distributed.lock.mapper")
public class DistributedLockApplication {public static void main(String[] args) {SpringApplication.run(DistributedLockApplication.class, args);}
}
- 新增实体类:StockEntity
/*** 库存信息实体*/
@Data
@TableName("db_stock")
public class StockEntity {/*** 主键ID*/@TableId(value = "id", type = IdType.AUTO)private Integer id;/*** 商品编号*/private String productCode;/*** 仓库编号*/private String stockCode;/*** 库存量*/private Integer count=5000;
}
- 新增Mapper接口:StockMapper
public interface StockMapper extends BaseMapper<StockEntity> {
}
-
新增Service服务:StockService
- IStockService
public interface IStockService extends IService<StockEntity> { }
- StockServiceImpl
@Service public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService { }
-
新增控制器:StockController
@RequestMapping("/stock")
@RestController
@RequiredArgsConstructor
public class StockController {private final IStockService stockService;}
- 基础项目结构如下:
简单实现减库存
- 修改StockController
package tech.msop.distributed.lock.controller;import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.msop.distributed.lock.service.IStockService;/*** 库存 控制器*/
@RequestMapping("/stock")
@RestController
@RequiredArgsConstructor
public class StockController {private final IStockService stockService;/*** 减库存* @return*/@GetMapping("/check/lock")public String checkAndLock(){stockService.checkAndLock();return "验证库存并锁库存成功";}/*** 库存重置*/@GetMapping("/reset")public void reset(){stockService.reset();}}
- 修改StockService
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;/*** 库存服务实现类*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {private StockEntity stock = new StockEntity();/*** 减库存*/@Overridepublic void checkAndLock() {stock.setCount(stock.getCount() - 1);log.info("库存余量:{}",stock.getCount());
// // 先查询库存是否充足
// StockEntity stock = this.getById(1);
// // 再减1个库存
// if (stock != null && stock.getCount() >0 ){
// stock.setCount(stock.getCount() - 1);
// this.updateById(stock);
// }}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
- 修改StockMapper
package tech.msop.distributed.lock.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.msop.distributed.lock.entity.StockEntity;public interface StockMapper extends BaseMapper<StockEntity> {void reset(@Param("count") Integer defaultStockCount);
}
- 修改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="tech.msop.distributed.lock.mapper.StockMapper"><update id="reset">update db_stockset count = #{count}</update>
</mapper>
- 接口调用并测试
- 查看控制台
使用接口一次一次调用时,每访问一次,库存量减1,没有任何问题。
简单演示超卖现象
使用Jmeter压力测试工具,高并发下压测一下。恢复库存数为5000,添加线程组:并发100循环50次,即5000次请求。
给线程组添加HTTP Request请求
添加测试接口与请求路径
选择想要的测试报表,这里选择聚和报告:
启动测试,查看压力测试报告
- Label 取样器别名,如果勾选Include Group Name,则会添加线程组的名称作为前缀
- # Samples 取样器运行测试
- Average 请求(事务)的平均响应时间
- Median 中位数
- 90%Line 90%用户响应时间
- 95%Line 95%用户响应时间
- 99%Line 99%用户响应时间
- Min 最小响应时间
- Max 最大响应时间
- Error 错误率
- Throughput 吞吐率
- Received KB/sec 每秒收到的千字节
- Sent KB/sec 每秒发送的千字节
测试结果:请求总数5000次,平均请求时间54ms,中位数(50%)请求在26ms内完成的,错误率0%,每秒钟平均吞吐率1396.3次。
查看数据库剩余库存数:461
此时如果还有人来下单,就会出现超卖现象(别人购买成功,而无货可发)。
传统锁处理
JVM本地锁处理
使用JVM锁:synchronized关键字
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;/*** 库存服务实现类*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {private StockEntity stock = new StockEntity();/*** 减库存*/@Overridepublic synchronized void checkAndLock() {stock.setCount(stock.getCount() - 1);log.info("库存余量:{}",stock.getCount());}
}
Jmeter压测测试报告:
库存余量:0
使用JVM锁:ReetrantLock
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {private StockEntity stock = new StockEntity();private ReentrantLock lock = new ReentrantLock();/*** 减库存*/@Overridepublic synchronized void checkAndLock() {lock.lock();try{stock.setCount(stock.getCount() - 1);log.info("库存余量:{}",stock.getCount());}finally {lock.unlock();}}
}
Jmeter压测测试报告:
库存余量:0
原理
添加了synchronized关键字后,StockService就具备了对象锁,由于添加了独占的排他锁,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会one-by-one执行下去,也就不会发生超卖现象。
JVM本地锁失效场景之:多例模式
Service添加多例模式注解,并进行压力测试
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类 <br/>* 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>* SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS*/
@Service
@Slf4j
@Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {/*** 减库存*/@Overridepublic synchronized void checkAndLock() {try {// 先查询库存是否充足StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));// 再减1个库存if (stock != null && stock.getCount() > 0) {stock.setCount(stock.getCount() - 1);this.updateById(stock);}} finally {}}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
查看数据库余量:4846
JVM本地锁已失效
JVM本地锁失效场景之:事务
更新库存余量为5000
请求方法添加事务注解,并进行压力测试
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {/*** 减库存* 添加事务注解*/@Override@Transactionalpublic synchronized void checkAndLock() {try {// 先查询库存是否充足StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));// 再减1个库存if (stock != null && stock.getCount() > 0) {stock.setCount(stock.getCount() - 1);this.updateById(stock);}} finally {}}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
查看数据库库存余量:14
JVM本地锁已失效
JVM本地锁失效场景之:集群部署
修改库存余量为5000
复制启动类,并命名为DistributedLockApplication2,修改启动类的端口号为8002
启动复制的服务:
编辑Nginx的配置文件nginx.conf文件,实现负载均衡
worker_processes 1;events {worker_connections 1024;
}http {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 D:/Program/Nginx/access.log main;sendfile on;#tcp_nopush on;keepalive_timeout 65;#gzip on;upstream distributedLock{server localhost:8001;server localhost:8002;}server{listen 80;server_name localhost;location / {proxy_pass http://distributedLock;}}include D:/Program/Nginx/conf/conf.d/*.conf;}
启动Nginx,修改Jmeter的HTTP请求,端口修改为80,并再次进行压力测试,查看数据库余量:2012
JVM本地锁机制已失效
单SQL语句处理
在更新数量时进行判断
可以解决JVM本地锁失效的场景
更新服务代码:
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类 <br/>* 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>* SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {/*** 减库存*/@Overridepublic void checkAndLock() {try {// 1.先查询库存是否充足
// StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
// // 2.判断库存余量
// if (stock != null && stock.getCount() > 0) {
// stock.setCount(stock.getCount() - 1);
// // 3.更新到数据库
// this.updateById(stock);
// }// update insert delete写操作本身就会加锁// 使用一条SQL语句完成减库存操作// update db_stock set count = count - 1 where product_code = '1001' and count >=1this.baseMapper.updateStock(1,"1001");} finally {}}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
更新Mapper代码:
package tech.msop.distributed.lock.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import tech.msop.distributed.lock.entity.StockEntity;public interface StockMapper extends BaseMapper<StockEntity> {void reset(@Param("count") Integer defaultStockCount);@Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count} ")void updateStock(@Param("count") int count,@Param("productCode") String productCode);
}
进行压力测试,并查看数据库余量:0
存在的问题
- 锁范围的问题
- 同一个商品可能有多条库存记录
- 无法记录库存变化前后的状态
MySQL悲观锁
select … for update
在MySQL的InnoDB中,预设的Transaction 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
代码实现
新增数据库数据:
修改服务类:
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.List;
import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类 <br/>* 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>* SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {/*** 减库存*/@Override@Transactionalpublic void checkAndLock() {// 1. 查询库存信息并锁定库存信息List<StockEntity> list = this.baseMapper.queryStock("1001");// 这里取第一个库存if (CollectionUtils.isEmpty(list)) {return;}StockEntity stock = list.get(0);// 2. 判断库存是否充足if (stock != null && stock.getCount() > 0) {stock.setCount(stock.getCount() - 1);// 3.更新到数据库this.updateById(stock);}}public void checkAndLock2() {try {// 1.先查询库存是否充足
// StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
// // 2.判断库存余量
// if (stock != null && stock.getCount() > 0) {
// stock.setCount(stock.getCount() - 1);
// // 3.更新到数据库
// this.updateById(stock);
// }// update insert delete写操作本身就会加锁// 使用一条SQL语句完成减库存操作// update db_stock set count = count - 1 where product_code = '1001' and count >=1this.baseMapper.updateStock(1, "1001");} finally {}}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
修改Mapper文件:
package tech.msop.distributed.lock.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import tech.msop.distributed.lock.entity.StockEntity;import java.util.List;public interface StockMapper extends BaseMapper<StockEntity> {void reset(@Param("count") Integer defaultStockCount);@Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count} ")void updateStock(@Param("count") int count,@Param("productCode") String productCode);@Select("select * from db_stock where product_code = #{productCode} for update")List<StockEntity> queryStock(@Param("productCode") String productCode);
}
进行压力测试并查询数据库余量:0
MySQL悲观锁中使用行级锁
- 锁的查询或者更新条件必须是索引字段
- 查询或者更新条件必须是具体值(如=、in,但like、!=条件均不可以,悲观锁仍是表级锁)
优缺点
- 优点:
- 解决同一个商品有多条库存记录同时更新的问题
- 可以记录库存变化前后的状态
- 缺点:
- 性能问题
- 死锁问题:对多条数据加锁时,加锁顺序要一致
- 库存操作要统一:select … for update 普通的select
MySQL乐观锁
借助时间戳/version版本号/CAS机制实现
- CAS:Compare And Swap(Set),比较并交换
- 变量K 旧值A 新值B
- 如用户更新密码,输入旧密码 A 与新密码 B,根据用户名 K 判断用户密码与旧密码是否一致,若一致,更新为新密码,否则放弃本次修改
- 每次更新时,更新库存时同时更新新的时间戳/版本号,并判断时间戳/版本号是否与查询时的数据一致
数据库表新增字段version
ALTER TABLE `distributed_lock`.`db_stock`
ADD COLUMN `version` int(11) NULL DEFAULT 0 COMMENT '版本号' AFTER `count`;
实体类同步新增字段:version
/*** 版本号*/private Integer version;
改造Service服务
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.List;
import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类 <br/>* 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>* SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {/*** 减库存* 乐观锁不要使用事务注解*/@Override
// @Transactionalpublic void checkAndLock() {// 1. 查询库存信息List<StockEntity> list = this.list(new QueryWrapper<StockEntity>().eq("product_code","1001"));// 这里取第一个库存if (CollectionUtils.isEmpty(list)) {return;}StockEntity stock = list.get(0);// 2. 判断库存是否充足if (stock != null && stock.getCount() > 0) {// 3.更新到数据库stock.setCount(stock.getCount() - 1);// 更新版本号,在原版本号的基础上加1Integer version = stock.getVersion();stock.setVersion(version + 1);// 判断是否更新成功,更新失败则递归调用,直至保证更新成功// true 表示更新行数不为null且大于等于1,false 表示更新失败boolean result = this.update(stock,new UpdateWrapper<StockEntity>().eq("id",stock.getId()).eq("version",version));if (!result){// 避免栈内存溢出try{Thread.sleep(20);}catch (InterruptedException e){e.printStackTrace();}this.checkAndLock();}}}}
使用Jmeter进行压力测试,并查询数据库余量:0
注意:
- 若需要递归调用确保数据更新成功,不要使用事务注解
- MDL(更新、删除、新增)语句会自动加锁,重复调用可能会导致阻塞
- 若需要递归调用确保数据更新成功,需要线程休眠一段时间,避免栈内存溢出
缺点
- 高并发情况下,性能极低
- ABA问题
- 用户1查询数据X=A
- 用户2更新数据X=B
- 用户3更新数据X=C
- 用户4更新数据X=A
- 用户1更新数据时判断X是否等于A,若相同,更新X=S
- 虽然X仍然等于A,但数据变更过
- 读写分离情况下导致乐观锁不可靠
- 写数据到主服务器,从服务器读取数据
MySQL锁总结
- 性能:单SQL>悲观锁>JVM锁>乐观锁
- 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下
- 优先选择:单SQL
- 如果写并发量较低(多读),争论不是很激烈的情况:
- 优先选择:乐观锁
- 如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试
- 优先选择:悲观锁
- 不推荐JVM本地锁
Redis乐观锁
更新Redis中的库存
在Redis中新增库存:
$ set stock 5000
更新StockService服务
package tech.msop.distributed.lock.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;import java.util.List;
import java.util.concurrent.locks.ReentrantLock;/*** 库存服务实现类 <br/>*/
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>implements IStockService {@Autowiredprivate StringRedisTemplate redisTemplate;/*** 减库存*/@Overridepublic void checkAndLock() {// 1. 查询库存信息String stock = redisTemplate.opsForValue().get("stock");// 2. 判断库存是否充足if (stock != null && stock.length() != 0){Integer st = Integer.valueOf(stock);if (st > 0){// 3.更新到数据库redisTemplate.opsForValue().set("stock",String.valueOf(--st));}}}public void checkAndLock2() {try {// 1.先查询库存是否充足
// StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
// // 2.判断库存余量
// if (stock != null && stock.getCount() > 0) {
// stock.setCount(stock.getCount() - 1);
// // 3.更新到数据库
// this.updateById(stock);
// }// update insert delete写操作本身就会加锁// 使用一条SQL语句完成减库存操作// update db_stock set count = count - 1 where product_code = '1001' and count >=1this.baseMapper.updateStock(1, "1001");} finally {}}/*** 重置库存数量*/@Overridepublic void reset() {this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);}
}
使用Jmeter进行压力测试,并查询库存余量
Redis乐观锁
watch:可以监控一个或多个key的值,如果在事务执行(exec)之前,key的值发生拜年话,则取消事务执行
multi:开启事务
exec:执行事务
利用Redis监听+事务
$ watch stock
$ multi
$ set stock 5000
$ exec
如果执行过程中,stock的值没有被其他链接改变,则执行成功
如果执行过程中stock的值被改变,则执行失败
更新StockService
/*** 减库存*/@Overridepublic void checkAndLock() {redisTemplate.execute(new SessionCallback<Object>() {@Overridepublic <K, V> Object execute(@NotNull RedisOperations<K, V> operations) throws DataAccessException {// watchoperations.watch((K) "stock");// 1. 查询库存信息String stock = (String) operations.opsForValue().get("stock");// 2. 判断库存是否充足if (stock != null && stock.length() != 0){Integer st = Integer.valueOf(stock);if (st > 0){// multioperations.multi();// 3.更新到数据库operations.opsForValue().set((K) "stock", (V) String.valueOf(--st));// exec 执行事务List<Object> exec = operations.exec();// 如果执行事务的返回结果集为空,则代表减库存失败,重试if (exec ==null || exec.size() == 0){try {Thread.sleep(40);} catch (InterruptedException e) {throw new RuntimeException(e);}checkAndLock();}return exec;}}return null;}});}
使用Jmeter进行压力测试并查询库存余量:0
缺点
- 性能问题
- 由于运行机器的性能问题,可能导致连接数不够用
分布式锁
跨进程、跨服务、跨服务器
分布式锁的应用场景:
- 超卖现象(NoSQL)
- 缓存击穿
分布式锁的实现方式:
- 基于Redis实现
- 基于Zookeeper/etcd实现
- 基于MySQL实现