☰
Spring Boot校园一卡通饭卡系统:并发扣款、幂等与数据一致性实战
2026/10/13 10:35:56 网站建设 项目流程

作为一个常年帮各路朋友做管理系统的老手,我见过太多把“springboot校园一卡通学生饭卡管理系统”当成简单增删改查的项目。说实话,如果只是想应付一下演示,两天就能搭个雏形出来,但真正到了食堂高峰期的并发扣费、月底对账、退款冲正这些场景,没提前设计好底层逻辑,线上跑起来就是一场灾难。这篇文章我不打算贴一堆没用的架构图,而是把从需求拆分、表结构设计、并发扣款实现,到部署上线踩坑的完整过程拆开讲清楚。无论你是要做毕业设计、课程设计,还是帮学校内部做一个可运行的一卡通Demo,这份经验都能让你少走不少弯路。

1. 需求拆解:一卡通系统不只是“饭卡记账”

很多人在拿到这类题目时,第一反应就是“学生表、商户表、消费记录表”,然后照着管理系统的模板套一套。但等你真正接触校园一卡通的业务场景,会发现它本质是一套小型的账户支付系统,背后牵扯的是余额、流水、商户结算、权限隔离,甚至还有刷卡终端的并发压力。

1.1 核心角色与用例边界

我们以“某高校一卡通学生饭卡管理系统”为模拟项目X,它至少包含三类使用者:

  • 学生/持卡人:查询余额、充值、查看消费明细、挂失/解挂。
  • 食堂商户:接收扣款请求、查看本商户的日流水、申请结算。
  • 系统管理员:维护学生信息、商户信息、卡状态、调整参数(如每日消费限额)、处理异常退款。

一个容易被忽略的点是**“商户”和“终端”的区别**。真实场景下,商户下面会有多个刷卡窗口,每个窗口就是一个扣款终端。设计时如果不把终端单独建模,后续一旦要统计某个窗口的笔数,或者做终端级别的故障排查,就只能靠消费记录里补一个字段,非常被动。所以我在设计表结构时,一定要加一张terminal表关联merchant,哪怕是Demo也要留出这个扩展点。

1.2 三个“非功能需求”才是真正的难点

除了一眼能看出来的功能用例,还有三个隐藏需求会决定项目质量:

  1. 扣款操作的并发正确性:食堂中午十二点,几百个学生同时刷卡,如果余额扣减逻辑是简单的“先查余额,再扣钱”,必然出现超扣。这个问题下面会专门展开。
  2. 金额精度和单位:实际开发中不能把元直接存成浮点数,否则累计计算会出乱子。最佳实践是全项目用**整数“分”**作为存储单位,展示层再转换。虽然多写一个转换工具,但能避免99%的精度坑。
  3. 流水的一致性与可追溯性:每一笔余额变动(充值、消费、退款、管理员调整)都必须落到一张资金流水表,且流水号全局唯一。这样做有两个好处:一个是对账时能知道钱从哪来、到哪去,另一个是实现幂等操作的天然依据——处理重复请求时先去查流水号,如果已经存在就直接返回旧结果。

如果把这三个点当作“额外加分项”,那项目大概率会在答辩或上线初期被问倒。反过来,把它们作为核心设计原则,整个代码的架构层次会清晰很多。

2. 技术选型的逻辑与数据一致性方案

这类系统用 Spring Boot 几乎是标准答案。原因很简单:生态成熟、上手快、社区资料多,而且不管是原生 JDBC、MyBatis、JPA 还是 MyBatis-Plus,都能无缝集成。

2.1 为什么我选了 Spring Boot + MyBatis-Plus + Redis 的组合

在模拟项目X里,我最终采用了:

  • Spring Boot 2.7.x:稳定,兼容性好,JDK8/11都能跑。
  • MyBatis-Plus:省掉大量单表CRUD的重复代码,自带分页插件。
  • MySQL 8.0:存储核心业务数据。
  • Redis:用来做热点缓存(比如卡状态、今日累计消费额)以及扣款接口的防重令牌。

有人可能会说,一个校园卡系统用不上Redis吧?如果规模很小,确实可以只用数据库锁,但一旦涉及“同一张卡在多个终端同时扣款”的极端并发,Redis在性能上会让你的接口响应时间好看很多。而且它不止能防重,还能存一些临时状态,比如“正在处理中的消费请求”,避免数据库行锁长时间持有。

2.2 数据一致性:先扣 Redis 还是先扣数据库

扣款流程最怕两种结果:实际扣了但没记录流水,或者记了流水但余额没变。为了在Demo阶段就把这件事理顺,我设计了一个“两段式”流程:

  1. 收到扣款请求后,先根据卡号做Redis幂等检查(用流水号作为key,不存在则设置并继续)。
  2. 数据库事务内做三步:更新账户余额(带条件,确保余额充足)、插入资金流水、插入商户日汇总表。
  3. 事务提交后,再异步刷新Redis里的卡状态缓存。

这里面有个很关键的原则:最终以数据库流水为准,Redis只是加速和防重。即使Redis缓存掉了,数据库机制仍然能保证一致性。有人会纠结要不要用分布式事务,其实单库场景根本用不着,一个本地事务就足够。

2.3 表结构设计中的“资金字段”规范

下面是这套系统里最核心的几张表,我简化了字段,只留关键内容:

表名核心字段设计要点
student(学生)id, card_no, name, balance, statuscard_no唯一,balance为整数(单位:分)
merchant(商户)id, name, contact状态、结算账户预留
terminal(终端)id, merchant_id, terminal_no一个商户多个终端
card(卡信息)id, student_id, card_no, status, daily_limit与student可合并,但分离更清晰
recharge_record(充值)id, student_id, amount, pay_method, out_trade_no幂等字段out_trade_no唯一
consumption_record(消费)id, card_no, terminal_id, amount, consume_time, order_no流水表,索引必须覆盖查询条件
account_log(资金流水)id, card_no, type, amount, before_balance, after_balance, related_no每次余额变动都写

特别注意account_log里的before_balance和after_balance。这两个字段看起来冗余,但实际对账时极为方便:一旦余额对不上,只要从这张表按顺序重放,就能定位是哪一笔出了问题。

对于consumption_record,我加了组合索引(card_no, consume_time),以及单独的order_no唯一索引。这样既能快速查某人的消费记录,又能保证商业订单号不会重复插入。

3. 充值接口与扣款接口的落地写法

这一章直接上核心代码思路。代码不是全部内容,但能把设计讲得更具体。

3.1 充值操作:用“外部订单号”幂等

学生在线充值时,会经过第三方支付(模拟),支付平台回调后返回一个out_trade_no。我们的接口在接到回调时,不能直接加余额,而要先判断这个out_trade_no是否已经处理过。如果用关系型数据库,可以直接在recharge_record表设置唯一索引:

ALTER TABLE recharge_record ADD UNIQUE KEY uk_out_trade_no (out_trade_no);

然后在代码里捕获重复插入的异常,或者先查后插:

@Transactional public RechargeResult handlePayCallback(PayCallbackDTO dto) { // 1. 先查流水,有则直接返回,避免重复加钱 RechargeRecord existing = rechargeRecordMapper.selectByOutTradeNo(dto.getOutTradeNo()); if (existing != null) { return RechargeResult.success(existing.getAmount()); } // 2. 插入充值记录 RechargeRecord record = new RechargeRecord(); record.setStudentId(dto.getStudentId()); record.setCardNo(dto.getCardNo()); record.setAmount(dto.getAmount()); record.setOutTradeNo(dto.getOutTradeNo()); rechargeRecordMapper.insert(record); // 3. 给账户加余额 studentMapper.increaseBalance(dto.getCardNo(), dto.getAmount()); return RechargeResult.success(dto.getAmount()); }

注意:这里先插recharge_record再更新余额,是为了保证“如果更新余额失败,事务回滚后,充值记录也不存在”。顺序不能反过来。还有一个坑是“先查再插”并不绝对安全,所以唯一索引必须加,捕获DuplicateKeyException后再查一次返回已存在结果即可。

3.2 消费扣款:一条UPDATE语句搞定并发

之前说过扣款要先扣Redis,但真正的数据一致性还得靠数据库的原子操作。最简单有效的方式,是使用带有余额判断的UPDATE语句:

@Transactional public ConsumeResult consume(ConsumeRequest request) { // 参数校验、终端校验、幂等校验省略... // 核心扣款:只有余额充足时才更新 int rows = studentMapper.deductBalanceWithCheck( request.getCardNo(), request.getAmount()); if (rows == 0) { // 说明余额不足或卡号不存在 throw new BusinessException("余额不足或卡状态异常"); } // 插流水 ConsumptionRecord record = new ConsumptionRecord(); record.setCardNo(request.getCardNo()); record.setTerminalId(request.getTerminalId()); record.setAmount(request.getAmount()); record.setOrderNo(request.getOrderNo()); consumptionRecordMapper.insert(record); // 写入资金流水 accountLogMapper.insert(/* 构造before/after余额 */); return ConsumeResult.success(); }

对应的SQL是:

UPDATE student SET balance = balance - #{amount} WHERE card_no = #{cardNo} AND status = 1 AND balance >= #{amount}

关键点就是balance >= #{amount}这个条件。它是一个原子操作,无论多少请求同时进来,数据库行锁只会让其中一个通过,其他的rows都为0,直接抛出余额不足异常。这比“先select余额,再判断,再update”安全得多,在代码层面也无需额外乐观锁。

3.3 流水查询的分页与索引

学生端最常用的功能就是查消费记录。如果直接对整表WHERE card_no = ? ORDER BY consume_time DESC而不加索引,数据量到几十万条后,查询会明显变慢。所以除了第2节提到的组合索引,我还建议:

  • 如果按月份查询很频繁,可以增加consume_time的二级索引,或者直接按月份分表。
  • 使用MyBatis-Plus分页插件时,务必让数据库执行COUNT(*)时走覆盖索引。

在Demo阶段,数据量不大,很多人会忽略这个问题。但假如答辩老师现场问一句“你这表数据量大了怎么办”,你能答出索引优化和按月分表的思路,观感会完全不同。

4. 安全和权限设计:超过想象的重要

一卡通系统直接涉及钱,权限模型必须清晰。我见到太多小项目用一个“用户表+角色字段”搞定所有登录,但在这个系统里,至少需要三种完全不同的角色和对应的接口隔离。

4.1 基于RBAC的接口隔离

我习惯在Spring Boot项目里用拦截器或过滤器做最基础的角色路径控制:

  • /api/student/**必须登录且角色为学生
  • /api/merchant/**必须登录且角色为商户
  • /api/admin/**必须登录且角色为管理员

在具体的实现中,使用JWT或Session都能做,但要注意一个细节:不要把角色信息只放在前端路由里,后端每个接口都要校验。举例来说,学生端“查询余额”接口和商户端“发起扣款”接口,如果后端不校验角色,恶意用户完全可以绕过前端页面,直接调用扣款接口,往自己卡里“加钱”。我在某次测试模拟项目X时,就发现自己不校验角色也能调通商户接口,吓出一身冷汗。

4.2 金额的边界校验

接送前端请求时,所有金额相关字段都要做两层校验:

  • 非空和非负:不能出现负数或零消费。
  • 单次限额和每日限额:一卡通系统一般会设置单笔最大金额(比如50元)和每日累计消费(比如200元)。每次扣款时,除了判断余额,还要查当日累计消费是否超限。这个累计值可以实时从consumption_record里SUM出来,也可以借助Redis维护一个当日累计key,超限直接拦截。

我见过一个反面案例:某同学设计的系统只校验了余额,没有校验每日限额。学生卡被盗刷后,一晚上被刷掉几千块,后续纠纷处理非常麻烦。虽然Demo不需要真的追回钱,但这个规则代表了对真实业务的理解。

4.3 防重复提交与防篡改

除了角色和限额,扣款请求本身要防止重复。最简单的方案是基于order_no做唯一索引,并让调用方(终端机)在每次消费时传一个UUID或自增序号,服务端捕获重复插入异常后返回“重复订单但处理成功”的响应。这样即使网络重发,也不会重复扣钱。

另一个安全考量是接口签名。真实终端扣款一般不在公网暴露,而是内网调用。如果必须走公网(比如远程演示),至少要加一个简单的签名参数:调用方用约定的密钥,将card_no + amount + order_no + timestamp做MD5或HMAC,服务端验签后再处理。这个思路可以在答辩时作为亮点提一句。

5. 实测路上的坑:并发超扣与事务失效

不管设计阶段想得多完美,代码写出来总要掉几次坑。下面说三个我印象最深的,每一个都值得你记在笔记里。

5.1 第一个坑:乐观锁版本号在“余额扣减”里失效

一开始我对student表加了version字段做乐观锁,代码是:

Student s = studentMapper.selectById(cardNo); if (s.getBalance() < amount) throw ... int updated = studentMapper.updateBalanceWithVersion(cardNo, amount, s.getVersion());

但经过并发压测,发现updated经常为0,但实际余额并没有被扣错。原因是同一张卡在同一毫秒内并发请求时,两个线程都读到了同一个version,后提交的必定失败。从业务角度看,用户会觉得“明明卡里有钱,为什么提示失败,再试一次就成功”,体验很差。

换到balance >= amount的原子UPDATE后,同样场景下只有一个请求失败(因为余额不足或重复提交),不会出现“明明下载余额够却因为版本号冲突失败”的假象。所以对于这种“扣余额”操作,我强烈建议直接用SQL条件,而不是传统乐观锁。

5.2 第二个坑:事务自调用导致回滚失效

在写充值回调时,我在同一个类里写了两个方法,handlePayCallback调用本类内部的doRecharge,并给doRecharge加了@Transactional。结果测试时发现,只要doRecharge里抛了异常,充值记录和余额更新都不会回滚。

原因是Spring AOP基于代理,this调用不会经过代理对象,事务注解自然不起作用。解决办法很简单:要么把两个方法拆到两个类(独立Bean),要么通过AopContext.currentProxy()调用,或者直接把事务注解加到外部调用入口方法上,让整个入口成为一个事务。这个坑非常经典,很多新手甚至老手偶尔也会中招。

5.3 第三个坑:MySQL默认隔离级别下的锁等待超时

扣款接口用了原子UPDATE,相当于对student表某一行加锁。并发量大时,如果事务里有其他慢查询(比如插入流水前先做了一次模糊查询),锁持有时间变长,就可能触发Lock wait timeout exceeded。排查下来发现是我在事务里顺手做了一次终端信息查询,而且那台终端的联合索引没建好,全表扫描。

处理方式:

  • 事务内尽量只做与当前资金变动直接相关的操作。
  • 终端校验和卡状态校验放在事务之前完成。
  • 建立必要的索引,减少行锁持有时间。

这种问题在Demo阶段很难遇到,但你只要用三五十个线程同时刷接口,就会暴露,属于“见光死”的隐藏问题。

6. 从“能跑”到“能用”:报表、压测与部署建议

功能都写完之后,项目只能算完成60%。剩下的40%是关于如何让它稳定、可维护、可演示。

6.1 对账与报表:一卡通系统的灵魂

真正运营一卡通,管理员最关心的是“今天食堂一共刷了多少钱”“这个月哪个商户交易额最大”。所以至少要做两张统计报表:

  • 按日汇总:每个商户、每台终端的交易笔数、交易总额。
  • 按卡汇总:每张卡的充值总额、消费总额、当前余额。

实现上可以直接对consumption_record做GROUP BY terminal_id, DATE(consume_time),但大数据量下建议额外建一张daily_summary表,每天定时汇总。Demo阶段用@Scheduled定时任务每天凌晨跑一次即可。这个功能非常能体现你对“真实业务需求”的理解,也是答辩时很容易展开讲的部分。

6.2 压测工具与连接池调优

上线前的压测不是可选项。我用模拟项目X做了一次简单压测,发现默认的数据库连接池配置下,并发到100时接口RT暴涨。调整思路是:

  • 连接池初始大小和最大大小:spring.datasource.hikari.maximum-pool-size根据压测曲线调到20~50。
  • 给MySQL的innodb_buffer_pool_size、max_connections做同步提升,但别用默认的坑人配置。
  • 接口层尽量缩短事务时间,扣款接口压测下来平均10ms左右,RT在100并发下也就几十毫秒,算是健康。

压测工具我习惯用JMeter或httpx(命令行压测),前者适合出图表报告,后者适合快速验证。关键是压测一定要造足够多的卡数据,而不是只用一个卡号去压,否则行锁会严重拉高RT。

6.3 部署环境的一些碎碎叨

最后说一下部署。如果只是演示,大家喜欢用mvn spring-boot:run在开发环境跑,但真实交付时建议打成Jar包,配合systemd或supervisor管理进程。我踩过的坑有:

  • 端口未开放:宿舍网络或云主机安全组忘了放行8080,能本地访问但外部请求不通。
  • MySQL时区问题:连接串少加serverTimezone=Asia/Shanghai,导致时间字段差8小时。
  • Redis持久化配置:如果Redis没配RDB/AOF,服务重启后所有缓存和防重数据丢失,虽然不影响最终一致,但会让部分请求在重启瞬间异常。

另外,管理员的初始密码务必用BCrypt加密后入库,不要明文存储。这算是安全底线,哪怕只是Demo也要这样。

7. 项目扩展与个人经验收尾

如果你做的模拟项目X还有余力,以下几个方向可以继续投入:

  • 人脸识别消费:现在很多学校都在用,本质上只是把卡号换成识别到的身份ID,再走同一套扣款逻辑。
  • 离线消费:终端先本地记账,再批量上传。这需要设计上传批次号和本地流水号,保证重复上传不重复扣款。
  • 消息推送:充值到账通知、余额不足提醒,可以使用短信或小程序订阅消息。

如果只是想把这个系统作为毕业设计,我的建议是:与其堆砌更多花哨页面,不如把扣款并发、幂等、流水一致性这三块核心代码打磨干净。我面试别人时,最常问的也是这三块。因为它们是区分“会写增删改查”和“能设计业务系统”的分水岭。

最后再分享一个小技巧:把所有金额输出前的转换统一封装成一个工具方法,比如FenUtils.toYuanString(Long fen),并且在所有JSON返回的VO字段上用String或Long承载金额,避免精度丢失。这个习惯能让你少挨骂几次。

希望这篇总结对正在做校园一卡通项目的人有用。系统不算大,但麻雀虽小五脏俱全,认真走一遍,你能学到的远不止一个Spring Boot项目本身。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询