# Secret store 與資料庫的寫入順序：失敗後留下什麼、怎麼接著重試

> Secret store 與資料庫若沒有共同交易，寫入順序會影響失敗後留下的狀態。本文用 SQLite 示範覆寫風險、pending 與重試，並說明這個範例為何無法使用 2PC。

原文：https://sheng.page/posts/secret-store-and-database-write-ordering/ · 發布：2026-11-02 · 作者：Sheng

以建立租戶為例，假設操作要寫入一筆資料庫資料列，並將租戶的簽章金鑰存進 secret store。若兩邊各自 commit，沒有共同交易，就可能出現一邊成功、另一邊失敗的狀態。寫入順序會影響失敗波及哪些資料，但仍得另外設計狀態辨認與重試方式。

這篇只談這兩個儲存體的配合。簽章本身要簽哪些欄位、金鑰怎麼輪替，〈[HMAC 請求簽章該簽哪些東西](/posts/what-to-sign-in-hmac-requests/)〉談過，這裡不重複。

## 兩種順序各自留下的狀態

範例把建立租戶拆成幾個步驟：取得 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。

```python
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())
```

```text
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 根本沒被碰到。

```python
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())
```

```text
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 模式。

```python
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])
```

```text
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 失敗，然後重送。

```python
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:  ")
```

```text
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，仍需驗證請求歸屬與並行重試是否安全，一般原則在〈[冪等與重送](/posts/idempotency-and-retries/)〉，這裡不展開。

這個範例還有兩個刻意省掉的地方。第一，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 同時呼叫不會選到同一個檔名。

```go
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))
}
```

```text
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](https://en.wikipedia.org/wiki/Two-phase_commit_protocol)〉：協定的投票與 commit 兩段流程，以及 blocking 的說明，本文的 2PC 背景來自這裡。
- PostgreSQL 17 文件〈[Two-Phase Transactions](https://www.postgresql.org/docs/17/two-phase.html)〉：資料庫端的 2PC 支援，三個指令與 prepared 狀態的儲存方式，官方文件，本文只引用不實跑。
- PostgreSQL 17 文件〈[PREPARE TRANSACTION](https://www.postgresql.org/docs/17/sql-prepare-transaction.html)〉：指令說明，以及用途限於外部 transaction manager 和 prepared 交易放太久的警告，本文關於用途限制的說法出自這裡。
- Go 文件〈[os.Rename](https://pkg.go.dev/os#Rename)〉：rename 的替換語意與非 Unix 平台不保證原子的限制；同頁的 `os.CreateTemp` 說明唯一檔名的保證。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 題目由 Sheng 選定，內文、案例整理與範例由 Claude 撰寫，範例在 Linux 上以 Python 3.13.18 與 Go 1.24.7 實測。