简介这是一份北京交通大学《数据库系统原理》课程的在线支付应用课程设计项目面向数据库方向学生与初学者用于理解如何将数据模型、事务处理、并发控制等数据库原理落地到实际系统中。压缩包内共147个文件大小仅651KB以dart、png、h/cpp、xml/plist、gradle等文件为主dart文件包含Flutter客户端核心逻辑png为界面切图h/cpp涉及底层桥接与平台实现gradle/plist/entitlements分别对应Android与iOS的构建配置。已有187人学习下载。项目完整涵盖在线支付系统的用户界面、支付处理模块与数据库交互层并涉及加密传输、访问控制与并发优化等关键设计通过阅读源码与配置可直观学习Flutter与数据库系统集成方式、常见支付流程的实现要点以及课程设计中易忽略的工程化细节适合参考整体架构、复用核心模块或作为数据库课程设计的起点。1. online_payment_app 这门数据库课程设计到底在做什么如果你正在为《数据库系统原理》的课程设计发愁或者想用一个在线支付应用把自己的数据库知识真正落地一遍那这份笔记就是写给你的。北京交通大学的这门课程设计用 online_payment_app 作为业务载体要求不是做一个能付钱的真实系统而是把 ER 建模、关系模式、SQL 建库、事务隔离、并发控制、索引优化、权限管理这一整条数据库原理链在一个“账绝对不能错”的场景里完整跑通。支付应用最大的特点就是数据强一致、并发高、容错低天然适合拿来检验你有没有吃透数据库原理。适合正在做课程设计的学生也适合想补数据库实践短板的开发新人。2. 数据库设计先行从 ER 图到建表 DDL 的一次理清2.1 先把业务边界划清楚支付场景里的实体和关系拿到题目别急着建表。任何课程设计扣分最多的不是 SQL 写错而是实体关系没理清就开始写代码。online_payment_app 的核心业务可以收敛成这几个实体用户、账户、商家、订单、交易流水。用户和账户是分开的因为一个用户可以有多种币种账户商家也需要独立出来因为订单要记录钱付给谁订单是业务的凭证交易流水才是账务的依据。实体之间关系其实很直接一个用户拥有多个账户一个用户产生多个订单一个订单对应一笔支付请求一笔支付请求会生成一条或多条账户流水。真正容易出问题的是订单和流水的关系。订单是业务视角记录“用户买了什么”流水是账务视角记录“哪个账户的余额发生了怎样的变化”。很多同学只在订单表里加一个状态字段没有单独设计流水表结果课程设计答辩时被问到“你怎么证明这笔钱确实到账了”就卡住了。2.2 关系模式设计为什么要把流水表单独拆出来关系模式设计本质上是在回答一个问题哪些信息必须落在同一张表里哪些信息必须拆开。我的做法是把订单和流水彻底分离订单表只管业务状态流水表专门记录账户资金的每一笔变动。这样做的理由有三个一是余额可以通过流水重算数据有了溯源能力二是订单状态修改和余额变动发生在不同表事务控制更干净三是对账时只需要查流水表不需要翻业务表。要拆到什么粒度用范式理论说话。订单表里冗余商品快照product_snapshot属于典型的“为了提高查询性能而设计的非规范化”这在支付场景里是合法的因为商品名称、价格可能后续被商家修改订单必须保留付款那一刻的原始信息。流水表只记录账户、金额、方向、余额快照不冗余订单详情保持第三范式。这样职责清晰后续写事务和索引都有明确边界。2.3 建表 DDL一个可以直接抄走的支付核心表结构以下是一组可以直接在 MySQL 8.0 里执行的建表语句覆盖用户、账户、订单、交易流水四个核心表。金额类型我统一用DECIMAL(18,2)这是做支付系统的底线绝对不要用FLOAT或DOUBLE精度问题后面避坑章节会专门说。-- 账户表一个用户可有多个币种账户version 字段用于乐观锁 CREATE TABLE account ( account_id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, frozen_balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT CNY, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_currency (user_id, currency) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;账户表是支付系统里最核心的表。balance是可用余额frozen_balance是冻结余额比如用户发起支付但还没确认收货时这笔钱应该冻结而不是直接扣掉。组合唯一键uk_user_currency保证同一个用户同一个币种只有一个账户这是数据一致性在表结构层面的第一道防线。-- 订单表记录业务事实product_snapshot 冗余购买时点的商品快照 CREATE TABLE orders ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付/1已支付/2已取消, product_snapshot VARCHAR(500) NOT NULL, pay_time DATETIME NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_merchant_id (merchant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;-- 资金流水表每一笔余额变动都落一条记录balance_after 用于对账 CREATE TABLE account_transaction ( txn_id BIGINT AUTO_INCREMENT PRIMARY KEY, txn_no VARCHAR(36) NOT NULL, account_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, direction TINYINT NOT NULL COMMENT 1进账/2出账, balance_after DECIMAL(18,2) NOT NULL, biz_type VARCHAR(16) NOT NULL COMMENT PAY/FROZEN/REFUND, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_txn_no (txn_no), KEY idx_account_time (account_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;流水表的uk_txn_no唯一索引非常重要。支付系统最怕的一件事是“重复扣款”如果一笔支付请求由于网络重试被提交了两次txn_no唯一索引能在数据库层面直接拒绝第二次插入。balance_after字段记录每一笔变动后的账户余额快照后续做对账时可以用一条 SQL 按时间顺序重放流水校验中间任意时刻的余额是否正确。建表之后用INSERT造几条用户、订单、初始资金的数据然后立刻写一条简单的查询验证不要等到写事务时才发现表结构漏了字段。3. 支付事务与并发控制扣款、加款、流水怎么才不翻车3.1 事务边界怎么画一个完整支付操作里必须有哪几步支付操作典型的事务边界是从用户账户扣款、往商家账户加款、标记订单已支付、写入交易流水这四件事要么全部成功要么全部失败。如果扣款成功但流水没写进去用户的钱就凭空消失了如果订单状态没更新但钱已扣用户在业务层面会认为支付失败实际却扣了一笔钱——这两种情况都属于数据不一致。在 MySQL 里事务边界就是START TRANSACTION到COMMIT之间的所有语句。画边界的原则是所有被修改的数据行必须在一个事务里所有跨表的一致性校验也必须在一个事务里。我的做法是支付操作只用一条事务语句块完成不用多条应用层代码分步提交。下面这段 SQL 是一个完整的支付扣款事务建议直接在你的课程设计里作为核心逻辑。START TRANSACTION; -- 1. 对账户行加悲观锁防止并发扣款读到旧余额 SELECT balance, version FROM account WHERE account_id 101 FOR UPDATE; -- 2. 条件更新余额充足才扣款避免超扣 UPDATE account SET balance balance - 59.90, version version 1 WHERE account_id 101 AND balance 59.90; -- 3. 更新订单状态只有待支付订单才能支付成功 UPDATE orders SET status 1, pay_time NOW() WHERE order_no PAY202401010001 AND status 0; -- 4. 写入资金流水保留余额变更后的快照 INSERT INTO account_transaction (txn_no, account_id, order_no, amount, direction, balance_after, biz_type) VALUES (UUID(), 101, PAY202401010001, 59.90, 2, 940.10, PAY); COMMIT;这段代码里最关键的是SELECT ... FOR UPDATE和UPDATE ... WHERE balance amount的配合。FOR UPDATE对账户行加了排他锁第二个会话在事务提交前操作同一行账户会被阻塞这是悲观锁的做法适合支付这种强一致场景。balance 59.90是条件更新即使两个会话都读到了旧余额数据库层面也会保证只有满足条件的那一条更新生效。最后流水表里写的balance_after是 940.10这个值必须在业务代码里通过本次查询到的余额扣减后得到不能直接用 SQL 去查实时余额。3.2 存储过程封装支付逻辑什么时候该用什么时候不该用把支付逻辑写进存储过程是很多数据库课程设计的加分项。存储过程的好处是事务逻辑封在数据库内部应用层只需一条CALL调用减少了网络往返也避免了应用层忘记提交事务导致的数据不一致。下面是一个简化版的支付存储过程模板包含事务补偿和前置校验。DELIMITER $$ CREATE PROCEDURE sp_pay_order( IN p_order_no VARCHAR(32), IN p_account_id BIGINT, IN p_amount DECIMAL(18,2), OUT o_code INT ) BEGIN DECLARE v_balance DECIMAL(18,2) DEFAULT 0.00; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET o_code -1; END; START TRANSACTION; -- 检查订单是否处于可支付状态 SELECT status INTO v_status FROM orders WHERE order_no p_order_no FOR UPDATE; IF v_status ! 0 THEN ROLLBACK; SET o_code -2; END IF; -- 扣款与写流水 UPDATE account SET balance balance - p_amount WHERE account_id p_account_id AND balance p_amount; IF ROW_COUNT() 0 THEN ROLLBACK; SET o_code -3; END IF; INSERT INTO account_transaction (txn_no, account_id, order_no, amount, direction, balance_after, biz_type) VALUES (UUID(), p_account_id, p_order_no, p_amount, 2, 0.00, PAY); COMMIT; SET o_code 0; END$$ DELIMITER ;存储过程把事务的COMMIT/ROLLBACK收敛到了数据库内部应用层只管调用和接收返回码。但存储过程不能滥用逻辑一旦膨胀会非常难维护调试也麻烦。我的建议是课程设计里封装三个最核心的存储过程即可——支付、退款、转账其余查询逻辑留在应用层。注意存储过程里SELECT ... FOR UPDATE同样必要因为订单状态的查询也需要锁住那一行避免两个事务同时把同一个订单改成已支付。3.3 隔离级别怎么选REPEATABLE READ 加行锁的合理性MySQL InnoDB 默认的隔离级别是REPEATABLE READ这在支付场景下够用原因在于我们用了行锁把并发变成了串行。如果你把隔离级别降到READ COMMITTED会出现幻读问题——在转账查询里同一个事务两次SELECT COUNT(*)返回不同结果。如果升级到SERIALIZABLEInnoDB 会把普通的SELECT也转成锁定读并发能力大幅下降。对于课程设计建议保持REPEATABLE READ不修改全局隔离级别通过事务里的FOR UPDATE来控制关键行的并发访问。一个常见误用是有人把隔离级别当作万能钥匙遇到并发问题就往上调结果死锁频繁。隔离级别解决的是读一致性行锁解决的是写冲突两个维度别混淆。验证并发正确性的方法不复杂开两个 MySQL 会话同时对同一个账户执行扣款看第二次执行是不是被阻塞到第一个事务提交后才继续这正是FOR UPDATE生效的表现。4. 性能与安全加固索引策略、权限收敛和备份恢复4.1 索引不是越多越好支付场景最少需要哪几个索引课程设计答辩里索引几乎是必问题。索引设计的原则是为高频查询和唯一约束建索引不为低基数列建索引。支付系统里高频查询基本是这几类按订单号查订单、按用户查账户、按账户查流水、按时间范围查流水。前面建表时已经给了对应的索引下面用EXPLAIN验证一下效果。EXPLAIN SELECT * FROM account_transaction WHERE account_id 101 AND created_at 2024-01-01;id select_type table type key rows Extra 1 SIMPLE account_transaction range idx_account_time 3 Using index conditiontype是range说明idx_account_time联合索引被正确使用扫描行数只有 3 行。如果这里显示ALL代表全表扫描那是索引设计有问题。注意联合索引的顺序(account_id, created_at)能同时过滤账户和时间范围如果你写成(created_at, account_id)则只能利用时间列账户过滤会失效。索引列上不要套函数比如WHERE DATE(created_at) 2024-01-01会让索引作废应该写成created_at 2024-01-01 AND created_at 2024-01-02。4.2 权限管理课程设计也要有最小权限的意识数据库权限控制是很多课程设计评分细则里容易被忽略的加分项。你的项目应该至少有两个数据库账号一个是管理员账号用于建表和迁移一个是应用运行账号用于业务读写。应用账号只给SELECT、INSERT、UPDATE不要给DROP、TRUNCATE等危险权限避免应用被注入后拿到整库的生杀大权。-- 创建应用账号并限制只允许本机连接 CREATE USER app_userlocalhost IDENTIFIED BY app_pass_2024; -- 只授权业务所需权限禁止 DDL 权限 GRANT SELECT, INSERT, UPDATE ON payment_db.* TO app_userlocalhost; -- 回收权限演示可选 REVOKE DELETE ON payment_db.* FROM app_userlocalhost;权限管理的另一个层次是视图。可以创建一个视图专门展示账户汇总信息只暴露用户名字和余额不暴露账户内部的管理字段。这样应用层即使查询视图也拿不到无关数据数据泄露的风险面更小。视图不解决性能问题它解决的是逻辑隔离。4.3 备份与恢复不做备份的课程设计等于裸奔实验过程中误删表、误清数据几乎是每个数据库开发者的必修翻车课。课程设计阶段就要养成备份习惯。用mysqldump做逻辑备份是最直接的手段关键参数是--single-transaction它在 InnoDB 下能拿到一致性快照备份过程中不影响业务读写。# 备份 payment_db 到带日期后缀的文件 mysqldump -u root -p \ --single-transaction \ --routines \ --triggers \ payment_db /backup/payment_db_$(date %F).sql--routines会把存储过程和触发器一起备份--triggers备份触发器这两个参数在课程设计里很容易漏掉。备份文件后务必做一次恢复演练mysql -u root -p -e DROP DATABASE payment_db mysql -u root -p -e CREATE DATABASE payment_db CHARACTER SET utf8mb4 mysql -u root -p payment_db /backup/payment_db_2024-06-01.sql恢复演练的意义不是备份本身而是确保备份可用。很多翻车事故是备份文件是生成了恢复时报错或者数据不完整等于没备份。课程设计答辩时能说“我做过恢复演练耗时 X 秒”比写十行“我配置了自动备份”有力得多。5. 课程设计避坑指南五个让我返工的血泪问题5.1 金额精度翻车59.9 存成了 59.899999现象对账时发现账户余额和流水累计对不上金额总是差零点几分。原因建表时用了DOUBLE或FLOAT类型。浮点数在二进制里无法精确表示十进制小数59.90实际存储为59.899999累计多次后误差会明显暴露。解决把所有金额字段统一改为DECIMAL(18,2)。DECIMAL是定点数按十进制存储不会出现二进制精度丢失。另外代码里不要对DECIMAL做除法后直接赋值给另一个DECIMAL字段要先四舍五入。5.2 并发超扣同一账户被两个请求同时扣款现象账户余额 100 元两个并发请求各扣 60 元最终余额变成 -20 元而不是被第二次扣款拒绝。原因事务里先SELECT balance检查余额再做UPDATE检查余额和扣款之间没有锁保护。两个事务同时读到余额 100都认为可以扣款后提交的覆盖了先提交的。解决用SELECT ... FOR UPDATE锁定账户行或者在UPDATE的条件里加balance amount让数据库在行锁上做唯一裁决。我的习惯是两个都用先加锁确保读取一致再加条件更新做兜底。5.3 死锁和长时间未提交事务现象某个会话执行了UPDATE之后一直不提交其他会话操作同一行时一直卡住直到连接超时。原因在 Navicat 或命令行工具里手动执行了事务但忘记COMMIT或者应用层事务里抛了异常但没有ROLLBACK。事务占用的锁只有提交或回滚才会释放。解决开发时把autocommit 1打开应用层用try ... finally确保任何情况下都执行提交或回滚用下面这条 SQL 定期排查长事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx WHERE trx_started NOW() - INTERVAL 30 SECOND;发现长时间未提交的事务确认无误后用对应的trx_mysql_thread_id去处理不要贸然KILL先看清楚它占用了哪些表。5.4 索引失效给索引列加了函数还怪 SQL 慢现象按订单创建时间查数据时特别慢EXPLAIN显示全表扫描。原因查询条件写成了WHERE DATE(create_time) 2024-01-01对索引列套了函数MySQL 无法正常使用索引。解决改写为范围查询WHERE create_time 2024-01-01 AND create_time 2024-01-02。另外避免在索引列上做隐式类型转换比如索引列是VARCHAR查询条件是WHERE order_no 123这也会让索引失效。5.5 误删数据的后悔药只能靠备份现象课程设计演示前清理脏数据一条DROP TABLE orders把业务表整个删了且没有备份只能从零建表。原因在测试环境操作时把生产库和测试库混用或者执行了没写WHERE的DELETE没有提前备份。解决改造备份习惯每次做大规模变更前执行一次mysqldump花了三十秒备份省下的是几个小时的重建时间。更稳妥的做法是给应用账号撤销DROP权限只保留SELECT、INSERT、UPDATE核心开发操作全部走管理员账号并提前备份。6. 答辩前的验证技巧用 EXPLAIN 和并发脚本证明设计正确验证数据库设计不能只说“我测试过了”。课程设计答辩要把验证过程量化让评审老师看到你是真的理解了原理。第一个技巧是准备好几条重点 SQL 的EXPLAIN输出逐条解释索引的使用情况和扫描行数第二个技巧是写一个并发压测脚本证明你的事务在并发扣款场景下不超扣、不丢流水。下面是一个 Python 脚本的骨架用十个线程同时对一个账户发起扣款请求扣款前余额 500 元每笔扣 50 元最终余额必须是 0且流水记录必须是 10 条。import threading import pymysql conn pymysql.connect(hostlocalhost, userapp_user, passwordapp_pass_2024, databasepayment_db) def deduct(thread_id): try: with conn.cursor() as cursor: # 这里启动一个独立事务生产建议每个线程单独建立连接 conn.begin() cursor.execute(SELECT balance FROM account WHERE account_id 101 FOR UPDATE) balance cursor.fetchone()[0] if balance 50: conn.rollback() return cursor.execute(UPDATE account SET balance balance - 50 WHERE account_id 101) cursor.execute(INSERT INTO account_transaction (txn_no, account_id, order_no, amount, direction, balance_after, biz_type) VALUES (%s, 101, %s, 50, 2, %s, PAY), (ftxn_{thread_id}, forder_{thread_id}, balance - 50)) conn.commit() except Exception as e: conn.rollback() threads [threading.Thread(targetdeduct, args(i,)) for i in range(10)] for t in threads: t.start() for t in threads: t.join()脚本跑完后执行对账查询检查余额与流水累计是否一致SELECT account_id, (SELECT balance FROM account WHERE account_id 101) AS current_balance, SUM(CASE WHEN direction 2 THEN amount ELSE -amount END) AS txn_total FROM account_transaction WHERE account_id 101;如果current_balance为 0txn_total为 -500说明并发控制生效。这里有一个实现细节要留意每个线程应该建立独立的数据库连接共用一个连接会导致事务互相干扰上面示例为了简写共用了一个连接你实际复用时务必改成每线程一个连接。最后一个忠告答辩时不要只给老师看页面把建表 DDL、存储过程、EXPLAIN结果、并发脚本输出这四样东西整理成文档每一项都能解释“为什么这么设计”。我的习惯是先讲业务场景和 ER 模型再拿并发测试数据讲事务设计最后用EXPLAIN讲索引这比任何口头描述都有说服力。希望这份沿着标题拆出来的实战路径能帮你少走几个坑把课程设计真正做成一件拿得出手的作品。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站