線上爆出一個問題,出庫服務中排線表中排線時段數(shù)據(jù)和排線服務中數(shù)據(jù)對不上,排查了一下是前兩天同事上線導致的,主要原因是處理數(shù)據(jù)庫并發(fā)操作時,選擇的樂觀鎖字段不當導致的。 下面記錄一下問題原因和解決過程。
業(yè)務背景介紹
排線服務中有兩張表t_route表(排線主表)和t_route_order表(排線對應訂單表),多個訂單對應同一個排線。
在出庫服務中,有很多業(yè)務涉及到排線數(shù)據(jù)的關(guān)聯(lián)查詢,因此需要將排線服務中t_route表和t_route_order表中的數(shù)據(jù)同步給出庫服務
(==下面以服務A代替排線服務,服務B代替出庫服務==)。
同步的實現(xiàn)方式是,服務A中使用databus拉取相關(guān)表的binlog,然后封裝成通用的數(shù)據(jù)實體,通過kafka同步給服務B。
服務B收到服務A中t_route表的變更消息后,會將數(shù)據(jù)插入到本地數(shù)據(jù)庫t_route_info表中(該表不僅包含排線主體信息,還有一些需要計算才能得到的信息,為了簡單起見這里不再贅述)。
服務B收到服務A中t_route_order表變更的消息后,會得到其中的排線時段數(shù)據(jù),并將該數(shù)據(jù)更新到t_route_info的 route_times字段中,多個排線時段用英文逗號隔開(排線字段主要用于展示使用)。

功能實現(xiàn)
下面是t_route_info表部分字段:
| 字段名 | 字段類型 | 備注 |
|---|---|---|
| route_no | varchar(100) | 排線號 |
| route_name | varchar(100) | 排線名稱 |
| route_times | varchar(100) | 排線時段 |
| update_time | datetime | 更新時間 |
由于一個排線上有多個訂單,每個訂單對應一個排線時段,并且在服務A中,插入t_route_order時是一個batch insert操作,這就會導致有多條數(shù)據(jù)變更的消息同時發(fā)送到kafka中,我們都知道kafka中同一個topic下一個分區(qū)對應一個消費線程,如果有多個分區(qū),消費端就會開啟多個線程進行消費,此時如果有多條訂單數(shù)據(jù)的變更消息同時(或者間隔時間很短)到來,服務B中就會出現(xiàn)并發(fā)更新t_route_info表的問題,為了解決并發(fā)操作的問題,同時在開發(fā)時選擇了update_time作為樂觀鎖字段。
每條訂單消息的處理邏輯偽代碼如下:
// 處理排線訂單消息
public boolean executeMsg(RouteOrder order){
//重試次數(shù)
int retryCount = 5;
while(retryCount>0){
//根據(jù)排線號查詢排線主體信息t_route_info,包含舊的排線時段信息
RouteInfo routeinfo = queryRouteInfo(order.getRouteNo());
//構(gòu)建排線時段信息,用英文逗號隔開
String routeTimes = buildRouteTimes(routeinfo.getRouteTimes(),order.getRouteTime());
// 更新排線主體信息
Date oldUpdateTime = routeinfo.getUpdateTime();
routeinfo.setRouteTimes(routeTimes);
// 設(shè)置一個新的時間
routeinfo.setUpdateTime(new Date());
// 更新排線主體信息,使用updateTime作為樂觀鎖
// sql為: update t_route_info set route_times = xxx,update_time = xxxx where route_no = xxx and update_time = oldUpateTime
// 如果并發(fā)更新失敗,返回為0
int count = updateRouteInfo(routeinfo,oldUpdateTime);
if(count > 0){
return true;
}
// 每次并發(fā)更新失敗,都要slee 1秒,并且次數(shù)減1
Thread.sleep(1000)
retryCount--;
}
}
結(jié)果發(fā)現(xiàn),如果同時有多個相同排線的訂單新同步過來,則排線時段數(shù)據(jù)會出現(xiàn)丟失的情況。
問題分析
上面功能實現(xiàn)中,使用update_time作為樂觀鎖,update_time字段類型為datetime,也就是會精確到秒(比如2018-12-01 15:33:20), 而代碼中使用Thread.sleep(1000),也即睡眠1秒。
為了簡單起見,假設(shè)當前有3條消息同時到來,此時數(shù)據(jù)庫中update_time值是2018-12-01 15:33:20,route_times值為空, 這3個線程的查詢得到的舊的值是一樣的。 消息1中排線時段值為10:00-11:00,消息1會將自己的排線時段新和舊的值拼接為“10:00-11:00”。
消息2中排線時段值為15:00-16:00,消息2會將自己的排線時段新和舊的值拼接為“15:00-16:00”。
消息3中排線時段值為17:00-18:00,消息3會將自己的排線時段新和舊的值拼接為“17:00-18:00”。
如果消息1的線程首先執(zhí)行update操作,將update_time更新為2018-12-01 15:33:30 (假設(shè)此時系統(tǒng)時間為2018-12-01 15:33:30秒),route_times更新為“10:00-11:00”。接著消息2和消息3對應的線程去更新時由于updaet_time已經(jīng)被更新,則這兩次操作返回結(jié)果都是0,這兩個線程都會sleep 1秒,然后再次重申執(zhí)行查詢,更新的操作(此時消息2拼接的排線時段值應該是“10:00-11:00,15:00-16:00 ”,而消息3拼接的值為“10:00-11:00,17:00-18:00”)。
此時消息2和消息3得到的舊的update_time都是2018-12-01 15:33:30 (系統(tǒng)時間是2018-12-01 15:33:31),然后消息2先執(zhí)行更新,由于操作時間很短(幾毫秒內(nèi)),update_time仍然會被設(shè)置為2018-12-01 15:33:30,接著消息3再去判斷update_time,仍然等于舊的oldUpateTime,可以更新成功,則消息2的更新就會被覆蓋。
解決辦法
根本原因是update_time不是一個原子變化的值,作為樂觀鎖的字段,其值在獲取或者說創(chuàng)建時應該保證唯一性(可以使用redis的incr,或者數(shù)據(jù)庫的 update set version = version+1)。
因此可以使用一下方式解決:
(1)分布式鎖。 但是這種方式性能太低,不建議使用。
(2)使用一個遞增的值作為樂觀鎖字段。
比如 添加version int(10) 字段。
每次查詢時,都得到舊的版本號,更新時:
update t_route_info set route_times = xxx,version = version +1 where route_no = xxx and version = oldVersion.