GoとRedisで超高速栄養検索APIを構築する
GoのパフォーマンスとRedisのキャッシュ機能を活かし、数百万件の食品データベースに対して10ミリ秒未満の検索結果を返すAPIを構築します。
このページの内容
ヘルス&ウェルネスアプリの的世界では、データが中心です。ユーザーは数百万の食品的营养情報への即時アクセスを期待しています。遅い不方便な検索は許容されません。APIのレスポンスに数秒かかる,就已经失去了勝機です。課題は明確です:巨大なデータセットにクエリをかけ、瞬時に結果を返すにはどのようにすればよいでしょうか?
今日は、この問題に真っ向から取り組みます。GoのパワフルさとRedisの驚くべきスピードを活用した、超高速な栄養検索APIを構築します。Redisを単純なキーバリューキャッシュとして使用するだけでありません。RediSearchモジュールを活用して、JSONドキュメント数百万件に対して10ミリ秒未満のレイテンシで結果を提供する強力なインデックス付き検索エンジンを作成します。
食品の全文検索を可能にするREST APIを構築します。データの構造化、効率的なインデックス作成、クリーンなGo APIでの配信方法を説明します。
前提条件:
- Go (v1.18+): Goの基礎的な理解が必要です。
- Docker: 必要なモジュール вместе с Redisを実行する最も簡単な方法。
- RESTクライアント: APIテスト用の
curl、Postman、Insomniaなどのツール。
これが開発者にとって重要な理由:これは単なる理論的な演習ではありません。構築するアーキテクチャは、大規模な構造化データセットでの高速検索を必要とするあらゆるアプリケーションするための強力なパターンです——商品カタログ、ユーザーディレクトリ、ドキュメントリポジトリなど。マイクロサービス最適化、高度なRedis使用法、高スループットバックエンドシステムの構築に関する実践的なスキルを習得できます。
問題点の理解
従来のアプローチでは、リレーショナルデータベース(PostgreSQLなど)とLIKEクエリを使用することが考えられます。数천件のレコードであればこれは機能します。数百万件では?それは slowdownして動かなくなります。PostgreSQLに全文検索機能を追加することは可能ですが、それにより複雑さが増します。
もう一つの一般的な解決策は、Elasticsearchのような専用の検索エンジンを使用することです。非常に強力ですが、管理するインフラストラクチャも複雑です。
私たちのアプローチはスイートスポットを見つけます。多くの開発者がキャッシュ目的で使用することを知っている、愛用しているRedisを使用しますが、その検索機能をアンロックします。RediSearchモジュールを使用することで、専用検索エンジンのパフォーマンスを、Redisのシンプルさと低オーバーヘッドで実現します。データはメモリに保存され、的高速ルックアップ用にインデックス化され、この種の read-heavy ワークロードに最適です。
高パフォーマンス検索アーキテクチャ
以下の図は、Redisを活用した検索APIのアーキテクチャを示しています:
前提条件とセットアップ
環境を準備しましょう。
DockerでRedis Stackを実行
Redis Stackには、必要なRediSearchモジュールが含まれています。開始する最も簡単な方法です。
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
このコマンドはRediSearchとともにRedisコンテナを起動し、Redisポート(6379)とRedisInsight GUIポート(8001)を公開します。ブラウザでlocalhost:8001に接続して、データの内容を視覚的に確認できます。
2. Goプロジェクトのセットアップ
プロジェクトディレクトリを作成し、Goモジュールを初期化します。
mkdir go-redis-search
cd go-redis-search
go mod init github.com/your-username/go-redis-search
3. Go依存関係のインストール
公式のgo-redisクライアント(Redisモジュールのサポートが優れています)と、軽量なHTTPルーターとしてGinを使用します。
go get github.com/redis/go-redis/v9
go get github.com/gin-gonic/gin
セットアップが完了しました!構築を始めましょう。 ✨
食品データのモデリングとRedisへの投入
まず、データが必要です。GoでFoodItem構造体を定義し、的巨大データセットを生成してRedisにロードするスクリプトを作成します。
何をしているか
食品データをJSONドキュメントとしてRedisに保存します。JSONは柔軟性があり、RediSearch的良好にサポートしています。各食品にはfood:1、food:2などの一意のキーを持たせます。
入力:Go構造体FoodItem(フィールド:Name、Brand、Calories、Protein、Fat、Carbs)
出力:Redisに保存された100万件のJSONドキュメント(キー:food:1〜food:1000000)
実装
main.goという名前のファイルを作成します:
// main.go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
var (
ctx = context.Background()
rdb *redis.Client
)
// FoodItemは栄養データの構造を表します
type FoodItem struct {
Name string `json:"name"`
Brand string `json:"brand"`
Calories float64 `json:"calories"`
Protein float64 `json:"protein"`
Fat float64 `json:"fat"`
Carbs float64 `json:"carbs"`
}
func main() {
// Redisに接続
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
_, err := rdb.Ping(ctx).Result()
if err != nil {
log.Fatalf("Redisに接続できませんでした: %v", err)
}
log.Println("Redisに接続しました!")
// データの投入と検索インデックスの作成
seedDataAndCreateIndex()
// Gin路由器とAPIエンドポイントの設定(後で追加)
// ...
}
func seedDataAndCreateIndex() {
// データが既に投入されているか確認
count, err := rdb.Exists(ctx, "food:1").Result()
if err != nil {
log.Fatalf("既存データの確認中にエラー: %v", err)
}
if count > 0 {
log.Println("データは既に投入済みです。投入をスキップします。")
return
}
log.Println("データを投入中...(少し時間がかかる場合があります)")
// 生成用のサンプルデータ
brands := []string{"HealthyCo", "NutriFoods", "FitBites", "Organics"}
names := []string{"Yogurt", "Chicken Breast", "Almonds", "Oats", "Apple"}
totalItems := 1_000_000 // 百万件作成します!
// パフォーマンス向上のため、一括挿入にパイプラインを使用
pipe := rdb.Pipeline()
for i := 1; i <= totalItems; i++ {
item := FoodItem{
Name: fmt.Sprintf("%s %s", brands[rand.Intn(len(brands))], names[rand.Intn(len(names))]),
Brand: brands[rand.Intn(len(brands))],
Calories: float64(rand.Intn(500) + 50),
Protein: float64(rand.Intn(50)),
Fat: float64(rand.Intn(30)),
Carbs: float64(rand.Intn(100)),
}
// 構造体をJSONにマーシャル
jsonBytes, _ := json.Marshal(item)
key := fmt.Sprintf("food:%d", i)
// JSON.SETコマンドをパイプラインに追加
pipe.JSONSet(ctx, key, "$", string(jsonBytes))
}
// パイプラインを実行
_, err = pipe.Exec(ctx)
if err != nil {
log.Fatalf("データの投入に失敗しました: %v", err)
}
log.Printf("%d件の投入に成功しました。\n", totalItems)
// RediSearchインデックスの作成(次のステップで詳しく説明)
}
動作の仕組み
- シリアライズ用のJSONタグを持つ
FoodItem構造体を定義します。 seedDataAndCreateIndexでは、アプリ起動時に再投入を避けるため、まずデータが存在するか確認します。pipelineを使用して、何千ものJSON.SETコマンドをサーバーへの単一のラウンドトリップにバッチ処理します。これは一度に1つのコマンドを送信するよりも劇的に高速です。
RediSearchモジュールでデータをインデックス化
JSONデータがRedisに入ったので、検索可能にする必要があります。RediSearchにどのフィールドに注意を払うかを指示する検索インデックスを作成します。
何をしているか
FT.CREATEコマンドを使用してインデックスのスキーマを定義します。nameとbrandフィールドを全文検索用のTEXTとして、可能ない範囲クエリのための数値フィールドをインデックス化します。
入力:キーfood:*に保存されたJSONドキュメント
出力:名前、ブランド、营养値上のインデックス付きフィールドを持つ検索インデックスidx:foods
実装
main.goのseedDataAndCreateIndex関数の末尾に以下のコードを追加します:
// main.go(seedDataAndCreateIndex関数内)
// ... データ投入後
log.Println("検索インデックスを作成中...")
// インデックススキーマ定義
schema := redis.NewSchema().
AddField(redis.NewTextFieldOptions("$.name", redis.TextFieldOptions{Weight: 5.0, As: "name"})).
AddField(redis.NewTextFieldOptions("$.brand", redis.TextFieldOptions{As: "brand"})).
AddField(redis.NewNumericFieldOptions("$.calories", redis.NumericFieldOptions{As: "calories"})).
AddField(redis.NewNumericFieldOptions("$.protein", redis.NumericFieldOptions{As: "protein"})).
AddField(redis.NewNumericFieldOptions("$.fat", redis.NumericFieldOptions{As: "fat"})).
AddField(redis.NewNumericFieldOptions("$.carbs", redis.NumericFieldOptions{As: "carbs"}))
// インデックスを作成
err = rdb.FTCreate(ctx, "idx:foods", &redis.FTCreateOptions{
Prefix: []string{"food:"},
Schema: schema,
}).Err()
// 「インデックスは既に存在します」エラーは無視
if err != nil && err.Error() != "Index already exists" {
log.Fatalf("インデックスの作成に失敗しました: %v", err)
}
if err == nil {
log.Println("検索インデックスが正常に作成されました。")
} else {
log.Println("検索インデックスは既に存在します。")
}
動作の仕組み
FTCreate:このコマンドはidx:foodsという名前の新しい検索インデックスを作成します。Prefix:インデックスにfood:で始まるキーのみを考慮するよう指示します。これにより食品データが分離されます。Schema:インデックス化するフィールドを定義します。$.nameと$.brandはJSONドキュメント内のフィールドを指すJSONPath式です。これらをTEXTとしてインデックス化します。検索結果での関連性を高めるため、nameフィールドにより高いWeightも付与します。- 数値フィールドもインデックス化し、「100カロリー未満の食品をすべて見つける」などのクエリを実行できるようにします。
アプリケーションを一度実行して、データを投入し、インデックスを作成します。
go run main.go
# 出力:
# Connected to Redis!
# Seeding data... (this might take a moment)
# Successfully seeded 1000000 items.
# Creating search index...
# Search index created successfully.
Ginフレームワークで検索APIエンドポイントを構築
データがインデックス化されたので、クエリを実行するAPIを構築できます。
何をしているか
Gin Webフレームワークを使用して、クエリパラメータqを受け取るシンプルな/searchエンドポイントを作成します。このエンドポイントはRediSearchを使用して一致する食品を検索します。
入力:クエリパラメータqを持つHTTP GETリクエスト(例:/search?q=yogurt)
出力:名前、ブランド、营养データを含む最大10件の匹配FoodItemオブジェクトのJSON配列
実装
main関数を更新し、新しいハンドラー関数を追加します。
// main.go
// ... (既存のコードをそのまま)
// 変更後のmain関数
func main() {
// Redisに接続
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
_, err := rdb.Ping(ctx).Result()
if err != nil {
log.Fatalf("Redisに接続できませんでした: %v", err)
}
log.Println("Redisに接続しました!")
// データの投入と検索インデックスの作成
seedDataAndCreateIndex()
// Gin路由のセットアップ
router := gin.Default()
router.GET("/search", searchHandler)
log.Println(":8080でサーバーを起動中")
router.Run(":8080")
}
func searchHandler(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(400, gin.H{"error": "クエリパラメータ 'q' は必須です"})
return
}
// RediSearch用のクエリをサニタイズしてフォーマット
// 例:名前とブランドでの簡単な部分一致検索
searchQuery := fmt.Sprintf("@name|brand:%s*", query)
// 検索を実行
docs, _, err := rdb.FTSearch(ctx, "idx:foods", searchQuery, &redis.FTSearchOptions{
Limit: &redis.Limit{
Num: 10, // 結果を10件に制限
},
}).Result()
if err != nil {
c.JSON(500, gin.H{"error": "検索の実行に失敗しました"})
return
}
// ドキュメントのリストが返ってきます。最初の要素は合計数、
// その後はキーと値のペアです。
var results []FoodItem
for i := 1; i < len(docs); i++ {
// 各ドキュメントは、フィールドが続くキーです
doc, ok := docs[i].(redis.Document)
if !ok {
continue
}
// ドキュメントのプロパティにはJSON文字列が含まれています
var item FoodItem
err := json.Unmarshal([]byte(doc.Properties["$"].(string)), &item)
if err == nil {
results = append(results, item)
}
}
c.JSON(200, results)
}
動作の仕組み
/searchにGETエンドポイントを持つGin路由を設定します。searchHandlerはqクエリパラメータを取得します。@name|brand:%s*:これはRediSearchクエリ構文です。「nameまたはbrandフィールドがクエリテキストを含むドキュメントを見つける」を意味します。*は前方一致検索を有効にし、「chick」で検索すると「Chicken」が見つかります。rdb.FTSearch:これが主要な関数呼び出しです。インデックス名とクエリを渡します。- パフォーマンスのため、結果を10件に制限します。
FTSearchの結果にはドキュメントキーと完全なJSONペイロード($プロパティの下)が含まれます。結果を繰り返し処理し、JSONをFoodItem構造体にアンmarshalして、レスポンスを構築します。
アプリを再度実行し(go run main.go)、テストしましょう!
# 別のターミナルで
curl "http://localhost:8080/search?q=healthy%20yogurt"
ほぼ瞬時に食品の匹配项のJSON配列が返ってきます!
パフォーマンスに関する考慮事項とキャッシュ
私たちのAPIは既に高速ですが、繰り返しクエリに対してはさらに高速にできます。これは古典的なキャッシュ・アサイドパターンが役立つ場所です。
ロジックはシンプルです:
- RediSearchにヒットする前に、シンプルなRedisキー(例:
cache:healthy yogurt)で結果を確認します。 - 存在する場合(キャッシュヒット)、キャッシュされたデータを即座に返します。
- 存在しない場合(キャッシュミス)、RediSearchにクエリを実行し、有効期限(TTL)とともに結果)をキャッシュキーに保存し、(TTL)、それを返します。
searchHandlerを変更してこれを実装する方法を示します:
// main.go(更新されたsearchHandler)
func searchHandler(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(400, gin.H{"error": "クエリパラメータ 'q' は必須です"})
return
}
cacheKey := "cache:" + query
// 1. まずキャッシュを確認
cachedResult, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
// キャッシュヒット!
var results []FoodItem
json.Unmarshal([]byte(cachedResult), &results)
c.JSON(200, results)
return
}
// 2. キャッシュミス。RediSearchにクエリ
searchQuery := fmt.Sprintf("@name|brand:%s*", query)
docs, _, err := rdb.FTSearch(ctx, "idx:foods", searchQuery, &redis.FTSearchOptions{
Limit: &redis.Limit{Num: 10},
}).Result()
if err != nil {
c.JSON(500, gin.H{"error": "検索の実行に失敗しました"})
return
}
var results []FoodItem
// ... (前述と同じパースロジック)
for i := 1; i < len(docs); i++ {
doc, ok := docs[i].(redis.Document)
if !ok { continue }
var item FoodItem
err := json.Unmarshal([]byte(doc.Properties["$"].(string)), &item)
if err == nil {
results = append(results, item)
}
}
// 3. TTL付きで結果をキャッシュに保存
jsonBytes, _ := json.Marshal(results)
err = rdb.Set(ctx, cacheKey, jsonBytes, 5*time.Minute).Err()
if err != nil {
// エラーをログに記録하지만、リクエストは失敗させない
log.Printf("結果のキャッシュに失敗しました: %v", err)
}
c.JSON(200, results)
}
ベンチマーキング
高速であることを証明しましょう。wrkなどのツールを使用します:
# -t = スレッド、-c = 接続数、-d = 期間
wrk -t8 -c100 -d30s "http://localhost:8080/search?q=yogurt"
初めて実行すると非常に低いレイテンシが見られます。しかし、2回目は、すべての結果がキャッシュされると、スループットはさらに高く、レイテンシはさらに低くなり、おそらく10ミリ秒未満になります。これはキャッシュ・アサイド戦略の力を示しています。
代替アプローチ
- Elasticsearch/OpenSearch: これらは非常に強力で、機能が豊富な検索エンジンです。複雑な検索ニーズ(集計、複雑なフィルタリング、関連性調整など)に適しています。ただし、より高い運用複雑さとリソース使用量が伴います。
- データベース全文検索(PostgreSQL、MySQL): 最新のデータベースのほとんどには組み込みのFTS機能があります。スタックをシンプルに保ちたい場合、これは良いオプションかもしれません。非常に大きなデータセットや高スループットのシナリオでは、Redisのようなメモリ内ソリューションと一致するパフォーマンスが得られない可能性があります。
結論
何百万ものレコードに対応できる信じられないほど低いレイテンシで高パフォーマンスな検索APIを構築しました。Goの効率性とRedisおよびRediSearchモジュールを組み合わせることで、強力で比較的 管理が簡単なソリューションを作成しました。
パフォーマンスへの影響:Redis RediSearchは数百万件のドキュメントに対する全文検索で10ミリ秒未満のクエリレイテンシを提供します。Goのgoroutineにより、最小限のリソースオーバーヘッドで10万件以上の同時リクエストを可能にします。メモリ内インデックスにより、従来のデータベースLIKEクエリと比較して10〜100倍高速なルックアップを提供します。キャッシュ・アサイドパターンと組み合わせると、このアーキテクチャはキャッシュされたクエリで99パーセンタイルレイテンシ5ミリ秒未満を達成します。
高速検索を必要とするアプリケーションのための堅牢なパターンを手にしました。より複雑なクエリ、ページネーション、数値フィールドでのフィルタリングを追加することで拡張できます。基盤はしっかりしています。
次のステップ
- 検索結果にページネーションを実装します。
calories、proteinなどでフィルタリングを追加します。- ファジー一致やジオフィルタリングなどのより高度なRediSearch機能を探索します。
バックエンド最適化の他のパターンについては、FastAPIとVaultでHIPAA準拠のデータパイプラインを構築する または Node.jsとRabbitMQでのイベント駆動型ワークアウト処理 を探索してください。マイクロサービスアーキテクチャのパターンについては、Kubernetesでフィットネスの世界を1kから100万ユーザーにスケールする を参照してください。
リソース
- Go-Redisクライアント: https://github.com/redis/go-redis
- RediSearchドキュメント: https://redis.io/docs/stack/search/
- Gin Webフレームワーク: https://github.com/gin-gonic/gin
クイックリファレンス
| 側面 | 主要情報 |
|---|---|
| トピック | 開発 |
| 関連性 | 患者教育と認識 |
| 行動 | 医療提供者にご相談ください |
| 頻度 | ケアチームのおすすめに応じて |
| エビデンス | 現在の臨床ガイドラインに基づく |
よくある質問
Q: これが自分に該当する方法を知るには? A: 個人的な病歴と家族歴について医療提供者と相談し、個人のリスク要因を評価し、適切な次のステップを決定してください。
Q: 医師に何を聞くべきですか? A: 良い質問例:「私の結果の意味は何ですか?」「私のオプションは何ですか?」「どのような生活習慣の改善が役に立ちますか?」「いつフォローアップする必要がありますか?」
Q: помочь助ける生活习惯の変更はありますか? A: ほとんどの健康状態は、基本的な対策から benefits:バランスの 取れた栄養、定期的な運動(週150分以上)、质の高い睡眠(7〜9時間)、ストレス管理、タバコの回避。
医療に関する免責事項
この記事は教育目的のみを目的としており、医学的アドバイスを提供ものではありません。健康、治療法、またはケアに関する決定する前に、常に資格のある医療提供者と相談してください。個人 の状況は異なり、一人に適切なものが別の人に適切であるとは限りません。
医学的緊急状況が発生した場合は、直ちに911(またはお住まいの地域の緊急番号)に 电话してください。