SQLパフォーマンスを劇的に向上:Drizzle ORMクエリのためのUpstash Redisキャッシュ活用法
先日、私たちはDrizzle ORMとのコラボレーションを実現する機会に恵まれました。
TypeScript ORMとしてコミュニティから絶大な支持を集めるDrizzle ORM。そのため、「はい 😳」と答えるのは簡単な決断でした:

本記事では、Upstash Redis × Drizzleのキャッシング統合がどのようにSQLパフォーマンスを向上させるのか、そしてLuaスクリプトとハッシュデータ構造を活用してこの統合を最適化した手法について詳しく解説します。
課題:モダンアプリケーションにおけるSQLパフォーマンス
従来のSQLデータベースは一貫性や複雑なリレーションのモデル化に優れていますが、次のような課題を抱えることがあります:
- 分散環境での高レイテンシ
- サーバーレス関数におけるコネクションプーリングの制限
- 頻繁にアクセスされるデータに対する反復的なクエリオーバーヘッド
- 高負荷な読み取り時のスケーリングボトルネック
では解決策は? それは、データのリレーションを理解し、キャッシュ無効化を自動的に管理してくれるキャッシュ層の導入です。
Upstash × Drizzleキャッシングの仕組み
読み取りパフォーマンスの改善:フォールバック付きキャッシュファースト
Drizzleのキャッシングを有効にしてクエリを実行すると、この統合はまずRedisにキャッシュされた結果が存在するかを確認します:
- キャッシュミス: 見つからない場合はデータベースから読み取りを行い、その結果は依存テーブルに関するメタデータとともにRedisへ保存されます
- キャッシュヒット: 以降の同一クエリは、リレーショナルデータベースにアクセスすることなくRedisから即座に返されます
// このクエリはまずRedisを確認し、必要な場合のみデータベースから読み取る
const users = await db.select().from(usersTable)
.where(eq(usersTable.status, 'active'))
.$withCache();
書き込み操作のためのスマートな無効化
真価を発揮するのは書き込み操作のときです。リレーショナルデータベースのデータを変更すると、この統合は自動的に以下の処理を行います:
- 依存関係の特定: 変更されたテーブルに依存するキャッシュ済みクエリを特定します
- バッチ無効化: 影響を受けるすべてのキャッシュエントリを一括削除します
// このinsertはusersTableに依存するすべてのキャッシュ済みクエリを自動的に無効化する
await db.insert(usersTable).values({
email: 'new@user.com',
status: 'active'
});
シンプルなキャッシュの構築:「素朴な」アプローチ
まず、キャッシング統合が解決する課題を理解するために、できるだけシンプルな実装から始めてみましょう。クエリ結果をキャッシュする際には、次の2点が必要になります:
- キャッシュ値を保存する
- 無効化のために、そのクエリがどのテーブルに依存しているかを追跡する
シンプルなキャッシュ保存
// キャッシュにアイテムを追加するとき
await redis.set(itemHash, cachedValue);
await Promise.all(
dependentTables.map((table) => redis.sadd(table, itemHash))
);
このアプローチでは、キャッシュ結果をキー・バリューのペアとして保存し、依存テーブルごとに名前が付けられたセット(set)へアイテムハッシュを追加することで依存関係を追跡します。
シンプルなキャッシュ無効化
// テーブルの変更に基づいて無効化するとき
const hashesToInvalidate = await redis.sunion(dependentTables);
await redis.del(...hashesToInvalidate);
この仕組みは、変更されたテーブルに依存するすべてのキャッシュアイテムを検索し、それらを削除することで機能します。
「素朴な」アプローチの問題点
技術的には動作しますが、この素朴な実装には2つのパフォーマンス上の問題があります。
問題1: 複数回のラウンドトリップ
無効化プロセスには2つの独立したRedis操作が必要です:
- まず、
SUNIONを呼び出して削除対象キーのリストを取得します - 次に、ステップ1の結果を使って
DELを呼び出します
これにより、2番目の操作が最初の操作の完了を待たなければならないラウンドトリップ依存が発生します。
問題2: 遅い大量削除
DELコマンドは、多数のキーを無効化する際にボトルネックになり得ます:
// 数千のキーを削除する可能性もある
await redis.del(...hashesToInvalidate);
usersのような多くのキャッシュ済みクエリから参照される人気テーブルの場合、わずか1回の更新でも何百もの個別Redisキーの削除がトリガーされる可能性があります。数百〜数千規模のキーになると、処理速度が深刻な問題になり得ます。
解決策1: Luaスクリプト
Upstash RedisはLuaスクリプトの評価を完全にサポートしています。
Luaスクリプトは、複数のRedisコマンドをサーバー側で実行することで、ラウンドトリップの問題を解決します:
-- SUNIONとDELを組み合わせた無効化スクリプト
local tables = KEYS -- テーブル名がキーとして渡される
local keysToDelete = {}
if #tables > 0 then
-- これらのテーブルに依存するすべてのハッシュを取得
local hashesToInvalidate = redis.call('SUNION', unpack(tables))
-- 削除準備
for _, hash in ipairs(hashesToInvalidate) do
keysToDelete[#keysToDelete + 1] = hash
end
-- テーブルセット自体も削除リストに追加
for _, table in ipairs(tables) do
keysToDelete[#keysToDelete + 1] = table
end
-- 単一のアトミックな削除
if #keysToDelete > 0 then
redis.call('DEL', unpack(keysToDelete))
end
end
Luaスクリプトのメリット:
- 単一ラウンドトリップ: すべての操作がサーバー側で完結します
- レイテンシの低減: 操作間のネットワークオーバーヘッドがありません
- 一貫性: ネットワーク障害による部分的な更新のリスクがありません
解決策2: 効率的な削除のためのハッシュベースストレージ
Luaスクリプトを使っても、何百もの個別キーの削除は思ったほど速くない場合があります。そこで、Redisハッシュがはるかに効率的なソリューションを提供します:
ハッシュベースのアプローチ
各キャッシュ済みクエリを個別のRedisキーとして保存する代わりに、同じテーブル群に依存するクエリをハッシュへグループ化します:
// 従来のアプローチ: 各クエリが独自のキーを持つ
await redis.set('query_hash_1', result1);
await redis.set('query_hash_2', result2);
await redis.set('query_hash_3', result3);
// 新しいアプローチ: テーブル依存関係ごとにクエリをグループ化
const compositeKey = 'users,posts'; // usersとpostsテーブル用のハッシュキー
await redis.hset(compositeKey, {
'query_hash_1': result1,
'query_hash_2': result2,
'query_hash_3': result3
});
ハッシュが圧倒的に速い理由
usersテーブルに依存するクエリを無効化する場合を考えてみましょう:
// 従来の方法: 多数の個別キーを削除(遅い)
await redis.del('query_hash_1', 'query_hash_2', /* ...何百個も... */);
// 新しい方法: ハッシュテーブル全体を削除(速い)
await redis.del('__CT__users,posts');
パフォーマンス上の利点:
- 単一の削除操作: 1つの
DELコマンドで何百ものキャッシュ済みクエリを削除できます - メモリ効率: Redisはハッシュテーブル全体を1回の操作で解放できます
- アトミックなクリーンアップ: 関連するすべてのクエリがまとめて無効化されます
最終的なLuaスクリプトがどのような形になるか気になる方は、Drizzleリポジトリ内の実装をご覧ください。
細やかな制御を可能にするキャッシュタグ
テーブルベースの無効化に加えて、Drizzleはきめ細かなキャッシュ制御のためのカスタムタグをサポートしています:
// カスタムタグを付けてキャッシュ
const premiumUsers = await db.select().from(usersTable)
.where(eq(usersTable.plan, 'premium'))
.$withCache({ tag: 'premium_users' });
// 後から、この特定のクエリだけを無効化
await db.$cache?.invalidate({ tags: 'premium_users' });
自動無効化 vs 手動無効化
自動無効化(デフォルト): 依存テーブルが変更されるとクエリが自動的に無効化されます。データの一貫性は保証されますが、キャッシュクリアはより積極的になります。
手動無効化: 結果整合性(eventual consistency)が許容できるシナリオでは、自動無効化をオフにし、キャッシュをクリアするタイミングを手動で制御できます:
// 自動無効化されない - 分析データなどに最適
const monthlyStats = await db.select()
.from(analyticsTable)
.$withCache({ autoInvalidate: false });
// 必要に応じて手動で無効化(例: 日次バッチジョブ)
await db.$cache?.invalidate({ tables: ['analyticsTable'] });
実際のユースケース
ここまで統合の技術的な側面を見てきましたので、次にこれらの概念が実務でどう活かされるのかを紹介します。
Eコマースの商品カタログ
// 自動無効化付きで商品リストをキャッシュ
const products = await db.select()
.from(productsTable)
.where(eq(productsTable.active, true))
.$withCache({ tag: 'active_products' });
// 在庫が変化すると、キャッシュは自動的に無効化される
await db.update(productsTable)
.set({ stock: newStock })
.where(eq(productsTable.id, productId));
コンテンツ管理
// 手動無効化で公開記事をキャッシュ
const articles = await db.select()
.from(articlesTable)
.where(eq(articlesTable.status, 'published'))
.$withCache({
autoInvalidate: false,
tag: 'published_articles'
});
// コンテンツ更新時に手動で無効化
await db.$cache?.invalidate({ tags: 'published_articles' });
まとめ
Upstash Redis & Drizzleのキャッシング統合は、(かなり)最小限のコード変更でSQLクエリのパフォーマンスを大幅に向上させ、データベースへの負荷を軽減できます。
キャッシュを有効にすれば、次のような効果が期待できます:
- 劇的に高速化されたキャッシュデータへのクエリ応答時間
- データベース負荷の軽減とスケーラビリティの向上
グローバル分散と従量課金制のサーバーレスファーストなアーキテクチャを備えたUpstash Redisは、モダンアプリケーションの強固な基盤となります。
Eコマースプラットフォーム、分析ダッシュボード、コンテンツ管理システムなどにぴったりです。
さらに学ぶために
より深く知りたい方のために、おすすめのリソースを紹介します:
- Upstash Redis & Drizzle統合ガイド
- Drizzleキャッシング公式ドキュメント
- Upstash Redisはじめの一歩
- Upstash Rate Limit SDK(TypeScript) - Luaスクリプトを活用して最適なパフォーマンスを実現するもう1つの強力なSDK
- Upstash Rate Limit SDK(Python) - Rate Limit SDKのPython実装
-
Redis MOVEコマンドの使い方 – キーを別のデータベースへ移動する方法
このチュートリアルでは、Redisデータストア上でキーをあるデータベースから別のデータベースへ移動する方法を解説します。キーの移動には、redis-cliで使用できるMOVEコマンドを利用します。 MOVEコマンドは、現在選択されているデータベース(ソース)から指定したキーを削除し、そのキーを移動先(デスティネーション)のデータベースに挿入するために使用されます。なお、以下の場合は操作が実行されず、戻り値として0が返されます。 指定したキーがソースデータベースに存在しない場合 同じ名前のキーがすでに移動先のデータベースに存在する場合 構文 Redis MOVEコマンドの基本構文は以下のとお
-
Redis APPENDコマンドの使い方 – 既存の文字列値に文字列を追加する方法
このチュートリアルでは、Redisデータストア内のキーに保存されている既存の文字列値に対して、別の文字列を追加(連結)する方法を解説します。この操作には、RedisのAPPENDコマンドを使用します。 APPENDコマンドとは APPENDコマンドは、指定した文字列を、キーに保存されている既存の文字列値の末尾に追加します。もしキーがRedisデータストアに存在しない場合は、追加操作を実行する前に、まず空の文字列を持つキーが自動的に新規作成されます。 一方、キーが存在していても、その値が文字列型ではない場合(リスト・セット・ハッシュなどの場合)は、エラーが返されます。また、このコマンドはO(1)