背景
項(xiàng)目中存在「批量接口」和「增量接口」误褪,兩個(gè)接口都更新DB中的數(shù)據(jù)。
如存在以下表格碾褂,主鍵為shopId兽间,shopName表示店名。
shopId | shopName |
---|---|
111 | info111 |
222 | info222 |
333 | info333 |
有以下兩個(gè)接口:
updateShopNameBatch(List<Integer> shopIdList, String shopName);
updateShopName(Integer shopId, String shopName);
兩個(gè)接口的功能分別為批量修改門店名稱和單個(gè)修改門店名稱正塌。
忽略接口中的其他操作嘀略,主要執(zhí)行了以下兩句sql。
update Shop set shopName = #{shopName} where shopId in
<foreach collection="shopIdList" item="shopId" index="index" open="(" separator="," close=")">
#{shopId}
</foreach>
update Shop set shopName = #{shopName} where shopId = #{shopId}
兩個(gè)sql可能會修改同個(gè)shopId的shopName屬性乓诽,存在一定的并發(fā)問題帜羊。
從數(shù)據(jù)庫層看,兩個(gè)sql的執(zhí)行過程完全隔離问裕,即先到先執(zhí)行逮壁。
從接口層面看,批量更新接口和增量更新接口的并發(fā)執(zhí)行會遇到以下情況:
- 批量接口先于增量接口收到請求粮宛,然而由于批量接口中執(zhí)行了一些額外操作窥淆,導(dǎo)致增量接口先執(zhí)行sql。最終結(jié)果被批量接口覆蓋巍杈。
- 接口調(diào)用發(fā)起方先調(diào)用批量接口忧饭,再調(diào)用增量接口,然而由于網(wǎng)絡(luò)問題筷畦,增量請求先于批量請求到達(dá)服務(wù)提供方词裤。
- 用戶先點(diǎn)擊批量修改按鈕,再修改了單個(gè)門店的名稱鳖宾。在分布式系統(tǒng)中吼砂,兩個(gè)請求打到了不同服務(wù)器,由于服務(wù)器負(fù)載不均鼎文,導(dǎo)致增量接口的sql先于批量sql執(zhí)行渔肩。
- ……
這個(gè)例子較為常見,由于分布式拇惋,網(wǎng)絡(luò)周偎,處理速度等原因抹剩,用戶先發(fā)起的請求,可能會被延后蓉坎。
問題
這里就引出請求順序的問題澳眷,請求A和請求B,到底是哪個(gè)先發(fā)生蛉艾。
在分布式系統(tǒng)中钳踊,一般很少去關(guān)注兩個(gè)請求哪個(gè)先發(fā)生,因?yàn)椋?/p>
- 多數(shù)接口為查詢接口伺通,并不關(guān)心請求的先后順序箍土。
- 很少存在批量接口和增量接口同時(shí)被調(diào)用的場景。
當(dāng)同時(shí)使用批量接口和增量接口時(shí)罐监,則需要著重關(guān)注這個(gè)問題吴藻。
接口調(diào)用示意圖如下:
解決思路
考慮到需要區(qū)分請求發(fā)生的先后,首先想到的是時(shí)間戳弓柱。
調(diào)用發(fā)起方在調(diào)用批量和增量接口時(shí)沟堡,增加時(shí)間戳入?yún)ⅰ?/p>
在服務(wù)端,將時(shí)間戳作為當(dāng)前sql記錄的版本號矢空。
- 如果庫中的版本號小于入?yún)r(shí)間戳航罗,則支持更新。
- 如果庫中的版本號大于入?yún)r(shí)間戳屁药,則不更新粥血。表示已有后發(fā)起的請求更新了數(shù)據(jù)庫。
然而酿箭,在分布式環(huán)境下复亏,時(shí)間校準(zhǔn)也是個(gè)難題。
因?yàn)闊o法保證批量接口和增量接口從同個(gè)服務(wù)器發(fā)起缭嫡。
不同服務(wù)器之間存在時(shí)間誤差缔御,則無法保證入?yún)r(shí)間戳的準(zhǔn)確性。
分布式場景如下:
考慮到時(shí)間準(zhǔn)確性妇蛀,想到時(shí)間協(xié)議耕突。
在網(wǎng)絡(luò)時(shí)間協(xié)議中,有一種常用的協(xié)議评架,NTP眷茁。
其作用是讓服務(wù)器時(shí)間和源服務(wù)器時(shí)間對齊。
NTP協(xié)議的流程圖如下:
NTP協(xié)議過程:
- 客戶端向服務(wù)器發(fā)送請求纵诞,并記錄客戶端時(shí)間為T1上祈。
- NTP服務(wù)器收到請求,記錄服務(wù)端時(shí)間為T2。
- 服務(wù)端做一些處理雇逞,響應(yīng)客戶端請求,并記錄服務(wù)端時(shí)間為T3茁裙。
- 客戶端收到響應(yīng)塘砸,記錄客戶端時(shí)間為T4。
可得:
- NTP服務(wù)端處理時(shí)間為T3 - T2晤锥。
- 整個(gè)過程耗時(shí)為T4 - T1掉蔬。
- 得到往返網(wǎng)絡(luò)延時(shí)為(T4 - T1) - (T3 - T2)。
- 假設(shè)請求網(wǎng)絡(luò)延時(shí)為delay1矾瘾,響應(yīng)網(wǎng)絡(luò)延時(shí)為delay2女轿,客戶端和NTP服務(wù)端的時(shí)差為d。
- 得到T1 + d + delay1 = T2, T3 - d + delay2 = T4壕翩。
- 使用等式干申大那多蛉迹,得到(T2 - T1) + (T3 - T4) = 2d + (delay1 - delay2)。
- 在NTP協(xié)議中放妈,默認(rèn)delay1 = delay2北救,即d = ((T2 - T1) + (T3 - T4)) / 2。
- 當(dāng)網(wǎng)絡(luò)足夠穩(wěn)定時(shí)芜抒,delay1約等于delay2珍策。那么,網(wǎng)絡(luò)越不穩(wěn)定宅倒,誤差也就越大攘宙。
- 在分布式中,不同客戶端與NTP服務(wù)端之間的網(wǎng)絡(luò)情況不同拐迁,將引入另一個(gè)誤差因素蹭劈。
采用方案
查看NTP協(xié)議之后,發(fā)現(xiàn)其誤差為毫秒級別唠亚。
NTP意圖將所有參與計(jì)算機(jī)的協(xié)調(diào)世界時(shí)時(shí)間同步到幾毫秒的誤差內(nèi)链方。 —— 維基百科
正常情況下,批量接口和增量接口的響應(yīng)時(shí)間為毫秒級灶搜,所以NTP協(xié)議的誤差是不能接受的祟蚀。
最終采用了一種較為粗暴的方案:
- 每條sql記錄都有一個(gè)版本號,初始值為0割卖。
- 在執(zhí)行批量之前前酿,首先select,查出版本號為verison鹏溯。
- 執(zhí)行update時(shí)罢维,將版本作為參數(shù)帶上,即update Shop set shopName = #{shopName} where shopId = #{shopId} and version = #{version}
- 如果update語句返回結(jié)果為1丙挽,則表示執(zhí)行成功肺孵;如果返回為0匀借,則表示在批量select和update過程中,已被增量接口修改平窘,即遇到并發(fā)問題吓肋。
- 如遇到并發(fā)問題,執(zhí)行告警操作瑰艘,并進(jìn)行人工數(shù)據(jù)對齊是鬼。
因無法確認(rèn)批量接口和增量借口發(fā)生的先后順序,最終采用了出錯(cuò)告警紫新,人工校對的方案均蜜。
該方案low中帶著一些粗暴。
換個(gè)思維角度芒率,也算是一種樂觀并發(fā)的思維囤耳,樂觀地認(rèn)為批量接口和增量接口很少會出現(xiàn)并發(fā)。即使出現(xiàn)并發(fā)問題敲董,在服務(wù)器之間交錯(cuò)調(diào)用后紫皇,最終結(jié)果有一定概率是正確的。(逃
后記
求比較優(yōu)雅的腋寨,用于解決批量聪铺、增量接口并發(fā)問題的方案,感激不盡萄窜。