Two workers, one order, processed twice. Every project gets this day. Someone writes SETNX, calls it a distributed lock, closes the ticket. I want to slow down here, because the ticket is not closed.
The small bugs first. A lock needs a TTL, or a crashed worker holds it forever. A lock needs an owner token, or worker A releases the lock of worker B:
$token = bin2hex(random_bytes(16));
$ok = $redis->set('lock:order:'.$orderId, $token, ['nx', 'ex' => 30]);
And the release must be atomic: compare the token and delete in one Lua script. Check in PHP, delete in a second command, and there is a gap. Something will land in that gap.
Now the bug no code fixes. The work takes longer than the lease. GC pause, slow external API, anything. TTL expires, another worker takes the lock, and two processes are inside the critical section, both sure they are alone. You can renew the lease from the worker. Renewal is also code, and code can pause. A lock with a TTL is honest about exactly one thing: mutual exclusion most of the time.
So before Redis I now ask a different question. Can the database do it? A unique constraint on (order_id, operation) makes the second insert fail, atomically, no lease, no clock. SELECT ... FOR UPDATE serializes two transactions on the same row. Boring, and correct.
The Redis lock keeps one honest job: cutting duplicate work when duplicates are merely expensive. Two workers rebuilding the same cache entry, fine, we wasted CPU. Two workers charging the same card, not fine, and no TTL value makes it fine.
Decide which case you have. Then decide if you need the lock.
There is still one SETNX on our side guarding a payment. It has not failed yet. I know how that sentence sounds.