系統(tǒng)實(shí)戰(zhàn):從數(shù)據(jù)庫(kù)設(shè)計(jì)到并發(fā)扣減與對(duì)賬)
簡(jiǎn)介本資源是一套基于.NET框架與C#語(yǔ)言開發(fā)的積分消費(fèi)系統(tǒng)完整源碼面向需要構(gòu)建會(huì)員積分管理功能的中初級(jí).NET開發(fā)者與課程設(shè)計(jì)學(xué)習(xí)者。系統(tǒng)采用ASP.NET MVC分層架構(gòu)配合SQL Server數(shù)據(jù)庫(kù)與ADO.NET數(shù)據(jù)訪問(wèn)技術(shù)涵蓋用戶管理、積分獲取、積分消費(fèi)、積分查詢、積分規(guī)則配置及操作日志記錄等核心模塊前端以HTML、CSS、JavaScript結(jié)合jQuery實(shí)現(xiàn)交互。壓縮包共約2000個(gè)文件包含206個(gè)cs源碼、181個(gè)aspx頁(yè)面、145個(gè)js腳本、70個(gè)css樣式及968個(gè)gif、302個(gè)png等界面素材另有46個(gè)dll依賴庫(kù)與數(shù)據(jù)庫(kù)文件整體約38.97MB目錄按Model、DBUtility、WebUI等分層組織結(jié)構(gòu)清晰便于二次開發(fā)。目前已有72人學(xué)習(xí)下載適合作為積分系統(tǒng)設(shè)計(jì)參考、畢業(yè)設(shè)計(jì)原型或企業(yè)積分模塊的起步框架。1. 從一份 .rar 說(shuō)起.NET 積分消費(fèi)系統(tǒng)到底在解決什么問(wèn)題商場(chǎng)會(huì)員卡里躺著幾千積分顧客到店消費(fèi)時(shí)收銀臺(tái)卻查不到余額運(yùn)營(yíng)做了一場(chǎng)雙倍積分活動(dòng)第二天財(cái)務(wù)對(duì)賬發(fā)現(xiàn)流水對(duì)不上門店斷網(wǎng)了積分核銷直接卡死。這些場(chǎng)景背后幾乎都指向同一類系統(tǒng)——基于 .NET 的積分消費(fèi)系統(tǒng)。它要干的事很樸素把「積分發(fā)放、積分扣減、消費(fèi)核銷、流水追溯」這四件事做成一套能扛住并發(fā)、能對(duì)賬、能離線兜底的服務(wù)端程序。標(biāo)題里那串net積分消費(fèi)系統(tǒng).rar_.net_net積分消費(fèi)系統(tǒng)_積分 系統(tǒng)_系統(tǒng)_系統(tǒng) net c#看著像壓縮包被反復(fù)重命名后的殘留但核心信息很清楚.NET 技術(shù)棧、C# 語(yǔ)言、積分消費(fèi)業(yè)務(wù)。適合誰(shuí)看正在用 C# 做會(huì)員/營(yíng)銷中臺(tái)的后端或者手里拿到一份積分系統(tǒng)源碼卻不知道怎么跑起來(lái)、怎么改、怎么上生產(chǎn)的工程師。下面按「業(yè)務(wù)模型怎么立 → 數(shù)據(jù)庫(kù)怎么設(shè)計(jì) → 核心扣減怎么防超賣 → 對(duì)賬怎么查 → 坑在哪」這條線走一遍能直接抄的部分我都給代碼。2. 積分消費(fèi)系統(tǒng)的業(yè)務(wù)模型與數(shù)據(jù)庫(kù)落地先想清楚積分不是錢2.1 積分和余額的本質(zhì)區(qū)別決定了表結(jié)構(gòu)很多人第一反應(yīng)是把積分當(dāng)錢存一張User表加個(gè)Points字段就開干。這是后面所有對(duì)賬災(zāi)難的源頭。積分和現(xiàn)金余額有三個(gè)本質(zhì)差異第一積分有有效期過(guò)期要清零錢不會(huì)第二積分有來(lái)源是消費(fèi)返的還是活動(dòng)送的來(lái)源不同可能退的時(shí)候規(guī)則不同第三積分變動(dòng)必須可追溯任何一筆扣減都要能回答「什么時(shí)候、因?yàn)槟墓P訂單、扣了多少、扣完剩多少」。所以正確的做法是「賬戶表 流水表」雙表結(jié)構(gòu)賬戶表存當(dāng)前可用余額快照流水表存每一筆變動(dòng)事實(shí)。余額永遠(yuǎn)可以由流水重算出來(lái)這就是你的后悔藥。-- 積分賬戶表一個(gè)用戶一條存快照 CREATE TABLE MemberPointsAccount ( MemberId BIGINT NOT NULL PRIMARY KEY, AvailablePoints INT NOT NULL DEFAULT 0, -- 當(dāng)前可用積分 FrozenPoints INT NOT NULL DEFAULT 0, -- 凍結(jié)積分下單未支付時(shí)占用 TotalEarned INT NOT NULL DEFAULT 0, -- 累計(jì)獲得用于等級(jí)計(jì)算 RowVersion ROWVERSION NOT NULL, -- 樂(lè)觀鎖版本號(hào) UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() ); -- 積分流水表只增不改每一筆變動(dòng)一條 CREATE TABLE MemberPointsLedger ( LedgerId BIGINT NOT NULL IDENTITY PRIMARY KEY, MemberId BIGINT NOT NULL, ChangeType TINYINT NOT NULL, -- 1消費(fèi)返 2活動(dòng)贈(zèng) 3消費(fèi)扣 4過(guò)期扣 5退款回滾 ChangePoints INT NOT NULL, -- 正數(shù)增加負(fù)數(shù)扣減 BalanceAfter INT NOT NULL, -- 變動(dòng)后余額對(duì)賬關(guān)鍵字段 BizOrderNo VARCHAR(64) NULL, -- 關(guān)聯(lián)業(yè)務(wù)單號(hào)冪等鍵 ExpireAt DATETIME2 NULL, -- 該筆積分的過(guò)期時(shí)間 CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), INDEX IX_Member_Created (MemberId, CreatedAt), UNIQUE KEY UK_BizOrder_Type (BizOrderNo, ChangeType) -- 防重復(fù)入賬 );BalanceAfter這個(gè)字段是血淚經(jīng)驗(yàn)換來(lái)的。只存變動(dòng)值對(duì)賬時(shí)你得把用戶所有流水按時(shí)間累加才能算出某時(shí)刻余額一旦中間有數(shù)據(jù)修復(fù)或補(bǔ)錄累加結(jié)果和賬戶表對(duì)不上排查起來(lái)就是黑匣子。存了變動(dòng)后余額任意一筆流水都能獨(dú)立驗(yàn)證對(duì)賬腳本直接比對(duì)BalanceAfter和賬戶表AvailablePoints即可。2.2 用 C# 定義領(lǐng)域模型和入賬服務(wù)表建好了落到 C# 代碼。核心是一個(gè)入賬方法它必須在一個(gè)數(shù)據(jù)庫(kù)事務(wù)里完成「寫流水 更新賬戶余額」兩件事并且用樂(lè)觀鎖防并發(fā)覆蓋。public class PointsService { private readonly AppDbContext _db; public PointsService(AppDbContext db) _db db; /// summary /// 積分變動(dòng)統(tǒng)一入口 /// /summary /// param namememberId會(huì)員ID/param /// param namechangeType變動(dòng)類型見枚舉/param /// param namepoints變動(dòng)值正加負(fù)減/param /// param namebizOrderNo業(yè)務(wù)單號(hào)冪等鍵/param public async Task ChangePointsAsync(long memberId, ChangeType changeType, int points, string bizOrderNo) { // 冪等檢查同一業(yè)務(wù)單號(hào)同一類型只允許入賬一次 bool exists await _db.Ledgers.AnyAsync(x x.BizOrderNo bizOrderNo x.ChangeType changeType); if (exists) return; await using var tx await _db.Database.BeginTransactionAsync(); var account await _db.Accounts .FromSqlInterpolated($SELECT * FROM MemberPointsAccount WITH (UPDLOCK) WHERE MemberId {memberId}) .FirstOrDefaultAsync(); if (account null) throw new BizException(積分賬戶不存在); int newBalance account.AvailablePoints points; if (newBalance 0) throw new BizException(積分不足); account.AvailablePoints newBalance; if (points 0) account.TotalEarned points; _db.Ledgers.Add(new PointsLedger { MemberId memberId, ChangeType changeType, ChangePoints points, BalanceAfter newBalance, BizOrderNo bizOrderNo, CreatedAt DateTime.UtcNow }); await _db.SaveChangesAsync(); await tx.CommitAsync(); } }邏輯說(shuō)明先做冪等檢查避免消息重投導(dǎo)致重復(fù)加分UPDLOCK提示讓 SQL Server 在讀取賬戶行時(shí)就加更新鎖防止兩個(gè)并發(fā)請(qǐng)求同時(shí)讀到舊余額余額不足直接拋業(yè)務(wù)異常事務(wù)回滾。參數(shù)上points正負(fù)決定加減bizOrderNo是冪等的關(guān)鍵訂單號(hào)、活動(dòng)ID 都可以但必須全局唯一且和ChangeType組合唯一。這套寫法在單庫(kù)單表、日流水百萬(wàn)級(jí)以內(nèi)完全夠用再往上就要考慮分庫(kù)或把熱點(diǎn)賬戶拆成子賬戶。3. 積分扣減的并發(fā)控制為什么你的系統(tǒng)會(huì)超扣3.1 超扣的三種典型成因積分消費(fèi)最怕的不是扣錯(cuò)是超扣——用戶只有 100 分兩筆并發(fā)請(qǐng)求各扣 80結(jié)果都成功了余額變成 -60。成因有三類一是讀改寫之間沒(méi)有鎖兩個(gè)線程讀到同一個(gè)舊值二是用了UPDATE ... SET Points Points - 80這種看似原子的寫法但沒(méi)加WHERE Points 80條件扣成負(fù)數(shù)也不報(bào)錯(cuò)三是分布式環(huán)境下多個(gè)服務(wù)實(shí)例各自持有本地緩存緩存里的余額是臟的。第一種靠數(shù)據(jù)庫(kù)鎖或樂(lè)觀鎖解決第二種靠條件更新解決第三種只能靠「緩存只讀、寫走數(shù)據(jù)庫(kù)」或者用分布式鎖。3.2 樂(lè)觀鎖重試適合讀多寫少的積分場(chǎng)景積分消費(fèi)的寫并發(fā)通常遠(yuǎn)低于查詢樂(lè)觀鎖是性價(jià)比最高的方案。原理是給賬戶表加RowVersion更新時(shí)帶上版本號(hào)版本不匹配說(shuō)明有人改過(guò)重試即可。public async Taskbool DeductWithRetryAsync(long memberId, int points, string orderNo, int maxRetry 3) { for (int i 0; i maxRetry; i) { try { var account await _db.Accounts.FindAsync(memberId); if (account null || account.AvailablePoints points) return false; account.AvailablePoints - points; _db.Ledgers.Add(new PointsLedger { MemberId memberId, ChangeType ChangeType.ConsumeDeduct, ChangePoints -points, BalanceAfter account.AvailablePoints, BizOrderNo orderNo }); await _db.SaveChangesAsync(); // RowVersion 不匹配會(huì)拋 DbUpdateConcurrencyException return true; } catch (DbUpdateConcurrencyException) { // 版本沖突清掉跟蹤重新讀 foreach (var entry in _db.ChangeTracker.Entries().ToList()) entry.State EntityState.Detached; await Task.Delay(20 * (i 1)); // 退避重試 } } return false; }參數(shù)說(shuō)明maxRetry一般設(shè) 3超過(guò)說(shuō)明熱點(diǎn)賬戶沖突嚴(yán)重該換悲觀鎖或隊(duì)列串行化了Task.Delay的退避系數(shù) 20ms 起步避免重試風(fēng)暴。注意SaveChangesAsync拋的DbUpdateConcurrencyException必須捕獲否則整個(gè)請(qǐng)求 500。這套代碼在 EF Core 里依賴實(shí)體配置Property(x x.RowVersion).IsRowVersion()SQL Server 會(huì)自動(dòng)維護(hù)版本。3.3 悲觀鎖與隊(duì)列串行化的適用邊界如果某個(gè)賬戶是超級(jí)熱點(diǎn)比如平臺(tái)補(bǔ)貼活動(dòng)幾萬(wàn)人同時(shí)搶同一個(gè)活動(dòng)賬戶樂(lè)觀鎖重試次數(shù)會(huì)飆升這時(shí)候用UPDLOCK悲觀鎖更穩(wěn)代價(jià)是吞吐下降。再極端一點(diǎn)把扣減請(qǐng)求丟進(jìn)內(nèi)存隊(duì)列Channel或BlockingCollection單線程消費(fèi)天然串行但要注意進(jìn)程重啟丟消息的問(wèn)題得配合持久化隊(duì)列。我一般會(huì)先上樂(lè)觀鎖監(jiān)控重試率超過(guò) 5% 再考慮升級(jí)。4. 積分消費(fèi)系統(tǒng)的對(duì)賬與排查余額對(duì)不上時(shí)先看這三張表4.1 對(duì)賬腳本用流水重算余額對(duì)賬的核心邏輯一句話對(duì)每個(gè)會(huì)員把流水表按時(shí)間累加看最終結(jié)果是否等于賬戶表余額。不等就是有問(wèn)題。-- 找出余額不一致的會(huì)員 SELECT a.MemberId, a.AvailablePoints AS AccountBalance, ISNULL(SUM(l.ChangePoints), 0) AS LedgerSum FROM MemberPointsAccount a LEFT JOIN MemberPointsLedger l ON a.MemberId l.MemberId GROUP BY a.MemberId, a.AvailablePoints HAVING a.AvailablePoints ISNULL(SUM(l.ChangePoints), 0);跑出來(lái)如果有記錄先別急著改數(shù)據(jù)。按下面順序排查第一看這些會(huì)員的流水里有沒(méi)有BizOrderNo為空的記錄空單號(hào)說(shuō)明是人工補(bǔ)錄或程序 bug 寫入的第二看有沒(méi)有同一BizOrderNo ChangeType出現(xiàn)兩次說(shuō)明冪等失效第三看BalanceAfter字段是否連續(xù)如果某條流水的BalanceAfter和上一條加變動(dòng)值對(duì)不上說(shuō)明寫入時(shí)就有并發(fā)問(wèn)題。4.2 用 BalanceAfter 做逐筆校驗(yàn)-- 逐筆校驗(yàn)當(dāng)前流水的 BalanceAfter 應(yīng)等于上一條 BalanceAfter 當(dāng)前 ChangePoints WITH Ordered AS ( SELECT LedgerId, MemberId, ChangePoints, BalanceAfter, LAG(BalanceAfter) OVER (PARTITION BY MemberId ORDER BY LedgerId) AS PrevBalance FROM MemberPointsLedger ) SELECT * FROM Ordered WHERE PrevBalance IS NOT NULL AND BalanceAfter PrevBalance ChangePoints;這條 SQL 能精確定位到哪一筆流水開始錯(cuò)亂。常見結(jié)果是某幾筆的BalanceAfter相同說(shuō)明兩個(gè)并發(fā)事務(wù)都基于同一個(gè)舊余額計(jì)算典型的樂(lè)觀鎖沒(méi)生效或用了錯(cuò)誤的隔離級(jí)別。4.3 消費(fèi)核銷的冪等排查積分消費(fèi)經(jīng)常和訂單系統(tǒng)聯(lián)動(dòng)訂單支付成功回調(diào)扣積分。如果回調(diào)重復(fù)觸發(fā)就會(huì)重復(fù)扣。排查時(shí)重點(diǎn)看MemberPointsLedger里同一BizOrderNo是否有多條ChangeType 3的記錄。有的話檢查回調(diào)接口有沒(méi)有做冪等以及唯一索引UK_BizOrder_Type是否真的建上了——我見過(guò)有人建了索引但字段順序反了導(dǎo)致冪等失效。5. 避坑與常見問(wèn)題積分系統(tǒng)上線后最容易翻車的五個(gè)點(diǎn)現(xiàn)象一活動(dòng)期間積分扣成負(fù)數(shù)。原因用了UPDATE Account SET Points Points - p但沒(méi)加WHERE Points p或者 C# 里先查后改沒(méi)加鎖。解決所有扣減必須帶條件更新或樂(lè)觀鎖數(shù)據(jù)庫(kù)層加CHECK (AvailablePoints 0)約束兜底讓負(fù)數(shù)直接寫不進(jìn)去。現(xiàn)象二用戶退款后積分沒(méi)退回。原因退款流程只處理了錢忘了積分回滾或者回滾時(shí)用了新的BizOrderNo導(dǎo)致和原扣減對(duì)不上。解決退款回滾必須用原訂單號(hào) 新的ChangeType如 5 退款回滾并在流水里記錄關(guān)聯(lián)的原流水 ID?,F(xiàn)象三積分過(guò)期任務(wù)跑完后余額對(duì)不上。原因過(guò)期扣減是批量操作直接UPDATE賬戶表但沒(méi)寫流水或者寫了流水但BalanceAfter算錯(cuò)。解決過(guò)期也必須走統(tǒng)一的ChangePointsAsync入口一筆一筆寫流水批量任務(wù)只是循環(huán)調(diào)用。現(xiàn)象四分布式部署后同一用戶并發(fā)扣減仍然超扣。原因每個(gè)實(shí)例的 EF Core 上下文緩存了賬戶實(shí)體FindAsync直接返回緩存沒(méi)查庫(kù)。解決扣減場(chǎng)景禁用一級(jí)緩存用AsNoTracking重新查或直接走FromSqlInterpolated強(qiáng)制查庫(kù)?,F(xiàn)象五對(duì)賬腳本跑出來(lái)一堆差異但業(yè)務(wù)說(shuō)沒(méi)投訴。原因?qū)~ SQL 沒(méi)排除測(cè)試數(shù)據(jù)或歷史遷移數(shù)據(jù)BizOrderNo為空的補(bǔ)錄流水被算進(jìn)去了。解決對(duì)賬前先過(guò)濾BizOrderNo IS NOT NULL并把補(bǔ)錄流水單獨(dú)標(biāo)記ChangeType對(duì)賬時(shí)排除。6. 進(jìn)階技巧把積分消費(fèi)做成可回放的事件流前面講的都是「當(dāng)前狀態(tài)」的維護(hù)但真正讓積分系統(tǒng)好維護(hù)的是把它當(dāng)成事件流來(lái)設(shè)計(jì)。每一筆積分變動(dòng)都是一條不可變事件賬戶余額只是事件的物化視圖。這樣做的好處是任何歷史時(shí)刻的余額都能重放出來(lái)對(duì)賬不再依賴BalanceAfter字段出問(wèn)題可以直接從流水重建賬戶表。具體做法是在現(xiàn)有流水表基礎(chǔ)上加兩個(gè)字段EventVersion該會(huì)員的事件序號(hào)從 1 遞增和SnapshotAt快照時(shí)間。每處理 N 筆比如 100 筆寫一個(gè)賬戶快照重放時(shí)從最近快照開始只回放之后的流水。// 從快照 流水重建賬戶余額 public async Taskint RebuildBalanceAsync(long memberId) { var snapshot await _db.Snapshots .Where(x x.MemberId memberId) .OrderByDescending(x x.EventVersion) .FirstOrDefaultAsync(); int balance snapshot?.Balance ?? 0; int fromVersion snapshot?.EventVersion ?? 0; var events await _db.Ledgers .Where(x x.MemberId memberId x.EventVersion fromVersion) .OrderBy(x x.EventVersion) .ToListAsync(); foreach (var e in events) balance e.ChangePoints; return balance; }驗(yàn)證方法很簡(jiǎn)單對(duì)任意會(huì)員跑一遍RebuildBalanceAsync結(jié)果應(yīng)該等于賬戶表AvailablePoints。如果不等說(shuō)明流水有缺失或事件版本號(hào)有斷層這時(shí)候去查EventVersion是否連續(xù)就能定位。我自己的習(xí)慣是每周跑一次全量重放校驗(yàn)把差異會(huì)員拉出來(lái)人工核對(duì)比等用戶投訴再查主動(dòng)得多。這套事件流思路還能平滑遷移到消息隊(duì)列積分變動(dòng)發(fā)到 Kafka下游對(duì)賬、風(fēng)控、報(bào)表各自消費(fèi)系統(tǒng)邊界一下就清晰了。積分系統(tǒng)看著簡(jiǎn)單真正難的是「每一分都能說(shuō)清楚從哪來(lái)、到哪去」。把流水當(dāng)事實(shí)、余額當(dāng)視圖、對(duì)賬當(dāng)習(xí)慣這套系統(tǒng)就能睡得著覺(jué)。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取