Laravelで本番運用するときにSQLiteってどうなの?

投稿日 最終更新日

2025年12月13日

この記事はQiita「Laravel Advent Calendar 2025」参加記事です。

「SQLiteでも本番運用に耐えられるのでは」的な話を聞いたり聞かなかったりするので、パフォーマンスを自分なりに検証してみる。

もし、結構いけそうなら個人開発レベルの本番運用に採用したい。

検証方法

なんちゃって検証なので、ローカルで検証する。

動作環境

  • Kubuntu OS
  • Laravel12
  • PHP: 8.4
  • SQLite: 3.46.1
  • MySQL: 8.4.7
  • Laravel Octane(FrankenPHP)
    • 理由: PHP-FPMだと同時書き込み量的にPHP-FPMが自体がボトルネックになりうるので
      上記の環境をDockerで構築して、MySQLの場合とSQLiteの場合で比較する。

また、他情報として

  • 負荷テストにはKubuntuからk6を使う
  • ストレージにはwrite/read共に1500Mib以上のSSDを利用している

SQLiteの設定(PRAGMA)

SQLiteには色々設定(PRAGMA)があるっぽい。

以下の2つだけ明示的に設定を指定し、あとはLaravelにお任せしている。

  • journal_mode: 以下2通りそれぞれ検証
    1. DELETE
    2. WAL
  • synchronous: NORMAL

MySQLの設定

こちらは速度に関係ありそうな設定はなにも弄らずMySQLイメージそのままの初期設定にしている。

DBの状態

事前にシーダーで10GBくらいデータを入れておく。
毎回初期化するのは面倒なので、データは1回いれたらそのまま。
検証順で若干のデータ量の揺れはあるけど、微差なはずなのでそこは妥協。

$ ls database.sqlite -l  
-rw-rw-r-- 1 tamakoma tamakoma 10694750208 12月 13 20:07 database.sqlite

MySQLの方、ちょっと入れすぎた

mysql> SELECT 
    ->     table_schema AS 'Database',
    ->     ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 2) AS 'Size (GB)'
    -> FROM information_schema.tables
    -> WHERE table_schema = 'laravel'
    -> GROUP BY table_schema;
+----------+-----------+
| Database | Size (GB) |
+----------+-----------+
| laravel  |     12.23 |
+----------+-----------+

articlesのレコード数は大体300万件くらいだった。

マイグレーションファイル

以下のマイグレーションファイルでusersとarticlesテーブルを作り利用する。

    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
	}
    public function up(): void
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

DBの操作コード

以下のルートへアクセスすることで、適当なデータを作成 or 読み込みするようになっている。
記事のtitleやbodyは'body' => Str::repeat("あ", 1000),で固定値を生成。

Route::post('articles', function () {
    $article = Article::factory()->create();
    return response()->json($article->load('user'), 201);
});

Route::get('articles', function () {
    $articles = Article::orderBy('id', 'desc')
        ->limit(20)
        ->get();

    return response()->json($articles);
});

検証する

読み取りのテスト

以下のk6コードでテストする。

import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  vus: 50,
  duration: '30s',
};

export default function () {
  const res = http.get('http://localhost/api/articles');
  check(res, { "status is 200": (res) => res.status === 200 });
  sleep(0.1);
}

やってることは

  • 30秒間
  • 50人同時に
  • 0.1秒ごとに読み取りリクエストを送る

という感じ。

結果は以下

項目SQLite(delete)SQLite(wal)MySQL
平均レスポンス4.5ms4.42ms3.49ms
中央値レスポンス4.16ms4.05ms3.19ms
最大値レスポンス45.7475.3333.58ms
レスポンス成功率100.00%100.00%100.00%
総リクエスト142991431214450

書き込みのテスト

以下のk6コードでテストする。

import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  const res = http.post('http://localhost/api/articles');
  check(res, { "status is 201": (res) => res.status === 201 });
  sleep(0.1);
}

やってることは

  • 30秒間
  • 10人同時に
  • 0.1秒ごとに書き込みリクエストが来る

という感じ。

結果は以下

項目SQLite(delete)SQLite(wal)MySQL
平均レスポンス11.62ms5.27ms14.82ms
中央値レスポンス8.52ms2.81ms11.98ms
最大値レスポンス840.39ms440.04ms322.94ms
レスポンス成功率100.00%100.00%100.00%
総リクエスト267728632603

MySQLよりSQLiteの方がこの値を見る分にはパフォーマンスが良いのにビビっている。

30同時接続の書き込みのテスト

思ったよりSQLiteがいけるので、もう少し負荷を上げる。

Articleの作成、編集、削除、トランザクションを詰め込んだコードを以下のように書き

Route::post('articles2', function () {
    DB::transaction(function () {
        $article = Article::factory()->create();
        $article->title = "いいいいいいい";
        $article->save();
        $article->delete();
    });

    return response()->json('ok');
});

以下のように同時接続数を3倍の30にしたものでやってみる。

import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  vus: 30,
  duration: '30s',
};

export default function () {
  const res = http.post('http://localhost/api/articles2');
  check(res, { "status is 200": (res) => res.status === 200 });
  sleep(0.1);
}

結果は以下

項目SQLite(delete)SQLite(wal)MySQL
平均レスポンス36.52ms4.5ms12ms
中央値レスポンス6.63ms2.31ms9.73ms
最大値レスポンス4.44s480.4ms506.89ms
レスポンス成功率100.00%100.00%100.00%
総リクエスト657985837989

SQLite(delete)は明らかにここらへんで処理能力が追いつけなくなっている。

50同時接続の書き込みテスト

30でもまだ行けそうなのでさっきのテストコードの同時書き込みを50にしてみる。

結果は以下

項目SQLite(delete)SQLite(wal)MySQL
平均レスポンス166.29ms4.72ms17.12ms
中央値レスポンス52.66ms2.35ms14.16ms
最大値レスポンス4.37s597ms600.29ms
レスポンス成功率100.00%100.00%100.00%
総リクエスト56441427812762

SQLite(delete)はもう無理だけど、SQLite(wal)とMySQLはまだ戦えそう。

100同時接続の書き込みテスト

50でもまだいけそうなので、同時接続を100にしてみる。

結果は以下

項目SQLite(delete)SQLite(wal)MySQL
平均レスポンス343.3ms16.76ms19.36ms
中央値レスポンス249.58ms4.61ms14.14ms
最大値レスポンス3.85s1.73s553.03ms
レスポンス成功率100.00%100.00%100.00%
総リクエスト68092563625051

私の環境だとだいたいここらへんが限界そう。

まとめ

私の環境では
SQLite(wal) >= MySQL > SQLite(delete)
という順でパフォーマンスが良かった。

なぜSQLiteの方がパフォーマンスが出たかなんだけど、MySQLの方は別コンテナで動かしているのでその分コンテナ間の通信に時間がかかっちゃってたんだと思う。ただ、そのコンテナ間通信が要らないというのもSQLiteの強みなので、しっかりその強みが見れたのかなと。

30秒間で25636リクエスト = 1秒間に850リクエストくらいあの書き込みを処理できるってことなので、思ったよりSQLiteすごい。

SQLiteとサーバー型のDBの使い分け

じゃあ全部SQLiteで良いのかっていうとそういうわけでもない。
実はSQLiteの公式サイトに使い分けに関しては載ってて、以下の場合はSQLiteよりMySQLとかの方が良い

  1. SQLiteのファイルとアプリケーションサーバーが同じ物理デバイス上にない
  2. 同時書き込みが多い
  3. 281テラバイト以上のデータを扱いたい

今回2番の同時書き込みに関してはかなり余裕があることがわかったので、サーバーが1台で足りる状態なら私は今後SQLiteを使うかなと思う。

ただ、やっぱりサーバーをスケーリングしてSQLiteをネットワークファイルシステムに置いて〜みたいになってくるとSQLiteは仕組み上不整合が起きるみたいなので、その場合はMySQLに移ったりする必要があるのかなと。

以上です。