開心一刻
有個問題一直困擾著我:許仙選擇了救蛇,為什麼楊過卻選擇救雕(而不救蛇)
後面想想,其實楊過救神鵰是有原因的,當年神鵰和巨蛇打架的時候
雕對楊過說:殺蛇,殺蛇,殺蛇!
蛇對楊過說:殺雕,殺雕,殺雕!
楊過果斷選擇了殺蛇
業務場景
業務描述
業務上有這樣的需求,張三、李四兩個使用者,如果互相關注則成為好友
設計上有兩張表,關注關係表: 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 ,執行如下邏輯
若 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 講》