エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

データドリブンなアプローチで巨大なモノリスをマイクロサービスに分割する(ことを考えてみる)

これは エムスリー Advent Calendar 2022 の27日目の記事です。 前日は id:yusukemoon による、エムスリー × マネーフォワード『社会を変えるサービスデザイン』振り返りと感想でした。

エムスリーエンジニアリンググループ AI・機械学習チームの笹川です。 趣味はバスケと筋トレで、このところは、年末の休暇に作ろうとしているOSSのことを考えています(大体いつも考えるだけで終わらないことが多いので今年こそは)。

今回は、巨大なモノリスアプリケーションをマイクロサービスに分割するという「できたらかっこいいけど、まぁまぁ厄介なタスク」に対するアプローチの一案を紹介します。 この取り組みはまだ弊社のアプリケーションの分割には使われておらず、考えてみたという段階ですが、あまり見ない方法かなと思ったので、少しでも参考になる所があればと思っています。

プロ並みの布団捌きで上手にぬくぬくする犬氏(かわいい)

前提

記事の汎用性と、説明の簡単のため、今回取り組むタスクについていくつか前提を置くことにします。

  1. 本記事中で「マイクロサービスに分割する」とは、DB(などのデータストア)をサービスごとに分割するパターンのマイクロサービス分割を指す
  2. アプリケーションの細かな実装についてのドメイン知識を利用しない

1については、DBを共有したマイクロサービスの設計パターンも考えることができ、その設計にはトランザクションが利用できるなど一定のメリットもありますが、以下のリンク先で説明されるようなデメリットを重く見て、今回は分割する方向で考えることにします。

microservices.io

2については、ここで弊社特有の事情を説明し、その知識を使ってDB分割を実施しても、読者に参考になる部分が少ないように感じました。また、筆者自身がそのアプリの担当者ではないという事情もあり、今回はドメイン知識の利用を極力少なくして取り組んでみることを考えます。

DBのクエリログを分析してみる

前述の通りDBの分割が前提となるため、まずはDBの内部で走るクエリのログを分析し、サービス分割の可能性を探ってみることにします。 対象の本番のOracle DBのクエリログを管理者権限を持つ人に依頼し、手始めに直近のクエリログを取得してもらいました。 詳細は残念ながらお見せできませんが、たくさんのSQLのリスト(ユニーク化して5000個弱のSQL)が取得できました。

次にSQLに含まれるtable名を取得することを考えてみます。 これはSQL中に現れるtableを列挙して、当該DBに含まれるtableと当てることで、「利用されていないtable」を発見できるのではと考えたためです。 利用されていないtableを削除できれば、調査のノイズを減らすことができるし、ストレージコストの削減なども期待できます。

SQLをparseしてtableを取り出すための手頃なツールがないか探してみたのですが、今回対象としているOracle DBの文法に対応したツールが見つからず、Oracle独自の方言に当たるとエラーになってしまいます。 そこで、parserを使うアプローチを諦め、別の方法を検討することにしました。

まず、DBに存在しているtable名は、Oracleであれば以下のSQLで取得できます。

SELECT owner, table_name FROM ALL_TABLES ORDER BY table_name; 

これで得られるtable名の集合を、SQLのリストに対して照合することで、SQLごとに出現するtableを列挙できそうです。 このように、1つの文字列Sに対して、文字列の集合Dを照合し、Dの要素の出現を求める問題はdictionary matching problemと呼ばれたりします。 そして、よく訓練された文字列erは、dictionary matchingと聞くと「Aho-Corasickアルゴリズム*1を使おう!」と半ば反射的に行動し始めます(実に天下り的な論理展開)。 上記の処理は、Pythonでは以下のようなコードでdictionary matchingが実現できます。

def used_tables(sql_file_path: str, table_file_path: str):
    """Extracts a list of used tables in the input sql file.
    """
    automaton = _make_tables_automaton(table_file_path)
    used_tables = extract_used_tables(automaton, sql_file_path)
    [print(t) for t in used_tables]

def extract_used_tables(automaton, sql_file_path: str) -> list[str]:
    with pathlib.Path(sql_file_path).open(mode='r') as f:
        reader = csv.DictReader(f)
        d = [row for row in reader]
        ts = [_extract_tables_by_ac_machine(automaton, e["SQL_FULLTEXT"]) for e in d]
        uniq = set(itertools.chain.from_iterable(ts))
        return sorted(list(uniq))

def _extract_tables_by_ac_machine(automaton, sql: str) -> list[str]:
    """ extracts a set of tables in sql by using aho corasick automaton.
    The result tables are sorted by lexicographical order.
    """
    ts = {original_value for _, (_, original_value) in automaton.iter(sql.lower())}
    return sorted(list(ts))

def _make_tables_automaton(table_file_path):
    automaton = ahocorasick.Automaton()
    with pathlib.Path(table_file_path).open(mode='r') as f:
        reader = csv.DictReader(f)
        original_tables = [row["TABLE_NAME"].lower() for row in reader]
        for i, t in enumerate(original_tables):
            automaton.add_word(t, (i, t))
    automaton.make_automaton()
    return automaton

Aho-Corasickアルゴリズムの実装には、以下のpackageが利用できます。

pypi.org

これ(+少しのfalse positive matchの掃除)により、SQLで利用されているtableを抽出することができました*2。 以下は、得られたtableのリストを全tableの一覧にvlookupでフラグ付けしたものです。モザイクで見づらいですが、一番右のカラムが赤くなっている部分が既に利用されていないテーブルです。

table一覧。赤く色づいているセルが利用されていないもの(わかりづらい)。

これらのテーブルは様子を見つつ、アプリケーションの該当部分と合わせて消していくことができそうです。

1つのSQLの中で共起するtableのグラフを分析する

さて、ここまでで、利用されていないtableのフィルタリングに成功しました。次は、table同士の関係性について調べてみることにします。

1つのSQLに複数のtableが出現する場合、そのtable達は1つのSQLに「共起している」と呼ぶことにします。 共起するtable達をそれぞれ別のDBに切り離してしまうと、SQLの書き換え、サービス間のデータのやりとりの実装などが必要になり、修正範囲が大きくなってしまいます。 そのような修正を極力避ける形でサービス分割を実現するため、共起するtable達について、tableをnode、共起関係をedgeとしたグラフを構築し、そのグラフの性質を調べてみました。

グラフの構築と、構築したグラフの性質の分析には、今回Pythonのnetworkxを利用しました。 この分析以前では、筆者はnetworkxをグラフ / ネットワークの可視化用のライブラリとしてしか使ったことがなかったのですが、今回グラフの性質(連結成分の特定など。詳細は後述)を分析するための各種アルゴリズムが、とても充実しているのを発見しました。 詳細は、以下のリンクをご覧いたければと思いますが、基本的なグラフに関するアルゴリズムは大体実装されています。

networkx.org

まずはじめに、グラフの連結成分 (connected components) について調べてみます。 連結成分とは、直感的には「ばらばらになっていない部分グラフの集合」のことです(正確な定義はこちらを参照)。 一般に、グラフを構築すると、いくつかの部分グラフに分かれて構築されることがあります。 これを今回作ったSQLの共起関係に置き換えて考えると、異なる連結成分に属するnodeは、「同一SQL内で共起しないtable」と言い換えることができるので、連結成分でDBを分割可能ということもできそうです。

以下のコードで共起グラフを構築し、連結成分を検出してみます。

def connected_components(sql_file_path: str, table_file_path: str):
    """Extracts a list of connected components in the table collocation graph generate from the input sql file.
    """
    automaton = _make_tables_automaton(table_file_path)
    collocation = extract_collocation(automaton, sql_file_path)
    unique_collocation = sorted([list(item) for item in set(tuple(row) for row in collocation)])
    cc = connected_component(unique_collocation)
    [print(sorted(list(c))) for c in sorted(cc, key=len, reverse=True)]

def extract_collocation(automaton, sql_file_path: str) -> list[list[str]]:
    with pathlib.Path(sql_file_path).open(mode='r') as f:
        reader = csv.DictReader(f)
        d = [row for row in reader]
        ts = [_extract_tables_by_ac_machine(automaton, e["SQL_FULLTEXT"]) for e in d]
        return sorted([t for t in ts if len(t) > 0])

def connected_component(collocation: list[list[str]]) -> list[set[str]]:
    g = networkx.Graph()
    for c in collocation:
        edges = set(itertools.combinations(c, 2))
        g.add_edges_from(edges)
    return list(networkx.connected_components(g))

まず、共起グラフですが、対象のDBについて構築後、可視化してみると以下のようなグラフが生成されました。 ここでは、グラフの構造の詳細というより「大きな塊が1つと、小さい離れ小島がいくつかあるな」くらいの認識で大丈夫です。 なんとなく、もう少しバラバラにできそうな感じであることを期待していたのですが、想像以上に絡まりあっていることがわかりました。

生成された共起グラフ(想像以上に絡みあっていた)

グラフの連結成分については、可視化されたグラフで既にみて取れるのですが、多数のテーブルを含む大きな連結成分が1つと、少数のテーブルで構成される連結成分が数個という構成でした。 見つかった少数のテーブルで構成される連結成分は、サービス分割に利用できそうですが、大きな部分が依然として残ってしまいます。 もう少し粘ってなんとかできないかを考えてみることにします。

グラフには、橋 (bridge) と呼ばれ「そのedgeを取り除くと、連結成分の数が増加する」という性質を持ったedgeや、関節点 (articulation point) と呼ばれ「そのnodeを取り除くと、連結成分の数が増加する」という性質を持ったnodeなど、「それを除けば」グラフを分割できるようになるという、特別な箇所が存在することがあります。 この性質を先ほど構築した共起グラフに適用するとすると、「特定のtableに関連する処理さえどうにか対処すればDBの分割ができる」となりそうです。 実際、ある種のマスタなどは、いろいろなところから参照されがちですが、そういうtableは行数が少なかったり、ほぼ静的なtableであることが多いと思うので、tableを分割した両方のDBに持ち、適切に同期するなどして対処できるかもしれません。

それぞれ、networkxにも実装されており、以下で実現できます。

こちらの分析もやってみたのですが、小さな離れ小島が少しできるくらいで、絡まり具合の激しさは想像以上でした。

分析の結論と感想

ここまでで、筆者の知識の範囲で簡単にできそうな手は打ち尽くしたので、結論として「簡単にはDBの分割は難しそう」となりました。

分析をしてみた感想としては、対象となるアプリについてのドメイン知識をほぼ使わずにここまで分析できることは一定意味のあることではないかなと思いました。 また、上記の分析に使った時間は、SQLのログデータを受領後から計測して2-3時間程度と非常に短いので、まずは定型的にやってみる、くらいの取り組みにはいいのではないでしょうか。

まとめ

今回は、モノリスアプリのサービス分割のタスクに対して、実行されているSQLのログデータを用いて、DBの分割可能性を探るための分析アプローチについて紹介しました。 今回は期待していた結果に辿りつくことはできませんでしたが、ある程度コスト低く実施できる取り組みなので、サービス分割に取り組む際の第一歩としてはいいのではないかと思います。

We are hiring!

エムスリーでは、既存のアプリの課題を技術で解決する腕力のあるエンジニアを広く募集中です。 ぜひ、我こそは!という方はカジュアル面談、ご応募お待ちしています!

jobs.m3.com

*1:ちなみにAho-CorasickのAhoは、コンパイラ―原理・技法・ツール(通称ドラゴンブック)の著者として有名です

*2:正確には、SQL中にtable名と同じtableでない部分文字列が出現すると誤った出現が発生してしまいます(例: userというtable名が存在して、別のtableには、カラムとしてuserが存在するようなケースなど)。 今回のケースでは、このような例は、そこまで多くなかったので、個別に対応することで回避しています。