---
title: SQLite 并发写入实测：WAL、busy_timeout 与 1000 次请求零丢失
url: https://vabora.dev/posts/sqlite-wal-concurrency
author: Vabora
published: 2026-09-19 09:30:00
updated: 2026-09-26 19:25:00
category: 技术分享
tags: 架构设计, 数据库, SQLite
canonical: https://vabora.dev/posts/sqlite-wal-concurrency
license: 转载与引用请注明出处并保留原文链接
---

# SQLite 并发写入实测：WAL、busy_timeout 与 1000 次请求零丢失

> 把 SQLite 放上生产后，最常见的担心是「并发写入会不会丢数据」。这篇给出可复现的压测方法与本机实测数字：100 并发 1000 次请求，写入零丢失，P95 88ms。
## 背景

把 SQLite 用在正式站点上，被问得最多的一句是：「并发写会不会丢数据？」

这个担心不是空穴来风。SQLite 默认是 `journal_mode=delete`，写操作会锁整库；一旦有并发写入，后到的会立刻拿到 `SQLITE_BUSY`。如果你的代码用 `try/catch` 把异常吞掉（很多"访问统计"就是这种写法），表现就是**静默丢数据**——页面正常、日志干净、统计慢慢偏少，等到发现时已经无从对账。

与其争论"能不能扛"，不如测出来。

## 做法

### 1. 开 WAL，并把等待时间交给数据库

```php
// 连接建立后立刻执行（SQLite 专用）
PRAGMA journal_mode = WAL;        // 读写不再互斥：读不阻塞写，写不阻塞读
PRAGMA busy_timeout = 5000;       // 拿不到写锁时等 5 秒，而不是立刻抛 SQLITE_BUSY
PRAGMA synchronous = NORMAL;      // WAL 下的推荐值：崩溃安全性够用，写入快得多
PRAGMA foreign_keys = ON;
```

三个参数里，**`busy_timeout` 是决定"丢不丢数据"的那一个**。WAL 让读写不互斥，但写与写之间仍然串行；有了超时等待，突发并发只是"排队变慢"，而不是"直接失败"。

### 2. 压测要带真实写入

只压 GET 静态页没有意义——要压的是「一次请求写一次库」的路径（本站每次前台访问都会写一条访问记录）。压测脚本 `tools/bench.php` 用 `curl_multi` 实现，零依赖：

```bash
# 8 个 worker 的开发服务器（模拟 PHP-FPM 的多进程）
PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:8000 -t public tools/serve-router.php

php tools/bench.php http://127.0.0.1:8000/services 50 800
php tools/bench.php http://127.0.0.1:8000/posts/xxx 100 1000
```

### 3. 关键一步：核对写入条数

压测跑完，别只看延迟，**去数一下库里的行数对不对**：

```bash
sqlite3 storage/database/vabora.sqlite 'select count(*) from v_visits;'
```

## 结果

本机实测（PHP 8.5 + SQLite，8 worker，开启 gzip）：

| 场景 | 并发 | 请求 | 成功 | 吞吐 | P50 | P95 | P99 | 写入 |
|---|---|---|---|---|---|---|---|---|
| 首页 | 20 | 300 | 300 | 952/s | 18.3ms | 31.6ms | 39.3ms | 301/300 ✓ |
| 服务列表 | 50 | 800 | 800 | 1026/s | 44.6ms | 61.6ms | 82.3ms | 801/800 ✓ |
| 文章详情 | 100 | 1000 | 1000 | 1295/s | 71.4ms | 88.3ms | 108.8ms | 1001/1000 ✓ |

（+1 是预热请求。写入数**不少于**请求数即为零丢失。）

100 并发下 P95 仍在 100ms 以内，没有一条 `SQLITE_BUSY`，没有一条统计数据丢失。对一台单机博客/企业官网来说，这个余量足够了。

## 取舍

- **`synchronous=NORMAL` 而不是 `FULL`**：WAL 下 `NORMAL` 在断电时最多丢失最近若干个事务，但不会损坏数据库文件。官网的访问统计丢几条无所谓，换来的是写入吞吐明显提升；如果记的是订单，请改回 `FULL`。
- **`busy_timeout=5000` 而不是更大**：等 5 秒已经远超正常写入耗时（毫秒级）。真等到 5 秒，说明有长事务在卡锁，此时让请求失败并暴露问题，比无限等待更健康。
- **SQLite 的边界**：单机、单写者场景非常合适；一旦要跨机器横向扩展、或者有持续的高频写入（每秒几百次以上），就换成 MySQL/PostgreSQL——`php vabora database:switch` 可以把数据搬过去，但**架构该换的时候不要硬撑**。
