1. 為什么需要鎖
在并發場景下,多個進程/線程同時對同一個資源進行訪問時,會產生沖突。
舉個例子:核酸采樣時,如果一次100個人同時要求大白進行采樣(并發),那么大白就要崩潰了,所以必須要控制一個大白一次只能對一個人采樣,其他人等待采樣完成,這就是對大白進行”加鎖”。
2. 鎖是用來解決什么問題的
鎖是用來解決并發問題的,如:
- 多個線程并發訪問同一個資源
- 分布式系統中不同模塊對同一資源進行修改
鎖的使用場景:
- 秒殺
- 搶紅包
- 庫存更新
3. 分布式鎖的解決方案
- 通過數據庫實現(利用數據庫唯一約束的特性實現)
- 通過Zookeeper實現(利用zookeeper的唯一節點特性或者有序臨時節點特性獲得最小節點作為鎖)
- 通過redis實現(setNx命令)
4 通過Redis實現分布式鎖示例
通過JAVA程序連接Redis,提示以下錯誤:
redis.clients.jedis.exceptions.JedisConnectionException: Failed to connect to any host resolved for DNS name.
at redis.clients.jedis.DefaultJedisSocketFactory.connectToFirstSuccessfulHost(DefaultJedisSocketFactory.java:63)
at redis.clients.jedis.DefaultJedisSocketFactory.createSocket(DefaultJedisSocketFactory.java:87)
at redis.clients.jedis.Connection.connect(Connection.java:180)
at redis.clients.jedis.Connection.initializeFromClientConfig(Connection.java:338)
可能有如下原因:
- Redis未啟動
- Redis IP地址或者端口不對
- Redis不允許遠程連接
4.1 配置允許遠程連接Redis
4.1.1 開放Redis端口(6379)
//查看6379端口狀態 mo表示未開放
[root@192 bin]# firewall-cmd --zone=public --query-port=6379/tcp
no
//配置放行6379端口
[root@192 bin]# firewall-cmd --zone=public --add-port=6379/tcp --permanent
success
//防火墻重載
[root@192 bin]# firewall-cmd --reload
success
//再次查看端口狀態
[root@192 bin]# firewall-cmd --zone=public --query-port=6379/tcp
yes
4.1.2 修改redis.conf配置文件
[root@192 redis]# vim redis.conf
將配置改成如下所示:
# 允許任何主機連接、訪問
bind 0.0.0.0
# 關閉保護模式
protected-mode no
# 允許啟動后在后臺運行,即關閉命令行窗口后仍能運行
daemonize yes
4.2 模擬秒殺下單減庫存的場景
4.2.1 新建以下商品表(t_goods)與訂單表(t_order)
DROP TABLE IF EXISTS `t_goods`;
CREATE TABLE `t_goods` (
`id` int(11) NOT NULL,
`name` varchar(60) DEFAULT NULL,
`qty` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
--初始化數量qty=20
INSERT INTO `t_goods` VALUES ('1', '華為nova7', '20');
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order` (
`oid` varchar(120) NOT NULL,
`createtime` datetime DEFAULT NULL,
`goodname` varchar(255) DEFAULT NULL,
`user` varchar(120) DEFAULT NULL,
PRIMARY KEY (`oid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
4.2.2 編寫main方法,同時啟動10000條線程模擬秒殺
public static void main(String[] args) throws ParseException {
//設置秒殺開始時間
String startTime="2022-4-27 23:24:00";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date start=simpleDateFormat.parse(startTime);
System.out.println("等待中...");
boolean isStart=false;
while (!isStart) {
if (start.compareTo(new Date()) < 0) {
isStart=true;
System.out.println("秒殺開始...");
//同時啟動10000條線程
CountDownLatch countDownLatch = new CountDownLatch(10000);
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
try {
countDownLatch.await();
//secKillGoodsByRedisLock();//Redis鎖
secKillGoods(); //未加鎖
// secKillGoodsByLock();//synchronized
} catch (InterruptedException | SQLException e) {
e.printStackTrace();
}
}).start();
countDownLatch.countDown();
}
}
}
}
4.2.3 未加鎖的情況
private static void secKillGoods() throws SQLException {
List list = DButil.query("select id,name,qty from t_goods where id=1 and qty>0");
//判斷是否還有庫存
if(list.size()>0){
String id=JedisUtil.getId();
Object[] insertObject={id,new Date(),"nova7",Thread.currentThread().getName()};
DButil.excuteDML("update t_goods set qty=qty-1 where id=1 ");
DButil.excuteDML("insert into t_order values(?,?,?,?);",insertObject);
System.out.println(Thread.currentThread().getName()+">>>搶到了."+id);
}
}
以上代碼每次執行都會先判斷是否還有庫存,如果有庫存則秒殺成功,否則秒殺失敗,沒有并發的情況下是可以正常運行的,但是一旦存在并發,則會出現負庫存(超賣)
4.2.4 通過synchronized關鍵字對代碼塊加鎖
private static void secKillGoodsByLock() throws SQLException {
synchronized (lockObj) {
secKillGoods();
}
}
以上程序在進入秒殺方法時,都會通過synchronized關鍵字加鎖,再次運行程序,我們發現不會出現負庫存了
但是如果在多進程或者分布式環境中,synchronized關鍵字會失效,讓我們再啟動一個進程,兩個進程同時啟動100000個線程進行秒殺(ps:同時啟動十萬個線程差點讓我的電腦沒緩過來…),終于出現了負庫存的現象
4.2.5 Redis鎖
private static void secKillGoodsByRedisLock(){
String id="1";
String key="lock"+id;
JedisUtil jedisUtil=new JedisUtil();
String lockId=jedisUtil.getLock(key,5);
if(null!=lockId){
try{
List list= DButil.query("select id,name,qty from t_goods where id=1 and qty>0");
if(list!=null && list.size()>0){
Object[] insertObject={lockId,new Date(),"nova7",Thread.currentThread().getName()};
DButil.excuteDML("update t_goods set qty=qty-1 where id=1 ");
DButil.excuteDML("insert into t_order values(?,?,?,?);",insertObject);
System.out.println(Thread.currentThread().getName()+">>>搶到了."+lockId);
jedisUtil.unLock(key,lockId);
}else {
System.out.println("搶完了");
}
} catch (SQLException e) {
jedisUtil.unLock(key,lockId);
e.printStackTrace();
}
}
}
以上代碼只有在獲取到Redis鎖成功后,才會去執行扣庫存和下單的邏輯,重復和上一步一樣,兩個進程同時啟動100000個線程進行秒殺,看看結果
以上結果沒有出現負庫存的現象,顯然是扛住了“秒殺”,getLock 的實現如下所示,其原理就是利用Redis setnx的原子性操作來控制并發,以下示例還設置了鎖失效的時間,避免死鎖。當然還有許多的問題需要在實際應用場景中考慮,如在鎖失效時間到了,秒殺動作未完成如何處理,Redis服務器崩潰了怎么辦等等。
public String getLock(String key,int timeout){
Jedis jedis=null;
try {
jedis=getJedis();
String value=getId();
long end=System.currentTimeMillis()+timeout;
while (System.currentTimeMillis()<end) {
//設置value成功,獲取鎖
if(jedis.setnx(key,value)==1){
//設置失效時間
jedis.expire(key,timeout);
System.out.println(Thread.currentThread().getName()+">>>>>獲取鎖成功。");
return value;
}
//當 key 存在但沒有設置剩余生存時間時
if(jedis.ttl(key)==-1){
//設置失效時間
jedis.expire(key,timeout);
}
}
} catch (Exception e) {
e.printStackTrace();
}finally {
if(null!=jedis){
jedis.close();
}
}
return null;
}
參考文獻:
為什么需要鎖,鎖分類,鎖粒度
各種鎖以及使用場景