简介:这份PDF文档面向电商系统开发初学者与后端设计人员,系统梳理电商数据库设计的核心知识框架,帮助读者理解如何构建高效、稳定且可扩展的电商平台数据层。内容围绕数据库模式设计展开,从概念模型、逻辑模型到物理模型逐层推进,并涵盖范式理论、商品管理、用户信息、订单系统、支付系统、库存管理、物流信息、评论评分、安全性备份与性能优化等模块,对多语言商品子表、订单状态流转、预占库存防超卖、读写分离与分库分表等关键设计均有说明。资源包共1个PDF文件,大小约142KB,便于随时查阅与打印学习。目前已有186人学习下载,适合需要系统掌握电商数据库表结构设计、字段规划与高并发优化思路的开发者参考,可作为项目设计前的知识梳理材料。
1. 电商数据库设计:从“能跑”到“扛得住”的分水岭
很多做电商项目的朋友,第一版数据库往往是这么来的:打开 Navicat,建一张users表,一张products表,再来一张orders表,字段用中文拼音缩写,外键懒得加,金额用float,库存直接int一把梭。本地跑起来没问题,测试环境也没问题,结果一上活动,订单超卖、金额对不上、查询慢到爆炸,这时候才想起来回头翻那本《电商数据库设计.pdf》——发现里面讲的范式、索引、事务隔离级别,自己一个都没用上。
电商数据库设计和普通管理系统最大的区别在于:它要同时扛住高并发写入(下单、支付、库存扣减)、复杂查询(商品搜索、订单列表、报表统计)和数据一致性(钱不能错、库存不能超)。这三者互相拉扯,决定了你不能用一套“万能模板”糊弄过去。这篇内容就是把这本 PDF 里最核心的落地路径拆开,从表结构怎么定、索引怎么加、事务怎么控,一路讲到分库分表之前你该做的准备。适合正在做电商项目、或者接手了一个“能跑但不敢动”的电商库的工程师。
2. 电商核心表结构:用户、商品、订单三张表怎么定才不翻车
2.1 用户表:别把登录信息和业务信息塞在一起
很多电商项目的users表最后会膨胀到五六十个字段:手机号、密码、昵称、头像、收货地址、会员等级、积分、余额、最后登录 IP、微信 openid……全在一张表里。这种设计在前期确实方便,但一旦用户量过百万,这张表就会变成热点表,任何一次ALTER TABLE都可能锁表几分钟。
常见做法是拆成两张表:user_auth负责认证相关(手机号、密码哈希、openid、token 版本),user_profile负责业务属性(昵称、头像、等级、积分)。两张表用user_id一对一关联。这样登录查询走user_auth,展示个人信息走user_profile,互不干扰。
-- 认证表:只放登录必需字段,行长度尽量小 CREATE TABLE `user_auth` ( `user_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `mobile` VARCHAR(20) NOT NULL COMMENT '手机号,登录凭证', `password_hash` VARCHAR(255) NOT NULL COMMENT 'bcrypt 哈希,不存明文', `wx_openid` VARCHAR(64) DEFAULT NULL COMMENT '微信 openid,用于第三方登录', `token_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '改密码时递增,使旧 token 失效', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_mobile` (`mobile`), UNIQUE KEY `uk_wx_openid` (`wx_openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 业务属性表:字段多但查询频率低,可以单独放 CREATE TABLE `user_profile` ( `user_id` BIGINT UNSIGNED NOT NULL, `nickname` VARCHAR(64) NOT NULL DEFAULT '', `avatar_url` VARCHAR(512) NOT NULL DEFAULT '', `member_level` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 普通 1 银卡 2 金卡', `points` INT UNSIGNED NOT NULL DEFAULT 0, `balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '余额,必须用 DECIMAL', PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;这里有几个参数值得注意。mobile加了唯一索引,因为手机号是登录凭证,重复注册必须被数据库层面拦住,不能只靠应用层查重——并发下应用层查重一定会漏。balance用DECIMAL(12,2)而不是FLOAT,因为浮点数在累加时会产生精度误差,电商场景下哪怕差一分钱,对账时都是血泪教训。token_version是一个低成本的安全设计:用户改密码时把这个字段加一,服务端校验 token 时比对版本号,旧 token 自然失效,不需要维护黑名单。
2.2 商品表:SPU 和 SKU 必须分开
电商商品模型里最容易踩的坑就是把“商品”当成一个东西。实际上用户看到的“iPhone 15”是一个 SPU(标准产品单位),而“iPhone 15 128G 黑色”是一个 SKU(库存量单位)。价格、库存、条码都在 SKU 上,SPU 只负责聚合展示。
-- SPU 表:商品聚合信息 CREATE TABLE `product_spu` ( `spu_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, `category_id` INT UNSIGNED NOT NULL, `brand_id` INT UNSIGNED NOT NULL DEFAULT 0, `main_image` VARCHAR(512) NOT NULL DEFAULT '', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 下架 1 上架', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`spu_id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- SKU 表:真正卖的东西,库存和价格在这里 CREATE TABLE `product_sku` ( `sku_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `spu_id` BIGINT UNSIGNED NOT NULL, `spec_json` JSON NOT NULL COMMENT '规格,如 {"颜色":"黑色","容量":"128G"}', `price` DECIMAL(10,2) NOT NULL, `stock` INT UNSIGNED NOT NULL DEFAULT 0, `sku_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商家编码', PRIMARY KEY (`sku_id`), KEY `idx_spu_id` (`spu_id`), UNIQUE KEY `uk_sku_code` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;spec_json用 JSON 类型而不是拆成多列,是因为不同类目的规格差异太大——手机有颜色和容量,衣服有尺码和颜色,家电可能还有功率。用 JSON 存规格,查询时用 MySQL 5.7+ 的 JSON 函数提取,灵活性最高。但要注意:不要对 JSON 字段建索引后做范围查询,JSON 索引适合等值匹配,范围查询性能不如拆列。
idx_category_status这个联合索引的顺序很关键。电商前台最常见的查询是“某个分类下所有上架商品”,所以category_id在前、status在后,遵循最左前缀原则。如果反过来,单独查status就用不上这个索引。
2.3 订单表:主表和明细表要同步写入
订单表的设计直接关系到后续的对账、退款、报表。核心原则是:订单主表存状态和金额汇总,订单明细表存商品快照。
-- 订单主表 CREATE TABLE `order_main` ( `order_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '对外订单号,雪花算法生成', `user_id` BIGINT UNSIGNED NOT NULL, `total_amount` DECIMAL(12,2) NOT NULL COMMENT '订单总金额', `pay_amount` DECIMAL(12,2) NOT NULL COMMENT '实付金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 待支付 1 已支付 2 已发货 3 已完成 4 已取消', `pay_time` DATETIME DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`order_id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_created` (`user_id`, `created_at`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表:商品快照,下单那一刻的信息冻结在这里 CREATE TABLE `order_item` ( `item_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_id` BIGINT UNSIGNED NOT NULL, `sku_id` BIGINT UNSIGNED NOT NULL, `sku_name` VARCHAR(255) NOT NULL COMMENT '下单时的商品名', `sku_spec` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '下单时的规格描述', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时的单价', `quantity` INT UNSIGNED NOT NULL, PRIMARY KEY (`item_id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_no用雪花算法生成而不是自增主键,是因为自增 ID 会暴露订单量,而且分库分表后自增会冲突。idx_user_created支持“我的订单”按时间倒序查询,idx_status_created支持后台按状态筛选订单。注意order_item里冗余了sku_name和price,这是故意的——商品信息会变,但订单必须保留历史快照,否则用户三个月后翻订单会发现商品名和价格都变了,客诉就来了。
3. 索引与查询优化:让订单列表和商品搜索不再拖垮数据库
3.1 联合索引的顺序决定查询生死
上一章提到了联合索引的最左前缀原则,这里展开讲一个真实场景。后台运营最常用的查询是:“查最近 7 天所有已支付的订单,按时间倒序”。对应的 SQL 是:
SELECT * FROM order_main WHERE status = 1 AND created_at >= '2026-01-01 00:00:00' ORDER BY created_at DESC LIMIT 20;如果索引是idx_status_created (status, created_at),这个查询可以完全走索引:先定位status=1,再在created_at范围里扫描,天然有序,不需要额外排序。但如果索引建反了,变成idx_created_status (created_at, status),MySQL 会先按时间范围扫描,再回表过滤status,数据量大时性能差一个数量级。
提示:用
EXPLAIN看type和Extra列。type=range且Extra没有Using filesort,说明索引顺序对了。
3.2 深分页是电商查询的隐形杀手
“我的订单”翻到第 100 页时,LIMIT 2000, 20会让 MySQL 扫描 2020 行再丢弃前 2000 行。用户越多、订单越多,这个查询越慢。常见优化方案有两种:
第一种是游标分页,用上一页最后一条的order_id作为锚点:
-- 第一页 SELECT * FROM order_main WHERE user_id = 123 ORDER BY order_id DESC LIMIT 20; -- 后续页:传入上一页最后一条的 order_id SELECT * FROM order_main WHERE user_id = 123 AND order_id < 10086 ORDER BY order_id DESC LIMIT 20;这种方式每页查询代价恒定,不会随页码加深而变慢。缺点是用户不能直接跳到第 50 页,但对“我的订单”这种无限下拉的场景完全够用。
第二种是延迟关联,先用覆盖索引查出主键,再回表取完整行:
SELECT o.* FROM order_main o INNER JOIN ( SELECT order_id FROM order_main WHERE user_id = 123 ORDER BY order_id DESC LIMIT 2000, 20 ) AS t ON o.order_id = t.order_id;子查询里的SELECT order_id走了覆盖索引,不需要回表,扫描 2020 行但只读索引页,比直接SELECT *快很多。外层再拿 20 个主键回表,代价可控。
3.3 商品搜索:别用 LIKE '%关键词%'
电商前台搜索框如果用LIKE '%手机%',索引直接失效,全表扫描。数据量小的时候感觉不出来,商品上到十万级就开始卡。正确的做法是引入搜索引擎,但在那之前,可以用 MySQL 全文索引顶一阵:
ALTER TABLE product_spu ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram; SELECT * FROM product_spu WHERE MATCH(title) AGAINST('手机' IN BOOLEAN MODE) AND status = 1;WITH PARSER ngram是中文分词的必备参数,否则 MySQL 默认按空格分词,中文搜索会失效。但全文索引的召回率和排序能力有限,如果搜索是核心功能,还是建议上 Elasticsearch,用 Canal 或 Logstash 同步数据。
4. 事务与库存扣减:超卖是怎么发生的,怎么根治
4.1 库存扣减的三种写法,只有一种是对的
先看错误的写法。很多项目会这么扣库存:
-- 错误示范:先查再改,并发下必然超卖 SELECT stock FROM product_sku WHERE sku_id = 1001; -- 应用层判断 stock >= quantity UPDATE product_sku SET stock = stock - 5 WHERE sku_id = 1001;两个并发请求同时查到stock=10,都判断可以扣,然后都执行UPDATE,最终库存变成 0,但实际卖出了 10 件。这就是超卖。
正确的写法是利用数据库的原子性,把判断和扣减合并成一条 SQL:
UPDATE product_sku SET stock = stock - 5 WHERE sku_id = 1001 AND stock >= 5;然后检查affected_rows,如果返回 0,说明库存不足,回滚事务。这条 SQL 在 InnoDB 行锁的保护下,并发请求会串行执行,不会超卖。
4.2 事务隔离级别选 RC 还是 RR
MySQL 默认隔离级别是 RR(可重复读),但很多电商项目会改成 RC(读已提交)。原因在于 RR 下的间隙锁在并发插入时容易造成死锁,而 RC 没有间隙锁,并发写入性能更好。代价是 RC 下可能出现不可重复读,但电商业务里“同一事务内两次读同一行”的场景很少,通常可以接受。
修改方式:
-- 查看当前隔离级别 SELECT @@transaction_isolation; -- 会话级别修改 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 全局修改(需重启或新连接生效) SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;注意:改隔离级别前要确认业务代码没有依赖 RR 的可重复读特性,否则可能出现“同一事务内两次查询结果不一致”的 bug。
4.3 分布式事务:能不用就不用
订单服务扣库存、支付服务改订单状态、积分服务加积分——这三个操作如果跨服务,就涉及分布式事务。常见方案有 TCC、Saga、本地消息表。我的建议是:能拆成最终一致性就不要用强一致性。比如下单后发一条消息到 MQ,积分服务消费消息后加积分,失败就重试。这样每个服务只关心自己的本地事务,系统复杂度低很多。
如果非要用强一致方案,Seata 的 AT 模式是落地成本最低的,但要注意它对全局锁的依赖,高并发下会成为瓶颈。
5. 避坑与排查:电商数据库设计里那些后悔药
5.1 金额字段用了 FLOAT,对账时差了几毛钱
现象:财务对账时发现订单总金额和明细累加差了几毛钱,查代码逻辑没问题。
原因:FLOAT和DOUBLE是二进制浮点数,无法精确表示0.1、0.2这类十进制小数。累加次数多了,误差就会显现。
解决:所有金额字段一律用DECIMAL(M,2),应用层用BigDecimal(Java)或decimal.Decimal(Python),禁止用float做金额运算。
5.2 订单表没有冗余商品快照,商品改名后历史订单全乱了
现象:运营修改了某个商品名称,用户翻看三个月前的订单,发现商品名变了,客诉“你们篡改订单”。
原因:order_item只存了sku_id,展示时 JOINproduct_sku取当前名称。
解决:下单时把sku_name、sku_spec、price冗余进order_item,展示时直接读快照,不 JOIN 商品表。
5.3 唯一索引没加,同一手机号注册了三个账号
现象:用户反馈登录后看不到自己的订单,排查发现同一手机号对应三个user_id。
原因:注册接口先SELECT查重再INSERT,并发下两个请求同时查到“不存在”,然后都插入成功。
解决:mobile字段加UNIQUE KEY,应用层捕获唯一键冲突异常,返回“手机号已注册”。
5.4 大事务导致主从延迟飙升
现象:促销期间,下单后立刻查订单查不到,过几秒才出现。
原因:下单事务里除了写订单,还更新了库存、写日志、发消息,事务执行时间长,binlog 写入量大,从库回放跟不上。
解决:把非核心操作移出事务,比如发消息用事务提交后异步发送,日志写本地文件再采集。事务里只保留必须原子性的操作。
5.5 分库分表选错了分片键,查询全跨库
现象:订单表按order_id分片后,按user_id查订单要扫描所有分片。
原因:分片键选的是order_id,但最高频查询是“按用户查订单”。
解决:分片键要选最高频查询的维度。订单表通常按user_id分片,保证“我的订单”走单库。如果同时需要按order_id查,可以建一张order_id到user_id的映射表,或者用基因法把user_id的后几位嵌入order_id。
6. 从单库到分库分表:什么时候该动手,怎么平滑过渡
分库分表不是越早越好。单表 500 万行以内,InnoDB 配合好索引完全能扛。真正需要分片的信号是:单表超过 2000 万行且查询开始变慢,或者单库磁盘 IO 达到瓶颈。在那之前,优先考虑读写分离、加缓存、归档历史数据。
如果确定要分,推荐用 ShardingSphere-JDBC,对应用侵入小。配置分片规则时,user_id取模分 16 库、每库 4 表,总共 64 张订单表。迁移时用双写方案:新代码同时写旧库和新库,跑一段时间后校验数据一致,再切读流量到新库,最后停掉旧库写入。
# ShardingSphere 分片配置示例 rules: - !SHARDING tables: order_main: actualDataNodes: ds_${0..15}.order_main_${0..3} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_inline shardingAlgorithms: order_inline: type: INLINE props: algorithm-expression: order_main_${user_id % 4}分片后跨库查询、分布式主键、分布式事务都会变成新问题。我的习惯是:分片前先把业务代码里的 JOIN 全部拆掉,改成应用层组装,这样分片时只需要改配置,不用大改代码。另外,分片键一旦选定,后期迁移成本极高,选之前至少把 Top 10 的查询 SQL 列出来,确认分片键能覆盖 80% 以上的场景。希望帮到你。
本文还有配套的精品资源,点击获取