當前位置: 妍妍網 > 碼農

分布式介面防抖終極解決方案,如何避免重復送出!

2024-06-24碼農

防抖技術

防抖(Debouncing)是一種編程技術,用於控制事件處理常式的執行頻率。在使用者與界面互動頻繁的場景中,比如連續捲動、連續輸入等,如果每次互動都觸發事件處理常式,可能會導致效能問題或不必要的資料庫操作。

防抖技術透過設定一個延遲時間,在這段時間內,即使觸發了多次事件,事件處理常式也只會在延遲時間結束後執行一次。如果在這個延遲時間內再次觸發事件,那麽之前的延遲會被重設,重新開始計算延遲時間。這樣,只有最後一次事件觸發後,延遲時間結束後,事件處理常式才會執行。

防抖技術常用於以下場景

  • 搜尋框輸入 :使用者連續輸入時,只有輸入停止一段時間後才觸發搜尋請求。

  • 視窗調整大小 :使用者調整視窗大小時,只有調整結束後才執行相關操作。

  • 捲動事件 :使用者捲動頁面時,只有捲動停止一段時間後才進行數據處理。

  • 解決方案

    在Web系統的互動設計中,表單送出是一個核心功能,但若不加以適當控制,使用者誤操作或網路的不穩定性都可能導致同一請求被重復發送,從而產生冗余數據。為了應對這一挑戰,我們可以從兩個層面進行最佳化:

    前端防抖 :透過在使用者介面上實作按鈕的載入狀態(loading state),可以有效防止使用者因手抖而重復點選,從而避免前端生成多個請求。

    後端防抖 :對於由網路波動引起的請求重發問題,前端的控制措施顯得不夠充分。因此,後端需要引入防抖邏輯,透過辨識請求的唯一性(例如使用請求ID或時間戳),確保即便在網路不穩定的情況下,同一請求也不會被重復處理。

    防抖策略是確保Web系統穩定性和數據一致性的關鍵。前端的防抖措施提升了使用者體驗,而後端的防抖措施則保障了數據的準確性和系統的健壯性。兩者結合,可以構建一個更加穩定和使用者友好的Web套用環境。透過這種雙重保障,我們可以有效地減少因誤操作或網路問題導致的重復請求,維護系統的高效執行。

    防抖場景

    在Web系統中,並非所有介面都需要防抖,但以下型別的介面通常可以從防抖機制中獲益:

    表單輸入場景

  • 搜尋框輸入 :使用者在搜尋框中輸入時,可能會觸發即時搜尋或自動完成功能。防抖可以減少因快速輸入導致的頻繁請求。

  • 表單輸入 :尤其是那些包含多個欄位或需要進行復雜驗證的表單,防抖可以避免使用者因誤操作而重復送出。

  • 按鈕點選場景

    按鈕點選類介面,如送出表單或保存設定,使用者在操作過程中可能會因各種原因頻繁點選按鈕,這不僅可能影響使用者體驗,還可能導致不必要的伺服器請求,增加系統負擔。

    為了防止使用者因急促操作而導致的頻繁請求。透過設定一個短暫的等待時間,只有在使用者停止點選達到預設的時間閾值後,才會觸發實際的請求發送。這種方法不僅減少了伺服器的負擔,也避免了因重復請求而可能產生的數據錯誤或沖突。

    捲動載入場景

    在捲動載入類介面中,如下拉重新整理、上拉載入等,使用者的操作往往伴隨著連續的捲動動作。為了提升系統效率並避免因頻繁觸發而導致的效能問題。透過設定一個合理的時間間隔,只有在使用者捲動動作停止一段時間後,系統才會執行請求發送,從而實作智慧的請求管理。

    如何防抖

    使用共享緩存

    圖片來源:https://developer.aliyun.com/article/1541251

    使用分布式鎖

    圖片來源:https://developer.aliyun.com/article/1541251

    常見的分布式元件有Redis、Zookeeper等,但結合實際業務來看,一般都會選擇Redis,因為Redis一般都是Web系統必備的元件,不需要額外搭建。

    程式碼實作

    模仿一個使用者添加介面

    目前資料庫表中沒有對 mobile 欄位做UK唯一索引限制,這就會導致每呼叫一次 userAdd 就會建立一個使用者,即使 mobile 相同。

    demo_user 表結構

    CREATETABLE`demo_user` (
    `id`int(11NOTNULL AUTO_INCREMENT,
    `username`varchar(32NOTNULL,
    `mobile`char(13NOTNULL,
    PRIMARY KEY (`id`)
    ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;

    分布式鎖

    RedisLock.php

    <?php
    /**
     * @desc RedisLock.php 描述資訊
     * @author Tinywan(ShaoBo Wan)
     * @date 2024/6/23 8:31
     */

    declare(strict_types=1);
    namespaceapp\common\service;
    usesupport\Redis;
    classRedisLock
    {
    // 分布式並行鎖
    const DISTRIBUTED_CONCURRENT_LOCK = 'DISTRIBUTED_CONCURRENT_LOCK:';
    /**
    @desc: 獲取鎖
    @param string $lock_name
    @param int $acquire_time
    @param int $lock_timeout
    @return bool|string
    @author Tinywan(ShaoBo Wan)
    */

    publicstaticfunctiongetLockWithTimeout(string $lock_name, int $acquire_time = 3, int $lock_timeout = 20)
    {
    $identifier = md5($_SERVER['REQUEST_TIME'] . mt_rand(110000000));
    $lock_name = self::DISTRIBUTED_CONCURRENT_LOCK . $lock_name;
    $lock_timeout = intval(ceil($lock_timeout));
    $end_time = time() + $acquire_time;
    while (time() < $end_time) {
    $script = <<<luascript
    local result = redis.call('setnx',KEYS[1],ARGV[1]);
    if result == 1 then
    return redis.call('expire',KEYS[1],ARGV[2])
    elseif redis.call('ttl',KEYS[1]) == -1 then
    return redis.call('expire',KEYS[1],ARGV[2]) -- 續租(renew)
    else
    return 0
    end
    luascript;

    $result = Redis::eval($script, 1, $lock_name, $identifier, $lock_timeout);
    if ($result === 1) {
    return $identifier;
    }
    usleep(100000);
    }
    returnfalse;
    }
    /**
    @desc: 釋放鎖
    @param string $lock_name
    @param string $identifier
    @return bool
    @author Tinywan(ShaoBo Wan)
    */

    publicstaticfunctionreleaseLock(string $lock_name, string $identifier)bool
    {
    $lock_name = self::DISTRIBUTED_CONCURRENT_LOCK . $lock_name;
    while (true) {
    $script = <<<luascript
    if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1]);
    else
    return 0
    end
    luascript;

    $result = Redis::eval($script, 1, $lock_name, $identifier);
    if ($result == 1) {
    returntrue;
    }
    break;
    }
    returnfalse;
    }
    }



    業務程式碼實作

    <?php
    /**
     * @desc Demo.php 描述資訊
     * @author Tinywan(ShaoBo Wan)
     * @date 2024/6/23 20:14
     */

    declare(strict_types=1);
    namespaceapp\controller;
    useapp\common\service\RedisLock;
    usesupport\Request;
    usesupport\Response;
    useTinywan\ExceptionHandler\Exception\BadRequestHttpException;
    useTinywan\ExceptionHandler\Exception\ServerErrorHttpException;
    classDemoController
    {
    /**
    @desc 使用者添加
    @param Request $request
    @return Response
    @throws BadRequestHttpException
    @throws ServerErrorHttpException
    @author Tinywan(ShaoBo Wan)
    */

    publicfunctionuserAdd(Request $request)Response
    {
    $param = $request->post();
    /** 鎖名稱 */
    $lockName = (string) $param['mobile'];
    /** 嘗試獲取搶占鎖標識 */
    $lockIdentifier = RedisLock::getLockWithTimeout($lockName);
    /** 沒有拿到鎖說明已經有了請求了 */
    if (false === $lockIdentifier) {
    thrownew BadRequestHttpException('您的操作太快啦!請不要連續點選送出');
    }
    try {
    /** 進行業務處理 */
    \think\facade\Db::table('demo_user')->insert($param);
    /** 進行業務處理 */
    catch (\Throwable $throwable) {
    /** 釋放鎖 */
    RedisLock::releaseLock($lockName, $lockIdentifier);
    thrownew ServerErrorHttpException('系統異常:' . $throwable->getMessage());
    }
    /** 釋放鎖 */
    RedisLock::releaseLock($lockName, $lockIdentifier);
    return json(['code' => 200'msg' => 'success']);
    }
    }


    RedisLock的核心思路就是搶鎖,當一次請求搶到鎖之後,對鎖加一個過期時間,在這個時間段內重復的請求是無法獲得這個鎖。

    驗證分布式鎖

    正確送出

    後端異常送出

    後端未響應之前送出

    相同時間段內重復,鎖釋放剩余時間