MySQL Under Load in a Sharia-Compliant Pipeline
By Mohamed Naser Technical Lead, Lynk
The first batch test we ever ran froze three of 31 orders for over twenty minutes; the ten-order retest still lost four. Eighteen months later the same platform ran 5,200 orders end to end at 99.94%. Ten case files on what MySQL did under load, and what it took to keep the pipeline compliant and moving.
- sharia compliant pipeline
- database bottlenecks
- mysql lock contention
- read committed isolation
- database deadlocks
- database performance tuning
- missing index
- scaling laravel
- performance engineering
- stress testing
- fintech scaling
LYNK Engineering · Murabaha Platform · Part 2 of 3
In January 2025 we ran ten automated Murabaha orders through the platform. Four of them got stuck. Eighteen months later the same pipeline ran 5,200 orders end to end at 99.94%, and 5,000-order runs became a daily routine. What follows is everything that happened in between.
Volume was never the hard part. A Murabaha order is not one transaction but a strict legal sequence — buy real commodity units, transfer ownership to the borrower, sell to the open market, complete — and none of it may be skipped, faked, or reordered. Each order fans out into a chain of queued jobs landing on the hottest tables in the system: market orders, inventories, and millions of individually coded units. Making one such pipeline correct is ordinary engineering. Making five thousand of them run at once, with every legal constraint intact, is where the last two years went.
Ten battles made the difference, told in the order we fought them — the ones fought inside the code and the database. The infrastructure battles that came after, once the code had stopped being the constraint, are Part 3. We have kept the scars visible: each case tells you what was actually going wrong, what we tried — including the ideas that did not survive contact with production — and what the numbers said when the dust settled.
The stack, named once. Laravel on PHP 8, MySQL 8 (InnoDB) as the system of record, Redis behind the queues and locks, deployed as Docker Compose services on a handful of Linux VMs. Where a case turns on a specific behaviour of one of those — InnoDB gap locks, Laravel's queue defaults, Compose's scaling flags — it is named and glossed at the point it matters.
Case 01 · Everything waits for everything
Aug 2024 – Jan 2025Impactceiling: tens → hundreds of orders
Our first real batch test, in December 2024, was humbling. We created 31 orders at once and watched three of them freeze for more than twenty minutes. When we retried with just ten orders, everything passed — and that, honestly, was the platform's ceiling at the time. A few weeks later a test run confirmed it the hard way: ten automated trade requests, four stuck in Purchasing Commodity.
Digging in, we found a platform designed for one order at a time, politely queuing the whole world behind it. Fifty simultaneous orders were processed strictly stage by stage — the first order could not buy its commodity until all forty-nine others had finished searching for theirs. Certificates rendered through a headless browser that fell over under load and took whole orders down with it. Stuck orders kept their commodity units reserved forever, so even cancelling them failed. And our very first "stuck orders" investigation ended somewhere nobody expected: the server disk was 98% full — 44 of 45 GB. Not a race condition, not a deadlock. A full disk.
We fixed the unglamorous things first: bigger disks. Then we gave the PDF service room to breathe — longer timeouts, more browser sessions, Chrome kept warm — which helped, though in hindsight we were treating a symptom we would only cure properly a year later (Case 04). The changes that mattered were structural. Work that ran synchronously inside requests moved into queued jobs. Jobs started carrying lightweight record IDs instead of whole serialized objects, because one stuck serialization had been pinning entire orders. And the single shared queue became three dedicated worker containers. Three. It felt like a fleet at the time.
Case 02 · The database fights back
Aug 2024 – Aug 2025ImpactP95 28s → 13s · slow tail 21.9% → 0
Then production started sending a new kind of message: Lock wait timeout exceeded.
Orders that should have flown were standing in line behind a single SELECT … FOR UPDATElocking reads ↓,
like shoppers queuing at the one open till. Underneath it sat something subtler: MySQL's default
isolation level — REPEATABLE READ, safe and strict — has InnoDB lock not just the rows a
transaction touches but the gaps between them, and when many transactions touch neighboring
inventory rows, those gap locks collide into deadlocks.gap locks ↓ We had the strange experience of adding capacity and watching throughput fall. More
concurrency meant more waiting.
It took three rounds to actually win. In the first, we treated each incident as it came: remove this
lock, log that deadlock (innodb_print_all_deadlocks on, so at least the next one would
leave a confession)deadlocks ↓. Every individual fix worked — and the class of problem kept coming back with a new
address.
In the second round, April 2025, we went after the class itself and switched the platform to READ COMMITTEDisolation ↓: dramatically less lock contention, in exchange for a strictness the Murabaha flow never actually relied on. Worth being precise about that trade, because in a financial system it sounds alarming. What REPEATABLE READ buys you is a stable snapshot across a long transaction — read a row twice, see the same value both times. No step in this pipeline does that. Each step reads the state it needs, acts, and commits inside a second, and the invariants that actually matter — a unit belongs to one order, a counter never goes negative — are enforced by explicit row locks and database constraints, not by the snapshot. We gave up a guarantee we were not using; the ones we were using are still there.anomalies ↓
The third round came during the summer stress tests, when the sell path deadlocked anyway — the global setting was not reaching every transaction, so the hottest services got it explicitly. And the last deadlocks were removed the cheapest way of all: not by locking more, but by making every code path write to tables in the same order, so transactions could no longer meet head-on.
public function handle(): void
{
DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
DB::beginTransaction();
try {
$this->inventoryService->assertOrderHasInventories($this->localMarketOrder);
// ... Case 03 · The purchase-path campaign
Jul – Sep 2025Impact1,000 orders, all within 14s
That summer we set ourselves a target — 500 orders without a single error — launched a 300-order run, and watched the first eleven orders die before doing anything at all. The culprit was a setting nobody had ever had a reason to think about: Laravel's default 60-second job timeout. No job was ever supposed to run for a minute. The timeout was not the disease; it was the thermometer — and it was telling us the purchase path simply did too much, one thing after another, for every single order.
We raised the timeout to 120 seconds the same day — not as a fix, but as anesthesia, so testing could continue while we operated. Then came the actual surgery, a deliberate summer campaign. Buying commodities, creating trader orders, and holding units each got their own queue instead of sharing one line. The search for eligible commodities was separated from order initiation, so five workers could do in parallel what one had been doing alone. Inventory scans switched to cursors. We even dropped foreign keysforeign keys ↓ from the busiest join table — heresy in most engineering conversations, but their integrity checks were pure cost on a table written millions of times a day, and every write to it already goes through a single service that enforces the same invariants. It is a real trade rather than a free one: the guarantee moved out of the engine and into code, where it stays honest by review and tests instead of by construction. We would not make the same trade on a table that a dozen code paths could write to. And the units table finally got its first serious indexes.
Case 04 · The certificate tax
Dec 2024 – Aug 2025Impact0 seconds per step
Every legal step of a Murabaha produces its evidence — a commodity certificate, an ownership certificate, a sell confirmation. For a long time nobody questioned where those documents were made: inside the step itself, by a headless Chrome browser that opened a full browser session for every single PDF and produced, at best, about 18 documents a minute. It was like closing the till to print each receipt on a printing press in the back room. Under load the press jammed, its queue backed up, and whole orders stood still — waiting not for money or commodities, but for paperwork.
First, we tuned the press. Longer timeouts, more concurrent sessions, Chrome kept warm and ready. It bought headroom for the next test — and changed nothing about the economics. Still one browser per document, still on the critical path.
Then we replaced the engine. A native PHP library went head-to-head against the browser and won convincingly: ~128 documents a minute against ~18 — seven times the throughput — with no external service, no per-document fees, documents staying on our own infrastructure, and proper Arabic right-to-left out of the box. Better in every way. And still seconds of rendering inside every step.
The winning move came from a different room. Sitting with the business and the Sharia requirements, we asked what the certificate actually had to be — and the answer was: it must exist and be obtainable, not be rendered before the step may complete. So the steps stopped generating PDFs entirely; a certificate renders the first time someone downloads it. And here is the quiet punchline: most certificates are never downloaded at all. Most of that work never needed speeding up. It needed to stop existing.
Case 05 · Which units can you even buy?
Dec 2024 – 2026Impactscan millions of rows → read a counter
Before a single unit can be purchased, the platform has to answer a question that sounds like a database query and is actually a small legal investigation: which of the millions of units is this particular customer allowed to buy, right now? A unit qualifies only if it is free, its supplier is active, and buying it would not violate the trade-rotation rules for that specific buyer — a unit you recently owned cannot rotate straight back to you.
In the early days we answered honestly and expensively: scan the units table, per order, with that customer's conditions. Millions of rows, thousands of concurrent orders, the same question over and over. For a long time, if you asked where the platform's time was going, a large part of the answer was: re-deriving an answer that had barely changed since the last time we derived it.
Generation one: compute the truth on demand. Correct, simple, and ruinously expensive — every order paid the full scan, and those scans were most of the read pressure behind the 94-million-read index in Case 09.
Generation two: the "settled" feature. When an order completes, mark its units as settled back to the market — in bulk, with a cheap flag — so eligibility queries can skip whole classes of units without inspecting them. The scans shrank. But the settlement chain groaned under its own weight (its jobs kept blowing past their time limits and eventually needed a full bulk rewrite), and the per-order scan was still there. Smaller, not gone.
Generation three: stop scanning altogether. If the question is always the same, keep the answer written down. A dedicated table now holds an eligible-quantity counter for every (customer, inventory) pair, and every event that changes the answer — a purchase, a settlement, a supplier switching on — updates the counter in that same moment, atomically. The schema itself refuses to let a counter go negative, and rebuild jobs can reconstruct any counter from the ground truth whenever we have a reason to doubt it.
public function up(): void
{
// Make eligible_quantity non-negative by using an UNSIGNED integer.
DB::statement('ALTER TABLE `local_market_eligible_quantities` '
.'MODIFY `eligible_quantity` INT UNSIGNED NOT NULL');
} Case 06 · The exact-change problem
Jan 2025 – 2026Impactgateway timeouts → milliseconds
Two constraints stack here, and it is the stacking that makes this hard. The first is exactness: a Murabaha for SAR 1,000,000 must be covered by whole units at fixed prices adding up to precisely that amount. One halala over or under is not a smaller trade; it is no trade at all. The second is eligibility. As Case 05 describes, a unit is not eligible just because it exists and is unsold — trade-rotation rules and previous ownership decide, per institution, whether this particular buyer may hold this particular unit. So the two questions the platform is really asking are: which units may this institution hold, and which subset of those adds up to the amount exactly.
That is why "the market has plenty of stock" is not an answer. An order can fail with millions of units sitting in the market, because most of them are ineligible for this buyer, and the eligible remainder happens to have no subset that lands on the amount. And the search space is not small: hundreds of eligible inventories, each with its own price and available count, combine into more candidate baskets than anything can enumerate. Mathematicians know this family as "exact change" problems, famous for one thing — naive solutions explode.
Failure is expensive at both ends. A search that finds nothing ends the order as "no eligible commodities available", and the customer's financing is cancelled — so an algorithm that gives up too early cancels trades the market could actually have covered. A search that refuses to give up is worse: on inputs of a few hundred inventories our first implementation searched until the gateway hung up on it, and a slow search does not stall one order, it stalls the purchase step of every order queued behind it.
The first one lived in the database. Ask for the eligible inventory that can absorb the most value — ordered by available quantity times unit price — take as many units as fit in what is left, exclude that inventory, ask again. Preferred commodity types first, then any type. It was simple, it was one query per pick, and while the market was small it worked. Its limit was structural: it could only ever walk one path forward. Having taken the biggest bucket first, it had no way to reconsider that choice, so it reported "no eligible commodities" on markets that genuinely did contain an exact answer — just not one reachable greedily. Every such report was a cancelled order.
The second one moved the whole search into memory. Load the eligible inventories once, then work on them without touching the database again. It still starts greedy — one linear pass, taking as many units of each inventory as still fit — because for the overwhelming majority of orders that single pass lands exactly on zero. What is new is what happens when it does not: it backtracks, putting the most-used inventory back, swapping in different ones, and repeating until the remainder closes or the options run out. Two things bound it. Before searching at all, a fast pre-check asks whether an answer can exist — if the most valuable 10,000 units on the table cannot reach the amount, the order fails immediately with a clear message instead of hunting for a solution that is not there. And the search itself is on a deliberate leash: at most 10,000 units per trade, at most three seconds of thinking. One order failing fast with a useful message beats five thousand orders waiting behind a search that refuses to give up.
Two details matter more than they look. It counts in whole halalas, never fractions — floating-point money that drifts by 0.01 is the difference between exact change and no change at all. And it writes down every gap it fails to close, so a failure arrives as a diagnosis rather than an error: the market lacks units at these prices. That list is market feedback; it tells a supplier what to list next.
For a year both algorithms ran side by side behind one interface so they could be judged on identical inputs. In 2026 the greedy one was deleted and the backtracking one became the only path.
What made that judgement possible was a harness. Neither algorithm could be trusted on intuition, and production is a terrible place to discover that a search has a blind spot — the evidence arrives as a cancelled customer order. So we built an internal tool that runs the algorithms against invented markets: thousands of synthetic price-and-quantity mixes crossed with thousands of financing amounts, the same inputs fed to both, recording for each run whether it found an exact cover, how long it took, and what remained uncovered. It turned "seems fine" into a table you could read. It is what caught the amount smaller than the cheapest unit on the market, the combinations only backtracking could reach, and the inputs where the search would have run past its time budget — each one fixed before an order ever met it.
private const DECIMAL_SCALE = 2;
public function __construct()
{
$this->maxUnitsPerTrader = config('trader.providers.lynk.max_units_per_trader', 10000);
$this->loanCoverageTimeout = config('trader.providers.lynk.loan_coverage_timeout', 3);
}
// ...
$elapsedTime = microtime(true) - $startTime;
if ($elapsedTime >= $this->loanCoverageTimeout) {
// ...
throw new Exception('Request timeout!', 408);
}
// ...
private function toMinorUnits(float $value): int
{
return (int) round($value * (10 ** self::DECIMAL_SCALE));
} Case 07 · The platform attacking itself
Oct 2025Impactdatabase load −85%
One October morning the monitoring dashboard reported 228,291 database queries in a few hours — on a system doing almost nothing. We went looking for the traffic and found ourselves. A scheduler ran every minute and, with no duplicate check, had dispatched the same background job 22,400 times. A single unindexed history lookup had run 114,000 times.n+1 ↓ The first alarm had been, embarrassingly, the monitoring bill: millions of recorded events per day, all of them faithfully documenting our own waste.
That lookup existed because of how an order remembers itself. Every step an order takes is appended to its status history as a new row, and nothing is ever overwritten — in a regulated pipeline the trail is the point. But almost everything the platform wanted to know came down to a single question: what did this order just do? The only way to answer it was to read the order's whole history and take the newest row. One query, per order, every time — on every screen, in every scheduler sweep, before every job decided whether it was allowed to run. A list of a hundred orders asked it a hundred times.
The fixes read like an apology to the database. One composite index turned the hot lookup from a scan into a seek.indexes ↓ Then we stopped asking the question and started remembering the answer: the last action an order took now lives on the order itself, written in the same transaction that appends the history row — the trail stays the record of truth, the column is just its most recent line, already read. The cost moved from every read to one write per transition, and roughly 90% of those history queries stopped running at all. And the scheduler grew up: it loads work in batches, skips anything it touched in the last five minutes, checks what is already queued before dispatching again, and staggers its dispatches with a small random delay — so a thousand jobs stop arriving in the very same second.
Case 08 · The regression the baseline caught
Nov 2025Impact2s per order recovered in a week
In November, the weekly numbers twitched. The purchase step, steady around seven seconds for weeks, came in at nine — then nearly ten. Nothing had failed: every order completed, no alarms fired, nobody complained. Which is exactly what makes this kind of bug dangerous. Without a baseline, two extra seconds on every order quietly becomes the new normal, and nobody ever asks why.
The percentile history let us bisect by deployment, and the answer was uncomfortable: the slowdown came from a bug-fix. A certificate had been naming the wrong previous owner; the correction was right, but it did its checking in the middle of every single purchase. We reverted it in production the same week — protecting the step while the fix stayed alive in testing — and then rebuilt the same correction so it did its work off the critical path.
Case 09 · The coded-unit ledger battles
2024 – 2026Impact3,300 orders · 100% · ledger intact
As Part 1 explains, stock lives as individually coded units, each tracked to its owner, with every ownership change appended to a legal ledger by a database trigger. That units table is the hottest object in the platform — one of its indexes alone had served 94 million reads when we first profiled it — and it answers to three masters at once: no two orders may ever hold the same unit, the ledger may never miss a row, and finding eligible units among millions has to stay fast. Almost every scaling battle we fought eventually landed on this table.
Exclusive holds took years of iteration: counters that tolerate crashes without drifting, a repair job for corrupted holds, overlap middleware on the jobs that could collide — and one queue deliberately left at a single worker, because for that step, correctness genuinely could not be parallelized. Knowing when not to scale is also scaling.
The table's indexes have a history like a battlefield map:query plans ↓ added, removed, rebuilt as virtual-column composites, extended, pruned again. Every index makes the reads faster and the millions of ownership writes slower, so each one had to earn its keep with measurements — index hygiene became a recurring chore, not a one-time setup.
2024-11 add previous-owner index
2024-12 add indexes to the inventory-units table
2024-12 add more index columns to the same table
2025-01 remove indexes from the inventory-units table
2025-02 create a new index on the inventory-units table
2025-03 add index (market inventory units)
2025-07 add index (market inventory units)
2025-10 add index on the last purchasing order
2025-11 drop the previous-owner grouping index (three times)
2026-04 add a covering index
2026-07 drop unused indexes One experiment deserves its own confession. We added model-level caching to the hot tables to save reads — and months later reverted it for the write-heavy ones, because under real load the caching cost more than it saved. It stung. It is also exactly why we measure.
And the trigger met its match at settlement scale. The per-row trigger guarantees no ledger row is ever missed — and it became the slowest part of bulk settlement. We did not remove the guarantee; we changed who writes. The settlement rewrite produces the very same ledger rows through set-based bulk statements, thousands at a time, with the trigger stepped around only for that audited path.
Case 10 · When the jobs themselves fail
2024 – 2026Impactfailed job ≠ failed transaction
Everything in this platform is a job — every step transition, every certificate, every settlement. That is what makes 5,000 concurrent orders possible, and it carries a sober corollary: whatever happens to a job happens to a customer's transaction.
In the early platform, a job's life could end three ways, and all of them were bad. A failed job simply died — there was no retry, so a database hiccup lasting one second froze a customer's Murabaha until an engineer went in by hand. Nothing stopped two workers on two different servers from grabbing the same work — and in a system that moves money and transfers ownership, doing something twice is as dangerous as not doing it at all. And when we first added locks to prevent that, the locks never expired: a worker killed mid-job left its lock behind forever, blocking the order with a "max attempts exceeded" error whose cause you could never find — because the cause was a ghost.
The platform we run now starts from a different assumption: every job will eventually die mid-flight — the work is making that boring. Jobs retry on their own, waiting 1, 2, 3, 5, then 30 seconds between attemptsretries ↓: patient enough for a transient fault to clear, quick enough that the order barely notices. If we had to name the single most important mechanism behind zero failed transactions, it is this one — most failures are temporary, and a platform that retries well simply does not lose them.
Critical jobs are declared unique per order across the whole fleetunique jobs ↓ — Laravel's
ShouldBeUnique, backed by the Redis lock store — so a duplicate
dispatch from any server is refused at the queue itself, not caught after the damage. The locks learned
to die: they expire shortly after the worker timeout, so a crashed worker's lock heals on its own, and
an overlapping dispatch politely re-queues after a short delay instead of burning through its retries on
the spot. Even deploys stopped being a hazard — a worker being shut down now finishes or hands back its
job instead of taking it to the grave.
class TransferOwnershipToCustomer implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StopsOrderOnJobFailure;
public $tries = 5;
public $backoff = [1, 2, 3, 5, 30]; /**
* WithoutOverlapping with safe defaults.
*
* Laravel's WithoutOverlapping defaults to expiresAfter = 0, meaning the lock
* never expires. When a worker is killed mid-job (timeout, OOM, deploy) the
* lock is orphaned forever, permanently blocking that order's job and producing
* MaxAttemptsExceededException with no underlying error. These defaults make a
* dead worker's lock self-heal:
*
* - expiresAfter: comfortably above the worker --timeout (120s) so a legitimately
* long-running job keeps its lock, while a crashed worker's lock is reclaimed.
* - releaseAfter: re-queue an overlapping dispatch after a delay instead of
* spinning and burning all retry attempts instantly.
*/
class PreventOverlapping extends WithoutOverlapping
{
public function __construct($key = '', $releaseAfter = 10, $expiresAfter = 180)
{
parent::__construct($key, $releaseAfter, $expiresAfter);
}
} The scaling ladder
2024 – 2026| Date | Test scale | Outcome |
|---|---|---|
| Dec 2024 | 31 orders | 3 stuck >20 min; 10-order retest passed — root cause: disk 98% full |
| Jan 2025 | 10 orders | 4 of 10 stuck in Purchasing Commodity |
| May 2025 | 388 orders / hr | Handful stuck at initiation and ownership |
| Jun 2025 | 535 orders | 534 completed — first full auto-sell cycle |
| Aug 2025 | 1,000 orders | Post-fix: P95 13s, zero orders over 15s |
| Feb 2026 | 2,500 × 5 runs | 99.92–100% success, ~48–50 min per run |
| Mar 2026 | 3,300 orders | 100% completed |
| Apr 2026 | 5,200 orders | 99.94% — 3 stuck, end-to-end |
| Jun 2026 → | 5,000 orders | Routine daily performance test; create-order API ~1.2s avg |
Business logic as a performance lever
ThroughoutThreaded through the cases above are wins that were not technical at all — product decisions about what the Murabaha flow actually requires:
- Auto Complete Sell. Allowing companies to opt into automatic contract, wakala, and sell steps is what made unattended 5,000-order runs possible at all — and mirrors how high-volume lenders want to operate.
- Certificates on demand. Sharia-required documents are still produced — but at download time, off the order's critical path (Case 04).
- Trade rotation count. A controlled pre-production A/B (five 2,500-order runs before and after) proved that reducing commodity trade rotations from 2 to 1 carried no performance penalty, clearing the way for the Specialized Entity flow on business merits alone.
- Force Forward. At scale, some tiny fraction of orders will always strand. Rather than database surgery by engineers, admins got an audited per-step "force forward" action, with automatic order expiry as the second safety net.
- A standing performance program. Weekly (now daily) pre-production stress tests with recorded percentiles, quarterly performance epics, and dedicated worst-case scenarios — performance became a product feature with an owner, not an emergency.
Ten orders once broke this platform. By the end of these ten cases, five thousand ran end to end at 99.94% — and the last of them had already changed the shape of the problem. With the code no longer the constraint, the waiting had moved somewhere else entirely: into the space between the jobs, and into a worker fleet nobody had designed on purpose. That is Part 3.
Further reading
This series names more than it can teach — a contract type here, an isolation level there, a queueing effect somewhere else. Where a term is carrying real weight in the text above, this is the primary source to go and read properly, rather than a summary of a summary. Every link was checked when this was published.
- Isolation levels, and what READ COMMITTED actually changes (Case 02) — the per-statement snapshot, and the gap locks InnoDB stops taking once you leave REPEATABLE READ: MySQL 8.0 Reference Manual, 17.7.2.1 Transaction Isolation Levels.
- What each isolation level is allowed to get wrong. The anomaly vocabulary comes from Berenson et al., "A Critique of ANSI SQL Isolation Levels"; Martin Kleppmann's Hermitage turns it into runnable tests showing which anomalies each database really permits, and Jepsen's consistency map places the whole family in one picture.
- Record locks, gap locks and next-key locks (Cases 02, 09) — what is actually being held while an order waits: MySQL 17.7.1, InnoDB Locking.
- Locking reads (Case 09) —
SELECT ... FOR UPDATE, plusNOWAITandSKIP LOCKED: MySQL 17.7.2.4; from the application side, Laravel's pessimistic locking. - Deadlocks are normal — the question is how you handle them (Case 02). The checklist we worked through, including "use READ COMMITTED" and "add the right index", is MySQL 17.7.5.3, How to Minimize and Handle Deadlocks.
- Indexes, and why one missing index cost seconds (Cases 03, 09) — MySQL 10.3.1, How MySQL Uses Indexes for the mechanics, and Markus Winand's Use The Index, Luke for the best developer-facing treatment of index design anywhere.
- Reading a query plan before changing anything — MySQL 10.8.1, Optimizing Queries with EXPLAIN.
- Foreign keys, and what dropping them buys and costs (Case 09) — the referential actions we gave up, and the locking behaviour we gave up with them: MySQL 15.1.20.5, FOREIGN KEY Constraints.
- N+1 queries and eager loading (Case 07) — how one query becomes 114,000: Laravel eager loading. Transaction handling itself is in Laravel's database docs.
- If you only read one book on this. Martin Kleppmann, Designing Data-Intensive Applications — Chapter 7 (Transactions) covers most of what this article ran into, in order.