Spring-boot 集成Redis應(yīng)用(二) --分布式鎖以及壓測介紹
一.基礎(chǔ)環(huán)境
jdk 1.8
maven 3.5.3
spring-boot 2.0.4
redis 4.0.11
-
ApacheBench 2.3
以上工具需要提前安裝以及熟悉基本操作,在本文中不會(huì)講解如何安裝
二.基本介紹
相信各位小伙伴在學(xué)習(xí)Redis時(shí),都了解到Redis不僅僅是一個(gè)內(nèi)存中的數(shù)據(jù)結(jié)構(gòu)存儲(chǔ)系統(tǒng),它可以用作數(shù)據(jù)庫、緩存和消息中間件。上一篇集成應(yīng)用已經(jīng)簡單的介紹了 Redis作為消息隊(duì)列配合基于Servlet 3的異步請(qǐng)求處理的簡單示例。本篇將介紹Redis在單機(jī)部署的場景下的分布式鎖。分布式鎖的思想來源于Redis官網(wǎng),下面給出中文翻譯相當(dāng)棒鏈接,方便大家了解分布式鎖。《Redis官方文檔》用Redis構(gòu)建分布式鎖。在這就不講解中心思想了。那么我會(huì)以一個(gè)模擬秒殺系統(tǒng)的簡單Demo來一步一步的展示redis分布式鎖的應(yīng)用。
三.無布式鎖時(shí)的秒殺代碼以及壓測結(jié)果
組件依賴
由于是無redis鎖的情況下的秒殺demo,則只需要引入spring-boot基礎(chǔ)依賴即可
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
代碼
由于是一個(gè)簡單的秒殺下單的demo代碼,那么只需要兩個(gè)接口,一個(gè)是下單接口(order),一個(gè)是查詢訂單接口(query),Controller層代碼如下:
/**
* @author Neal
* 測試無分布式鎖controller 層
*/
@RestController
@RequestMapping("/order")
public class NoDistributeController {
//無分布式鎖service
@Autowired
NoDistributeService redisDistributeService;
/**
* 查詢剩余訂單結(jié)果接口
* @param pid 訂單編號(hào)
* @return
*/
@GetMapping("/query/{pid}")
public String query(@PathVariable String pid) {
return redisDistributeService.queryMap(pid);
}
/**
* 下單接口
* @param pid 訂單編號(hào)
* @return
*/
@GetMapping("/{pid}")
public String order(@PathVariable String pid) {
redisDistributeService.order(pid);
return redisDistributeService.queryMap(pid);
}
}
service層代碼如下:
/**
* @author Neal
* 測試無分布式鎖service 層
*/
@Service
public class NoDistributeService {
//模擬商品信息表
private static Map<String,Integer> products;
//模擬庫存表
private static Map<String,Integer> stock;
//模擬訂單表
private static Map<String,String> orders;
static {
products = new HashMap<>();
stock = new HashMap<>();
orders = new HashMap<>();
//模擬訂單表數(shù)據(jù) 訂單編號(hào) 112233 庫存 100000
products.put("112233",100000);
//模擬庫存表數(shù)據(jù) 訂單編號(hào)112233 庫存100000
stock.put("112233",100000);
}
/**
* 模擬查詢秒殺成功返回的信息
* @param pid 商品編號(hào)
* @return 返回拼接的秒殺商品結(jié)果字符串
*/
public String queryMap(String pid) {
return "秒殺商品限量:" + products.get(pid) + "份,還剩:"+stock.get(pid) +"份,成功下單:"+orders.size() + "人";
}
/**
* 下單方法
* @param pid 商品編號(hào)
*/
public void order(String pid) {
//從庫存表中獲取庫存余量
int stockNum = stock.get(pid);
//如果庫存為0 則輸出庫存不足
if(stockNum == 0) {
System.out.println("商品庫存不足");
}else{ //如果有庫存
//往訂單表中插入數(shù)據(jù) 生成UUID作為用戶ID pid
orders.put(UUID.randomUUID().toString(),pid);
//線程休眠 模擬其他操作
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//減庫存操作
stock.put(pid,stockNum-1);
}
}
}
代碼上有注釋,我就不過多的啰嗦解釋了,那么讓我們來模擬秒殺環(huán)境,使用 ApacheBench 來模擬并發(fā),來看看結(jié)果如何:
進(jìn)入到 ApacheBench 的bin目錄下,我的路徑是 Apache24\bin。
-
輸入命令以及參數(shù):ab -n 1000 -c 100 http://127.0.0.1:8080/order/112233 該命令的含義是 向指定的URL發(fā)送 1000次請(qǐng)求 100 并發(fā)量。 結(jié)果如圖
無鎖壓測圖1
雖然只耗時(shí)了2.596秒,但是處理的結(jié)果卻不盡如人意。
- 然后使用查詢接口查詢一下下單和庫存結(jié)果是否一致,請(qǐng)求查詢接口:http://localhost:8080/order/query/112233。結(jié)果如圖
無鎖查詢結(jié)果
可以看出下單人數(shù)和庫存余量明顯不符,就這就是無鎖時(shí),在高并發(fā)環(huán)境中會(huì)引起的問題。
Redis分布式鎖下的秒殺代碼以及壓測結(jié)果
前言
網(wǎng)上看了很多的例子,都是使用redis的SETNX命令來實(shí)現(xiàn)的,Redis 官網(wǎng)并不推薦使用SETNX命令,而是推薦使用SET,因?yàn)閺?.6.12版本以后,Redis對(duì)SET命令增加了一系列的選項(xiàng)。
EXseconds – Set the specified expire time, in seconds.PXmilliseconds – Set the specified expire time, in milliseconds.NX– Only set the key if it does not already exist.XX– Only set the key if it already exist.EXseconds – 設(shè)置鍵key的過期時(shí)間,單位時(shí)秒PXmilliseconds – 設(shè)置鍵key的過期時(shí)間,單位時(shí)毫秒NX– 只有鍵key不存在的時(shí)候才會(huì)設(shè)置key的值-
XX– 只有鍵key存在的時(shí)候才會(huì)設(shè)置key的值注意: 由于
SET命令加上選項(xiàng)已經(jīng)可以完全取代SETNX, SETEX, PSETEX的功能,所以在將來的版本中,redis可能會(huì)不推薦使用并且最終拋棄這幾個(gè)命令。 原文地址
所以本例也是使用上述文章所推薦的加鎖解鎖方法。
加鎖:使用命令 SET resource_name my_random_value NX PX 30000 這個(gè)命令的作用是在只有這個(gè)key不存在的時(shí)候才會(huì)設(shè)置這個(gè)key的值(NX選項(xiàng)的作用),超時(shí)時(shí)間設(shè)為30000毫秒(PX選項(xiàng)的作用) 這個(gè)key的值設(shè)為“my_random_value”。這個(gè)值必須在所有獲取鎖請(qǐng)求的客戶端里保持唯一。
解鎖: 使用LUA腳本語言
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
這段腳本的意思是:刪除這個(gè)key當(dāng)且僅當(dāng)這個(gè)key存在而且值是我期望的那個(gè)值。
LUA腳本的原子性 原文鏈接
Redis 使用單個(gè) Lua 解釋器去運(yùn)行所有腳本,并且, Redis 也保證腳本會(huì)以原子性(atomic)的方式執(zhí)行:當(dāng)某個(gè)腳本正在運(yùn)行的時(shí)候,不會(huì)有其他腳本或 Redis 命令被執(zhí)行。這和使用 MULTI / EXEC 包圍的事務(wù)很類似。在其他別的客戶端看來,腳本的效果(effect)要么是不可見的(not visible),要么就是已完成的(already completed)。
另一方面,這也意味著,執(zhí)行一個(gè)運(yùn)行緩慢的腳本并不是一個(gè)好主意。寫一個(gè)跑得很快很順溜的腳本并不難,因?yàn)槟_本的運(yùn)行開銷(overhead)非常少,但是當(dāng)你不得不使用一些跑得比較慢的腳本時(shí),請(qǐng)小心,因?yàn)楫?dāng)這些蝸牛腳本在慢吞吞地運(yùn)行的時(shí)候,其他客戶端會(huì)因?yàn)榉?wù)器正忙而無法執(zhí)行命令。
組件依賴
相關(guān)jedis依賴
<!--Jedis 相關(guān)依賴-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
代碼
1.配置jedis。網(wǎng)上有很多配置jedis的例子,我就給一個(gè)簡單的配置實(shí)現(xiàn)。我使用的是自定義的配置,首先在 application.properties中添加Jedis配置參數(shù)
#jedis相關(guān)配置
#Redis IP
jedis.host=192.168.56.101
#Redis 端口
jedis.port=6379
#Redis 密碼
jedis.password=123456
jedis.timeout=3
jedis.poolMaxTotal=10
jedis.poolMaxIdle=10
jedis.poolMaxWait=3
2.聲明自定義配置Bean,使用springboot 注解ConfigurationProperties來加載application.properties中的配置參數(shù)。
/**
* @author Neal
* 自定義Jedis配置bean
*/
@Component
@ConfigurationProperties(prefix = "jedis")
public class MyJedisBean {
private String host;
private int port;
private String password;
private int timeout;
private int poolMaxTotal;
private int poolMaxIdle;
private int poolMaxWait;
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getTimeout() {
return timeout;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public int getPoolMaxTotal() {
return poolMaxTotal;
}
public void setPoolMaxTotal(int poolMaxTotal) {
this.poolMaxTotal = poolMaxTotal;
}
public int getPoolMaxIdle() {
return poolMaxIdle;
}
public void setPoolMaxIdle(int poolMaxIdle) {
this.poolMaxIdle = poolMaxIdle;
}
public int getPoolMaxWait() {
return poolMaxWait;
}
public void setPoolMaxWait(int poolMaxWait) {
this.poolMaxWait = poolMaxWait;
}
}
3.生成JedisPool組件bean
這里就是把JedisPool的相關(guān)配置參數(shù)配置到JedisPoolConfig并且利用JedisPool的構(gòu)造方法來聲明對(duì)象。
/**
* @author Neal
* 初始化jedis 連接池
*/
@Component
public class MyJedisConfig {
/**
* 自定義jedis配置bean
*/
@Autowired
private MyJedisBean myJedisBean;
@Bean
public JedisPool jedisPoolFactory() {
//聲明jedispool 配置類
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(myJedisBean.getPoolMaxIdle());
jedisPoolConfig.setMaxTotal(myJedisBean.getPoolMaxTotal());
jedisPoolConfig.setMaxWaitMillis(myJedisBean.getPoolMaxWait() *1000);
/**
* 利用Jedis的構(gòu)造方法 生成 jedispool
*/
JedisPool jedisPool = new JedisPool(jedisPoolConfig,myJedisBean.getHost(),myJedisBean.getPort(),myJedisBean.getTimeout()*1000,myJedisBean.getPassword(),0);
return jedisPool;
}
}
4.實(shí)現(xiàn)Redis分布式鎖方法
該類中只有加鎖(redisLock)和解鎖(redisUnlock)兩個(gè)方法。 聲明的靜態(tài)變量 都是根據(jù)Redis原生命令 SET resource_name my_random_value NX PX 30000 聲明的命令字符串。在加鎖和解鎖時(shí) resource_name 對(duì)應(yīng)的是 商品ID, my_random_value 對(duì)應(yīng)的是我們用UUID 生成的模擬用戶ID。
/**
* @author Neal
* 分布式鎖
*/
@Component
public class MyRedisLock {
//Only set the key if it does not already exist.
private static final String IF_NOT_EXIST = "NX";
// Set the specified expire time, in milliseconds.
private static final String SET_EXPIRE_TIME = "PX";
//超時(shí)時(shí)間為 500毫秒
private static final int EXPIRE_TIME = 500;
//加鎖成功后返回的標(biāo)識(shí)
private static final String ON_LOCK = "OK";
//LUA 解鎖腳本
private static final String LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* 獲取配置好的jedis pool 組件
*/
@Autowired
private JedisPool jedisPoolFactory;
/***
* redis 加鎖方法
* @param id 商品ID
* @param uuid 模擬用戶ID
* @return 返回 true 加鎖成功 false 解鎖成功
*/
public boolean redisLock(String id,String uuid) {
//從 jedis 連接池中 獲取jedis
Jedis jedis = jedisPoolFactory.getResource();
boolean locked = false;
try{
//使用Jedis 加鎖
locked = ON_LOCK.equals(jedis.set(id,uuid,IF_NOT_EXIST,SET_EXPIRE_TIME,EXPIRE_TIME));
}finally {
//將連接放回連接池
jedis.close();
}
return locked;
}
/***
* redis 解鎖方法
* @param id 商品ID
* @param uuid 模擬用戶ID
* @return 由于是使用LUA腳本,則會(huì)保證原子性的特質(zhì)
*/
public void redisUnlock(String id,String uuid) {
//從 jedis 連接池中 獲取jedis
Jedis jedis = jedisPoolFactory.getResource();
try{
//使用Jedis 的 eval解鎖
Object result = jedis.eval(LUA_SCRIPT, Collections.singletonList(id),Collections.singletonList(uuid));
if(1L == (Long)result) {
System.out.println("客戶ID為:《" + uuid + "》 解鎖成功!");
}
}finally {
jedis.close();
}
}
}
核心的加鎖代碼已經(jīng)介紹完了,下面就是關(guān)于 在秒殺service層加鎖與解鎖相關(guān)的代碼了。
5.Controller層
controller層與之前的無鎖controller沒有變化,還是一樣的代碼。
/**
* @author Neal
* 測試分布式鎖controller 層
*/
@RestController
@RequestMapping("/distribute")
public class RedisDistributeController {
@Autowired
private RedisDistributeService redisDistributeService;
@Autowired
private JedisPool jedisPoolFactory;
@GetMapping("/query/{pid}")
public String query(@PathVariable String pid) {
return redisDistributeService.queryMap(pid);
}
@GetMapping("/{pid}")
public String order(@PathVariable String pid) {
redisDistributeService.order(pid, UUID.randomUUID().toString());
return redisDistributeService.queryMap(pid);
}
}
6.service層
在service層中的下單方法(order)中加入了 加鎖與解鎖的操作。
/**
* @author Neal
* 測試分布式鎖service 層
*/
@Service
public class RedisDistributeService {
//模擬商品信息表
private static Map<String,Integer> products;
//模擬庫存表
private static Map<String,Integer> stock;
//模擬訂單表
private static Map<String,String> orders;
//redis 鎖組件
@Autowired
MyRedisLock myRedisLock;
static {
products = new HashMap<>();
stock = new HashMap<>();
orders = new HashMap<>();
products.put("112233",100000);
stock.put("112233",100000);
}
/**
* 模擬查詢秒殺成功返回的信息
* @param pid 商品名稱
* @return
*/
public String queryMap(String pid) {
return "秒殺商品限量:" + products.get(pid) + "份,還剩:"+stock.get(pid) +"份,成功下單:"+orders.size() + "人";
}
/**
* 下單方法
* @param pid 商品名稱
*/
public void order(String pid,String uuid) {
//redis 加鎖
if(!myRedisLock.redisLock(pid,uuid)) { //如果沒獲得鎖則直接返回,不執(zhí)行下面的代碼
System.out.println("客戶ID為:《"+ uuid +"》未獲得鎖");
return;
}
System.out.println("客戶ID為:《"+ uuid +"》獲得鎖");
//從庫存表中獲取庫存余量
int stockNum = stock.get(pid);
if(stockNum == 0) {
System.out.println("商品庫存不足");
}else{
//往訂單表中插入數(shù)據(jù)
orders.put(uuid,pid);
//線程休眠 模擬其他操作
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//減庫存操作
stock.put(pid,stockNum-1);
}
//redis 解鎖
myRedisLock.redisUnlock(pid,uuid);
}
}
<u>代碼已經(jīng)介紹完了那么下面開始?jí)簻y,并看看結(jié)果是否跟沒加鎖的代碼有區(qū)別</u>
7.壓測
壓測方式跟上面講述的一樣,我就不在啰嗦了,直接上結(jié)果圖。
由圖中可以看出,同樣的壓測命令,秒殺的結(jié)果卻只有13個(gè)秒殺成功,但是庫存余量與秒殺的數(shù)量是對(duì)應(yīng)的上的,不會(huì)出現(xiàn)庫存與秒殺數(shù)量不一致問題。
結(jié)論:
在寫Redis鎖的時(shí)候看了很多前輩的博文以及教學(xué)視頻,發(fā)現(xiàn)之前用的都是基于SETNX,解鎖也是各有千秋,最后還是參考了 Redis的官方實(shí)踐。
DEMO代碼地址
Redis命令大全