简介:数据库设计是构建可靠业务系统的核心地基,尤其在涉及资金交易的在线支付场景中,表结构、事务与并发控制直接决定系统的正确性与稳定性。从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:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| mobile | varchar(20) | 手机号,唯一索引 |
| nickname | varchar(50) | 用户昵称 |
| status | tinyint | 状态:0禁用,1正常 |
| create_time | datetime | 创建时间 |
账户表account:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 关联用户,唯一索引 |
| balance | bigint | 余额,单位分 |
| frozen_amount | bigint | 冻结金额,单位分 |
| version | int | 乐观锁版本号 |
订单表orders:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 业务订单号,唯一索引 |
| user_id | bigint | 下单用户 |
| merchant_id | bigint | 商户 |
| total_amount | bigint | 订单金额,单位分 |
| status | tinyint | 订单状态 |
| create_time | datetime | 创建时间 |
支付单表payment_order:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| trade_no | varchar(32) | 支付单号,唯一索引 |
| order_no | varchar(32) | 关联订单号 |
| channel | varchar(20) | 支付渠道 |
| amount | bigint | 支付金额 |
| status | tinyint | 支付状态:0待支付,1成功,2失败 |
| callback_time | datetime | 回调时间 |
| out_trade_no | varchar(64) | 第三方支付流水号 |
流水表account_flow:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| account_id | bigint | 账户ID |
| flow_no | varchar(32) | 流水号,唯一 |
| change_amount | bigint | 变动金额,正数入账,负数出账 |
| before_balance | bigint | 变动前余额 |
| after_balance | bigint | 变动后余额 |
| biz_type | tinyint | 业务类型:1支付,2退款,3充值 |
| biz_no | varchar(32) | 业务单号 |
| create_time | datetime | 创建时间 |
退款单表refund_order:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| refund_no | varchar(32) | 退款单号,唯一 |
| payment_trade_no | varchar(32) | 原支付单号 |
| order_no | varchar(32) | 原订单号 |
| refund_amount | bigint | 退款金额 |
| status | tinyint | 退款状态: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 UPDATE:
SELECT 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分析了一条慢SQL:
EXPLAIN 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自增就行,但是所有对外暴露的业务编号,比如订单号、支付单号、流水号,一定要单独生成、单独存字段、加唯一索引。对外展示的编号不能暴露数据库主键,否则别人可以通过主键自增规律直接探测你的业务量,这在真实的支付系统里是绝对不允许的。
本文还有配套的精品资源,点击获取