Skip to content

Commit ada864e

Browse files
committed
perf(core): add composite index on notifications to fix slow unread count queries
Fixes #3952. On forums with large notification tables, the unread/new notification count queries (serialized on every page load via CurrentUserSerializer) were performing a full table scan. MySQL abandons the single-column user_id index when one user owns a significant fraction of the table, which is common on active forums. The fix adds a composite index (user_id, is_deleted, read_at, type) so MySQL can use a range scan on (user_id, is_deleted=0, read_at IS NULL) and satisfy the entire count query from the index without scanning the table. In the realistic production case (a user with many read notifications and a small number unread), this reduces the rows examined from ~1M to the exact unread count. Benchmarked against a 1M-row table with 200k notifications for one user (5k users total, two notification subject types to exercise both visibility scoping branches): Realistic case (500 unread / 200k total for user): Before: 86ms — full table scan, ~1M rows examined After: 3ms — index range scan, 500 rows examined (96% faster) Worst case (200k unread / 200k total for user): Before: 106ms — full table scan, ~1M rows examined After: 124ms — index range scan, ~200k rows examined The worst case (user has never read any notification) shows a slight regression because the index scan still visits 200k rows. This scenario is not realistic in production, and on a cold disk-backed database the index would still outperform the full scan. No code changes — visibility scoping is fully retained and there are no breaking changes for extensions.
1 parent cfb85d1 commit ada864e

2 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
use Illuminate\Database\Schema\Blueprint;
11+
use Illuminate\Database\Schema\Builder;
12+
13+
return [
14+
'up' => function (Builder $schema) {
15+
$schema->table('notifications', function (Blueprint $table) {
16+
// Composite index to support the unread/new notification count queries
17+
// issued on every page load. Without this, MySQL abandons the single-
18+
// column user_id index when one user owns a large fraction of the table
19+
// (common in active communities) and falls back to a full table scan.
20+
//
21+
// Column order rationale:
22+
// 1. user_id — equality prefix, mandatory filter on all count queries
23+
// 2. is_deleted — equality (always 0 for visible notifications)
24+
// 3. read_at — IS NULL filter for unread; narrows to a small fraction
25+
// 4. type — IN (...) filter; included so the index covers the query
26+
//
27+
// With this index, the count query can be satisfied with an index range
28+
// scan on (user_id, is_deleted=0, read_at IS NULL) without touching the
29+
// table rows, regardless of how many notifications a user has in total.
30+
$table->index(['user_id', 'is_deleted', 'read_at', 'type'], 'notifications_user_unread_type_index');
31+
});
32+
},
33+
34+
'down' => function (Builder $schema) {
35+
$schema->table('notifications', function (Blueprint $table) {
36+
$table->dropIndex('notifications_user_unread_type_index');
37+
});
38+
}
39+
];

0 commit comments

Comments
 (0)