最近のサービス間のデータとイベントの連携について

こんにちは。牧本 (@makimoto) です。最近はバックエンドシステムの設計をやったりしています。

今回は複数のサービスが存在するとき、その間でどのようにデータ連携を実現するかついて述べていきます。

背景と問題定義

cookpad.com は世界有数の規模の Ruby on Rails で作られたウェブアプリケーションです。

巨大な Rails アプリケーションは単純に起動やデプロイ、テストが遅いという問題以上に、自分の変更が与える影響範囲を予測するのが困難という大きな問題があります。cookpad のメインレポジトリ (cookpad_all と呼ばれる) には1つの mountable engine を共有する Rails アプリケーションは7つがあり、困難さに拍車をかけています。cookpad_all を触る開発者は新しい機能を追加する、既存の機能に手を入れる、不用な機能を消すなど様々な場面でこの困難さと対峙することになります。

そのため、われわれは既存の機能を切り出したり、新機能を新しいアプリケーションとして実装するという取り組みを行なっています。その際問題となることの一つに、複数のサービス間から必要とされるデータをどのように共有したり、またそれらデータの変更などをどのように伝播させるかというものがあります。

1つのデータストレージがモデルロジックの異なる複数のサービスで共有されるのはデータの不備や不整合の温床となるので原則として行ないません。*1 そのため、一方のサービスのデータを他方で使いたい場合は、直接データストレージにアクセスするのではなく、データを管理するサービスを通して取得するというのが基本となります。

そのような複数のサービスの間でデータを連携したいという状況では、一方のサービスで発生した変更を受けて他方のサービスで処理をする、といった場面が増えてきます。たとえば、「あるユーザーが退会したことにより、他のサービスに存在するそのユーザーに関連するアイテムを削除してアクセスできなくする」といった場合です。

これらの課題を現実的なコストで解決することが必要となってきます。

アプローチ

Garage と GarageClient の導入による RESTful Hypermedia API の社内標準化

まず、データ連携の第一歩としてウェブ API によるデータの作成・閲覧・更新・削除についてです。

クックパッドではウェブ API の開発・利用には Garage とそのクライアントである GarageClient を用いています。 Garage は RESTful Hypermedia API を Ruby on Rails 上で開発するためのライブラリであり、クックパッドで4年近くの開発・利用されています。Garage 自体については過去の紹介記事を参照いただくことにして詳細は割愛します。

共通の API 実装のためのライブラリを用いることで、各サービス間のインターフェースの差異をなくし、NantokaClient を乱立させることなくスムーズにサービスが管理しているデータにアクセスできるようにしています。また、Garage は認証認可の機能も提供しているので、共通基盤である認証システムと連携させることで適切なアクセス制御も実現できるようになっています。

イベントにもとづくデータの連携

前節では各サービスの管理しているデータにアクセスする方法を説明しました。 次は前述した「あるユーザーが退会したことにより、他のサービスに存在するそのユーザーに関連するアイテムを削除してアクセスできなくする」というケースについて考えていきます。

シンプルな方法

複数のサービスをまたいだデータの連携を実現するためのもっとも単純な方法は、データの変更があったタイミングで、そのデータに依存しているサービスに対し処理を促すリクエストを行なう方法です。この方法は、2つのサービス間でしか連携がないことがわかっている場合はうまく動きます。しかし、サービスが増えてくると複雑さが増していきます。また、この方法では、データの提供元と提供先の両方の実装に手を入れる必要があるため開発自体も煩雑になります。

Amazon SNS を用いた pub-sub メッセージング

そこでクックパッドでは、 Amazon Web Services の提供する Simple Notification Service (SNS) を用いた pub-sub メッセージングを採用しています。

これは以下の流れで実現します:

  1. あるイベント (たとえば、ユーザーの削除など) が発生するとそれに対応する SNS トピックに通知を行なう *2
  2. SNS はトピックを購読している HTTPS エンドポイントに対しイベントが発生した旨を通知する
  3. 通知を受けた HTTPS エンドポイントのサービスはその通知内容に応じて処理を行なう

なお、 SNS のメッセージには 256 KB のサイズ制限があるので、SNS のメッセージとしてはデータの ID のみを渡して、データ本体は Garage 経由で取得するという方式がよく取られます。

これらは Ping という名前の社内ライブラリによって実現されており、各サービスで簡単に導入できるようになっています。

この方法の問題点

しかしながら、この方法はいくつかの問題があります:

  • 本来内部通信しかないサービスであったとしても HTTPS エンドポイントを SNS の通知を受け取るためにインターネットに公開する必要がある
  • 通知を HTTP リクエストとして受けるので、リクエスト数が増えたらアプリケーションサーバ (Unicorn であるケースが多い) が詰まるリスクがある

Barbeque と Amazon SQS を用いたファンアウト

これを解決するため、通知の広報先を HTTPS エンドポイントから AWS の Simple Queue Service (SQS) のキューに変更し、そのキューをポーリングしてジョブを実行するという方法に転換しようとしています。 (いわゆるファンアウト)

この仕組みは、クックパッドのジョブキューシステムである Barbeque の機能として実装しています。 Barbeque は SQS をキューとして使っているため、実装的には、キューとジョブと SNS トピックを関連付けてキューでトピックを購読する機能を追加するだけでこの仕組みは実現可能でした。 (https://github.com/cookpad/barbeque/pull/20)

この方法での流れは以下の通りです:

  1. あるサービスでイベント (たとえば、ユーザーが退会する、など) が発生すると、サービスは SNS トピックに通知を行なう
  2. SNS はトピックを購読している SQS のキューに対しイベントが発生した旨を通知する
  3. Barbeque のワーカーが対象のキューをポーリングし、通知を受けとる
  4. Barbeque のワーカーが通知をもとに関連付けられたジョブを実行する

Barbeque は Docker の利用を前提としたジョブキューシステムなので、ジョブの実行は Docker コンテナ内で行なわれます。すでに稼動しているサービスとは別のコンテナでジョブが実行されるため、ジョブが増えたことによりリクエストが増えて Unicorn のワーカーが詰まる問題を避け、よりスケーラブルなジョブ実行環境となります。クックパッドでは、現状ほとんどすべての新規サービスが Docker コンテナ上で動いているため、この仕組みの恩恵を十分に受けることができます。

まとめ

本稿では、クックパッドでどのように複数のサービス間でのデータ連携を行なっているかについて述べました。 Docker、AWS のメッセージング系のサービス、内製のソフトウェアを組み合わせて複数のサービスで協調してデータを扱うことを実現しています。

サービスを分割するときの心配事の1つとして、既存サービスのデータとうまく連動させるのが難しいというのがありますが、今回紹介した方法である程度は解決できると考えています。

*1:ただし、既存サービスから機能を徐々に切り出す場合 などのケースで例外はあります。

*2:実際の運用では SNS にリクエストを送るために fluentd を経由させています。

Web サービスの完全 HTTPS 化

インフラストラクチャー部長の星 (@kani_b) です。

2017年1月5日をもって、クックパッド における全ページで HTTPS が使われるようになりました。 完全 HTTPS 化をするにあたり、その理由や具体的な進め方について紹介します。 以前 SRE Tech Talks #2 にて一部発表した内容も含みますので、ご興味のある方はあわせてスライドもご覧ください。

完全 HTTPS 化に踏み切った理由

以前のクックパッドは、ログインや登録情報の参照など、いわゆる個人情報や認証情報を扱う箇所のみに HTTPS が使われていました。 このように「必要な箇所にのみ HTTPS を使う」構成は、ある程度歴史のある Web サービスにおいてよく使われている構成です。 この状態から、完全 HTTPS 化に踏み切った理由を説明します。

サービスをよりセキュアにするため

HTTPS の利用を考えるにあたりまず思い浮かぶ利点は、「通信を暗号化できる」そして「通信先を認証できる」ことでしょう。前述の通り、これまでは機密性の高い情報を扱う箇所のみで HTTPS を利用してきました。これだけでも、守りたい情報が簡単に窃取できてしまう状態は避けることができます。しかし、現代は公衆無線 LAN などオープンなネットワークがかなり普及しており、また国家レベルでの盗聴なども明らかになってきています。

「レシピサイトくらいで大げさな」と思われるかもしれませんが、食事は人間の生活と密接に紐付いており、思わぬ情報を得られる場合があります。例えばクックパッドはレシピの検索機能を提供しています。この機能にはこれまで HTTP が使われてきました。しかし、実際の検索動向を見てみると、例えば「ダイエット」であったり、特定の病気 (糖尿病など) に適したレシピなど、プライバシに大きく関わるキーワードが含まれることがあります。

こう見ると、「では検索は HTTPS にしたほうが良い」という気持ちが働きます。しかし、検索だけではなく、他の機能についても同様のことが考えられます。「その機能が実際どのように使われるのか」を完全に想定することは困難です。よって、全ての通信が暗号化されている状態をまず前提とすることにしました。もちろん、HTTPS の上でやり取りされる情報がどのように扱われるか、はまた別の話であり、アプリケーションレイヤにおいてどのように情報を扱うかは今後も考慮していく必要があります。

プラットフォームの進化

iOS の App Transport Security (ATS) 対応必須化などをはじめ、プラットフォーム側で通信を HTTPS にする流れも進んでいます。現在完全 HTTPS 化が進められている主な要因はここにあるのではないかと思います。Chrome においても近年は HTTPS 絡みの変更が盛んです。Chrome 56 でリリースされた、 HTTP ページにログインフォームが表示されている場合に "安全でないページ" という警告を出す機能は、多くの HTTP ページでログイン用のモーダルを表示していたクックパッドにも影響を及ぼす変更でした。

また、検索エンジン側の変更も要因の一つです。Google は検索ランキングにおいて HTTPS の利用有無をランキングアルゴリズムに利用することを発表しています。また多くの検索エンジンが検索画面そのものを HTTPS 化しており、 HTTPS 化なしに自社サービスへの流入などを正確に計測することは難しくなります。こうした外部のプラットフォームが HTTPS 化へと舵を切ってきたことも、移行理由の一つになりました。

開発のやりにくさ

完全 HTTPS 化をしていないサービスの多くで、ログインフォームや登録情報の参照など一部の画面のみが HTTPS 化されています。クックパッドでも長い間同様に HTTPS が使われている画面を使い分けていましたが、開発者が「この画面には HTTPS が必要かどうか」を判断して使い分けていたため、本来 HTTPS であるべき画面がそうでない、などの事故が起きうる状況でした。また、HTTPS 画面で提供されているエンドポイントにアクセスするために CORS に対応する必要があるなど、普段の開発にも影響が及んでいました。

新技術への対応

HTTPS は、現在出てきている新しい技術の必須要件とされることも多くなりました。例えば HTTP/2 はその代表格 (正確には HTTP/2 自体が HTTPS を要求しているわけではないが、インターネットサービスにデプロイするためにはほぼ必須) でしょう。他にも、ServiceWorker や Web Push, iOS の Shared Web Credentials などは HTTPS が利用されていることが要件となっています。 こうした新しい技術を活かしていくためにも、完全 HTTPS 化は必要でした。

以上が、クックパッドを完全 HTTPS 化するに至った理由です。Web サービスにおいて、もはや完全 HTTPS 化をしないポジティブな理由はないと考えます。

完全 HTTPS 化までの道のり

完全 HTTPS 化する理由がまとまったところで、次に実際どのように HTTPS 化したか、具体的な進め方について説明します。完全 HTTPS 化は、概ね以下のように進めました。

  1. HTTPS テスト環境を作成する
  2. mixed content をなくす
  3. 段階的リリース
  4. 全体リリース

完全 HTTPS 化は社内のアプリケーション、検索エンジンはもちろんのこと、提携他社からのアクセス、メディア媒体掲載時に利用される URL などあらゆる範囲に影響するため、社内での宣言は早めに行いました。結果として、多くの部署の協力を得ながら完全 HTTPS 化を進めることができました。

HTTPS テスト環境の作成

まずは HTTPS になった際のアプリケーションをテストできる環境をつくる必要があります。 クックパッドでは、Rails の HTTPS 必須化スイッチである force_ssl を利用し、特別な Cookie をリクエストに差し込んだ場合に force_ssl が有効化された専用のアプリケーションサーバにルーティングされるようにしていました。こちらは、段階的リリースのタイミングでは内部アクセスや Cookie の扱いに不都合があったため、リバースプロキシでリダイレクトする形に変更しています。

mixed content をなくす

mixed content とは、HTTPS の中に HTTP のリソースが含まれることです。多くのブラウザは、mixed content になっているリソースはロードしない、あるいは動作させないような制約を持っているため、完全 HTTPS 化にあたり mixed content をなくすことは必須です。最も大変なのがこの対応だと思います。

アプリケーションや CSS の中に埋まっている HTTP URL を探しだし、HTTPS に修正します。クックパッドにおいても、いくつか HTTP URL が記載されてしまっているケースがありました。 こういった URL は、 protocol-relative URL (//: ではじまる URL) に置き換えたり、アプリケーション側でリクエストプロトコルを見て URL を生成するように修正します。

大きなコードベースにおいて、この作業は根気の要るものです。また、この作業をしている間もサービスは開発され続けているため、終わりがありません。また、mixed content はコードそのもの以外 (ユーザの持つデータなど) に起因することも多いため、実際に本番に出してテストしていくことが大きな助けになります。そのため今回は、いくつかの主要機能を定めておき、それらに mixed content が発生しない段階で次のステップに進むことにしました。

ネットワーク広告の HTTPS 対応

ネットワーク広告は、その仕組み上実際に配信される広告クリエイティブが HTTPS を利用しているかが非常に重要です。クックパッドが完全 HTTPS 化に着手した2015年8月頃は、まだ多くの事業者が HTTPS に対応していない、あるいは対応を保証できない状態でした。つまり、ネットワーク広告を配信すると、mixed content が起きてしまう可能性が高かったのです。

しかし、ATS がリリースされたことによるものか、現在では多くの事業者が HTTPS 対応を進めています。クックパッドでも一部ネットワーク広告が利用されているため、2015年の着手時にはこれが原因となり一度ペンディングとなったものの、流れが変わったことで再び進めることができるようになりました。このあたりの事情は @suzu_v さんのスライドに非常にわかりやすくまとまっています。

完全 HTTPS 化完了後、ネットワーク広告の売上について事業部と確認も行いましたが、特に影響はありませんでした。

段階的リリース

完全 HTTPS 化による影響を確認するため、段階的なリリースを行いました。 リバースプロキシのレイヤで、特定の Cookie を用いて全ユーザのうち数%が完全 HTTPS 化されたアプリケーションサーバにアクセスするようにします。 アプリケーション側のエラーやパフォーマンスをトラックしつつ、ユーザからのご意見やお問い合わせをユーザサポート部門と連携しながら確認します。 結果として、いくつかのリダイレクトミスと不具合を見つけ、修正しました。

CSP Report の活用

今回の移行では利用できなかったのですが、Content Security Policy (CSP) の機能を使うことで、より効率的に mixed content の情報を集めることができます。CSP のディレクティブとして block-all-mixed-content ディレクティブがあり、これを指定するとブラウザは mixed content を一切読み込みません。また、CSP の機能として、ポリシ違反が発生した際に指定した URL にレポートを送出する機能 (report-uri *1 ) があります。 これらを活用することで、ユーザのブラウザで起きた mixed content 情報を収集することが可能です。

そのままユーザにリリースしてしまうと mixed content が存在した場合本当にリソースが読み込まれなくなってしまうため、Content-Security-Policy ヘッダでなく Content-Security-Policy-Report-Only ヘッダを利用します。

Content-Security-Policy-Report-Only: block-all-mixed-content; report-uri https://example.com/csp-report

上記のようなヘッダを送出すると、もし mixed content が発生した場合指定した URL にそのブラウザから JSON レポートが POST されます。内容は以下のようなものです。

{
    "csp-report": {
        "blocked-uri": "http://example.com/some_picture.png", 
        "disposition": "report", 
        "document-uri": "https://example.com/mixed_content.html", 
        "effective-directive": "block-all-mixed-content", 
        "line-number": 6, 
        "original-policy": "block-all-mixed-content; report-uri https://example.com/csp-report", 
        "referrer": "", 
        "source-file": "https://example.com/mixed_content.html", 
        "status-code": 0, 
        "violated-directive": "block-all-mixed-content"
    }
}

JSON が送られてくるだけですので、受け取るサーバの実装も簡素なことに加え、 Elasticsearch などに投入することで簡単に分析することが可能です。 クックパッドでは、一部のユーザに CSP を送出しています。また、レポートの受信には Amazon API Gateway を使い、受け取った JSON を Amazon Kinesis Firehose に送信して Amazon Elasticsearch Service で分析を行えるようにしています。実装が必要な箇所は API Gateway が JSON を受け取る箇所のみですので、非常に楽です。

全体リリース

段階的リリースにおいて徐々に公開範囲を広げていき、問題がなければ全体リリースへと進みます。 アプリケーションが HTTPS 接続を受け入れられる状態にした上で、リバースプロキシでリダイレクトを行いました。問題が判明した際すぐに切り戻せるよう、以下の点に気をつけていました。

  • HTTP 301 ではなく 307 (Temporary Redirect) の利用
  • Cookie 属性や HSTS など切り戻しのしにくい変更を行わない
  • ユーザサポート部門との協力

リリース後に監視を行いましたが、大きな問題は見当たりませんでした。

切り戻しのしにくい変更

完全 HTTPS 化における "後戻りのしにくい" 変更として、Cookie への secure 属性の付与や HTTP Strict Transport Security (HSTS) があります。

Cookie の secure 属性は、Cookie を HTTPS 環境下でのみ送出する属性であり、設定ミスや攻撃による意図しない HTTP アクセスでの Cookie 漏出を防ぐことができます。 完全 HTTPS 化が完了すればこの属性を付与することは何ら問題ないのですが、万一 HTTP への切り戻しを行った際に、例えば既存のユーザセッションが全て無効 (セッション Cookie が送出されない) になるといった事態を招きます。 そのため、完全 HTTPS 化を行った上で、サービスに影響がないことを確認できたタイミングで付与する必要があります。

HTTP Strict Transport Security (HSTS) は、Web サービス側からブラウザに対し「次回以降このドメインには HTTPS でアクセスしてほしい」旨を伝え記憶してもらう仕組みです。 完全 HTTPS 化により、 HTTP ページへのアクセスを HTTPS ページにリダイレクトします。つまり、リダイレクトされるまでの初回アクセスは HTTP になってしまいます。 HSTS を付与することで、例えばユーザがブラウザにドメインのみを入力してアクセスする場合でも、そのドメインにアクセスしたことががあれば HTTPS を利用するようになります。

この仕組みも、ブラウザの挙動を変更するため導入には注意が必要です。HSTS が設定されたドメインで、切り戻しのために HTTPS から HTTP へのリダイレクトを単に行うとリダイレクトループが発生します。 HSTS は max-age を 0 にした HSTS ヘッダを送出することで無効にすることができますので、secure 属性よりは切り戻しやすいといえます。しかし、HSTS は常に HTTPS で送出される必要がある (無効にしたい場合でも) という点には注意が必要です。 例えばロードバランサの負荷が心配なケースで、一度 HSTS を全体で有効にしてしまうと、切り戻したあとも継続して HTTPS アクセスを (リダイレクトが行われるまで) 受け続ける必要があります。

上記のことを踏まえ、クックパッドではまず HSTS を有効にして意図せず HTTP でアクセスされているページや動かない機能がないことを社内のエンジニアや事業部門に確認してから secure 属性の付与を行いました。 アプリケーション側でヘッダ送出を行うことも可能ですが、設定の見通しをよくするためリバースプロキシを利用しています。

全体リリース後

画像やあまり利用されていない機能などでトラブルが起きることもあるため、ご意見やお問い合わせを見ながら個別に対応を行っています。 また、URL が変更されるため、集計バッチなどの動作にも気を配る必要がありました。

完了後の反響など

上記のような進め方で、無事にクックパッドを完全 HTTPS 化することができました。 移行にあたり、パフォーマンスなども懸念として上がっていましたが、現在特に問題にはなっていません。HTTPS のオーバーヘッドは当然存在しますが、近年では端末のリッチ化やネットワークの高速化、安定化により大きな問題にはなりにくいと考えています。また、HTTP/2 や TLS 1.3 などプロトコルの進化により、よりオーバーヘッドは減らせると考えています。 また、上述の通りネットワーク広告の売上や、検索エンジンの順位などにも影響はみられませんでした。

おわりに

クックパッドを完全 HTTPS 化した背景や具体的な進め方についてご紹介しました。現在は、公開している全てのサービスを HTTPS で提供しています。 完全 HTTPS 化は、エンジニアに限らず様々な人を巻き込む必要があり、場合によっては少し根気も要る作業になりますが、段階的リリースや CSP などを使うことでよりよく進めていくことも可能です。

個人的には、完全 HTTPS 化に限らず、ユーザが安全にインターネットを利用できるようにすることは Web サービス事業者の一つの責務と考えています。完全 HTTPS 化は、その中でも実行のための障壁がなくなりつつある改善の一つです。

ユーザの安全や新技術など、Web が次の段階に進んでいくためにも HTTPS 対応は今後より必須になることでしょう。この記事が、まだ HTTPS 移行が済んでいない方のお役に立てば幸いです。

*1:report-uri は CSP Level 3 において廃止されているため、今後は report-to を使っていく必要があります

MySQL with InnoDB のインデックスの基礎知識とありがちな間違い

こんにちは、サービス開発部の荒引 (@a_bicky) です。

突然ですが、RDBMS の既存のテーブルを見てみたら「何でこんなにインデックスだらけなの?」みたいな経験はありませんか?不要なインデックスは容量を圧迫したり、挿入が遅くなったりと良いことがありません。

そんなわけで、今回はレコードを検索するために必要なインデックスの基礎知識と、よく見かける不適切なインデックスについて解説します。クックパッドでは Rails のデータベースとして主に MySQL 5.6、MySQL のストレージエンジンとして主に InnoDB を使っているので、MySQL 5.6 の InnoDB について解説します。

InnoDB のインデックスに関する基礎知識

インデックスの構造 (B+ 木)

InnoDB では B+ 木が使われています。B+ 木は次のような特徴を持った木構造です。

  • 次数を b とすると、各内部ノード(葉ノード以外のノード)は最大 b - 1 個のキーと最大 b 個の子ノードを持つ*1
  • 内部ノードは値を持たない
  • 葉ノードの各キーは値(または値へのポインタ)を持つ
  • 葉ノードは次の葉ノードへのポインタを持つ
  • O(\log_bn) で検索できる

次の図は次数 3 の例です。

f:id:a_bicky:20170417093420p:plain:w552

例えば、key が 4 の value を取り出すには次のように木を辿れば良いです。

f:id:a_bicky:20170417093427p:plain:w440

key が 2 〜 9 の value を全て取り出すには次のように木を辿ることができます。葉ノード間が繋がっていることによって範囲検索を効率的に行うことができます。

f:id:a_bicky:20170417093430p:plain:w440

InnoDB の B+ 木では、インデックスに使われているカラムの値と主キーの値が value に格納されています。

ここで (c1, c2) のような複合インデックスを考えてみます。イメージとしては次のような構造になります。例えば、key の上位 4 bytes を c1 に割り当て、下位 4 bytes を c2 に割り当てるイメージです。

f:id:a_bicky:20170418083840p:plain:w440

図からわかるように、c2 = 2 に対応するインデックスレコードを探そうとしても、c1, c2 の順番で並んでいるので、木を辿ることで探すことはできません。c1 = 4 AND c2 = 2 のように c1 の条件が指定されることで木を辿ることができるようになります。

また、c1 >= 2 AND c2 <= 4 のような条件の場合、c1 >= 2 の条件を満たすインデックスレコードは木を辿ることで探せますが、それらのインデックスレコードを更に c2 <= 4 の条件で絞り込むために木を利用することはできません。具体的には、図の例だと [7,5] が条件を満たさないインデックスレコードですが、その隣の [9,3] が条件を満たすかどうかはわからないですよね。結局のところ、c1 >= 2 のインデックスレコード全てを走査しないといけません。

よって、インデックスレコードの走査の数を減らす観点では c1 = 2 AND c2 <= 4 のように c1 の条件が等価比較になる場合に限って、複合インデックスとしての意味を持つことになります。*2

このことについては MySQL のドキュメントの 8.2.1.2 Range Optimization にも次のように言及されています。

The optimizer attempts to use additional key parts to determine the interval as long as the comparison operator is =, <=>, or IS NULL. If the operator is >, <, >=, <=, !=, <>, BETWEEN, or LIKE, the optimizer uses it but considers no more key parts.

InnoDB における B+ 木の実装について知りたい方は次の記事と言及されている関連記事を読むとかなり理解が深まると思います。

B+Tree index structures in InnoDB – Jeremy Cole

クラスタ化インデックスとセカンダリインデックス

InnoDB の主キーはクラスタ化インデックス (clustered indexes) になっており、B+ 木の葉ノードにレコードの全データが格納されています。 特定のカラムに張るインデックスはセカンダリインデックスと呼ばれ、セカンダリインデックスの葉ノードには主キーが必ず含まれています。

セカンダリインデックスを使った検索は、まずセカンダリインデックスの B+ 木を辿って主キーを取得し、次にクラスタ化インデックスの B+ 木を辿ってレコードを取得することになります。

f:id:a_bicky:20170417093437p:plain:w440

セカンダリインデックスに必要な情報が全て格納されていれば、クラスタ化インデックスを辿る手順をスキップすることができるので高速です。このようにレコードを取得する際にセカンダリインデックスで完結する場合のことをカバリングインデックスと呼びます。

MySQL がレコードを取得する大まかな手順

MySQL がレコードを取得する際の主要な登場人物として、executor*3 と storage engine (e.g. InnoDB) がいます。

storage engine が InnoDB の場合は次のようにレコードを取得します。

  1. executor が storage engine にレコードを要求する
  2. storage engine (InnoDB) はセカンダリインデックスの木を辿ることで、取得すべきレコードのインデックスに含まれているカラム値と主キーの値を得る
    • インデックスが使えない場合はクラスタ化インデックスに含まれている全レコード情報を executor に返す
  3. storage engine は 2 で得たデータのうち、インデックスに含まれているカラム値の情報を使って取得すべきレコードをフィルタリングする (Using index condition)
    • インデックスに含まれている情報が使えない場合はスキップ
  4. storage engine は 3 で得たデータの主キー情報を使ってクラスタ化インデックスからレコードを取得する
    • SELECT で指定されているカラムや WHERE で指定されているカラムの情報が全てインデックスに含まれている場合はスキップ (Using index)
  5. storage engine は取得したレコード情報を executor に返す
  6. executor は storage engine がフィルタリングできなかったレコードをフィルタリングする (Using where)
    • storage engine 側で全てフィルタリングされている場合はスキップ

「雑なMySQLパフォーマンスチューニング」はこの辺の内容をわかりやすく説明しているので、ピンとこなければ読むことをお勧めします。

以上の説明で、Explain で extra に Using index, Using index condition, Using where が出る場合にどのような処理が行われているかイメージが付くと思います。 Using index condition が出る場合は ICP (Index Condition Pushdown) 最適化と呼ばれ、MySQL 5.6 から導入されました。これによって、クラスタ化インデックスから取得するレコードが減るのでその分高速になります。c1 >= 2 AND c2 <= 4 のような条件のために (c1, c2) の複合インデックスを張っても意味がないと前述しましたが、ICP の恩恵を受けてパフォーマンスが改善する場合もあります。

インデックスを張る上でのポイント

インデックスを張る上では次のような内容がポイントになると思います。

  • インデックスで絞り込めるレコード数が大きいか?(選択性が高いか?)
    • WHERE c1 = 1 AND c2 = 2 AND c3 = 3 AND c4 = 4 みたいな条件があるからといって、(c1, c2, c3, c4) の複合インデックスが必要とは限らない
    • c1 で十分絞りこめるのに他のカラムもインデックスに含めるとインデックスが肥大化する
  • 複合インデックスを張る場合
    • 絞り込めるレコード数の大きいカラムを先にしているか?
    • 等価比較するカラムが先になっているか?
  • カバリングインデックスの恩恵を十分に受けられるか?
  • ICP の恩恵を十分に受けられるか?
  • ソートに使うか?*4

不適切なインデックスの例

これまでに説明した内容がわかっていると、以下に挙げる内容が不適切なインデックスだとわかるはずです。

  • インデックスの最初のカラムに範囲指定をする複合インデックス
  • 選択性の悪いインデックス
  • 他のインデックスで代替できるインデックス

具体例を挙げるために、次のようなテーブルを扱うことにします。商品情報を管理するテーブルで、現在以降に掲載される商品の情報が日々追加されていく想定です。

CREATE TABLE `products` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `shop_id` int(10) unsigned NOT NULL,  -- 商品を掲載している店舗の ID
  `name` varchar(255) NOT NULL,         -- 商品名
  `price` int(10) unsigned NOT NULL,    -- 商品の価格
  `starts_at` datetime NOT NULL,        -- 商品の掲載開始日時
  `ends_at` datetime NOT NULL,          -- 商品の掲載終了日時
  PRIMARY KEY (`id`)
) ENGINE=InnoDB

それでは具体例を見ていきます。

インデックスの最初のカラムに範囲指定をする複合インデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_ends_at_starts_at (ends_at, starts_at);

これは、次のようなクエリに対処するために定義されたインデックスですが、ends_at 単一のインデックスを張るのとあまり変わりません。*5

-- 現在掲載されている商品を抽出する
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();

この理由は次のような B+ 木をイメージするとわかりやすいです。この木は (c1, c2) に対して構築された B+ 木ですが、c1 >= 2 AND c2 <= 5 のような条件でレコードを引こうと思った場合にインデックスレコードを走査する数が減りません。例えば、[2,8] のインデックスレコードが c2 の条件を満たしませんが、その隣の [3,1]c2 の条件を満たすかどうかは木の構造から判断できず、c1 >= 2 のインデックスレコードを全て走査することになります。

f:id:a_bicky:20170418084900p:plain:w440

MySQL 5.6 では前述した ICP 最適化という仕組みがあるので、starts_at >= NOW() での絞り込みで劇的にレコードが減るようであれば、ICP の恩恵を受けられるかもしれません。

ICP の効果は session status の Handler_read_next の値がどれだけ変わるかを見てみるのが良いでしょう。この値が少ないほどインデックスレコードの走査が少ないことを意味します。

-- ICP 有効
FLUSH STATUS;
SET @@optimizer_switch = "index_condition_pushdown=on";
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();
SHOW SESSION STATUS LIKE 'Handler%';

-- ICP 無効
FLUSH STATUS;
SET @@optimizer_switch = "index_condition_pushdown=off";
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();
SHOW SESSION STATUS LIKE 'Handler%';

ICP の効果が低いと判断したら、ix_ends_at_starts_at は削除して単一のインデックスを張りましょう。

ALTER TABLE products DROP INDEX ix_ends_at_starts_at,
  ADD INDEX ix_ends_at (ends_at);

似たような例として、次のようなインデックスを考えます。

ALTER TABLE products ADD INDEX ix_ends_at_shop_id (ends_at, shop_id);

これは、次のようなクエリに対処するために定義されたインデックスですが、同様に ends_at 単一のインデックスを張るのとあまり変わりません。

-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

shop_id は等価比較で使う前提なので、(shop_id, ends_at) の順番でインデックスを張ることで複合インデックスとしての恩恵が得られます。

ALTER TABLE products DROP INDEX ix_ends_at_shop_id,
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

選択性の悪いインデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_shop_id_starts_at (shop_id, starts_at);

これは、次のようなクエリに対処するために定義されたインデックスですが、選択性の観点で良くありません。

-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

ix_shop_id_starts_at の選択性が良いかどうかはテーブルとクエリの特性次第ですが、products テーブルが現在以降に掲載される商品しか追加されず、過去に掲載されていた商品が残り続けるのであれば、starts_at <= NOW() という条件は該当レコードが日々増えていきます。一方で、ends_at >= NOW() は商品の増え方が一定であれば該当レコードの量は一定とみなせます。

よって、(shop_id, ends_at) にインデックスを張るべきです。

ALTER TABLE products DROP INDEX ix_shop_id_starts_at,
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

他のインデックスで代替できるインデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_shop_id (shop_id),
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

これは次のような 2 種類のクエリに対処するために定義されたインデックスですが、ix_shop_id は冗長です。

-- shop_id = 1234 の店舗に掲載されたことがある、または掲載される予定の商品を全て抽出する
SELECT * FROM products WHERE shop_id = 1234;
-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

shop_id での絞り込みに特化したインデックスとして ix_shop_id を導入したと思われますが、次の 2 つの木を見れば ix_shop_id_ends_atix_shop_id の役割を包含していることは一目瞭然です。

f:id:a_bicky:20170417093440p:plain:w440

よって、ix_shop_id は削除すべきです。

ALTER TABLE products DROP INDEX ix_shop_id;

最後に

以上、MySQL (InnoDB) のインデックスについて簡単に解説しました。InnoDB について完璧に理解しようと思うと膨大な知識が必要ですが、よくある単純な用途でインデックスを張るだけであれば、必要とされる知識はほんの少しであることがわかると思います。 本エントリーによって、世の中から不適切なインデックスだらけのテーブルが少しでもなくなれば幸いです。

*1:次数の解釈は文献によって異なるので、2017 年 4 月 17 日時点の Wikipedia に合わせています

*2:後述する ICP が効果を発揮する場合はその限りではありません

*3:executor を mysql server と表現している記事を見かけることがありますが、sql_executor.cc に実装されているので executor という表現の方が適切だと思います

*4:本エントリではソートには触れないので、興味のある方は「漢(オトコ)のコンピュータ道: Using filesort」を参照すると良いと思います

*5:Explain の key_len 的には starts_at も使われるように見えるので、ソースコードを読んでその理由を調べようと前々から思ってますが、未だに調査できてません…