SQLite 足矣
SQLite Is All You Need

原始链接: https://www.dbpro.app/blog/sqlite-is-all-you-need

本文指出,开发者往往会过度设计数据库基础设施,在 SQLite 完全够用的情况下,却默认选择复杂的 Postgres 配置。 为了验证这一点,作者使用 `STRICT` 模式的 SQLite 构建了一个名为“Chirp”的社交网络。在笔记本电脑上运行单个 Node.js 进程的情况下,其最繁重的查询每天可处理 3.15 亿次请求。通过使用预写日志(WAL),他们消除了数据库锁,并在没有错误的情况下处理了数千次并发读写。 **核心要点:** * **性能:** SQLite 作为本地文件使用时表现优异,消除了网络开销。一台标准、低成本的 VPS 即可轻松处理每天超过 1 亿次的请求。 * **简洁:** 使用 SQLite 无需连接池,无需管理端口,备份只需简单地复制文件 (`cp`)。 * **最佳实践:** 务必使用 `STRICT` 表以确保类型安全,在本地 NVMe 存储上运行(切勿使用网络挂载硬盘),并优先考虑单核 CPU 速度而非核心数量。 作者总结认为,除非你需要读写分离、海量写入并发或特殊扩展,否则所谓的“扩容需求”往往只是“对 docker-compose.yml 的焦虑”。从一个文件开始,专注于获取用户,只有在有真实、数据支持的理由时,再迁移到 Postgres。

这场 Hacker News 讨论探讨了使用 SQLite 与 PostgreSQL 等传统客户端-服务器数据库的可行性。 支持者认为,对于大多数中低流量的应用,SQLite 通常已经足够,并指出了其出色的性能、易用性以及“本地优先”的优势。他们以 Lobste.rs 等网站为例,证明了 SQLite 完全能够处理可观的真实流量。 相反,怀疑者认为 SQLite 常被滥用于需要高并发、复杂数据类型或高可用性的场景。批评者强调,虽然 SQLite 是一款出色的嵌入式引擎,但它缺乏成熟的复制机制、多进程处理能力以及经受过考验的故障转移功能,而这些正是 PostgreSQL 成为行业扩展标准的核心所在。 舆论共识认为,选择取决于具体情境。虽然许多开发者因过早选择复杂架构而存在“过度设计”的问题,但也有人提醒,如果应用预期会有增长或需要高冗余,直接选择 PostgreSQL 比起未来再进行艰难的迁移要务实得多。归根结底,这场争论的核心在于开发者是更看重 SQLite 的简洁性,还是传统关系型数据库(RDBMS)的可扩展性与丰富功能。
相关文章

原文

I read Prefer STRICT tables in SQLite by Evan Hahn last week, and it got me thinking. Here is someone treating SQLite as a database you put real, typed, production data into. Which raises the obvious question: how far does that actually go?

So we found out. We built a social network on SQLite, 50,000 users and a million posts in a single 343MB file, put it behind a Node API, made every table STRICT on Evan's advice, and hammered it until the numbers stopped being flattering. This post is what came back: the benchmarks, the config, the code, and the places it falls over.

The short version: one file, one process, and the heaviest page in the app served 315 million requests a day on a laptop. For almost anything you are building, SQLite in WAL mode is enough, and the Postgres container you spun up out of habit was never needed.

If you want the evidence rather than the argument, skip straight to the benchmarks. The limits are here too, and they are real.

What we built

A social app is a good stress test because the hard query is unavoidable. A home timeline has to join what you posted against who I follow, sort by time, and count likes. You cannot cache your way out of it on the first request, and it gets slower as the graph grows.

So: Chirp, a social network that lives in one file.

TableRows
users50,000
posts1,000,000
follows2,498,799
likes4,999,764
Total343 MB in one file

Every table is STRICT, which we will come back to. The whole backend is one Node process talking to chirp.db sitting next to it. No database server, no connection string, no port 5432, no container.

The entire production configuration is five pragmas:

That is the whole setup. There is no step where you provision anything.

The numbers

Everything below ran on an Apple M1 laptop, 8 cores, 16GB of RAM, on Node 22 with better-sqlite3 (SQLite 3.49.2). Not a server. A laptop.

First, end to end over real HTTP: a real Node server, real sockets, real JSON serialization, 50 concurrent connections, autocannon on the other end. This is the whole stack, not the database in isolation. Every figure is the median of four runs, and run-to-run variance is around 10%.

Endpointreq/sp50p99Errors
GET /post/:id (point read)51,4270ms1ms0
GET /u/:handle (profile)47,7760ms2ms0
GET /timeline/:id (the heavy one)3,54313ms27ms0
Mixed: 95% timeline reads, 5% writes3,65413ms27ms0

Sit with the last row for a second. That is the worst query in the app, the one that joins two and a half million follows against a million posts and counts likes on every result, served alongside live writes, from a single Node process, on a laptop.

3,654 requests per second is 315 million requests a day. On the heaviest endpoint. The point read would do 4.4 billion.

If your product serves more than 315 million timeline loads a day, you are not in the 99.99%, and you already know who you are. Everyone else is arguing about connection poolers for a workload that fits in a file.

Underneath the HTTP layer, the raw query numbers look like this. Each benchmark runs in a fresh process against a fresh copy of the database, three times, and we take the median.

Queryops/sp50p99
Point read (post by id)232,0110.004ms0.007ms
Profile page (two aggregates)175,8500.005ms0.007ms
Home timeline (20 posts + like counts)4,2470.232ms0.337ms
Insert a post (one transaction each)23,4590.012ms0.089ms
Like a post (one transaction each)12,6180.016ms0.243ms
Insert posts (batched, 100 per transaction)32,217 rows/s

Every write there is a real, committed, durable transaction. Not a batch, not a buffer, not a queue. Twenty-three thousand committed transactions a second, on a laptop, with foreign keys on.

WAL is the part that matters

The old objection to SQLite is that it locks. One writer takes the database, everyone else waits. That objection is about the rollback journal, which has been the wrong default for most apps since 2010.

Write-Ahead Logging changes the shape of the problem. The writer appends to a log instead of mutating pages in place, so readers keep reading the last committed snapshot while a write is in flight. Readers do not block the writer. The writer does not block readers.

We tested it instead of asserting it. Seven reader threads run the timeline query. First alone, then next to a writer doing a realistic 1,000 writes per second, then next to a writer going flat out. We ran the identical test in WAL and in the old rollback journal mode so the difference is visible.

journal_mode = WAL

Scenarioreads/sp99 readworst readSQLITE_BUSY
Readers only17,5811.53ms7ms0
Readers + 1,000 writes/s2,7924.40ms17ms0
Readers + writer flat out (14,838 w/s)2,8545.16ms30ms0

journal_mode = DELETE (the rollback journal, the thing people remember)

Scenarioreads/sp99 readworst readSQLITE_BUSY
Readers only18,4391.23ms6ms0
Readers + 1,000 writes/s497133.85ms794ms0
Readers + writer flat out (2,806 w/s)227586.02ms1,762ms0

Same query, same data, same machine. One pragma.

With a writer running at a thousand writes a second, WAL serves 5.6x the read throughput and holds its 99th percentile at 4.40ms. The rollback journal collapses to 133ms at p99, with individual reads stalling for nearly eight hundred milliseconds. That is the SQLite people complain about, and it is a database from a decade ago.

Under WAL, across every scenario, zero SQLITE_BUSY errors. Not "few." Zero.

Where the numbers stop

If this post only had the good tables in it, you should not trust it. Here is what we found that does not flatter SQLite.

Reads stop scaling once anything writes. Seven reader threads with no writer do 17,581 reads/s. Add a writer doing only 1,000 writes/s and reads drop to 2,792. That is a 6.3x fall, and it does not get meaningfully worse if you go to 14,000 writes/s, which tells you it is not about write volume.

It is about cache invalidation. Those readers were getting their speed from a 256MB memory-mapped window and a warm page cache. Every commit invalidates mapped pages, so readers fall back to real I/O and re-validation. We confirmed it by sweeping the config: with mmap off, the read-only number drops from 17,069/s to 6,034/s and the penalty from a concurrent writer mostly disappears. The 17,581 figure is a read-only artifact. The honest mixed-workload number is around 2,800 reads/s of the heaviest query per machine, and that is the one we built the argument on.

One writer, globally. SQLite takes a single write lock for the whole database. Writes do not run in parallel, they queue. At 23,000 committed transactions a second that queue drains fast, but it is a queue, and no amount of hardware makes it two queues.

One machine. There is no failover. If the box dies, you are down until it comes back, and your recovery time is however long it takes to restore a file. For a lot of products that is a completely acceptable trade for the operational simplicity. For some it is not, and if you are in a regulated industry with an uptime SLA, you already know which one you are.

Reach for Postgres when you have many writers contending on the same rows, when you need read replicas or automatic failover, when you need a real analytics engine over hundreds of millions of rows, or when your team genuinely needs the extension ecosystem. Those are real reasons. "We might scale one day" is not one of them, and it is the reason most of these containers exist.

"But you tested on a laptop"

Fair. An M1 is not a fast machine by 2026 standards, but it is not a $6 VPS either, and the whole argument falls apart if these numbers only exist on Apple silicon.

We did not rent the boxes and re-run this, so what follows is an estimate, and it is labelled as one. But it is a more constrained estimate than it looks, because of something our own numbers already told us.

The Node server is single-threaded. That 3,543 req/s came from one core. And remember the concurrency result: seven reader threads next to a writer did 2,792 reads/s, which is less than one thread on its own managed. Once writes are in the mix, this workload plateaus at roughly one core's worth of throughput no matter how many cores you throw at it.

So the question "how fast is this on a cheap VPS" collapses into "how fast is one core on that VPS." That is a question you can answer from published single-core scores, within a sensible margin.

MachinevCPU / RAMRough $/moEst. timeline req/sEst. requests/day
Apple M1 (measured)8 / 16GBn/a3,543315M
Hetzner CAX21 (Ampere Arm)4 / 8GB~€7~1,600 to 1,900~140M to 165M
Hetzner CPX31 (AMD)4 / 8GB~€13~1,900 to 2,300~165M to 200M
Hetzner CCX13 (dedicated AMD)2 / 8GB~€13~2,000 to 2,400~175M to 205M
DigitalOcean Basic1 / 2GB~$12~1,300 to 1,600~110M to 140M
DigitalOcean Premium AMD2 / 4GB~$28~1,800 to 2,100~155M to 180M

Assume plus or minus 30% on every estimated row, and treat the prices as approximate list prices that will drift.

The method: an M1 performance core scores roughly 1.7x to 2.3x a typical cloud core on single-threaded work, with Ampere's Arm cores at the lower end and current AMD EPYC cores at the higher end. The 343MB database fits in page cache on every box in that table, so none of them are going to disk on reads. Writes will be somewhat worse than the ratio suggests, because fsync on cloud NVMe is slower than on a laptop SSD, and that lands in the write tail rather than the read path.

Even taking the worst row and the pessimistic end of its range: a $12 droplet serves something like 110 million timeline requests a day. The heaviest page in the app. On the cheapest box on the list. That is the entire argument, and it survives leaving Apple silicon behind.

What to actually buy

Since the workload is one core plus page cache, the shopping list is short and slightly counterintuitive.

Buy single-core speed, not core count. A 2 vCPU box with fast cores beats an 8 vCPU box with slow ones for this workload. This is the opposite of how people usually size a database server, and it is because you are not running one.

Buy enough RAM to hold the database. Your working set wants to live in page cache. A 343MB database barely registers, and any of these boxes will happily keep a several-gigabyte database resident. When your database no longer fits in RAM, that is a real signal, and it is the first one worth acting on.

Insist on local NVMe. Never put SQLite on network storage. This is the one that will actually hurt you. A DigitalOcean Volume, an EBS volume, an NFS mount: these are networks pretending to be disks, and SQLite's locking and fsync behaviour assumes a local filesystem. You will get latency you cannot explain and, on NFS, correctness problems you do not want. The local disk that comes with the droplet is the right disk. This is the sharpest edge in the whole approach, and it is easy to walk into by accident.

Pay for a dedicated core if you care about p99. The cheapest tiers put you on a physical core alongside other tenants, and their noise lands directly in your tail latency. Hetzner's CCX line and DigitalOcean's dedicated plans cost a few dollars more and remove the variable.

The punchline is that the entire server costs less per month than most teams' managed Postgres instance, and the database is a file on it.

What the 99.99% actually means

So the performance holds up, on a laptop and on a cheap server, and we have been clear about where it does not. Which leaves the part of the argument that is not about SQLite at all.

We keep saying 99.99%, and that sounds like marketing. Here is what we mean by it, because it is not a claim about the database. It is a claim about you.

Your new app does not have users. Not yet, and quite possibly not ever. That is not an insult, it is the base rate, and it applies to almost everything anyone ships. The hard part of building software was never the database.

We would know. We have been building DB Pro for over six months now, and by a distance the hardest thing we work on is not query parsing, or schema diffing, or shipping a native app on three platforms. It is user acquisition. Getting people to find the thing, try the thing, and come back to the thing a second time. Every engineering problem we have hit has been solvable with enough care. That one is not, and it is the one that decides whether any of the rest mattered.

So when you provision a Postgres instance for the app you vibecoded last weekend, look closely at what you are doing. You are engineering for the traffic you imagine, in a future where you already won, and paying for it in complexity now, in a present where you have not. You have added a moving part, a service that must be running, a connection string that must be right in three environments, and an ongoing bill, to a product that does not yet have a single user.

The honest version of scale looks like this. If you ever get enough traffic that SQLite cannot serve it, you will have revenue, a team, and an extremely clear signal about exactly what to fix. Moving a working product with real users onto Postgres is a good problem to have, and you will be far more qualified to solve it then than you are today. Pre-solving it now, for a repository nobody has cloned, is not engineering. It is anxiety with a docker-compose.yml.

Ship the file. Go and find the users. That is the hard part, and until you have done it, the database was never the thing standing in your way.

Use STRICT tables

So that is the case. The rest of this post is how to actually run it.

Start with the schema, and back to the post that started this. Evan's argument is the right companion to everything above: if you are going to run SQLite as your real database, run it in the mode that does not let you put text in an integer column.

By default, SQLite will take anything you give it:

Add one word to the end of the table definition and it stops:

That error text is real output, not paraphrased. Values that convert losslessly still work, so '123' goes in and comes back as the number 123, not the string.

The second thing it catches is the one that surprised us. Without STRICT, SQLite accepts column types that do not exist:

You get a table where every column has the wrong affinity and nothing tells you. With STRICT, all three are rejected outright:

Only INT, INTEGER, REAL, TEXT, BLOB, and ANY are allowed, and ANY is there when you actually want a key-value column. Store your JSON in a TEXT column and use the JSON functions on it, which is what SQLite was doing under the hood anyway.

Run it yourself: