Redis 分布式鎖的 5個(gè)坑,真是又大又深
引言
最近項(xiàng)目上線的頻率頗高,連著幾天加班熬夜,身體有點(diǎn)吃不消精神也有些萎靡,無奈業(yè)務(wù)方催的緊,工期就在眼前只能硬著頭皮上了。腦子渾渾噩噩的時(shí)候,寫的就不能叫代碼,可以直接叫做Bug。我就熬夜寫了一個(gè)bug被罵慘了。
由于是做商城業(yè)務(wù),要頻繁的對(duì)商品庫存進(jìn)行扣減,應(yīng)用是集群部署,為避免并發(fā)造成庫存超買超賣等問題,采用 redis 分布式鎖加以控制。本以為給扣庫存的代碼加上鎖lock.tryLock就萬事大吉了
- /**
- * @author xiaofu
- * @description 扣減庫存
- * @date 2020/4/21 12:10
- */
- public String stockLock() {
- RLock lock = redissonClient.getLock("stockLock");
- try {
- /**
- * 獲取鎖
- */
- if (lock.tryLock(10, TimeUnit.SECONDS)) {
- /**
- * 查詢庫存數(shù)
- */
- Integer stock = Integer.valueOf(stringRedisTemplate.opsForValue().get("stockCount"));
- /**
- * 扣減庫存
- */
- if (stock > 0) {
- stock = stock - 1;
- stringRedisTemplate.opsForValue().set("stockCount", stock.toString());
- LOGGER.info("庫存扣減成功,剩余庫存數(shù)量:{}", stock);
- } else {
- LOGGER.info("庫存不足~");
- }
- } else {
- LOGGER.info("未獲取到鎖業(yè)務(wù)結(jié)束..");
- }
- } catch (Exception e) {
- LOGGER.info("處理異常", e);
- } finally {
- lock.unlock();
- }
- return "ok";
- }
結(jié)果業(yè)務(wù)代碼執(zhí)行完以后我忘了釋放鎖lock.unlock(),導(dǎo)致redis線程池被打滿,redis服務(wù)大面積故障,造成庫存數(shù)據(jù)扣減混亂,被領(lǐng)導(dǎo)一頓臭罵,這個(gè)月績效~ 哎·~。
隨著 使用redis 鎖的時(shí)間越長,我發(fā)現(xiàn) redis 鎖的坑遠(yuǎn)比想象中要多。就算在面試題當(dāng)中redis分布式鎖的出鏡率也比較高,比如:“用鎖遇到過哪些問題?” ,“又是如何解決的?” 基本都是一套連招問出來的。
今天就分享一下我用redis 分布式鎖的踩坑日記,以及一些解決方案,和大家一起共勉。
一、鎖未被釋放
這種情況是一種低級(jí)錯(cuò)誤,就是我上邊犯的錯(cuò),由于當(dāng)前線程 獲取到redis 鎖,處理完業(yè)務(wù)后未及時(shí)釋放鎖,導(dǎo)致其它線程會(huì)一直嘗試獲取鎖阻塞,例如:用Jedis客戶端會(huì)報(bào)如下的錯(cuò)誤信息
- redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
redis線程池已經(jīng)沒有空閑線程來處理客戶端命令。
解決的方法也很簡單,只要我們細(xì)心一點(diǎn),拿到鎖的線程處理完業(yè)務(wù)及時(shí)釋放鎖,如果是重入鎖未拿到鎖后,線程可以釋放當(dāng)前連接并且sleep一段時(shí)間。
- public void lock() {
- while (true) {
- boolean flag = this.getLock(key);
- if (flag) {
- TODO .........
- } else {
- // 釋放當(dāng)前redis連接
- redis.close();
- // 休眠1000毫秒
- sleep(1000);
- }
- }
- }
二、B的鎖被A給釋放了
我們知道Redis實(shí)現(xiàn)鎖的原理在于 SETNX命令。當(dāng) key不存在時(shí)將 key的值設(shè)為 value ,返回值為 1;若給定的 key已經(jīng)存在,則 SETNX不做任何動(dòng)作,返回值為 0 。
- SETNX key value
我們來設(shè)想一下這個(gè)場景:A、B兩個(gè)線程來嘗試給key myLock加鎖,A線程先拿到鎖(假如鎖3秒后過期),B線程就在等待嘗試獲取鎖,到這一點(diǎn)毛病沒有。
那如果此時(shí)業(yè)務(wù)邏輯比較耗時(shí),執(zhí)行時(shí)間已經(jīng)超過redis鎖過期時(shí)間,這時(shí)A線程的鎖自動(dòng)釋放(刪除key),B線程檢測(cè)到myLock這個(gè)key不存在,執(zhí)行 SETNX命令也拿到了鎖。
但是,此時(shí)A線程執(zhí)行完業(yè)務(wù)邏輯之后,還是會(huì)去釋放鎖(刪除key),這就導(dǎo)致B線程的鎖被A線程給釋放了。
為避免上邊的情況,一般我們?cè)诿總€(gè)線程加鎖時(shí)要帶上自己獨(dú)有的value值來標(biāo)識(shí),只釋放指定value的key,否則就會(huì)出現(xiàn)釋放鎖混亂的場景。
三、數(shù)據(jù)庫事務(wù)超時(shí)
emm~ 聊redis鎖咋還扯到數(shù)據(jù)庫事務(wù)上來了?別著急往下看,看下邊這段代碼:
- @Transaction
- public void lock() {
- while (true) {
- boolean flag = this.getLock(key);
- if (flag) {
- insert();
- }
- }
- }
給這個(gè)方法添加一個(gè)@Transaction注解開啟事務(wù),如代碼中拋出異常進(jìn)行回滾,要知道數(shù)據(jù)庫事務(wù)可是有超時(shí)時(shí)間限制的,并不會(huì)無條件的一直等一個(gè)耗時(shí)的數(shù)據(jù)庫操作。
比如:我們解析一個(gè)大文件,再將數(shù)據(jù)存入到數(shù)據(jù)庫,如果執(zhí)行時(shí)間太長,就會(huì)導(dǎo)致事務(wù)超時(shí)自動(dòng)回滾。
一旦你的key長時(shí)間獲取不到鎖,獲取鎖等待的時(shí)間遠(yuǎn)超過數(shù)據(jù)庫事務(wù)超時(shí)時(shí)間,程序就會(huì)報(bào)異常。
一般為解決這種問題,我們就需要將數(shù)據(jù)庫事務(wù)改為手動(dòng)提交、回滾事務(wù)。
- @Autowired
- DataSourceTransactionManager dataSourceTransactionManager;
- @Transaction
- public void lock() {
- //手動(dòng)開啟事務(wù)
- TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition);
- try {
- while (true) {
- boolean flag = this.getLock(key);
- if (flag) {
- insert();
- //手動(dòng)提交事務(wù)
- dataSourceTransactionManager.commit(transactionStatus);
- }
- }
- } catch (Exception e) {
- //手動(dòng)回滾事務(wù)
- dataSourceTransactionManager.rollback(transactionStatus);
- }
- }
四、鎖過期了,業(yè)務(wù)還沒執(zhí)行完
這種情況和我們上邊提到的第二種比較類似,但解決思路上略有不同。
同樣是redis分布式鎖過期,而業(yè)務(wù)邏輯沒執(zhí)行完的場景,不過,這里換一種思路想問題,把redis鎖的過期時(shí)間再弄長點(diǎn)不就解決了嗎?
那還是有問題,我們可以在加鎖的時(shí)候,手動(dòng)調(diào)長redis鎖的過期時(shí)間,可這個(gè)時(shí)間多長合適?業(yè)務(wù)邏輯的執(zhí)行時(shí)間是不可控的,調(diào)的過長又會(huì)影響操作性能。
要是redis鎖的過期時(shí)間能夠自動(dòng)續(xù)期就好了。
為了解決這個(gè)問題我們使用redis客戶端redisson,redisson很好的解決了redis在分布式環(huán)境下的一些棘手問題,它的宗旨就是讓使用者減少對(duì)Redis的關(guān)注,將更多精力用在處理業(yè)務(wù)邏輯上。
redisson對(duì)分布式鎖做了很好封裝,只需調(diào)用API即可。
- RLock lock = redissonClient.getLock("stockLock");
redisson在加鎖成功后,會(huì)注冊(cè)一個(gè)定時(shí)任務(wù)監(jiān)聽這個(gè)鎖,每隔10秒就去查看這個(gè)鎖,如果還持有鎖,就對(duì)過期時(shí)間進(jìn)行續(xù)期。默認(rèn)過期時(shí)間30秒。這個(gè)機(jī)制也被叫做:“看門狗”,這名字。。。
舉例子:假如加鎖的時(shí)間是30秒,過10秒檢查一次,一旦加鎖的業(yè)務(wù)沒有執(zhí)行完,就會(huì)進(jìn)行一次續(xù)期,把鎖的過期時(shí)間再次重置成30秒。
通過分析下邊redisson的源碼實(shí)現(xiàn)可以發(fā)現(xiàn),不管是加鎖、解鎖、續(xù)約都是客戶端把一些復(fù)雜的業(yè)務(wù)邏輯,通過封裝在Lua腳本中發(fā)送給redis,保證這段復(fù)雜業(yè)務(wù)邏輯執(zhí)行的原子性。
- @Slf4j
- @Service
- public class RedisDistributionLockPlus {
- /**
- * 加鎖超時(shí)時(shí)間,單位毫秒, 即:加鎖時(shí)間內(nèi)執(zhí)行完操作,如果未完成會(huì)有并發(fā)現(xiàn)象
- */
- private static final long DEFAULT_LOCK_TIMEOUT = 30;
- private static final long TIME_SECONDS_FIVE = 5 ;
- /**
- * 每個(gè)key的過期時(shí)間 {@link LockContent}
- */
- private Map<String, LockContent> lockContentMap = new ConcurrentHashMap<>(512);
- /**
- * redis執(zhí)行成功的返回
- */
- private static final Long EXEC_SUCCESS = 1L;
- /**
- * 獲取鎖lua腳本, k1:獲鎖key, k2:續(xù)約耗時(shí)key, arg1:requestId,arg2:超時(shí)時(shí)間
- */
- private static final String LOCK_SCRIPT = "if redis.call('exists', KEYS[2]) == 1 then ARGV[2] = math.floor(redis.call('get', KEYS[2]) + 10) end " +
- "if redis.call('exists', KEYS[1]) == 0 then " +
- "local t = redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) " +
- "for k, v in pairs(t) do " +
- "if v == 'OK' then return tonumber(ARGV[2]) end " +
- "end " +
- "return 0 end";
- /**
- * 釋放鎖lua腳本, k1:獲鎖key, k2:續(xù)約耗時(shí)key, arg1:requestId,arg2:業(yè)務(wù)耗時(shí) arg3: 業(yè)務(wù)開始設(shè)置的timeout
- */
- private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
- "local ctime = tonumber(ARGV[2]) " +
- "local biz_timeout = tonumber(ARGV[3]) " +
- "if ctime > 0 then " +
- "if redis.call('exists', KEYS[2]) == 1 then " +
- "local avg_time = redis.call('get', KEYS[2]) " +
- "avg_time = (tonumber(avg_time) * 8 + ctime * 2)/10 " +
- "if avg_time >= biz_timeout - 5 then redis.call('set', KEYS[2], avg_time, 'EX', 24*60*60) " +
- "else redis.call('del', KEYS[2]) end " +
- "elseif ctime > biz_timeout -5 then redis.call('set', KEYS[2], ARGV[2], 'EX', 24*60*60) end " +
- "end " +
- "return redis.call('del', KEYS[1]) " +
- "else return 0 end";
- /**
- * 續(xù)約lua腳本
- */
- private static final String RENEW_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
- private final StringRedisTemplate redisTemplate;
- public RedisDistributionLockPlus(StringRedisTemplate redisTemplate) {
- this.redisTemplate = redisTemplate;
- ScheduleTask task = new ScheduleTask(this, lockContentMap);
- // 啟動(dòng)定時(shí)任務(wù)
- ScheduleExecutor.schedule(task, 1, 1, TimeUnit.SECONDS);
- }
- /**
- * 加鎖
- * 取到鎖加鎖,取不到鎖一直等待知道獲得鎖
- *
- * @param lockKey
- * @param requestId 全局唯一
- * @param expire 鎖過期時(shí)間, 單位秒
- * @return
- */
- public boolean lock(String lockKey, String requestId, long expire) {
- log.info("開始執(zhí)行加鎖, lockKey ={}, requestId={}", lockKey, requestId);
- for (; ; ) {
- // 判斷是否已經(jīng)有線程持有鎖,減少redis的壓力
- LockContent lockContentOld = lockContentMap.get(lockKey);
- boolean unLocked = null == lockContentOld;
- // 如果沒有被鎖,就獲取鎖
- if (unLocked) {
- long startTime = System.currentTimeMillis();
- // 計(jì)算超時(shí)時(shí)間
- long bizExpire = expire == 0L ? DEFAULT_LOCK_TIMEOUT : expire;
- String lockKeyRenew = lockKey + "_renew";
- RedisScript<Long> script = RedisScript.of(LOCK_SCRIPT, Long.class);
- List<String> keys = new ArrayList<>();
- keys.add(lockKey);
- keys.add(lockKeyRenew);
- Long lockExpire = redisTemplate.execute(script, keys, requestId, Long.toString(bizExpire));
- if (null != lockExpire && lockExpire > 0) {
- // 將鎖放入map
- LockContent lockContent = new LockContent();
- lockContent.setStartTime(startTime);
- lockContent.setLockExpire(lockExpire);
- lockContent.setExpireTime(startTime + lockExpire * 1000);
- lockContent.setRequestId(requestId);
- lockContent.setThread(Thread.currentThread());
- lockContent.setBizExpire(bizExpire);
- lockContent.setLockCount(1);
- lockContentMap.put(lockKey, lockContent);
- log.info("加鎖成功, lockKey ={}, requestId={}", lockKey, requestId);
- return true;
- }
- }
- // 重復(fù)獲取鎖,在線程池中由于線程復(fù)用,線程相等并不能確定是該線程的鎖
- if (Thread.currentThread() == lockContentOld.getThread()
- && requestId.equals(lockContentOld.getRequestId())){
- // 計(jì)數(shù) +1
- lockContentOld.setLockCount(lockContentOld.getLockCount()+1);
- return true;
- }
- // 如果被鎖或獲取鎖失敗,則等待100毫秒
- try {
- TimeUnit.MILLISECONDS.sleep(100);
- } catch (InterruptedException e) {
- // 這里用lombok 有問題
- log.error("獲取redis 鎖失敗, lockKey ={}, requestId={}", lockKey, requestId, e);
- return false;
- }
- }
- }
- /**
- * 解鎖
- *
- * @param lockKey
- * @param lockValue
- */
- public boolean unlock(String lockKey, String lockValue) {
- String lockKeyRenew = lockKey + "_renew";
- LockContent lockContent = lockContentMap.get(lockKey);
- long consumeTime;
- if (null == lockContent) {
- consumeTime = 0L;
- } else if (lockValue.equals(lockContent.getRequestId())) {
- int lockCount = lockContent.getLockCount();
- // 每次釋放鎖, 計(jì)數(shù) -1,減到0時(shí)刪除redis上的key
- if (--lockCount > 0) {
- lockContent.setLockCount(lockCount);
- return false;
- }
- consumeTime = (System.currentTimeMillis() - lockContent.getStartTime()) / 1000;
- } else {
- log.info("釋放鎖失敗,不是自己的鎖。");
- return false;
- }
- // 刪除已完成key,先刪除本地緩存,減少redis壓力, 分布式鎖,只有一個(gè),所以這里不加鎖
- lockContentMap.remove(lockKey);
- RedisScript<Long> script = RedisScript.of(UNLOCK_SCRIPT, Long.class);
- List<String> keys = new ArrayList<>();
- keys.add(lockKey);
- keys.add(lockKeyRenew);
- Long result = redisTemplate.execute(script, keys, lockValue, Long.toString(consumeTime),
- Long.toString(lockContent.getBizExpire()));
- return EXEC_SUCCESS.equals(result);
- }
- /**
- * 續(xù)約
- *
- * @param lockKey
- * @param lockContent
- * @return true:續(xù)約成功,false:續(xù)約失?。?、續(xù)約期間執(zhí)行完成,鎖被釋放 2、不是自己的鎖,3、續(xù)約期間鎖過期了(未解決))
- */
- public boolean renew(String lockKey, LockContent lockContent) {
- // 檢測(cè)執(zhí)行業(yè)務(wù)線程的狀態(tài)
- Thread.State state = lockContent.getThread().getState();
- if (Thread.State.TERMINATED == state) {
- log.info("執(zhí)行業(yè)務(wù)的線程已終止,不再續(xù)約 lockKey ={}, lockContent={}", lockKey, lockContent);
- return false;
- }
- String requestId = lockContent.getRequestId();
- long timeOut = (lockContent.getExpireTime() - lockContent.getStartTime()) / 1000;
- RedisScript<Long> script = RedisScript.of(RENEW_SCRIPT, Long.class);
- List<String> keys = new ArrayList<>();
- keys.add(lockKey);
- Long result = redisTemplate.execute(script, keys, requestId, Long.toString(timeOut));
- log.info("續(xù)約結(jié)果,True成功,F(xiàn)alse失敗 lockKey ={}, result={}", lockKey, EXEC_SUCCESS.equals(result));
- return EXEC_SUCCESS.equals(result);
- }
- static class ScheduleExecutor {
- public static void schedule(ScheduleTask task, long initialDelay, long period, TimeUnit unit) {
- long delay = unit.toMillis(initialDelay);
- long period_ = unit.toMillis(period);
- // 定時(shí)執(zhí)行
- new Timer("Lock-Renew-Task").schedule(task, delay, period_);
- }
- }
- static class ScheduleTask extends TimerTask {
- private final RedisDistributionLockPlus redisDistributionLock;
- private final Map<String, LockContent> lockContentMap;
- public ScheduleTask(RedisDistributionLockPlus redisDistributionLock, Map<String, LockContent> lockContentMap) {
- this.redisDistributionLock = redisDistributionLock;
- this.lockContentMap = lockContentMap;
- }
- @Override
- public void run() {
- if (lockContentMap.isEmpty()) {
- return;
- }
- Set<Map.Entry<String, LockContent>> entries = lockContentMap.entrySet();
- for (Map.Entry<String, LockContent> entry : entries) {
- String lockKey = entry.getKey();
- LockContent lockContent = entry.getValue();
- long expireTime = lockContent.getExpireTime();
- // 減少線程池中任務(wù)數(shù)量
- if ((expireTime - System.currentTimeMillis())/ 1000 < TIME_SECONDS_FIVE) {
- //線程池異步續(xù)約
- ThreadPool.submit(() -> {
- boolean renew = redisDistributionLock.renew(lockKey, lockContent);
- if (renew) {
- long expireTimeNew = lockContent.getStartTime() + (expireTime - lockContent.getStartTime()) * 2 - TIME_SECONDS_FIVE * 1000;
- lockContent.setExpireTime(expireTimeNew);
- } else {
- // 續(xù)約失敗,說明已經(jīng)執(zhí)行完 OR redis 出現(xiàn)問題
- lockContentMap.remove(lockKey);
- }
- });
- }
- }
- }
- }
- }
五、redis主從復(fù)制的坑
redis高可用最常見的方案就是主從復(fù)制(master-slave),這種模式也給redis分布式鎖挖了一坑。
redis cluster集群環(huán)境下,假如現(xiàn)在A客戶端想要加鎖,它會(huì)根據(jù)路由規(guī)則選擇一臺(tái)master節(jié)點(diǎn)寫入key mylock,在加鎖成功后,master節(jié)點(diǎn)會(huì)把key異步復(fù)制給對(duì)應(yīng)的slave節(jié)點(diǎn)。
如果此時(shí)redis master節(jié)點(diǎn)宕機(jī),為保證集群可用性,會(huì)進(jìn)行主備切換,slave變?yōu)榱藃edis master。B客戶端在新的master節(jié)點(diǎn)上加鎖成功,而A客戶端也以為自己還是成功加了鎖的。
此時(shí)就會(huì)導(dǎo)致同一時(shí)間內(nèi)多個(gè)客戶端對(duì)一個(gè)分布式鎖完成了加鎖,導(dǎo)致各種臟數(shù)據(jù)的產(chǎn)生。
至于解決辦法嘛,目前看還沒有什么根治的方法,只能盡量保證機(jī)器的穩(wěn)定性,減少發(fā)生此事件的概率。
總結(jié)
上面就是我在使用Redis 分布式鎖時(shí)遇到的一些坑,有點(diǎn)小感慨,經(jīng)常用一個(gè)方法填上這個(gè)坑,沒多久就發(fā)現(xiàn)另一個(gè)坑又出來了,其實(shí)根本沒有什么十全十美的解決方案,哪有什么銀彈,只不過是在權(quán)衡利弊后,選一個(gè)在接受范圍內(nèi)的折中方案而已。