概述
爲了防止分佈式系統中的多個進程之間相互干擾,我們需要一種分佈式協調技術來對這些進程進行調度。而這個分佈式協調技術的核心就是來實現這個分佈式鎖。
該博客配套代碼: https://github.com/ming1995/distributed_lock
爲什麼要使用分佈式鎖
假設有3個用戶對一個秒殺系統的商品點擊購買並且該商品的數量只有1件,如果不設置分佈式鎖的情況,會出現3個人都可能出去購買成功的情況,這種情況是系統不允許的.
例如下面情況,當庫存是100的時候,用jmeter模擬100個用戶下單,會顯示庫存一直只減少了1件.
實現分佈式鎖的技術
一 . 基於 redis的 setnx()、expire() 方法實現
使用步驟
setnx(lockkey, 1) 如果返回 0,則說明佔位失敗;如果返回 1,則說明佔位成功
expire() 命令對 lockkey 設置超時時間,爲的是避免死鎖問題。
JAVA代碼如下
1.1.引入springboot redis data jpa
1.2.applicaiton.yml文件
1.3.Controller層代碼邏輯
下面獲取鎖的時候加了嘗試獲取鎖的機制,如果不需要可以註釋掉,直接reutrn 錯誤提示
package com.ljm.redislock.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* @author Dominick Li
* @date 2020/2/20 7:03 AM
*/
@RestController
public class OrderController {
@Autowired
StringRedisTemplate stringRedisTemplate;
@RequestMapping("/deduct_stock/{productId}")
public String deductStock(@PathVariable String productId) {
String lockKey = "product_" + productId;
try {
//利用redis單線程模型去寫值,寫入成功即獲取鎖,設置30秒後失效,避免程序出現宕機情況
boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lock", 30, TimeUnit.SECONDS);
if (!result) {
//嘗試再去獲取3次鎖,如果不需要嘗試獲取鎖可以註釋了下面這段,直接返回失敗
result = deductStockCAS(lockKey, 3);
if (!result) {
return "error";
}
}
Integer stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
stock -= 1;
stringRedisTemplate.opsForValue().set("stock", stock.toString());
System.out.println("庫存扣減成功,剩餘庫存:" + stock);
return "success";
}
System.out.println("庫存不足,扣減失敗!");
return "error";
} finally {
//釋放鎖
stringRedisTemplate.delete(lockKey);
}
}
/**
* 設置要獲取的key和嘗試的次數
* 沒有獲取到鎖,通過CAS自旋
*/
public boolean deductStockCAS(String lockKey, Integer count) {
try {
int i = 0;
do {
Thread.sleep(1000L);
i++;
if (i == count + 1) {//自旋結束
return false;
}
} while (!stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lock", 30, TimeUnit.SECONDS));
return true;
} catch (Exception e) {
return false;
}
}
}
然後分別啓動2個端口8081和8080。
1.4使用 nginx做負載均衡
nginx.conf關鍵代碼如下.
然後使用jmeter再模擬100個用戶
(jmeter使用請參考我寫的 https://blog.csdn.net/ming19951224/article/details/88931209)
可以看到庫存是正常扣減的,因爲上面獲取鎖這塊嘗試的次數是3次,所以庫存少了5,
可以把嘗試的次數加多或者把嘗試過程中休眠的時間加長。
二. 基於數據庫的排它鎖
利用主鍵唯一的特性,如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那麼我們就可以認爲操作成功的那個線程獲得了該方法的鎖,當方法執行完畢之後,想要釋放鎖的話,刪除這條數據庫記錄即可。
下圖是商品庫存是5件,用jmeter模擬100個用戶請求,重數據庫扣減庫存出現的情況,爲了避免這種情況出現,加上分佈式鎖解決該問題
2.1引入依賴
<!--data jpa-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!--數據庫-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--增強處理,用於在任務調度的方法切入獲取鎖請求-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2.2 yml配置文件
正常配置數據庫的連接信息就行
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/databaselook?useSSL=false&serverTimezone=GMT%2b8&characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true
username: root
password: 123456
type: com.zaxxer.hikari.HikariDataSource
initialization-mode: always
data: classpath:data.sql
hikari:
# 連接池名稱
pool-name: MyHikariCP
#最小空閒連接,默認值10,小於0或大於maximum-pool-size,都會重置爲maximum-pool-size
minimum-idle: 10
#連接池最大連接數,默認是10
maximum-pool-size: 300
#空閒連接超時時間,默認值600000(10分鐘),大於等於max-lifetime且max-lifetime>0,會被重置爲0;不等於0且小於10秒,會被重置爲10秒。
idle-timeout: 180000
#連接最大存活時間,不等於0且小於30秒,會被重置爲默認值30分鐘.設置應該比mysql設置的超時時間短
max-lifetime: 1800000
#連接超時時間:毫秒,小於250毫秒,否則被重置爲默認值30秒
connection-timeout: 30000
#用於測試連接是否可用的查詢語句
connection-test-query: SELECT 1
jpa:
database: mysql
show-sql: false
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
enable_lazy_load_no_trans: true
2.3 JAVA代碼
只貼了關鍵代碼,model和dao層代碼請參考github地址
2.3.1 aop切入點代碼
package com.ljm.databaselook.point;
import com.ljm.databaselook.model.MethodLock;
import com.ljm.databaselook.repository.MethodLockRepository;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import java.util.Date;
/**
* @author Dominick Li
* @date 2019/7/20 8:03 AM
*/
@Aspect
@Configuration
public class TaskPointcut {
@Autowired
private MethodLockRepository methodLockRepository;
private final Logger logger= LoggerFactory.getLogger(TaskPointcut.class);
/**
* 需要加分佈式鎖的切入點
* 這裏可以指定OrderController下面的所有方法
*/
@Pointcut("execution(public * com.ljm.databaselook.controller.OrderController.*(..))")
public void methodLock(){ }
/**
* 事前處理
* 獲取鎖
* @return 成功獲取鎖,繼續執行操作,獲取鎖失敗則返回錯誤信息
*/
@Around("methodLock()")
public Object around(ProceedingJoinPoint pj){
//獲取目標方法的參數信息
String methodName="";
try {
MethodSignature signature = (MethodSignature) pj.getSignature();
methodName=signature.getMethod().getName();
// logger.info("around getLook taskName={}",methodName);
MethodLock methodLock=new MethodLock();
methodLock.setId(1);
methodLock.setMethodDesc(Thread.currentThread().getId()+"");
methodLock.setMethodName(methodName);
methodLock.setUpdateTime(new Date());
methodLockRepository.save(methodLock);
return pj.proceed();
}catch (Throwable e){
//logger.info("getLook fail methodName={}",methodName);
return "getLook fail";
}
}
/**
* 事後處理
* 釋放鎖信息
* @param joinPoint
*/
@After("methodLock()")
public void doAfterAdvice(JoinPoint joinPoint){
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName=signature.getMethod().getName();
MethodLock methodLock=methodLockRepository.findTopByMethodName(methodName);
if(methodLock!=null){
methodLockRepository.delete(methodLock);
// logger.info("doAfterAdvice unLook methodName={}",methodName);
}
}
/**
* 異常處理
* @param joinPoint
*/
@AfterThrowing("methodLock()")
public void afterThrowing(JoinPoint joinPoint){
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName=signature.getMethod().getName();
MethodLock methodLock=methodLockRepository.findTopByMethodName(methodName);
if(methodLock!=null){
methodLockRepository.delete(methodLock);
//logger.info("afterThrowing unLook methodName={}",methodName);
}
}
}
2.3.2控制器層代碼
2.4 啓動程序
商品庫存是5件。
分別啓動2個端口8081和8080。然後配置nginx負載均衡,用jmeter模擬30個用戶測試
運行結果如下,8080端口搶到了4件商品,8081搶到了1件商品
三.基於 ZooKeeper 做分佈式鎖
原理:利用臨時節點與 watch 機制。每個鎖佔用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下創建一個臨時節點,創建成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作後再去爭鎖。臨時節點好處在於當進程掛掉後能自動上鎖的節點自動刪除即取消鎖。
3.1引入zookeeper依賴
<!--zookeeper api操作依賴-->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>${curator.version}</version>
</dependency>
<!--添加zookeeper服務註冊-->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-x-discovery</artifactId>
<version>${curator.version}</version>
</dependency>
3.2yml配置文件
server:
port: 8080
zk:
url: 192.168.0.105:2181 #zookeeper服務器ip
serviceName: /service
3.3 JAVA代碼
3.3.1 註冊到zookeeper服務中心
package com.ljm.zookeeper.config;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.api.CuratorEvent;
import org.apache.curator.framework.api.CuratorEventType;
import org.apache.curator.framework.api.CuratorListener;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.x.discovery.ServiceDiscovery;
import org.apache.curator.x.discovery.ServiceDiscoveryBuilder;
import org.apache.curator.x.discovery.ServiceInstance;
import org.apache.curator.x.discovery.ServiceInstanceBuilder;
import org.apache.curator.x.discovery.details.JsonInstanceSerializer;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author lijunming
* @date 2019/3/24 6:59 PM
*/
@Configuration
public class ZookeeperConfig {
@Value("${zk.url}")
private String zkUrl;
@Value("${zk.serviceName}")
private String serviceName;
Log log = LogFactory.getLog(ZookeeperConfig.class);
/**
* 這是一個線程安全的類,可以用它完成所有的zk操作
* RetryPolicy 用於設置重連策略,當某種原因導致zk不可用的時候進行重連嘗試的策略,
* 如下設置的是1000是初始化的間隔時間,3代表重連次數
* @return CuratorClient
*/
@Bean
public CuratorFramework getCuratorFramework() {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient(zkUrl, retryPolicy);
client.getCuratorListenable().addListener(new CuratorListener() {
@Override
public void eventReceived(CuratorFramework curatorFramework, CuratorEvent curatorEvent) throws Exception {
CuratorEventType type = curatorEvent.getType();
if (type == CuratorEventType.WATCHED) {
WatchedEvent we = curatorEvent.getWatchedEvent();
Watcher.Event.EventType et = we.getType();
if(we.getType()!= Watcher.Event.EventType.None) {
log.info(et + ":" + we.getPath());
//zk得到監聽消息後,客戶端必須再設置一次監聽,才能收到後面的節點變化事件
client.checkExists().watched().forPath(we.getPath());
}
}
}
});
client.start();
//添加到服務註冊中心
try {
registerService(client);
} catch (Exception e) {
e.printStackTrace();
log.info("註冊到服務中心失敗!");
}
return client;
}
/**
* 註冊到服務中心
* @param client
* @throws Exception
*/
protected void registerService(CuratorFramework client) throws Exception {
//構造一個服務描述
log.info("registerService ....");
ServiceInstanceBuilder<Map> service = ServiceInstance.builder();
service.address("127.0.0.1"); //服務地址,如果沒調用,curator會自動設置本機地址
service.port(8080); //端口號
service.name("test"); //服務名稱
Map config = new HashMap(); //服務的配置信息
config.put("url", "/set");
service.payload(config);
ServiceInstance serviceInstance = service.build();
//basePath指定服務註冊的根節點,client指定客戶端,serializer設置序列化類,採用jackson作爲序列化類
ServiceDiscovery<Map> serviceDiscovery = ServiceDiscoveryBuilder.builder(Map.class).
client(client).serializer(new JsonInstanceSerializer<Map>(Map.class)).basePath(serviceName).build();
//服務註冊
serviceDiscovery.registerService(serviceInstance);
serviceDiscovery.start();
}
}
3.3.3 控制器層代碼
package com.ljm.zookeeper.controller;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
public class OrderController {
@Autowired
private CuratorFramework zkClient;
String lookPath = "/look/test";
AtomicInteger atomicInteger=new AtomicInteger(5);//設置庫存
/**
* 只有等鎖釋放了,別的線程才能獲取新的鎖
* @return
*/
@RequestMapping("/deduct_stock")
public String deduct_stock() {
try {
InterProcessMutex lock = new InterProcessMutex(zkClient, lookPath);
//acquire設置等待時間,下面設置的嘗試獲取鎖的時間,不設置參數默認無限等待
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
if(atomicInteger.get()>0) {
atomicInteger.set(atomicInteger.get() - 1);
System.out.println("購買成功,剩餘庫存爲:" + atomicInteger.get());
return "success";
}
System.out.println("庫存不足:" + atomicInteger.get());
} finally {
//釋放鎖
lock.release();
}
}
return "error";
} catch (Exception ex) {
ex.printStackTrace();
return "error";
}
}
}
zookeeper測試分佈式鎖用的是局部變量,大家可以根據上面2個案例,把庫存放在數據庫 或者redis裏再測試。
測試的話也是用jmeter測試。