SCAN with a MATCH pattern is still O(n) overall

SCAN is the safe replacement for KEYS because each call does bounded work, and the pattern is applied after the keys are fetched — so scanning a million keys to find four still reads a million keys.

// bounded per call, unbounded in total
$cursor = null;
do {
    $keys = $redis->scan($cursor, 'session:expired:*', 1000);
} while ($cursor > 0);

// what to do instead: keep the index yourself
$redis->sAdd('sessions:expired', $sessionId);
$redis->sMembers('sessions:expired');

The COUNT argument is a hint about how much work per call, not how many results to return, so a call can legitimately return zero keys and a non-zero cursor — code that stops on an empty result is wrong and will appear to work until the keyspace grows. Maintaining a set alongside the keys is the pattern that scales, and it costs one extra command on write to avoid an unbounded read.