这个问题已经有一段时间没有回答了。我认为这是一个非常好的问题,因为像这样的线程安全问题可能会对应用程序的完整性造成损害,而且由于Rails感觉如此神奇,所以最好总是翻开引擎盖,了解发生了什么。
在指定情况下,如果数据的状态可以在代码执行期间更改并影响结果,则此方法(find_each
)将不是线程安全的。(例如,使用已删除的数据调用块,两次使用相同的数据调用块,以及块跳过部分数据)。
总之,find_each
不是线程安全的。它不进行任何锁定,因此它不能确保数据已被删除、更新、插入或移动,当它调用块时。它唯一保证的是不会两次调用同一个主索引的块。
以下是一个示例,说明它可能产生奇怪的结果(尽管是愚蠢的案例)。假设有以下账户(Account)
表:
|id|balance|
| 1| 1000|
| 2| 500|
| 3| 2000|
以下是代码(由于表格很小,让我们使用batch_size: 1
):
total = 0
Account.find_in_batches(batch_size: 1) |acc|
total += acc.balance
end
第一次迭代时,它将运行带有Account(id: 1, balance: 1000)
的块,因此total
将等于1000
。
现在,在第二次迭代正在运行时,另一个线程运行以下代码:
Account.transaction do
acc1 = Account.find(1).lock!
acc3 = Account.find(3).lock!
acc1.update(balance: acc1.balance + acc3.balance)
acc3.update(balance: 0)
end
它基本上是将账户1的所有内容转移到账户3。现在表格看起来像是这样:
|id|balance|
| 1| 3000|
| 2| 500|
| 3| 0|
但要记住,我们已经处理了第一个帐户,它将继续运行第二个帐户的块,所以
total
会等于
1500
,最后运行第三个帐户的块,因为余额现在为
0
,
total
将保持在
1500
。
这将导致您的
total
为
1500
,而您明显想将其设置为
3500
。
(
each
不是完全线程安全的,但它保证这种情况)
如果需要确保线程安全,一种简单的方法是在表上获取锁定(例如,在postgres中)。请记住,锁定整个表可能会严重影响性能。
count = 0
MyModel.transaction do
ActiveRecord::Base.connection.execute("LOCK TABLE mymodels SHARE")
MyModel.find_each do |model|
count += 1 if model.foo?
end
end
请注意,MyModel.lock.find_each
也不是线程安全的。
find_each
的工作方式是通过按照主索引(通常是id
)对所有内容进行排序,并使用批处理大小(默认为1000
)限制结果。
SELECT "models".* FROM "models" WHERE "models"."id" > 1000) ORDER BY "models"."id" ASC LIMIT $1
它存储了批处理中的最后一个 ID,然后为每一行调用该块。一旦对每一行执行完该块,它会使用 models.id > last_id
运行另一个查询,直到达到结束。