Selective async commits in PostgreSQL – balancing durability and performance
shayon.dev
shayon.dev
- commit logically committed (meaning it won't fail to complete for any reason other than power or hardware failure, and in particular no trigger or other such logic can keep it from completing)
- commit completed durably (i.e., all associated writes have reached stable, persistent storage, and future reads will see them even if there is a power failure in between)
A UI could indicate that a transaction is logically complete and have a second way to indicate that the transaction is durably committed.
The high throughput gains if async commit shine when the totality of the workload is async.
Sure. But this doesn't affect other code that is merely wait()ing for 'logically committed'. Not in the past, and usefully, not in the future either. The 'logical system' can be far ahead of the 'durable' system, it doesn't have to wait.
Where it would go wrong is if code A needs to wait around for code B to do a thing (such as provide a value), and B's code for some reason contains a wait() on durable. Possibly because B was written before the split in commit behaviour was around, and for backwards compatibility reasons, for them COMMIT; means: "wait for durable commit".
Given that `SET synchronous_commit = off;` works per transaction, I'm kinda inspired here. I can think of a few places where we commit for various logical reasons but I don't need the fsync guarantee.
Only the sync committer needs to wait for those. (Nothing changes.)
The async committers can go on their merry way.
UI wise it does not make sense to have this distinction, as the window to get durability is a small fraction of a second. But for concurrent modifications the reduction in lock duration can mean an order of magnitude throughput.
Go spin up vanilla Postgres on an i4i instance, or even your personal computer, assuming you have a fast NVMe drive. You'll see what fast is. Nothing comes remotely close, not even io2 Block Express. A modern enterprise NVMe drive can do over 1,000,000 IOPS.
def with_synchronous_commit_off(&block)
ActiveRecord::Base.connection.exec_query("SET synchronous_commit = off")
yield
ensure
ActiveRecord::Base.connection.exec_query("SET synchronous_commit = on")
end
I'm not a Rails expert: does this reliably give you the same connection from the pool? If not, you could be setting `synchronous_commit = off` on one "physical" connection, and clearing it on another. This is setting it at the Postgres session level, so it will persist for the lifetime of the connection. If it's not managed reliably, it could lead to some very confusing bugs (only triggered in a very small window when Postgres shuts down unexpectedly while the transaction is still unsynced to durable storage, making it extra fun to debug).In general, though, this is a very handy feature.
Then, I suspect you may probably rather want to use `SET LOCAL` rather than just `SET`, so it applies to a single transaction only. So maybe something more like
def asynchronous_transaction(&block)
ActiveRecord::Base.connection.transaction do
ActiveRecord::Base.connection.execute("SET LOCAL synchronous_commit = off")
yield
end
end
Please note that I don't really know Rails (in particular, I've no idea whenever `Base.connection` is guaranteed to be exactly the same throughout this function's lifecycle or if e.g. there's a connection pool underneath), so I could be introducing some subtle bugs here.If I use `SET synchronous_commit = off;` in my Rails code will Aurora also mimic this behaviour?
A read-write sync transaction that reads the result of a non-durable transaction cannot commit ahead of the async transaction. But there is no such guarantee for read-only transactions. So a transaction could read a non-durable state, decide that no further action is needed, and have that decision be invalidated by a crash/failover.
To make things even worse, similar things can happen with synchronously replicated transactions. If a committing transaction waiting on replication is cancelled it will become visible immediately. A reasonably written application would run a retry of the transaction, see that the result is there and conclude that no action is necessary, even though the data is not replicated and would be lost if a failover then happens.
An anecdote: just the other day I've had to implement batching instead of concurrent parallel processing because PostgreSQL really hated me having thousands of concurrent transactions on the same table.
My particular workflow was essentially this - I get a batch (full state dump) with some products, and I need to update my `products` table to keep track of them (soft-deleting what had disappeared, inserting new, updating existing):
BEGIN;
-- Quickly load the batch into a temporary table
CREATE TEMPORARY TABLE products_tmp (LIKE products INCLUDING ALL) ON COMMIT DROP;
COPY products_tmp FROM STDIN;
-- Soft-delete products missing from the current batch
UPDATE products SET is_active = FALSE WHERE is_active AND store_id = ANY($1) AND id NOT IN (SELECT id FROM products_tmp WHERE store_id = ANY($1));
-- Upsert products from the current batch (add new, update existing)
INSERT INTO products (...) SELECT ... FROM products_tmp ON CONFLICT (id) DO UPDATE SET ...;
COMMIT;
With just a few thousands of concurrent writers things started to look quite ugly, with constant serialization failures (I started at SERIALIZABLE, then downgraded to REPEATABLE READ, was reluctant to use READ COMMITTED) and deadlocks preventing me from performing some DDL (schema migrations) on the products table.So I've started to batch those batches elsewhere and dump them at periodic intervals - and things started to look a lot better. Maybe that was a naive/bruteish approach and I should've done some parameter tweaking and/or fancy table partitioning or something else (idk) for congestion control instead, but at least it worked.
It breaks the durabality for that transaction until a new transaction with synchronous commit 'on' completes or time passes for auto-fsync.
All transactions work the same. When/If the server fails, some transactions (that you allowed yourself) may be not durable (or not replicated). But all things are still transactional.