Transactions Are Not Locks (2022)
benburwell.com
benburwell.com
Just append an update to an immutable data store, and program the logic to unfold from the latest records.
See Diatomic: https://docs.datomic.com/pro/getting-started/brief-overview....
There’s a lot of machinery involved, and a subtle logic change or error could invalidate balances for your entire client base. The transactional approach, with event sourcing used as an audit utility used by humans to verify correctness, is often a better path.
Most of us are never going to work with a system with the scale and SLA to necessitate this approach, but it's an enjoyable interview nonetheless.
Standard practice for accountancy is to record each transaction twice: once for the debited account and again for the credited account. You even create accounts for expenses and income; which are explicitly allowed to go negative (otherwise you couldn't record income). This is known as double-entry accounting, and it's equivalent to what you deride as "event sourcing".
To put it back into the language of databases, double-entry accounting is the third normal form of accounting. You wouldn't store a "sum total of blog posts on a website" row in your database schema now would you? Why would you have a "sum total of transactions" or single entries for your transactions that only show that money came in but not where it went?
The exact pedantry that you are making is kind of poorly chosen, in double-entry bookkeeping you would say that you record the transaction proper either once or n + 1 times, depending on how pedantic you are being. (Double-entry bookkeeping discovered the feature that in filesystems is called “journaling,” you always write first to the transaction log, which is the “1” if you answer 1, then you update the n account ledger pages involved in the transaction with their individual deltas plus the transaction log ID.) A real bank also allows accounts to go negative all the time, which is another criticism.
The “event sourcing” is actually just the transaction log, which is not double entry, which is why I say that the pedantry is poorly chosen. The ledger, in combination with “closing the books for the year,” actually instantiates a generic construction for persisting data structures, see lectures at [1], where you take periodic snapshots of a value to limit your worst-case reconstruction time while storing a tractable buffer of deltas. The point of the ledger thus is to denormalize the data to make it efficient to answer the question, “how did this client's balance change over time?” without seeking over the whole transaction log.
(P.s. personally I don’t believe in event sourcing at all. This is a great idea that just doesn’t seem to work in practice).
It can be as simple as a Postgres append only table keyed by an (id, version) tuple with a JSON column for the change event. You don’t need to pull in the whole enterprise pattern with aggregates, projections, etc to get the essence of event sourcing.
Edit: Ah, looks like I've struck a nerve with the event sourcing Datomic fanboys. Enjoy using your golden hammer and turning every problem into a nail!
By the way, if you ever make my way into my organization and waste my time with this nonsense, I WILL fire you. Nobody should tolerate your desire to solve already-solved problems with poorer quality solutions you have selected for their novelty. At that point, I'll kindly ask you to please explain to me what a write ahead log is and what it does, because if you don't, I'll happily explain it for you.
select balance from accounts where name = 'A' for update;
> Now, other transactions won’t be able to read this row until our transaction is committed or rolled back (for update can only be used inside a transaction).This is true for SERIALIZABLE isolation. Depending on the configured isolation level (e.g. READ COMMITTED aka snapshot) other concurrent transactions can read (but not update) the locked row, which can lead to write skew.
---
The article is correct. That section contains this snippet:
lock table accounts;
The default lock mode for LOCK is ACCESS EXCLUSIVE, which will prevent concurrent reads. ACCESS EXCLUSIVE (AccessExclusiveLock)
Conflicts with locks of all modes [...]. This mode guarantees that the holder is the only transaction accessing the table in any way.
https://www.postgresql.org/docs/current/explicit-locking.htm...> Now, other transactions won’t be able to read this row until our transaction is committed or rolled back (for update can only be used inside a transaction).
Finally if transaction isolation is SERIALIZABLE, then a row lock can block normal SELECT reads. This is a consequence of SERIALIZABLE isolation preventing write skew. Transactions can be run in parallel, but there has to be some ordering of transactions that produces the same result as if each transaction was one run one at a time.
https://www.postgresql.org/docs/current/explicit-locking.htm...
So the article is correct that SELECT FOR UPDATE will ensure that another concurrent SELECT FOR UPDATE transaction never acquire the same row (it will block), though nothing prevents other non-blocking selects to query this row concurrently.
You can think of it as usual locks - only the threads that explicitly use the same lock have the mutual exclusion guarantees. If there’s another thread that does not acquire the lock and tries to access the critical section - it will be able to do so.
And remember, don't use transactions when you cannot compensate the failure. For example, if you were to transfer money to an external bank, you probably can't just "undo" the sending of money. Your bank is on the hook for that money, so if, for example, the account you withdrew money from would now be negative ... you probably want it to still be negative if a failure happens ... instead of staying zero after rolling back the transaction. Otherwise, that other bank is going to come asking for that money you said you sent them.
If the api call succeeds, but your transaction fails to write to the db and rollsback, then what?
You then have to rely on the Stripe webhooks to "sync" your local db state with what stripe has, oh but don't forget stripe webhooks don't have any guaranteed order in which they are delivered, so just ignore all of the event data, you have to fetch the current state of the stripe object your event is associated with and make sure the handling of these events are idempotent.
It's not wise to rely solely on Stripe being the source of truth, but there is no way to really implement 2 phase commit with external services like this. Maybe you fire off an event into a pubsub or queue to handle the actual writing to your db, but what if the webhook gets to your service first?
Shit is hard.
- active state transitions (webhooks)
- reconciliation (api)
You can do reconciliation without active state transitions, but it usually takes awhile (slow and scheduled). However, it’s a requirement with webhooks because you don’t know if they are in-order or delayed or just broken. Thus, you need to reconcile your state every so often.
Consider for example the following scenario; you are experiencing higher than normal load, leading you to send a lot of requests to this other system. Their scaling is flawed, and your traffic actually brings them down. This leads to timeouts and long transaction times. Now you are holding lots of locks for a very long time, and you database is under a heavy load - you're now in danger of overloading your database and going down.
I'd suggest commiting a record representing your intention to integrate with this other system, executing your API call, and then updating your state (assuming the call is successful). (For the example above, circuit breakers would also be an important mitigation.)
You can do this with deferred triggers, for example. Here's where a COMMIT trigger would be handy -- something that runs when the client's COMMIT begins executing (though other triggers could still run in the COMMIT trigger were to execute any DMLs).
txn1 = db.begin()
# implicit exclusive row lock established here on A row, exclusive rather than shared read because we say for update
currBalance1 = txn1.query('select balance from accounts where name = A for update’)
… <snipped some of the example code> …
txn1.execute('update accounts set balance = balance - $amount where name = A')
# Concurrently tx2 begins, tx1 has locked row A so far
txn2 = db.begin()
# we’re exclusively locking row B in txn2
currBalance2 = txn2.query('select balance from accounts where name = B for update’)
# This is the first point in transaction 1 where we take an exclusive lock on B, however we are deadlocked now
txn1.execute('update accounts set balance = balance + $amount where name = B')
The solution is what the parent suggested, take the exclusive locks on rows A&B at the same time because if transaction 2 is locking B&C you can’t afford to do 2 separate for update calls because b might get locked as you’ve just locked A and were about to lock B select balance from accounts where name = 'A' or name = ‘B’ for update select balance from accounts where name = 'A' for update
select balance from accounts where name = 'B' for update
where the two statements are run with smaller 'name' first, or select balance from accounts where name = 'A' or name = ‘B’ order by name for update
Of course, I'd use the primary key (likely an id rather than name).By locking the accounts in smallest->largest name (or id), you can afford to do 2 separate for update calls. Even when done in a single statement specify the canonical ordering of locks to avoid deadlocks. For example, 3 transactions locking (A, B), (B, C), (C, A):
tx1: lock A, lock B
tx2: lock B, lock C
tx3: lock A, lock C
This is the lock order regardless of which of A/B/C is being debited or credited. As a final detail, make sure the ORDER BY that you specify is on a unique index or else there could/will be range locks on the index and won't strictly be using row locks.What engineers would call a "lock", is referred to as a "latch" in DBMS internals terminology.
A "lock" then is a higher level abstraction that functions as a logical lock rather than a fine-grained physical one.
Locks are generally used to control access to database objects like rows, pages, tables, etc, to ensure data integrity and transactional consistency.
"Latches" are low-level synchronization primitives used to protect access to internal DB structures.
The balance would still be wrong for the whole range where balance > 0. Two (positive) deposits would not store the correct ending balance.
I just wish it were applicable to something else.