分布式锁
代码已同步至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.Driver
url: jdbc:mysql://localhost:3306/distributed_lock?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
# Redis配置
redis:
host: localhost
database: 0
port: 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();
/**
* 减库存
*/
@Override
public 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);
// }
}
/**
* 重置库存数量
*/
@Override
public 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_stock
set 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();
/**
* 减库存
*/
@Override
public 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();
/**
* 减库存
*/
@Override
public 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 {
/**
* 减库存
*/
@Override
public 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 {
}
}
/**
* 重置库存数量
*/
@Override
public 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
@Transactional
public 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 {
}
}
/**
* 重置库存数量
*/
@Override
public 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 {
/**
* 减库存
*/
@Override
public 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 >=1
this.baseMapper.updateStock(1,"1001");
} finally {
}
}
/**
* 重置库存数量
*/
@Override
public 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
@Transactional
public 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 >=1
this.baseMapper.updateStock(1, "1001");
} finally {
}
}
/**
* 重置库存数量
*/
@Override
public 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
// @Transactional
public 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);
// 更新版本号,在原版本号的基础上加1
Integer 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 {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 减库存
*/
@Override
public 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 >=1
this.baseMapper.updateStock(1, "1001");
} finally {
}
}
/**
* 重置库存数量
*/
@Override
public 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
/**
* 减库存
*/
@Override
public void checkAndLock() {
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(@NotNull RedisOperations<K, V> operations) throws DataAccessException {
// watch
operations.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){
// multi
operations.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实现