Nic Lin's Blog

喜歡在地上滾的工程師

不停機 migration 避免鎖表的幾種操作

基本上在商業應用中,我們會盡可能的避免停機操作,試想光 CloudFlare 因為 bad deploy 當機幾分鐘至一個小時就會引發一連串的災難,在商場上每分每秒都是錢,為了避免不必要的損失,軟體工程常常會面臨到所謂的 zero downtime 操作。

但像資料庫的結構設計不可能一開始就符合大型架構,所以這過程通常都是不停的升級遷移,才有了符合現在設計的樣貌,所以不可避免的停機還是會發生的,通常幾個原因

  1. 應用程式的 code 不能同時兼容 migration 前 / 後的資料庫
  2. 因為資料量大,在跑 migration 時造成的長時間鎖表

讓程式碼能夠對應 migration 前 / 後的資料庫

通常步驟會是分好幾次部署,確保是兼容運行

  1. 讓程式碼能夠同時對應新的作法及舊的作法
  2. 進行 migrate
  3. 刪除為了向下兼容的作法

舉例來說,要刪除 table 中的 column 時

class RemoveStatusFromUsers < ActiveRecord::Migration
  def change
    remove_column :users, :status, :string
  end
end

在刪除前通常會確定 code 裡面已經沒有用到,是個作廢的欄位,但要注意的是 ActiveRecord 會在 rails 啟動時先緩存所有的 column,也就是說

當你正在刪除 column 時,如果有人要執行類似 user.save ,其實是會噴錯的

ERROR: column "status" does not exist

但通常跑完 migration 時也會重新啟動 rails server 所以這問題比較少發生,但如果是併發比較高的應用,可能要避免這種事情發生

可以先到 User model 裡面加上

# For Rails 5+
class User < ApplicationRecord
  self.ignored_columns = ["status"]
end

# For Rails < 5
class User < ActiveRecord::Base
  def self.columns
    super.reject { |c| c.name == "status" }
  end
end

這時候在去跑 migration,就可以避免上述的情況發生,然後再確保 migration 順利運行完後,將這個為了向下兼容的 code 刪除,就完成了 zero downtime migration 的操作了

跑 migration 時造成的長時間鎖表

先來看一下常見的 19 種操作(來自 2015 的 paper 整理,可以看文章底部的參考來源)

ScenariosMySQL 5.5MySQL 5.6PostgreSQL 9.3PostgreSQL 9.4
Adding a non-nullable columnRead-OnlyNon-BlockingBlockingBlocking
Adding a nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Renaming a non-nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Renaming a nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Dropping a non-nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Dropping a nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Modifying the data type of a non-nullable columnN/AN/ANon-BlockingNon-Blocking
Modifying the data type of a nullable columnN/AN/ANon-BlockingNon-Blocking
Modifying the data type of a non-nullable column from integer to textN/AN/ABlockingBlocking
Making a non-nullable column nullableRead-OnlyRead-OnlyNon-BlockingNon-Blocking
Making a nullable column non-nullableRead-OnlyRead-OnlyBlockingBlocking
Modifying the default value of a non-nullable columnNon-BlockingNon-BlockingNon-BlockingNon-Blocking
Modifying the default value of a nullable columnNon-BlockingNon-BlockingNon-BlockingNon-Blocking
Creating a foreign key constraint on a non-nullable columnRead-OnlyRead-OnlyBlockingBlocking
Creating a foreign key constraint on a nullable columnRead-OnlyRead-OnlyBlockingBlocking
Creating an index on an existing non-nullable columnRead-OnlyNon-BlockingNon-BlockingNon-Blocking
Renaming an existing indexN/AN/ANon-BlockingBlocking
Dropping an existing indexBlockingNon-BlockingNon-BlockingNon-Blocking
Renaming an existing tableNon-BlockingNon-BlockingNon-BlockingNon-Blocking

以 PostgreSQL 來說,常見的鎖表操作

  • 增加 not null column 或是 default 值不為空的 column
  • 改變 column type
  • 增加或是重命名 index
  • 增加 foreign key / 限制

增加 not null column 或是 default 值不為空的 column

如果直接新增一個欄位在數據量較大的表上,會造成鎖表

add_column :users, :job_title, :string, default: "rails_developer"

所以我們可以拆幾個步驟去做

先增加 column,不要設置 default 值,再去設置 default 值可以避免鎖表

add_column :users, :job_title, :string
change_column_default :users, :job_title, "rails_developer"

但這裡要注意的是,如果資料庫裡面已經存在沒有 default 值的資料,需要額外寫一個 rake task 去補回

# Rails 5+
User.in_batches.where(job_title: nil).update_all(job_title: "rails_developer")

改變 column type

改變 type 會影響資料庫底層結構,所以通常都是鎖表,可以用比較安全的步驟進行更改

  1. 建立新的 column
  2. 將 code 設置同時寫新舊欄位
  3. 寫 task 把舊資料填到新欄位
  4. 開始用新的 column 讀資料
  5. 停止寫舊 column
  6. 完整的移除舊欄位

index 鎖表相關

打 index 請參考我之前寫過的另一篇文章 如何快速的對大資料量建立索引,避免 Downtime

避免 transaction 包在 migration 內

有些人習慣會把 migration 和 patch 資料一起做,例如

class AddPublishedToPosts < ActiveRecord::Migration
  def change
    add_column :posts, :published, :boolean
    Post.unscoped.update_all(published: true)
  end
end

其實比較建議不要放在同一個 transaction 內

  1. migration 單獨跑 add_column
  2. 修改資料的請另外單獨寫一次性的 task
# migration
# db/migrate/xxxxxxxxx_add_published_to_posts
class AddPublishedToPosts < ActiveRecord::Migration
  def up
    add_column :posts, :published, :boolean
  end
end

# task
# lib/tasks/post.rake
namespace :post do
  task patch_post_published_column: :environment do
    Post.unscoped.update_all(published: true)
  end
end

小結

通常只修改幾個欄位大概都還可以用 zero downtime migration 的招去做

但如果是

  • 數據量龐大,只要鎖表就超久
  • 大量的欄位結構更改

建議還是有完整的計畫直接停機做比較好,畢竟 zero downtime 的作法通常都是 拆好幾個步驟去實現,當步驟一多就增加出錯的風險

這裡可以參考當初 airbnb 進行停機 migrate 的準備和作法 How We Partitioned Airbnb’s Main Database in Two Weeks

參考資源

comments powered by Disqus