線上問題引發的對於Map.values()的探究思考
專案場景:
專案場景:我們的資料需要同步到es,以供列表頁查詢。產品反映修改了資料之後,列表頁顯示的欄位可能是修改之前的甚至更老版本的資料。
問題描述:
我們目前做的一個系統,表單資料會有多個版本,但是不同版本欄位不一定相同。需要把所有資料同步到es,在es進行列表搜尋顯示。上線幾天後發現一個詭異的事兒是,某些表單,在經過多次編輯之後,從es查到的列表資料某些欄位會展示位舊資料,而表單詳情是最新資料。
- 定位問題,問題一定出現在表單多版本資料融合,然後同步到es的地方
- 檢視程式碼
List<String> taskDetailCodes = taskEntities.stream()
.sorted(Comparator.comparing(BaseEntity::getCreateTime))
.map(TicketTaskEntity::getDetailCode)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toList());
Map<String, Object> detail = new HashMap<>();
taskDetailCodes.add(0, ticketEntity.getDetailCode());
// 工單主表單資料處理
Map<String, Map<String, String>> taskDetailMap = objectDAO.getMapByCodes(taskDetailCodes);
detail = combineMap(taskDetailMap.values());
下面是combineMap的邏輯
private Map< String, Object> combineMap(Collection<Map<String, String>> detailList) {
Map<String, Object> combinedMap = new HashMap<>();
for (Map<String, String> map : detailList) {
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if (BuiltInFieldEnum.ASSIGNEE.name().equals(key)) {
// 受理人,儲存所有
.........
} else {
// 其他情況,只保留最新的值
addToDetail(combinedMap, entry);
}
}
}
return combinedMap;
}
綜上,可以看到具體邏輯是,先查出表單資料的各個版本,然後利用createTime排序,把多版本的資料按照先後順序放入到detail中,同時相同欄位進行覆蓋,這樣某個欄位最新的value,一定是最後儲存到detail裡面的那個。
原因分析:
- 通過排查,發現出問題的表單查出來的最終ticketDetailCodes的資料是,其中欄位為desc
String key1 = "1AN1GKELGK3PPSEC";// desc: test1
String key2 = "1AN1GKELGK3PQ51G";// desc: test0
String key3 = "1AO1GKIO2DCTF4EC";// desc: test1
按照線上問題,模擬出上述場景,三個key對應的map中都有desc欄位,value分別在註釋處,按照業務邏輯,應該是key3的desc最終覆蓋之前的desc,所以整個detail的desc的值應該是test1,但是實際場景確實desc的值為test0。
大腦靈光一閃,detail = combineMap(taskDetailMap.values());,這句程式碼返回的value也許根本沒有按照我們put進map的順序,然後做了個實現:
public static void main(String[] args) {
String key1 = "1AN1GKELGK3PPSEC"; // hash = "-1104869755", (n - 1) & hash = 5
String key2 = "1AN1GKELGK3PQ51G"; // hash = "-1104869412", (n - 1) & hash = 12
String key3 = "1AO1GKIO2DCTF4EC"; // hash = "1370718008", (n - 1) & hash = 8
Map<String,String> map = new HashMap<>();
map.put(key1, key1);
map.put(key2, key2);
map.put(key3, key3);
System.out.println(map.values());
}
輸出
[1AN1GKELGK3PPSEC, 1AO1GKIO2DCTF4EC, 1AN1GKELGK3PQ51G]
可以看到,確實和我們設想的一致,打印出來的順序1AN1GKELGK3PQ51G反而到了最後一個,所以最終detail裡的desc的值用的也就是它的,也就是test0
HashMap.values()的返回值
因為咱們用的是HashMap,所以來看看hashmap中的原始碼
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
final class Values extends AbstractCollection<V> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<V> iterator() { return new ValueIterator(); }
public final boolean contains(Object o) { return containsValue(o); }
public final Spliterator<V> spliterator() {
return new ValueSpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
根據原始碼可以發現,最終返回的邏輯就是Values類裡forEach的邏輯,也就是把HashMap裡的值,遍歷tab屬性的下標,然後遍歷每個下標下的連結串列。此處就不累贅HashMap的putVal方法了,但是可以把我的實驗結果列出來:
String key1 = "1AN1GKELGK3PPSEC";
String key2 = "1AN1GKELGK3PQ51G";
String key3 = "1AO1GKIO2DCTF4EC";
Map<String,String> map = new HashMap<>();
map.put(key1, key1); // hash = "-1104869755", (n - 1) & hash = 5
map.put(key2, key2); // hash = "-1104869412", (n - 1) & hash = 12
map.put(key3, key3); // hash = "1370718008", (n - 1) & hash = 8
System.out.println(map.values());
由於這3個key都沒有衝突,所以他們分別放到了如下的下標裡key1->5,key2->12, key3->8,這樣的話,如果呼叫values(),那麼返回的就會是按照key1,key3,key2的順序返回value,但是因為不是必現,所以測試階段測試同學沒有測出來。
那麼如果用LinkedHashMap呢?
LinkedHashMap.values()
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new LinkedValues();
values = vs;
}
return vs;
}
final class LinkedValues extends AbstractCollection<V> {
public final int size() { return size; }
public final void clear() { LinkedHashMap.this.clear(); }
public final Iterator<V> iterator() {
return new LinkedValueIterator();
}
public final boolean contains(Object o) { return containsValue(o); }
public final Spliterator<V> spliterator() {
return Spliterators.spliterator(this, Spliterator.SIZED |
Spliterator.ORDERED);
}
public final void forEach(Consumer<? super V> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
action.accept(e.value);
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
可以看到LinkedHashMap返回順序是按照內部定義的雙向連結串列的node順序返回的,因為LinkedHashMap雙向列表的順序可以通過accessOrder控制是按照插入順序還是修改順序排序,但是預設是按照插入順序排序。
做個簡單的實驗
public static void main(String[] args) {
String key1 = "1AN1GKELGK3PPSEC";
String key2 = "1AN1GKELGK3PQ51G";
String key3 = "1AO1GKIO2DCTF4EC";
Map<String,String> map = new LinkedHashMap<>();
map.put(key1, key1);
map.put(key2, key2);
map.put(key3, key3);
System.out.println(map.values());
}
以下是輸出
[1AN1GKELGK3PPSEC, 1AN1GKELGK3PQ51G, 1AO1GKIO2DCTF4EC]
可以看到輸出的順序和插入順序一致
解決方案:
既然原因找到了,其實可以按照以上的分析,提供兩種解決方案:
- 利用LinkedHashMap的特性來解決:修改objectDAO.getMapByCodes(taskDetailCodes);返回的map的型別為LinkedHashMap,但是因為可能影響其他呼叫處,未使用該方法
Map<String, Map<String, String>> taskDetailMap = objectDAO.getMapByCodes(taskDetailCodes);
2.根據排序完的taskDetailCodes的順序,重新組裝list,而不直接使用taskDetailMap.values()。這樣程式碼的可讀性也比較強,雖然程式碼不夠優雅。最終程式碼如下:
Map<String, Map<String, String>> taskDetailMap = objectDAO.getMapByCodes(taskDetailCodes);
List<Map<String, String>> taskDetailMapList = Lists.newLinkedList();
for (String taskDetailCode : taskDetailCodes) {
taskDetailMapList.add(taskDetailMap.getOrDefault(taskDetailCode, Collections.emptyMap()));
}
detail = combineMap(taskDetailMapList);