高負荷Webアプリケーションにおけるデータベースパフォーマンス最適化において、読み書き分離(Read-Write Splitting)は実践的かつ効果的な戦略です。本稿では、Turso上で動作するSQLiteベースの予測投票システムを対象に、主サーバー(マスター)と複数のレプリカサーバー(スレーブ)を組み合わせた分散クエリルーティング機構の設計・実装を解説します。
課題の背景:単一インスタンスの限界
リアルタイム投票集計機能を備えるアプリケーションでは、以下のような負荷特性が顕著になります:
- 読込み操作(例:投票結果表示)が全体トラフィックの80%以上を占める
- 書込み操作(例:ユーザーによる投票登録)はトランザクション整合性が厳密に求められる
- 単一Tursoデータベースへの集中アクセスにより、クエリ応答時間が非線形に増加
特に、predictionsテーブル(試合ごとの得票数)とuser_votesテーブル(ユーザーごとの1回限りの投票記録)の両方を同一インスタンスで処理すると、ロック競合やI/Oボトルネックが発生しやすくなります。
アーキテクチャ概要
本構成では、以下の3層で構成される分散データアクセス層を導入します:
| コンポーネント | 役割 | 適用例 |
|---|---|---|
| マスターデータベース | すべての変更系操作(INSERT/UPDATE/DELETE)を処理 | 投票登録、得票数更新、ユーザー履歴修正 |
| レプリカクラスタ | 読み取り専用クエリを負荷分散して実行 | 試合別集計表示、ユーザー過去投票一覧取得 |
| クエリディスパッチャー | HTTPメソッドおよびSQL文タイプに基づきルーティング判断 | GETリクエスト→レプリカ、POST/PUT→マスター |
実装手順
1. Turso環境の準備
Tursoダッシュボードより、以下のインスタンスを事前に作成してください:
- マスターデータベース:
velada-core-primary - レプリカ1:
velada-read-replica-01 - レプリカ2:
velada-read-replica-02
各インスタンスの接続情報を環境変数として設定します:
# .env
TURSO_PRIMARY_URL=libsql://velada-core-primary.turso.io
TURSO_PRIMARY_TOKEN=xxx_primary_token_xxx
TURSO_REPLICA_URLS='["libsql://velada-read-replica-01.turso.io","libsql://velada-read-replica-02.turso.io"]'
TURSO_REPLICA_TOKENS='["xxx_replica01_token_xxx","xxx_replica02_token_xxx"]'
2. データベースクライアントの抽象化
src/utils/db/client.tsに、動的レプリカ選択ロジックを実装します:
import { createClient } from '@libsql/client/web';
import { parse } from 'json5';
// 環境変数からレプリカリストを安全にパース
const replicaUrls = parse(import.meta.env.TURSO_REPLICA_URLS) as string[];
const replicaTokens = parse(import.meta.env.TURSO_REPLICA_TOKENS) as string[];
// ラウンドロビン方式によるレプリカ選択(簡易版)
let replicaIndex = 0;
export function selectReplicaClient() {
const idx = replicaIndex % replicaUrls.length;
replicaIndex++;
return createClient({
url: replicaUrls[idx],
authToken: replicaTokens[idx],
});
}
// マスタークライアントは固定
export const primaryClient = createClient({
url: import.meta.env.TURSO_PRIMARY_URL,
authToken: import.meta.env.TURSO_PRIMARY_TOKEN,
});
3. クエリ実行層の分離
src/features/voting/service.tsにて、用途に応じたクライアント選択を行います:
// 読み取り専用関数:レプリカを使用
export async function fetchCombatSummary(combatId: string): Promise<CombatStats> {
const replica = selectReplicaClient();
const result = await replica.execute({
sql: `SELECT
SUM(CASE WHEN fighter_id = ? THEN 1 ELSE 0 END) AS votes_for_a,
SUM(CASE WHEN fighter_id = ? THEN 1 ELSE 0 END) AS votes_for_b
FROM user_votes
WHERE combat_id = ?`,
args: [fighterAId, fighterBId, combatId],
});
return {
combatId,
votesForA: Number(result.rows[0].votes_for_a || 0),
votesForB: Number(result.rows[0].votes_for_b || 0),
};
}
// 書き込み専用関数:マスターを使用
export async function submitVote(
userId: string,
combatId: string,
selectedFighter: string
): Promise<void> {
// 前提チェック(重複投票防止など)...
await primaryClient.execute({
sql: `INSERT INTO user_votes (user_id, combat_id, fighter_id, created_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
args: [userId, combatId, selectedFighter],
});
// 集計テーブルの原子的更新
await primaryClient.batch([
{
sql: `UPDATE predictions
SET votes = votes + 1, updated_at = CURRENT_TIMESTAMP
WHERE combat_id = ? AND fighter_id = ?`,
args: [combatId, selectedFighter],
}
]);
}
一貫性保証のための補完戦略
直後読み取り(Read-After-Write)対応
ユーザーが投票後に即座に最新結果を確認できるよう、マスターからの即時再取得をサポートします:
export async function voteAndRefresh(combatId: string, fighterId: string, userId: string) {
await submitVote(userId, combatId, fighterId);
// マスターから最新状態をフェッチ(遅延回避)
const fresh = await primaryClient.execute({
sql: `SELECT votes FROM predictions
WHERE combat_id = ? AND fighter_id = ?`,
args: [combatId, fighterId],
});
return { success: true, currentVotes: Number(fresh.rows[0].votes) };
}
レプリケーション遅延監視
scripts/replica-lag-check.tsで、マスターと任意のレプリカの最終更新時刻差を定期検知します:
import { createClient } from '@libsql/client';
async function measureLag(): Promise<number> {
const master = createClient({
url: process.env.TURSO_PRIMARY_URL!,
authToken: process.env.TURSO_PRIMARY_TOKEN!,
});
const replica = createClient({
url: JSON.parse(process.env.TURSO_REPLICA_URLS!)[0],
authToken: JSON.parse(process.env.TURSO_REPLICA_TOKENS!)[0],
});
const [masterTime, replicaTime] = await Promise.all([
master.execute('SELECT MAX(updated_at) FROM predictions'),
replica.execute('SELECT MAX(updated_at) FROM predictions'),
]);
const m = new Date(masterTime.rows[0]['MAX(updated_at)']).getTime();
const r = new Date(replicaTime.rows[0]['MAX(updated_at)']).getTime();
return Math.max(0, m - r);
}
// 実行例:node scripts/replica-lag-check.ts
measureLag().then(ms => console.log(`Lag: ${ms}ms`));
運用検証ポイント
- マスターへの書込み後、レプリカへの反映までにかかる平均遅延(通常は100–500ms)
- レプリカノード追加時の自動負荷分散効果(クライアント側コード変更不要)
- マスターダウン時のフェイルオーバー対応(本構成では手動切り替えを前提)
パフォーマンス改善効果(ベンチマーク例)
| 指標 | 単一インスタンス | 読み書き分離構成 | 向上率 |
|---|---|---|---|
| トップページ読み込み時間(P95) | 760ms | 290ms | 62% |
| 同時投票処理能力(TPS) | 42 | 185 | 338% |
| 読み取りクエリ平均応答時間 | 165ms | 41ms | 75% |