TiDB 適配應用實踐:MyBatis 3.5.X 在 JDK8 中性能問題的排查與優化

最近有金融客戶使用 TiDB 適配批處理場景,數據量在數億級。對於相同的數據量的處理耗時,TiDB 有 35 分鐘,Oracle 有 15 分鐘,足足相差 20 分鐘。從之前的經驗來看,在批處理場景上 TiDB 的性能是要好過 Oracle 的,這讓我們感到困惑。經過一番排查最終定位是批處理程序問題。調整後,在應用服務器有性能瓶頸、數據庫壓力依然不高且沒有進行參數優化的情況下,TiDB 處理時間縮短到 16 分鐘,與 Oracle 幾乎持平。

遠程排查

通過 Grafana 發現執行批處理時數據庫集羣的資源使用率非常低,判斷應用發來的壓力較小,將併發數從 40 提高到 100,資源使用率和 QPS 指標幾乎沒有變化。通過 connection count 監控看到,連接數隨着併發數增加而增加,確認併發數修改是生效的。執行 show processlist 發現大部分連接是空閒狀態。簡單走查了下應用程序代碼,是 Spring batch + MyBatis 結構。因爲 Spring batch 設置併發的方式很簡單,所以考慮線程數的調整應該是生效且可以正常工作的。 雖然還沒有搞清資源使用率低的問題,但還是有其他收穫,ping 應用和 TiDB 集羣的網絡延遲,達到了 2~3 ms。爲了排除高網絡延遲的干擾,將應用部署到 TiDB 集羣內部運行,批處理耗時從 35 分鐘下降到 27 分鐘,但依然和 Oracle 有較大差距。因爲數據庫本身沒有壓力,所以調整數據庫參數也沒什麼意義。

因爲應用提高併發的效果不符合預期,所以考慮線程可能造成了阻塞,但也沒有證據,於是想了這樣的場景來簡單驗證到底是應用的問題還是數據庫的問題: 在 TiDB 集羣中創建兩個完全相同的 Database,d1 和 d2,使用兩個完全相同的批處理應用分別對 d1,d2 中的數據進行處理,等同於雙倍壓力寫入 TiDB 集羣,預期結果是對於雙倍的數據量,同樣可以在 27 分鐘處理完,同時數據庫資源使用率應大於一個應用的。 測試結果符合預期,證明應用沒有真正的提高併發。

可能的原因?

  1. 應用併發太高,CPU 繁忙導致應用性能瓶頸。

    應用服務器的 CPU 消耗只有 6%,不應該存在性能瓶頸。

  2. Spring batch 內部有一些元數據表,同時更新元數據表的同一條數據會造成阻塞。

    這種情況應該是阻塞在數據庫造成鎖等待或鎖超時,不應該阻塞在應用端。

該如何解決?

  1. 多應用部署併發運行,性能隨應用部署數線性提升。

    不能解決單機應用性能瓶頸問題,對於業務高峯時的拓展也很不方便。

  2. 採用異步處理的方案,提高應用吞吐。

    目前是有些異步訪問數據庫的技術,但成熟度低,強烈不建議使用。

現場排查

爲了弄清問題根本原因,來到現場。

  • 現場使用 JDBC 編寫了一個 Demo 對問題集羣進行壓測,發現數據庫資源使用率隨着 demo 併發數提高而增長,證明提高併發數可以給數據庫製造更高的壓力,此時完全排除數據庫問題的可能。

  • 通過 VisualVM 發現,應用程序的大量線程處於阻塞狀態,這種情況線程開的多其實也沒用上,實錘性能瓶頸來自應用。

  • 走查應用代碼,發現雖然有用到同步鎖等邏輯,但應該不會造成嚴重的線程阻塞。

  • 通過 dump 發現線程都阻塞在了 MyBatis 的堆棧中,是在源碼的這個位置:

@Override
  public Reflector findForClass(Class<?> type) {
    if (classCacheEnabled) {
      // synchronized (type) removed see issue #461
      return MapUtil.computeIfAbsent(reflectorMap, type, Reflector::new);
    } else {
      return new Reflector(type);
    }
  }

這裏大致是這樣,MyBatis 在進行參數處理、結果映射等操作時,會涉及大量的反射操作。Java 中的反射雖然功能強大,但是代碼編寫起來比較複雜且容易出錯,爲了簡化反射操作的相關代碼, MyBatis 提供了專門的反射模塊,它對常見的反射操作做了進一步封裝,提供了更加簡潔方便的反射 API 。DefaultReflectorFactory 提供的 findForClass() 會爲指定的 Class 創建 Reflector 對象,並將 Reflector 對象緩存到 reflectorMap 中,造成線程阻塞的就在對 reflectorMap 的操作上。

因爲 MyBatis 支持對 ReflectorFactory 自定義實現,所以當時的思路是繞過緩存的步驟,也就是將 classCacheEnabled 設爲 false,走 return new Reflector(type) 的邏輯。但依然會在其他調用 ConcurrentHashmap.computeIfAbsent 的地方被阻塞。

到這看起來是一個通用問題,於是將注意力放到 concurrentHashmapcomputerIfAbsent 上。computerIfAbsent 是 JDK8 中 爲 map 提供的新方法

public V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)

它首先判斷緩存 map 中是否存在指定 key 的值,如果不存在,會自動調用 mappingFunction (key) 計算 key 的 value,然後將 key = value 放入到緩存 Map。ConcurrentHashMap 中重寫了 computeIfAbsent 方法確保 mappingFunction 中的操作是線程安全的。

官方說明中一段:

The entire method invocation is performed atomically, so the function is applied at most once per key. Some attempted update operations on this map by other threads may be blocked while computation is in progress, so the computation should be short and simple, and must not attempt to update any other mappings of this map.

可以看到,爲了保證原子性,當對相同 key 進行修改時,可能造成線程阻塞。顯而易見這會造成比較嚴重的性能問題,在 Java 官方 Jira,也有用戶提到了同樣的問題。

[JDK-8161372] ConcurrentHashMap.computeIfAbsent(k,f) locks bin when k present

很多開發者以爲 computeIfAbsent 是不會造成線程 block 的,事實卻相反。Java 官方當時認爲這個設計是沒問題的,不過之後也覺得在性能還不錯的 Concurrenthashmap 中有這麼個拉胯兄弟屬實不太合適。最終在 JDK9 中修復了這個問題。

驗證

將現場 JDK 版本升級到 9 ,應用在 500 併發,並排除網絡延遲干擾的情況下,批處理耗時 16 分鐘。應用服務器 CPU 達到 85% 左右使用率,出現性能瓶頸。理論上,提高應用服務器配置、優化數據庫參數都可以進一步提升性能。

當時的結論

MyBatis 3.5.X 在緩存反射對象用到的 computerIfAbsent 方法在 JDK8 中性能不理想。需要升級 jdk9 及以上版本解決這個問題。對於 MyBatis 3.5.X 本身,沒有針對 JDK8 中的 computerIfAbsent 性能問題進行特殊處理。

升級的路走不通,也可以試着降級到 MyBatis 3.4.X,這個版本還沒有引入 computerIfAbsent,理論上沒有這個問題。

@Override
public Reflector findForClass(Class<?> type) {
    if (classCacheEnabled) {
            // synchronized (type) removed see issue #461
      Reflector cached = reflectorMap.get(type);
      if (cached == null) {
        cached = new Reflector(type);
        reflectorMap.put(type, cached);
      }
      return cached;
    } else {
      return new Reflector(type);
    }
  }

現在的結論

MyBatis 官方在收到我們的反饋後,非常效率地 fix 了這個問題。手動點贊。

可以看到 MyBatis 官方對 computerIfAbsent 進行了一層封裝,如果 value 已存在,則直接 return,這樣操作相同 key 的線程阻塞問題就被繞過去了。MyBatis 會在 3.5.7 版本中合入這個 PR。

public class MapUtil {
  /**
   * A temporary workaround for Java 8 specific performance issue JDK-8161372 .<br>
   * This class should be removed once we drop Java 8 support.
   *
   * @see <a href="https://bugs.openjdk.java.net/browse/JDK-8161372">https://bugs.openjdk.java.net/browse/JDK-8161372</a>
   */
  public static <K, V> V computeIfAbsent(Map<K, V> map, K key, Function<K, V> mappingFunction) {
    V value = map.get(key);
    if (value != null) {
      return value;
    }
    return map.computeIfAbsent(key, mappingFunction::apply);
  }

  private MapUtil() {
    super();
  }
}

結語

經過這次排查,我們發現了 JAVA 語言源代碼中的 bug,並且進一步推進了已經受這個 bug 影響的 MyBatis 框架繞開了這個編程語言級的 bug。調整後的應用處理速度大幅提升,在我們這個場景中提升了一倍以上。相信對於使用應用開發框架 MyBatis 的企業來說會提供巨大幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章