ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL购物网站数据库设计:事务、锁与防库存超卖完整方案

MySQL购物网站数据库设计:事务、锁与防库存超卖完整方案

简介:MyShop 商城系统数据库设计资源包,面向需完成 MySQL 课程设计、毕业设计或希望掌握电商数据库设计方法的初学者与开发者。资源围绕用户、地址、商品、购物车、订单与订单项六大核心数据模块展开,并从用户管理、商品管理、购物车管理、订单管理、地址管理等处理需求入手,给出商城后台典型业务场景下的完整设计思路,可帮助读者理解实体关系、业务边界、权限控制与订单流转等关键环节。压缩包整体大小约 196.67MB,资源粒度适中,既适合在课程设计时参照搭建数据库原型,也适合电商项目前期的数据建模与表结构规划。内容兼顾数据需求分析与处理需求分析两条主线,便于对照业务功能逐层展开设计。已有 393 人学习/下载,适合需要从零梳理商城数据库全流程的初学者,以及希望借助完整案例巩固 MySQL 设计能力的开发者。

1. 购物网站数据库设计:一次库存超卖演示翻车后的完整建模方案

A同学拿着自己做的购物网站课设去演示,导师让他现场模拟两个账号同时抢一件只剩 1 件库存的商品,结果操作完一看,库存变成了 -2,订单却生成了 3 条。问题不出在他写的业务代码上,而是整个 MySQL 数据库只有一张商品表加一张订单表,没有事务、没有锁、没有库存校验。购物网站的数据库设计不是简单地建几张表存数据,而是要从业务流程反推实体关系,再落到索引、事务、并发控制的完整方案。这篇文章就以「MySQL 数据库应用」最常见的选题——购物网站系统为例,从需求分析、E-R 设计、建表 DDL、下单事务到验收测试,给出一套可以直接参考甚至照搬的数据库设计方案。

2. 需求分析与概念设计:从购物流程拆出六个核心实体

2.1 业务流程梳理:先弄清楚数据从哪来、往哪去

购物网站的数据库设计,第一步不是打开 MySQL 建表,而是先把业务流程画出来。这个步骤看着浪费时间,实际上决定了后面表之间的关系对不对。一个标准的购物流程是这样:用户注册并登录,维护个人资料和收货地址;浏览商品分类和商品列表,查看商品详情;将商品加入购物车,修改数量,勾选商品后提交订单;支付订单、商家发货、用户确认收货;后台管理商品分类、上下架商品、处理订单状态。

从这条链路能看出,数据不是零散堆在一起的,它有明确的流向。用户在商品详情页看到的价格要跟订单里记录的价格保持一致,购物车里的商品在生成订单后要被清空,订单状态从待支付到已完成要由多个字段承载。通常的做法是先把业务流程中每个环节涉及的数据名称列出来,再归并成实体和属性。这一步能避免后面反复改表结构,也能让答辩时的 E-R 图有据可依。

我在设计这一步时习惯用一个表整理各环节的数据需求:用户登录环节需要用户名和密码,商品浏览环节需要分类和价格,下单环节需要商品ID、数量、单价、收货人信息,支付环节需要订单号、支付时间、支付金额。把这些梳理清楚后,再进入实体设计就不会乱。

2.2 实体与关系:六个核心实体之间的三种联系类型

购物网站最典型的实体有六个:用户、商品分类、商品、库存、购物车、订单。其中订单还要拆出一个明细实体来记录订单包含哪些商品,所以最终的物理表通常是七到八张。它们之间的关系可以归纳为三种:

  • 用户与订单:一对多,一个用户可以下多个订单;
  • 商品与订单明细:一对多,一个商品可以出现在多个订单明细中;
  • 用户与购物车:一对多,购物车中的每条记录是某个用户对某件商品的一次加购。

这里特别要提的是订单与订单明细的关系。订单主表存的是订单的整体信息,比如总金额、支付状态、收货地址;订单明细表存的才是这个订单买了哪些商品、每件商品当时的单价和数量。为什么要把价格冗余一份到明细表?因为商品价格会变动,下单之后如果商品改价,订单里的成交价不能跟着变。这个冗余在数据库设计里看起来违反第三范式,但在电商业务里是必要的,属于为业务正确性买单的合理冗余。

商品与库存的关系也值得注意。很多课设会把库存字段直接放在商品表里,这种做法在商品数量少、并发低的时候没问题,但如果要演示限时抢购场景,独立的库存表更容易配合锁机制做扣减。我一般会把库存拆成单独一张表,字段包括可用库存、锁定库存和版本号,后面讲并发控制时会用到。

2.3 逻辑设计:主键、外键与范式取舍

逻辑设计阶段要确定每张表的主键和约束策略。主键统一使用自增整数类型 BIGINT UNSIGNED AUTO_INCREMENT,理由是写入性能好、索引占用小、唯一性稳定。用户名要加唯一索引,订单号必须加唯一索引,购物车表用用户ID加商品ID的组合唯一索引,这几个唯一约束直接决定了并发下不会出现重复数据。

外键这里有个常见的分叉。课设里为了方便画 E-R 关系图,很多人会建立物理外键,让数据库自己保证引用完整性。但实际开发中,物理外键在删除和更新时会引入额外的锁和校验开销,高并发下容易成为瓶颈,所以生产环境普遍的做法是逻辑外键——表结构里保留关联字段,但不创建 FOREIGN KEY 约束,由应用层保证数据一致性。如果你的课程设计要求必须体现外键,可以在设计文档里说明逻辑外键的理由,反倒能成为答辩的加分项。

范式方面,我不建议把三范式当作教条。用户表和订单表之间隔了收货地址表,设计上可以用外键关联,但订单表直接冗余收货人姓名、电话、地址字段会更稳妥——因为订单是历史记录,地址变更不能影响历史订单的展示。类似这种「以空间换正确性」的取舍,在电商数据库里比比皆是,理解它比背范式定义重要得多。

3. 表结构设计与建表实现:MySQL 核心 DDL 逐表拆解

3.1 用户与商品模块:user、category、product、inventory 四张表

用户表是整套设计的起点。密码字段用 CHAR(64) 存哈希值而不是明文,status 字段做逻辑删除,避免物理删除导致关联数据丢失。建表语句如下:

CREATE TABLE user ( user_id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录名', password CHAR(64) NOT NULL COMMENT '密码SHA-256哈希值', nickname VARCHAR(50) DEFAULT '' COMMENT '昵称', phone VARCHAR(20) DEFAULT '' COMMENT '手机号', email VARCHAR(100) DEFAULT '' COMMENT '邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (user_id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

username 上的唯一索引 uk_username 是并发注册场景下的最后一道防线,代码里先查重再插入并不能百分百防重,只有数据库唯一约束才能兜底。update_time 用 ON UPDATE CURRENT_TIMESTAMP 可以在每次修改时自动刷新,省去应用层手动维护的时间字段。

商品分类表支持二级分类,parent_id 为 0 表示一级分类。商品表单独存价格和上架状态,不直接放库存,为的是让库存变化不影响商品基础信息的读写:

CREATE TABLE category ( category_id SMALLINT UNSIGNED AUTO_INCREMENT COMMENT '分类ID', parent_id SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0为一级分类', category_name VARCHAR(50) NOT NULL COMMENT '分类名称', sort_order TINYINT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', PRIMARY KEY (category_id), KEY idx_parent_id (parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表'; CREATE TABLE product ( product_id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '商品ID', category_id SMALLINT UNSIGNED NOT NULL COMMENT '所属分类ID', product_name VARCHAR(200) NOT NULL COMMENT '商品名称', subtitle VARCHAR(500) DEFAULT '' COMMENT '副标题', main_image VARCHAR(255) DEFAULT '' COMMENT '主图URL', detail TEXT COMMENT '商品详情HTML', price DECIMAL(10,2) NOT NULL COMMENT '当前售价', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (product_id), KEY idx_category_id (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

这里的索引策略是:商品表只对 category_id 建普通索引,因为商品名很少用等值查询,更多是模糊搜索,模糊搜索走索引性价比不高。如果课程设计里要求支持商品名搜索,可以在设计文档里补一句「实际项目中会用全文索引或搜索引擎,本方案聚焦核心交易链路」。

库存表单独拆出来,用 product_id 做主键,这是典型的一对一关系。version 字段留给乐观锁使用:

CREATE TABLE inventory ( product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID,与商品表一一对应', stock INT NOT NULL DEFAULT 0 COMMENT '可售库存', locked_stock INT NOT NULL DEFAULT 0 COMMENT '锁定库存,支付中冻结', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新加1', PRIMARY KEY (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';

3.2 购物车与订单模块:cart、orders、order_item 三张表

购物车表用 user_id 和 product_id 做联合唯一索引,实现同一个用户对同一件商品只有一条购物车记录。这样加购时可以直接执行 INSERT ... ON DUPLICATE KEY UPDATE 来累加数量,省去先查再改的步骤:

CREATE TABLE cart ( cart_id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '购物车条目ID', user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', quantity INT NOT NULL DEFAULT 1 COMMENT '数量', checked TINYINT NOT NULL DEFAULT 1 COMMENT '是否勾选 1是 0否', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (cart_id), UNIQUE KEY uk_user_product (user_id, product_id), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';

orders 表是整张设计的核心,字段设计上有两个关键点。第一,order_no 业务订单号加唯一索引,它对外展示给用户,对内关联支付流水,自增主键 order_id 不暴露给前端。第二,收货人信息直接冗余三个字段,不关联地址表,这样即使地址被修改或删除,订单里的收货信息依然是下单那一刻的快照:

CREATE TABLE orders ( order_id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '订单ID', order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', user_id BIGINT UNSIGNED NOT NULL COMMENT '下单用户ID', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未支付 1已支付 2已退款', ship_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未发货 1已发货 2已收货 3已取消', receiver_name VARCHAR(50) NOT NULL COMMENT '收货人姓名', receiver_phone VARCHAR(20) NOT NULL COMMENT '收货人电话', receiver_address VARCHAR(200) NOT NULL COMMENT '收货地址', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (order_id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

订单明细表保存商品快照。product_name、product_image、price 都从商品表冗余过来,这样就算商品之后被删掉或改价,用户的订单详情依然能正常展示。明细表只对 order_id 建索引,不需要独立的商品索引——查询总是从订单出发:

CREATE TABLE order_item ( item_id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '明细ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单ID', product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', product_name VARCHAR(200) NOT NULL COMMENT '商品名称快照', product_image VARCHAR(255) DEFAULT '' COMMENT '商品主图快照', price DECIMAL(10,2) NOT NULL COMMENT '下单时成交单价', quantity INT NOT NULL COMMENT '购买数量', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (item_id), KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

3.3 字段类型与存储引擎:为什么金额用 DECIMAL、引擎选 InnoDB

字段类型的选择直接决定数据会不会失真。金额字段一律用 DECIMAL(10,2),禁止 FLOAT 和 DOUBLE,原因很简单:浮点数用二进制表示十进制小数有精度误差,0.1 加 0.2 在 FLOAT 里可能算出 0.30000000000000004,账目对账的时候会出现莫名其妙的几分钱差异。DECIMAL 是定点数,按十进制存储,金额计算不会丢精度。

字符编码统一用 utf8mb4,而不是 utf8mb3 或 latin1。utf8mb4 是完整的 Unicode 编码,能存下表情符号和生僻字,商品详情、收货地址里出现特殊字符不会报错。排序规则默认用 utf8mb4_unicode_ci 就够了。

存储引擎固定 InnoDB。MyISAM 的查询速度在老场景下有一定优势,但它不支持事务、不支持行级锁,崩溃恢复能力也差。购物网站的核心链路是下单扣库存,必须保证事务的原子性和隔离性,这个需求直接把 MyISAM 排除掉了。InnoDB 的行级锁也让并发更新同一件商品的库存时不至于把整张表锁死。

时间字段统一用 DATETIME,带默认值 CURRENT_TIMESTAMP。TIMESTAMP 类型有 2038 年问题,而且受时区影响,DATETIME 的范围和稳定性更适合业务系统。每个数据表都保留 create_time 和 update_time 两个时间戳字段,排查数据问题时能省很多力气。

4. 核心业务实现:下单事务、库存锁与状态流转

4.1 下单流程的事务边界设计

下单是购物网站里最典型的事务场景:扣库存、写订单、写明细必须同时成功或同时失败。如果先扣了库存再去写订单,写订单报错又没有回滚,就会出现「库存已扣但订单不存在」的黑匣子问题。

一个标准的下单事务 SQL 序列如下:

START TRANSACTION; -- 1. 锁定库存行,防止其他事务同时扣减 SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE; -- 2. 条件扣减库存,stock >= 1 防止超卖 UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001 AND stock >= 1; -- 3. 检查上一步影响行数,若为 0 说明库存不足,回滚并提示用户 -- (实际由应用层判断 ROW_COUNT() 或 JDBC 返回的更新行数) -- 4. 写入订单主表 INSERT INTO orders (order_no, user_id, total_amount, receiver_name, receiver_phone, receiver_address) VALUES ('202406010001', 1, 299.00, '张三', '13800138000', '某市某区某路1号'); -- 5. 写入订单明细表 INSERT INTO order_item (order_id, product_id, product_name, price, quantity, subtotal) VALUES (LAST_INSERT_ID(), 1001, '无线鼠标', 299.00, 1, 299.00); COMMIT;

这里有两道保险:SELECT ... FOR UPDATE 先把库存行锁住,防止两个事务同时读到同一个库存值;UPDATE 语句里的 stock >= 1 条件是第二道保险,就算之前读到的库存已经过期,条件更新也能挡住负数。应用层拿到 UPDATE 的影响行数后,如果返回 0 就直接回滚整个事务。

事务边界要尽量短,不能在事务里做网络请求、发短信、调第三方支付接口这些耗时操作。一个常见的反面写法是:事务里先调支付接口,等用户扫码支付完成再提交事务,结果连接被长时间占用,数据库连接池很快被打满。正确的做法是:先扣库存生成订单,事务提交后,再异步去调支付,支付结果通过回调更新订单状态。

4.2 库存防超卖:悲观锁与乐观锁两种方案对比

库存扣减是购物网站并发问题的重灾区。悲观锁的做法是在事务内锁定库存行,适合并发冲突较多的场景;乐观锁则是更新时校验版本号,适合读多写少的场景。两种方案的实现我都列出来,你可以根据课设的演示需求选一种。

悲观锁使用 SELECT ... FOR UPDATE,事务提交或回滚时自动释放锁:

START TRANSACTION; SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE; -- 业务代码里判断 stock >= 所需数量,不足则回滚 UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001; INSERT INTO orders (order_no, user_id, total_amount) VALUES ('202406010002', 1, 299.00); COMMIT;

乐观锁使用版本号字段,更新时校验读取到的版本号是否仍然有效:

-- 第一次读取库存和版本号 SELECT stock, version FROM inventory WHERE product_id = 1001; -- 应用层计算新库存,然后执行条件更新 UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE product_id = 1001 AND version = 1; -- 影响行数为 1 说明成功;为 0 说明有并发修改,需要重试

悲观锁的代价是锁等待。两个事务同时锁同一件商品时,后到的事务要等前一个事务提交或回滚,如果前一个事务执行时间过长,后一个会超时。乐观锁没有锁等待,但更新失败后需要应用层重试,重试次数多了用户体验会下降。课程设计演示通常并发量不大,两种都能满足要求;如果要讲得更深一层,可以在答辩时说:秒杀场景用悲观锁保证强一致,普通加购场景用乐观锁提升吞吐。

4.3 订单号生成与支付状态流转

订单号不直接用自增主键,原因是主键自增值会被别人猜到,而且多表合并时容易冲突。常用的生成方式是时间戳加随机数:格式为 yyyyMMddHHmmss + 6 位随机数 + 用户ID后四位,长度控制在 32 个字符以内,配合订单表的唯一索引 uk_order_no 兜底。即使同一秒内两个用户同时下单,随机数加用户ID的尾部组合也几乎不会重复。

支付状态和物流状态分开用两个 TINYINT 字段表示,不要合成一个状态字段。订单的完整状态流转如下表:

pay_status含义可流转方向
0未支付1 已支付、3 已取消
1已支付2 已退款
2已退款终态
3已关闭终态

ship_status 独立管理发货链路:0 未发货,1 已发货,2 已收货。两个字段组合起来可以表达「已支付未发货」「已发货未收货」等中间状态。字段拆开的好处是后台查询可以分别按支付状态和发货状态筛选,不需要做复杂的字符串解析。

状态更新用带条件的 UPDATE,防止状态乱跳,例如支付回调里执行:

UPDATE orders SET pay_status = 1, pay_time = NOW() WHERE order_id = 1001 AND pay_status = 0;

影响行数为 0 就说明订单已经不是未支付状态,直接返回,避免重复回调把已支付的订单覆盖成未支付。

5. 避坑指南:购物网站数据库最容易翻车的五个问题

5.1 并发下单导致库存变成负数

现象:两个账号同时购买同一件只剩 1 件库存的商品,系统最终显示库存为 -1,而且生成了两条订单。

原因:应用层先 SELECT 查库存,判断大于 0 后再 UPDATE,这中间存在时间差。两个请求同时读到 stock=1,都通过了判断,于是各自执行扣减,库存就变成了 -1。这类问题用单表、无锁设计时几乎必然出现,属于典型的并发读写竞态。

解决:把扣减和判断合并到一条 UPDATE 语句里,用 stock >= 数量 作为条件。UPDATE inventory SET stock = stock - 1 WHERE product_id = ? AND stock >= 1; 再根据影响行数决定是否继续生成订单。同时推荐在库存表增加 version 字段做乐观锁兜底,两条防线配合,超卖在逻辑上就不可能发生。

5.2 订单表数据量增长后查询缓慢

现象:上线初期订单查询很快,数据到几万条后,按用户查历史订单越来越慢,后台翻页到第 100 页基本要等好几秒。

原因:最常见的是没在 user_id 上建索引,导致每次查询都是全表扫描。另一个原因是深分页,LIMIT 10000, 20 需要先扫描前 10000 条再丢弃,数据量越大开销越高。

解决:orders 表的 idx_user_id 索引必须有。分页查询用「上次最大 ID」代替页码,或者限制用户只能查看最近三个月的订单,从业务层面控制扫描范围。课程设计里能体现这一点会很加分,说明你考虑过数据量增长的问题。

5.3 物理删除用户导致历史订单查不到

现象:后台删掉一个测试用户后,这个用户的历史订单在管理端也消失了,订单汇总数据对不上。

原因:订单表通过 user_id 以物理外键关联用户表,删除用户时 CASCADE 把订单连带删掉了。或者是应用层先删用户再删订单,但没有意识到订单是核心交易数据,不应该随用户销毁。

解决:购物网站里用户和订单都只能做逻辑删除。用户表用 status 字段标记禁用,订单表不做删除操作,只做状态流转。物理外键改为逻辑外键,由应用层保证关联字段的有效性。这样保留完整交易链路,也符合电商系统的审计要求。

5.4 金额字段用了 FLOAT,对账时出现几分钱差异

现象:订单总金额 299.90 元,明细加总却显示 299.8999999,数据库和前端展示不一致,财务对账不平。

原因:FLOAT 和 DOUBLE 是浮点数,用二进制近似表示十进制小数,累加时误差会累积。5 分钱的误差在单笔订单上不明显,汇总到月度报表就成了大问题。

解决:所有金额字段统一使用 DECIMAL(10,2),包括订单总额、单价、小计。Decimal 以字符串形式存储十进制数,加减乘除不会出现二进制浮点误差。这一点在数据库设计文档的字段类型说明里一定要写清楚。

5.5 事务未提交就抛异常,连接资源泄漏

现象:压测时数据库连接数持续增长,最终报 Too many connections,应用假死。

原因:下单逻辑里开启了事务,中间某一步抛出异常却没有触发回滚,事务一直持有连接不释放。或者事务里调用了外部接口,外部接口超时,事务长时间挂着。连接池耗尽后,其他正常请求也拿不到连接。

解决:代码里用 try-catch 包裹事务边界,catch 里显式执行 ROLLBACK,finally 里归还连接。事务内只做数据库操作,外部调用一律放到事务提交之后。同时给连接池配置合理的最大连接数和等待超时时间,比如最大 50、超时 3 秒,这样单个事务异常不会拖垮整个应用。

6. 验收与答辩:功能测试、并发压测与讲解技巧

6.1 功能验收清单:一张表跑完所有业务路径

验收阶段不要上来就压测,先把功能路径走通。准备好测试数据:5 个商品分类、每个分类 3 件商品、库存各 10 件、两个测试用户。按下面的清单逐项验证:

验收项操作方式预期结果
用户注册新建账号插入成功,用户名重复时被唯一索引拦截
商品浏览按分类查询商品只返回 status=1 的上架商品
加入购物车同一商品反复加购购物车记录不重复,数量累加
提交订单勾选商品后结算库存扣减、订单生成、购物车对应条目清空
支付回调模拟支付成功订单 pay_status 从 0 变为 1,记录支付时间
取消订单未支付订单取消pay_status 变为 3,库存回补

每一项验证完后记录结果,这是答辩时最实在的过程材料。特别关注取消订单时的库存回补逻辑,这是很多课设会漏掉的细节。

6.2 并发正确性验证:同时下单不超卖

并发验证用最简单的办法:写一个 Python 脚本,开 20 个线程同时抢购同一件库存为 10 的商品,看最终生成多少订单、剩余多少库存。这个脚本本身很简单,关键是验证结果:

import threading import urllib.request url = "http://127.0.0.1:8080/order/submit?productId=1001&quantity=1" def buy(): try: urllib.request.urlopen(url, timeout=5) except Exception: pass threads = [threading.Thread(target=buy) for _ in range(20)] for t in threads: t.start() for t in threads: t.join()

跑完后查数据库:orders 表里该商品的订单数加上 inventory 表的剩余库存必须等于初始库存 10,多一单少一单都说明事务边界或锁逻辑有问题。反复跑三次,结果一致才算通过。这个验证在答辩现场展示时直观且可信,能够直接证明方案具备应对高并发的基础能力。

6.3 答辩讲解:把设计决策讲成亮点

答辩时不需要背表结构,重点是讲清楚「为什么这么设计」。三个必讲的问题:为什么订单明细冗余商品名称和价格快照,因为商品信息会变,历史订单必须展示下单时的快照;为什么不用物理外键,因为高并发下物理外键的额外锁校验会成为瓶颈,逻辑外键由应用层控制更灵活;为什么库存表单独拆出来并加版本号,因为库存是高频更新字段,独立成表并配合乐观锁能降低锁冲突范围。

最后收个尾,讲一次真实的教训:之前 A同学做第一个版本时,把库存放在商品表里,用 SELECT 判断再 UPDATE,演示直接翻车,库存变成负数。从那以后我每次做数据库设计,都会强制把「并发写入的防重、防超卖」作为第一优先级去验证,先解决最恶劣的情况,再谈查询优化。这套购物网站数据库设计方案的每一张表、每一个索引,都是围绕这条主线展开的,希望能帮你把课设或毕设做得更扎实,顺利通过验收。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进