[{"data":1,"prerenderedAt":932},["ShallowReactive",2],{"article-\u002Farticles\u002Fpoints-ledger-idempotency":3},{"id":4,"title":5,"body":6,"date":918,"description":29,"excerpt":919,"extension":920,"meta":921,"navigation":346,"path":922,"seo":923,"stem":924,"tags":925,"__hash__":931},"articles\u002Farticles\u002Fpoints-ledger-idempotency.md","點數系統為什麼不能直接改餘額？用交易紀錄、冪等鍵與條件更新設計一個對得起帳的點數帳本",{"type":7,"value":8,"toc":909},"minimark",[9,14,23,81,84,114,118,121,128,154,402,405,409,416,431,439,446,450,453,646,649,676,680,683,812,815,819,822,896,899,902,905],[10,11,13],"h2",{"id":12},"直覺的設計一個餘額欄位","直覺的設計：一個餘額欄位",[15,16,17,18,22],"p",{},"做 B 端刊登平台時，房仲經紀人要先儲值點數，再用點數購買刊登、置頂等方案。最直覺的資料模型是在會員資料表上放一個 ",[19,20,21],"code",{},"Balance"," 欄位：",[24,25,30],"pre",{"className":26,"code":27,"language":28,"meta":29,"style":29},"language-sql shiki shiki-themes github-dark","-- 扣點\nUPDATE Members SET Balance = Balance - 300 WHERE MemberId = @MemberId;\n","sql","",[19,31,32,41],{"__ignoreMap":29},[33,34,37],"span",{"class":35,"line":36},"line",1,[33,38,40],{"class":39},"sAwPA","-- 扣點\n",[33,42,44,48,52,55,58,61,63,66,70,73,76,78],{"class":35,"line":43},2,[33,45,47],{"class":46},"snl16","UPDATE",[33,49,51],{"class":50},"s95oV"," Members ",[33,53,54],{"class":46},"SET",[33,56,57],{"class":50}," Balance ",[33,59,60],{"class":46},"=",[33,62,57],{"class":50},[33,64,65],{"class":46},"-",[33,67,69],{"class":68},"sDLfK"," 300",[33,71,72],{"class":46}," WHERE",[33,74,75],{"class":50}," MemberId ",[33,77,60],{"class":46},[33,79,80],{"class":50}," @MemberId;\n",[15,82,83],{},"這個設計在開發環境裡跑得很好，但只要和「錢」有關，上線後一定會遇到這三個問題：",[85,86,87,95,108],"ol",{},[88,89,90,94],"li",{},[91,92,93],"strong",{},"重複扣點","：使用者連點兩次「購買」、前端逾時自動重送、或是訊息佇列重複投遞，同一筆購買被扣了兩次",[88,96,97,100,101,104,105,107],{},[91,98,99],{},"併發覆蓋","：如果程式先 ",[19,102,103],{},"SELECT"," 出餘額、在程式裡算好、再 ",[19,106,47],{}," 回去，兩個請求同時進來時，後寫的會蓋掉先寫的",[88,109,110,113],{},[91,111,112],{},"對帳對不起來","：客服接到「我的點數怎麼少了」，資料庫裡只有一個「現在是 600」的數字，完全不知道它是怎麼變成 600 的",[10,115,117],{"id":116},"換個角度交易紀錄才是真相","換個角度：交易紀錄才是真相",[15,119,120],{},"銀行的存摺不是只印「餘額」，而是一筆一筆的存入、提出，餘額只是最後一欄的加總。點數也應該這樣設計：",[15,122,123],{},[124,125],"img",{"alt":126,"src":127},"點數帳本設計比較：左邊直接改餘額只留下現在的數字，會有重複扣點、併發覆蓋、無法對帳的問題；右邊以交易紀錄為主，每筆加值、扣點、退點都有唯一的 RequestId，重複請求會被唯一鍵擋下，餘額快照與交易紀錄在同一個資料庫交易內一起更新，並且永遠等於紀錄的加總","\u002Fimages\u002Farticles\u002Fpoints-ledger.svg",[129,130,131,144],"ul",{},[88,132,133,136,137,140,141],{},[91,134,135],{},"交易紀錄（Ledger）","：每一次點數異動都",[91,138,139],{},"新增","一筆紀錄，包含異動數量、類型（加值、扣點、退點）、原因、關聯的訂單。紀錄",[91,142,143],{},"只新增、不修改、不刪除",[88,145,146,149,150,153],{},[91,147,148],{},"餘額快照（Balance）","：為了查詢效率，另外維護一個目前餘額。但它只是",[91,151,152],{},"快取","，隨時可以由交易紀錄重新加總出來",[24,155,157],{"className":26,"code":156,"language":28,"meta":29,"style":29},"CREATE TABLE PointTransactions (\n    Id          BIGINT IDENTITY PRIMARY KEY,\n    MemberId    INT           NOT NULL,\n    RequestId   VARCHAR(64)   NOT NULL,   -- 冪等鍵\n    Type        TINYINT       NOT NULL,   -- 1 加值 \u002F 2 扣點 \u002F 3 退點\n    Amount      INT           NOT NULL,   -- 加值為正、扣點為負\n    OrderId     INT           NULL,\n    Note        NVARCHAR(200) NULL,\n    CreatedAt   DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),\n    CONSTRAINT UQ_PointTransactions_RequestId UNIQUE (RequestId)\n);\n\nCREATE TABLE PointBalances (\n    MemberId    INT PRIMARY KEY,\n    Balance     INT NOT NULL CHECK (Balance >= 0)\n);\n",[19,158,159,174,191,205,232,249,264,277,299,320,335,341,348,360,371,397],{"__ignoreMap":29},[33,160,161,164,167,171],{"class":35,"line":36},[33,162,163],{"class":46},"CREATE",[33,165,166],{"class":46}," TABLE",[33,168,170],{"class":169},"svObZ"," PointTransactions",[33,172,173],{"class":50}," (\n",[33,175,176,179,182,185,188],{"class":35,"line":43},[33,177,178],{"class":50},"    Id          ",[33,180,181],{"class":46},"BIGINT",[33,183,184],{"class":46}," IDENTITY",[33,186,187],{"class":46}," PRIMARY KEY",[33,189,190],{"class":50},",\n",[33,192,194,197,200,203],{"class":35,"line":193},3,[33,195,196],{"class":50},"    MemberId    ",[33,198,199],{"class":46},"INT",[33,201,202],{"class":46},"           NOT NULL",[33,204,190],{"class":50},[33,206,208,211,214,217,220,223,226,229],{"class":35,"line":207},4,[33,209,210],{"class":50},"    RequestId   ",[33,212,213],{"class":46},"VARCHAR",[33,215,216],{"class":50},"(",[33,218,219],{"class":68},"64",[33,221,222],{"class":50},")   ",[33,224,225],{"class":46},"NOT NULL",[33,227,228],{"class":50},",   ",[33,230,231],{"class":39},"-- 冪等鍵\n",[33,233,235,238,241,244,246],{"class":35,"line":234},5,[33,236,237],{"class":46},"    Type",[33,239,240],{"class":46},"        TINYINT",[33,242,243],{"class":46},"       NOT NULL",[33,245,228],{"class":50},[33,247,248],{"class":39},"-- 1 加值 \u002F 2 扣點 \u002F 3 退點\n",[33,250,252,255,257,259,261],{"class":35,"line":251},6,[33,253,254],{"class":50},"    Amount      ",[33,256,199],{"class":46},[33,258,202],{"class":46},[33,260,228],{"class":50},[33,262,263],{"class":39},"-- 加值為正、扣點為負\n",[33,265,267,270,272,275],{"class":35,"line":266},7,[33,268,269],{"class":50},"    OrderId     ",[33,271,199],{"class":46},[33,273,274],{"class":46},"           NULL",[33,276,190],{"class":50},[33,278,280,283,286,288,291,294,297],{"class":35,"line":279},8,[33,281,282],{"class":50},"    Note        ",[33,284,285],{"class":46},"NVARCHAR",[33,287,216],{"class":50},[33,289,290],{"class":68},"200",[33,292,293],{"class":50},") ",[33,295,296],{"class":46},"NULL",[33,298,190],{"class":50},[33,300,302,305,308,311,314,317],{"class":35,"line":301},9,[33,303,304],{"class":50},"    CreatedAt   ",[33,306,307],{"class":46},"DATETIME2",[33,309,310],{"class":46},"     NOT NULL",[33,312,313],{"class":46}," DEFAULT",[33,315,316],{"class":68}," SYSUTCDATETIME",[33,318,319],{"class":50},"(),\n",[33,321,323,326,329,332],{"class":35,"line":322},10,[33,324,325],{"class":46},"    CONSTRAINT",[33,327,328],{"class":50}," UQ_PointTransactions_RequestId ",[33,330,331],{"class":46},"UNIQUE",[33,333,334],{"class":50}," (RequestId)\n",[33,336,338],{"class":35,"line":337},11,[33,339,340],{"class":50},");\n",[33,342,344],{"class":35,"line":343},12,[33,345,347],{"emptyLinePlaceholder":346},true,"\n",[33,349,351,353,355,358],{"class":35,"line":350},13,[33,352,163],{"class":46},[33,354,166],{"class":46},[33,356,357],{"class":169}," PointBalances",[33,359,173],{"class":50},[33,361,363,365,367,369],{"class":35,"line":362},14,[33,364,196],{"class":50},[33,366,199],{"class":46},[33,368,187],{"class":46},[33,370,190],{"class":50},[33,372,374,377,379,382,385,388,391,394],{"class":35,"line":373},15,[33,375,376],{"class":50},"    Balance     ",[33,378,199],{"class":46},[33,380,381],{"class":46}," NOT NULL",[33,383,384],{"class":46}," CHECK",[33,386,387],{"class":50}," (Balance ",[33,389,390],{"class":46},">=",[33,392,393],{"class":68}," 0",[33,395,396],{"class":50},")\n",[33,398,400],{"class":35,"line":399},16,[33,401,340],{"class":50},[15,403,404],{},"錯了也不刪紀錄：扣錯點，就新增一筆「退點」；退錯了，再新增一筆「扣點」。每一點的來龍去脈都留在紀錄裡。",[10,406,408],{"id":407},"冪等鍵同一個請求只生效一次","冪等鍵：同一個請求只生效一次",[15,410,411,412,415],{},"「冪等（Idempotent）」的意思是：",[91,413,414],{},"同一個操作做一次和做很多次，結果都一樣","。",[15,417,418,419,422,423,426,427,430],{},"做法是讓每一次購買都帶一個",[91,420,421],{},"唯一的請求識別碼（RequestId）","，例如前端在打開結帳畫面時產生一個 UUID，或直接用訂單編號組成 ",[19,424,425],{},"order-{OrderId}","。這個值寫進交易紀錄，並設定",[91,428,429],{},"唯一索引","：",[129,432,433,436],{},[88,434,435],{},"第一次請求：交易紀錄寫入成功，點數扣除",[88,437,438],{},"重複的請求：寫入時撞到唯一索引，整個交易失敗、點數不會再扣一次。程式捕捉到這個錯誤後，直接回傳「第一次的結果」給呼叫端",[15,440,441,442,445],{},"為什麼不在程式裡先查「這個 RequestId 有沒有處理過」再決定要不要寫？因為「先查再寫」在兩個請求",[91,443,444],{},"同時","進來時，兩邊都會查到「沒有」，然後都寫進去。把判斷交給資料庫的唯一索引，才是真正可靠的保證。",[10,447,449],{"id":448},"條件更新避免併發覆蓋與扣成負數","條件更新：避免併發覆蓋與扣成負數",[15,451,452],{},"餘額快照的更新不要「讀出來、算好、寫回去」，而是讓資料庫在一個陳述式裡完成判斷與更新：",[24,454,456],{"className":26,"code":455,"language":28,"meta":29,"style":29},"BEGIN TRANSACTION;\n\n-- 1. 先寫交易紀錄（重複請求會在這裡被唯一索引擋下）\nINSERT INTO PointTransactions (MemberId, RequestId, Type, Amount, OrderId, Note)\nVALUES (@MemberId, @RequestId, 2, -@Cost, @OrderId, @Note);\n\n-- 2. 條件更新餘額：餘額不足就不更新\nUPDATE PointBalances\nSET    Balance = Balance - @Cost\nWHERE  MemberId = @MemberId\n  AND  Balance >= @Cost;\n\nIF @@ROWCOUNT = 0\nBEGIN\n    ROLLBACK TRANSACTION;  -- 餘額不足，連同交易紀錄一起回滾\n    THROW 50001, 'Insufficient points', 1;\nEND\n\nCOMMIT TRANSACTION;\n",[19,457,458,469,473,478,492,511,515,520,527,543,556,569,573,586,591,604,625,631,636],{"__ignoreMap":29},[33,459,460,463,466],{"class":35,"line":36},[33,461,462],{"class":46},"BEGIN",[33,464,465],{"class":46}," TRANSACTION",[33,467,468],{"class":50},";\n",[33,470,471],{"class":35,"line":43},[33,472,347],{"emptyLinePlaceholder":346},[33,474,475],{"class":35,"line":193},[33,476,477],{"class":39},"-- 1. 先寫交易紀錄（重複請求會在這裡被唯一索引擋下）\n",[33,479,480,483,486,489],{"class":35,"line":207},[33,481,482],{"class":46},"INSERT INTO",[33,484,485],{"class":50}," PointTransactions (MemberId, RequestId, ",[33,487,488],{"class":46},"Type",[33,490,491],{"class":50},", Amount, OrderId, Note)\n",[33,493,494,497,500,503,506,508],{"class":35,"line":234},[33,495,496],{"class":46},"VALUES",[33,498,499],{"class":50}," (@MemberId, @RequestId, ",[33,501,502],{"class":68},"2",[33,504,505],{"class":50},", ",[33,507,65],{"class":46},[33,509,510],{"class":50},"@Cost, @OrderId, @Note);\n",[33,512,513],{"class":35,"line":251},[33,514,347],{"emptyLinePlaceholder":346},[33,516,517],{"class":35,"line":266},[33,518,519],{"class":39},"-- 2. 條件更新餘額：餘額不足就不更新\n",[33,521,522,524],{"class":35,"line":279},[33,523,47],{"class":46},[33,525,526],{"class":50}," PointBalances\n",[33,528,529,531,534,536,538,540],{"class":35,"line":301},[33,530,54],{"class":46},[33,532,533],{"class":50},"    Balance ",[33,535,60],{"class":46},[33,537,57],{"class":50},[33,539,65],{"class":46},[33,541,542],{"class":50}," @Cost\n",[33,544,545,548,551,553],{"class":35,"line":322},[33,546,547],{"class":46},"WHERE",[33,549,550],{"class":50},"  MemberId ",[33,552,60],{"class":46},[33,554,555],{"class":50}," @MemberId\n",[33,557,558,561,564,566],{"class":35,"line":337},[33,559,560],{"class":46},"  AND",[33,562,563],{"class":50},"  Balance ",[33,565,390],{"class":46},[33,567,568],{"class":50}," @Cost;\n",[33,570,571],{"class":35,"line":343},[33,572,347],{"emptyLinePlaceholder":346},[33,574,575,578,581,583],{"class":35,"line":350},[33,576,577],{"class":46},"IF",[33,579,580],{"class":50}," @@ROWCOUNT ",[33,582,60],{"class":46},[33,584,585],{"class":68}," 0\n",[33,587,588],{"class":35,"line":362},[33,589,590],{"class":46},"BEGIN\n",[33,592,593,596,598,601],{"class":35,"line":373},[33,594,595],{"class":46},"    ROLLBACK",[33,597,465],{"class":46},[33,599,600],{"class":50},";  ",[33,602,603],{"class":39},"-- 餘額不足，連同交易紀錄一起回滾\n",[33,605,606,609,612,614,618,620,623],{"class":35,"line":399},[33,607,608],{"class":46},"    THROW",[33,610,611],{"class":68}," 50001",[33,613,505],{"class":50},[33,615,617],{"class":616},"sU2Wk","'Insufficient points'",[33,619,505],{"class":50},[33,621,622],{"class":68},"1",[33,624,468],{"class":50},[33,626,628],{"class":35,"line":627},17,[33,629,630],{"class":46},"END\n",[33,632,634],{"class":35,"line":633},18,[33,635,347],{"emptyLinePlaceholder":346},[33,637,639,642,644],{"class":35,"line":638},19,[33,640,641],{"class":46},"COMMIT",[33,643,465],{"class":46},[33,645,468],{"class":50},[15,647,648],{},"幾個重點：",[129,650,651,657,663,669],{},[88,652,653,656],{},[19,654,655],{},"Balance = Balance - @Cost"," 是在資料庫內原子地計算，兩個請求同時扣點，資料庫會依序執行，不會互相覆蓋",[88,658,659,662],{},[19,660,661],{},"WHERE Balance >= @Cost"," 讓「檢查餘額是否足夠」和「扣除」變成同一個動作，沒有「檢查完、還沒扣之前，被別人先扣走」的空窗",[88,664,665,668],{},[91,666,667],{},"交易紀錄和餘額在同一個資料庫交易內更新","，要嘛都成功、要嘛都失敗，兩者永遠一致",[88,670,671,672,675],{},"資料表上的 ",[19,673,674],{},"CHECK (Balance >= 0)"," 是最後一道防線，就算程式寫錯，資料庫也不允許出現負數餘額",[10,677,679],{"id":678},"對帳餘額永遠可以重新算出來","對帳：餘額永遠可以重新算出來",[15,681,682],{},"因為交易紀錄是真相，對帳就是一條 SQL：",[24,684,686],{"className":26,"code":685,"language":28,"meta":29,"style":29},"SELECT b.MemberId, b.Balance, t.Total\nFROM   PointBalances b\nJOIN  (SELECT MemberId, SUM(Amount) AS Total\n       FROM   PointTransactions\n       GROUP BY MemberId) t ON t.MemberId = b.MemberId\nWHERE  b.Balance \u003C> t.Total;\n",[19,687,688,720,728,753,761,789],{"__ignoreMap":29},[33,689,690,692,695,698,701,703,706,708,710,712,715,717],{"class":35,"line":36},[33,691,103],{"class":46},[33,693,694],{"class":68}," b",[33,696,697],{"class":50},".",[33,699,700],{"class":68},"MemberId",[33,702,505],{"class":50},[33,704,705],{"class":68},"b",[33,707,697],{"class":50},[33,709,21],{"class":68},[33,711,505],{"class":50},[33,713,714],{"class":68},"t",[33,716,697],{"class":50},[33,718,719],{"class":68},"Total\n",[33,721,722,725],{"class":35,"line":43},[33,723,724],{"class":46},"FROM",[33,726,727],{"class":50},"   PointBalances b\n",[33,729,730,733,736,738,741,744,747,750],{"class":35,"line":193},[33,731,732],{"class":46},"JOIN",[33,734,735],{"class":50},"  (",[33,737,103],{"class":46},[33,739,740],{"class":50}," MemberId, ",[33,742,743],{"class":68},"SUM",[33,745,746],{"class":50},"(Amount) ",[33,748,749],{"class":46},"AS",[33,751,752],{"class":50}," Total\n",[33,754,755,758],{"class":35,"line":207},[33,756,757],{"class":46},"       FROM",[33,759,760],{"class":50},"   PointTransactions\n",[33,762,763,766,769,772,775,777,779,782,784,786],{"class":35,"line":234},[33,764,765],{"class":46},"       GROUP BY",[33,767,768],{"class":50}," MemberId) t ",[33,770,771],{"class":46},"ON",[33,773,774],{"class":68}," t",[33,776,697],{"class":50},[33,778,700],{"class":68},[33,780,781],{"class":46}," =",[33,783,694],{"class":68},[33,785,697],{"class":50},[33,787,788],{"class":68},"MemberId\n",[33,790,791,793,796,798,800,803,805,807,810],{"class":35,"line":251},[33,792,547],{"class":46},[33,794,795],{"class":68},"  b",[33,797,697],{"class":50},[33,799,21],{"class":68},[33,801,802],{"class":46}," \u003C>",[33,804,774],{"class":68},[33,806,697],{"class":50},[33,808,809],{"class":68},"Total",[33,811,468],{"class":50},[15,813,814],{},"這條查詢應該永遠回傳 0 筆。可以把它做成每天跑一次的排程，一旦有資料就發出告警。客服遇到「點數怎麼少了」的問題，也只要列出這位會員的交易紀錄，每一筆異動的時間、原因、關聯訂單都一清二楚。",[10,816,818],{"id":817},"使用情境b-端刊登平台的點數","使用情境：B 端刊登平台的點數",[15,820,821],{},"在 B 端房仲刊登平台中，所有付費方案都以點數購買：",[823,824,825,841],"table",{},[826,827,828],"thead",{},[829,830,831,835,838],"tr",{},[832,833,834],"th",{},"動作",[832,836,837],{},"交易紀錄",[832,839,840],{},"RequestId 範例",[842,843,844,858,871,883],"tbody",{},[829,845,846,850,853],{},[847,848,849],"td",{},"經紀人信用卡儲值 1,500 點",[847,851,852],{},"加值 +1,500",[847,854,855],{},[19,856,857],{},"topup-{金流交易編號}",[829,859,860,863,866],{},[847,861,862],{},"購買「刊登 1 件 90 天」",[847,864,865],{},"扣點 −300",[847,867,868],{},[19,869,870],{},"order-{訂單編號}",[829,872,873,876,879],{},[847,874,875],{},"購買「關注焦點 7 天」",[847,877,878],{},"扣點 −450",[847,880,881],{},[19,882,870],{},[829,884,885,888,891],{},[847,886,887],{},"方案取消",[847,889,890],{},"退點 +450",[847,892,893],{},[19,894,895],{},"refund-{訂單編號}",[15,897,898],{},"儲值的 RequestId 使用金流服務回傳的交易編號：金流的付款結果通知（Callback）常常會重送好幾次，有了冪等鍵，不管通知來幾次，點數都只會加一次。",[10,900,901],{"id":901},"一句話總結",[15,903,904],{},"和錢有關的數字不要只存「現在是多少」，而是把每一次異動記成一筆只增不改的交易紀錄，餘額只是可以隨時重算的快照；再用唯一索引當冪等鍵擋掉重複請求、用條件更新防止併發覆蓋與負數餘額，點數帳本就能做到每一點都查得到來源、每天都對得起帳。",[906,907,908],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":29,"searchDepth":43,"depth":43,"links":910},[911,912,913,914,915,916,917],{"id":12,"depth":43,"text":13},{"id":116,"depth":43,"text":117},{"id":407,"depth":43,"text":408},{"id":448,"depth":43,"text":449},{"id":678,"depth":43,"text":679},{"id":817,"depth":43,"text":818},{"id":901,"depth":43,"text":901},"2022-08-23",null,"md",{},"\u002Farticles\u002Fpoints-ledger-idempotency",{"title":5,"description":29},"articles\u002Fpoints-ledger-idempotency",[926,927,928,929,930],"系統設計","資料庫","MSSQL","冪等性","金流","agBkgtM-eYwGOM-0eP4Txf3qU5hNo6P7gwz9NSROvyM",1792959150566]