在公司,有各种 Ruby 老司机。我们都喜欢用 Ruby 来解决各种问题。比如:操作线上的数据库记录,修改 ElasticSearch 等。
描述:某个功能需要为数据库增加相关数据。大概逻辑是:根据表 Company,根据某些条件往 Department 和 DepartmentsUser 表中插入数据。 然后将 User 表中满足筛选条件的 status 字段进行更改。
结果:需要超过一个小时才将整个脚本跑完,也就是这一小时都在影响线上数据库
反思:可以更可控、稳定地写 Ruby 脚本操作线上数据库的表吗?
如果脚本可以在项目升级前运行,则可以在一定程度上更早的发现问题,以做出对正式升级时的预防。比如,操作新的表,或新的字段。这种情况是完全可以提早运行脚本的。 有的脚本是必须要升级之前运行的,由于线上代码逻辑依赖于这个脚本的数据。所以,需要在升级之前跑一下,升级之后再跑一下。这样的脚本需要写成可重复执行。 但是有的脚本是只能升级后跑的,则千万别在升级前跑
警惕长时间 commit 的操作。举个例子:比如用 update_all 或批量插入在一定程度上可以更快的运行 sql,但是,这些操作的 commit 的时间可能会很长 (如果你用的是 MySQL)。这时候用户可以进行 select 或 insert 操作,但是对 update 操作就会阻塞了。 如果时间过长则很有可能超时。这样会让用户感知到系统的延迟或崩溃。所以,不使用这种批量操作,排除 commit 长时间的操作,使用粒度小的 create,update,destroy 操作或者每次小批量的 commit 操作。如果一个 commit 很大,中间异常断了,则前面跑的 sql 语句就废弃了。而粒度小的操作则可以避免这样。虽然,总的执行时间会更长,但是,这样就不会长时间占用数据库的某个表而影响用户的一些使用。 如果真的无法避免长时间的操作,就需要在后半夜大家睡觉的时候跑脚本或者进行数据库操作了 (比如新增字段,新增索引等)。
使用异常,捕获异常错误,将异常打印出来。或者可以在全局新建一个数组,然后将某次异常的操作中相关记录的 id 存入数组中,最后打印出来,就可以知道在执行过程中哪个记录是有问题的,可以很方便的追踪错误,后续就可以 很方便的进行验证和纠错了。
尽量让脚本可以重复运行。即使加入额外的 find 或者 find_or_create_by 查询。或者捕获异常,而不是让异常中断操作。不要使用清空数据库的方式,因为这样需要从 0 条记录开始跑,所需要的时间更长。 脚本重复运行要注意的是,避免每次的重复运行增加脏数据。只有 update 操作的脚本可以重复运行,如果是 create 的操作,可以在操作前先判断一下是否存在,不存在则创建。 find_or_create_by 方法可以使用 block 方法,更方便的使用。
使用 find_each 或 find_in_batches 分批次的进行查询循环操作。注意,find_in_batches 得到的是对象数组集合。
在 Gemfile 中假如 gem 'ruby-progressbar', require: false。然后在脚本文件中 require 'ruby-progressbar' 进行使用。可以很方便的展示脚本执行的百分比和进度。
在 rails c(用的是 pry 环境) 不要跑脚本,因为脚本代码中如果有 next 方法,会使用到的是 pry 的 next 方法而不是 ruby 的 next 方法,然后你就掉坑里了这对 rails 4.0 的版本有这个问题,4.2 以上的版本修复了这个问题
代码中不要定义和数据库字段相同名称的方法,如果有重名的方法,请先确认是需要该方法还是需要的是数据库的字段。是数据库的字段请使用 read_attributes
让每个脚本只执行一个循环模块,进行一种数据的更改。尽量保持独立。如果有互相依赖的循环逻辑,再写在一个脚本中。
而不是脚本执行完就过了,而不知道线上数据到底正确不正确。所以,执行脚本其实应该分两部分。一部分是正常的更改逻辑,另一部分是验证线上数据正确与否的方案。
也许会因此会有成倍的代码量,但是,如果给线上数据带来隐患,给用户造成损失或不便,这才是更大的问题。
代码示例分析:
require 'ruby-progressbar'
progressbar = ProgressBar.create :total => Company.count, format: "%p%% [%B] %c/%C %E"
progressbar.log "= 开始初始化 ="
Company.find_each do |company|
begin
department = Department.create!(company_id: company.id, name: company.subdomain, parent_id: 0, is_default: true)
company.agents.each do |agent|
begin
DepartmentsUser.create!(user_id: agent.id, department_id: department.id)
rescue => e
puts e
next
end
end
rescue => e
puts e
next
end
progressbar.increment
end
progressbar.log "=== 初始化完毕 ==="
User.where(p: ['A','UG']).update_all(status: true)
require 'ruby-progressbar'
progressbar = ProgressBar.create :total => Company.count, format: "%p%% [%B] %c/%C %E"
progressbar.log "= 开始初始化 ="
error_user = []
error_company = []
update_user_error = []
Company.find_each do |company|
progressbar.increment
begin
department = Department.find_or_create_by!(company_id: company.id, name: company.subdomain, parent_id: 0, is_default: true)
company.users.each do |user|
begin
DepartmentsUser.find_or_create_by!(user_id: user.id, department_id: department.id)
rescue => e
puts e
error_user << user.id
next
end
end
rescue => e
puts e
error_company << company.id
next
end
end
User.where(p: ['A','UG']).find_each do |user|
begin
user.update!(status: true)
rescue => e
puts e
update_user_error << user.id
end
end
progressbar.log "Company count: #{Company.count} -- Department count: #{Department.count} -- DepartmentsUser count: #{DepartmentsUser.count} User count: #{User.count}"
progressbar.log "error: error_user: #{error_user}; error_company: #{error_company}"
progressbar.log "=== 初始化完毕 ==="
执行: bundle exec rails runner -e development init_department.rb
= 开始初始化 =
100% [======================================================================================================================================================================] xxx/xxx Time: 00:00:00
Company count: xxx -- Department count: xxx -- DepartmentsUser count: xxx User count: xxx
error: error_user: []; error_company: []
=== 初始化完毕 ===
本文提供的几个思路其实都不难,只是往往我们都忽略了。
无论是写 rails 脚本,rake 脚本,复杂的 migration 或者其他语言的脚本操作线上的数据库,都应该思考如何对线上的数据库影响最小,对用户的使用影响最小,以及最终的结果是否正确 也许在本地或测试环境你的脚本运行的没有问题,但是,线上的数据数量往往是本地和测试环境的 n 倍,并且已经有很多隐藏的脏数据,各种奇妙的事情都有可能发生。即使是在半夜运行脚本, 能够预先做更多的准备对应出现的异常情况,也能更快的找到问题的所在。