基本上在商業應用中,我們會盡可能的避免停機操作,試想光 CloudFlare 因為 bad deploy 當機幾分鐘至一個小時就會引發一連串的災難,在商場上每分每秒都是錢,為了避免不必要的損失,軟體工程常常會面臨到所謂的 zero downtime 操作。
但像資料庫的結構設計不可能一開始就符合大型架構,所以這過程通常都是不停的升級遷移,才有了符合現在設計的樣貌,所以不可避免的停機還是會發生的,通常幾個原因
- 應用程式的 code 不能同時兼容 migration 前 / 後的資料庫
- 因為資料量大,在跑 migration 時造成的長時間鎖表
讓程式碼能夠對應 migration 前 / 後的資料庫
通常步驟會是分好幾次部署,確保是兼容運行
- 讓程式碼能夠同時對應新的作法及舊的作法
- 進行 migrate
- 刪除為了向下兼容的作法
舉例來說,要刪除 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 整理,可以看文章底部的參考來源)
Scenarios | MySQL 5.5 | MySQL 5.6 | PostgreSQL 9.3 | PostgreSQL 9.4 |
---|---|---|---|---|
Adding a non-nullable column | Read-Only | Non-Blocking | Blocking | Blocking |
Adding a nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Renaming a non-nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Renaming a nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Dropping a non-nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Dropping a nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Modifying the data type of a non-nullable column | N/A | N/A | Non-Blocking | Non-Blocking |
Modifying the data type of a nullable column | N/A | N/A | Non-Blocking | Non-Blocking |
Modifying the data type of a non-nullable column from integer to text | N/A | N/A | Blocking | Blocking |
Making a non-nullable column nullable | Read-Only | Read-Only | Non-Blocking | Non-Blocking |
Making a nullable column non-nullable | Read-Only | Read-Only | Blocking | Blocking |
Modifying the default value of a non-nullable column | Non-Blocking | Non-Blocking | Non-Blocking | Non-Blocking |
Modifying the default value of a nullable column | Non-Blocking | Non-Blocking | Non-Blocking | Non-Blocking |
Creating a foreign key constraint on a non-nullable column | Read-Only | Read-Only | Blocking | Blocking |
Creating a foreign key constraint on a nullable column | Read-Only | Read-Only | Blocking | Blocking |
Creating an index on an existing non-nullable column | Read-Only | Non-Blocking | Non-Blocking | Non-Blocking |
Renaming an existing index | N/A | N/A | Non-Blocking | Blocking |
Dropping an existing index | Blocking | Non-Blocking | Non-Blocking | Non-Blocking |
Renaming an existing table | Non-Blocking | Non-Blocking | Non-Blocking | Non-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 會影響資料庫底層結構,所以通常都是鎖表,可以用比較安全的步驟進行更改
- 建立新的 column
- 將 code 設置同時寫新舊欄位
- 寫 task 把舊資料填到新欄位
- 開始用新的 column 讀資料
- 停止寫舊 column
- 完整的移除舊欄位
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 內
- migration 單獨跑 add_column
- 修改資料的請另外單獨寫一次性的 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
參考資源
- Paper: Zero-Downtime SQL Database Schema Evolution for Continuous Deployment
好文轉自作者Nic Lin’s Blog–喜歡在地上打滾的 Rails Developer如果你喜歡他的文章,歡迎回到他的部落格觀看更多:) - 01/02 晚上由他擔任分享者的小聚活動,已經進入報名倒數階段,活動資訊請看下方連結:
學程式主題小聚 x Devstars Lighthouse|區塊鏈懂不懂|Nic