Skip to content

Performance results

What a rush costs yoyaku, and what to read before sizing one.

Every figure here is produced by the load harness rather than typed in by hand. What each operation costs in database statements is a developer's question and is kept out of the documentation site, in PERFORMANCE.md at the root of the project.

Under concurrency

What one request costs is a statement count and lives in PERFORMANCE.md. This is the other question: what happens when many requests arrive at once, measured with the harness in Load testing the booking path. The figures are a machine's, and only the shape transfers: which resource gives way first, and whether adding bookers still takes seats.

These figures are older than the code

Measured on the 1.x branch as it stood on 2026-08-15, and the booking path has been worked on steadily since. The locking work is the part whose effect on this rig is known: it took failed claims from around 1900 a run to none, and 1360 server errors to none. The shape below still transfers and the figures no longer do: read them as a floor, and re-run the harness before sizing anything.

Measured 2026-08-15, MySQL 8.0, memcache on for the default, render and dynamic page cache bins, no step debugger, opcode cache on. One container of Apache prefork with mod_php, a worker cap of 150, on a fourteen core virtual machine shared with the driver and with other containers. A hall of 8000 places in 80 rows and sixteen parts. Each booking visitor wants between one and three seats side by side, claimed in one request, and looks again when somebody beats it to them. The hall is emptied and the web server restarted between rungs, so no rung starts inside the last one's memory.

What a rush costs a machine

The ladder itself is under Filling a house from empty to full, which drives a rush rather than a window and is the arm to read. What follows is what the same runs said about the machine, which does not depend on the arm.

The first attempt at 160 has a lesson in it that is not about yoyaku. The machine ran out of memory mid-run and the kernel killed the database, so every request answered 500 and the reading was void. Run again later against the same code with 11.8 GB free, it completed and measured.

The tempting explanation was wrong, and it is worth writing down which measurement misled. Summing per-process RSS over the web server's workers gave 2.9 GB across 56 of them, about 48 MB each, which makes 150 workers look unaffordable. RSS counts every shared page once per process, and these processes share a PHP binary, an opcode cache and every library between them. The container's own accounting says what an extra worker really costs: 152 MB at 11 workers against 472 MB at 56, which is about 7 MB each, so the whole worker cap is close to a gigabyte and was never the problem. The first run died because the machine was busy with other work at that moment, not because a booking site cannot hold 150 workers.

That 7 MB is the marginal cost of a worker on this hall, and it is not the sizing figure either. A worker keeps what the largest page it served needed: on a 1440 place hall one settles around 114 MB, on an 8000 place hall around 150 MB, against a floor of about 72 MB before it serves anything. Which of the three numbers to plan with, and why the answer is the last one, is in Sizing for a rush.

The rule to take from it is the one the caveats already state: this is one machine, and a run has to be watched rather than trusted. The harness now records what the machine had free while it ran.

Where holds wait, on one slot and on several

The same load, holds only, so the render is out of the way. Taking a place serializes on the slot row by design, and slots do not serialize against each other, so the two arms separate what is serialized from what merely collides.

arm bookers holds/s wait edges on yoyaku_slot wait edges on cachetags
one slot 20 21.2 12 0
four slots 20 24.4 3 0
one slot 80 51.3 2582 2
four slots 80 84.6 42 169

Spreading the same eighty visitors over four slots is worth 65 percent more holds a second, which is the price of the guarantee, quantified: the lock that makes overbooking impossible is also the thing that bounds a single slot's rush.

And the contention moves rather than disappearing. Off the slot row, the row holds queue on next is the cachetags entry for yoyaku_booking_list, which every hold invalidates. Two readings matter here, and they were reached by measuring something the code had only been reasoned about:

  • In a single-slot rush it is not zero, as predicted, but it is two. Deferred invalidation runs after the transaction commits, which is outside the slot lock, so two holds can reach that row together. Rare, because they arrive there one at a time.
  • Across slots it is the majority of the waiting, 169 edges against 42. Slots that do not serialize against each other still write the same row.

It is small either way, and nothing here argues for breaking the core-wide contract that tag invalidation is. It does mean the cost of that tag is now a measurement rather than a suspicion, and the arm that would show a regression exists.

The same rush spread over more slots, on a hall of 8000

Measured 2026-08-15, same machine and same stack as above. A hall of 8000 places in 80 rows offered on sixteen slots, no capacity and no quota, 160 bookers all holding, three places a click, sixty second window after a thirty second warmup, the hall emptied and the web server restarted between arms so that no arm starts inside the last one's memory.

Read the ratios, not the absolutes. Four ladders of the identical configuration produced one-slot throughput of 60.6, 55.0, 50.0 and 36.0 holds a second, a spread of 68 percent, with nothing changed and every run valid on its own terms: memory headroom intact, workers saturated, the driver not starving. Normalized to their own first rung the same ladders agree closely. Anything smaller than that spread cannot be claimed from a comparison between two runs on this machine.

Each ladder against its own one-slot rung. A, B and C are 80 workers, D is 160; B put the tag checksums in memcache, the rest are stock.

slots A B C D
1 1.00 1.00 1.00 1.00
2 1.48 1.84
4 1.98 1.85 2.23 2.56
8 2.29 3.04
16 2.84 2.62 2.65 3.50

Four slots carry about twice the holds of one, sixteen about two and three quarter times, and three independent ladders whose absolute throughput differed by 68 percent agree on it to within a tenth. That is the reproducible result.

The wait edges say why, counted inside one run: the same load that produced 162688 waits on one slot's row produced 2581 across four and 483 across sixteen.

Ladder D says the extra workers gain more where the load is parallel and less where it queues on one row: it reaches only 29 percent of its own sixteen-slot figure at one slot, against 35 percent for the 80 worker ladder. The absolute comparison, whether 160 workers serve more holds a second than 80, cannot be made on this rig and is not made here.

The promise, under a rush that cannot be satisfied

Sixty visitors, all booking, on a hall of 200 places, taking five places each, with no warmup so that every hold of the run is inside the window.

what count
successful holds 200, which is capacity, to the unit
refused, the place already taken 4281
failures of any kind 0

The hall held 200 places, none of them held twice, and the driver's count of successes and the hall's own count agreed. That is the no-overbooking guarantee demonstrated under real concurrency rather than argued from the code.

The refusal path is also the cheap one, which matters because during a rush most people meet a refusal: a refused click answered in 16 ms at the median and 37 ms at p95, against 16 and 46 for a click that got its place.

The other kind of promise: an area counted rather than claimed

A seat is claimed once, and two bookers conflict only over the same seat. An area offered by quantity is counted, so every booker is competing for one number, and a number is what a race can actually spoil. The seated run above says nothing about it.

Measured the same day, on a slot whose only shared number is the area's own: no slot capacity, no tariff quota, an area of 200 units, 60 visitors all booking, asking for three units a click, no warmup so that every unit taken falls inside the window.

what count
units given out 200, which is the area, to the unit
clicks served whole 66
clicks served in part 2
clicks refused, the area gone 3689
failures of any kind 0

The two partial answers are the interesting ones and they are correct: asked for three units with fewer than three left, the endpoint gives what remains and refuses the rest in the same answer. A run that counted answers rather than units would have miscounted exactly there, which is why the harness reads how much a click actually got rather than whether it succeeded.

Refusing is again as cheap as succeeding: 35 ms at the median for a refused click against 35 for one that got its units, and 85 at p95 for both. Throughput looks low at 4.4 units a second only because the area was exhausted seconds into a 45 second window; after that the run is measuring the refusal path, which is the point of the arm.

Filling a house from empty to full

The arms above measure a fixed window on a hall with room to spare. This one runs a rush: 8000 seats in 16 parts on one slot, 160 bookers, from an empty house until the seats or the clock run out. It is the arm that shows what a click costs as the house fills, which a window on a quarter-empty hall cannot.

Read TIME TO FILL THE HOUSE from it, not holds per second. The run drives a fixed window whatever happens, so an average over it is the house divided by the window and says nothing.

bookers at once sustained time to take a seat taken of 8000
40 47 seats/s 30-35 ms 5444, the window ran out
80 90 seats/s 38-106 ms 7649
160 175 seats/s 53-90 ms 7999
160, orphan rule enforced 160 seats/s 54-93 ms 7850

Doubling the bookers doubles the seats taken, 47 to 90 to 175. A ladder bends at that step when something on the path grows as the house fills; nothing on this one does, and the curve says so. A full house of eight thousand goes in under two minutes at 160.

Three readings matter more than the figures.

The time to take a seat does not climb as the house fills. A hold reads the seats it names and the section it is being seated into, and neither grows with what has been taken, so the last click of a full house costs what the first one did.

How long availability is held decides how much of a rush is wasted. At 150 seats a second a picture 30 seconds old is thousands of seats out of date, and a booker shown one picks seats that have gone: measured against the same house, holding it for thirty seconds rather than five roughly doubles how long the rush takes. That is not a database result, it is bookers being told the truth, and it is why the shipped default is 5 seconds.

Enforcing the orphan rule costs about a twelfth of the throughput. Not the request: the time to take a seat is the same either way, because the rule reads one section. What it costs is the tail, where the free seats have fragmented and fewer of those left can be taken without stranding a neighbor. The house still reaches 98 per cent, and what it settles with is pairs that only a party of exactly two can use, which is the rule doing what it is for.

Filling the same house when the house chooses the seats

The rush above is bookers clicking seats on a plan. The other way in is a booker who names none: "three of us, at this price", and the house decides where they sit. It is the harder shape to serve, because nothing in the request narrows it down, and it is the shape a slot-wide lock would be taken for.

Same hall, same 160 bookers, parties of one to three seats asked for in a single request, one price over the whole house:

what is measured reading
taken of 8000 8000, the whole house
sustained through the rush 64-76 seats/s
refused while seats remained none
refused in the last tenth 84 per cent

The house fills. Every seat goes, and the bookings hold 8000 distinct places, which is the assertion that matters: serving everybody by handing them all the same seat would look identical in a throughput figure.

It is about a third of the click path's rate under this load, and the whole of that difference is queueing. Both ways in ask for one to three seats, so it is not the size of the party, and it is not the cost of the request either: the claim takes 66 ms with nothing contending against the click's 36 ms, and far more at 160 bookers, where the work has not changed and the queue has. Over ninety seconds of the same run InnoDB reports about four thousand row-lock waits for each way in, and 38 seconds of waiting for the clicks against 8560 for the parties: the same number of waits, two hundred times the wait.

What they queue for is the section. A booker who names nothing is offered the best section still open to them, and house order makes that the same front section for everybody until it fills, so they line up on one anchor row while clicks spread over sixteen. Each holder keeps that row for a section read, the seating and the writes. Offering a section at random instead is worth about a fifth of the throughput and costs the booker the seats they would rather have had, which is the wrong trade. What the rush is worth is bounded by how long that row is held, so shortening the seating's read is where the next gain is.

Nobody is refused while the house has room. A party put in a section that turns out not to seat it is not refused there: the guess is dropped and the hall is asked again with no section of its own, which waits on everywhere the tariff reaches and is therefore answered by what is free rather than by what was free. Refusing on the first try instead costs 10 to 20 per cent of the parties a booking the hall could have taken, and costs the rush a fifth of its rate, because a refused booker comes back and asks again for nothing.

The last tenth still refuses most of what it is asked, and that refusal is the honest one: by then the house really is nearly gone, and three seats together are not there to be had.

The same engine on a hall ten times the size

Eighty thousand places in eighty sections, one price, forty bookers at once. The question is what grows with the hall, so everything here is beside the same reading on the hall of eight thousand.

what is measured 8000 places 80000 places
waiting for the locks small none: 0 ms, 2 ms at the ninetieth
the locked section 17 ms 23 ms
statements a hold runs 13, 19, 28 13, 19, 28
rows a claim reads 16694 51411
the booking page, as a booker gets it 5 ms 21 ms
a claim, forty bookers at once 66 ms 90 ms
refused while seats remained none none

Nothing the engine does grows with the hall. A hold runs the same statements on a stadium as in a studio, waits for nobody, and holds its part of the house for about as long. Eighty sections is eighty queues, which is why the waiting is gone rather than merely shorter.

What grows is what a claim reads, and less of it than the hall does. Three times the rows for ten times the house: what is left in each part of the house is one kept reading rather than a listing of every free seat, and how many seats a hall holds is a requirement about the building that outlives a booking. Keeping those numbers on the sections themselves, which is #3617101, is what would make the remainder a row read.

What a claim costs is what a stadium can book. The page is one build shared by every booker, and a claim is a request of its own that names a number and answers with what is held, so neither pays for the other. Under forty bookers at once this hall takes seats at about fifty a second with a claim's median between 235 and 370 ms across runs; the hall of eight thousand takes them at a hundred and seventy-seven a second under a hundred and sixty bookers, with a claim's median at 349 ms.

What is left inside a claim is the number every area card carries: how many seats each part of the house has left, counted over the parts a tariff is offered in. One price across eighty sections is one count of the whole house, which is why the stadium's claim is dearer than the arena's rather than merely bigger. Keeping that number on each section's own row, which is #3617101, turns the count into a read.

A word about the map. The page a booker opens is a shell of 14 KB whatever the venue, but the geometry behind it is 1.2 MB for eight thousand places and 11.8 MB for eighty thousand. A house that large fills by area rather than by seat, which is what an assigned area is for: the booker names a part of the house and the seating gives them places inside it, with no geometry fetched at all.

These are forty bookers, not a hundred and sixty

A hundred and sixty against a hall this size took the rig's own database and cache out with the kernel's out-of-memory killer, and everything answered 500 afterwards. The numbers above were taken at forty, on a box where the load driver runs beside the site it is loading. They say what the engine does, not what a sized deployment would.

What a load driver has to do for any of this to mean anything

A harness that cannot fail loudly will lie quietly, and every fault below produces a plausible reading rather than an error:

  • Read AVAILABILITY, not the picker page, and choose seats from what it says is free. A driver clicking blindly measures its own blindness, and the tell is a refusal rate that tracks occupancy exactly.
  • Count SEATS, not requests. A party is claimed in one request, so counting bookings reads a house taken in twos as half taken.
  • Claim a party in one request. Offered together they are judged together; clicked one at a time, the rule against stranding a place judges each alone and refuses the half-claimed run in the middle.
  • Look again after a refusal, as a booker does, rather than giving up.

Assert these in the rig itself: the refusal rate must not equal occupancy, and the seats it believes it gave out must match the bookings in the database.

How a number gets measured

Queries are counted with the database log, never inferred from reading the code:

\Drupal\Core\Database\Database::startLog('probe');
// ... the one operation being measured ...
$queries = count(\Drupal\Core\Database\Database::getLog('probe'));

getLog() consumes the key, so it is read once. Four rules make the numbers comparable:

  • Warm the caches first. Run the same operation once before the one that is measured, or the figure is a cache-miss figure and every later comparison drifts.
  • Name the scenario completely: the hall's size and shape, the party size, what was already booked, the database driver. A count without them means nothing.
  • Say measured or inferred. If a figure comes from reading the code rather than from a log, write that it does.
  • Count cache work over HTTP, never in a kernel test. KernelTestBase puts every cache bin on the memory backend, so a cache read and a cache invalidation are not database statements there at all. A kernel budget test cannot see either, which means a change that adds or removes cache traffic reads as costing exactly nothing. Measure that kind of change in a functional test, where the bins are the database, and log inside the request.