実録パフォーマンス改善 - 高速化のためアーキテクチャやアルゴリズム選択から見直すSansanの事例
インフラの特性をふまえ、ミドルウェアの挙動を理解し、プロファイリングによってボトルネックを把握し、要求に合ったアーキテクチャを選択する。そういった工夫を重ねることでアプリケーションのパフォーマンスを改善する事例を、Sansanの千田智己さんに聞きました。
アプリケーションの設計・実装方法を変えることで、性能が格段に向上するケースは数多くあります。有名IT企業のエンジニアは、どのような方針のもとでアーキテクチャあるいはアルゴリズム選択などでパフォーマンスを改善しているのでしょうか?
法人向けクラウド名刺管理サービス「Sansan」や個人向け名刺アプリ「Eight」を提供するSansan株式会社の千田智己さんに、これまで取り組んできた事例と、そのノウハウを教えていただきました。
- 千田 智己(せんだ・ともみ)
- Sansan株式会社 Sansan事業部 プロダクト開発部
情報処理系専門学校卒業後、受諾開発・SESを行う地元企業に新卒入社。自治体向けシステムのPJにて、業務アプリケーション等の設計・開発に従事。上京後、旅行系の基幹システムやMDM開発、証券系システムのアーキテクチャ設計からインフラの構築・実装等、幅広く対応。2013年6月にSansan株式会社に入社。以来、法人向けサービスSansanの開発に携わる。
バッチのパフォーマンス改善
~インフラの特性をふまえて設計する~
──千田さんがこれまでSansanで行ってきたパフォーマンス改善の事例について教えてください。
千田 過去の事例から順に話すと、まず会社情報の名寄せを行うバッチのパフォーマンス改善を行いました。私が改修する前の仕様は「数分に1回起動して、データを1,000件ずつ処理する」というものだったのですが、サービスの利用者が多くなるにつれ、1,000件ずつの処理では名刺の流入に追いつかなくなっていました。
──なぜ1,000件ずつという制限がかかっていたのでしょうか?
千田 これには歴史的な経緯があって、Sansanではかつてインフラをオンプレで運用しており、ある高速ストレージを導入していました。この機器には「高い負荷をかけると温度が急上昇する」という特性があり、一定以上の温度になると安全機構が動いてストレージが落ちてしまうんです。
──それは恐ろしいですね……。
千田 そのため、わざとバッチの処理量を抑えていました。その後でインフラをAWSに移行しましたが、バッチ処理には手が入らないままだったんです。
──1,000件ずつを数千件にするなどで、暫定対応できなかったのでしょうか?
千田 おそらくできたのですが、根本解決にはなりません。ビジネスが右肩上がりに成長して継続的にデータが増えていたので、一時的に処理件数を増やしてもすぐに限界が来てしまいます。また、かなりの量のデータが滞留していたので、それらを救出するためにもバッチの性能を大幅に向上させる必要もありました。そこで、バッチのコードを完全に書き直すことに決めたんです。
──バッチの作り直しを決めたとき、何を大事にしてコードを書こうと考えましたか?
千田 一番大事にしたのは、スループットを出し続けられることです。改修後は1時間に1回起動するバッチとして作り直したのですが、今後どれだけデータが増えたとしても確実に1時間以内に処理を終えられるようにしました。結果的には2分以内には実行完了するほどにパフォーマンス改善でき、データが約30倍に増えても耐えられるようになりました。
──バッチのパフォーマンス改善を行う際には、何を大事にしてアルゴリズムの変更を行うべきでしょうか?
千田 まず、プロファイリングをすることが重要だと思います。I/OバウンドなのかCPUバウンドなのか、どの箇所がボトルネックになっているのかを可視化して、丁寧にチューニングをすること。
それから、「クエリを発行する回数をどれだけ減らせるか」も非常に重視しています。クラウド環境を利用しているとネットワークのレイテンシーを忘れがちになりますが、これによる処理の劣化は馬鹿になりません。
AWSで、バッチとデータベースがAZ(Availability Zone)間をまたぐ場所にある場合、同一AZの場合と比べると、ネットワーク通信にかかる時間が数倍は違ってきます。ですが、クラウド環境では、各サーバがどの箇所に配置されるかを自分たちでコントロールすることは難しい。そのため、できるだけネットワークの影響を受けないように、1回のデータベースアクセスで取得・更新するデータの量を多くすることが大事です。
Webアプリケーションの場合、1回のリクエストでクエリを発行する回数はそれほど多くありません。でも大量データを扱うバッチの場合、クエリの発行回数はかなり多くなります。だからこそ、よりネットワークのレイテンシーを考慮しなければいけない。アプリケーションを開発するときは、必ず「インフラがどういう特性を持っているか」を気にしなければいけないんです。
ヒント句(pg_hint_plan)の導入
~実行計画をコントロールして、効率の良いクエリを~
──他にはどのようなパフォーマンス改善の事例がありますか?
千田 ヒント句
──なぜ、実行計画が安定しないのでしょうか?
千田 SELECT句のWHERE句を複合条件にした場合の「行数推定」がうまくいかなくなるからです。リレーショナルデータベース内部では検索条件をもとに「これくらいのデータ量が返ってくるだろう」という行数推定が行われ、それをもとに実行計画が策定されますが、PostgreSQLでは特定の複合条件の場合に、行数が過少に算出されてしまいます。
その結果何が起きるかというと、結合アルゴリズムにおいて、本当はハッシュ結合(hash join)を使ってほしいのに、ネステッドループ結合(nested loop join)が選択されるケースが出てきてしまう。数万件×数万件のデータでネステッドループ結合をするようなことが起こり得ます。
──膨大な計算量になってしまいますね。
千田 そこで導入したのがヒント句でした。ヒント句を用いることで、特定のクエリにおいて常にハッシュ結合を選択させることが可能になります。行数推定を無視して、 実行計画をコントロールするわけです。
余談ですが、ヒント句を一度使うと(クエリの安定化が簡単にできて)やめられなくなるので、私たちは「ヒント句中毒」と呼んでいます(笑)。
──ヒント句中毒(笑)。
千田 PostgreSQLの行数推定の仕様については、Sansanのエンジニアである加畑(博也)が詳細にまとめた発表資料があります。ぜひ読んでみてください。
──すごい情報量。これを読むだけで、行数推定の仕様をかなり深く理解できますね。
千田 他にもPostgreSQLの実行計画を理解する上では、「第30回PostgreSQL勉強会」でTISの方が発表された資料がおすすめです。情報の凝集度が高くて勉強になるので、自分もよく参考にしています。
名刺検索処理の高速化
~コンピューター内部で何が起きているのかを理解する~
──Sansanのものづくりの情報を発信する「Sansan Builders Box」の千田さんのインタビューでは、名刺検索のパフォーマンス改善を行った旨が書かれていました。
千田 かつて名刺検索の処理には、パフォーマンス上の課題がありました。社員数の多い企業を検索すると、結果が返るまでかなりの時間がかかっていたんです。その状況を解消するため、検索処理のリファクタリングに取り組みました。
──素人のような意見で恐縮ですが、「DBインスタンスのスペックを上げてパフォーマンスを良くする」という対策はできなかったのでしょうか?
千田 当時、既に全てのデータがメモリに乗るように、相当にスペックの良いDBインスタンスを使っていたんです。物理的にもはやそれ以上速くしようがありませんでした。物理限界をどうやって突破するかを考えたのが、このプロジェクトだったんです。
──「データが全てオンメモリだけれど、なおパフォーマンスに課題がある」とはすごい次元の話ですね……。
千田 パフォーマンス改善のプロジェクトがスタートしたのは2017年の2月くらいだったのですが、5月には超大手企業のお客さまがSansanの利用を開始し、数百万枚の名刺が取り込まれることが決定していました。なんとしても、そのタイミングまでには間に合わせたい。数カ月という限られた時間のなかで、パフォーマンスを改善する必要がありました。
──どんな方法で高速化を達成したのですか?
千田 短期的に効果を出すための手段として、テーブルの非正規化を行いました。検索処理のパフォーマンスが悪いのは、Sansanのサービスの特性上、JOINの数が多いことに起因しています。そこで、検索用のJOIN済みテーブルを事前に作っておく設計にしました。
この設計を行うにあたって、考慮しなければならないのが「非正規化することでテーブル数が増えるため、データベースの合計サイズも大きくなってしまう」という課題です。そこで、追加するテーブルの空間を稼ぐために、既存テーブルから使われていないインデックスやカラムを削除する施策も同時にやりました。
──地道にデータ容量を稼いでいくのですね。
千田 さらにテーブルの“カラム順”も工夫をしています。PostgreSQLでは、データのカラムはCREATE TABLEで指定した順序に並びます。それらのカラムは、メモリアクセスを最適化するために8バイトずつの「アライメント」と呼ばれる境界で区切られているんです。
カラム順によってはぴったり8バイトに収まらないわけですが、その場合どうなるかというと、余ったデータ領域がパディングされて容量が調整されます。無駄な空間が発生するため、格納効率が落ちてしまうんです。例えば、2バイト、2バイト、4バイトという順でカラムを並べるとぴったり8バイトになりますが、2バイト、4バイト、4バイトという順では8バイトからはみ出してしまう。
逆にいえば、カラム順を工夫するだけでパフォーマンスが良くなるわけです。非正規化後のテーブルに対して、アライメントをまたがず、ぴったり8バイトずつになるような順序でカラムを並べていきました。社内ではこれを「カラムテトリス」と読んでいました(笑)。
──たしかにテトリスに似ています(笑)。PostgreSQLの仕様を正しく知っているからこそ、この最適化が実現できたわけですね。
千田 SQLが速くなった後は、さらにプロファイリングを行ってアプリケーションのどの処理が遅くなっているのかを探りました。一番のボトルネックになっていたのは、検索結果のキャッシュをシリアライズ・デシリアライズする処理です。
データ量が数十万件くらいの場合、キャッシュサイズは数MBほどになります。このキャッシュのシリアライズが全てのリクエストに対して必要になってくるので、変換処理がかなり重くなっていました。
──その課題をどう解決したのですか?
千田 変換するから処理コストがかかってしまうので、「変換せずにそのまま格納する」という方法をとりました。構造体で作った最も効率の良いメモリレイアウトを、直接Redisに格納する実装にしています。キャッシュを使う際には、Redisにある構造体の情報をそのままメモリ上に展開します。変換がなくなるので、シリアライズ・デシリアライズの処理コストはゼロになり、かなりの高速化が達成できました。
using ProtoBuf; using System.Runtime.InteropServices; void Main() { const int length = 1_000_000; var sw = new Stopwatch(); // protobuf-net Console.WriteLine("** protobuf-net **"); var rand1 = new Random(1); var exampleData1 = Enumerable.Range(1, length) .Select(i => new ExampleStruct1(rand1.Next() * rand1.Next(), i % 2 == 0, i % 3 == 0, i % 4 == 0)).ToArray(); var s = new MemoryStream(); ProtoBuf.Serializer.Serialize(s, exampleData1); Console.WriteLine($"size : {s.Length:n0} byte"); for (var i = 0; i < 5; i++) { sw.Restart(); for (var j = 0; j < 10; j++) { s = new MemoryStream(); ProtoBuf.Serializer.Serialize(s, exampleData1); } Console.WriteLine($"attempt {i + 1} : {sw.ElapsedMilliseconds:n0} ms"); } // Marshal.Copy Console.WriteLine(); Console.WriteLine("** Marshal.Copy **"); var rand2 = new Random(1); var exampleData2 = Enumerable.Range(1, length) .Select(i => new ExampleStruct2(rand2.Next() * rand2.Next(), i % 2 == 0, i % 3 == 0, i % 4 == 0)).ToArray(); Console.WriteLine($"size : {ExampleStruct2.Serialize(exampleData2).Length:n0} byte"); for (var i = 0; i < 5; i++) { sw.Restart(); for (var j = 0; j < 10; j++) { ExampleStruct2.Serialize(exampleData2); } Console.WriteLine($"attempt {i + 1} : {sw.ElapsedMilliseconds:n0} ms"); } // [output example] /* ---- ** protobuf-net ** size : 12,601,325 byte attempt 1 : 3,521 ms attempt 2 : 3,872 ms attempt 3 : 3,754 ms attempt 4 : 3,718 ms attempt 5 : 3,795 ms ** Marshal.Copy ** size : 9,000,000 byte attempt 1 : 60 ms attempt 2 : 46 ms attempt 3 : 51 ms attempt 4 : 46 ms attempt 5 : 46 ms ---- */ } // protobuf-net を使った例 [ProtoContract] struct ExampleStruct1 { public ExampleStruct1(long id, bool flagA, bool flagB, bool flagC) { Id = id; FlagA = flagA; FlagB = flagB; FlagC = flagC; } [ProtoMember(1)] public long Id { get; } [ProtoMember(2)] public bool FlagA { get; } [ProtoMember(3)] public bool FlagB { get; } [ProtoMember(4)] public bool FlagC { get; } } // Marshal.Copy を使う場合の例 // StructLayout で struct のサイズを必要最小限にする。 // Size を指定しない場合はアラインメントに合わせて 16 バイトにパディングされる。 // アラインメントを無視するとメモリへのアクセス効率が悪くなるため、データ転送効率とのトレードオフとなる。 [StructLayout(LayoutKind.Explicit, Size = 9)] struct ExampleStruct2 { private const byte FlagABitmask = 0b0001; private const byte FlagBBitmask = 0b0010; private const byte FlagCBitmask = 0b0100; [FieldOffset(0)] public readonly long _id; // bool が Bittable 型ではなく GCHandle が pin できないため、Bittable 型である byte にフラグを詰め込む [FieldOffset(8)] private readonly byte _flags; public ExampleStruct2(long id, bool flagA, bool flagB, bool flagC) { _id = id; _flags = (byte)((flagA ? 1 : 0) | (flagB ? 1 : 0) << 1 | (flagC ? 1 : 0) << 2); } public long Id => _id; public bool FlagA => (_flags & FlagABitmask) != 0; public bool FlagB => (_flags & FlagBBitmask) != 0; public bool FlagC => (_flags & FlagCBitmask) != 0; public static byte[] Serialize(ExampleStruct2[] values) { var length = Marshal.SizeOf(typeof(ExampleStruct2)) * values.Length; var buffer = new byte[length]; var valuesPinned = GCHandle.Alloc(values, GCHandleType.Pinned); try { // struct 配列を byte 配列にコピー Marshal.Copy(valuesPinned.AddrOfPinnedObject(), buffer, 0, length); } finally { valuesPinned.Free(); } return buffer; } public static ExampleStruct2[] Deserialize(byte[] buffer) { var values = new ExampleStruct2[buffer.Length / Marshal.SizeOf(typeof(ExampleStruct2))]; var valuesPinned = GCHandle.Alloc(values, GCHandleType.Pinned); try { // byte 配列を struct 配列にコピー Marshal.Copy(buffer, 0, valuesPinned.AddrOfPinnedObject(), buffer.Length); } finally { valuesPinned.Free(); } return values; } }
顧客データHubのアーキテクチャ構築
~「達成したい要件は何か」を考え、設計を導き出す~
──今年3月には「Sansan」の新機能である「顧客データHub※」が発表されました。この機能を設計・実装されたのも千田さんだそうですが、アーキテクチャで工夫した点を教えてもらえますか?
千田 初期のフェーズで「これだけはやらないといけない」と考えたのが、スケーラビリティの確保でした。顧客データHubにはSansanで取り込まれた名刺データだけではなく、CRMやSFAなど多種多様なシステムからデータが大量に取り込まれることを想定しています。かつ、それらのデータを遅延なく処理していくことがゴールになっているんです。
スケーラビリティを確保するために選択したのが、データの「結果整合性」でした。Sansanではリレーショナルデータベースを用いてACID特性を常に担保しているので、これまでとは真逆のデータ特性を選択したわけです。複数の関数がメッセージングを介しながら連動して処理を行っていき、最終的にはElasticsearchにあるデータを更新するつくりになっています。
──Elasticsearch以外のミドルウェアやデータ分析基盤は選定候補に挙がりましたか?
千田 例えば、不定形なデータを大量に入れるためのデータ分析基盤としてはHadoopも考えられますが、基本的にHadoopはデータの追記には向いているものの更新に向かないという特性があります。顧客データHubの特徴は、入力されたデータを継続的に名寄せし続けることです。だから、更新に不向きなアーキテクチャは選択しづらいんですね。
ストレージに求められていた機能は、スケーラビリティと更新、検索、集計で、それを手軽に実現できるのがElasticsearchでした。全ての機能が強いわけではないですが、それらのバランスをうまく取ってくれます。
──一方で、Elasticsearchはデータの永続性を保証することは不向きです。その欠点はどう補っているのでしょうか?
千田 永続化させるための元データは、Azure Table Storageに格納しています。これはバックアップ的な意味合いが強くて、常にElasticsearchのデータを作り出せる状態をキープしているんです。
──Azure Table StorageはKVS(Key-Value Store)形式のデータストアですから、“多種多様な形態のデータを持つ”という意味でも向いているわけですね。他に、検索の利便性を上げるための工夫はしていますか?
千田 Elasticsearchのための形態素解析器として 「Sudachi」を使っています。標準の形態素解析を用いると、辞書にない単語は全てユニグラム(任意の文字列が1文字だけ続いた文字列のこと。任意のn文字の連続は、n-gramと呼ばれる)による検索になってしまうんです。
──ユニグラムによる検索だと、どんな問題が生じるのでしょうか?
千田 例を挙げると、例えば「昭和シェル」という社名を検索する場合に問題が生じます。実は「昭和シェル」という単語はIPADIC(IPA辞書)に載っておらず、「昭和シェル石油」という単語で登録されているんです。そのため、「昭和シェル」と入力して検索すると、ユニグラムによる検索になってしまうため、検索結果のノイズが多くなってしまいます。
ですが 「Sudachi」の場合は複数の分割単位での出力が可能になっているため、より精度の高い検索を実現できます。日本語の検索にかなり向いたライブラリだと思いますね。
──今回話してくださったような「いくつもある設計の選択肢のなかから、最適なものを選ぶスキル」は、どうすれば身に付けられるでしょうか?
千田 Sansanで働くエンジニアには、ある特性が求められます。それは「プロジェクトの進め方やアーキテクチャの選定が、なぜその方針になったのかを自分なりに解釈できていること」です。もし自分が方針策定に携わっていないのだとしても、他のメンバーがどうしてその道を選んだのかを理解していなければならない。
これは、設計のスキルを上げる上で、汎用的に必要になる考え方だと思っています。選定理由が分かっているからこそ、「あのときはこういう理由でこのアーキテクチャを採用しなかったけど、別の場面では使い道がある」とイメージできる。その思考を繰り返すことが、技術的な引き出しを増やすことに直結していきます。
解くのが難しいからこそ、やりがいがある
──今回話していただいたエピソードは、国内SaaSとして最大規模のSansanだからこその難しさもありそうですね。
千田 そうですね。Sansanの業務って、いい意味で難しいことばっかりなんですよ。
例えば、サービスのUXを向上させる際に、まずペルソナを立てて、ペルソナに最適化した施策を考えることがありますよね。でも、Sansanはあらゆる企業のさまざまな年齢・職種の方に使われることを想定しているので、特定のペルソナがいないんです。利用者は20歳かもしれないし、60歳かもしれない。エンジニアかもしれないし、営業かもしれない。ペルソナがいないUXを考えるのって、すごく難しいんですよね。
サービスの可用性がお客さまのビジネスに大きく影響するので、サービスを止めないようにどう高速化するかを常に考え続けなければいけないし、膨大な量のデータをどう扱うかを考えるのも難しい。
でも、難しい状況だからこそ、エンジニアは伸びるし、仕事のやりがいもあると感じています。
取材・執筆:中薗昴/写真:鈴木智哉