用戶重覆註冊分析-多線程事務中加鎖引發的bug

来源:https://www.cnblogs.com/wayn111/archive/2022/12/10/16972525.html
-Advertisement-
Play Games

本文記錄博主線上項目一次用戶重覆註冊問題的分析過程與解決方案 博主github地址: github.com/wayn111 一 復現過程 線上客戶端用戶使用微信掃碼登陸時需要再綁定一個手機號,在綁定手機後,用戶購買客戶端商品下線再登錄,發現用戶賬號ID被變更,已經不是用戶剛綁定手機號時自動登錄的用戶 ...


本文記錄博主線上項目一次用戶重覆註冊問題的分析過程與解決方案

一 復現過程

線上客戶端用戶使用微信掃碼登陸時需要再綁定一個手機號,在綁定手機後,用戶購買客戶端商品下線再登錄,發現用戶賬號ID被變更,已經不是用戶剛綁定手機號時自動登錄的用戶賬號ID,查詢線上資料庫,發現同一個手機生成了多個賬號id,至此問題復現

二 分析過程

發現資料庫中一個手機號生成了多個用戶賬號,第一反應是用戶在綁定手機號過程中,多次點擊綁定按鈕,導致綁定介面被調用多次,造成多線程併發調用用戶註冊介面,進而生成多個賬號。為了驗證我們的猜想,直接查看綁定手機後的用戶註冊方法

/**
 * 根據用戶手機號進行註冊操作
 */
// 啟動@Transactional事務註解
@Transactional(rollbackFor = Exception.class)
public boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) {
    RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10);
    boolean lock;
    try {
        lock = redisLock.lock();
        // 使用redis分散式鎖
        if (lock) {
            // 查詢資料庫該用戶手機號是否插入成功,已存在則退出操作
            MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
            if (Objects.nonNull(member)) {
                resp.setResultFail(ReturnCodeEnum.USER_EXIST);
                return false;
            }
            // 執行用戶註冊操作,包含插入用戶表、訂單表、是否被邀請
            ...
        }
    } catch (Exception e) {
        log.error("用戶註冊失敗:", e);
        throw new Exception("用戶註冊失敗");
    } finally {
        redisLock.unLock();
    }
    // 添加註冊日誌,上報到數據分析平臺...
    return true;
}

初看代碼,在分散式環境中,先加分散式鎖保證同時只能被一個線程執行,然後判斷資料庫中是否存在用戶手機信息,已存在則退出,不存在則執行用戶註冊操作,咋以為邏輯上沒有問題,但是線上環境確實就是出現了相同手機號重覆註冊的問題,首先代碼被 @Transactional 註解包含,就是在自動事務中執行註冊邏輯

現在博主帶大家回憶一下,MySQL 事務的隔離級別有4個

  • Read uncommitted:讀取未提交,其他事務只要修改了數據,即使未提交,本事務也能看到修改後的數據值。
  • Read committed:讀取已提交,其他事務提交了對數據的修改後,本事務就能讀取到修改後的數據值。
  • Repeatable read:可重覆讀,無論其他事務是否修改並提交了數據,在這個事務中看到的數據值始終不受其他事務影響。
  • Serializable:串列化,一個事務一個事務的執行。
  • MySQL資料庫預設使用可重覆讀( Repeatable read)。

隔離級別越高,越能保證數據的完整性和一致性,但是對併發性能的影響也越大,MySQL的預設隔離級別是讀可重覆讀。在上述場景里,也就是說,無論其他線程事務是否提交了數據,當前線程所在事務中看到的數據值始終不受其他事務影響

說人話(劃重點):就是在 MySQL 中一個線程所在事務是讀不到另一個線程事務未提交的數據的

下麵結合上述代碼給出分析過程:上述註冊邏輯都包含在 Spring 提供的自動事務中,整個方法都在事務中。而加鎖也在事務中執行。最終導致我們註冊 線程B 在當前事物中查詢不到另一個註冊 線程A 所在事物未提交的數據, 舉個例子

eg:

  1. 當用戶執行註冊操作,重覆點擊註冊按鈕時,假設線程A和B同時執行到 redisLock.lock()時,假設線程A獲取到鎖,線程B進入自旋等待,線程A執行mapper.findByMobile(body.getAccount(), body.getRegRes())操作,發現用戶手機不存在資料庫中,進行註冊操作(添加用戶信息入庫等),執行完畢,釋放鎖。執行後續添加註冊日誌,上報到數據分析平臺操作,註意此時事務還未提交。
  1. 線程B終於獲取到鎖,執行mapper.findByMobile(body.getAccount(), body.getRegRes())操作,在我們一開始的假設中,以為這裡會返回用戶已存在,但是實際執行結果並不是這樣的。原因就是線程A的事務還未提交,線程B讀不到線程A未提交事務的數據也就是說查不到用戶已註冊信息,至此,我們知道了用戶重覆註冊的原因。

三 解決方案:

給出三種解決方案

3.1 修改事務範圍,將事務的操作代碼最小化,保證在加鎖結束前完成事務提交,代碼如下開啟手動事務,這樣其他線程在加鎖代碼塊中就能看到最新數據

@Autowired
private PlatformTransactionManager platformTransactionManager;

@Autowired
private TransactionDefinition transactionDefinition;

private boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) {
    RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10);
    boolean lock;
    TransactionStatus transaction = null;
    try {
        lock = redisLock.lock();
        // 使用redis分散式鎖
        if (lock) {
            // 查詢資料庫該用戶手機號是否插入成功,已存在則退出操作
            MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
            if (Objects.nonNull(member)) {
                resp.setResultFail(ReturnCodeEnum.USER_EXIST);
                return false;
            }
            // 手動開啟事務
            transaction = platformTransactionManager.getTransaction(transactionDefinition);
            // 執行用戶註冊操作,包含插入用戶表、訂單表、是否被邀請
            ...
            // 手動提交事務
            platformTransactionManager.commit(transaction);
            ...
        }
    } catch (Exception e) {
        log.error("用戶註冊失敗:", e);
        if (transaction != null) {
            platformTransactionManager.rollback(transaction);
        }
        return false;
    } finally {
        redisLock.unLock();
    }
    // 添加註冊日誌,上報到數據分析平臺...
    return true;
}

3.2 在用戶註冊時針對註冊介面添加防重覆提交處理

下麵給出一個基於 AOP 切麵 + 註解實現的限流邏輯

/**
 * 限流枚舉
 */
public enum LimitType {
    // 預設
    CUSTOMER,
    //  by ip addr
    IP
}

/**
 * 自定義介面限流
 *
 * @author jacky
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
    boolean useAccount() default true;
    String name() default "";
    String key() default "";
    String prefix() default "";
    int period();
    int count();
    LimitType limitType() default LimitType.CUSTOMER;
}

/**
 * 限制器切麵
 */
@Slf4j
@Aspect
@Component
public class LimitAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Pointcut("@annotation(com.dogame.dragon.sparrow.framework.common.annotation.Limit)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attrs.getRequest();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method signatureMethod = signature.getMethod();
        Limit limit = signatureMethod.getAnnotation(Limit.class);
        boolean useAccount = limit.useAccount();
        LimitType limitType = limit.limitType();
        String key = limit.key();
        if (StringUtils.isEmpty(key)) {
            if (limitType == LimitType.IP) {
                key = IpUtils.getIpAddress(request);
            } else {
                key = signatureMethod.getName();
            }
        }
        if (useAccount) {
            LoginMember loginMember = LocalContext.getLoginMember();
            if (loginMember != null) {
                key = key + "_" + loginMember.getAccount();
            }
        }
        String join = StringUtils.join(limit.prefix(), key, "_", request.getRequestURI().replaceAll("/", "_"));
        List<String> strings = Collections.singletonList(join);

        String luaScript = buildLuaScript();
        RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
        Long count = stringRedisTemplate.execute(redisScript, strings, limit.count() + "", limit.period() + "");
        if (null != count && count.intValue() <= limit.count()) {
            log.info("第{}次訪問key為 {},描述為 [{}] 的介面", count, strings, limit.name());
            return joinPoint.proceed();
        } else {
            throw new DragonSparrowException("短時間內訪問次數受限制");
        }
    }

    /**
     * 限流腳本
     */
    private String buildLuaScript() {
        return "local c" +
                "\nc = redis.call('get',KEYS[1])" +
                "\nif c and tonumber(c) > tonumber(ARGV[1]) then" +
                "\nreturn c;" +
                "\nend" +
                "\nc = redis.call('incr',KEYS[1])" +
                "\nif tonumber(c) == 1 then" +
                "\nredis.call('expire',KEYS[1],ARGV[2])" +
                "\nend" +
                "\nreturn c;";
    }
}

3.3 前端針對綁定手機按鈕添加防止連點處理

四 總結

線上項目對於 Spring 提供的自動事務註解使用要多加思考,儘可能減少事務影響範圍,針對註冊等按鈕要在前後端添加防重覆點擊處理


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 分散式鎖的核心其實就是採用一個集中式的服務,然後多個應用節點進行搶占式鎖定來進行實現,今天介紹如何採用Redis作為基礎服務,實現一個分散式鎖的類庫,本方案不考慮 Redis 集群多節點問題,如果引入集群多節點問題,會導致解決成本大幅上升,因為 Redis 單節點就可以很容易的處理10萬併發量了,這 ...
  • 簡述 工業控制系統,簡稱工控系統,一般運行在工業生產環境中具有特定功能設備的作業系統,比如收銀系統、過磅稱重系統、無人零售系統等。根據需求不同,有單片機、PLC、Linux、Win7等不同的平臺實現方案,本文主要是針對Windows系統,如何技術選型開發工控系統。 工控控制系統與其他應用系統最大的區 ...
  • 《對話工程師》是「貿澤電子」贊助、「與非網」製作的一檔網路節目,自2022年11月起,邀請不同技術領域的資深工程師,聊聊開發過程中的經驗感悟,欄目共 10 期,痞子衡有幸被邀請做了第 4 期節目的嘉賓(12月5日在 「B站 - 與非網官方賬號」里剛播出第 1 期)。 說起與《對話工程師》節目的結緣, ...
  • 1.5.6 NN與2NN 1.5.6.1 HDFS元數據管理機制 問題1:NameNode如何管理和存儲元數據? 電腦中存儲數據兩種:記憶體或者是磁碟 元數據存儲磁碟:存儲磁碟無法面對客戶端對元數據信息的任意的快速低延遲的響應,但是安全性高 元數據存儲記憶體:元數據存放記憶體,可以高效的查詢以及快速響應 ...
  • Redis項目總結--緩存更新策略 1.更新策略 | | 記憶體淘汰 | 超時剔除 | 主動更新 | | : : | : : | : : | : : | | 說明 | 不用自己維護,利用Redis記憶體淘汰機制,記憶體不足時自動淘汰部分數據,下次查詢時更新緩存 | 給緩存數據添加過期時間,到期刪除,下次查 ...
  • this指針,存儲的是一個記憶體地址,如同變數一樣,指向一塊記憶體區域; 而這個記憶體區域,保存的就是一個對象的數據,那麼這個對象是什麼呢? 通常來說,this指針,主要是用在方法(函數)中,用來指向調用方法(函數)的對象; 比如說,有個方法eat(),這個方法裡面有個this指針; 當Tom調用eat時 ...
  • "Simplicity is prerequisite for reliability." - Edsger Dijkstra “簡單是可靠的前提條件。” —— 艾茲格·迪傑斯特拉 0x00 大綱 0x01 前言 最近在重溫設計模式(in Java)的相關知識,然後在工廠模式的實現上面進行了一些較深 ...
  • 一、6-8作業總結 (1)第六次作業:第一次作業分了兩個題,一個電信1題目非常長,給出了類圖,類很多工作量很大。還一個題以容器類為例展現了介面,多態的用處和效果,題目給出的提示非常多,按照題目來,再加上一些測試代碼,可以運用equals類實現。 (2)第七次作業:第二次作業分了三個小題,第一個還是電 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...