基于.NET的积分消费系统实战:账本模型、并发扣减与对账补偿
简介这是一套基于.NET框架与C#开发的积分消费系统完整源码面向需要搭建会员积分体系的企业开发者及学习ASP.NET MVC的中级程序员。系统采用ASP.NET MVC分层架构配合SQL Server数据库与ADO.NET数据访问涵盖用户管理、积分获取与消费、积分查询、规则配置及日志记录等模块可解决积分发放、兑换与追溯的业务需求。压缩包共约2000个文件整体38.97MB其中206个cs源文件与181个aspx页面构成核心业务代码968个gif与302个png为界面素材另有js、css、dll、mdf/ldf数据库文件及sln、csproj工程文件目录按Model、DBUtility、WebUI等分层组织结构清晰。目前已有71人学习下载。读者可从中获取一套可直接运行的积分系统参考实现理解实体建模、数据访问封装、控制器与视图协作方式并借鉴积分规则配置与操作日志设计用于二次开发或课程设计。1. 积分消费系统在 .NET 技术栈下的真实定位从需求到选型很多做企业信息化的朋友第一次接到「积分消费系统」的需求时脑子里第一反应是「不就是加加减减吗」。真动手才发现积分系统本质是一个高并发、强一致、带账本审计的类金融系统只不过流通的不是钱是积分。用户签到、下单返积分、积分抵现、积分兑换商品、积分过期清零每一步都涉及余额变更和流水记录一旦对不上账运营和财务就会找上门。这个标题里的 .NET 和 C# 指向的是实现语言和运行时而「积分消费系统」才是业务核心。我做过几套类似的东西有 WinForm 给门店收银台用的也有 ASP.NET Core 做线上商城的踩过的坑足够写一篇血泪经验。这篇文章面向的是手里有积分业务需求、准备用 C# 落地、或者已经写了一版但总觉得哪里不对的工程师。我会把账本模型、并发扣减、事务边界、对账补偿这几件事讲透代码可以直接抄去改。选 .NET 不是因为别的语言不行而是 C# 在事务、类型安全、异步这块写业务代码确实顺手尤其是 decimal 类型处理金额和积分比浮点数省心太多。2. 积分账本的数据模型为什么不能只存一个余额字段2.1 余额字段的诱惑与代价新手最容易犯的错是在用户表上加一个Points字段扣积分就UPDATE Users SET Points Points - 100 WHERE Id id。单机低并发时能跑一旦同时有两个请求扣同一用户的积分或者扣减过程中程序崩溃余额和实际流水就对不上了。更麻烦的是运营要查「这个用户上个月为什么少了 500 分」你只能看到一个当前余额没有任何可追溯的记录。积分系统的本质是账本账本的核心原则是余额是流水的推导结果不是独立存储的真相。所以正确的做法是拆成两张表一张积分流水表记录每一笔增减一张账户表存当前余额作为冗余加速查询两者必须在同一个事务里更新。2.2 三张核心表的结构设计我一般会设计三张表积分账户表、积分流水表、积分消费订单表。账户表存用户维度的汇总流水表存每一笔变动订单表存消费兑换的业务单据。下面给出 SQL Server 的建表脚本MySQL 把IDENTITY换成AUTO_INCREMENT、DATETIME2换成DATETIME即可。-- 积分账户表每个用户一条余额是流水汇总的冗余 CREATE TABLE PointAccount ( UserId BIGINT NOT NULL PRIMARY KEY, Balance DECIMAL(18,2) NOT NULL DEFAULT 0, -- 当前可用积分 FrozenBalance DECIMAL(18,2) NOT NULL DEFAULT 0, -- 冻结积分下单未支付时占用 TotalEarned DECIMAL(18,2) NOT NULL DEFAULT 0, -- 累计获得用于等级计算 RowVersion ROWVERSION NOT NULL, -- 乐观锁版本号 UpdatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); -- 积分流水表只增不改账本审计的唯一依据 CREATE TABLE PointTransaction ( TxId BIGINT IDENTITY(1,1) PRIMARY KEY, UserId BIGINT NOT NULL, ChangeAmount DECIMAL(18,2) NOT NULL, -- 正数为增加负数为扣减 BalanceAfter DECIMAL(18,2) NOT NULL, -- 变动后余额方便对账 BizType TINYINT NOT NULL, -- 1签到 2下单返 3抵现 4兑换 5过期 6人工调整 BizOrderNo VARCHAR(64) NULL, -- 关联业务单号幂等键 Remark NVARCHAR(200) NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), INDEX IX_User_Created (UserId, CreatedAt) ); -- 积分消费订单表兑换或抵现的业务单据 CREATE TABLE PointConsumeOrder ( OrderNo VARCHAR(64) NOT NULL PRIMARY KEY, UserId BIGINT NOT NULL, PointsUsed DECIMAL(18,2) NOT NULL, Status TINYINT NOT NULL, -- 0待支付 1已完成 2已取消 CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME() );账户表的Balance和FrozenBalance分开是因为下单到支付完成之间有个时间窗口积分要先冻结再扣减否则用户下单后立刻把积分花在别处订单支付时就会扣成负数。RowVersion是 SQL Server 的行版本用来做乐观锁MySQL 可以用一个Version INT字段手动维护。流水表的BalanceAfter字段很关键对账时只要按时间顺序累加流水看最终值是否等于账户余额就能发现数据不一致。BizOrderNo是幂等键同一个业务单号只能产生一笔流水防止重复回调导致积分多发。2.3 余额与流水的一致性约束有人会问既然流水是真相为什么还要存余额因为每次查余额都去SUM流水用户量一大就慢得没法看。余额是性能妥协的产物代价是必须保证它和流水一致。我的做法是在同一个事务里先插流水、再更新账户更新时带上RowVersion条件如果影响行数为 0 说明有并发冲突重试整个事务。这样即使两个请求同时扣减也只有一个能成功另一个会重试后基于最新余额计算。下面这段 C# 代码演示了扣减积分的核心逻辑用的是 Dapper 做数据访问事务和乐观锁都在里面。public async Taskbool DeductPointsAsync(long userId, decimal amount, string bizOrderNo, byte bizType) { // amount 为正数内部转为负数写入流水 const int maxRetry 3; for (int i 0; i maxRetry; i) { using var conn new SqlConnection(_connStr); await conn.OpenAsync(); using var tx conn.BeginTransaction(); // 1. 幂等检查同一业务单号是否已处理 var exists await conn.ExecuteScalarAsyncint( SELECT COUNT(1) FROM PointTransaction WHERE BizOrderNono, new { no bizOrderNo }, tx); if (exists 0) { tx.Rollback(); return true; } // 2. 读取账户当前余额和版本号 var acc await conn.QuerySingleOrDefaultAsyncPointAccount( SELECT Balance, FrozenBalance, RowVersion FROM PointAccount WHERE UserIduid, new { uid userId }, tx); if (acc null || acc.Balance amount) { tx.Rollback(); return false; } // 3. 插入流水BalanceAfter 为扣减后余额 await conn.ExecuteAsync( INSERT INTO PointTransaction(UserId,ChangeAmount,BalanceAfter,BizType,BizOrderNo) VALUES(uid,chg,after,type,no), new { uid userId, chg -amount, after acc.Balance - amount, type bizType, no bizOrderNo }, tx); // 4. 更新账户带 RowVersion 乐观锁 var rows await conn.ExecuteAsync( UPDATE PointAccount SET BalanceBalance-amt, UpdatedAtSYSDATETIME() WHERE UserIduid AND RowVersionver, new { amt amount, uid userId, ver acc.RowVersion }, tx); if (rows 1) { tx.Commit(); return true; } tx.Rollback(); // 版本冲突重试 } throw new InvalidOperationException(积分扣减并发冲突重试次数耗尽); }这段代码的关键点有三个。第一幂等检查放在事务最前面用BizOrderNo唯一索引兜底即使两个请求同时进来数据库唯一约束也会挡住第二个。第二余额判断和扣减在同一个事务里中间没有其他写操作避免超扣。第三RowVersion乐观锁保证并发更新时只有一个成功失败的重试会重新读取最新余额。参数maxRetry设 3 次是经验值积分扣减冲突概率不高3 次足够设太多反而在极端情况下拖慢响应。bizType用枚举值区分来源方便后续按类型统计和对账。3. 并发扣减与事务边界把超扣和重复发放挡在门外3.1 乐观锁、悲观锁与分布式锁的取舍并发扣减积分锁的选择直接决定系统能扛多少量。乐观锁适合冲突少的场景比如用户自己操作自己的积分同一用户并发请求概率低用RowVersion重试成本小。悲观锁用SELECT ... WITH (UPDLOCK)在事务里锁住行适合秒杀兑换这种热点但锁持有时间长会拖垮数据库。分布式锁用 Redis 的SET NX适合多实例部署时防止同一用户并发但引入额外组件和锁超时问题。我的经验是单用户维度用乐观锁热点商品兑换用 Redis 分布式锁加库存预扣不要一上来就上分布式锁大部分积分系统根本到不了那个量级。下面是一个用 Redis 做用户级锁的例子锁的粒度是lock:points:{userId}超时 5 秒防止死锁。public async Taskbool DeductWithRedisLockAsync(long userId, decimal amount, string bizOrderNo) { var lockKey $lock:points:{userId}; var lockValue Guid.NewGuid().ToString(N); // SET NX EX 5原子获取锁并设过期时间 var acquired await _redis.StringSetAsync(lockKey, lockValue, TimeSpan.FromSeconds(5), When.NotExists); if (!acquired) return false; // 获取锁失败让上层重试或排队 try { return await DeductPointsAsync(userId, amount, bizOrderNo, 3); } finally { // 释放锁前校验 value防止误删别人的锁 var script if redis.call(get,KEYS[1])ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; await _redis.ScriptEvaluateAsync(script, new RedisKey[] { lockKey }, new RedisValue[] { lockValue }); } }释放锁用 Lua 脚本校验 value是因为如果业务执行超过 5 秒锁自动过期另一个请求拿到锁此时第一个请求执行完直接DEL会删掉别人的锁。这个细节很多人翻车过线上表现为「偶尔扣了积分但没扣成功」或者「锁失效后并发扣减」。锁超时设 5 秒是权衡积分扣减正常在 100 毫秒内完成5 秒足够太长会导致锁等待堆积。3.2 事务里不要做远程调用一个高频翻车点在扣积分的事务里调用第三方接口比如发短信通知、调风控服务。远程调用可能超时几秒事务一直不提交数据库连接被占满行锁不释放整个积分系统卡死。正确做法是把远程调用挪到事务提交之后用本地消息表或领域事件异步处理。我一般会在流水表加一个Notified字段事务提交后由后台任务扫描未通知的记录去发消息失败重试。这样即使通知服务挂了积分扣减本身不受影响。事务边界要尽可能短只包住数据库写操作这是积分系统稳定的底线。3.3 冻结积分的两阶段处理下单抵现场景用户提交订单时先冻结积分支付成功再真正扣减支付超时或取消则解冻。冻结用FrozenBalance字段扣减时从Balance移到FrozenBalance完成时从FrozenBalance减去并写流水。这里要注意冻结和解冻的幂等同一个订单号只能冻结一次、解冻一次。我见过因为支付回调重复触发导致积分被解冻两次、余额凭空多出来的案例。解决办法还是唯一索引加状态机订单状态从「待支付」到「已完成」只能流转一次用UPDATE ... WHERE Status0的影响行数判断。4. 积分消费的典型场景实现签到、抵现、兑换与过期4.1 每日签到的防重复与连续奖励签到是最简单的积分发放但防重复和连续签到计算容易写错。防重复用(UserId, BizType, 日期)唯一索引或者用BizOrderNo存sign:{userId}:{yyyyMMdd}。连续签到天数不要实时遍历流水算在账户表加一个LastSignDate和ContinuousDays字段签到时判断昨天是否签过是则天数加一否则重置为一。下面这段代码演示签到逻辑注意日期用服务器本地日期还是 UTC 要根据业务定跨时区业务建议统一用 UTC 存储、展示时转换。public async TaskSignResult SignInAsync(long userId) { var today DateTime.UtcNow.Date; var bizNo $sign:{userId}:{today:yyyyMMdd}; using var conn new SqlConnection(_connStr); await conn.OpenAsync(); using var tx conn.BeginTransaction(); // 幂等今天已签到直接返回 var signed await conn.ExecuteScalarAsyncint( SELECT COUNT(1) FROM PointTransaction WHERE BizOrderNono, new { no bizNo }, tx); if (signed 0) { tx.Rollback(); return SignResult.AlreadySigned; } var acc await conn.QuerySingleAsyncPointAccount( SELECT * FROM PointAccount WHERE UserIduid, new { uid userId }, tx); // 连续天数昨天签过则累加否则重置 var continuous acc.LastSignDate today.AddDays(-1) ? acc.ContinuousDays 1 : 1; // 基础 10 分连续 7 天额外奖励 50 分 var reward 10m (continuous % 7 0 ? 50m : 0m); await conn.ExecuteAsync( INSERT INTO PointTransaction(UserId,ChangeAmount,BalanceAfter,BizType,BizOrderNo) VALUES(uid,chg,after,1,no), new { uid userId, chg reward, after acc.Balance reward, no bizNo }, tx); await conn.ExecuteAsync( UPDATE PointAccount SET BalanceBalancer, TotalEarnedTotalEarnedr, LastSignDatetoday, ContinuousDaysc, UpdatedAtSYSDATETIME() WHERE UserIduid, new { r reward, today, c continuous, uid userId }, tx); tx.Commit(); return new SignResult { Reward reward, ContinuousDays continuous }; }ContinuousDays存在账户表而不是每次算是为了签到接口的响应速度。reward的计算规则用配置表驱动更好这里写死是为了演示。注意LastSignDate存的是日期不是时间比较时用.Date否则同一天不同时刻签到会判断错误。4.2 积分抵现的金额换算与上限控制积分抵现要处理三个参数兑换比例、单笔抵扣上限、是否允许部分抵扣。兑换比例比如 100 积分抵 1 元用decimal计算避免精度丢失。上限控制分两种按订单金额的百分比最多抵 50%和固定值最多抵 200 元取两者较小值。计算时先把订单金额转成分再算可用积分最后向下取整到整数积分避免出现 0.5 积分这种无法处理的值。下面是一个换算函数。public decimal CalcDeductiblePoints(decimal orderAmount, decimal userBalance, decimal pointsPerYuan, decimal maxPercent, decimal maxFixedYuan) { // 订单金额对应的最大抵扣金额元 var maxByPercent orderAmount * maxPercent; var maxByFixed maxFixedYuan; var maxYuan Math.Min(maxByPercent, maxByFixed); // 最大抵扣金额换算成积分向下取整 var maxPoints Math.Floor(maxYuan * pointsPerYuan); // 不能超过用户余额 return Math.Min(maxPoints, Math.Floor(userBalance)); }参数pointsPerYuan是每元需要多少积分比如 100。maxPercent传 0.5 表示最多抵一半。Math.Floor保证不会出现小数积分。这个函数是纯计算不涉及数据库单元测试很好覆盖建议把各种边界值都测一遍比如订单金额为 0、余额为 0、比例和固定值相等的情况。4.3 积分兑换商品的库存与积分双扣兑换商品要同时扣积分和扣库存两个操作必须在一个事务里否则会出现积分扣了库存没了或者库存扣了积分不够。库存表用UPDATE Stock SET QuantityQuantity-1 WHERE SkuIdid AND Quantity1影响行数为 0 说明库存不足回滚事务。积分扣减复用前面的DeductPointsAsync但要注意它内部自己开了事务嵌套事务在 SQL Server 里用SavePoint或者把事务提出来统一管理。我一般会把数据访问层的事务参数化让上层决定事务边界避免嵌套。兑换订单表记录OrderNo、UserId、PointsUsed、SkuId状态流转和支付订单类似。4.4 积分过期的批量处理与提前提醒积分过期是运营刚需但实现起来有坑。不能简单地按获取时间加有效期然后删除因为用户可能先花掉旧积分。常见做法是 FIFO先获得的积分先过期。实现方式是在流水表记录每笔获得的ExpireAt过期任务扫描ExpireAt now且未被消费的流水生成过期扣减流水。更简单的做法是账户表加ExpiringPoints和ExpireDate每月批量计算一次即将过期的积分提前 7 天发提醒。批量任务要分页处理每批 500 条避免一次性锁太多行。过期任务必须幂等用BizOrderNo expire:{userId}:{yyyyMM}防重复。5. 积分系统避坑与排查那些上线后才暴露的问题5.1 现象用户余额变成负数原因通常是扣减时没有校验余额或者并发下两个请求都读到旧余额然后各自扣减。解决扣减 SQL 加AND Balance amt条件影响行数为 0 就回滚同时用乐观锁或分布式锁控制并发。已经出现负数的写一个补偿脚本从流水重新汇总余额修正账户表。5.2 现象同一笔订单积分发了两次原因多是支付回调重复触发或者消息队列重投。解决流水表BizOrderNo加唯一索引插入冲突就忽略业务层先查后插但唯一索引是最后防线。注意唯一索引要包含BizType因为不同业务类型可能用相同的单号格式。5.3 现象对账时流水汇总和余额对不上原因可能是事务部分提交、手工改库、或者程序 bug。解决写一个每日对账任务按用户分组SUM(ChangeAmount)和账户Balance比对不一致的记录下来人工核查。对账任务要在业务低峰期跑避免影响线上。发现不一致不要自动修正先查原因自动修正可能掩盖 bug。5.4 现象积分扣减接口偶发超时原因可能是事务里做了远程调用或者锁等待。解决检查事务边界把非数据库操作移出事务用sp_who2或SHOW PROCESSLIST看是否有长时间运行的事务热点账户考虑用队列串行化处理。5.5 现象过期任务把不该过期的积分清了原因是没有按 FIFO 处理或者ExpireAt计算错误。解决过期前先跑一遍预演输出将要过期的用户和积分数量人工确认后再执行ExpireAt用获取时间加有效期不要用自然月末除非业务明确要求。6. 积分系统的验证与压测用对账脚本和并发测试兜底写完积分系统上线前必须做两件事对账验证和并发压测。对账验证是写一个脚本模拟一批用户随机签到、下单、兑换跑完后用流水汇总和账户余额比对不一致就说明有 bug。这个脚本用 C# 控制台程序写直接调业务接口跑 1000 个用户各 100 次操作能在几分钟内暴露大部分逻辑错误。并发压测用Parallel.ForEach或 BenchmarkDotNet模拟同一用户并发扣减看是否出现超扣或死锁。我一般会设 50 个并发线程对同一用户扣 100 次每次扣 1 分初始余额 100 分跑完余额必须是 0流水正好 100 条。下面是对账脚本的核心逻辑。// 对账按用户汇总流水和账户余额比对 public async TaskListReconcileDiff ReconcileAsync() { using var conn new SqlConnection(_connStr); var sql SELECT a.UserId, a.Balance, ISNULL(SUM(t.ChangeAmount),0) AS FlowSum FROM PointAccount a LEFT JOIN PointTransaction t ON a.UserId t.UserId GROUP BY a.UserId, a.Balance HAVING a.Balance ISNULL(SUM(t.ChangeAmount),0); var diffs await conn.QueryAsyncReconcileDiff(sql); return diffs.ToList(); }这个查询在数据量大时会慢因为要对全量流水分组。优化方式是只对账最近 7 天有变动的用户或者用增量对账记录上次对账的最大TxId只汇总之后的流水。压测时注意数据库连接池大小默认 100 个连接50 并发够用但如果有远程调用占着连接就要调大或优化。压测环境要和生产环境配置接近否则数据没参考价值。我自己的习惯是任何积分相关的改动上线前必须跑一遍对账脚本确认流水和余额一致才发布。这个习惯帮我挡掉过好几次因为事务边界写错导致的隐性 bug。积分系统不怕功能简单怕的是账对不上一旦对不上修复成本远高于开发成本。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →