02_基于mysql实现锁机制
基于mysql实现锁机制
改造库存扣减情况从mysql中扣减:
新建数据库distributed以及创建库存表:
/* Navicat MySQL Data Transfer Source Server : centos Source Server Version : 50716 Source Host : 172.16.116.100:3306 Source Database : distributed_lock Target Server Type : MYSQL Target Server Version : 50716 File Encoding : 65001 Date: 2022-07-25 21:44:16 */ SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- -- Table structure for tb_lock -- ---------------------------- DROP TABLE IF EXISTS `tb_lock`; CREATE TABLE `tb_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `lock_name` varchar(50) NOT NULL, `lock_time` datetime NOT NULL, `server_id` varchar(255) NOT NULL, `thread_id` int(11) NOT NULL, `count` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_unique` (`lock_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of tb_lock -- ---------------------------- -- ---------------------------- -- Table structure for tb_stock -- ---------------------------- DROP TABLE IF EXISTS `tb_stock`; CREATE TABLE `tb_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_code` varchar(20) NOT NULL, `warehouse` varchar(20) NOT NULL, `count` int(11) NOT NULL, `version` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_pc` (`product_code`) ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of tb_stock -- ---------------------------- INSERT INTO `tb_stock` VALUES ('1', '1001', '北京仓', '0', '5009'); INSERT INTO `tb_stock` VALUES ('2', '1001', '上海仓', '4999', '0'); INSERT INTO `tb_stock` VALUES ('3', '1002', '深圳仓', '4997', '0'); INSERT INTO `tb_stock` VALUES ('4', '1002', '上海仓', '5000', '0');
引入mysql,mybatis-plus相关依赖:
<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> </dependency>
配置数据库连接信息:
spring: datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/distributed username: root password: 123
在启动类上增加配置信息:
@SpringBootApplication @MapperScan("com.yyj.distributed.lock.mapper") public class DistributedLockApplication { public static void main(String[] args) { SpringApplication.run(DistributedLockApplication.class, args); } }
实体类添加注解信息:
@Data @TableName("tb_stock") public class Stock { @TableId private Long id; private String productCode; private String warehouse; private Integer count; }
增加mapper接口:
@Mapper public interface StockMapper extends BaseMapper<Stock> { }
service层代码改造:
public void deduct() { Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001")); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); stockMapper.updateById(stock); } }
使用jmeter进行并发压力测试:
吞吐量大致在760左右:
库存扣减情况在并发的情况下出现问题:
解决方案:
使用jdk提供的synchronized关键字或ReentrantLock锁来处理。
public void deduct() { try { lock.lock(); Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001")); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); stockMapper.updateById(stock); } } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } }
系统每秒钟平均吞吐量下降为548次:
以下三种情况可能会导致本地锁失效:
1、多例模式:
讲service改为原型bean之后,锁对象用的不是同一把锁故本地锁失效:
@Service @Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS) public class StockService {
库存扣减有误,吞吐量再570左右:
2、事务:加上事务注解@Transactional之后会导致本地锁失效:
@Transactional public void deduct() { try { lock.lock(); Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001")); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); stockMapper.updateById(stock); } } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); try { Thread.sleep(20); } catch (Exception ignore) { } } }
库存扣减出现问题:
失效原理分析:
当A用户释放锁但未提交事务时,B用户获取锁成功并查询到库存为未提交的库存数量,则在并发的情况下,事务操作会导致本地锁失效。
3、集群部署的情况下多服务使本地锁失效:
多服务的情况下,不同的服务实例使用的都不是同一把锁,故本地锁会失效,与多例模式类似。
修改端口号启动两个服务:
-Dserver.port=10020
修改nginx配置进行反向代理,负载均衡:
增加upstream:
upstream distributed { server 127.0.0.1:10010; server 127.0.0.1:10020; }
配置反向代理:
location / { proxy_pass http://distributed; }
之后启动nginx服务器进行访问:http://127.0.0.1/stock/deduct
linux环境下安装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
使用jemeter进行测试:
集群部署的情况下,本地锁机制会失效,库存扣减情况发生异常:
以上三种情况会导致jvm本地锁失效,故在使用jvm本地锁的情况下,要避免以上情况的发生。
使用Mysql相关锁机制解决并发问题:
1、使用Mysql自带的行锁来完成库存扣减:
原理:在sql语句中直接更新时判断,在更新中判断库存是否大于0。
update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
dao层添加接口:updateStock
@Update("update tb_stock set count = count - #{count} where count >= #{count} and product_code = #{productCode}") int updateStock(@Param("count") int count, @Param("productCode") String productCode);
启动两台实例,启动nginx,做负载均衡:
使用jmeter测试集群情况下是否保证了库存扣减的线程安全的问题:
测试结果:库存扣减情况正常,吞吐量也非常的不错在3000左右,且解决了以上三种本地锁失效的情况(原型模式、事务、集群部署情况)。
产生的问题:
1、锁范围问题:
在没有加索引的情况下,默认会使用表级锁,在更新其中一条记录时,会将整张表进行加锁,使得其他商品的库存扣减阻塞。
使用行锁的条件:
1.锁的查询或者更新条件必须是索引字段
2.查询或者更新条件必须是具体值(要保证索引不能失效且使用到了索引)。
2、商品编码相同时,对应多条记录的情况下,无法使用一条sql进行处理。
3、无法记录库存前后变化的日志情况。
2、悲观锁:
原理:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
select ... for update
增加dao层接口queryStock:
@Select("select * from tb_stock where product_code = #{productCode} for update") List<Stock> queryStock(@Param("productCode") String productCode);
改造service层代码:
@Transactional public void deduct() { List<Stock> stocks = stockMapper.queryStock("1001"); Stock stock; if (!CollectionUtils.isEmpty(stocks)) { // 可以通过数据模型来判断扣减某个仓库的 stock = stocks.get(0); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); } int update = stockMapper.updateById(stock); } }
使用jmeter进行并发压力测试,吞吐量在1000左右,库存扣减正常,且解决一条sql语句扣减库存存在的问题,同一个商品码对应多条商品问题以及无法记录库存前后变化日志信息问题,但性能会下降:
死锁问题:
加锁的顺序需要保持一致。如:线程A对1001商品查询并使用悲观锁进行加锁但事务未提交且释放锁,同时线程B对1002商品查询并使用悲观锁进行加锁但事务未提交且释放锁,此时若出现线程A需要对1002进行查询并加锁,线程B需要对1001进行查询并加锁则会出现死锁问题。
库存操作问题:
所有对库存数据有影响的操作都应该使用for update语句进行库存操作,若不使用for update语句进行加锁则会对库存操作产生并发问题。
3、乐观锁:
原理:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新进行重试,version 或者 时间戳(CAS思想)。
实现方式:
数据库中增加字段:version
service实现方式:
/** 乐观锁方式实现 **/ @Transactional public void deduct() { List<Stock> stocks = stockMapper.selectList(new QueryWrapper<Stock>().eq("product_code", "1001")); Stock stock; if (!CollectionUtils.isEmpty(stocks)) { // 可以通过数据模型来判断扣减某个仓库的 stock = stocks.get(0); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); Integer version = stock.getVersion(); stock.setVersion(version + 1); int update = stockMapper.update(stock, new UpdateWrapper<Stock>().eq("id",stock.getId()).eq("version", version)); if (update <= 0) { deduct(); } } } }
使用jmeter测试结果,大部分请求出现异常报错情况:
查看报错信息:
栈内存溢出情况:因为在更新失败后会不断的进行递归重试,所以在导致栈内存溢出的情况。
递归调用也会加入同一个事务中,事务更新操作在同一个事务中迟迟不能没有提交成功(跟事务传播机制有关),导致数据库连接超时:
解决方式去除事务注解以及失败重试增加间隔时间:
/** 乐观锁方式实现 **/ // @Transactional public void deduct() { List<Stock> stocks = stockMapper.selectList(new QueryWrapper<Stock>().eq("product_code", "1001")); Stock stock; if (!CollectionUtils.isEmpty(stocks)) { // 可以通过数据模型来判断扣减某个仓库的 stock = stocks.get(0); if (stock != null && stock.getCount() > 0) { stock.setCount(stock.getCount() - 1); Integer version = stock.getVersion(); stock.setVersion(version + 1); int update = stockMapper.update(stock, new UpdateWrapper<Stock>().eq("id",stock.getId()).eq("version", version)); if (update <= 0) { try { Thread.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); } deduct(); } } } }
使用jmeter测试工具进行压力测试:
乐观锁在并发越高的情况下吞吐量越低,库存扣减正常。
存在问题:
1、并发高的情况下重试次数会很多,对CPU资源造成了浪费。
2、ABA问题:版本号字段的值若出现重复的情况下则会造成数据的错误。
3、在读写分离的情况下,由于主从数据库的同步存在网络延时,则可能导致从数据库读取到的数据并不是最新的数据。
总结:
性能:一个sql > 悲观锁 > jvm锁 > 乐观锁
如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下:优先选择:sql中处理,使用行锁。
如果写并发量较低(多读),争抢不是很激烈的情况下优先选择:乐观锁。
如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试: 优先选择mysql悲观锁。
不推荐jvm本地锁。