项目背景:
项目北京:我们的数据需要同步到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);