記一次有意思的業務實現 → 單向關注是關注,雙向關注則成好友

2022-06-06 12:06:04

開心一刻

  有個問題一直困擾著我:許仙選擇了救蛇,為什麼楊過卻選擇救雕(而不救蛇)

  後面想想,其實楊過救神鵰是有原因的,當年神鵰和巨蛇打架的時候

  雕對楊過說:殺蛇,殺蛇,殺蛇!

  蛇對楊過說:殺雕,殺雕,殺雕!

  楊過果斷選擇了殺蛇

業務場景

  業務描述

  業務上有這樣的需求,張三、李四兩個使用者,如果互相關注則成為好友

  設計上有兩張表,關注關係表: tbl_follow 

  朋友關係表: tbl_friend 

  我們以張三關注李四為例,業務實現流程是這樣的

    1、先查詢李四有沒有關注張三

    2、如果李四關注了張三,則成為好友,往 tbl_friend 插入一條記錄;如果李四沒有關注張三,則只是張三單向關注李四,往 tbl_follow 插入一條記錄

  看似沒問題,可如果我們從並行的角度來看,是不是還正常了?

  如果張三、李四同時關注對方,那麼業務實現流程的第 1 步得到的結果可能就是雙方都沒有關注對方(加資料庫的排他鎖也沒用,記錄不存在,行鎖無法生效)

  得到的結果就是張三關注李四、李四關注張三,但張三和李四沒有成為朋友,這就導致了與業務需求不符!

  問題復現

  相關環境如下

   MySQL : 5.7.21-log ,隔離級別 RR

   Spring Boot : 2.1.0.RELEASE 

   MyBatis-Plus : 3.1.0 

  核心程式碼如下

  完整程式碼見:mybatis-plus-demo

  我們來複現下問題

  正確結果應該是: tbl_follow 、 tbl_friend 中各插入一條記錄

  但目前的結果是隻往 tbl_follow 中插了兩條記錄

  該如何處理該問題,歡迎大家評論區留言

JVM 鎖

  既然並行了,那就加鎖唄

  JVM 自帶的 synchronized 和 Lock 都有同步作用,我們以 synchronized 為例,來看看效果

   tbl_follow 和 tbl_friend 中各插入一條記錄,問題得到解決!

  但是完美嗎?如果專案是叢集部署,張三、李四關注對方的請求分別落在了叢集中不同的節點上,不能成為好友的問題會不會出現?

分散式鎖

  因為 JVM 鎖只能控制同個 JVM 程序的同步,控制不了不同 JVM 程序間的同步,所有如果專案是叢集部署,那麼就需要用分散式鎖來控制同步了

  關於分散式鎖,我就不多說了,網上資料太多了,推薦一篇:再有人問你分散式鎖,這篇文章扔給他

  如果用分散式鎖去解決上述案例的問題,樓主就不去實現了,只是強調一個小細節:如何保證 張三關注李四 、 李四關注張三 它們申請同一把鎖

  以 Redis 實現為例, key 的命名是有規範的,比如:業務名:方法名:資源名,具體到如上的案例中, key 的名稱:user:follow:123:456

  如果 張三關注李四 申請的 user:follow:123:456 ,而 李四關注張三 申請的是 user:follow:456:123 ,那麼申請的都不是同一把鎖,自然也就沒法控制同步了

  所以申請鎖之前,需要進行一個小細節處理,將 followId 與 userId 進行排序處理,小的放前面,大的放後面,類似: user:follow:小id:大id 

  那麼就能保證它們申請的是同一把鎖,自然就能控制同步了

唯一索引

  接下來要講的實現方式不常見,但是挺有意思的,大家仔細看

  我們改造一下 tbl_follow ,另取名字 tbl_follow_plus 

  注意欄位看欄位的描述

  tbl_follow 中 user_id 固定為 被關注者 , tbl_follow 中 follower_id 固定為 關注者 

  tbl_follow_plus 中 one_side_id 和 other_side_id 沒有固定誰是 關注者 ,誰是 被關注者 ,而是通過 relation_ship 的值來指明誰關注誰

  業務實現

  當 one_side_id 關注 other_side_id 的時候,比較它倆的大小

  若 one_side_id < other_side_id ,執行如下邏輯

  執行效果如下

  我們分析下結果

  tbl_follow_plus 只插入了一條記錄

  relation_ship = 3 表示雙向關注

  tbl_friend 插入了一條記錄

  同時關注 這個業務就實現了

  有小夥伴就有疑問了:樓主你只分析了 one_side_id 關注 other_side_id 的情況,沒分析 other_side_id 關注 one_side_id 的情況呀

  大家注意看 tbl_follow_plus 表中各個列名的註釋, one_side_id 和 other_side_id 並不是具體的 關注者 和 被關注者 ,兩者的業務含義是等價的

  至於是誰關注誰,是通過 relation_ship 的值來確定的,所以 one_side_id 關注 other_side_id 和 other_side_id 關注 one_side_id 是一樣的

  至於適不適用單向關注的情況,大家自行去驗證

  原理分析

  雖然業務需求是實現了,但卻難以理解,讓我們一步一步往下分析

  1、為什麼要比較 one_side_id 和 other_side_id 的大小?

     tbl_follow_plus 有個唯一索引 UNIQUE KEY `uk_one_other` (`one_side_id`,`other_side_id`) 

    比較大小的目的就是保證 tbl_follow_plus 的 one_side_id 記錄的是小值,而 other_side_id 記錄的是大值

    例如 123 關注 456 , one_side_id = 123 , other_side_id = 456 , relation_ship = 1 

       456 關注 123 , one_side_id = 123 , other_side_id = 456 ,但 relation_ship = 2 

    那這有什麼用?

    還記得我在上面的 分散式鎖 實現方案中強調的那個細節嗎

    這裡比較大小的作用也是為了保證 123 關注 456 與 456 關注 123 在唯一索引上競爭的是用一把行鎖

  2、insert … on duplicate key update

    其作用簡單點說就是:資料庫表中存在某個記錄時,執行這個語句會更新,而不存在這條記錄時,就會插入

    有個前置條件:只能基於唯一索引或主鍵使用;具體細節可檢視:記錄不存在則插入,存在則更新 → MySQL 的實現方式有哪些?

     insert ... on duplicate 確保了在事務內部,執行了這個 SQL 語句後,就佔住了這個行鎖(先佔鎖,再執行 SQL)

    確保了之後查詢 relation_ship 的邏輯是在行鎖保護下的讀操作

  3、relation_ship=relation_ship | 1(relation_ship=relation_ship | 2)

    這個寫法就有點巧妙了,這裡的 | 指的是 按位元或運算 

     relation_ship 的值是在業務程式碼中指定的,只能是 1 或者 2

    因為在 MySQL 層面有個唯一索引的 行鎖 ,所以 123 關注 456 和 456 關注 123 的事務之間存在鎖競爭,必定是序列的

    3.1 若先執行 123 關注 456 的事務, relation_ship 傳入的值是 1,事務執行完之後, relation_ship 的值等於 1 | 1 = 1 ;

      再執行 456 關注 123 的事務, relation_ship 傳入的值是 2,事務執行完之後, relation_ship 的值等於 1 | 2 = 3 

    3.2 若先執行 456 關注 123 的事務, relation_ship 傳入的值是 2,事務執行完之後, relation_ship 的值等於 2 | 2 = 2 ;

      再執行 123 關注 456 的事務, relation_ship 傳入的值是 1,事務執行完之後, relation_ship 的值等於 2 | 1 = 3 

    這裡也可以看出 relation_ship 的列舉值也不是隨意的,當然也可以選擇其他的,但是需要滿足如上的位運算邏輯

  4、insert ignore into friend

    其作用簡單點說就是:資料庫表中存在該記錄時忽略,不存在時插入

    同樣也是基於主鍵或唯一索引使用

  另外,在重複呼叫時,按位元或(|)和 insert ignore 可以保證冪等性

總結

  1、就文中這個業務而言,唯一索引的實現可讀性太差,不推薦大家使用

  2、 insert into on duplicate key update 和 insert ignore into 還是比較常見的,最好掌握它們

參考

  《MySQL 實戰 45 講》