現在 code 都讓 AI 寫了,這種判斷它靠得住嗎?
你在資料庫改了一筆設定——權限、feature flag、某個閾值——接著要決定:改完到底要不要重啟服務才生效?
放在以前,你會憑經驗猜:保守派一律重啟(常常多此一舉、白白製造一次中斷),樂觀派賭它即時生效(結果改半天前端還吃舊行為,debug 一小時才發現要重啟)。但現在多了一個選項,也是大多數人實際在做的——直接問 AI。於是真正的問題變成:這種「要不要重啟」的判斷,AI 答得對嗎?
AI 會怎麼判斷——而且為什麼常常自信地答錯
先說結論:這題剛好是 AI 最容易出錯的類型,而且會錯得很有自信。
AI 寫 code 的本質,是從訓練資料的 pattern 推測最可能的答案。而「改設定要重啟」在無數系統裡都成立,於是它傾向很篤定地叫你重新部署,卻沒去確認你眼前這份程式碼到底是 live 讀還是快取。版本差異更是重災區——同一個框架舊版快取、新版改成 live 讀,AI 常常混為一談,給你一個「看起來很對」的錯答案。
問題的本質其實沒變,只是換了位置:從前要自己判斷,現在要判斷「AI 的判斷對不對」。而要驗證它、或反過來把它導對,你得先有一把尺——一條不靠經驗、可以拿原始碼直接對答案的準則。
那把尺:即時讀,還是啟動時快取?
決定「改完要不要重啟」的,從來不是設定存在哪裡,而是程式什麼時候去讀它:
| 讀取時機 | 改完是否即時生效 | 典型例子 |
|---|---|---|
| 每個請求即時讀(per-request live read) | ✅ 即時,不必重啟 | 每次查 DB 的權限、即時拉的 feature flag |
| 啟動時載入記憶體後快取(boot / in-memory cache) | ❌ 必須重啟或等過期 | bootstrap 載入的設定、process 內的計數器 |
換句話說:改 DB 要不要重啟,等於問「這個值是 live 讀還是被快取了」。把這句話記住,後面所有案例都是它的推論。
麻煩的是,框架很少在文件裡明講某個值屬於哪一類,AI 也答不準。所以正確的態度不是查文件、更不是憑經驗猜,而是翻原始碼,看那個值是在「請求路徑」上被讀,還是在「啟動路徑」上被讀。下面用一正一反兩個真實案例,把這把尺磨利。
實例一:Strapi 權限「改 DB 即刻生效」
需求很單純:把兩個 content-type(就叫它 article 與 category)的 CUD(Create / Update / Delete,建立/更新/刪除)權限,授給幾個自訂角色。Strapi(一個 Node.js 的 headless CMS)的權限存在資料庫的 up_permissions 表,所以最直接的做法就是下 SQL 把對應的 grant 寫進去。
問題來了——寫完 SQL,到底要不要 rollout 重啟 pod?團隊過去的習慣是「權限變更都跟著部署一起上」,於是大家都默認「改權限要重啟才生效」。但這個默認從來沒人驗證過,它只是「剛好都跟部署一起發生」造成的錯覺。
為什麼即刻生效——翻原始碼,不要猜
Strapi 的 users-permissions 外掛在每個請求進來時,會跑一段認證流程去算這個使用者「能做什麼」。順著 auth strategy 追下去,核心就兩行:
// @strapi/plugin-users-permissions —— 每個 request 的認證流程裡呼叫
async findRolePermissions(roleID) {
// load() 直接打 DB 撈出該 role 的 permissions 關聯
return strapi.db.query('plugin::users-permissions.role').load({ id: roleID }, 'permissions');
}
關鍵在 strapi.db.query(...).load(...):它是每個請求都即時打資料庫撈出該角色的權限,再當場生成這次請求的 ability(能力集)。整條路徑上沒有任何 role → 權限的記憶體快取。
這就回答了那個猶豫:SQL 一 commit,下一個請求就會讀到新權限。不必重啟,即刻生效。 過去「跟部署一起上」純粹是時機巧合,不是必要條件。
這裡要小心一個常見的誤解:Strapi 在啟動時確實有快取一份東西——但快取的是「系統裡總共有哪些 action 存在」(action registry),不是「哪個角色被授予了哪個 action」。前者是程式碼結構(新增 route/controller 才會變,那才需要部署);後者只是權限表裡的一筆資料——哪個角色被授予哪個 action——改它只要動一筆 SQL、不必碰程式碼,而且每次請求都 live 查。分清楚這兩層,才不會把「加新功能要部署」錯記成「加權限要重啟」。
衍生的安全做法:surgical SQL 優於破壞性 import
確認了 live 讀之後,還有一個做法上的選擇。許多 config-sync 類的工具提供「把設定檔同步回 DB」的 import 指令,但它通常是破壞性的:它以設定檔為準,會把「DB 有、檔案沒有」的權限直接刪掉。只要有人曾在後台手動加過權限而沒同步回檔案,一次 import 就會把它清掉。
所以更穩的是用一段冪等的 surgical SQL只加不刪:
-- 只在「該角色尚未擁有此 action」時才插入;重跑也不會重複
INSERT INTO up_permissions (document_id, action, created_at, updated_at, published_at)
SELECT v.doc_id, v.action, now(), now(), now()
FROM (VALUES (1001, 'api::article.article.create', 'fakedoc0001')) AS v(role_id, action, doc_id)
WHERE NOT EXISTS (
SELECT 1 FROM up_permissions p
JOIN up_permissions_role_lnk l ON l.permission_id = p.id
WHERE p.action = v.action AND l.role_id = v.role_id
);
冪等(idempotent,跑幾次結果一樣)讓你敢在不同環境重複執行;WHERE NOT EXISTS 讓它對既有權限毫無破壞,是 additive-safe(只加不刪)的。配合「live 讀、免重啟」的特性,整個授權變成一個低風險、可重跑、即時生效的操作。
一個務實的雷:同一份權限在不同環境的
role_id往往不一樣(例如測試環境某角色是7、正式環境是5)。surgical SQL 千萬別寫死 ID,跑之前先SELECT id, name FROM up_roles確認。
實例二(反例):OTP 速率限制「非重啟不可」
同一個系統,另一個功能正好是反例。使用者啟用 TOTP(Time-based One-Time Password,時間基礎一次性密碼)時連續輸錯,畫面跳出「嘗試太多次,請稍後再試」。要解開它——這次重啟反而是最快的辦法。
看一眼實作就懂為什麼:
// 速率限制計數器:存在 process 記憶體的 Map,不是 DB
const otpAttempts = new Map(); // key: userId
const MAX_OTP_ATTEMPTS = 5;
const RATE_LIMIT_TTL_MS = 10 * 60 * 1000; // 10 分鐘後過期
這個計數器是 process 記憶體裡的一個 Map,連 DB 都沒碰。於是它的解鎖方式跟實例一完全相反:
| 權限 grant | OTP 速率限制 | |
|---|---|---|
| 狀態存哪 | 資料庫 up_permissions | process 記憶體 Map |
| 讀取時機 | 每個請求 live 讀 | 全在記憶體累加 |
| 改完要重啟嗎 | ❌ 不用 | ✅ 重啟即清(或等 TTL 過期) |
| 用 SQL 改得動嗎 | ✅ 改得動 | ❌ DB 裡根本沒這筆 |
因為計數器不在 DB,你沒辦法用 SQL 清它;但因為它只活在記憶體,重啟 pod 就整個歸零、立刻解鎖(或耐心等那 10 分鐘 TTL(Time To Live,存活時間)自然過期)。
這裡還埋著一個多副本(multi-replica)的坑:這種 in-memory 狀態不跨 pod 共享。如果服務有多個副本、前面又沒有 sticky session,使用者的失敗次數會分散在不同 pod 上,計數變得不可預測——這也是為什麼正式環境的速率限制通常要改放 Redis 之類的共享儲存。
把準則放到別的生態系驗證
這條準則真正的價值在於它不綁 Strapi。把前面兩個案例的問法——「這個值是 live 讀,還是被快取了」——套到其他常見的權限與設定系統,它們會整齊地分成兩半:
| 框架/工具 | 授權來源怎麼讀 | 改了來源後 |
|---|---|---|
| Django 權限 | 每個請求查 DB(單一請求內才有 _perm_cache) | 下一個請求即生效 |
| Rails(Pundit/CanCanCan) | 每個請求從 model 即時算 | 即時生效 |
| Spring Security(未配 UserCache) | 每次認證載入授權 | 下次認證生效;一旦配了 cache 就得主動失效它 |
| Casbin | enforcer 把 policy 載進記憶體 | 改 DB 沒反應,要 LoadPolicy() 或掛 watcher |
| Keycloak | 角色包進已簽發的 JWT | 要 token refresh/重新登入才更新 |
| LaunchDarkly | SDK 維護本地 cache(stream 推送或 polling TTL) | 非純即時,看同步模式 |
上半部 live、下半部快取——這條線把「live 讀 vs 快取,決定要不要重啟」從「我個人的說法」變成「跨六個生態系都成立的規律」。
更重要的是,下半部那些正是工程師最常踩的「我明明改了怎麼沒生效」case:Casbin 改了 DB policy 卻沒反應、Keycloak 改了使用者角色要重登才更新——根因都一樣,你改的是來源,但程式讀的是那份還沒更新的快取。一旦用「它在哪裡被讀」的角度看,這些看似不相關的雷其實是同一顆。
怎麼快速判斷一個設定屬於哪一類
不必把整個框架讀完,問三個問題就能歸類:
- 這個值有沒有進資料庫? 沒進 DB(只在記憶體/process 變數)→ 幾乎篤定要重啟才清。
- 讀取它的程式碼在請求路徑上,還是在啟動路徑上?
grep一下讀取點:在bootstrap/register/模組頂層被讀 → 啟動時快取,改完要重啟;在 controller/middleware/auth 流程裡被讀 → 多半 live 讀,免重啟。 - 有沒有顯式快取層? 看到
cache.get、Map、memoize包在讀取外面 → 即使來源是 DB,也會被快取擋住,要嘛重啟、要嘛主動失效那層快取。
養成「先翻原始碼確認讀取時機,再決定要不要重啟」的習慣,比背一堆「這個框架要、那個不要」的零碎經驗可靠得多——因為判斷的永遠是同一件事,跟你用哪個框架無關。這三個問題,也正是你該丟回給 AI 的要求:別給我結論,指出那一行。
結語
回到開場那個問題:AI 答得對嗎?這題它經常自信地答錯,因為它在賭 pattern,而答案藏在你這份程式碼的讀取時機裡。準則本身很短——這個值是每個請求即時讀,還是啟動時被快取了。 Strapi 權限因為每次請求都 live 查 DB,所以改完即刻生效、還能用冪等 SQL 安全地只加不刪;OTP 速率限制因為活在 process 記憶體,所以 SQL 動不了它、重啟才能清。
AI 很擅長照你的指令去翻原始碼,卻很不擅長主動懷疑自己給的答案——而「該不該停下來查證」這件事,目前仍是工程師要守的底線。所以不論這段 code 是你寫的還是 AI 寫的,下次要不要重啟都別用猜的:翻開原始碼,找出那個值到底在哪裡被讀,答案就寫在那裡。在人人都用 AI 寫 code 的今天,能用一句 grep 戳破一個自信的錯答案,正在變成比「會寫 code」更值錢的能力。
