[{"data":1,"prerenderedAt":1258},["ShallowReactive",2],{"article-\u002Farticles\u002Fredis-distributed-lock":3},{"id":4,"title":5,"body":6,"date":1245,"description":87,"excerpt":1246,"extension":1247,"meta":1248,"navigation":469,"path":1249,"seo":1250,"stem":1251,"tags":1252,"__hash__":1257},"articles\u002Farticles\u002Fredis-distributed-lock.md","用 Redis 做分散式鎖：SET NX PX、Token 驗證、續期，以及它保證不了的事",{"type":7,"value":8,"toc":1229},"minimark",[9,13,34,37,66,73,77,80,90,108,126,130,137,144,172,183,228,232,239,247,258,289,293,312,790,793,873,886,889,896,900,911,914,917,928,943,947,962,1023,1030,1034,1088,1091,1124,1131,1135,1171,1174,1215,1218,1225],[10,11,12],"h2",{"id":12},"為什麼需要分散式鎖",[14,15,16,17,21,22,25,26,30,31,33],"p",{},"在單一應用程式裡，要確保同一段程式不會同時被執行，用 ",[18,19,20],"code",{},"lock"," 或 ",[18,23,24],{},"SemaphoreSlim"," 就可以了。但部署到 K8s、開了 3 個 Pod 之後，",[27,28,29],"strong",{},"每個 Pod 都是獨立的行程","，各自的 ",[18,32,20],{}," 只鎖得住自己，管不到其他 Pod。",[14,35,36],{},"常見的情境：",[38,39,40,54,60],"ul",{},[41,42,43,46,47,50,51],"li",{},[27,44,45],{},"排程只能跑一次","：每天凌晨的結算工作寫在 ",[18,48,49],{},"BackgroundService"," 裡，3 個 Pod 時間一到就",[27,52,53],{},"同時執行 3 次",[41,55,56,59],{},[27,57,58],{},"同一筆資料不能被同時處理","：同一張訂單同時收到兩個「付款完成」的通知，兩個 Pod 各處理一次，出貨兩次",[41,61,62,65],{},[27,63,64],{},"呼叫有次數限制的外部 API","：多個實例同時重新取得 Token，把對方的 Token 刷掉",[14,67,68,69,72],{},"這時候需要一個",[27,70,71],{},"所有實例都看得到","的地方來放鎖，Redis 是最常見的選擇：快、每個指令都是原子的，而且大部分系統本來就有一台。",[10,74,76],{"id":75},"第一版set-nx-px","第一版：SET NX PX",[14,78,79],{},"搶鎖的核心是一個指令：",[81,82,88],"pre",{"className":83,"code":85,"language":86,"meta":87},[84],"language-text","SET lock:settlement:2024-11-26 {token} NX PX 30000\n","text","",[18,89,85],{"__ignoreMap":87},[38,91,92,102],{},[41,93,94,97,98,101],{},[18,95,96],{},"NX","：只有 Key ",[27,99,100],{},"不存在","時才寫入，存在就失敗——誰先寫入誰就拿到鎖",[41,103,104,107],{},[18,105,106],{},"PX 30000","：30 秒後自動過期——拿到鎖的實例當掉了，鎖也會自己釋放，不會永遠卡住",[14,109,110,113,114,117,118,121,122,125],{},[27,111,112],{},"這兩個參數一定要在同一個指令裡","。舊的寫法是先 ",[18,115,116],{},"SETNX"," 再 ",[18,119,120],{},"EXPIRE","，如果在兩個指令之間當掉，這把鎖就",[27,123,124],{},"永遠沒有過期時間","，之後誰都拿不到。",[10,127,129],{"id":128},"漏洞一刪到別人的鎖","漏洞一：刪到別人的鎖",[14,131,132,133,136],{},"最直覺的釋放方式是做完工作後 ",[18,134,135],{},"DEL"," 這個 Key。但看看這個時序：",[14,138,139],{},[140,141],"img",{"alt":142,"src":143},"Redis 分散式鎖的兩個時序比較：上方是有問題的做法，Pod A 取得鎖、TTL 30 秒，但工作因為 GC 停頓與資料庫變慢跑了 40 秒，第 30 秒鎖自動過期，Pod B 取得鎖開始執行，此時 A 與 B 同時在做同一件事；第 40 秒 A 做完後直接 DEL，把 B 的鎖刪掉，Pod C 又取得鎖，三個實例同時執行。下方是改善後的做法，每次取鎖時寫入唯一的 Token，背景每 10 秒檢查 Token 後續期，讓 A 在工作期間一直持有鎖；釋放時用 Lua 比對 Token 相同才刪除，B 在 A 釋放後才取得鎖","\u002Fimages\u002Farticles\u002Fredis-lock-timeline.svg",[145,146,147,150,153,160,169],"ol",{},[41,148,149],{},"Pod A 拿到鎖，過期時間 30 秒",[41,151,152],{},"A 的工作因為資料庫變慢，跑了 40 秒",[41,154,155,156,159],{},"第 30 秒，鎖過期了，",[27,157,158],{},"Pod B 拿到鎖","，開始做同一件事",[41,161,162,163,165,166],{},"第 40 秒，A 做完了，",[18,164,135],{}," 這個 Key——",[27,167,168],{},"刪掉的是 B 的鎖",[41,170,171],{},"Pod C 接著也拿到鎖，現在 B、C 同時在執行",[14,173,174,175,178,179,182],{},"解法是",[27,176,177],{},"每次取鎖時寫入一個唯一的 Token","（例如 GUID），釋放時",[27,180,181],{},"確認 Token 是自己的才刪除","。「讀取比對」和「刪除」必須是原子的，否則在兩者之間鎖剛好過期又被別人拿走，還是會刪錯。所以要用 Lua Script，讓 Redis 一次執行完：",[81,184,188],{"className":185,"code":186,"language":187,"meta":87,"style":87},"language-lua shiki shiki-themes github-dark","-- 釋放鎖：Token 相同才刪除\nif redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n    return redis.call(\"DEL\", KEYS[1])\nelse\n    return 0\nend\n","lua",[18,189,190,198,204,210,216,222],{"__ignoreMap":87},[191,192,195],"span",{"class":193,"line":194},"line",1,[191,196,197],{},"-- 釋放鎖：Token 相同才刪除\n",[191,199,201],{"class":193,"line":200},2,[191,202,203],{},"if redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n",[191,205,207],{"class":193,"line":206},3,[191,208,209],{},"    return redis.call(\"DEL\", KEYS[1])\n",[191,211,213],{"class":193,"line":212},4,[191,214,215],{},"else\n",[191,217,219],{"class":193,"line":218},5,[191,220,221],{},"    return 0\n",[191,223,225],{"class":193,"line":224},6,[191,226,227],{},"end\n",[10,229,231],{"id":230},"漏洞二工作還沒做完鎖就過期了","漏洞二：工作還沒做完，鎖就過期了",[14,233,234,235,238],{},"Token 解決了「刪錯鎖」，但沒解決上面第 3 步：",[27,236,237],{},"A 還在做，B 已經拿到鎖了","。過期時間設多長都很難拿捏：",[38,240,241,244],{},[41,242,243],{},"設太短：工作偶爾變慢，鎖就提前失效",[41,245,246],{},"設太長：實例當掉時，要等很久才會有別人接手",[14,248,249,250,253,254,257],{},"常見的做法是",[27,251,252],{},"自動續期（Watchdog）","：拿到鎖之後啟動一個背景計時器，每隔過期時間的三分之一（例如每 10 秒），",[27,255,256],{},"確認 Token 還是自己的","，再把過期時間延長回 30 秒。工作做完就停止續期並釋放。實例當掉的話，續期也跟著停止，鎖最多 30 秒後過期。",[81,259,261],{"className":185,"code":260,"language":187,"meta":87,"style":87},"-- 續期：Token 相同才延長\nif redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n    return redis.call(\"PEXPIRE\", KEYS[1], ARGV[2])\nelse\n    return 0\nend\n",[18,262,263,268,272,277,281,285],{"__ignoreMap":87},[191,264,265],{"class":193,"line":194},[191,266,267],{},"-- 續期：Token 相同才延長\n",[191,269,270],{"class":193,"line":200},[191,271,203],{},[191,273,274],{"class":193,"line":206},[191,275,276],{},"    return redis.call(\"PEXPIRE\", KEYS[1], ARGV[2])\n",[191,278,279],{"class":193,"line":212},[191,280,215],{},[191,282,283],{"class":193,"line":218},[191,284,221],{},[191,286,287],{"class":193,"line":224},[191,288,227],{},[10,290,292],{"id":291},"在-net-裡實作","在 .NET 裡實作",[14,294,295,296,299,300,303,304,307,308,311],{},"StackExchange.Redis 已經內建了上面這三個操作：",[18,297,298],{},"LockTakeAsync"," 就是 ",[18,301,302],{},"SET NX PX","，",[18,305,306],{},"LockExtendAsync"," 和 ",[18,309,310],{},"LockReleaseAsync"," 都會先比對 Token，不用自己寫 Lua。",[81,313,317],{"className":314,"code":315,"language":316,"meta":87,"style":87},"language-csharp shiki shiki-themes github-dark","public async Task\u003Cbool> RunWithLockAsync(string resource, Func\u003CCancellationToken, Task> work)\n{\n    var key = $\"lock:{resource}\";\n    var token = Guid.NewGuid().ToString(\"N\");\n    var expiry = TimeSpan.FromSeconds(30);\n\n    if (!await _redis.LockTakeAsync(key, token, expiry))\n        return false;  \u002F\u002F 別人正在做\n\n    using var cts = new CancellationTokenSource();\n    var watchdog = Task.Run(async () =>\n    {\n        while (!cts.IsCancellationRequested)\n        {\n            await Task.Delay(expiry \u002F 3, cts.Token);\n            if (!await _redis.LockExtendAsync(key, token, expiry))\n            {\n                \u002F\u002F 續期失敗代表鎖已經不是自己的了，通知工作盡快停下來\n                cts.Cancel();\n                break;\n            }\n        }\n    });\n\n    try\n    {\n        await work(cts.Token);\n        return true;\n    }\n    finally\n    {\n        cts.Cancel();\n        try { await watchdog; } catch (OperationCanceledException) { }\n        await _redis.LockReleaseAsync(key, token);\n    }\n}\n","csharp",[18,318,319,378,383,413,442,465,471,491,507,512,535,562,568,582,588,611,627,633,639,650,658,664,670,676,681,687,692,704,714,720,726,731,741,767,779,784],{"__ignoreMap":87},[191,320,321,325,328,332,336,339,342,345,348,351,354,357,360,362,365,367,370,372,375],{"class":193,"line":194},[191,322,324],{"class":323},"snl16","public",[191,326,327],{"class":323}," async",[191,329,331],{"class":330},"svObZ"," Task",[191,333,335],{"class":334},"s95oV","\u003C",[191,337,338],{"class":323},"bool",[191,340,341],{"class":334},"> ",[191,343,344],{"class":330},"RunWithLockAsync",[191,346,347],{"class":334},"(",[191,349,350],{"class":323},"string",[191,352,353],{"class":330}," resource",[191,355,356],{"class":334},", ",[191,358,359],{"class":330},"Func",[191,361,335],{"class":334},[191,363,364],{"class":330},"CancellationToken",[191,366,356],{"class":334},[191,368,369],{"class":330},"Task",[191,371,341],{"class":334},[191,373,374],{"class":330},"work",[191,376,377],{"class":334},")\n",[191,379,380],{"class":193,"line":200},[191,381,382],{"class":334},"{\n",[191,384,385,388,391,394,398,401,404,407,410],{"class":193,"line":206},[191,386,387],{"class":323},"    var",[191,389,390],{"class":330}," key",[191,392,393],{"class":323}," =",[191,395,397],{"class":396},"sU2Wk"," $\"lock:",[191,399,400],{"class":396},"{",[191,402,403],{"class":334},"resource",[191,405,406],{"class":396},"}",[191,408,409],{"class":396},"\"",[191,411,412],{"class":334},";\n",[191,414,415,417,420,422,425,428,431,434,436,439],{"class":193,"line":212},[191,416,387],{"class":323},[191,418,419],{"class":330}," token",[191,421,393],{"class":323},[191,423,424],{"class":334}," Guid.",[191,426,427],{"class":330},"NewGuid",[191,429,430],{"class":334},"().",[191,432,433],{"class":330},"ToString",[191,435,347],{"class":334},[191,437,438],{"class":396},"\"N\"",[191,440,441],{"class":334},");\n",[191,443,444,446,449,451,454,457,459,463],{"class":193,"line":218},[191,445,387],{"class":323},[191,447,448],{"class":330}," expiry",[191,450,393],{"class":323},[191,452,453],{"class":334}," TimeSpan.",[191,455,456],{"class":330},"FromSeconds",[191,458,347],{"class":334},[191,460,462],{"class":461},"sDLfK","30",[191,464,441],{"class":334},[191,466,467],{"class":193,"line":224},[191,468,470],{"emptyLinePlaceholder":469},true,"\n",[191,472,474,477,480,483,486,488],{"class":193,"line":473},7,[191,475,476],{"class":323},"    if",[191,478,479],{"class":334}," (",[191,481,482],{"class":323},"!await",[191,484,485],{"class":334}," _redis.",[191,487,298],{"class":330},[191,489,490],{"class":334},"(key, token, expiry))\n",[191,492,494,497,500,503],{"class":193,"line":493},8,[191,495,496],{"class":323},"        return",[191,498,499],{"class":461}," false",[191,501,502],{"class":334},";  ",[191,504,506],{"class":505},"sAwPA","\u002F\u002F 別人正在做\n",[191,508,510],{"class":193,"line":509},9,[191,511,470],{"emptyLinePlaceholder":469},[191,513,515,518,521,524,526,529,532],{"class":193,"line":514},10,[191,516,517],{"class":323},"    using",[191,519,520],{"class":323}," var",[191,522,523],{"class":330}," cts",[191,525,393],{"class":323},[191,527,528],{"class":323}," new",[191,530,531],{"class":330}," CancellationTokenSource",[191,533,534],{"class":334},"();\n",[191,536,538,540,543,545,548,551,553,556,559],{"class":193,"line":537},11,[191,539,387],{"class":323},[191,541,542],{"class":330}," watchdog",[191,544,393],{"class":323},[191,546,547],{"class":334}," Task.",[191,549,550],{"class":330},"Run",[191,552,347],{"class":334},[191,554,555],{"class":323},"async",[191,557,558],{"class":334}," () ",[191,560,561],{"class":323},"=>\n",[191,563,565],{"class":193,"line":564},12,[191,566,567],{"class":334},"    {\n",[191,569,571,574,576,579],{"class":193,"line":570},13,[191,572,573],{"class":323},"        while",[191,575,479],{"class":334},[191,577,578],{"class":323},"!",[191,580,581],{"class":334},"cts.IsCancellationRequested)\n",[191,583,585],{"class":193,"line":584},14,[191,586,587],{"class":334},"        {\n",[191,589,591,594,596,599,602,605,608],{"class":193,"line":590},15,[191,592,593],{"class":323},"            await",[191,595,547],{"class":334},[191,597,598],{"class":330},"Delay",[191,600,601],{"class":334},"(expiry ",[191,603,604],{"class":323},"\u002F",[191,606,607],{"class":461}," 3",[191,609,610],{"class":334},", cts.Token);\n",[191,612,614,617,619,621,623,625],{"class":193,"line":613},16,[191,615,616],{"class":323},"            if",[191,618,479],{"class":334},[191,620,482],{"class":323},[191,622,485],{"class":334},[191,624,306],{"class":330},[191,626,490],{"class":334},[191,628,630],{"class":193,"line":629},17,[191,631,632],{"class":334},"            {\n",[191,634,636],{"class":193,"line":635},18,[191,637,638],{"class":505},"                \u002F\u002F 續期失敗代表鎖已經不是自己的了，通知工作盡快停下來\n",[191,640,642,645,648],{"class":193,"line":641},19,[191,643,644],{"class":334},"                cts.",[191,646,647],{"class":330},"Cancel",[191,649,534],{"class":334},[191,651,653,656],{"class":193,"line":652},20,[191,654,655],{"class":323},"                break",[191,657,412],{"class":334},[191,659,661],{"class":193,"line":660},21,[191,662,663],{"class":334},"            }\n",[191,665,667],{"class":193,"line":666},22,[191,668,669],{"class":334},"        }\n",[191,671,673],{"class":193,"line":672},23,[191,674,675],{"class":334},"    });\n",[191,677,679],{"class":193,"line":678},24,[191,680,470],{"emptyLinePlaceholder":469},[191,682,684],{"class":193,"line":683},25,[191,685,686],{"class":323},"    try\n",[191,688,690],{"class":193,"line":689},26,[191,691,567],{"class":334},[191,693,695,698,701],{"class":193,"line":694},27,[191,696,697],{"class":323},"        await",[191,699,700],{"class":330}," work",[191,702,703],{"class":334},"(cts.Token);\n",[191,705,707,709,712],{"class":193,"line":706},28,[191,708,496],{"class":323},[191,710,711],{"class":461}," true",[191,713,412],{"class":334},[191,715,717],{"class":193,"line":716},29,[191,718,719],{"class":334},"    }\n",[191,721,723],{"class":193,"line":722},30,[191,724,725],{"class":323},"    finally\n",[191,727,729],{"class":193,"line":728},31,[191,730,567],{"class":334},[191,732,734,737,739],{"class":193,"line":733},32,[191,735,736],{"class":334},"        cts.",[191,738,647],{"class":330},[191,740,534],{"class":334},[191,742,744,747,750,753,756,759,761,764],{"class":193,"line":743},33,[191,745,746],{"class":323},"        try",[191,748,749],{"class":334}," { ",[191,751,752],{"class":323},"await",[191,754,755],{"class":334}," watchdog; } ",[191,757,758],{"class":323},"catch",[191,760,479],{"class":334},[191,762,763],{"class":330},"OperationCanceledException",[191,765,766],{"class":334},") { }\n",[191,768,770,772,774,776],{"class":193,"line":769},34,[191,771,697],{"class":323},[191,773,485],{"class":334},[191,775,310],{"class":330},[191,777,778],{"class":334},"(key, token);\n",[191,780,782],{"class":193,"line":781},35,[191,783,719],{"class":334},[191,785,787],{"class":193,"line":786},36,[191,788,789],{"class":334},"}\n",[14,791,792],{},"使用時：",[81,794,796],{"className":314,"code":795,"language":316,"meta":87,"style":87},"\u002F\u002F 3 個 Pod 同時觸發，只有一個會真的執行\nvar date = DateTime.Today.ToString(\"yyyy-MM-dd\");\nvar executed = await _lock.RunWithLockAsync($\"settlement:{date}\", ct => _settlement.RunAsync(date, ct));\n",[18,797,798,803,825],{"__ignoreMap":87},[191,799,800],{"class":193,"line":194},[191,801,802],{"class":505},"\u002F\u002F 3 個 Pod 同時觸發，只有一個會真的執行\n",[191,804,805,808,811,813,816,818,820,823],{"class":193,"line":200},[191,806,807],{"class":323},"var",[191,809,810],{"class":330}," date",[191,812,393],{"class":323},[191,814,815],{"class":334}," DateTime.Today.",[191,817,433],{"class":330},[191,819,347],{"class":334},[191,821,822],{"class":396},"\"yyyy-MM-dd\"",[191,824,441],{"class":334},[191,826,827,829,832,834,837,840,842,844,847,849,852,854,856,858,861,864,867,870],{"class":193,"line":206},[191,828,807],{"class":323},[191,830,831],{"class":330}," executed",[191,833,393],{"class":323},[191,835,836],{"class":323}," await",[191,838,839],{"class":334}," _lock.",[191,841,344],{"class":330},[191,843,347],{"class":334},[191,845,846],{"class":396},"$\"settlement:",[191,848,400],{"class":396},[191,850,851],{"class":334},"date",[191,853,406],{"class":396},[191,855,409],{"class":396},[191,857,356],{"class":334},[191,859,860],{"class":330},"ct",[191,862,863],{"class":323}," =>",[191,865,866],{"class":334}," _settlement.",[191,868,869],{"class":330},"RunAsync",[191,871,872],{"class":334},"(date, ct));\n",[14,874,875,876,878,879,881,882,885],{},"注意 ",[18,877,374],{}," 收到了 ",[18,880,364],{},"：續期失敗時會被取消，工作要",[27,883,884],{},"檢查這個 Token 並停下來","，不然即使知道鎖已經沒了，程式還是會繼續做下去。",[10,887,888],{"id":888},"它保證不了的事",[14,890,891,892,895],{},"補完上面這些，Redis 鎖在大部分情況下都能正常運作。但有幾種情況，它",[27,893,894],{},"原理上","就無法保證：",[897,898,899],"h3",{"id":899},"程序停頓",[14,901,902,903,906,907,910],{},"A 拿到鎖之後，整個行程因為一次很長的 GC、或是 VM 被暫停，",[27,904,905],{},"停了 40 秒","。這段時間 Watchdog 也一起停了，沒辦法續期。鎖過期、B 拿到鎖。A 醒來之後，",[27,908,909],{},"它不知道自己睡了 40 秒","，繼續寫資料庫。",[14,912,913],{},"續期只能縮小這個時間窗，不能消除它。",[897,915,916],{"id":916},"主從切換",[14,918,919,920,923,924,927],{},"Redis 的主從複製是",[27,921,922],{},"非同步","的。A 在 Master 上取得鎖，Master 還沒同步給 Replica 就掛了，Replica 被提升為新的 Master——",[27,925,926],{},"上面沒有這把鎖","，B 馬上就能拿到。",[14,929,930,931,934,935,938,939,942],{},"Redis 作者提出的 ",[27,932,933],{},"Redlock"," 演算法，是向 5 個",[27,936,937],{},"彼此獨立","的 Redis 取鎖，過半數成功才算拿到，以避免單一節點故障。但它依賴「各台機器的時鐘大致準確、停頓不會太久」這類時間上的假設，Martin Kleppmann 在〈How to do distributed locking〉一文中就以程序停頓和時鐘跳動為例，認為它不足以保證正確性。這場討論的結論，對使用者來說很實用：",[27,940,941],{},"先想清楚你要的是「效率」還是「正確性」","。",[897,944,946],{"id":945},"fencing-token","Fencing Token",[14,948,949,950,953,954,957,958,961],{},"如果需要正確性，問題要在",[27,951,952],{},"被保護的資源那一端","解決。做法是每次取鎖時，再拿一個",[27,955,956],{},"單調遞增的號碼","（例如 ",[18,959,960],{},"INCR lock:settlement:fence","），寫入資料時帶上這個號碼，資料庫只接受比目前記錄更大的號碼：",[81,963,967],{"className":964,"code":965,"language":966,"meta":87,"style":87},"language-sql shiki shiki-themes github-dark","UPDATE Settlement\nSET    Status = 'Done', FenceToken = @token\nWHERE  SettleDate = @date AND FenceToken \u003C @token;\n","sql",[18,968,969,977,999],{"__ignoreMap":87},[191,970,971,974],{"class":193,"line":194},[191,972,973],{"class":323},"UPDATE",[191,975,976],{"class":334}," Settlement\n",[191,978,979,982,985,987,990,993,996],{"class":193,"line":200},[191,980,981],{"class":323},"SET",[191,983,984],{"class":323},"    Status",[191,986,393],{"class":323},[191,988,989],{"class":396}," 'Done'",[191,991,992],{"class":334},", FenceToken ",[191,994,995],{"class":323},"=",[191,997,998],{"class":334}," @token\n",[191,1000,1001,1004,1007,1009,1012,1015,1018,1020],{"class":193,"line":206},[191,1002,1003],{"class":323},"WHERE",[191,1005,1006],{"class":334},"  SettleDate ",[191,1008,995],{"class":323},[191,1010,1011],{"class":334}," @date ",[191,1013,1014],{"class":323},"AND",[191,1016,1017],{"class":334}," FenceToken ",[191,1019,335],{"class":323},[191,1021,1022],{"class":334}," @token;\n",[14,1024,1025,1026,1029],{},"A 拿到的是 33、B 拿到的是 34。B 先寫入之後，A 醒來帶著 33 想寫入，條件不成立，",[27,1027,1028],{},"寫入失敗","。這樣即使兩個實例同時以為自己持有鎖，資料也不會被舊的那個覆蓋。",[10,1031,1033],{"id":1032},"效率還是正確性","效率還是正確性？",[1035,1036,1037,1053],"table",{},[1038,1039,1040],"thead",{},[1041,1042,1043,1047,1050],"tr",{},[1044,1045,1046],"th",{},"目的",[1044,1048,1049],{},"鎖失效的後果",[1044,1051,1052],{},"建議",[1054,1055,1056,1071],"tbody",{},[1041,1057,1058,1065,1068],{},[1059,1060,1061,1064],"td",{},[27,1062,1063],{},"效率","：避免重複做同一件事",[1059,1066,1067],{},"偶爾多做一次，浪費一點資源",[1059,1069,1070],{},"Redis 單節點鎖 + Token + 續期就夠了",[1041,1072,1073,1079,1082],{},[1059,1074,1075,1078],{},[27,1076,1077],{},"正確性","：絕對不能同時做",[1059,1080,1081],{},"資料錯誤、重複扣款、重複出貨",[1059,1083,1084,1085],{},"Redis 鎖只當第一道防線，",[27,1086,1087],{},"資料庫必須有自己的保護",[14,1089,1090],{},"需要正確性時，真正的保證應該來自資料庫：",[38,1092,1093,1099,1112],{},[41,1094,1095,1098],{},[27,1096,1097],{},"唯一索引","：結算紀錄以日期為唯一鍵，第二次寫入直接失敗",[41,1100,1101,1104,1105,1108,1109],{},[27,1102,1103],{},"條件更新／樂觀鎖","：",[18,1106,1107],{},"WHERE Version = @version","、",[18,1110,1111],{},"WHERE Status = 'Pending'",[41,1113,1114,1117,1118,1123],{},[27,1115,1116],{},"冪等鍵","：每個請求帶一個唯一 ID，處理過的就不再處理（點數這類情境可以參考 ",[1119,1120,1122],"a",{"href":1121},"\u002Farticles\u002Fpoints-ledger-idempotency","點數帳本的冪等鍵與條件更新","）",[14,1125,1126,1127,1130],{},"Redis 鎖在這裡的角色，是",[27,1128,1129],{},"擋掉絕大多數的重複執行","，讓資料庫的保護機制很少需要被觸發。",[10,1132,1134],{"id":1133},"使用情境多個-pod-的每日結算排程","使用情境：多個 Pod 的每日結算排程",[38,1136,1137,1143,1154,1157,1168],{},[41,1138,1139,1140,1142],{},"結算服務部署 3 個 Pod，每個 Pod 的 ",[18,1141,49],{}," 都會在凌晨 00:05 觸發",[41,1144,1145,1146,1149,1150,1153],{},"用 ",[18,1147,1148],{},"settlement:{日期}"," 當鎖的名稱，",[27,1151,1152],{},"只有搶到鎖的 Pod 會執行","，其他兩個直接略過",[41,1155,1156],{},"執行中的 Pod 透過 Watchdog 每 10 秒續期；Pod 被重啟或當掉，30 秒後鎖過期，下一個排程週期由其他 Pod 接手",[41,1158,1159,1160,1163,1164,1167],{},"結算結果寫入時，資料表以結算日期為",[27,1161,1162],{},"唯一鍵","，並用 ",[18,1165,1166],{},"Status"," 做條件更新——就算鎖失效、兩個 Pod 都跑了，第二個也寫不進去",[41,1169,1170],{},"鎖的取得、續期失敗、釋放都記錄 Log，續期失敗時告警",[10,1172,1173],{"id":1173},"注意事項",[38,1175,1176,1182,1188,1194,1203,1209],{},[41,1177,1178,1181],{},[27,1179,1180],{},"鎖的名稱要夠精確","：鎖整個「結算」，和鎖「某一天的結算」、「某一個帳號」，平行度完全不同。鎖的範圍越小，越不容易互相等待",[41,1183,1184,1187],{},[27,1185,1186],{},"拿不到鎖時要決定行為","：直接略過（排程）、等待重試（處理同一筆資料），還是回傳錯誤（使用者操作）。等待時要有上限，不要無限輪詢",[41,1189,1190,1193],{},[27,1191,1192],{},"不要在鎖裡做太久的事","：鎖住的時間越長，被停頓、網路問題影響的機率越高。能拆小就拆小",[41,1195,1196,1199,1200,1202],{},[27,1197,1198],{},"不需要跨實例時不要用","：只是要防止同一個 Pod 內的並行，用 ",[18,1201,24],{}," 就好，多一次 Redis 往返沒有意義",[41,1204,1205,1208],{},[27,1206,1207],{},"Redis 掛掉時的行為要先想好","：拿不到鎖是「全部都不做」還是「降級成都做、靠資料庫擋」，依業務決定，但要事先決定，而不是出事時才發現",[41,1210,1211,1214],{},[27,1212,1213],{},"排程框架通常有內建","：像 Hangfire 這類排程框架本身就有分散式鎖機制，用框架的功能就好，不用自己再包一層",[10,1216,1217],{"id":1217},"一句話總結",[14,1219,1220,1221,1224],{},"Redis 分散式鎖用 ",[18,1222,1223],{},"SET key token NX PX"," 一次完成取鎖與過期，用 Token 確認只刪自己的鎖，用 Watchdog 在工作期間續期；但程序停頓與主從切換讓它原理上無法保證絕對互斥，所以它適合用來「避免重複做事」，真正需要正確性的地方，要靠資料庫的唯一鍵、條件更新或 Fencing Token 來把關。",[1226,1227,1228],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":87,"searchDepth":200,"depth":200,"links":1230},[1231,1232,1233,1234,1235,1236,1241,1242,1243,1244],{"id":12,"depth":200,"text":12},{"id":75,"depth":200,"text":76},{"id":128,"depth":200,"text":129},{"id":230,"depth":200,"text":231},{"id":291,"depth":200,"text":292},{"id":888,"depth":200,"text":888,"children":1237},[1238,1239,1240],{"id":899,"depth":206,"text":899},{"id":916,"depth":206,"text":916},{"id":945,"depth":206,"text":946},{"id":1032,"depth":200,"text":1033},{"id":1133,"depth":200,"text":1134},{"id":1173,"depth":200,"text":1173},{"id":1217,"depth":200,"text":1217},"2024-11-26",null,"md",{},"\u002Farticles\u002Fredis-distributed-lock",{"title":5,"description":87},"articles\u002Fredis-distributed-lock",[1253,1254,1255,1256],"Redis","分散式鎖","分散式系統",".NET","bbY6h82I8TbD28qPD_6GPdLNhmA7mSx84VpQDBo8IPM",1792959150312]