併發與鎖的心得分享

mane115發表於2019-02-16

併發場景

  • 秒殺

    秒殺系統是可以籠統的稱為多使用者對同一資源發起請求,正確響應次數少於使用者請求量。此時最安全的做法是使用悲觀鎖,資料級層面的鎖,例如oracle的sql:select for update.但是悲觀鎖的缺點在高併發場景也是很明顯,就是允許的併發量低,容易造成504,就像安檢一樣,一次只能通過一個人,效率和體驗都十分低下。
    所以應該使用樂觀鎖,或者利用redis的原子性做併發量限制,再使用mq進行任務分發。

    正常的流程:

    使用者下單->redis併發庫存鎖,減少庫存->通過mq生產訂單任務->mq消費者消費任務,生成訂單以及更新庫存一系列操作。

    • 樂觀鎖:

    redis原生提供樂觀鎖 watch,watch是基於連結的,而主流nodejs裡面模組redis是基於pipeline做的,無連線池,所以,watch單單基於業務來說對於nodejs並無作用,只要正確利用好redis的原子性即可。
    但是系統大了以後就會牽扯到叢集的問題,在多系統(多連結)的設計下,watch就尤為重要了,個人認為watch可以提供一個“次級操作”的空間,對於秒殺系統來說,庫存的更新與秒殺業務是可以同事存在的
    例如:賣家在秒殺期間補充庫存、由於業務問題鎖住庫存等。這個時候watch可以提供優先順序,即當管理員鎖住庫存(清零)與多個買家發起秒殺同一時間發出請求,可以保證管理員的請求是正確通過的,而買家由於更新庫存,該次請求失效。

    const redis = Redis.createClient();
    
    let lock = async function(key) {
      let transactionStatus = false;
      await redis.watchAsync(key);
      let stock = await redis.getAsync(key);
      if(+stock < 1) {
        //庫存不足的情況
      }
      let reply = await redis.multi().decr(key).execAsync();
      if(!reply) {
        // 當事務失敗的時候reply為null,進行錯誤處理
      } else if(reply[0] < 0) {
        // 當事務成功的時候返回array,multi可理解為Promise.all相當
        // 健壯處理超賣情況,此時應該補redis的庫存避免以後因為負數庫存導致以後補充庫存出錯,並且與事務失敗執行一致操作
        redis.incr(key);
      } else {
        // 事務成功且正確減少庫存的時候
        transactionStatus = true;
      }   
      return transactionStatus;
    };
    
    let produceOrder = function(orderId,userId){
      let payload = JSON.stringify({
        orderId,
        userId
      });
      let productor = new Productor();
      return productor.produce(TOPIC.ORDER,payload);
    };
    
    let buy = async function(orderId,userId){
      let key = `lock:order:${orderId}:${userId}`;
      let lockResult = await lock(key);
      if(lockResult) {
        await produceOrder(orderId,userId);
      }
      return Promise.resolve();
    };
  • 關注

    關注併發可以作為同一使用者對於同一資源重複請求的代表,例如:在網路差的時候,使用者點選關注某人的時候由於相應過慢,同時發出了多次請求。這個相當於是過濾無用請求,客戶端需要做相應處理,但是一個健壯的後臺,也必須要考慮這種情況。

    • 正規化設計的資料庫可以利用設定唯一聯合索引來避免

    • 可利用redis的set、hash、bitmap等資料結構做去重

    • 如果是反正規化設計(類似mongo的內嵌陣列設計),可利用db層上($in、$addToSet等操作符)做去重

    • 使用悲觀鎖,從邏輯層做去重

    可以利用redis的原子性或setex操作來完成悲觀鎖:

    const redis = Redis.createClient();
    
    let checkLock = async function(key){
        let ttl = await redis.ttlAsync(key);
        if(+ttl === -1) {
          //如果ttl為-1,為無過期時間,立即設定過期時間避免死鎖
          redis.expire(key, 10)
        }
        return Promise.resolve();
    };
    
    let lock = async function(key){
      let result = await redis.incrAsync(key);
      if(+result !== 1) {
        checkLock(key)
        //已被鎖住,進行錯誤處理
      } else {
        //成功上鎖,設定過期時間避免死鎖
        redis.expire(key, 10);
      }
      return Promise.resolve();
    };
    
    let releaseLock = function(key){
      return redis.setAsync(key,0)
    };
    
    let follow = async function(followId,userId){
      let key = `lock:follow:${followId}:${userId}`;
      try {
        await lock(key);
        // 關注邏輯:查詢、遍歷、插入等
      } catch(err) {
        // 錯誤處理
      } finally {
        await releaseLock(key)
      }
    };

相關文章