作者。 Dmitry Tsepelev,Evil Martians的后端开发人员
在Rails中,确保每条数据库记录都是唯一的,并且表不包含重复的内容,这似乎是一项简单的任务。Active Record有一个内置的验证器,你可以把它放在一个模型上,然后就可以了。直到你的应用程序遇到高负载和并发写入--那时你将不得不处理那些难以在本地重现或在测试中捕获的讨厌的bug。请继续阅读高级方法的比较和基准,这将使你的应用程序更加健壮,你的数据更加一致。
在这篇文章中,我们将使用一个有两个表的单文件PostgreSQL支持的Rails应用程序,用Ruby线程模拟不同的竞赛条件,并看看我们如何在应用程序层面或使用数据库工具来强制执行记录的唯一性。我们将讨论锁、约束、唯一索引、"倒装 "以及Active Record内置的uniqueness 验证器的缺点。在本文结束时,你将做好充分准备,为这项工作选择最佳工具。请继续关注!
为我们的实验室做实验准备 🧪
今天的实验的小白鼠是一个假想的时间跟踪应用程序,用户可以记录他们在不同任务上花费的时间。每次用户进行记录时,我们在time_tracks 表中创建一条记录,引用user_id 和task_id 。
诀窍是避免为每个用户和每天的任务创建一个以上的条目。
换句话说,如果 "阅读 "是一个ID为1的任务,而用户想为同一个date,再记录一个小时的阅读--我们应该只是更新time_tracks 中的记录的hours 属性,而不是创建一个重复的。听起来很容易!
这里是模式。
ActiveRecord::Schema.define do
enable_extension "plpgsql"
create_table "time_tracks", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "task_id", null: false
t.integer "hours", null: false
t.date "date", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
add_foreign_key "time_tracks", "users"
end
这是天真的控制器实现,利用#find_or_initialize_by。
class TimeTracksController < ApplicationController
def track
identifiers = time_track_params.slice(:date, :task_id)
entry = current_user.time_tracks
.find_or_initialize_by(identifiers)
entry.update!(hours: time_track_params[:hours])
render status: :ok
end
private
def time_track_params
params.require(:time_track).permit(:task_id, :date, :hours)
end
end
或者,你可以使用#find_or_create_by来代替,这基本上是同样的事情。
class TimeTracksController < ApplicationController
def track
identifiers = time_track_params.slice(:date, :task_id)
entry =
current_user.time_tracks
.create_with(hours: time_track_params[:hours])
.find_or_create_by!(identifiers)
entry.update!(hours: time_track_params[:hours])
render status: :ok
end
private
def time_track_params
params.require(:time_track).permit(:task_id, :date, :hours)
end
end
正如你将看到的那样,这两种方法都有类似的缺点,所以我们将专注于#find_or_create_by! 。
为了让你省去生成Rails应用程序的麻烦,以便你能跟上进度,我们把所有的例子都打包在单文件的可运行脚本中,在你每次运行它们时都会引导一个模式并模拟不同的条件。你可以在这个gist中找到它们。
#find_or_create_by的危险!
根据它的名字,#find_or_create_by! 试图找到匹配的表行,只有在#find 没有返回的情况下才会创建一个新的表行。运行这个脚本来检查。
user = User.create
100.times do
date = Date.new(2020, 1, rand(1..10))
user.time_tracks.create_with(hours: 10).find_or_create_by!(date: date, task_id: 1)
end
TimeTrack.from(
TimeTrack.group(:date, :user_id).having('COUNT(*) > 1').select(:date, :user_id)
).select('COUNT(subquery.date) AS not_unique').to_a.first.not_unique # => 0
它在本地运行得很好,但是一旦你把你的应用程序部署到生产中,我们可能会有不止一个线程试图执行这个操作,甚至不同的应用程序实例都在运行很多线程。它会有不同的表现吗?让我们检查一下。
threads = []
wait_for_it = true
user = User.create
20.times do
date = Date.new(2020, 1, rand(1..4))
threads << Thread.new(date) do
# A loop to make threads busy until we `join` them
true while wait_for_it
user.time_tracks.create_with(hours: 10).find_or_create_by!(date: date, task_id: 1)
end
end
wait_for_it = false
threads.each(&:join)
TimeTrack.from(
TimeTrack.group(:date, :user_id).having('COUNT(*) > 1').select(:date, :user_id)
).select('COUNT(subquery.date) AS not_unique').to_a.first.not_unique # => not 0!
好吧,这是一个无奈之举。
问题是,find_or_create_by! 不是原子性的--find 和create 是独立的操作。因此,有一个很大的机会,两个线程会试图并行插入相同的数据,而#find 将对两者都不返回,使它们都执行#create 。你已经得到了你的重复!
我不想破坏气氛,但这些问题在规范中特别难以捕捉,因为它们只能通过线程来重现。你最后一次在多线程环境中测试你的代码是什么时候?
驯服竞赛条件
正如我们现在所看到的,问题来自于两个并行线程,在读取数据和写入数据时造成了竞赛条件。根据ACID原则,必须使操作成为原子操作(即所有操作 "一起 "发生)。在Rails中,其中一种方法是使用悲观锁。
# Works both with MySQL and PostgreSQL
user.with_lock do
user.time_tracks
.create_with(hours: 10)
.find_or_create_by!(date: date, task_id: 1)
end
在这种情况下,.with_lock ,不会让另一个线程进入块内。它在指定的记录上执行SELECT ... FOR UPDATE ,所有其他试图取锁或更新该行的事务将等待当前事务的提交。
我们可以结束这一天了吗?还有其他方法可以将不一致的数据放入表中吗?不幸的是,是的:有人可以添加代码,在没有锁的情况下写入数据,甚至可以直接把数据放到表中!你将不得不行使难以置信的纪律。你将不得不在团队层面上行使令人难以置信的纪律,以确保你所有的非线程安全的写操作都被锁定。
有什么东西能让人更放心吗?
限制条件来拯救你!
强制执行数据一致性的最简单的方法是在表中添加约束。这是PostgreSQL的一个原生功能,在你的应用程序中使用它是一个好主意。
为了用Active Record迁移创建一个新的唯一性约束,我们需要添加唯一索引。Active Record会根据你的数据库适配器想办法正确添加约束。现在,每次我们试图写一个重复的内容时,它都会提出RecordNotUnique ,不管我们是在Ruby中做,还是完全绕过ORM,降到纯SQL。
class AddUniqueIndexToTimeTracks < ActiveRecord::Migration[6.0]
# You need this if you are adding an index _concurrently_
disable_ddl_transaction!
def change
add_index :time_tracks, %i[user_id task_id date], unique: true, algorithm: :concurrently
end
end
🚨停机警报! 如果你的表是巨大的--请确保同时添加索引。否则,当表因创建索引而被锁定时,所有试图写到这个表的线程将不得不等待锁被释放。如果有足够多的表写入,使所有的工作者都很忙--这可能会导致停机时间。
很酷,但如果我们已经有一个巨大的应用程序,数据不一致怎么办?在添加索引之前,你必须先修复代码和数据,所以我们来谈谈这个问题。
找到需要锁的地方
虽然解决方案相当琐碎(添加适当的锁!),但你可能会想,我们应该把这些锁放在哪里,特别是当你有一个巨大的代码库时。如果你觉得自己很幸运,可以承受生产中的几个错误,那么计划是这样的。
- 找到并修复所有你能找到的负责重复的代码(以确保我们不会得到太多的崩溃)。
- 编写一个可以清除或合并重复代码的rake任务。
- 在生产中运行该任务。
- 添加唯一的索引并等待错误报告。
- 当你得到
RecordNotUnique-要么迅速修复它,要么删除索引,然后再修复它。重复步骤3-5,直到你不再得到错误。
让我们也指定一下我们想从rake任务中得到什么:它应该找到所有的异常情况,并合并或删除它们。在我们的例子中,这很简单:我们需要将 "时间轨迹 "按user_id,task_id, 和date 分组,并删除每组中除最后一条以外的所有记录。
附带的:条件唯一索引
这与本主题没有直接关系,但是在向表添加唯一索引时,你可能会遇到这样的情况,有些值可能为零。想象一下,用户可以跟踪与任何任务无关的时间("自由时间"),但每天仍然只创建一条记录。猜猜看,如果我们按照之前的方式添加索引会发生什么?task_id 它将认为所有在NULL 内的记录都是唯一的,因为在PostgreSQL中NULL != NULL!
在这种罕见的情况下,你需要用可以检查唯一性的东西来代替NULL ,例如用0来代替。
add_index :time_tracks,
'user_id, COALESCE(task_id, 0), date',
unique: true,
algorithm: :concurrently
这种方法的缺点是,你很难使用这个索引进行查询。PSQL只有在查询条件与索引中指定的条件完全匹配时才使用索引。如果你想将索引用于约束和查询--使用功能索引。
add_index :time_tracks,
'user_id, task_id, date',
name: 'unique_user_task_date_when_task_exists',
where: 'task_id is not null',
unique: true,
algorithm: :concurrently
add_index :time_tracks,
'user_id date',
name: 'unique_user_date_when_task_not_exists',
where: 'task_id is null',
unique: true,
algorithm: :concurrently
然而,如果你需要许多列上的索引,而这些索引又是相互依赖的,你可能需要指定许多功能索引,这就使这种方法有点脆了。
现在,回到主题上。
create_or_find_by vs. find_or_create_by
当你有了唯一的索引,你就可以开始使用现代的替代方法来#find_or_create_by!-满足#create_or_find_by!按照名字,它做同样的事情,但是相反:它试图插入记录,如果遇到RecordNotUnique-加载记录(因为在这种情况下,我们确信记录存在)。注意,它在里面运行一个嵌套事务。
换句话说。
def create_or_find_by!(params)
transaction(requires_new: true) { create!(params) }
rescue RecordNotUnique
find_by(params)
end
这就是我们的代码在这种方法下的样子。
threads = []
wait_for_it = true
user = User.create
20.times do
date = Date.new(2020, 1, rand(1..4))
threads << Thread.new(date) do
true while wait_for_it
user.time_tracks.create_with(hours: 10).create_or_find_by!(date: date, task_id: 1)
end
end
wait_for_it = false
threads.each(&:join)
TimeTrack.from(
TimeTrack.group(:date, :user_id).having('COUNT(*) > 1').select(:date, :user_id)
).select('COUNT(subquery.date) AS not_unique').to_a.first.not_unique # => 0
#create_or_find_by! 在读多于写的情况下,可能会比 和 的组合慢。find_or_create_by with_lock
另外,我们可以只实现create! ,并自己拯救ActiveRecord::RecordNotUnique ,因为我们不需要锁定任何东西(我们已经有一个唯一性约束)--这样我们可以避免不必要的嵌套事务。请看完整的要点。
lookup_params = { date: date, task_id: 1 }
begin
user.time_tracks.create!(lookup_params.merge(hours: 10))
rescue ActiveRecord::RecordNotUnique
user.time_tracks.find_by(lookup_params).update(hours: 10)
end
我敢打赌,你一定很好奇应该选择哪种描述的方式,所以让我们来做个基准测试吧!
首先,让我们检查一下当我们从不插入重复的内容时会发生什么。
create! + rescue: 803.9 i/s
create_or_find_by!: 694.5 i/s - same-ish
with_lock + find_or_create_by!: 359.0 i/s - 2.24x slower
如你所料,create! 是最快的一种(因为我们从不rescue )。
第二,让我们选择在我们总是插入重复内容时使用的方法。
with_lock + find_or_create_by!: 530.3 i/s
create_or_find_by!: 525.5 i/s - same-ish
create! + rescue: 491.9 i/s - same-ish
尽管我们要花时间上锁,但find 显示了最好的结果(但与create_or_find_by! 的差别并不明显)。事实证明,首选的方式取决于你是否有唯一索引(在这种情况下,你需要一个悲观的锁),以及ActiveRecord::RecordNotUnique 实际发生的频率。
使用UPSERTs
如果数据库如此聪明,我们是否可以委托它检查记录是否存在,并在记录缺失的情况下更新它或创建它?这将让应用程序避免处理任何异常(它是隐藏的,但它们仍然在引擎盖下被捕获)。当然,我们是这样做的!有一种特殊的机制叫做UPSERT(或者更准确地说,INSERT ... ON CONFLICT ... )。Rails也通过#upsert方法支持它。
看一下吧。
threads = []
wait_for_it = true
user = User.create
20.times do
date = Date.new(2020, 1, rand(1..4))
threads << Thread.new(date) do
true while wait_for_it
user.time_tracks.upsert(
{
hours: 10,
date: date,
task_id: 1,
created_at: Time.current,
updated_at: Time.current
},
unique_by: %i[user_id task_id date]
)
end
end
wait_for_it = false
threads.each(&:join)
TimeTrack.from(
TimeTrack.group(:date, :user_id).having('COUNT(*) > 1').select(:date, :user_id)
).select('COUNT(subquery.date) AS not_unique').to_a.first.not_unique # => 0
请注意,它不创建任何ActiveRecord对象,省略所有验证和回调,并尝试直接将数据插入到表中。这种方法更快,但让我们明确地提供created_at 和updated_at 。
在后台,它制作了一个INSERT 语句,要求PostgreSQL在发生冲突时做一些事情:在我们的方案中,我们想覆盖hours 。
INSERT INTO time_tracks (hours, date, task_id, created_at, updated_at, user_id)
VALUES (10, '2020-01-03', 1, '2021-08-05 14:15:39.346889', '2021-08-05 14:15:39.346891', 1)
ON CONFLICT (user_id, task_id, date) DO
UPDATE SET
hours = excluded.hours,
created_at = excluded.created_at,
updated_at = excluded.updated_at
RETURNING id
内置的唯一性验证
如果没有特殊的验证器来确保列值在给定的范围或整个表内是唯一的,那么Rails就不是Rails了。
class TimeTrack < ActiveRecord::Base
validates :task_id, uniqueness: { scope: %i[date user_id] }
end
当验证器被调用时,它会执行数据库请求,以确保在表中已经没有这样的行。当验证通过后,ActiveRecord保存模型。这听起来是不是和#find_or_create_by ? 哦,确实如此,而且它也有同样的竞赛条件问题!
threads = []
wait_for_it = true
user = User.create
40.times do
date = Date.new(2020, 1, rand(1..4))
threads << Thread.new(date) do
true while wait_for_it
begin
user.time_tracks.create_with(hours: 10).find_or_create_by!(date: date, task_id: 1)
rescue ActiveRecord::RecordInvalid
puts 'RecordInvalid rescued!'
end
end
end
wait_for_it = false
threads.each(&:join)
TimeTrack.from(
TimeTrack.group(:date, :user_id).having('COUNT(*) > 1').select(:date, :user_id)
).select('COUNT(subquery.date) AS not_unique').to_a.first.not_unique # => not 0!
让它正常工作的唯一方法是用.with_lock 来包装每个create 、update 或save ,而我们不能在模型层面上这样做,所以整个方法听起来有点容易出错。
内置验证器还有一个问题:它不能很好地与#create_or_find_by 和#create_or_find_by! 。在尝试插入数据到表中之前,它执行了所有的验证,当然,当存在相同属性的记录时,唯一性验证失败。
结果,我们没有得到新的或现有的记录,而是得到一个无效的记录。
# inserting a first record
user.time_tracks
.create_with(hours: 10)
.create_or_find_by!(date: Date.new(2020, 1, 1), task_id: 1)
# okay, it's in the table
# trying to add a second one
user.time_tracks
.create_with(hours: 10)
.create_or_find_by!(date: Date.new(2020, 1, 1), task_id: 1)
# => Validation failed: Task has already been taken (ActiveRecord::RecordInvalid)
# trying non–bang version
user.time_tracks
.create_with(hours: 10)
.create_or_find_by(date: Date.new(2020, 1, 1), task_id: 1)
# => #<TimeTrack id: nil, user_id: 1, task_id: 1, hours: 10, date: "2020-01-01", created_at: nil, updated_at: nil>
事实证明,在这两种情况下,验证器都阻止了ActiveRecord创建记录或找到一个记录!😕
人类可读的错误呢?
了解了上面发现的所有缺点,你可能会想知道什么时候内置的唯一性验证会有帮助。有这样一种情况:当我们需要让用户知道他插入的数据无效的确切原因时。例如,当我们不希望他们意外地覆盖他们已经有的数据时,就可能发生这种情况。
像所有其他验证器一样,唯一性验证器可以添加消息到errors (有I18n和所有的铃铛和口哨)。如果我们不需要#create_or_find_by! ,对于一个特定的模型--我们可以使用内置的验证器(只是不要忘记锁!)。
另外,还有一个很好的gemdatabase_validations,它能理解数据库级别的验证,并知道如何将它们与模型errors 。
class User < ActiveRecord::Base
validates :email, db_uniqueness: true
end
总结
让我们总结一下今天学到的东西。
find_or_initialize_by,find_or_create_by和find_or_create_by!不是线程安全的,所以我们需要悲观的锁。- 数据库中的唯一索引是保持数据一致性的最好方法。
- 当唯一索引建立后,我们可以使用原子
create_or_find_by,create_or_find_by!,或find_or_create_by!与rescue ActiveRecord::RecordNotUnique。 - 内置的唯一性验证器也不是线程安全的(如果你真的需要它--使用悲观锁),而且它不能与
create_or_find_by和create_or_find_by!。 - 你可能需要使用一个验证器来告诉用户他插入的数据是无效的(而不是默默地覆盖它);在这种情况下,验证器可以提供帮助,但要考虑使用数据库_validations gem中的验证器。
谢谢你的阅读!如果你想讨论数据库问题,或者你需要招募邪恶的火星人来为你的Rails应用程序工作,请随时联系我们。