Skip to content
Alex Hollyman

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

With an index A few pages of the index are read, then the one row.

Index pages read
4
Rows read
1

An example for a table of 1,000,000 rows. With JavaScript on, both lookups are run and timed in your browser.

The table is a real array in your browser's memory and the index is a real B-tree built over it. Each lookup is timed over thousands of repetitions. The scan is replayed slowed down; at that speed the index lookup would be over within one frame, so its steps are shown one at a time. A database reading from disk takes longer than this either way, but the gap grows with the table in the same way.

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 (?, ?, ?, …)

Illustration: the number of queries is what Laravel would run for this code, and the length of each bar is simulated.

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.

Illustration: browser memory is not PHP memory, so nothing here is measured. Memory is drawn as the number of rows held at once, with the limit set at 25,000 rows so the shapes are easy to see. 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.

members The table, matched on email. 4 rows.

  1. First import 2 created, 1 updated, 2 unchanged, 1 failed
  2. Same file again 0 created, 0 updated, 5 unchanged, 1 failed
This runs for real in your browser: each value is trimmed and the email lower-cased, the row is validated, and it is matched to a member by email before anything is written. The six people in the file are invented.

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

1041

1042

Both orders have been given reference 1042.

With a transaction and lockForUpdate()

Request A

Last reference

Request B

1041

1041 locked by A

1042 locked by A

1042 locked by B

1043 locked by B

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.

Illustration: the two requests are simulated in your browser, in the order MySQL would run them with and without the lock.

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;
    }
}