SQLite on Rails: The how and why of optimal performance
fractaledmind.github.io
fractaledmind.github.io
Here's the intro paragraph: "Litestack is a Ruby gem that provides both Ruby and Ruby on Rails applications an all-in-one solution for web application data infrastructure. It exploits the power and embeddedness of SQLite to deliver a full-fledged SQL database, a fast cache , a robust job queue, a reliable message broker, a full text search engine and a metrics platform all in a single package."
I'm currently using it on a project and can't say enough good things about it!
This is useful to anyone trying to scale SQLite web applications, even beyond Rails.
So thank you!
I had to figure most of this stuff out on my own years ago. Thank you for writing this!
I’m making a FOSS analytics system, and ease-of-installation is important. I want to send event data to a separate SQLite database, to keep analytics data separate from the main app’s data.
I’m concerned about scaling, since even a modestly busy website could have 1000+ events per second.
My thought is to store events in memory on the server and then make one batched write every second.
Does this seem like a reasonable way to get around the SQLite limitation where it struggles with lots of DB writes? Any better ideas?
What you need to worry about is slightly higher complexity: (1) what happens when a single batched write doesn't complete within one second; (2) what is the size of queue you store events in memory and whether it is unbounded or not; (3) if it is unbounded are you confident that overloading the server won't cause it to be killed by OOM (queueing theory says when the arrival rate is too high the queue size becomes infinite so there must be another mechanism to push back), and if it is bounded are you comfortable with dropping entries; (4) if you do decide to drop entries from a bounded queue, which entries you drop; (5) for a bounded queue what its limit is. These are very necessary questions that arise in almost every system that needs queueing. Thinking about these questions not only help you in this instance, but also in many other future scenarios you may encounter.
I'd suggest taking a good look at it.
You can't beat SQLite for ease of use. I'd try it out and simulate some load to see if SQLite can keep up, if you keep your inserts simple I bet it can.
I’ve had good experience with ClickHouse, too, but it feels a bit more like Postgres rather than SQLite in terms of portability.
https://clickhouse.com/docs/en/chdb
https://clickhouse.com/docs/en/operations/utilities/clickhou...
I'm writing in PHP, so it looks like that's a nonstarter for now, but _very_ interested to see if a PHP extension pops up for this in the future.
Do you mean periodically flushing a queue of logs into a new parquet file (e.g. named with a timestamp)?
What most people do as mentioned is either batch writes, or simply send them over some kind of channel to a single thread that is the designated writer, some kind of MPSC queue, and that queue effectively acts as a serialization barrier.
Either can work depending on your latency/durability requirements.
You also absolutely need to use the WAL journaling mode which allows concurrent reads/writes at the same time, N readers but only 1 writer, and you probably want to take a hard look at disabling synchronous mode, which forces SQLite to fsync everywhere all the time. In practice this sounds bad but consider your example: if you make one batched write every second, then there is always a 1-second window where data can be lost anyway. There's always a "window" where uncommitted data can be lost, it's mostly a matter of how small that window is, and if internal consistency of the system is preserved in face of that failed write.
In your case the lack of synchronous mode wouldn't really be that bad because your typical "loss window" would be much greater than what it implies. At the same time, turning off synchronous mode can give you an order of magnitude performance increase. So it's very well worth thinking about.
TL;DR use a single thread to serialize writes (or batch writes), enable WAL mode, and think about synchronous mode. If you do these things you can hit extremely fast write speeds quite easily.
Another great pattern for batch writes is to use a system like Nagle's algorithm - push stuff into a queue, and insert from the queue in batches.
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
Not sure why people are still trying to shoe-horn it into a role that it's not meant to be in, and not even really supported to be.
1. Let the clients connect to Postgres directly.
- or -
2. Cut Postgres out of the picture and double down on the homegrown DMBS.
Some are going the #1 route, others #2. Where #2 is opted, SQLite is a convenient engine on which to build upon. It may not be perfect but it is what we have. Keep in mind that this realization on a grand scale (I'm sure some noticed many years ago) is fairly recent, so there is a lot of experimenting going on to figure out what works and what doesn't.It's the natural cycle of computing. What is old is new again.
(Replace Postgres with MySQL, MSSQL, Oracle, or other DBMS as you see fit.)
Second: the reasons are straightforward:
* For read-heavy access patterns, SQLite is crazy fast.
* It's fast enough that you can often simplify your database access code; for instance, N+1 queries are often just not a problem in practice.
* SQLite removes a whole tier from the N-tier architecture, which in turn removes a whole set of things that can go wrong (and if you've ever managed your own Postgres or MySQL: things do go wrong).
It's not a perfect fit for every application, or even the majority of applications, but the push you're seeing is a correction against the pretty clearly false idea that SQLite is well suited only for "tiny embedded client-side application databases".
If Hipp thought that SQLite was suitable for backend applications where the database is the authority then he would allow real types and the associated constraints. But he won't do that because it complicates the code and bloats the embedded object size.
SQLite is great for what it is. But it's not a real concurrent backend database. It's a client-side database. That's all the SQLite developers will ever allow it to be.
We can try to layer-on a bunch of stuff like Lite Stream or whatever, and sharding. But the fact is that the core database itself is not, and will never be, suitable for backend applications.
You can accidentally write a string to an int column. Will SQLite say no? No. SQLite doesn't care. It returns everything is A-OK!
You can query an ISO-8601 string column with date_trunc() and strftime() and it just returns NULL whether there was a value or not, or maybe just because it did't recognize the string in that column (LOL).
SQLite is fine. But it's not a real backend database. It's not a replacement for PG.
The correctness arguments apply just as much, if not more so, to MySQL and to document/schemaless databases. Lots of people don't like those databases, but nobody claims they're not "real backend databases".
You seem hung up on the idea that "backend" means "n-tier", with a segregated compute/storage tier for the database with networked connectivity to the app server. That architecture is something SQLite will never support, but that is not the only backend architecture.
(To a first approximation ~nobody is interested in SQLite because it lacks correctness or rigid typing features; what's interesting about SQLite is not what was interesting about schemaless databases, but rather the ability to ship backend apps without a separate database tier.)
Again: I think you need to snap out of the idea that n-tier architectures are axiomatically optimal for all backend applications. They often are! But not all the time.
If you write your application on a flimsy database then your application becomes equally flimsy. All of your business constraints become flimsy because your source-of-truth (the database) is flimsy.
SQLite is flimsy by design.
I suspect what you are really trying to say is that you trust Hipp more than you trust yourself to get the constraints right. Indeed, if you screw it up you're in for a world of hurt, so you are right to be cautious. But, if you have more trust in a random stranger who has no care for your data than you do yourself to implement it for you, perhaps you shouldn't be writing any code at all? Software development certainly isn't for everyone.
People are successfully using it server-side, in specific situations it appears to be a good fit.
> You can accidentally write a string to an int column
Yes, you need more validation logic client-side in exchange for the performance gain. It's a trade-off, not a black/white distinction. A strongly typed language can help here.
Totally baseless claim. Advances to the query optimizer complicate code and bloat the binary far more than adding DECIMAL, DATETIME or UUID as types would.
The reason types don't change is forward and backward compatibility, and the promise of supporting the current file format and APIs for interacting with it for at least another 25 years.
It conceptually simplifies things in so many ways that benefit the app developer, not just the sqlite devs and low-spec hardware. Simpler documentation, shorter learning curve, smaller surface area for bugs, smaller binary size, etc.
There's a trend to add bloat and complexity to everything in software these days, but I'm so glad that a few projects like SQLite are pushing against that.
ArchiveBox uses SQLite via django and I've run into exactly the issue the author describes in rails fairly often. It would be awesome to have a SQLite-layer solution that doesn't require serializing all my writes through some other channel in the app.
https://simonwillison.net/2022/Oct/23/datasette-gunicorn/#be...
I think @flexterra (aka gcollazo) should also add `"check_same_thread": False` to the recommended OPTIONS, right? Unless they left it out intentionally?
Following the issue comment link https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss..., it sounds like they they had a suspicion about a significant cost of reacquiring the lock but didn't validate it. Sounds iffy especially given all this workaround effort.
I feel in eg Python extensions culture this would have gotten designed the other way (maybe someone knows how it's done there?).
edit: also, there's this other comment in the linked issue:
> The extralite gem is an alternative SQLite client which releases the GVL during blocking, see note on concurrency here: https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... It is both significantly faster than this gem in general and doesn't have concurrency issues.
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = 1000000000;
PRAGMA foreign_keys = true;
PRAGMA temp_store = memory;
And use BEGIN IMMEDIATE transactions.2015
> It’d analyze the feed, see which jobs were remote, then normalize the data and then push it into a simple SQLite database (yes, I’m not using JSON text files as a database anymore, thank you :P).
https://levels.io/how-i-build-my-minimum-viable-products/
2014
> This includes all data used by the app. I actually shy away from using real database systems in the 12 startups. Instead I use JSON text files.
I was hoping to find data on Pieter's current projects using Sqlite and their loads. (For all I know, RemoteOK still does, but I can't find more recent posts about it).
Most applications don't have even hundreds of thousands of simultaneous users, so SQLite can be a great fit. Where SQLite also shines in that it has clients in just about every platform/language you're likely to want to use.
Archival/backup/portability are also very nice use cases for SQLite. I've worked on projects where there are specific, time-boxed data input and had actively pushed for using SQLite per box and still feel it would have been better. Vs having a very complex schema with export/archive functionality as custom code. My idea would have allowed to simply copy a file as archive/backup and schema changes over time would not necessarily need to be accounted for as deeply.
YMMV, but it's definitely a decent solution for many problems. Much in that using PostgreSQL or another RDBMS is often a better solution over using a more scalable no-sql option for most applications. There has been a tendency to over-engineer things, and we're approaching a level of compute/io that is less and less likely to justify those efforts.
I run a Rails app; I switched to Postgres years ago and never looked back. Postgres is awesome. Still, it's great to have alternatives available, and I use sqlite for other tasks, so I know it has good capabilities too.
That said, then you have operational overhead that defeats a part of the purpose with SQLite. In practice, you can get very far with a single machine and many CPUs (Postgres is ironically a good example of this). In eg Go you can easily parallelize most workloads. In rails, I don’t know if that’s possible. A quick search suggests there’s a GIL which can be limiting.
So, you aren’t limited to single machine, but you should stay single machine as long as possible and extract as much value from that operational simplicity before you trade that simplicity for some kind of horizontal scale
Yes, but there are a few options even then. First, you can of course tune http caching etc, find traditional bottlenecks.
Second, you can also break the business logic into a separate API endpoint that runs only SQLite + business logic + API responses. Then you can add more frontend nodes in case rendering and other things are most expensive.
The main downside is all logic practically has to be written in the same language as a monolith.
Feature request: a similar article for other DBs, starting with PostgreSQL.
Just my own $.02, you can definitely tweak an RDBMS, but it's definitely going to vary by use case and more work can definitely be needed (indexing in particular is a bit of a dark art).
I learned about BEGIN IMMEDIATE TRANSACTION.
And there's also busy_timeout.
The article also explains why/how/when things occur in detail which is valuable.
I suggest reading the manual on the section: "Sometimes Queries Return SQLITE_BUSY In WAL Mode"
https://sqlite.org/wal.html#sometimes_queries_return_sqlite_...
Reads returning busy is rare under WAL, but WAL mode does very little for writer-writer contention.
Should you do this for all apps? No. Do you have read heavy applications? Consider SQLite
And once you do understand docker-compose, it becomes second nature. I'd be willing to state that dealing with a merge conflict with source control is more difficult than docker-compose.
If so, I'd argue you still have N+1 problems, you just won't notice them until N gets a bit larger.
For others, the short-ish answer is that doing hundreds of SQL queries in response to a request (loading nested timeline elements in their case) in SQLite is fine because of the lack of networking/IPC overhead. The nature of N+1 queries is unchanged.
Most applications won't come close to encountering the N+1 problem on Sqlite, whereas it comes early on in server-based databases.
Even for running tens of thousands of integration tests in a few seconds, pg is fine.
I'm sure it makes things easier for some use cases but it's not a given.