ActiveRecord touch多Worker并发安全吗?Sidekiq重复Job场景问询
1. Is ActiveRecord's touch method concurrency-safe in a multi-worker environment?
Short answer: Yes, it is concurrency-safe.
Here's why: Under the hood, touch(:read_at) translates to an atomic SQL UPDATE statement like this:
UPDATE "cars" SET "read_at" = CURRENT_TIMESTAMP WHERE "cars"."id" = $1
Most production databases (like PostgreSQL, MySQL with InnoDB) use row-level locking for UPDATE operations. When multiple workers try to touch the same record simultaneously, the database will queue these updates and execute them one at a time. You won't get corrupted data or lost updates—each touch will successfully update the read_at timestamp, with the last executed operation overwriting the timestamp from earlier ones. The key point is that each touch operation is atomic and isolated, so there's no risk of race conditions breaking data integrity.
2. What happens when two Sidekiq workers run this job in parallel, and how to fix it?
The Problem: Duplicate Emails
In a concurrent scenario, the race condition here can lead to duplicate emails being sent to the user. Let's walk through a realistic execution flow:
- 19:00:01: W1 runs
car.touch(:read_at)→ setsread_attoT1 - 19:00:01: At the exact same time, W2 runs
car.touch(:read_at)→ setsread_attoT2(slightly later than T1) - 19:00:02: W1 queries for the earliest
read_atnon-null car → gets this car (either it's the only one with a non-nullread_at, or the earliest if others exist) - 19:00:02: W2 runs the same query → also gets this car
- Both workers then execute
send_user_email, resulting in two identical emails hitting the user's inbox.
The root issue is that the touch and subsequent query/check aren't atomic—there's a window between the touch and the query where another worker can modify the record and trigger the same email condition.
The Fix: Atomic Operations with Row Locking
To prevent this, wrap the entire logic in a block that uses row-level locking to ensure only one worker can execute the sequence at a time. ActiveRecord's with_lock method does exactly this:
car.with_lock do car.touch(:read_at) cars = Car.where.not(read_at: nil).order(read_at: :asc).limit(1) send_user_email if cars.include?(car) end
Here's how this works:
with_lockstarts a transaction and acquires an exclusive row lock on thecarrecord.- Any other worker trying to run
with_lockon the same car will block until the first worker's transaction completes. - This ensures the entire sequence (touch → query → email check) runs as an atomic unit. By the time the second worker gets the lock, the
read_attimestamp has already been updated; depending on your business logic, you might want to add an extra check (like aemail_sentflag on the car) to avoid redundant emails entirely.
If you want to avoid holding the lock during email delivery (which could be slow), split the logic:
- Use
with_lockto atomically update the car and check if it's the earliest eligible car. - If it is, mark the car as "email pending" and enqueue a separate job to send the email. This way, the lock is only held for fast database operations, not the potentially slow email send.
内容的提问来源于stack exchange,提问作者Rafael Oliveira

