Secret store 與資料庫的寫入順序:失敗後留下什麼、怎麼接著重試
以建立租戶為例,假設操作要寫入一筆資料庫資料列,並將租戶的簽章金鑰存進 secret store。若兩邊各自 commit,沒有共同交易,就可能出現一邊成功、另一邊失敗的狀態。寫入順序會影響失敗波及哪些資料,但仍得另外設計狀態辨認與重試方式。
這篇只談這兩個儲存體的配合。簽章本身要簽哪些欄位、金鑰怎麼輪替,〈HMAC 請求簽章該簽哪些東西〉談過,這裡不重複。
兩種順序各自留下的狀態
範例把建立租戶拆成幾個步驟:取得 API key,以它為唯一鍵插入資料列,再把對應的 secret 寫進 secret store。資料庫用 unique constraint 擋重複 key;這裡假設 secret store 會直接覆寫,不提供「已存在就拒絕」的語意。其他 store 是否如此,需驗證。
順序一:先寫 secret,再插入資料列。如果 API key 已經有別的租戶在用,INSERT 會被 unique constraint 擋下,但 secret store 這時已經寫過了,既有租戶的 secret 被換成新的值。下面用 Python 標準函式庫的 sqlite3 當資料庫、一個 dict 當 secret store 重現,store 用 dict 是刻意的,這個範例沒有替 dict 實作 rollback,用來模擬寫入後無法隨資料庫一起回復的 store。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE tenants (api_key TEXT PRIMARY KEY, name TEXT)")
secrets = {} # 檔案型 secret store 的替身:沒有 transaction,寫了就是寫了
def create_tenant_secret_first(api_key, name, secret):
secrets[api_key] = secret # 第一步:先寫 secret
db.execute("INSERT INTO tenants VALUES (?, ?)", (api_key, name))
db.commit() # 第二步:再插入資料列
create_tenant_secret_first("key-A", "tenant-1", "s3cret-of-tenant-1")
print("before:", secrets["key-A"])
try:
create_tenant_secret_first("key-A", "tenant-2", "s3cret-of-tenant-2")
except sqlite3.IntegrityError as e:
print("insert failed:", e)
print("after: ", secrets["key-A"])
print("rows: ", db.execute("SELECT * FROM tenants").fetchall())
before: s3cret-of-tenant-1
insert failed: UNIQUE constraint failed: tenants.api_key
after: s3cret-of-tenant-2
rows: [('key-A', 'tenant-1')]
資料列仍屬於 tenant-1,secret 卻已換成 tenant-2 的值。假設 API 把 unique 衝突回成 409,呼叫端只會看到建立失敗,未必知道 tenant-1 的金鑰已改變。tenant-1 何時開始驗章失敗,還得看驗章端何時讀到新值;若有快取或舊金鑰寬限期,錯誤可能更晚才出現,也更難和這次建立操作連起來。
順序二:先插入資料列,commit 之後才寫 secret。重複的 key 在第一步就被擋下,secret store 根本沒被碰到。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE tenants (api_key TEXT PRIMARY KEY, name TEXT)")
secrets = {}
def create_tenant_commit_first(api_key, name, secret):
db.execute("INSERT INTO tenants VALUES (?, ?)", (api_key, name))
db.commit() # commit 回來才算資料列存在
secrets[api_key] = secret # 之後才寫 secret
create_tenant_commit_first("key-A", "tenant-1", "s3cret-of-tenant-1")
print("before:", secrets["key-A"])
try:
create_tenant_commit_first("key-A", "tenant-2", "s3cret-of-tenant-2")
except sqlite3.IntegrityError as e:
print("insert failed:", e)
print("after: ", secrets["key-A"])
print("rows: ", db.execute("SELECT * FROM tenants").fetchall())
before: s3cret-of-tenant-1
insert failed: UNIQUE constraint failed: tenants.api_key
after: s3cret-of-tenant-1
rows: [('key-A', 'tenant-1')]
在這個範例裡,重複 key 不再覆寫既有租戶的 secret,但資料列 commit 後,store 仍可能寫入失敗,留下沒有 secret 的新租戶。若系統能辨認並處理這筆未完成資料,這個順序較容易控制影響範圍;它仍需要後續處理,不能只把兩行程式交換就算完成。
commit 邊界比順序更早要確定
「先插入再寫 secret」裡的「插入成功」要說清楚是哪一種成功。autocommit 模式下,要確認 INSERT 已完成且成功提交;放在 transaction 裡的 INSERT 回來只代表這個 statement 沒被擋,後面的 COMMIT 還是可能失敗。secret 要在確認 commit 成功之後才寫,寫在 INSERT 之後、COMMIT 之前,等於又回到順序一的狀態。
SQLite 在 rollback journal 模式下可以示範「INSERT 成功但 COMMIT 失敗」:另一個連線正在讀同一個檔、讀到一半不放,寫入端的 INSERT 照樣成功,COMMIT 要升級成 exclusive lock 時拿不到,等到逾時就失敗。這個示範依賴該鎖定模式,不能直接套到 WAL 模式。
import sqlite3, tempfile, os
path = os.path.join(tempfile.mkdtemp(), "tenants.db")
setup = sqlite3.connect(path)
setup.execute("CREATE TABLE tenants (api_key TEXT PRIMARY KEY, name TEXT)")
setup.executemany("INSERT INTO tenants VALUES (?, 'seed')", [(f"key-{i}",) for i in range(100)])
setup.commit()
reader = sqlite3.connect(path, isolation_level=None)
cursor = reader.execute("SELECT * FROM tenants") # 讀取中,持有 SHARED lock
cursor.fetchone() # 故意不讀完,鎖不放
writer = sqlite3.connect(path, timeout=0.2)
writer.execute("INSERT INTO tenants VALUES ('key-A', 'tenant-1')")
print("INSERT inside transaction: ok")
try:
writer.commit()
print("commit: ok")
except sqlite3.OperationalError as e:
print("commit failed:", e)
writer.rollback()
cursor.close()
print("rows with key-A:", sqlite3.connect(path).execute(
"SELECT count(*) FROM tenants WHERE api_key = 'key-A'").fetchone()[0])
INSERT inside transaction: ok
commit failed: database is locked
rows with key-A: 0
PostgreSQL 也可能因 deferred constraint 或 serialization failure 而無法 commit;若連線在 COMMIT 當下斷掉,提交結果還可能不明,需驗證。這些情況都不能只憑 INSERT 成功,就認定資料已提交。
設計時可以逐步列出副作用,以及每一步是否還能 rollback。這個範例的資料庫寫入在 COMMIT 前可以回復,secret store 則沒有參與同一筆交易,寫回舊值也可能失敗。因此先確認資料庫 commit 成功,再寫 secret,有助於避開前面的覆寫問題;其他 store 的補償能力與操作語意仍需驗證。
失敗狀態怎麼辨認、怎麼接著做
順序二留下的新問題是「資料列存在、secret 沒寫成」。要處理它,資料列得先能說出自己在哪個階段。做法是給租戶一個建立狀態,插入時是 pending,secret 寫成之後才改成 active,驗章路徑只認 active 的租戶。沒有 secret 的租戶驗章本來就會失敗,加上狀態之後,pending 的租戶拿到的會是明確的「尚未啟用」,排查時一眼就看得出和簽章錯誤無關。
有了狀態,還要能確認是同一個建立請求,重試才有依據。同一個建立請求重送時,先查資料列:不存在就插入;存在且 pending 就跳過插入,直接做缺的那一步;存在且 active 就不碰,那筆資料已經完成,可能是前一次重試完成的,也可能根本是另一個請求建立的。下面只模擬同一請求依序重試的狀態變化,未實作歸屬驗證或並行重試協調,不可直接作為安全的租戶建立實作。範例讓第一次寫 secret 失敗,然後重送。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE tenants (api_key TEXT PRIMARY KEY, name TEXT, status TEXT)")
secrets = {}
fail_next_secret_write = True
def put_secret(api_key, secret):
global fail_next_secret_write
if fail_next_secret_write:
fail_next_secret_write = False
raise OSError("secret store unavailable")
secrets[api_key] = secret
def create_tenant(api_key, name, secret):
row = db.execute("SELECT status FROM tenants WHERE api_key = ?", (api_key,)).fetchone()
if row is None:
db.execute("INSERT INTO tenants VALUES (?, ?, 'pending')", (api_key, name))
db.commit()
elif row[0] == "active":
return "already active" # 不是本次建立留下的,不碰
put_secret(api_key, secret) # 從缺的那一步接著做
db.execute("UPDATE tenants SET status = 'active' WHERE api_key = ?", (api_key,))
db.commit()
return "active"
def dump(label):
print(label, db.execute("SELECT api_key, status FROM tenants").fetchall(), secrets)
try:
create_tenant("key-A", "tenant-1", "s3cret-of-tenant-1")
except OSError as e:
print("first attempt failed:", e)
dump("after failure:")
print("retry:", create_tenant("key-A", "tenant-1", "s3cret-of-tenant-1"))
dump("after retry: ")
print("again:", create_tenant("key-A", "tenant-1", "other"))
dump("after again: ")
first attempt failed: secret store unavailable
after failure: [('key-A', 'pending')] {}
retry: active
after retry: [('key-A', 'active')] {'key-A': 's3cret-of-tenant-1'}
again: already active
after again: [('key-A', 'active')] {'key-A': 's3cret-of-tenant-1'}
第一次失敗後留下 pending 的列和空的 store,重試只補了 secret 和狀態,沒有再插入一次。第三次呼叫帶了不同的 secret,因為資料列已經 active,整個操作直接結束,store 裡的值沒被改掉。這個例子把「同一筆」簡化成 API key 相同,還不足以保證重送請求屬於同一次建立。若採用呼叫端帶的 idempotency key,仍需驗證請求歸屬與並行重試是否安全,一般原則在〈冪等與重送〉,這裡不展開。
這個範例還有兩個刻意省掉的地方。第一,SELECT 之後再 INSERT 有競爭,兩個請求同時建立同一個 key 時,其中一個的 INSERT 會撞 unique constraint,那個錯誤要接住,再回頭讀狀態,還得確認是否能由這個請求接續,不能只憑「已存在」就繼續寫。第二,範例裡 pending 的列不會自動清掉;如果要清,補償刪除前得確認那筆資料屬於本次建立,而且還在 pending,不然會刪到另一個請求剛完成的租戶。同步做補償還是交給背景工作定期掃,取決於 pending 的列留著有沒有害,驗章路徑拒絕 pending 只是其中一個條件,保留它是否影響其他流程仍需驗證。
two-phase commit 在這裡做不到的地方
兩個儲存體要一起 commit,教科書答案是 two-phase commit。協定分兩段:coordinator 先問每個參與者能不能 commit,參與者把要 commit 或 undo 的資訊寫到 log 之後投票;全部投 yes,coordinator 才下令 commit,否則下令 abort。Wikipedia 的條目把它最大的缺點寫得很直接,它是個 blocking protocol,已經投了 yes 的參與者在收到最終決策之前只能等,若 coordinator 永久失效,且無法取得最終決策,參與者就可能一直等待。
這裡的前提問題比缺點更早出現。2PC 要求每個參與者都能持久保存 prepared 狀態,並且在之後接受 commit 或 abort 的指令。PostgreSQL 這端做得到,PREPARE TRANSACTION 之後交易的狀態完整存在磁碟上,COMMIT PREPARED 或 ROLLBACK PREPARED 可以從任何 session 下。但官方文件同時寫明,這組指令的用途是讓外部的 transaction manager 做跨資源的原子交易,應用程式和互動 session 不該直接用它,而且 prepared 的交易放太久會妨礙 VACUUM 回收空間,極端情況下會讓資料庫為了避免 transaction ID wraparound 而停機。本文假設的檔案型 secret store 沒有 prepared 狀態,一寫就生效,它根本沒辦法參與協定,只有資料庫一方支援 2PC,無法保證這兩次寫入的跨儲存體原子性。
在這個範例的限制下,可以採用順序加補償,但保證比 2PC 弱:中間狀態是看得到的,pending 的列會存在一段時間,補償本身也可能失敗。換來的是不需要一個能參與協定的 secret store,也不需要處理 prepared 交易留在資料庫裡的那些後果。這個取捨適不適合,取決於 pending 狀態對系統其他部分有沒有害,如果驗章路徑拒絕 pending 的租戶,列表頁也不會把它當成正常租戶顯示,保留它的影響可能較小,仍需驗證其他流程是否也正確處理這個狀態。
附註:檔案型 store 的暫存檔與 rename
檔案型 secret store 常見的寫法是先寫暫存檔,再 rename 成目標檔,若執行環境保證原子替換,讀取端才會只看到舊的完整內容或新的完整內容。這個做法本身有個容易踩到的坑:如果主服務和背景 worker 都會寫這個檔,而兩邊用同一個固定的暫存檔名,同時啟動時就會互相覆寫或刪掉對方的暫存檔。修法是暫存檔用唯一名稱,Go 的 os.CreateTemp 會依 pattern 產生含隨機字串的檔名,文件保證多個程式或 goroutine 同時呼叫不會選到同一個檔名。
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
)
// 各自用 os.CreateTemp 拿唯一的暫存檔名,寫完再 rename 到目標。
func writeAtomically(dir, target, content string) (string, error) {
tmp, err := os.CreateTemp(dir, ".secrets-*.tmp")
if err != nil {
return "", err
}
if _, err := tmp.WriteString(content); err != nil {
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
return tmp.Name(), os.Rename(tmp.Name(), filepath.Join(dir, target))
}
func main() {
dir, _ := os.MkdirTemp("", "store")
var wg sync.WaitGroup
for _, who := range []string{"main-service", "background-worker"} {
wg.Add(1)
go func(who string) {
defer wg.Done()
name, err := writeAtomically(dir, "secrets.json", `{"writer":"`+who+`"}`)
fmt.Printf("%-17s tmp=%s err=%v\n", who, filepath.Base(name), err)
}(who)
}
wg.Wait()
data, _ := os.ReadFile(filepath.Join(dir, "secrets.json"))
left, _ := filepath.Glob(filepath.Join(dir, ".secrets-*.tmp"))
fmt.Printf("final content: %s\nleftover temp files: %d\n", data, len(left))
}
background-worker tmp=.secrets-3390068016.tmp err=<nil>
main-service tmp=.secrets-1248894322.tmp err=<nil>
final content: {"writer":"main-service"}
leftover temp files: 0
兩個 goroutine 拿到不同的暫存檔名,兩次 rename 都成功,目標檔是其中一方的完整內容,沒有殘留的暫存檔。哪一方最後寫入,以及隨機字串的內容,都可能隨執行而變。
這段的範圍要講清楚。os.Rename 的文件說目標檔已存在且不是目錄時會替換它,但也說在非 Unix 平台上,即使在同一個目錄,rename 也不保證原子。原子替換不保證斷電後持久,fsync 的適用範圍與平台條件仍需驗證,範例也未實作持久化或錯誤路徑的完整清理。原子替換也不等於讀改寫有鎖,兩個寫入者各自讀舊檔、各自改、各自 rename,後 rename 的會把前一個的修改整個蓋掉,上面的輸出只示範兩邊寫入整份內容,沒有重現讀改寫遺失更新的流程。唯一的暫存檔名只解決路徑碰撞這一件事,多個寫入者會遺失更新的問題要靠鎖或單一寫入者解決,例如規定這種檔只由主服務產生。
補充筆記
- 在本文的覆寫式 store 範例中,先寫 secret 可能改掉既有租戶的金鑰,先 commit 資料列則可能留下未完成的新租戶。
- secret 要在確認資料庫 COMMIT 成功之後才寫,transaction 內 INSERT 成功不算,此處 SQLite 的 rollback journal 示範用持續持鎖的 reader 讓 COMMIT 逾時失敗。
- 這個範例先完成可 rollback 的資料庫操作,再寫入無法隨交易回復的 store,其他操作仍要檢查各自的語意。
- 資料列要有 pending 這類建立狀態,驗章路徑只認 active;重試先確認請求歸屬與狀態,再從缺的步驟接續,已 active 的不碰。
- 2PC 需要兩邊都能持久保存 prepared 狀態,本文的檔案型 store 未提供這項能力;PostgreSQL 文件也明說
PREPARE TRANSACTION是給外部 transaction manager 用的。 - 檔案型 store 的暫存檔用
os.CreateTemp取唯一名稱,但這只解決路徑碰撞,不解決多個寫入者遺失更新,os.Rename在非 Unix 平台不保證原子性。
延伸閱讀
- Wikipedia〈Two-phase commit protocol〉:協定的投票與 commit 兩段流程,以及 blocking 的說明,本文的 2PC 背景來自這裡。
- PostgreSQL 17 文件〈Two-Phase Transactions〉:資料庫端的 2PC 支援,三個指令與 prepared 狀態的儲存方式,官方文件,本文只引用不實跑。
- PostgreSQL 17 文件〈PREPARE TRANSACTION〉:指令說明,以及用途限於外部 transaction manager 和 prepared 交易放太久的警告,本文關於用途限制的說法出自這裡。
- Go 文件〈os.Rename〉:rename 的替換語意與非 Unix 平台不保證原子的限制;同頁的
os.CreateTemp說明唯一檔名的保證。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 題目由 Sheng 選定,內文、案例整理與範例由 Claude 撰寫,範例在 Linux 上以 Python 3.13.18 與 Go 1.24.7 實測。