Two workers claiming with SELECT ... FOR UPDATE serialise — the second blocks on the row the first holds — so adding workers adds no throughput at all.
START TRANSACTION;
SELECT id, payload FROM jobs
WHERE reserved_at IS NULL
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE jobs SET reserved_at = NOW(), attempts = attempts + 1 WHERE id = ?;
COMMIT;
-- then do the work, OUTSIDE the transaction
SKIP LOCKED passes over rows another transaction holds rather than waiting, so each worker takes a different job. Keeping the transaction short is the other half — doing the work inside it holds the lock and recreates the serialisation it was meant to remove. NOWAIT is the sibling that errors instead of skipping, which suits an interactive request that would rather fail than hang.