本案例主要講解Redis實(shí)現(xiàn)分布式鎖的兩種實(shí)現(xiàn)方式:Jedis實(shí)現(xiàn)、Redisson
實(shí)現(xiàn)。網(wǎng)上關(guān)于這方面講解太多了,Van自認(rèn)為文筆沒(méi)他們好,還是用示例代碼說(shuō)明。
一、jedis 實(shí)現(xiàn)
該方案只考慮Redis單機(jī)部署的場(chǎng)景
1.1 加鎖
1.1.1 原理
jedis.set(String key, String value, String nxxx, String expx, int time)
* key: 使用key來(lái)當(dāng)鎖,因?yàn)閗ey是唯一的;
* value: 我傳的是唯一值(UUID),很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什么還要用到value?原因是分布式鎖要滿(mǎn)足解鈴還須系鈴人
:通過(guò)給value賦值為requestId,我們就知道這把鎖是哪個(gè)請(qǐng)求加的了,在解鎖的時(shí)候要驗(yàn)證value值,不能誤解鎖;
* nxxx: 這個(gè)參數(shù)我填的是NX,意思是SET IF NOT EXIST,即當(dāng)key不存在時(shí),我們進(jìn)行set操作;若key已經(jīng)存在,則不做任何操作;
* expx: 這個(gè)參數(shù)我傳的是PX,意思是我們要給這個(gè)key加一個(gè)過(guò)期的設(shè)置,具體時(shí)間由第五個(gè)參數(shù)決定;
* time: 與第四個(gè)參數(shù)相呼應(yīng),代表key的過(guò)期時(shí)間。
1.1.2 小結(jié)
* set()加入了NX參數(shù),可以保證如果已有key存在,則函數(shù)不會(huì)調(diào)用成功,也就是只有一個(gè)客戶(hù)端能持有鎖,滿(mǎn)足互斥性;
* 其次,由于我們對(duì)鎖設(shè)置了過(guò)期時(shí)間,即使鎖的持有者后續(xù)發(fā)生崩潰而沒(méi)有解鎖,鎖也會(huì)因?yàn)榈搅诉^(guò)期時(shí)間而自動(dòng)解鎖(即key被刪除),不會(huì)發(fā)生死鎖;
* 最后,因?yàn)槲覀儗alue賦值為requestId,代表加鎖的客戶(hù)端請(qǐng)求標(biāo)識(shí),那么在客戶(hù)端在解鎖的時(shí)候就可以進(jìn)行校驗(yàn)是否是同一個(gè)客戶(hù)端。
1.2 釋放鎖
釋放鎖時(shí)需要驗(yàn)證value值,也就是說(shuō)我們?cè)讷@取鎖的時(shí)候需要設(shè)置一個(gè)value,不能直接用del key這種粗暴的方式,因?yàn)橹苯觗el key
任何客戶(hù)端都可以進(jìn)行解鎖了,所以解鎖時(shí),我們需要判斷鎖是否是自己的(基于value值來(lái)判斷)
* 首先,寫(xiě)了一個(gè)簡(jiǎn)單Lua腳本代碼,作用是:獲取鎖對(duì)應(yīng)的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖);
* 然后,將Lua代碼傳到j(luò)edis.eval()方法里,并使參數(shù)KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId。eval()
方法是將Lua代碼交給Redis服務(wù)端執(zhí)行。
1.3 案例(家庭多人領(lǐng)取獎(jiǎng)勵(lì)的場(chǎng)景)
這里放出的是關(guān)鍵代碼,詳細(xì)可運(yùn)行的代碼可至文末地址下載示例代碼。
1.3.1 準(zhǔn)備
該案例模擬家庭內(nèi)多人通過(guò)領(lǐng)取一個(gè)獎(jiǎng)勵(lì),但是只能有一個(gè)人能領(lǐng)取成功,不能重復(fù)領(lǐng)?。ㄖ白鲞^(guò)獎(jiǎng)勵(lì)模塊的需求)
* family_reward_record表 CREATE TABLE `family_reward_record` ( `id` bigint(10)
NOT NULL AUTO_INCREMENT COMMENT '主鍵id', `family_id` bigint(20) NOT NULL DEFAULT
'0' COMMENT '商品名稱(chēng)', `reward_type` int(10) NOT NULL DEFAULT '1' COMMENT
'商品庫(kù)存數(shù)量', `state` int(1) NOT NULL DEFAULT '0' COMMENT '商品狀態(tài)', `create_time`
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入庫(kù)時(shí)間', `update_time`
timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY
KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=270 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci COMMENT='家庭領(lǐng)取獎(jiǎng)勵(lì)表(家庭內(nèi)多人只能有一個(gè)人能領(lǐng)取成功,不能重復(fù)領(lǐng)取)';
* application.yml spring: datasource: url: jdbc:mysql://47.98.178.84:3306/dev
username: dev password: password driver-class-name: com.mysql.jdbc.Driver
redis: host: 47.98.178.84 port: 6379 password: password timeout: 2000 # mybatis
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package:
cn.van.mybatis.demo.entity
1.3.2 核心實(shí)現(xiàn)
* Jedis 單機(jī)配置類(lèi) - RedisConfig.java @Configuration public class RedisConfig
extends CachingConfigurerSupport { @Value("${spring.redis.host}") private
String host; @Value("${spring.redis.port}") private int port;
@Value("${spring.redis.password}") private String password;
@Value("${spring.redis.timeout}") private int timeout; @Bean public JedisPool
redisPoolFactory() { JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
if (StringUtils.isEmpty(password)) { return new JedisPool(jedisPoolConfig,
host, port, timeout); } return new JedisPool(jedisPoolConfig, host, port,
timeout, password); } @Bean(name = "redisTemplate") public
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory
redisConnectionFactory) { RedisTemplate<String, Object> redisTemplate = new
RedisTemplate(); redisTemplate.setConnectionFactory(redisConnectionFactory);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, Visibility.ANY);
objectMapper.enableDefaultTyping(DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new
Jackson2JsonRedisSerializer(Object.class);
jsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setDefaultSerializer(jsonRedisSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.afterPropertiesSet(); return redisTemplate; } }
* 分布式鎖工具類(lèi) - RedisDistributedLock.java @Component public class
RedisDistributedLock { /** * 成功獲取鎖標(biāo)示 */ private static final String
LOCK_SUCCESS = "OK"; /** * 成功解鎖標(biāo)示 */ private static final Long RELEASE_SUCCESS
= 1L; @Autowired private JedisPool jedisPool; /** * redis 數(shù)據(jù)存儲(chǔ)過(guò)期時(shí)間 */ final int
expireTime = 500; /** * 嘗試獲取分布式鎖 * @param lockKey 鎖 * @param lockValue 請(qǐng)求標(biāo)識(shí) *
@return 是否獲取成功 */ public boolean tryLock(String lockKey, String lockValue) {
Jedis jedis = null; try{ jedis = jedisPool.getResource(); String result =
jedis.set(lockKey, lockValue, "NX", "PX", expireTime); if
(LOCK_SUCCESS.equals(result)) { return true; } } finally { if(jedis != null){
jedis.close(); } } return false; } /** * 釋放分布式鎖 * @param lockKey 鎖 * @param
lockValue 請(qǐng)求標(biāo)識(shí) * @return 是否釋放成功 */ public boolean unLock(String lockKey, String
lockValue) { Jedis jedis = null; try { jedis = jedisPool.getResource(); String
script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end"; Object result =
jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(lockValue)); if (RELEASE_SUCCESS.equals(result)) {
return true; } } finally { if(jedis != null){ jedis.close(); } } return false;
} }
* 不加鎖時(shí):模擬 familyId = 1 的家庭同時(shí)領(lǐng)取獎(jiǎng)勵(lì) @Override public HttpResult receiveAward() {
Long familyId = 1L; Map<String, Object> params = new HashMap<String,
Object>(16); params.put("familyId", familyId); params.put("rewardType", 1); int
count = familyRewardRecordMapper.selectCountByFamilyIdAndRewardType(params); if
(count == 0) { FamilyRewardRecordDO recordDO = new
FamilyRewardRecordDO(familyId,1,0,LocalDateTime.now()); int num =
familyRewardRecordMapper.insert(recordDO); if (num == 1) { return
HttpResult.success(); } return HttpResult.failure(-1, "記錄插入失敗"); } return
HttpResult.success("該記錄已存在"); }
* 加鎖的實(shí)現(xiàn):模擬 familyId = 2 的家庭同時(shí)領(lǐng)取獎(jiǎng)勵(lì) @Override public HttpResult
receiveAwardLock() { Long familyId = 2L; Map<String, Object> params = new
HashMap<String, Object>(16); params.put("familyId", familyId);
params.put("rewardType", 1); int count =
familyRewardRecordMapper.selectCountByFamilyIdAndRewardType(params); if (count
== 0) { // 沒(méi)有記錄則創(chuàng)建領(lǐng)取記錄 FamilyRewardRecordDO recordDO = new
FamilyRewardRecordDO(familyId,1,0,LocalDateTime.now()); // 分布式鎖的key(familyId +
rewardType) String lockKey = recordDO.getFamilyId() + "_" +
recordDO.getRewardType(); // 分布式鎖的value(唯一值) String lockValue = createUUID();
boolean lockStatus = redisLock.tryLock(lockKey, lockValue); // 鎖被占用 if
(!lockStatus) { log.info("鎖已經(jīng)占用了"); return HttpResult.failure(-1,"失敗"); } //
不管多個(gè)請(qǐng)求,加鎖之后,只會(huì)有一個(gè)請(qǐng)求能拿到鎖,進(jìn)行插入操作
log.info("拿到了鎖,當(dāng)前時(shí)刻:{}",System.currentTimeMillis()); int num =
familyRewardRecordMapper.insert(recordDO); if (num != 1) { log.info("數(shù)據(jù)插入失?。?quot;);
return HttpResult.failure(-1, "數(shù)據(jù)插入失?。?quot;); } log.info("數(shù)據(jù)插入成功!準(zhǔn)備解鎖..."); boolean
unLockState = redisLock.unLock(lockKey,lockValue); if (!unLockState) {
log.info("解鎖失敗!"); return HttpResult.failure(-1, "解鎖失敗!"); } log.info("解鎖成功!");
return HttpResult.success(); } log.info("該記錄已存在"); return
HttpResult.success("該記錄已存在"); } private String createUUID() { UUID uuid =
UUID.randomUUID(); String str = uuid.toString().replace("-", "_"); return str; }
1.3.3 測(cè)試
我采用的是JMeter工具進(jìn)行測(cè)試,加鎖和不加鎖的情況都設(shè)置成:五次并發(fā)請(qǐng)求。
1.3.3.1 不加鎖
/** * 家庭成員領(lǐng)取獎(jiǎng)勵(lì)(不加鎖) * @return */ @PostMapping("/receiveAward") public
HttpResult receiveAward() { return redisLockService.receiveAward(); }
* 請(qǐng)求方式:POST
* 請(qǐng)求地址:http://localhost:8080/redisLock/receiveAward
<http://localhost:8080/redisLock/receiveAward>
* 返回結(jié)果:插入了五條記錄
1.3.3.2 加鎖
/** * 家庭成員領(lǐng)取獎(jiǎng)勵(lì)(加鎖) * @return */ @PostMapping("/receiveAwardLock") public
HttpResult receiveAwardLock() { return redisLockService.receiveAwardLock(); }
* 請(qǐng)求方式:POST
* 請(qǐng)求地址:http://localhost:8080/redisLock/receiveAwardLock
<http://localhost:8080/redisLock/receiveAwardLock>
* 返回結(jié)果:只插入了一條記錄
通過(guò)對(duì)比,說(shuō)明分布式鎖起作用了。
1.4 小結(jié)
我上家使用的就是這種加鎖方式,看上去很OK,實(shí)際上在Redis集群的時(shí)候會(huì)出現(xiàn)問(wèn)題,比如:
A客戶(hù)端在Redis的master節(jié)點(diǎn)上拿到了鎖,但是這個(gè)加鎖的key還沒(méi)有同步到slave節(jié)點(diǎn),master故障,發(fā)生故障轉(zhuǎn)移,一個(gè)slave節(jié)點(diǎn)升級(jí)為
master節(jié)點(diǎn),B客戶(hù)端也可以獲取同個(gè)key的鎖,但客戶(hù)端A也已經(jīng)拿到鎖了,這就導(dǎo)致多個(gè)客戶(hù)端都拿到鎖。
正因?yàn)槿绱?,Redis作者antirez基于分布式環(huán)境下提出了一種更高級(jí)的分布式鎖的實(shí)現(xiàn)方式:Redlock。
二、Redlock實(shí)現(xiàn)
2.1 原理
antirez提出的Redlock算法大概是這樣的:
在Redis的分布式環(huán)境中,我們假設(shè)有N個(gè)Redis master。這些節(jié)點(diǎn)完全互相獨(dú)立,不存在主從復(fù)制或者其他集群協(xié)調(diào)機(jī)制。我們確保將在N個(gè)實(shí)例上使用與在
Redis單實(shí)例下相同方法獲取和釋放鎖?,F(xiàn)在我們假設(shè)有5個(gè)Redis master節(jié)點(diǎn),同時(shí)我們需要在5臺(tái)服務(wù)器上面運(yùn)行這些Redis
實(shí)例,這樣保證他們不會(huì)同時(shí)都宕掉。
2.1.1 加鎖
為了取到鎖,客戶(hù)端應(yīng)該執(zhí)行以下操作(RedLock算法加鎖步驟):
* 獲取當(dāng)前Unix時(shí)間,以毫秒為單位;
* 依次嘗試從5個(gè)實(shí)例,使用相同的key和具有唯一性的value(例如UUID)獲取鎖。當(dāng)向Redis
請(qǐng)求獲取鎖時(shí),客戶(hù)端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如你的鎖自動(dòng)失效時(shí)間為10秒,則超時(shí)時(shí)間應(yīng)該在5-50
毫秒之間。這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下,客戶(hù)端還在死死地等待響應(yīng)結(jié)果。如果服務(wù)器端沒(méi)有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶(hù)端應(yīng)該盡快嘗試去另外一個(gè)Redis
實(shí)例請(qǐng)求獲取鎖;
* 客戶(hù)端使用當(dāng)前時(shí)間減去開(kāi)始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間。當(dāng)且僅當(dāng)從大多數(shù)(N/2+1,這里是3個(gè)節(jié)點(diǎn))的Redis
節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功;
* 如果取到了鎖,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)。
* 如果因?yàn)槟承┰颍@取鎖失?。](méi)有在至少N/2+1個(gè)Redis實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過(guò)了有效時(shí)間),客戶(hù)端應(yīng)該在所有的Redis
實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒(méi)有加鎖成功,防止某些節(jié)點(diǎn)獲取到鎖但是客戶(hù)端沒(méi)有得到響應(yīng)而導(dǎo)致接下來(lái)的一段時(shí)間不能被重新獲取鎖)。
2.1.2 解鎖
向所有的Redis實(shí)例發(fā)送釋放鎖命令即可,不用關(guān)心之前有沒(méi)有從Redis實(shí)例成功獲取到鎖.
2.2 案例(商品超賣(mài)為例)
這部分以最常見(jiàn)的案例:搶購(gòu)時(shí)的商品超賣(mài)(庫(kù)存數(shù)減少為負(fù)數(shù))為例
2.2.1 準(zhǔn)備
* good表 CREATE TABLE `good` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT
'主鍵id', `good_name` varchar(255) NOT NULL COMMENT '商品名稱(chēng)', `good_counts`
int(255) NOT NULL COMMENT '商品庫(kù)存', `create_time` timestamp NOT NULL ON UPDATE
CURRENT_TIMESTAMP COMMENT '創(chuàng)建時(shí)間', PRIMARY KEY (`id`) ) ENGINE=InnoDB
AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; -- 插入兩條測(cè)試數(shù)據(jù) INSERT INTO
`good` VALUES (1, '哇哈哈', 5, '2019-09-20 17:39:04'); INSERT INTO `good` VALUES
(2, '衛(wèi)龍', 5, '2019-09-20 17:39:06');
* 配置文件跟上面一樣
2.2.2 核心實(shí)現(xiàn)
* Redisson 配置類(lèi) RedissonConfig.java
我這里配置的是單機(jī),更多配置詳見(jiàn)https://github.com/redisson/redisson/wiki/配置
<https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95>
@Configuration public class RedissonConfig { @Value("${spring.redis.host}")
private String host; @Value("${spring.redis.port}") private String port;
@Value("${spring.redis.password}") private String password; /** *
RedissonClient,單機(jī)模式 * @return * @throws IOException */ @Bean public
RedissonClient redissonSentinel() { //支持單機(jī),主從,哨兵,集群等模式,此為單機(jī)模式 Config config =
new Config(); config.useSingleServer() .setAddress("redis://" + host + ":" +
port) .setPassword(password); return Redisson.create(config); } }
* 不加鎖時(shí) @Override public HttpResult saleGoods(){ // 以指定goodId = 1:哇哈哈為例 Long
goodId = 1L; GoodDO goodDO = goodMapper.selectByPrimaryKey(goodId); int
goodStock = goodDO.getGoodCounts(); if (goodStock >= 1) {
goodMapper.saleOneGood(goodId); } return HttpResult.success(); }
* 加鎖 @Override public HttpResult saleGoodsLock(){ // 以指定goodId = 2:衛(wèi)龍為例 Long
goodId = 2L; GoodDO goodDO = goodMapper.selectByPrimaryKey(goodId); int
goodStock = goodDO.getGoodCounts(); String key = goodDO.getGoodName();
log.info("{}剩余總庫(kù)存,{}件", key,goodStock); // 將商品的實(shí)時(shí)庫(kù)存放在redis 中,便于讀取
stringRedisTemplate.opsForValue().set(key, Integer.toString(goodStock)); //
redisson 鎖 的key String lockKey = goodDO.getId() +"_" + key; RLock lock =
redissonClient.getLock(lockKey); // 設(shè)置60秒自動(dòng)釋放鎖 (默認(rèn)是30秒自動(dòng)過(guò)期) lock.lock(60,
TimeUnit.SECONDS); // 此步開(kāi)始,串行銷(xiāo)售 int stock =
Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); //
如果緩存中庫(kù)存量大于1,可以繼續(xù)銷(xiāo)售 if (stock >= 1) { goodDO.setGoodCounts(stock - 1); int num =
goodMapper.saleOneGood(goodId); if (num == 1) { // 減庫(kù)存成功,將緩存同步
stringRedisTemplate.opsForValue().set(key,Integer.toString((stock-1))); }
log.info("{},當(dāng)前庫(kù)存,{}件", key,stock); } lock.unlock(); return
HttpResult.success(); }
2.3 測(cè)試
采用的是JMeter工具進(jìn)行測(cè)試,初始化的時(shí)候兩個(gè)商品的庫(kù)存設(shè)置都是:5;所以這里加鎖和不加鎖的情況都設(shè)置成:十次并發(fā)請(qǐng)求。
2.3.1 不加鎖
/** * 售賣(mài)商品(不加鎖) * @return */ @PostMapping("/saleGoods") public HttpResult
saleGoods() { return redisLockService.saleGoods(); }
* 請(qǐng)求方式:POST
* 請(qǐng)求地址:http://localhost:8080/redisLock/saleGoods
<http://localhost:8080/redisLock/saleGoods>
* 返回結(jié)果:id =1的商品庫(kù)存減為-5
2.3.2 加鎖
/** * 售賣(mài)商品(加鎖) * @return */ @PostMapping("/saleGoodsLock") public HttpResult
saleGoodsLock() { return redisLockService.saleGoodsLock(); }
* 請(qǐng)求方式:POST
* 請(qǐng)求地址:http://localhost:8080/redisLock/saleGoodsLock
<http://localhost:8080/redisLock/saleGoodsLock>
* 返回結(jié)果:id =1的商品庫(kù)存減為0
2.3.3 小結(jié)
通過(guò)2.3.1和2.3.2的結(jié)果對(duì)比很明顯:前者出現(xiàn)了超賣(mài)情況,庫(kù)存數(shù)賣(mài)到了-5,這是決不允許的;而加了鎖的情況后,庫(kù)存只會(huì)減少到0,便不再銷(xiāo)售。
三、總結(jié)
再次說(shuō)明:以上代碼不全,如需嘗試,請(qǐng)前往Van 的 Github 查看完整示例代碼
第一種基于Redis的分布式鎖并不適合用于生產(chǎn)環(huán)境。Redisson 可用于生產(chǎn)環(huán)境。當(dāng)然,分布式的選擇還有Zookeeper
的選項(xiàng),Van后續(xù)會(huì)整理出來(lái)供大家參考。
3.1 示例源碼地址
https://github.com/vanDusty/SpringBoot-Home/tree/master/springboot-demo-lock/redis-lock
<https://github.com/vanDusty/SpringBoot-Home/tree/master/springboot-demo-lock/redis-lock>
3.2 技術(shù)交流
* 風(fēng)塵博客 <https://www.dustyblog.cn/>
* 風(fēng)塵博客-掘金 <https://juejin.im/user/5d5ea68e6fb9a06afa328f56/posts>
* 風(fēng)塵博客-CSDN <https://blog.csdn.net/weixin_42036952>
熱門(mén)工具 換一換
