简介:这是一份《数据库原理与应用》课程中校园卡管理系统数据库设计的完整文档,面向需要完成数据库课程设计或初学数据库原理的高校学生。文档以校园卡日常管理、电子钱包、身份认证三个子系统为背景,完整展示了需求分析、数据入库、存储过程创建、系统调试与测试等核心环节;并针对食堂消费、超市消费、课程考勤、宿舍归宿等场景,给出数据字典、逻辑结构定义和存储过程定义。附录收录了全部SQL运行语句及功能验证方法,可直接对照实现或作为答辩参考。资源为单个PDF文件,大小1.28MB,已有119人学习下载。内容还兼顾安全性、完整性约束、性能优化与故障恢复,配合校园卡办理、充值、挂失等业务流程图,能帮助读者系统理解数据库设计从分析到实施的完整思路。
1. 校园卡管理系统数据库设计:从一张卡到一组表,课程设计最容易翻车的地方
校园卡丢了要挂失,补卡后旧卡流水还得查得着;食堂高峰上千人同时刷卡,余额不能被扣成负数;月底对账差五毛钱,不该查一整夜——这些场景的背后是一套数据库设计。很多学生拿到「数据库原理与应用校园卡管理系统数据库设计」这类课程设计题目,第一反应是画张 ER 图、建几张表交差,结果一上真数据就翻车:并发扣款把余额扣成负数、补卡后历史流水对不上、日终对账差几分钱找不出原因。这里按一套完整设计往下拆:业务怎么建模、实体和联系怎么定、关系模式怎么做范式取舍、建表 SQL 怎么落、并发怎么控、对账怎么验证,让新手能照着复现,让熟手能拿去答辩和压测。
2. 需求分析与概念结构设计:先拆业务边界,再画 ER 图
评审一份数据库课程设计,最先看的就是需求分析和 ER 模型。这块如果立不住,后面的表结构就是空中楼阁。很多同学为了凑实体数量,把「充值方式」「食堂窗口」这种纯属性硬抬成实体,E-R 图变得又臃肿又难辩护。正确顺序是:先讲清业务用例,再从用例里圈实体、连联系,最后才落 ER 图。
2.1 从卡片生命周期倒推业务用例,增删改查不是凭空来的
把校园卡的一生列出来:发卡、充值、消费、挂失、解挂与补卡、销户退款。每个环节都对应一组明确的数据库动作:发卡是新增持卡人、新增卡片并同时创建资金账户;充值要更新账户余额并写入充值流水;消费是条件扣款加消费流水;挂失要同时改卡状态和账户冻结标记;补卡只新增一张卡,不允许改动原账户余额;销户则要查余额、走退款流程、再置账户销户。课程设计文档里最常被问到的「增删改查」就是从这些动作来的,不是凭空列出来给老师凑数的。
这些业务动作里藏着关键约束,应整理成一张用例表,写清楚每个动作的输入、输出和不允许出现的情况:
| 业务动作 | 涉及对象 | 主要数据库操作 | 核心约束 |
|---|---|---|---|
| 发卡 | 持卡人、卡片、账户 | INSERT 三张表 | 同一持卡人可有多张历史卡 |
| 充值 | 账户、流水 | UPDATE 余额 + INSERT 流水 | 充值流水号唯一 |
| 消费 | 账户、卡片、商户、流水 | UPDATE 余额 + INSERT 流水 | 余额不足时禁止扣款 |
| 挂失 | 卡片、账户 | UPDATE 卡状态 + UPDATE 冻结标记 | 挂失后账户不可消费 |
| 补卡 | 卡片 | INSERT 新卡 + UPDATE 旧卡状态 | 不改变账户余额与流水 |
| 销户 | 账户、流水 | UPDATE 账户状态 + INSERT 退款流水 | 余额不为负,退款有据 |
实际操作时我一般会按四步走:从需求描述里圈出所有名词,这是候选实体;把纯属性剔除,姓名、余额、时间是属性不是实体;为保留独立行为或独立状态的对象单独建实体,卡有挂失状态,账户有冻结状态,二者必须分开;最后用动词连线,充值、消费、挂失都是联系,联系两端各是什么实体一目了然。
2.2 实体清单:持卡人、校园卡、账户、商户、流水为什么必须分开
实体识别的核心原则是独立状态和独立行为。卡片有「正常、挂失、注销」三种状态,账户有「正常、冻结、销户」三种状态,两种状态变更并不是同步发生的:挂失卡片的瞬间,账户余额要冻结,但账户本身并没有销户;补卡后新卡正常使用,账户状态仍然继承。把卡和账户合成一张表,挂失和补卡时就要反复改同一行的同一批字段,稍微一个事务漏提交,数据就互相污染。
校园卡管理系统里最少要有五个核心实体。持卡人统一建模,学生和教职工都放进同一张表,用身份类型区分;校园卡一张实体表,存物理卡号、卡面号、状态;账户是资金实体,余额和冻结标记与实体卡分离;商户是收款方,终端挂靠在商户下;交易流水记录每一笔充值、消费、退款,是系统里增量最大的一张表。把这五个实体列成清单,属性边界就清楚了:
| 实体 | 核心属性 | 备注 |
|---|---|---|
| 持卡人 | 学号、姓名、证件类型、证件号、手机号、状态 | 学生与教职工共用 |
| 校园卡 | 卡面号、物理卡号、持卡人ID、账户ID、状态 | 一人可多卡,含历史卡 |
| 账户 | 持卡人ID、余额、冻结标记、乐观锁版本号 | 一持卡人一个活跃账户 |
| 商户 | 商户名称、类型、状态 | 含食堂、超市、门禁维护方 |
| 终端 | 终端编号、所属商户ID、位置 | 消费 POS、门禁读卡器 |
| 交易流水 | 业务流水号、卡ID、账户ID、商户ID、金额、类型 | 只追加,不修改不删除 |
交易流水必须独立建模而不是当卡片的属性集合:一笔流水同时关联卡片、商户、终端三个对象,而且流水只有「插入」和「查询」两种操作,天然要单独成表。很多课程设计把流水设计成卡片表的一个 JSON 字段或一张弱表,后续做对账、日结、审计时全部卡壳。
2.3 联系与基数:1:1、1:N、M:N 的三个决策检查点
实体之间的联系方式,是 ER 图里最容易扣分的地方。校园卡系统里的基数关系不复杂,但每个决策点背后都对应真实业务约束:持卡人与校园卡是 1:N,因为补卡后旧卡仍在历史数据里存在;卡片与账户是 1:1,业务上一个活跃持卡人只对应一个资金账户,补卡不换账户;卡片与流水是 1:N,商户与流水是 1:N,终端与流水是 1:N;如果需求包含门禁授权,卡片与门禁点是 M:N,必须建授权中间表。
检查基数设计时,我习惯用三个问题自测。第一,挂失时能不能只改卡状态、不碰账户余额?如果答案是不能,说明卡和账户的职责没有拆干净。第二,一个商户有多个终端,报表是按商户汇总还是按终端汇总?消费流水必须能同时回溯「哪个商户、哪个终端」,所以终端 ID 和商户 ID 都要出现在流水里。第三,学校大门门禁和宿舍门禁是同一个实体吗?如果两个系统的授权时间、授权范围不一致,就应当拆成两类门禁点,而不是在一个实体上堆一堆用不到的属性。
ER 图画到这里,已经能看出这套设计的骨架:五张核心实体表加若干联系表,后面逻辑结构设计就是把这张图翻译成关系模式。这一步不要省,评审老师问「为什么有这个表、为什么这样关联」时,ER 图就是你最直接的答辩证据。
3. 逻辑结构设计:ER 转关系模式,范式检查别只停留在理论
概念结构定了,下一步是把 ER 模型转成关系模式。这一步教材里叫逻辑结构设计,落到课程设计里就是「定表结构、定主外键、查范式」。范式不是用来背名词的,流水表、账户表里那几个典型问题,能用范式理论解释清楚,比堆一堆教科书原话更有说服力。
3.1 转换规则与第一版关系模式清单
ER 转关系模式有三条通用规则。1:1 联系尽量把一方的主键放进另一方,用作外键,比如卡片表里直接放 account_id;1:N 联系把 1 方主键放进 N 方,流水表里放 card_id、merchant_id;M:N 联系必须建独立中间表,卡片与门禁点的授权关系就落在授权表上。按照这套规则,上一章的 ER 图落成关系模式后是这样一组表:
| 关系模式 | 主键 | 外键 / 关联字段 |
|---|---|---|
| 持卡人表 | holder_id | 无 |
| 校园卡表 | card_id | holder_id, account_id |
| 账户表 | account_id | holder_id |
| 商户表 | merchant_id | 无 |
| 终端表 | terminal_id | merchant_id |
| 交易流水表 | trans_id | holder_id, card_id, account_id, merchant_id, terminal_id |
| 授权表(可选) | auth_id | card_id, gate_id |
| 门禁点表(可选) | gate_id | 无 |
这版表清单能覆盖校园卡系统的核心场景:消费、充值、挂失、补卡、商户日结、流水查询。是不是还需要扩展表,要看选题范围。如果题目给了图书馆借阅或考勤场景,就再往里加实体;如果只要求校园卡消费和充值,这八张表已经足够撑起一篇课程设计。
3.2 用流水表做范式体检:两个必改的反例
范式检查不要对着每一张表空喊「这符合 3NF」,要拿着具体字段做判断。流水表是最容易出问题的,举两个典型反例。
第一个反例:流水表里直接存了商户名称,而不是 merchant_id。这违反第三范式。商户名称属于商户表,如果商户改名叫「第一食堂」,历史流水里的旧名称不会跟着变,日终报表里就会出现两张名字不同的同一商户数据。正确做法是流水表只存 merchant_id,需要商户名时通过 JOIN 关联查询。
第二个反例:流水表以 (card_id, trans_id) 做联合主键。这种设计问题更隐蔽。联合主键意味着表的主键由两个字段共同构成,但 trans_time、trans_type 这些字段只依赖于 trans_id,不依赖于 card_id,这就产生了部分函数依赖,表停留在第一范式到第二范式之间。实际业务里卡片丢失补办后 card_id 变化,历史流水的主键也跟着变化,整个关联全乱。
正确设计是流水表用单列自增主键 trans_id,card_id 作为普通字段加索引。单列主键消灭部分依赖,流水类型、交易时间都完全依赖于主键,整个表天然符合 2NF 和 3NF。这是答辩时最值得主动展示的一个分析过程。
3.3 合法反范式与冗余字段的答辩口径
范式检查通过后,还有一个绕不开的问题:流水表里要不要存 balance_after(交易后余额)?从第三范式角度看,交易后余额可以由账户表的 balance 反推,属于冗余字段。但从账务系统角度看,这个字段又必须存在,因为流水表承担审计职责,要还原交易发生那一刻的账户状态;如果账户余额后来被别的交易修改,再查流水就看不到「当时剩多少」了。
这种冗余属于可控反范式,课程设计文档里应当主动写一段设计说明:balance_after 字段的取值由同一事务内的扣款语句写入,来源是账户表更新后的余额,不存在多处维护的问题;它服务的是对账和审计需求,不参与日常余额计算。同样道理,流水表冗余 holder_id 也是为补卡后按人查流水服务的。把「为什么冗余、谁来保证一致」写清楚,评审老师不但不会扣分,反而会觉得你有工程意识。
4. 物理设计与建表落地:一套可以直接抄的 MySQL DDL
逻辑结构设计完成后,进入物理设计:选引擎、定字符集、写建表语句、设计索引。这部分是「数据库 SQL」和「数据库优化」最集中的体现,也是整套设计能不能跑起来的关键。课程设计答辩时要现场演示,SQL 写得不好不仅在 MySQL 里报错,性能演示时还会卡得尴尬。
4.1 引擎、字符集与金额字段的三个硬性选择
建库之前先定三个全局参数。存储引擎选 InnoDB,理由是事务安全和行级锁。校园卡消费是典型的小事务高并发场景,扣款和写流水必须在一个事务里完成,MyISAM 不支持事务,直接排除。字符集选 utf8mb4,排序规则用 utf8mb4_general_ci。utf8mb4 能存生僻字和 emoji,商户名里出现特殊符号也不会写入失败;只存中文姓名的老设计用 utf8 够用,但课程设计没必要冒这个风险。
金额字段必须用 DECIMAL,这是血泪经验。FLOAT 和 DOUBLE 是浮点数,二进制无法精确表示所有十进制小数,0.1 加 0.2 都可能出现 0.30000000000000004。校园卡余额和流水金额涉及钱,累计误差到月底对账时就是翻车现场。余额字段用 DECIMAL(10,2),能表示到千万级别的金额,校园卡场景绰绰有余。
建库语句收在这里,后面所有表都默认落在这个库下:
CREATE DATABASE campus_card DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;逻辑说明:数据库级的字符集设置决定了后续建表不指定字符集时的默认值,避免每张表单独声明还出现遗漏。排序规则选 general_ci 足够支持中文与英文字符的等值查询和排序,性能比 utf8mb4_unicode_ci 略好。
4.2 四张核心表的建表 SQL:账户、卡片、商户、流水
建表时优先建被引用的基础表。这里按「先账户、再卡片、再商户、最后流水」的顺序给出四张主表。每张表的注释字段和业务约束都写进 DDL,方便课程设计文档直接截图。
CREATE TABLE t_account ( account_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '账户ID', holder_id BIGINT UNSIGNED NOT NULL COMMENT '持卡人ID', balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额,单位元', freeze_flag TINYINT NOT NULL DEFAULT 0 COMMENT '冻结标记:0正常 1冻结', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_holder (holder_id) ) ENGINE=InnoDB COMMENT='资金账户表';逻辑说明:balance 用 DECIMAL(10,2),严禁换成 DOUBLE;freeze_flag 用 TINYINT 存字典值,不存字符串,节省空间且状态扩展方便;version 字段为并发扣款做乐观锁准备;holder_id 上有唯一键,保证一个持卡人只有一个活跃账户。
CREATE TABLE t_card ( card_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '卡ID', holder_id BIGINT UNSIGNED NOT NULL COMMENT '持卡人ID', account_id BIGINT UNSIGNED NOT NULL COMMENT '账户ID', card_no VARCHAR(32) NOT NULL COMMENT '卡面号', chip_no VARCHAR(32) NOT NULL COMMENT '芯片物理号', status TINYINT NOT NULL DEFAULT 1 COMMENT '卡状态:0注销 1正常 2挂失', issue_time DATETIME NOT NULL COMMENT '发卡时间', expire_time DATETIME NULL COMMENT '有效期,NULL为长期有效', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE KEY uk_card_no (card_no), KEY idx_holder (holder_id) ) ENGINE=InnoDB COMMENT='校园卡表';逻辑说明:chip_no 是物理卡号,卡面号可以补印,物理号不可以,所以两者分开存;status 用 0/1/2 字典编码,挂失是状态更新不是删行;idx_holder 支撑按持卡人查所有历史卡。
CREATE TABLE t_merchant ( merchant_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '商户ID', merchant_name VARCHAR(64) NOT NULL COMMENT '商户名称', category TINYINT NOT NULL DEFAULT 0 COMMENT '商户类型:0食堂 1超市 2门禁', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0停用 1正常', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT='商户表';逻辑说明:商户类型用字典值而不是字符串,日结报表要按类型分组统计,TINYINT 更高效;停用商户用状态位标记,不删除历史数据,流水表里的关联才不会断。
CREATE TABLE t_transaction ( trans_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '流水ID', trans_no VARCHAR(40) NOT NULL COMMENT '业务流水号,全局唯一', holder_id BIGINT UNSIGNED NOT NULL COMMENT '持卡人ID,冗余用于按人查询', card_id BIGINT UNSIGNED NOT NULL COMMENT '卡ID', account_id BIGINT UNSIGNED NOT NULL COMMENT '账户ID', merchant_id BIGINT UNSIGNED NULL COMMENT '商户ID,充值场景为NULL', terminal_id BIGINT UNSIGNED NULL COMMENT '终端ID', trans_type TINYINT NOT NULL COMMENT '流水类型:1消费 2充值 3退款 4圈存', amount DECIMAL(10,2) NOT NULL COMMENT '变动金额,充值正数,消费负数', balance_after DECIMAL(10,2) NOT NULL COMMENT '交易后账户余额', status TINYINT NOT NULL DEFAULT 1 COMMENT '流水状态:0失败 1成功 2冲正', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '交易时间', UNIQUE KEY uk_trans_no (trans_no), KEY idx_card_time (card_id, create_time), KEY idx_merchant_time (merchant_id, create_time), KEY idx_holder_time (holder_id, create_time) ) ENGINE=InnoDB COMMENT='交易流水表';逻辑说明:trans_no 由业务系统生成,例如「时间戳+随机数」或「日期+终端号+序号」,数据库里加唯一键防止重复记账;amount 用正负号区分入账和出账,对账时 SUM(amount) 直接出净变动额;balance_after 是反范式字段,作用已在上一章说明。这四张表建完,校园卡的核心数据模型已经可以支撑消费、充值、挂失、补卡和日终对账。
4.3 索引设计:流水表的查询路径决定了联合索引
索引不是建得越多越好。流水表是增量最大、查询最频繁的表,索引要围绕真实查询路径设计。校园卡场景里高频查询有三种:按卡查某张卡的历史流水、按商户查某天营业额、按持卡人查全部卡的流水。对应到索引就是三个联合索引。
ALTER TABLE t_transaction ADD KEY idx_card_time (card_id, create_time), ADD KEY idx_merchant_time (merchant_id, create_time), ADD KEY idx_holder_time (holder_id, create_time);逻辑说明:联合索引把筛选字段和排序字段揉在一起,走索引就能同时完成 WHERE 过滤和 ORDER BY 排序,避免 filesort。不要给 trans_type、status 这类低基数字段单独建单列索引,区分度太低,优化器大概率不走索引,反而增加写入开销。
数据量大了以后,流水表要按时间归档。常见做法是保留最近一年数据在在线表里,历史流水迁移到备份库或归档表;也可以在建表时就按月份做 RANGE 分区,比如 PARTITION BY RANGE (TO_DAYS(create_time)),每个月一个分区,查询只扫对应分区。课程设计阶段插入几万条测试数据验证索引效果即可,分区方案写在文档里作为扩展方案。
4.4 落库规范:隐私加密、状态字典与并发版本号
除了主表 DDL,还有三条落库规范建议写进课程设计文档。证件号属于隐私字段,不要明文落库,应用层用加密算法处理后再写入;展示时再解密,数据库里被拖库也不会直接泄露明文。所有状态字段统一用 TINYINT 字典编码,并在 COMMENT 里写明每个值的含义,这比在 Java 代码里定义魔法数字可维护得多。账户表保留 version 字段,配合条件 UPDATE 实现乐观锁,这是下一条避坑内容里的关键依赖。
5. 避坑:校园卡设计里五个典型的翻车现场
课程设计答辩翻车,几乎都翻在数据一致性问题上。这里列五个我在实际排错中见过最多的问题,每一条都按「现象到原因再到解决」的顺序讲,后面压测时能直接验证。
5.1 先查余额再扣款,并发场景把余额扣成负数
现象:模拟两个终端同时消费同一张卡,账户余额变成负数,或者余额明明够,第二次扣款却失败。
原因:应用程序先执行 SELECT balance,再在 Java 或 Python 里判断余额是否足够,最后执行 UPDATE balance = balance - amount。两个事务同时读到余额 50 元,各自判断「够扣 30 元」,先后更新后余额变成 -10 元。这是数据库并发锁没控制好,不是 MySQL 的问题。
解决:把「判断余额」和「扣款」合并成一条原子 UPDATE,由数据库的行锁保证并发安全:
UPDATE t_account SET balance = balance - 30.00, version = version + 1 WHERE account_id = 1001 AND balance >= 30.00 AND freeze_flag = 0;逻辑说明:这条语句执行时,InnoDB 对 account_id = 1001 的行加行级锁,余额判断和扣款在同一原子操作里完成;如果受影响行数为 0,说明余额不足或账户冻结,应用程序再返回对应提示。业务侧不要先 SELECT 再 UPDATE,这是并发扣款的第一原则。
5.2 补卡后历史流水查不到,问题出在按卡号关联
现象:学生丢卡补办后,在查询界面按新卡号只能看到补卡后的流水,旧卡消费记录全部消失。
原因:流水表与卡片表通过 card_id 关联,查询界面把「当前卡号」传进去过滤流水。旧卡已经被标记注销,新卡和旧卡的 card_id 不同,按新卡查自然查不到旧卡的数据。
解决:流水表冗余 holder_id,查询按持卡人维度过滤,而不是按卡号过滤。补卡不改 holder_id,同一持卡人的新卡旧卡流水都能查到。如果还想区分「这张交易是用哪张实体卡刷的」,列表里展示 card_no 即可,关联关系由流水表里的 card_id 保证。
5.3 挂失的卡还能刷成功,状态没参与扣款条件
现象:卡片挂失后,门禁或消费仍然产生成功流水,资金被扣走。
原因:挂失业务更新了 t_card.status,但扣款 SQL 只校验了账户余额,没有校验卡片状态和账户冻结标记。应用代码里虽然先查了卡状态,但查状态和扣款是两个独立操作,中间卡被挂失也拦不住。
解决:扣款条件里同时带上卡片状态和账户冻结标记,并把它们写进同一条 SQL。扣款时 JOIN t_card 校验 status = 1,账户侧校验 freeze_flag = 0。挂失事务要把「卡状态置为 2」和「账户 freeze_flag 置为 1」放进同一个数据库事务,任何一个失败都整体回滚。
5.4 日终对账永远差几分钱,流水表分散是根源
现象:日终把消费流水、充值流水分别汇总,和 t_account 的余额总额对不上,差额不是整数,怎么查都查不出来。
原因:两个问题叠加。第一,消费流水和充值流水被拆成两张表,某张表漏记或记错一笔,对账时只能靠肉眼比对;第二,余额变动和流水写入不在同一事务里,扣款成功但插流水失败,账户余额变了,流水没记录。
解决:统一为一张流水表,用 trans_type 区分业务类型;余额变动和流水插入放进同一个事务,要么都成功,要么都回滚。对账时一条 SQL 就能完成:SELECT account_id, SUM(amount) FROM t_transaction WHERE status = 1 GROUP BY account_id,再与 t_account.balance 逐行比对。这个设计在第 4 章建表时已经预留好了。
5.5 emoji 商户名写入报错,建库字符集选错了
现象:插入商户名「奶茶店」的 emoji 变体时报 Incorrect string value,但中文插入一切正常。
原因:数据库字符集是 utf8(MySQL 的 utf8 实际最多 3 字节),而 emoji 需要 4 字节存储。建库时没选 utf8mb4,等于把 4 字节字符直接拒之门外。
解决:建库时指定 utf8mb4 和 utf8mb4_general_ci,已经建好的库用 ALTER DATABASE 和 ALTER TABLE 转成 utf8mb4。应用层连接串也要加 useUnicode=true&characterEncoding=utf8mb4,否则程序层转码仍会失败。字符串字段长度也要注意,utf8mb4 下 VARCHAR(64) 存 emoji 占用的字节数比中文多,长度评估要留余量。
6. 验证与进阶:对账视图、并发压测和答辩的几个关键口径
设计写完,怎么证明它站得住?这里给三样东西:两张对账视图、一个并发压测脚本、三个答辩口径。
对账视图把「账户余额」和「流水汇总」拉到同一张表里对比,日终对账从跑批变成一次查询:
CREATE VIEW v_balance_check AS SELECT a.account_id, a.balance AS account_balance, COALESCE(SUM(t.amount), 0) AS flow_sum FROM t_account a LEFT JOIN t_transaction t ON t.account_id = a.account_id AND t.status = 1 GROUP BY a.account_id, a.balance;逻辑说明:视图把账户余额与流水净变动额并列展示,两者不一致的行就是要排查的差异行。商户日结同理,按商户和日期分组统计营业额:
CREATE VIEW v_merchant_daily AS SELECT merchant_id, DATE(create_time) AS trade_date, SUM(CASE WHEN trans_type = 1 THEN amount ELSE 0 END) AS sale_amount, COUNT(*) AS trans_count FROM t_transaction WHERE status = 1 GROUP BY merchant_id, DATE(create_time);并发扣款验证用 Python 多线程循环跑同一账户的扣款 SQL,观察最终余额是否等于初始余额减去成功扣款总额:
import threading import pymysql config = dict(host='127.0.0.1', user='root', password='123456', database='campus_card', charset='utf8mb4') def deduct(account_id, amount): conn = pymysql.connect(**config) try: with conn.cursor() as cur: affected = cur.execute( "UPDATE t_account SET balance = balance - %s, " "version = version + 1 " "WHERE account_id = %s AND balance >= %s AND freeze_flag = 0", (amount, account_id, amount)) if affected == 0: print('扣款失败:余额不足或账户冻结') return cur.execute( "INSERT INTO t_transaction " "(trans_no, holder_id, card_id, account_id, merchant_id, " "terminal_id, trans_type, amount, balance_after, status) " "VALUES (%s, 1, 1, %s, 1, 1, 1, %s, " "(SELECT balance FROM t_account WHERE account_id = %s), 1)", (f"TEST{account_id}{amount}", account_id, amount, account_id)) conn.commit() finally: conn.close() threads = [threading.Thread(target=deduct, args=(1, 10)) for _ in range(100)] for t in threads: t.start() for t in threads: t.join()逻辑说明:100 个线程同时扣同一个账户 10 元,初始余额 1000 元时,成功扣款次数应为 100,最终余额 0;如果出现负数或扣款次数超过 100,说明并发控制失效。balance_after 用 UPDATE 后的账户余额查询写入,避免应用层读到旧值。
答辩时三个关键口径要提前准备好。第一,范式问题怎么解释:流水表里的 balance_after 和 holder_id 是可控冗余,服务于审计和按人查询,由事务保证一致,不是设计疏漏。第二,为什么用 DECIMAL 而不用 FLOAT:金额精度是账务系统的底线,浮点数累加会产生误差。第三,为什么扣款不用存储过程:业务扣款逻辑涉及多表状态校验,放在应用层用事务控制更灵活;触发器能自动记账,但会放大锁竞争,高并发场景下性能不划算。
我自己的习惯是,课程设计交付前一定跑一遍这个压测脚本,并保留执行结果截图放进文档。答辩时直接展示「100 并发扣款后余额不为负、流水总数正确」,比背十页范式定义都管用。希望帮到你。
本文还有配套的精品资源,点击获取