Data and performance
I am comfortable handling large datasets and love squeezing every millisecond of performance out of a query. I think ahead when designing user-facing systems, APIs and data structures.
These are five habits from my Laravel and MySQL work. Each has a demonstration you can operate, and where a figure is described as measured, your browser has just measured it.
Finding one row: scan or index
Without an index, MySQL finds a row by reading every row in the table and checking each one, so the query gets slower as the table grows. With an index it reads a few pages of a sorted tree and then the one row it needs.
I decide the indexes while I design the schema and write them into the migration: a unique constraint on each natural key, composite indexes for columns that are queried together, and foreign keys between tables.
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained();
$table->string('reference')->unique();
$table->string('status');
$table->timestamps();
$table->index(['status', 'created_at']);
});
select * from orders where reference = 4829133
Without an index Every row is read and checked.
- Rows read
- 1,000,000
- Time
With an index A few pages of the index are read, then the one row.
- Index pages read
- 4
- Rows read
- 1
- Time
An example for a table of 1,000,000 rows. With JavaScript on, both lookups are run and timed in your browser.
Milliseconds for one lookup, by rows in the table
- Without an index
- With an index
Each table size you try adds a point.
| Rows | Without an index | With an index |
|---|
The N+1 query
A page lists 40 posts and shows the author of each. If the authors are not loaded up front, Laravel fetches them one at a time as the template asks for them: one query for the posts, then one more for every post. Eager loading fetches all the authors in a single second query.
I load relations with with(), loadMissing() and withCount(). In
recent projects I also switch on preventLazyLoading() everywhere except production, so an
N+1 query throws an exception while I am developing and cannot reach production unnoticed.
// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());
The query
$posts = Post::query() ->with('author') ->latest() ->take(40) ->get();
The highlighted line is the only difference.
The template
@foreach ($posts as $post)
<li>{{ $post->author->name }}</li>
@endforeach
Without eager loading
41 queries to build the page
Each bar is one query. Time runs from left to right. The faint bars are the same page without eager loading.
- 1 × select * from posts order by created_at desc limit 40
- 40 × select * from users where id = ? limit 1
With eager loading
2 queries to build the page
- 1 × select * from posts order by created_at desc limit 40
- 1 × select * from users where id in (?, ?, ?, …)
Large tables without large memory
Calling get() on a big table builds every row as an object before the first one is
processed, and on a big enough table that runs out of memory. For scheduled commands and backfills I use
chunkById(), which reads a batch, processes it and lets it go before reading the next.
Inside queued jobs that assemble a large output I use lazy(). It reads in batches
underneath but hands my code one record at a time, so each record can be written out as it arrives.
// A scheduled command or a backfill
Order::where('status', 'pending')
->chunkById(2000, function ($orders) {
$orders->each->recalculate();
});
// A queued job writing a large file
foreach (Order::with('customer')->lazy() as $order) {
$writer->addRow($order->toExportRow());
}
Rows held in memory while working through a table of 100,000 rows
get()
Load every row, then start work.
- Rows processed
- 0
- Most rows in memory at once
- 25,000
Stopped: out of memory before the first row was processed.
chunkById(2000)
Read a batch, process it, let it go.
- Rows processed
- 100,000
- Most rows in memory at once
- 2,000
Finished: every row processed.
lazy()
Batches underneath, one record at a time to my code.
- Rows processed
- 100,000
- Most rows in memory at once
- 1,000
Finished: every row processed.
lazy()
fetches in batches of 1,000 unless told otherwise, which is why its line is a finer version of the one beside
it.
An import you can run twice
Files from other systems arrive with stray spaces, mixed case and repeated rows, and an import sometimes has to be run again after it has half finished. I trim and lower-case each identifier, then match the row to an existing record by its natural key, so importing the same file again creates no duplicates.
Each row is validated on its own and the import keeps a status for it, so one bad row is reported with its reason and the rest still go in. Large spreadsheets are read in chunks.
$member = Member::updateOrCreate(
['email' => Str::lower(trim($row['email']))],
['name' => Str::squish($row['name'])],
);
$status = match (true) {
$member->wasRecentlyCreated => 'created',
$member->wasChanged() => 'updated',
default => 'unchanged',
};
members.csv Cleaned as each row is read. A small square is a stray space.
- 1 Sam.Example@Example.org Sam Example Created
- 2 ALEX.SAMPLE@EXAMPLE.ORG Alex Sample Unchanged
- 3 jo.placeholder@example.org Jo Placeholder Updated
- 4 sam.example@example.org Sam Example Unchanged
- 5 robin.invented.example.org Robin Invented Failed The email field must be a valid email address.
- 6 Pat.Demo@example.org Pat Demo Created
members The table, matched on email. 4 rows.
- 1 alex.sample@example.org Alex Sample
- 2 jo.placeholder@example.org Jo Placeholder
- 3 sam.example@example.org Sam Example
- 4 pat.demo@example.org Pat Demo
- First import 2 created, 1 updated, 2 unchanged, 1 failed
- Same file again 0 created, 0 updated, 5 unchanged, 1 failed
Two requests, one reference number
Some values must be given out exactly once, such as the next sequential reference number. If two requests read the last number at the same moment, both add one and both write the same value.
Where two requests could collide like this, I wrap the read and the write in a database transaction and
lock the row with lockForUpdate(). The second request then waits until the first has
committed, and reads the new value. I use the same approach when publishing a draft.
$reference = DB::transaction(function () {
$counter = Counter::where('name', 'order_reference')
->lockForUpdate()
->first();
$counter->increment('value');
return $counter->value;
});
Without a lock
Request A
Last reference
Request B
1041
Reads 1041
Reads 1041
1041
Writes 1042
Writes 1042
1042
Order A gets 1042
Order B gets 1042
Both orders have been given reference 1042.
With a transaction and lockForUpdate()
Request A
Last reference
Request B
1041
Locks the row and reads 1041
Waits for the lock
1041 locked by A
Writes 1042 and commits
1042 locked by A
Locks the row and reads 1042
1042 locked by B
Writes 1043 and commits
1043 locked by B
Order A gets 1042
Order B gets 1043
Each order has its own reference.
What each request runs
begin;select value from counters where id = 1 for update;update counters set value = ? where id = 1;commit;
The highlighted parts are what the lock adds.
Caching and queues
Two more habits, without demonstrations. Aggregates that are expensive to compute and read often go
behind Cache::remember() with a time limit, so they are worked out once and reused until
it runs out.
Queued work runs on named queues, so heavy jobs such as building a large document cannot hold up quick ones such as notifications. Expensive jobs are marked unique, so the same one cannot be queued twice.
$totals = Cache::remember(
'orders.totals',
now()->addMinutes(10),
fn () => Order::totalsByStatus(),
);
// Heavy work has its own queue, and cannot be queued twice
class BuildDocument implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Document $document)
{
$this->onQueue('documents');
}
public function uniqueId(): string
{
return (string) $this->document->id;
}
}