2026/8/29 8:27:42

在线支付应用数据库课设实战:从E-R模型到事务并发控制

在线支付应用数据库课设实战:从E-R模型到事务并发控制 简介数据库设计是构建可靠业务系统的核心地基尤其在涉及资金交易的在线支付场景中表结构、事务与并发控制直接决定系统的正确性与稳定性。从E-R模型抽象业务实体到依据范式与反范式平衡设计订单、支付单、流水等核心表再到利用索引优化查询性能每一步都体现数据库原理的工程落地。事务隔离级别、乐观锁与幂等更新机制则是保证资金不超扣、不重复入账的关键技术。无论是完成数据库课程设计还是构建真实的交易系统掌握这些基础方法都能有效应对高并发下的数据一致性问题。本文以在线支付应用为例完整展示从业务建模、表结构设计到核心SQL与事务实操的全程并对字符集不一致导致索引失效、长事务死锁等经典问题给出排查思路为同类项目提供可复用的参考样本。 做数据库课设选“在线支付应用”这个方向我一开始以为是件挺简单的事无非就是建几张表、存一下订单和用户再把支付状态改一改。真把《数据库系统原理》的课程要求套进去才发现这个题目其实能挖得很深——从E-R模型、范式设计到事务隔离级别、索引优化、并发控制几乎把课程核心知识点全都覆盖了。这篇文章把我做“北京交通大学《数据库系统原理》课程设计在线支付应用”的完整过程整理出来包括业务建模思路、表结构设计、核心SQL与事务实现、以及踩过的坑希望能给正在做同类课设的同学一个能直接参考的样本也让想了解“数据库原理到底怎么落地”的开发者有些收获。整个项目我采用MySQL 8.0作为数据库后端用Spring Boot写接口但重点全放在数据库本身。代码可以抄SQL可以复制但真正值钱的是每张表为什么这么建、每个事务为什么这么写背后的原理。下面我按项目的真实推进顺序来写。1. 需求分析与模型设计在线支付不是“扣钱”这么简单1.1 在线支付的核心业务闭环在线支付应用表面上就是用户发起支付、系统扣款、商户收款这三步。但作为数据库课程设计必须把业务拆到“能用数据表完整表达”的程度。我梳理出来的核心闭环是用户在小程序或App端浏览商品、提交订单系统创建订单并同步生成一笔待支付记录用户调用支付渠道完成付款支付渠道异步回调通知结果系统校验回调合法性更新订单与账户余额、生成交易流水用户发起退款时走退款单流程原路退回并记录负向流水。这五个环节每一环都对应至少一张数据表。最初我图省事想把订单和支付信息放在一张表里后来在做退款流程时立刻发现行不通一笔订单可能被拆成多次支付一次支付又可能对应多次退款如果用一张大表硬扛要么字段冗余到不可控要么根本没有办法表达这种一对多的关系。所以要理解这个项目的数据库设计第一步是理解为什么需要把订单、支付单、交易流水彻底拆开。1.2 订单、支付单、流水三层拆分三层拆分不是设计技巧是被业务状态逼出来的。订单表负责记录“用户买了什么”核心属性是商品快照、订单金额、订单状态。支付单表负责记录“一次支付行为是否完成”核心属性是支付渠道、第三方流水号、支付状态。交易流水表负责记录“账户上的每一分钱怎么进来的、怎么出去的”核心属性是变动方向、变动金额、变动前后余额。我见过不少同学把支付渠道和第三方订单号直接塞进订单表然后订单表里同时出现“待支付、已支付、已发货、已退款、退款中”一大堆状态整个表变成一个巨型状态机写SQL时条件多到怀疑人生。三层拆分之后每一张表的状态机都变得极为简单订单表只关心业务订单状态支付单表只关心支付状态流水表只负责追加记录不更新、不删除。这个设计思路其实在数据库课程里就对应着“职责单一”和“降低数据冗余”的范式思想。1.3 概念模型到E-R图的映射课程设计文档通常要求先画E-R图。我画图时定义了七个实体用户、商户、商品、订单、支付单、交易流水、退款单。其中用户与订单是一对多订单与支付单是一对多支付单与退款单是一对一订单与流水是一对多。商户与商品是一对多。在E-R模型阶段我额外做了一件课程不要求但实际很关键的事把“账户”和“用户”区分开。用户是身份主体账户是资金载体一个用户拥有一个账户。这样设计的好处是如果以后要做余额理财、红包、佣金之类的扩展不需要改动用户表只需要扩展账户类型。虽然是个课程设计但面向扩展的设计习惯值得从一开始就养成。2. 表结构设计与索引规划把范式落到实际字段上2.1 六张核心表的字段定义我把最终确定的表结构直接分享出来这是经过三轮调整之后的结果。第一轮按纯理论设计把范式推到极致结果发现查询要join五张表第三轮加入了适度冗余性能才平衡下来。用户表user字段名类型说明idbigint主键自增mobilevarchar(20)手机号唯一索引nicknamevarchar(50)用户昵称statustinyint状态0禁用1正常create_timedatetime创建时间账户表account字段名类型说明idbigint主键user_idbigint关联用户唯一索引balancebigint余额单位分frozen_amountbigint冻结金额单位分versionint乐观锁版本号订单表orders字段名类型说明idbigint主键order_novarchar(32)业务订单号唯一索引user_idbigint下单用户merchant_idbigint商户total_amountbigint订单金额单位分statustinyint订单状态create_timedatetime创建时间支付单表payment_order字段名类型说明idbigint主键trade_novarchar(32)支付单号唯一索引order_novarchar(32)关联订单号channelvarchar(20)支付渠道amountbigint支付金额statustinyint支付状态0待支付1成功2失败callback_timedatetime回调时间out_trade_novarchar(64)第三方支付流水号流水表account_flow字段名类型说明idbigint主键account_idbigint账户IDflow_novarchar(32)流水号唯一change_amountbigint变动金额正数入账负数出账before_balancebigint变动前余额after_balancebigint变动后余额biz_typetinyint业务类型1支付2退款3充值biz_novarchar(32)业务单号create_timedatetime创建时间退款单表refund_order字段名类型说明idbigint主键refund_novarchar(32)退款单号唯一payment_trade_novarchar(32)原支付单号order_novarchar(32)原订单号refund_amountbigint退款金额statustinyint退款状态0处理中1成功2失败2.2 金额字段为什么一定要用bigint存“分”关于金额字段我见过很多初学者直接用float或double然后对账时发现差了0.01元怎么都找不出来。原因很简单二进制浮点数无法精确表示大多数十进制小数0.1在double里其实是无限循环的二进制近似值。数据库设计里处理金额有三个可选方案decimal(10,2)最直观SQL可读性好但decimal计算时性能略低而且不同数据库的decimal实现细节有差异bigint存分整数运算绝对精确性能最高前端展示时自行除以100字符串存金额只适合特殊业务场景主流系统基本不用。我最后选了bigint存分。理由有三个第一账务系统对精度是零容忍的整数运算能彻底规避精度问题第二性能上bigint比decimal更快索引也更小第三这是目前支付行业用得最普遍的做法照着主流方案走不会错。前端需要展示时后端返回的数字除以100转成元即可。2.3 索引设计从查询需求反推索引是《数据库系统原理》课程的重点也是这个项目里最能体现功力的地方。我设计的索引不是拍脑袋加的而是把系统里最高频的几个查询全部列出来再反推需要哪些索引高频查询一用户查看自己的订单列表。条件为WHERE user_id ? ORDER BY create_time DESC所以建立(user_id, create_time)组合索引既能过滤用户又能利用索引完成排序。高频查询二支付网关回调时根据第三方流水号查询支付单。条件为WHERE out_trade_no ?所以给out_trade_no建立唯一索引同时这个唯一索引还承担防重复回调的作用。高频查询三对账系统按天拉取某渠道的支付记录。条件为WHERE channel ? AND create_time BETWEEN ? AND ?建立(channel, create_time)组合索引。高频查询四用户查询自己的交易流水。条件为WHERE account_id ? ORDER BY create_time DESC LIMIT ?建立(account_id, create_time)组合索引。我特别想强调的一点是主键之外我几乎没有为每张表设计单独的status索引。开发初期我给订单表加了status单列索引后来看执行计划发现当某个状态值占比超过10%时MySQL全表扫描比走索引更快这个索引其实几乎没用还增加了写入开销。后来我把它改成(user_id, status)组合索引才有效果。索引不是越多越好而是越贴合查询越好。2.4 范式与反范式的实战平衡课程里讲第三范式要求非主属性不能传递依赖于主键。我在订单表里冗余了merchant_name字段表面上看违反了第三范式因为商户名是从商户表传递依赖过来的。但在实际操作中订单创建后商户可能改名字订单详情页需要展示“下单时的商户名”。如果去join商户表拿当前名字会出现历史订单显示新商户名的错误。所以这里的冗余反而是业务需要的同时避免了高频查询时的多表关联。课程设计答辩如果被问到这一点可以理直气壮地说范式是理论指导真实系统在可控范围内用反范式换查询性能和历史快照一致性是行业通用做法。3. 核心SQL与事务实操从下单到退款全流程3.1 下单接口事务保证订单与支付单同时可见用户提交订单时后端需要同时往orders表和payment_order表插入数据。这两条insert如果不放在同一个事务里就会出现“订单创建成功但支付单创建失败”的脏数据。我在Spring Boot里用Transactional声明事务核心代码如下Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderRequest request) { // 1. 生成业务订单号 String orderNo generateOrderNo(); // 2. 插入订单表 insertOrder(orderNo, request); // 3. 插入支付单表 insertPaymentOrder(orderNo, request.getPayChannel()); return orderNo; }对应的两条SQL是INSERT INTO orders (order_no, user_id, merchant_id, total_amount, status, create_time) VALUES (#{orderNo}, #{userId}, #{merchantId}, #{totalAmount}, 0, NOW()); INSERT INTO payment_order (trade_no, order_no, channel, amount, status, create_time) VALUES (#{tradeNo}, #{orderNo}, #{channel}, #{totalAmount}, 0, NOW());这里的Transactional默认隔离级别是数据库的默认级别MySQL默认是REPEATABLE READ。对于这个场景READ COMMITTED其实足够且并发性能更好。但我没有刻意改隔离级别因为单条insert场景下隔离级别差异几乎无感知过度优化反而增加理解成本。真正需要注意的是事务千万不要在循环里开启一个请求一个事务就够了。3.2 支付回调幂等更新的关键动作支付回调是整个系统里最容易出问题的环节。第三方支付渠道为了保证回调送达会重试多次。如果系统没有做幂等处理一条回调被处理两次用户账户就被重复加两次钱这属于重大资金安全事故。幂等更新的核心SQL是UPDATE payment_order SET status 1, callback_time NOW(), out_trade_no #{outTradeNo} WHERE trade_no #{tradeNo} AND status 0;这条SQL的巧妙之处在于status 0这个条件充当了乐观锁。第一次回调执行成功后status变成1第二次回调再来WHERE条件里status 0已经匹配不到任何行受影响行数为0程序就知道这是重复回调直接返回成功即可不再执行加款逻辑。在支付回调的事务里我同时做了三件事更新支付单状态、更新订单状态、插入账户流水。这三件事要么全成功要么全失败不能出现支付单成功但订单还是待支付的情况。这里也解释了为什么把流水表设计成只追加不更新——流水是资金审计的依据一旦允许修改对账就完全失去意义。3.3 余额扣减防止并发超扣用户支付成功后需要从用户账户余额里扣钱。这里要处理一个典型的并发问题用户同时发起两笔支付两个请求都读到余额为100元各自扣减50元最后余额变成50元而不是0元这属于严重的超扣。两种解法我都试过。第一种是悲观锁用SELECT ... FOR UPDATESELECT id, balance FROM account WHERE user_id #{userId} FOR UPDATE; -- 在业务代码里判断余额是否足够 -- 然后执行 UPDATE account SET balance balance - #{amount} WHERE id #{accountId};FOR UPDATE会锁住账户行直到事务提交或回滚。这期间其他事务的SELECT ... FOR UPDATE会被阻塞从而避免同时读到旧余额。优点是一定不会错缺点是并发量上来之后锁等待严重。第二种是乐观锁用版本号UPDATE account SET balance balance - #{amount}, version version 1 WHERE user_id #{userId} AND balance #{amount} AND version #{version};这条SQL把“检查余额”和“更新余额”合并成一条原子语句balance #{amount}是余额约束version #{version}是并发控制条件。受影响行数为0时说明余额不足或者版本变化需要重试或报错。这是我在生产环境里更推荐的方式因为不需要显式锁行并发表现更好。3.4 退款流程与流水冲正退款是支付的逆向操作。用户申请退款后系统创建退款单并调用支付渠道退款接口。退款成功回调后同样需要幂等更新退款单状态同时插入一条负向流水把用户的账户余额加回去。退款涉及两张表的状态同步-- 更新退款单状态 UPDATE refund_order SET status 1 WHERE refund_no #{refundNo} AND status 0; -- 更新原支付单状态为已退款 UPDATE payment_order SET status 2 WHERE trade_no #{paymentTradeNo} AND status 1; -- 插入流水change_amount为正数表示退款入账 INSERT INTO account_flow (account_id, flow_no, change_amount, before_balance, after_balance, biz_type, biz_no, create_time) VALUES (#{accountId}, #{flowNo}, #{refundAmount}, #{before}, #{before} #{refundAmount}, 2, #{refundNo}, NOW());退款场景里before_balance和after_balance必须精确记录这是对账的基础。我见过把这两个字段省略的流水表等到要排查资金差异时完全无从下手。流水表里的每一行都应该能回答这样一个问题这笔钱在哪个时刻、因为什么业务、让余额从多少变成了多少。4. 常见问题与排查技巧实录4.1 字符集不一致导致索引失效项目联调时遇到过一个问题订单表和支付单表join查询时执行计划显示全表扫描明明两个表都有索引。排查过程不算难但也让我印象很深。两个表的order_no字段一张表建表时用了默认的utf8mb4_0900_ai_ci排序规则另一张手工建表时带了utf8mb4_general_ci两张表字段的排序规则不一致MySQL无法直接使用索引做关联只能先把一张表的字段做隐式转换再比较于是索引失效。检查方法很简单执行计划里看到Using where; Using join buffer大概率就是字符集或排序规则不一致。解决办法是统一两边的排序规则建议所有库表统一使用utf8mb4字符集和utf8mb4_0900_ai_ci排序规则。数据库中字符串比较跟数字比较不一样除了内容本身“怎么比”也很关键字符集不一致就是在比较规则上产生了分歧。4.2 长事务引发死锁压力测试时出现过一次死锁报错信息是Deadlock found when trying to get lock; try restarting transaction。排查后发现问题出在我在事务里调用了第三方支付渠道的HTTP接口。支付回调进来后事务先更新了支付单状态然后在事务内发起HTTP请求通知商户系统此时事务持有支付单的行锁。商户系统收到通知后又反向调用了查询订单接口这个查询在另一个事务里想读同一行数据被阻塞等待。如果这个时候有另一个回调线程持有了别的锁就很容易形成循环等待。解决办法是把HTTP调用移到事务之外。事务内只做数据库状态更新提交成功后再通知商户系统。即使通知失败也可以通过定时任务补偿不需要在事务里等网络响应。这是我在这个项目里学到的非常重要的一课数据库事务边界内只做数据库操作远程调用一律放到事务外。4.3 账户流水查询慢流水表跑了几个月后用户查询流水的接口明显变慢。我通过EXPLAIN分析了一条慢SQLEXPLAIN SELECT * FROM account_flow WHERE account_id 1001 ORDER BY create_time DESC LIMIT 20;执行计划显示走了(account_id, create_time)组合索引理论上应该很快。但实际响应时间超过1秒。再往下排查发现SELECT *把所有字段都取出来了包括几个很大的VARCHAR字段同时这个查询还触发了大量的回表操作。优化方案是两个方向第一把SELECT *改成只查需要的字段第二创建覆盖索引(account_id, create_time, change_amount, biz_type, biz_no)让查询所需的所有列都在索引里MySQL就不需要回表了。优化后同样的数据量响应时间降到了几十毫秒。日常开发里SELECT *的危害在数据量小的时候看不出来一旦数据量上来就会被无限放大。4.4 连接池参数设置不合理课程设计阶段我忽视了连接池的作用用的是Spring Boot默认的HikariCP配置。后来在模拟并发压测时发现数据库连接数飙升数据库CPU也居高不下。调优参数我最终定为spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 max-lifetime: 1800000连接池不是越大越好。每个连接背后都是一个数据库线程连接数过多反而会因为上下文切换导致性能下降。20个连接对一个课程设计级别的在线支付应用来说完全足够。这个参数背后其实也体现了一个数据库原理数据库是共享资源连接管理要克制才能把资源留给真正的业务。做这个课设最深的体会是数据库设计永远在跟业务对话。第一次设计时我脑子里全是理论想着把所有表都规范到爆炸第二次我站在查询和并发角度重新审视才明白了索引为什么存在、事务边界为什么重要、幂等为什么是资金系统的第一原则。如果你也在做类似的课程设计建议先别急着写代码把业务画清楚把表结构反复推敲几遍后面写SQL会顺手很多。最后再分享一个具体的小技巧所有业务表的主键用bigint自增就行但是所有对外暴露的业务编号比如订单号、支付单号、流水号一定要单独生成、单独存字段、加唯一索引。对外展示的编号不能暴露数据库主键否则别人可以通过主键自增规律直接探测你的业务量这在真实的支付系统里是绝对不允许的。本文还有配套的精品资源点击获取