1. 簡介
隨着技術的快速發展,業務系統規模的不斷擴大,分布式系統越來越普及。一個應用往往會部署到多台機器上,在一些業務場景中,為了保證數據的一致性,要求在同一時刻,同一任務只在一個節點上運行,保證同一個方法同一時刻只能被一個線程執行。這時候分布式鎖就運用而生了。
分布式鎖有很多的解決方案。常見的有:
-
基於數據庫的:悲觀鎖,樂觀鎖。
-
基於zookeeper的分布式鎖。
-
本章中講的基於redis的分布式鎖。
2. 超賣
下單減庫存是互聯網項目中必不可少的環節。然而,如果我么考慮不得當,將會帶來很多問題。比如最不能忍受的:超賣
如下代碼,一個初始化庫存的方法和一個購買圖書的方法,我們沒有做任何的並發處理,查看下最終結果。
package com.ldx.redisson.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Objects;
/**
* redis 實現分布式鎖
*
* @author ludangxin
* @date 2021/8/15
*/
@Slf4j
@RestController
@RequestMapping("redis")
public class RedisLockTestController {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 商品key
private static final String KEY = "book";
// 庫存數量
private static final Long STOCK = 50L;
/**
* 初始化
*/
@GetMapping("init")
public String init() {
stringRedisTemplate.opsForValue().set(KEY, String.valueOf(STOCK));
return "初始化成功~";
}
/**
* 購買圖書
*/
@GetMapping("buy")
public String buy() {
// 獲取到當前庫存
String buyBefore = stringRedisTemplate.opsForValue().get(KEY);
if(Objects.isNull(buyBefore)) {
log.error("未找到\"{}\"的庫存信息~", KEY);
return "暫未上架~";
}
long buyBeforeL = Long.parseLong(buyBefore);
if(buyBeforeL > 0) {
// 對庫存進行-1操作
Long buyAfter = stringRedisTemplate.opsForValue().decrement(KEY);
log.info("剩余圖書==={}", buyAfter);
return "購買成功~";
}
else {
log.info("庫存不足~");
return "庫存不足~";
}
}
}
啟動測試:
這里我們使用jemter
來進行並發請求。配置如下:
線程組配置:
請求配置:
請求結果:
只復制了部分日志
通過日志很明顯的看到,即使在業務代碼中判斷了庫存 > 0
但還是超賣了。
......
2021-08-15 21:01:22.614 INFO 66913 --- [io-8080-exec-30] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.614 INFO 66913 --- [io-8080-exec-99] c.l.r.c.RedisLockTestController : 剩余圖書===-42
2021-08-15 21:01:22.614 INFO 66913 --- [io-8080-exec-29] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.622 INFO 66913 --- [io-8080-exec-89] c.l.r.c.RedisLockTestController : 剩余圖書===-40
2021-08-15 21:01:22.622 INFO 66913 --- [io-8080-exec-90] c.l.r.c.RedisLockTestController : 剩余圖書===-35
2021-08-15 21:01:22.622 INFO 66913 --- [o-8080-exec-135] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.622 INFO 66913 --- [o-8080-exec-177] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.622 INFO 66913 --- [io-8080-exec-92] c.l.r.c.RedisLockTestController : 剩余圖書===-34
2021-08-15 21:01:22.622 INFO 66913 --- [io-8080-exec-86] c.l.r.c.RedisLockTestController : 剩余圖書===-37
2021-08-15 21:01:22.642 INFO 66913 --- [io-8080-exec-11] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.642 INFO 66913 --- [o-8080-exec-115] c.l.r.c.RedisLockTestController : 庫存不足~
2021-08-15 21:01:22.642 INFO 66913 --- [io-8080-exec-72] c.l.r.c.RedisLockTestController : 剩余圖書===-33
2021-08-15 21:01:22.643 INFO 66913 --- [nio-8080-exec-3] c.l.r.c.RedisLockTestController : 庫存不足~
3. redis setnx
主要是用redis的 setnx (set not exists)命令實現分布式鎖。
3.1 編寫邏輯
在超買的場景中,我們了解了分布式鎖的必要性。
上面的場景如果是單機的話,直接使用jvm鎖就能解決問題,但是在分布式場景下下jvm鎖無法處理。
接下來我們將使用redis命令來解決一下超賣問題。
-
新增了鎖標識key。
-
在進行業務處理之前,給redis中
setIfAbsent(LOCK_KEY, clientId, 30, TimeUnit.SECONDS)
作為lock。LOCK_KEY
:鎖的標識,比如秒殺的商品id_lock
:當對該商品進行秒殺下單時,加鎖使其線性執行。clientId
:當前請求的唯一值,為了在刪除鎖時進行鎖判斷。即只能刪除自己加的鎖。防止誤刪鎖。30
:失效時間,防止死鎖(如果加鎖時不設置過期時間,當系統執行完加鎖還未進行解鎖時系統宕機,那其他節點也無法進行下單,因為鎖一直在)。 -
解鎖邏輯最好放在
finally
中進行,防止報錯導致死鎖。
// 鎖標識
private static final String LOCK_KEY = "book_lock";
/**
* 購買圖書
*/
@GetMapping("buy1")
public String buy1() {
String clientId = UUID.randomUUID().toString();
/*
* 給redis設置一個key,並設置過期時間防止死鎖
* setIfAbsent(setnx):當key不存在時才設置值
* flag=true:值設置成功(獲取鎖) flag=false:設置值失敗(獲取鎖失敗)
*/
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, clientId, 30, TimeUnit.SECONDS);
try {
if(Objects.nonNull(flag) && flag) {
String buyBefore = stringRedisTemplate.opsForValue().get(KEY);
if(Objects.isNull(buyBefore)) {
log.error("未找到\"{}\"的庫存信息~", KEY);
return "暫未上架~";
}
long buyBeforeL = Long.parseLong(buyBefore);
if(buyBeforeL > 0) {
Long buyAfter = stringRedisTemplate.opsForValue().decrement(KEY);
log.info("剩余圖書==={}", buyAfter);
return "購買成功~";
}
else {
log.info("庫存不足~");
return "庫存不足~";
}
}
else {
log.error("獲取鎖失敗~");
}
}
catch(Exception e) {
e.printStackTrace();
}
finally {
// 防止誤刪鎖
if(clientId.equals(stringRedisTemplate.opsForValue().get(LOCK_KEY))) {
stringRedisTemplate.delete(LOCK_KEY);
}
}
return "系統錯誤~";
}
3.2 啟動測試
啟動兩個服務,並配置nginx負載均衡。
nginx配置如下:
jemter配置如下:
啟動測試:
部分日志如下:
redis中查看庫存:
打完收工~
3.3 小節
這里主要是用了setnx
來實現分布式鎖。雖然解決了超賣問題,但其中還是有很多缺陷。比如:
- 當請求獲取鎖失敗時,能不能嘗試重新獲取鎖或者阻塞等待獲取鎖,而不是直接返回
系統繁忙
之類的提示語。 - 如果持有鎖的請求處理時間超過了設置的超時時間,也就是業務邏輯還沒有處理完呢,但鎖已經失效了,此時剛好又進來一個請求,又有並發問題了😂。
此時:redisson
:申請出戰🙋。
4. redisson
4.1 簡介
Redisson是一個在Redis的基礎上實現的Java駐內存數據網格(In-Memory Data Grid)。它不僅提供了一系列的分布式的Java常用對象,還提供了許多分布式服務。其中包括(BitSet
, Set
, Multimap
, SortedSet
, Map
, List
, Queue
, BlockingQueue
, Deque
, BlockingDeque
, Semaphore
, Lock
, AtomicLong
, CountDownLatch
, Publish / Subscribe
, Bloom filter
, Remote service
, Spring cache
, Executor service
, Live Object service
, Scheduler service
) Redisson提供了使用Redis的最簡單和最便捷的方法。Redisson的宗旨是促進使用者對Redis的關注分離(Separation of Concern),從而讓使用者能夠將精力更集中地放在處理業務邏輯上。
Redisson底層采用的是Netty框架。支持Redis2.8以上版本,支持Java1.6+以上版本。
Jedis 與 Redisson
-
Jedis:Jedis 只是簡單的封裝了 Redis 的API庫,可以看作是Redis客戶端,它的方法和Redis 的命令很類似,相比於Redisson 更原生一些,更靈活。
-
Redisson:Redisson 不僅封裝了 redis ,還封裝了對更多數據結構的支持,以及鎖等功能,相比於Jedis 更加大。
4.2 quick start
4.2.1 添加依賴
springboot 基礎上添加此依賴。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.1</version>
</dependency>
4.2.2 application.yaml
因為使用的是單機redis,並且使用的是自動裝配的依賴,所以直接使用redis的基本配置即可。
server:
port: ${port}
spring:
# redis 配置
redis:
# 地址
host: localhost
# 端口,默認為6379
port: 6379
# 連接超時時間
timeout: 10s
lettuce:
pool:
# 連接池中的最小空閑連接
min-idle: 0
# 連接池中的最大空閑連接
max-idle: 8
# 連接池的最大數據庫連接數
max-active: 8
# #連接池最大阻塞等待時間(使用負值表示沒有限制)
max-wait: -1ms
4.2.3 controller
package com.ldx.redisson.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* redisson test
*
* @author ludangxin
* @date 2021/8/15
*/
@Slf4j
@RestController
@RequestMapping("test")
@RequiredArgsConstructor
public class RedissonLockTestController {
private final RedissonClient redissonClient;
/**
* 沒獲取到鎖的線程阻塞等待獲取鎖
*/
@GetMapping("/lock")
public void lock() {
log.info("進入了測試方法~");
RLock lock = null;
try {
lock = redissonClient.getLock("lock");
lock.lock();
log.info("獲取到鎖~");
Thread.sleep(2000);
}
catch(InterruptedException e) {
e.printStackTrace();
}
finally {
//如果當前線程保持鎖定則解鎖
if (null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 沒獲取到鎖的線程直接返回鎖狀態
*/
@GetMapping("tryLock")
public void tryLock() {
log.info("進入了測試方法~");
RLock lock = null;
try {
lock = redissonClient.getLock("lock");
if(lock.tryLock()) {
log.info("獲取到鎖~");
Thread.sleep(6000);
}
else {
log.error("獲取鎖失敗~");
}
}
catch(InterruptedException e) {
e.printStackTrace();
}
finally {
//如果當前線程保持鎖定則解鎖
if (null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 沒獲取到鎖的線程嘗試獲取鎖
*/
@GetMapping("tryLockWithBlock")
public void tryLockWithBlock() {
log.info("進入了測試方法~");
RLock lock = null;
try {
//非公平鎖,隨機取一個等待中的線程分配鎖
lock = redissonClient.getLock("lock");
//公平鎖,按照先后順序依次分配鎖
//lock=redissonClient.getFairLock("lock");
//最多等待鎖3秒,5秒后強制解鎖
if(lock.tryLock(3, 5, TimeUnit.SECONDS)) {
log.info("獲取到鎖~");
Thread.sleep(1000);
}
else {
log.error("獲取鎖失敗~");
}
}
catch(InterruptedException e) {
e.printStackTrace();
}
finally {
//如果當前線程保持鎖定則解鎖
if (null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
4.2.4 啟動測試
jemter 配置如下:
啟動9個線程並同一時刻進行請求。
請求lock方法日志如下:
所有請求同一時刻進入方法,並且請求阻塞每隔兩秒獲取到鎖。
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-7] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.435 INFO 63602 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.437 INFO 63602 --- [io-8080-exec-10] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.437 INFO 63602 --- [io-8080-exec-11] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.437 INFO 63602 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:58:26.443 INFO 63602 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:28.474 INFO 63602 --- [io-8080-exec-11] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:30.499 INFO 63602 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:32.523 INFO 63602 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:34.548 INFO 63602 --- [io-8080-exec-10] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:36.569 INFO 63602 --- [nio-8080-exec-7] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:38.595 INFO 63602 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:40.616 INFO 63602 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:58:42.643 INFO 63602 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 獲取到鎖~
請求tryLock方法日志如下:
所有請求同一時刻進入方法,並且只有一個請求獲取到了鎖,其他請求直接返回結果。
2021-08-15 16:59:17.925 INFO 63602 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.925 INFO 63602 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.925 INFO 63602 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.928 INFO 63602 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.931 INFO 63602 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.931 INFO 63602 --- [io-8080-exec-11] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.933 INFO 63602 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.933 INFO 63602 --- [nio-8080-exec-8] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.933 INFO 63602 --- [io-8080-exec-10] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 16:59:17.937 ERROR 63602 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.937 INFO 63602 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 16:59:17.938 ERROR 63602 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.937 ERROR 63602 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.939 ERROR 63602 --- [nio-8080-exec-8] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.939 ERROR 63602 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.939 ERROR 63602 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.939 ERROR 63602 --- [io-8080-exec-11] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 16:59:17.939 ERROR 63602 --- [io-8080-exec-10] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
請求tryLockWithBlock方法日志如下:
所有請求同一時刻進入方法,並且只有三個請求獲取到了鎖。
2021-08-15 23:34:41.617 INFO 70413 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.617 INFO 70413 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.617 INFO 70413 --- [nio-8080-exec-7] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.617 INFO 70413 --- [nio-8080-exec-8] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.618 INFO 70413 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.618 INFO 70413 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.618 INFO 70413 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.618 INFO 70413 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.617 INFO 70413 --- [nio-8080-exec-3] c.l.r.c.RedissonLockTestController : 進入了測試方法~
2021-08-15 23:34:41.658 INFO 70413 --- [nio-8080-exec-8] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 23:34:42.681 INFO 70413 --- [nio-8080-exec-7] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 23:34:43.697 INFO 70413 --- [nio-8080-exec-3] c.l.r.c.RedissonLockTestController : 獲取到鎖~
2021-08-15 23:34:44.624 ERROR 70413 --- [nio-8080-exec-9] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 23:34:44.624 ERROR 70413 --- [nio-8080-exec-1] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 23:34:44.624 ERROR 70413 --- [nio-8080-exec-5] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 23:34:44.624 ERROR 70413 --- [nio-8080-exec-2] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 23:34:44.625 ERROR 70413 --- [nio-8080-exec-6] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
2021-08-15 23:34:44.630 ERROR 70413 --- [nio-8080-exec-4] c.l.r.c.RedissonLockTestController : 獲取鎖失敗~
4.2.5 小節
redisson 提供了lock()
和tryLock()
,tryLock(long time, TimeUnit unit)
,tryLock(long waitTime, long leaseTime, TimeUnit unit)
方法。
lock()
:會阻塞未獲取鎖的請求,默認持有30s
鎖,但當業務方法在30s內沒有執行完時,會有看門狗(默認每隔10s)
給當前鎖續時30s
。tryLock()
:嘗試獲取鎖,獲取不到則直接返回獲取失敗,默認持有30s
鎖,但當業務方法在30s內沒有執行完時,會有看門狗(默認每隔10s)
給當前鎖續時30s
。tryLock(long time, TimeUnit unit)
:嘗試獲取鎖,等待time TimeUnit
,默認持有30s
鎖,但當業務方法在30s內沒有執行完時,會有看門狗(默認每隔10s)
給當前鎖續時30s
。tryLock(long waitTime, long leaseTime, TimeUnit unit)
:嘗試獲取鎖,等待waitTime TimeUnit
,鎖最長持有leaseTime TimeUnit
,當業務方法在leaseTime TimeUnit
時長內沒有執行完時,會強制解鎖。
4.3 解決超賣
private static final String KEY = "book";
/**
* 購買圖書
*/
@GetMapping("buy1")
public String buy1() {
RLock lock = null;
try {
lock = redissonClient.getLock("lock");
if(lock.tryLock(3, TimeUnit.SECONDS)) {
RAtomicLong buyBefore = redissonClient.getAtomicLong(KEY);
if(Objects.isNull(buyBefore)) {
log.error("未找到\"{}\"的庫存信息~", KEY);
return "暫未上架~";
}
long buyBeforeL = buyBefore.get();
if(buyBeforeL > 0) {
Long buyAfter = buyBefore.decrementAndGet();
log.info("剩余圖書==={}", buyAfter);
return "購買成功~";
}
else {
log.info("庫存不足~");
return "庫存不足~";
}
}
else {
log.error("獲取鎖失敗~");
}
}
catch(Exception e) {
e.printStackTrace();
}
finally {
//如果當前線程保持鎖定則解鎖
if(null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return "系統錯誤~";
}
經測試不會存在超賣問題。
並且避免了3.3
小節中提到的問題。
4.4 小節
方便,好用。