ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

外卖系统数据库设计实战:订单表、状态机与防超卖方案

外卖系统数据库设计实战:订单表、状态机与防超卖方案 简介这是一份基于C#与SQL Server 2019开发的餐饮外卖销售系统数据库设计完整工程面向高校数据库课程设计及初学桌面应用开发的读者。系统涵盖商家、客户、骑手三类用户界面及注册模块采用扁平化设计代码结构清晰、注释详细可直接在Visual Studio中还原环境后编译运行适合作为课程设计满分参考或毕设拓展基础。压缩包共46个文件包含C#窗体与业务逻辑cs、界面资源resx/resources、编译配置config、可执行程序exe及少量图片素材整体仅886KB内容紧凑且便于快速部署。这一版本已有1592人学习经过验证的方案能帮助读者减少踩坑、快速理解数据库与C#联调的核心思路。从工程目录看登录、注册、商家/客户/骑手功能划分明确附带全部源工程文件与配置文件便于逐模块研读和二次开发。1. 拿到「餐饮外卖销售系统数据库设计.rar」后先搞懂这套表该怎么读「餐饮外卖销售系统数据库设计」这个压缩包很多做课设、毕设或刚开始搭外卖项目的同学都见过。解压后通常是一份 ER 图、几张表结构文档和一堆建表 SQL看起来挺全可真按它把库建起来跑到下单、配送、对账那一步就开始出怪问题——要么订单金额对不上要么状态乱跳要么库存超卖。问题多半不是代码写错而是表结构一开始就没立住。这份压缩包本质上是一套业务模型用户、店铺、商品、订单、配送、支付这些实体之间的关系以及每个字段该怎么约束。它解决的是「建库之前先把业务边界画清楚」这件事适合正在做外卖系统课设、毕设或者准备给小型餐饮团队搭后端的人。下面我按一套能跑通的外卖库设计把表怎么建、字段怎么设、坑在哪整体过一遍。2. 核心表先定三组用户信息表、店铺商品表、购物车表怎么落地数据库设计是外卖系统的地基。我一般拿到别人的设计文档第一件事不是看 ER 图画得有多漂亮而是先数它有几张表、每张表的主键和唯一键是什么。尤其要跟苍穹外卖这类成型项目的数据库设计文档对一遍差别通常出现在地址快照、支付流水和状态日志上。如果把核心实体都揉在一两张表里后面写订单、写结算都会很别扭。2.1 用户信息表为什么把买家、骑手、商家管理员放进一张表不少实训课程把「第 1 关数据库表设计 - 用户信息表」放在最前面确实用户是整个系统的入口。很多人拿到设计稿会纠结买家、骑手、商家管理员要不要拆成三张表我见过不少课设把这三类用户拆成buyer、rider、admin三张独立表结果登录逻辑要写三套手机号唯一性要跨三张表维护非常痛苦。常见的做法是只建一张users表用role字段区分身份。因为外卖系统里买家、骑手、商家管理员本质上都是「账号 手机号 密码」的登录体系业务差异体现在订单、配送这些业务表上而不是用户表本身。CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, phone VARCHAR(20) NOT NULL COMMENT 登录手机号全局唯一, password_hash CHAR(60) NOT NULL COMMENT 密码哈希值不存明文, nickname VARCHAR(50) NOT NULL DEFAULT COMMENT 昵称, role TINYINT NOT NULL DEFAULT 1 COMMENT 角色1消费者 2骑手 3商家管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0冻结, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;三个参数值得注意。phone用 VARCHAR(20) 而不是 BIGINT是因为手机号前面可能有国家区号而且它不做算术运算用字符串更安全。password_hash用 CHAR(60)正好对应 bcrypt 哈希的输出长度不用 VARCHAR(255) 留太多余量。role用 TINYINT 而不是字符串枚举查询和索引都更省空间应用层用常量映射即可。用户表之外还需要一张收货地址表addresses一个用户会存多个地址。常见的错误是把地址直接塞到users表里加一个address字段换地址就覆盖历史订单的收货信息全丢了。CREATE TABLE addresses ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 所属用户, receiver_name VARCHAR(50) NOT NULL COMMENT 收货人姓名, receiver_phone VARCHAR(20) NOT NULL COMMENT 收货人电话, detail VARCHAR(200) NOT NULL COMMENT 详细地址, is_default TINYINT NOT NULL DEFAULT 0 COMMENT 是否默认地址1是 0否, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收货地址表;is_default用 0/1 标记默认地址就够了不用搞复杂的排序逻辑。下单时从这张表带出收货信息但订单表里要单独做快照这个我放到订单章节细说。2.2 店铺与商品多店模型还是单店模型在建表前就要定外卖系统有两种常见形态一种是美团、饿了么这种多商家平台一种是单店自营的小程序。很多课设文档只按单店设计商品表里没有shop_id后面想扩展成多店就得改一堆代码。我的建议是哪怕你现在只做一个店也把shop_id字段留着默认值 1这是最便宜的后悔药。店铺表本身很简单重点是商品表。商品必须挂在分类下但分类不能直接写成商品表里的一个字符串字段否则商家调整分类、按分类统计销量时全得改代码。CREATE TABLE categories ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, shop_id BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 预留店铺ID单店模式固定为1, name VARCHAR(50) NOT NULL COMMENT 分类名如热销、主食、饮品, sort INT NOT NULL DEFAULT 0 COMMENT 排序值越小越靠前, PRIMARY KEY (id), KEY idx_shop (shop_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品分类表; CREATE TABLE dishes ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, shop_id BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 预留店铺ID, category_id BIGINT UNSIGNED NOT NULL COMMENT 所属分类, name VARCHAR(100) NOT NULL COMMENT 菜品名称, price DECIMAL(10,2) NOT NULL COMMENT 售价单位元, image_url VARCHAR(255) NOT NULL DEFAULT COMMENT 菜品图片, stock INT NOT NULL DEFAULT 0 COMMENT 库存数量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表;price用 DECIMAL(10,2) 是硬性要求。FLOAT 和 DOUBLE 在二进制里无法精确表示 0.1短期看不出问题月底对账差几分钱时就知道了。DECIMAL(10,2) 的最大值是 99999999.99对餐饮外卖的客单价来说绰绰有余。stock字段放在dishes表里是常规做法但它的并发控制要特别小心。先SELECT stock再UPDATE stock的写法在并发下一定会超卖这个坑我在第五章专门展开。2.3 购物车是临时数据别给它加状态机购物车表是很多新手设计过度的地方。有人给购物车加status字段搞什么「待结算」「已失效」状态还有人把购物车和订单明细共用一张表这都是给自己找麻烦。购物车的本质是用户的暂存数据不参与对账、不参与统计、没有状态流转清空或过期都行。CREATE TABLE cart_items ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, dish_id BIGINT UNSIGNED NOT NULL COMMENT 菜品ID, quantity INT NOT NULL DEFAULT 1 COMMENT 数量, checked TINYINT NOT NULL DEFAULT 1 COMMENT 是否勾选结算1勾选 0不勾选, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT购物车表;uk_user_dish这个唯一索引是关键。同一个用户把同一个菜品加两次购物车不应该出现两行记录而是把quantity加 1。应用层的做法是先按user_id dish_id查存在就 UPDATE 数量不存在就 INSERT。checked字段值得保留。很多外卖 App 支持用户在购物车里取消勾选某个菜品再结算这个勾选状态如果只存在前端刷新页面就丢用户会很恼火。放到后端表里是最省事的做法。3. 订单主表与明细表快照、状态机与流水日志一起设计订单是外卖系统里最复杂、也最值得花时间设计的一张表。它不是「记录用户买了什么」那么简单还要回答三个问题用户下单那一刻看到的信息是什么样、订单现在走到哪一步了、中途每一步是谁在什么时间改的。回答不了这三个问题后面做售后、做对账、做数据分析都会卡壳。3.1 订单主表地址和价格必须做快照订单主表记录一笔订单的汇总信息。先看表结构再解释为什么有些字段看起来「冗余」。CREATE TABLE orders ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号全局唯一, user_id BIGINT UNSIGNED NOT NULL COMMENT 下单用户, shop_id BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 店铺ID, receiver_name VARCHAR(50) NOT NULL COMMENT 收货人姓名快照, receiver_phone VARCHAR(20) NOT NULL COMMENT 收货人电话快照, receiver_address VARCHAR(200) NOT NULL COMMENT 收货地址快照, total_amount DECIMAL(10,2) NOT NULL COMMENT 商品总额, delivery_fee DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 配送费, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 优惠金额, pay_amount DECIMAL(10,2) NOT NULL COMMENT 实付金额 商品总额 配送费 - 优惠金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3配送中 4已完成 5已取消 6售后中, remark VARCHAR(200) NOT NULL DEFAULT COMMENT 用户备注, paid_at DATETIME DEFAULT NULL COMMENT 支付时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;三个设计点必须理解。第一receiver_name、receiver_phone、receiver_address是快照字段。用户下单后改了收货地址历史订单的收货信息不能跟着变否则商家发货发到新地址去责任算谁的所以下单时要把地址表里的数据复制一份到订单表。第二金额拆成total_amount、delivery_fee、discount_amount、pay_amount四个字段而不是只存一个最终金额。对账时财务会问实付 25 元里商品多少钱、配送多少钱、优惠了多少没有拆分字段这笔账就得靠猜。第三order_no业务订单号要单独建唯一索引。它对外展示给用户对内用于支付回调、对账、客服查询绝对不允许重复。自增id是主键但不能当订单号暴露出去否则别人根据 id 差值就能估算你的单量。3.2 订单明细表保存「下单时」的商品信息而不是「当前」的商品信息订单主表管汇总订单明细表管每一行商品。明细表的设计直接决定售后和财务统计的准确性。CREATE TABLE order_items ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID关联orders.id, dish_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 商品ID商品删除后此字段仍保留, dish_name VARCHAR(100) NOT NULL COMMENT 下单时商品名称快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL DEFAULT 1 COMMENT 购买数量, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计 price * quantity, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;为什么要存dish_name和price快照因为商家可能改价、改菜名、甚至下架菜品。如果明细表只存dish_id每次查询都要 JOIN 菜品表拿实时数据而实时数据可能已经不是用户下单时的数据了。用户拿着 3 天前的订单来投诉「我买的时候是 15 块怎么现在显示 18 块」系统必须能拿出下单那一刻的价格。dish_id不建外键默认值设为 0也是有意为之。物理外键会阻止你删除菜品记录但订单明细是历史事实不能因为菜品删了就查不到。常见的做法是菜品在业务上做软删除status 0历史订单里的dish_id仍然指向这条记录。subtotal字段看起来可以随时算出来但我建议在写入订单时直接计算并存储。查询时少一次乘法事小重点是保证每一行数据的口径一致不会出现「单价 × 数量」和「小计」对不上的脏数据。3.3 订单状态机用枚举表定义流转用日志表记录每一步外卖订单的状态不是随便改的。从用户提交订单到最终完成每个状态都有前置条件。最常见的坑是代码里到处都是update orders set status 4然后订单就从「待支付」跳到了「已完成」中间的制作、配送环节全被跳过。状态机适合先用一张枚举表把流转关系定死业务代码按表执行。这张表不需要建到数据库里应用层的常量或配置文件就够了但设计文档里必须写清楚。状态码状态名前置状态触发动作0待支付-用户提交订单1已支付0支付回调成功2制作中1商家接单3配送中2骑手取餐4已完成3用户确认或超时自动完成5已取消0, 1用户取消 / 商家拒单 / 超时取消6售后中1, 2, 3, 4用户发起售后申请只有status字段还不够你还得知道「订单从 1 变成 2是谁在什么时间操作的」。这就要一张状态流转日志表。CREATE TABLE order_status_log ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, from_status TINYINT NOT NULL COMMENT 原状态新建订单时为-1, to_status TINYINT NOT NULL COMMENT 新状态, operator_type TINYINT NOT NULL COMMENT 操作方1用户 2商家 3骑手 4系统, operator_id BIGINT UNSIGNED DEFAULT 0 COMMENT 操作者ID系统操作为0, remark VARCHAR(200) NOT NULL DEFAULT COMMENT 备注如取消原因, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_time (order_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单状态流转日志表;这张日志表就是订单状态的黑匣子。用户说「我根本没收到餐订单怎么显示已完成」你查order_status_log就能看到状态是从 3 变成 4操作方是系统还是用户时间点是什么时候。没有这张表就只能去翻应用日志运气不好还翻不到。状态变更的代码逻辑必须统一走一个入口不能散落在各处直接 UPDATE。先做条件更新再插日志两步放同一个事务里保证数据一致。这个写法在第五章第 5 小节给出具体示例。4. 配送支付售后三张外围表把「钱」和「履约」单独建模订单表之外外卖系统还有三块业务必须有自己的表配送履约、支付流水、退款流水。很多压缩包里的设计稿把骑手 ID 直接放进订单表、把支付状态也塞进订单表短期能跑但业务一复杂就出问题。4.1 配送单把订单生命周期和配送生命周期解耦订单已经有个status表示「配送中」为什么还要单独建配送表因为订单的配送状态比订单状态更细待接单、取餐中、配送中、已送达。而且骑手接单、改派、上报异常这些动作不应该直接改订单主表否则订单状态字段会被各种业务逻辑反复横跳。CREATE TABLE deliveries ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, rider_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 骑手用户ID关联users.id, pickup_time DATETIME DEFAULT NULL COMMENT 取餐时间, delivered_time DATETIME DEFAULT NULL COMMENT 送达时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待接单 1取餐中 2配送中 3已送达 4配送异常, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order (order_id), KEY idx_rider_status (rider_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT配送单;uk_order唯一索引表示一个订单最多有一条配送记录。rider_id单独建索引是为了支持「骑手查看自己今天所有配送单」这类高频查询。pickup_time和delivered_time是配送履约的关键时间点骑手端 App 上报这两个时间用户端才能展示配送进度。把骑手 ID 从订单表挪到配送表之后订单表不用随着骑手改派而更新配送表可以独立记录每个骑手的接单历史这对骑手结算很有用。4.2 支付流水与退款流水钱的问题必须有单独的账本支付信息绝不能只放在订单表里。第三方支付回调可能重复、退款可能分多次、对账需要按渠道汇总这些场景都要求支付有独立的流水表。CREATE TABLE payments ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, payment_no VARCHAR(64) NOT NULL COMMENT 第三方支付单号, channel TINYINT NOT NULL COMMENT 支付渠道1微信 2支付宝 3余额, amount DECIMAL(10,2) NOT NULL COMMENT 支付金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0创建 1支付成功 2支付失败 3已退款, callback_time DATETIME DEFAULT NULL COMMENT 支付回调确认时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_channel (order_id, channel), KEY idx_payment_no (payment_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付流水表;uk_order_channel是防重复回调的关键。同一笔订单在同一渠道最多只能有一条支付流水第三方支付平台因为网络原因重推回调时应用层根据这个唯一索引做幂等直接跳过重复处理。payment_no是第三方支付系统返回的单号也要建唯一索引。对账时拿渠道账单里的单号来匹配本地记录这个索引能显著加速。退款不是把订单状态改成「已退款」就完事。支付通道的退款是独立请求退款可能失败、可能部分退款所以退款也要单独记账。CREATE TABLE refunds ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, payment_id BIGINT UNSIGNED NOT NULL COMMENT 关联的支付流水ID, refund_no VARCHAR(64) NOT NULL COMMENT 退款单号全局唯一, amount DECIMAL(10,2) NOT NULL COMMENT 本次退款金额, reason VARCHAR(200) NOT NULL DEFAULT COMMENT 退款原因, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1退款成功 2退款失败, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_refund_no (refund_no), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT退款流水表;注意refunds表不能在order_id上建唯一索引因为一个订单可能有多笔部分退款。refund_no才是业务上保证唯一的字段。退款金额必须小于等于原支付金额这个校验放在应用层做数据库层只能保证流水不丢。4.3 全库表清单拿它对照你的压缩包缺了哪些把前面讨论的表汇总一下就是一套完整的外卖系统数据库表清单。你拿到压缩包之后不用逐行读 SQL先拿这张清单对照一遍心里就有数了。表名职责关键设计点users用户账号role 区分买家/骑手/商家phone 唯一addresses收货地址用户地址快照来源categories菜品分类shop_id 预留多店扩展dishes菜品金额用 DECIMALstatus 控制上下架cart_items购物车user_id dish_id 唯一orders订单主表地址与金额快照order_no 唯一order_items订单明细商品名与价格快照order_status_log状态流转日志每次状态变更插一条deliveries配送单与订单解耦记录骑手履约payments支付流水防重复回调存第三方单号refunds退款流水支持多次部分退款这 11 张表是最小可用集。如果压缩包里没有deliveries、payments、refunds这几张说明设计稿只覆盖了「下单」这个动作没覆盖「履约」和「资金」。照着这个思路补齐后面业务跑起来才不会临时改表。5. 常见问题与避坑排查订单金额对不上、超卖、状态乱跳怎么查数据库设计里有一些坑是只写代码发现不了的要等业务跑到一定量级才爆出来。以下 5 条是我做外卖项目反复遇到过的翻车现场按「现象 → 原因 → 解决」的顺序写你设计表的时候直接避开。5.1 金额字段用 FLOAT月底对账差几分钱现象月度结算时orders.pay_amount合计和支付渠道的账单对不上差几毛甚至几分钱。原因FLOAT/DOUBLE 是二进制浮点数0.1 在二进制里是无限循环小数存储时有精度损失。单笔订单损失极小但几千笔累计下来就是可见的差额。解决所有金额字段一律用DECIMAL(10,2)包括订单、支付、退款、菜品价格。如果库已经建好用下面的语句排查哪些表还在用浮点类型SELECT table_name, column_name, column_type FROM information_schema.columns WHERE table_schema your_db AND column_type IN (float, double);有查询结果就说明存在隐患。另外要统一金额单位后端用「元」还是「分」必须全项目一致。我建议统一用「元 DECIMAL(10,2)」对账时直接和渠道账单相加不用来回换算。5.2 取消订单重复退款状态没有做原子校验现象用户连点两次「取消订单」后台支付回调也刚好触发取消逻辑结果退款执行了两次用户收到了两笔退款。原因多个请求同时读到订单status 0都认为自己是第一个取消的都走了退款流程。解决状态变更必须用条件更新让数据库来保证原子性。UPDATE orders SET status 5 WHERE id 123 AND status 0;执行这条 SQL 后影响行数为 1 说明当前请求抢到了状态变更权可以继续执行退款影响行数为 0 说明订单已经不是待支付状态直接返回「操作失败」就行。这个模式对「取消订单」「支付回调」「商家接单」等所有状态变更都适用。5.3 高并发下单导致库存超卖先查后改必翻车现象菜品库存只剩 1 份两个用户同时下单都成功了出库时才发现库存不够。原因应用层先SELECT stock FROM dishes WHERE id ?判断大于 0 后再UPDATE dishes SET stock stock - 1。两个请求都读到 stock 1都通过判断都执行了扣减。解决把库存判断写进 UPDATE 的条件里。UPDATE dishes SET stock stock - 1 WHERE id 456 AND stock 0;影响行数为 1 表示扣减成功为 0 表示库存不足。这个写法在数据库层面保证了「读取和扣减」的原子性。不要用SELECT ... FOR UPDATE虽然也能解决但行锁的粒度太粗高峰期会把所有下单请求串行化。5.4 唯一索引撞上软删除手机号注销后无法重新注册现象用户注销账号后用同一个手机号重新注册系统报Duplicate entry 138xxxx for key uk_phone。原因users表上phone建了唯一索引注销时做的是软删除记录还在表里手机号仍然被占用。解决把软删除标记从deleted0/1 改成deleted_at DATETIME DEFAULT NULL删除时写入当前时间戳然后唯一索引改成(phone, deleted_at)。MySQL 的联合索引里多个 NULL 值互不冲突所以同一手机号可以有多条已删除记录但只能有一条未删除记录。ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT 软删除时间; DROP INDEX uk_phone ON users; ALTER TABLE users ADD UNIQUE KEY uk_phone_deleted (phone, deleted_at);新用户注册时插入(phone, NULL)注销时更新deleted_at NOW()。注意deleted_at精确到秒同一个人在同一秒内不可能注销两次所以默认精度够用。5.5 订单状态乱跳没有记录排查只能翻应用日志现象订单从「制作中」直接变成「已完成」客服问是谁操作的后台操作记录里查不到。原因代码里多个地方直接UPDATE orders SET status ...没有统一的入口也没有状态流转日志。出问题时只能确定「最终状态是已完成」但谁改的、什么时候改的、中间状态跳过了什么全是黑匣子。解决所有状态变更必须走同一个方法先条件更新再插日志放同一事务。def change_order_status(order_id, from_status, to_status, operator_type, operator_id, remark): affected db.execute( UPDATE orders SET status %s WHERE id %s AND status %s, (to_status, order_id, from_status) ) if affected 0: raise BusinessError(f订单状态已是 {from_status}无法变更为 {to_status}) db.execute( INSERT INTO order_status_log(order_id, from_status, to_status, operator_type, operator_id, remark) VALUES(%s, %s, %s, %s, %s, %s), (order_id, from_status, to_status, operator_type, operator_id, remark) )affected 0分支要抛业务异常不能静默通过否则调用方以为状态改成功了。事务必须把 UPDATE 和 INSERT 包在一起两个操作要么都成功要么都回滚。6. 验收设计从压缩包到能跑业务的完整核对清单拿到压缩包把表全部建好之后先别急着写业务代码花十分钟做一遍验收。第一个动作是业务流走查。打开你的数据库模拟一个完整流程用户注册 → 添加地址 → 加购物车 → 提交订单 → 支付 → 商家接单 → 骑手取餐 → 送达 → 完成。每一步问自己一个问题这一步要读写哪几张表、哪些字段比如「商家接单」这个动作要 UPDATEorders.status从 1 变 2要 INSERT 一条order_status_log。如果走查过程中发现某一步找不到对应的表或字段就是设计缺漏。第二个动作是字段级检查。用一个 SQL 扫一遍所有金额字段确保没有浮点类型混进来再确认每张业务表都有created_at订单、用户这类核心表都有updated_at。SELECT table_name, column_name, column_type FROM information_schema.columns WHERE table_schema your_db AND column_type IN (float, double);第三个动作是索引检查。拿订单查询最频繁的场景出来 EXPLAIN 一下看有没有走索引EXPLAIN SELECT * FROM orders WHERE user_id 123 ORDER BY created_at DESC;key列出现idx_user_created说明索引生效type列是ALL说明全表扫描单量上去必卡。这个检查方式对order_items.order_id、deliveries.rider_id这类高频查询都适用。说我自己的教训。我做第一个外卖项目时把订单状态直接写在业务代码里每个接口按需改状态没建order_status_log。上线第三周一个用户投诉订单无故被取消我翻了一下午应用日志才定位到是超时任务误取消了已支付的订单。如果当时有状态日志表按order_id一查五分钟就能看到状态从 1 变 5 的时间和操作方。从那以后状态日志表被我列为订单表设计的第一优先。这套核对清单也是那一次之后沉淀下来的。希望帮到你。本文还有配套的精品资源点击获取
返回列表