Tursoを活用したSQLiteベースの読み書き分離アーキテクチャ構築

高負荷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%

タグ: Turso SQLite read-write-splitting LibSQL database-replication

6月23日 17:01 投稿