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;}});
}