ブラックフライデー当日の午前2時14分、オンコールのPagerDutyアラートによってインフラチーム全員が叩き起こされました。16コアのPostgreSQLプライマリノードはCPU使用率100%に張り付き、PgBouncerのプール接続上限である300コネクションは完全に枯渇。クエリレイテンシの中央値は22ミリ秒から9秒以上にまで跳ね上がっていました。
pg_stat_activity を確認すると、原因は一目瞭然でした。サイトのヘッダー検索バーで、何千人もの買い物客が同時に samusng glalxy のようなクエリを入力していたのです。入力ミスのたびに140万行のテーブル全体に対する ILIKE '%query%' のワイルドカードスキャンが走り、データベースを圧迫していました。午前8時に予定されている大規模なマーケティング配信の前に、検索トラフィックを専用エンジンへオフロードしなければなりませんでした。
私たちは急遽DockerでMeilisearchを立ち上げ、タイポ耐性とファセットフィルターを調整し、イベント駆動型の同期パイプラインを構築しました。その結果、日の出を迎える頃には、検索レイテンシを6ミリ秒未満にまで抑え込むことに成功しました。
クイックスタート(5分)
本番環境が炎上しているような緊急時には、シンプルな構成こそが最大の武器になります。Dockerを使えば、永続ストレージを備えた本番仕様の検索ノードを5分足らずで立ち上げることが可能です。
1. Docker Composeサービスの定義
シンプルな docker-compose.yml ファイルを作成します。予期せぬインデックスフォーマットのアップグレードを避けるためにエンジンのバージョンを固定し、データベースファイル用に名前付きボリュームをマウントして、32文字のマスターキーを指定します:
version: '3.8'
services:
meilisearch:
image: getmeili/meilisearch:v1.6
container_name: meilisearch_prod
restart: always
ports:
- "7700:7700"
environment:
- MEILI_ENV=production
- MEILI_MASTER_KEY=4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d
- MEILI_NO_ANALYTICS=true
volumes:
- meili_data:/meili_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 10s
timeout: 5s
retries: 3
volumes:
meili_data:
driver: local
2. 起動とヘルスチェック
コンテナをバックグラウンドで起動します:
docker compose up -d
curl -X GET 'http://localhost:7700/health'
2秒以内にエンドポイントから {"status":"available"} が返ってくれば準備完了です。サービスが稼働し、カタログデータを取り込む準備が整いました。
詳細解説
リレーショナルデータベースは完全一致を前提として設計されているため、曖昧な検索に対してはシーケンシャルスキャンを実行し、ディスクI/Oに深刻な負荷をかけます。一方、Meilisearchはインメモリのプレフィックスツリーとレーベンシュタイン距離(タイポ距離)を活用して全く逆のアプローチを取ります。ただし、短い型番などを扱うECサイトの場合、デフォルト設定のままでは検索品質が著しく低下するリスクがあります。
タイポ耐性(Typo Tolerance)のチューニング
デフォルトのMeilisearchは、5文字以上の単語で1文字、9文字以上の単語で2文字までのタイポを許容します。一般的な文章の検索には適していますが、4文字のハードウェア型番や厳密なSKUを検索する際には誤マッチの原因となります。
このしきい値はインデックス設定エンドポイントからカスタマイズ可能です。以下の設定を行うことで、「headphnes」のような一般的な単語の打ち間違いには柔軟に対応しつつ、「RTX」などの専門用語やカタログのSKUについては完全一致を強制できます:
curl -X PATCH 'http://localhost:7700/indexes/products/settings/typo-tolerance' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"enabled": true,
"minWordSizeForTypos": {
"oneTypo": 4,
"twoTypos": 8
},
"disableOnWords": ["amd", "rtx", "usb"],
"disableOnAttributes": ["sku"]
}'
特に disableOnAttributes には注目してください。sku のタイポ修正を無効化することで、ユーザーがシリアル番号を1桁打ち間違えた際に、まったく無関係なパーツが検索結果に表示されるのを防ぎます。
ファセット検索(Faceted Search)の設定
ユーザーはフリーワード検索だけで終わることは稀で、ブランド、在庫状況、価格帯などで絞り込みを行います。Meilisearchでこれらを高速に処理するには、Roaring Bitmapインデックスを事前計算できるよう、フィルタリングやソートに使用するフィールドをあらかじめ明示しておく必要があります:
curl -X PATCH 'http://localhost:7700/indexes/products/settings' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"filterableAttributes": [
"category",
"brand",
"in_stock",
"price"
],
"sortableAttributes": [
"price",
"created_at"
]
}'
ビットマップが生成されることで、複雑な条件付き検索であっても生のドキュメントをフルスキャンすることなく、インデックス構造上で直接高速に評価されます:
curl -X POST 'http://localhost:7700/indexes/products/search' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"q": "headphne",
"filter": "brand = 'Sony' AND price <= 300 AND in_stock = true",
"facets": ["category", "brand"]
}'
毎秒450件の同時リクエスト負荷テストにおいても、このクエリはわずか4.2ミリ秒で応答しました。
応用テクニック
検索機能をプライマリデータベースから切り離すと、状態の同期(データ整合性)という課題が浮上します。PostgreSQL側で商品が完売したにもかかわらず、Meilisearch側で在庫ありと表示されたままでは、バックオーダーやユーザーの不満につながります。
トランザクションアウトボックスパターンによるアプリケーション層の同期
メインのHTTPリクエスト/レスポンスサイクル内で外部の検索APIを同期的に呼び出すのは避けてください。検索クラスタ側でレイテンシが発生した場合、Web APIのスレッドが巻き込まれて枯渇してしまいます。データベースのアウトボックステーブルやメッセージブローカーに変更イベントを書き込み、バックグラウンドワーカーでバッチ処理させる設計が鉄則です:
import os
import time
from meilisearch import Client
MEILI_URL = os.getenv("MEILI_URL", "http://localhost:7700")
MEILI_KEY = os.getenv("MEILI_KEY", "4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d")
client = Client(MEILI_URL, MEILI_KEY)
index = client.index("products")
def sync_product_record(product_data):
"""
Meilisearchへ単一またはバッチの商品更新データを送信します。
'id' によるドキュメントの置換はMeilisearch側で自動処理されます。
"""
task = index.add_documents([{
"id": product_data["id"],
"name": product_data["name"],
"sku": product_data["sku"],
"brand": product_data["brand"],
"category": product_data["category"],
"price": float(product_data["price"]),
"in_stock": product_data["stock_quantity"] > 0,
"updated_at": int(time.time())
}], primary_key="id")
print(f"同期タスクを登録しました(タスクID: {task.task_uid})")
# アウトボックスのコンシューマーから受け取ったサンプルイベント
new_item = {
"id": 1042,
"name": "ワイヤレスノイズキャンセリングヘッドホン",
"sku": "WH-1000XM5",
"brand": "Sony",
"category": "オーディオ",
"price": 299.99,
"stock_quantity": 12
}
sync_product_record(new_item)
初期データの投入とマイグレーション
緊急デプロイの最中、データベースのダンプから52,000件の既存レコードをMeilisearchへ一括移行する必要がありました。エクスポートしたCSVの在庫データをバッチインポート用のJSONチャンクに変換する際、私は toolcraft.app/ja/tools/data/csv-to-json を活用しました。パース処理がブラウザのクライアント側で完結するため、機密性の高い商品データが作業端末の外へ送信される心配がありません。プレッシャーのかかる現場で使い捨ての変換スクリプトを一から書くことなく、Meilisearchに流し込める綺麗なJSONペイロードを素早く準備できました。
実践のベストプラクティス
ブラックフライデーのアクセス急増を乗り切る中で得られた、運用上の教訓を3つ共有します:
1. フロントエンドコードにマスターキーを含めない
MEILI_MASTER_KEY はインデックス削除を含むすべての管理者権限を持ちます。クライアントサイドのSPAやモバイルアプリ向けには、読み取り操作のみに限定したスコープ付きAPIキーを発行して利用してください:
curl -X POST 'http://localhost:7700/keys' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '{
"description": "検索専用公開キー",
"actions": ["search"],
"indexes": ["products"],
"expiresAt": null
}'
発行された key トークンをフロントエンドの環境変数に設定します。これにより、万が一悪意のある第三者にクライアントのネットワーク通信を傍受されても、設定の改ざんやコレクション全体のデータ流出を防ぐことができます。
2. インデックススワップによるゼロダウンタイム再インデックス
トークナイザーのルールやストップワードを変更すると、Meilisearchは全ドキュメントの再インデックスを要求します。稼働中のインデックスを直接再構築するとクエリレイテンシが大幅に悪化するため、バックグラウンドでセカンダリインデックスを構築・検証した上で、アトミックスワップを実行するのが安全な手順です:
# ステップ1: products_v2 へレコードを取り込む
# ステップ2: 稼働中の検索を中断することなく、エイリアスをアトミックに切り替える
curl -X POST 'http://localhost:7700/swap-indexes' \
-H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
-H 'Content-Type: application/json' \
--data-binary '[
{
"indexes": ["products", "products_v2"]
}
]'
切り替え処理は10ミリ秒未満で完了します。実行中だったリクエストは旧データで完了し、以降の新規リクエストはすべて即座に products_v2 を参照します。
3. LMDBのメモリマッピングを監視する
Meilisearchはインデックスの保存にLMDBを採用しており、独自のメモリ管理よりもLinuxの仮想メモリページキャッシュに大きく依存しています。Dockerで運用する場合、生データセットサイズの少なくとも2倍以上のメモリ制限を割り当ててください。私たちの環境では、3.2GBのデータベースファイルに対して mem_limit: 8g を設定しました。これより少ない場合、バックグラウンドでのインデックス更新と検索トラフィックの急増が重なった際に、OOM Killerによるプロセスキルが発生するリスクが高まります。

