本篇是"前端工程师转后端"系列第 4 篇。前 3 篇我们已经知道了后端工程怎么启动、HTTP 请求如何进入 Controller、Request 和 Response 如何成为接口契约。从这一篇开始,我们把视线继续往下移动:接口返回的商品、购物车、订单、支付数据,最终来自哪里?答案是数据库。本项目使用 MySQL 作为主要持久化数据库,
sql/01_schema.sql定义了核心表结构,sql/02_seed.sql准备了本地演示数据。本篇先补数据库基础,不要求你已经懂 MyBatis-Plus。
说明:本文中的前端代码均为"前端侧示意代码",用于类比浏览器内存状态、localStorage、接口数据和 MySQL 持久化数据。当前仓库没有真实前端源码目录。
1. 这篇解决什么问题
前端同学转后端时,常见误区是把"数据"只理解成 JavaScript 对象。例如一个商品在前端可能是:
ts
// 前端侧示意代码
const product = {
id: 1,
title: '九成新 iPhone 15',
price: 3999,
stock: 5
}
你会把它放在组件状态、Pinia / Vuex、React state、localStorage,或者从接口响应里拿到后渲染页面。但后端同学首先会问:这个数据是否需要长期保存?由谁创建?如何保证唯一?如何查询?如何排序?如何防止重复?如何避免库存变成负数?如何在订单创建后保留价格快照?
这些问题不是 Java 对象自己能解决的,而是数据库设计要解决的。当前项目的核心数据库文件是:
text
sql/01_schema.sql
sql/02_seed.sql
01_schema.sql 定义表结构、字段、主键、唯一索引、普通索引、检查约束;02_seed.sql 插入本地学习用的用户、分类、商品、SKU、购物车、订单、支付等演示数据。后续 MyBatis-Plus、事务、库存、订单、支付章节都会基于这些表继续展开。
本篇解决这些问题:
- 数据库、表、行、列和前端对象有什么关系;
- 为什么后端不能只把数据放内存或 Redis;
- MySQL 在当前项目中承担什么角色;
CREATE TABLE、字段类型、NOT NULL、默认值是什么意思;- 主键、唯一索引、普通索引分别解决什么问题;
- 商品、分类、SKU、购物车、订单、支付这些表如何组成商城业务;
- 为什么订单项要保存"快照"字段;
- 如何写最基础 SQL 查询本项目数据;
- 如何用 Docker MySQL 和
mysql客户端验证; - 前端转后端学习数据库时最容易踩哪些坑。
学完本篇,你不需要马上成为 DBA,但应该能打开 sql/01_schema.sql,读懂每张表大概保存什么、字段有什么约束、索引为什么存在、接口返回的数据大概率来自哪几张表。
2. 用前端知识类比:state、localStorage、接口缓存与 MySQL
前端也有很多"存数据"的地方:
| 前端位置 | 生命周期 | 适合存什么 | 后端类比 |
|---|---|---|---|
| 组件 state | 页面组件存在期间 | 输入框内容、loading、临时选择 | 方法里的局部变量或内存对象 |
| Pinia / Vuex | 页面刷新前,或应用运行期间 | 登录态、购物车展示态、筛选条件 | 后端进程内缓存,但不可靠持久 |
| localStorage | 浏览器本地长期保存 | token、用户偏好、草稿 | 客户端本地存储,不可信 |
| Axios response cache | 短期减少请求 | 商品详情、字典数据 | Redis / 本地缓存 |
| 后端接口响应 | 请求结束后前端拿到副本 | 页面展示数据 | Response DTO |
| MySQL | 长期、可靠、可查询 | 用户、商品、订单、支付 | 持久化事实来源 |
这里最重要的区别是:前端状态通常是某个用户、某个浏览器、某次会话的局部副本;MySQL 是服务端业务事实的长期来源。
例如,前端购物车 store 里有一个商品数量 quantity = 2,这只是当前页面展示。真正提交订单时,后端还要检查数据库里的 SKU 是否仍然可售、库存是否足够、价格是否变化、购物车项是否属于当前用户。前端传来的数据不能直接相信,因为前端环境完全由用户控制。
Redis 也能存数据,但它在本项目中主要用于缓存,后续第 7 篇会讲。缓存不是事实来源。缓存可以丢,可以过期,可以重建。MySQL 里的表才是用户、商品、订单、支付这些核心业务数据的权威记录。
可以用这张图理解数据层级:
前端转后端学习数据库时,建议先建立一个朴素判断:凡是"刷新页面不能丢、换浏览器不能丢、服务重启不能丢、多人同时访问要一致"的数据,都应该进入持久化数据库或由数据库派生出来。商品、订单、支付显然都属于这一类。
3. 后端核心概念讲解
3.1 数据库、表、行、列
MySQL 是关系型数据库。你可以先把它类比成一个非常严格、可查询、可持久化的表格系统。
- 数据库 database:一组业务表的集合。当前项目创建的是
fullstack_mall; - 表 table:某类数据的集合,例如
mall_product商品表; - 行 row:一条具体记录,例如一件商品;
- 列 column:一条记录的某个字段,例如
title、status_code; - SQL:操作数据库的语言,例如创建表、插入数据、查询数据、更新数据。
前端对象:
ts
// 前端侧示意代码
{
id: 1,
title: '九成新 iPhone 15',
statusCode: 'ON_SALE'
}
数据库行:
text
id | title | status_code
1 | 九成新 iPhone 15 | ON_SALE
前端对象字段通常是驼峰命名,数据库字段常用下划线命名。当前项目在 application.yml 中配置了 MyBatis-Plus 的 map-underscore-to-camel-case: true,后续 ORM 章节会讲它如何把 status_code 映射成 Java 的 statusCode。
3.2 为什么需要持久化
如果后端只把商品保存在 Java 内存里,服务重启后数据就没了;如果只把购物车保存在前端 localStorage,用户换设备就没了;如果只把订单保存在 Redis,缓存淘汰或过期就会丢失重要交易记录。
MySQL 的价值在于:
- 持久化:服务重启后数据仍在;
- 结构化:字段类型、长度、是否允许为空都有约束;
- 可查询:可以按条件、排序、分页查询;
- 一致性:事务可以保证一组修改要么都成功,要么都失败;
- 约束:主键、唯一索引、检查约束能防止部分脏数据;
- 并发控制:多个请求同时修改数据时,数据库提供锁、隔离级别、版本控制等机制。
前端同学可以把 MySQL 理解成"服务端全局 store",但这个 store 比前端状态管理严格得多。前端 store 主要服务页面渲染,MySQL 服务业务事实。
3.3 CREATE DATABASE 和字符集
当前 sql/01_schema.sql 开头是:
sql
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS fullstack_mall
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
USE fullstack_mall;
SET NAMES utf8mb4 和 CHARACTER SET utf8mb4 都与字符编码有关。简单说,utf8mb4 能正确保存中文和 emoji。前端同学应该很熟悉乱码问题:页面、接口、数据库任何一层编码不一致,都可能出现中文变问号。后端建库时指定字符集,就是为了从源头避免这类问题。
CREATE DATABASE IF NOT EXISTS 表示如果数据库不存在就创建,已经存在则不报错。USE fullstack_mall 表示后续建表都在这个数据库里执行。
3.4 字段类型:BIGINT、VARCHAR、DECIMAL、DATETIME
看 mall_product 表:
sql
CREATE TABLE IF NOT EXISTS mall_product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品主键',
category_id BIGINT UNSIGNED NOT NULL COMMENT '分类 ID',
title VARCHAR(100) NOT NULL COMMENT '商品标题',
subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT '商品副标题',
description VARCHAR(2000) NULL COMMENT '商品描述',
status_code VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT、ON_SALE、OFF_SHELF',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建管理员 ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id)
)
这里出现了几个常用类型。
BIGINT UNSIGNED 适合保存较大的非负整数 ID。UNSIGNED 表示不允许负数。商品 ID、用户 ID、订单 ID 都不应该是负数。
VARCHAR(100) 表示变长字符串,最多 100 个字符。商品标题、用户名、SKU 编码都适合用字符串。长度限制很重要,因为它既表达业务规则,也保护数据库不要被超长文本拖垮。
DECIMAL(12,2) 用于金额,例如 SKU 销售价、订单总金额、支付金额。不要用浮点数保存钱。前端 JavaScript 的 number 在小数运算上可能出现精度问题,后端和数据库处理金额时更要谨慎。DECIMAL(12,2) 表示总共 12 位,其中小数 2 位。
DATETIME(3) 表示日期时间,精确到毫秒。当前项目很多表都有 created_at、updated_at,订单和支付还会有 paid_at、closed_at 等业务时间。
3.5 NOT NULL、NULL、默认值
字段后面的 NOT NULL 表示不能为空,NULL 表示可以为空。例如商品标题 title 是必填,所以 NOT NULL;商品描述 description 可以没有,所以是 NULL。
默认值用于前端或业务没有显式提供时,由数据库填充。比如:
sql
status_code VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
subtitle VARCHAR(100) NOT NULL DEFAULT ''
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
创建商品时,如果没有指定状态,默认是草稿 DRAFT。副标题默认为空字符串。创建时间默认使用当前时间。这样可以减少业务代码重复设置基础字段,也能保证数据库层有兜底。
但默认值不是万能的。比如 created_by 不能简单默认 0,因为它应该来自当前登录管理员;total_amount 不能随便默认 0,因为订单金额必须由订单项计算出来。哪些字段能默认,哪些字段必须由业务明确生成,是表设计的一部分。
3.6 主键:每行数据的唯一身份
几乎每张表都有:
sql
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '...主键'
PRIMARY KEY (id)
主键可以理解成每条记录的唯一身份。前端列表渲染时也会强调 key,例如 Vue 的 v-for 要有稳定 :key,React list 要有 key。数据库主键比前端 key 更严格:它用于唯一定位一行记录,被其他表引用,也经常作为更新和删除的条件。
AUTO_INCREMENT 表示数据库自动递增生成 ID。创建商品时,前端不应该传 id,后端也不应该随便手写 ID,而是让数据库生成。种子数据里为了演示会显式插入固定 ID,这是为了本地学习可重复,不代表业务接口也让用户传 ID。
3.7 唯一索引:防止重复业务数据
唯一索引保证某个字段或字段组合不能重复。例如:
sql
UNIQUE KEY uk_mall_user_username (username)
UNIQUE KEY uk_mall_category_name (name)
UNIQUE KEY uk_mall_product_sku_code (sku_code)
UNIQUE KEY uk_mall_cart_item_user_sku (user_id, sku_id)
UNIQUE KEY uk_mall_order_user_idempotency (user_id, idempotency_key)
这些约束背后都有业务含义。
用户名不能重复,否则登录时无法判断到底是哪一个用户。分类名称不能重复,否则后台运营会混乱。SKU 编码是全局唯一的,因为它代表具体规格商品。购物车里同一个用户同一个 SKU 只能有一行,否则数量就会分散到多条记录里,结算时容易出错。订单的 (user_id, idempotency_key) 唯一,是为了防止用户重复点击提交订单造成重复下单。
前端也会做防重复点击、输入框重名校验,但前端防线不可靠。真正防止重复数据,必须依赖数据库唯一约束。因为并发请求可能同时到达后端,两个请求在前端看来都只点了一次,但数据库必须保证最终不会插入两条冲突记录。
3.8 普通索引:让查询更快
普通索引不保证唯一,它主要服务查询性能。例如:
sql
KEY idx_mall_product_status_created_at (status_code, created_at)
KEY idx_mall_product_category_id (category_id)
KEY idx_mall_order_status_expires_at (status_code, expires_at)
公开商品列表通常会查已上架商品,并按时间排序,所以 (status_code, created_at) 有意义。按分类筛选商品时,category_id 索引有意义。超时关单任务需要找某些状态且已经过期的订单,所以 (status_code, expires_at) 有意义。
前端类比可以是"给大数组查询提前建索引"。如果你每次都在十万条数组里全量遍历,性能会很差;如果提前按 ID 建一个 Map,查找就快很多。数据库索引也是类似思想,只是它由数据库引擎维护,支持大量数据和复杂查询。
但索引不是越多越好。每个索引都会占空间,也会增加写入成本。因为插入或更新数据时,数据库不仅要改表数据,还要维护索引结构。后端设计索引时,要根据真实查询场景来加。
3.9 检查约束:把底线放到数据库层
当前项目使用了 CHECK 约束,例如:
sql
CONSTRAINT chk_mall_product_sku_price CHECK (sale_price > 0)
CONSTRAINT chk_mall_product_sku_available_stock CHECK (available_stock >= 0)
CONSTRAINT chk_mall_cart_item_quantity CHECK (quantity BETWEEN 1 AND 99)
CONSTRAINT chk_mall_cart_item_checked CHECK (checked IN (0, 1))
CONSTRAINT chk_mall_order_total_amount CHECK (total_amount >= 0)
这些约束表达的是数据底线:价格必须大于 0,可售库存不能小于 0,购物车数量必须在 1 到 99,勾选状态只能是 0 或 1,订单金额不能为负。
前端表单可以限制数量输入框最小 1、最大 99;Controller Request DTO 也可以用 @Min、@Max;业务代码也可以校验。但数据库约束是最后一道防线。多层校验不是重复劳动,而是不同边界的安全设计。
3.10 表之间的关系:不要只看单表
商城项目很少只查一张表。当前表大致可以分成几组:
- 用户:
mall_user; - 商品:
mall_category、mall_product、mall_product_sku; - 购物车:
mall_cart_item; - 订单:
mall_order、mall_order_item; - 支付:
mall_payment。
它们之间的关系可以画成这样:
注意,当前 SQL 文件里没有显式 FOREIGN KEY 语句,但字段命名已经表达了业务关联,例如 product_id、category_id、user_id、order_id、sku_id。不显式建外键并不代表没有关系,而是关系由业务代码和索引约束共同维护。很多互联网项目会谨慎使用数据库外键,以便在高并发、迁移、分库分表场景下更灵活。初学阶段只要先理解字段关联即可。
4. 在本项目中对应哪些文件
| 文件 | 本章关注点 |
|---|---|
sql/01_schema.sql |
数据库、表、字段、主键、索引、检查约束定义 |
sql/02_seed.sql |
本地演示数据,帮助你用 SQL 查询真实样例 |
docker-compose.dev.yml |
本地 MySQL 8.4 容器端口映射,默认宿主机端口 3308 |
backend/service/src/main/resources/application.yml |
Spring Boot 默认连接 MySQL 的 JDBC 配置 |
backend/service/src/main/resources/application-local.yml |
local profile 使用 H2 内存库,不依赖 MySQL |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductDetailResponse.java |
响应字段如何来自商品表、分类表和服务端生成字段 |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java |
商品接口最终会通过业务层查询表数据 |
这一章虽然讲 MySQL,但仍然要和前面 Controller 章节连接起来:前端请求 Controller,Controller 调 Facade,Facade 后续会调用 DbService / Mapper,Mapper 最终执行 SQL 或通过 MyBatis-Plus 生成 SQL。今天先把表看懂,下一章再讲 Java 对象如何和表映射。
5. Mermaid 图:从接口字段追到数据库字段
以商品详情为例,前端看到的是 ProductDetailResponse,数据库里是多张表:
这个图要表达的不是当前方法一定只执行这些 SQL,而是训练你建立"响应字段来自哪里"的意识。看到一个响应字段,不要只问 Java 类里有没有这个属性,还要继续问它来自哪张表、是否经过计算、是否是快照、是否可能为空、是否会被缓存。
6. 逐段读 sql/01_schema.sql
6.1 用户表 mall_user
用户表保存登录和权限相关基础信息:
sql
CREATE TABLE IF NOT EXISTS mall_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户主键',
username VARCHAR(50) NOT NULL COMMENT '登录用户名',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt 密码哈希,绝不保存明文密码',
role_code VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:USER-普通用户,ADMIN-运营管理员',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
...
UNIQUE KEY uk_mall_user_username (username)
)
这里最值得你注意的是 password_hash,注释明确写着"绝不保存明文密码"。前端登录表单提交的是明文密码,但后端验证后只保存哈希。数据库泄露时,哈希仍然比明文安全得多。后续 JWT 与 Spring Security 章节会讲登录校验。
role_code 表示用户角色,普通用户是 USER,管理员是 ADMIN。这与前端路由守卫很像:前端可以根据角色隐藏按钮,但真正权限判断必须在后端。管理员接口 /api/admin/products 不能只靠前端隐藏入口。
status 表示账号是否启用。即使用户名密码正确,停用账号也不应登录或操作业务。
6.2 分类表 mall_category
分类表:
sql
CREATE TABLE IF NOT EXISTS mall_category (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类主键',
name VARCHAR(50) NOT NULL COMMENT '分类名称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前',
...
UNIQUE KEY uk_mall_category_name (name),
KEY idx_mall_category_status_sort (status, sort_order)
)
分类看起来简单,但它是商品查询的重要筛选条件。name 唯一,避免后台创建两个同名分类。status 可以让分类停用,但不一定删除历史数据。sort_order 用于前端展示分类列表时排序。
前端同学容易把"删除"和"不可见"混为一谈。后端系统里,很多数据不会物理删除,而是通过状态控制是否可用。分类停用后,历史商品或订单仍然可能需要保留关联信息。
6.3 商品表 mall_product
商品表保存 SPU 级别信息:
sql
CREATE TABLE IF NOT EXISTS mall_product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品主键',
category_id BIGINT UNSIGNED NOT NULL COMMENT '分类 ID',
title VARCHAR(100) NOT NULL COMMENT '商品标题',
subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT '商品副标题',
description VARCHAR(2000) NULL COMMENT '商品描述',
status_code VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT、ON_SALE、OFF_SHELF',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建管理员 ID',
...
)
SPU 可以先理解为"商品款"。比如"九成新 iPhone 15"是一件商品;它下面可能有不同规格 SKU,比如"黑色 / 128G"、"蓝色 / 256G"。当前商品表存标题、描述、状态、分类、创建人,不直接存库存和价格。价格与库存放在 SKU 表。
status_code 是非常重要的业务状态:DRAFT 草稿、ON_SALE 上架、OFF_SHELF 下架。公开商品接口只应该展示 ON_SALE。管理端可以查看和切换状态。后续状态机章节会讲哪些状态允许切换。
created_by 是创建管理员 ID,来源于可信登录上下文,而不是前端提交。第三篇讲过 ProductCreateRequest 没有 createdBy,原因就在这里。
6.4 SKU 表 mall_product_sku
SKU 表保存具体规格、价格和库存:
sql
CREATE TABLE IF NOT EXISTS mall_product_sku (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'SKU 主键',
product_id BIGINT UNSIGNED NOT NULL COMMENT '所属 SPU 商品 ID',
sku_code VARCHAR(64) NOT NULL COMMENT '全局唯一 SKU 编码',
spec_text VARCHAR(200) NOT NULL COMMENT '规格描述,例如 黑色 / 128G',
sale_price DECIMAL(12,2) NOT NULL COMMENT '销售价',
available_stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '可售库存',
locked_stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '锁定库存,阶段 7 使用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
...
)
前端商品详情页可能把商品和 SKU 展示在一个页面上,但数据库要拆开。因为一个商品可能有多个规格,每个规格价格和库存不同。如果把所有规格都塞进商品表,会导致字段重复、结构僵硬、查询困难。
available_stock 是可售库存,locked_stock 是锁定库存。用户提交订单但还没支付时,库存可能先锁定,支付成功再确认扣减,超时未支付再释放。这个设计后续订单章节会详细讲。
version 是乐观锁版本号,用于并发控制。前端防重复点击只能减少重复请求,不能解决多个用户同时抢同一库存的问题。真正的库存并发安全需要数据库条件更新、版本号、事务等后端机制。
6.5 购物车表 mall_cart_item
购物车明细表:
sql
CREATE TABLE IF NOT EXISTS mall_cart_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '购物车行 ID',
user_id BIGINT UNSIGNED NOT NULL COMMENT '所属用户 ID',
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID',
quantity INT UNSIGNED NOT NULL COMMENT '购买数量,当前限制 1 到 99',
checked TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否勾选:0 否,1 是',
...
UNIQUE KEY uk_mall_cart_item_user_sku (user_id, sku_id),
CONSTRAINT chk_mall_cart_item_quantity CHECK (quantity BETWEEN 1 AND 99)
)
前端购物车 store 可以随便组织数组,但后端购物车表必须保证一个用户同一个 SKU 只有一行。这就是 uk_mall_cart_item_user_sku 的作用。否则用户把同一 SKU 加入两次,数据库里可能出现两条记录,结算时到底按哪条数量算就会混乱。
checked 表示是否勾选。前端购物车页面里的 checkbox 不是纯 UI 状态,它可能需要持久化,因为用户刷新页面后仍然希望保留勾选状态。是否持久化某个状态,要看它是否属于业务流程。
6.6 订单主表 mall_order
订单主表:
sql
CREATE TABLE IF NOT EXISTS mall_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单主键',
order_no VARCHAR(64) NOT NULL COMMENT '对外订单号',
user_id BIGINT UNSIGNED NOT NULL COMMENT '下单用户 ID',
status_code VARCHAR(30) NOT NULL COMMENT '订单状态:PENDING_PAYMENT、PAID、CANCELLED、CLOSED',
total_amount DECIMAL(12,2) NOT NULL COMMENT '订单成交总金额',
idempotency_key VARCHAR(64) NOT NULL COMMENT '客户端幂等键,同一用户内唯一',
expires_at DATETIME(3) NOT NULL COMMENT '支付截止时间',
paid_at DATETIME(3) NULL COMMENT '支付成功时间',
cancelled_at DATETIME(3) NULL COMMENT '用户取消时间',
closed_at DATETIME(3) NULL COMMENT '系统超时关闭时间',
...
)
订单是后端学习中的重点,因为它连接用户、购物车、SKU、库存、支付、定时任务和事务。order_no 是对外订单号,不直接暴露数据库主键。status_code 表示订单状态流转。idempotency_key 用于防止重复提交订单。
前端点击"提交订单"时,很可能因为网络慢、用户连点、页面重试而发出多次请求。后端不能相信"前端按钮已经 disabled"。UNIQUE KEY uk_mall_order_user_idempotency (user_id, idempotency_key) 能让同一个用户同一个幂等键只创建一张订单。
expires_at、paid_at、cancelled_at、closed_at 是业务时间。订单不是只有 created / updated,它还有支付截止、支付成功、用户取消、系统关闭等生命周期节点。后续超时关单会依赖这些字段。
6.7 订单项表 mall_order_item
订单项表保存下单时的商品快照:
sql
CREATE TABLE IF NOT EXISTS mall_order_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单项主键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单 ID',
sku_id BIGINT UNSIGNED NOT NULL COMMENT '下单时 SKU ID',
product_id BIGINT UNSIGNED NOT NULL COMMENT '下单时商品 ID',
product_title VARCHAR(100) NOT NULL COMMENT '下单时商品标题快照',
sku_code VARCHAR(64) NOT NULL COMMENT '下单时 SKU 编码快照',
spec_text VARCHAR(200) NOT NULL COMMENT '下单时规格快照',
unit_price DECIMAL(12,2) NOT NULL COMMENT '下单时单价快照',
quantity INT UNSIGNED NOT NULL COMMENT '购买数量',
line_amount DECIMAL(12,2) NOT NULL COMMENT '订单行金额'
)
这里最关键的是"快照"。为什么订单项不只保存 sku_id,每次展示订单时再去查 SKU 当前标题、规格、价格?因为商品后续可能改名、改价、下架,但历史订单必须保留下单当时的信息。
前端类比:你在页面上渲染订单详情时,不能因为商品现在改名,就让用户历史订单里的商品名也变掉;也不能因为今天降价,就改变昨天订单的成交价。订单是交易凭证,必须保存下单时快照。
这也是前端转后端必须建立的一个核心概念:有些数据是当前状态,有些数据是历史事实。 商品表表示当前商品资料,订单项表示下单那一刻的商品快照。
6.8 支付流水表 mall_payment
支付表:
sql
CREATE TABLE IF NOT EXISTS mall_payment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '支付流水主键',
payment_no VARCHAR(64) NOT NULL COMMENT '本系统支付流水号',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单 ID,一张订单一条支付流水',
user_id BIGINT UNSIGNED NOT NULL COMMENT '支付用户 ID',
amount DECIMAL(12,2) NOT NULL COMMENT '来自订单快照的支付金额',
status_code VARCHAR(20) NOT NULL COMMENT '支付状态:PENDING、SUCCESS、CLOSED',
provider_transaction_no VARCHAR(64) NULL COMMENT '模拟支付平台交易号',
callback_id VARCHAR(64) NULL COMMENT '回调事件唯一 ID',
...
)
支付流水记录的是支付过程。payment_no 是本系统支付流水号,provider_transaction_no 是模拟支付平台交易号,callback_id 是回调事件唯一 ID。唯一索引 uk_mall_payment_callback_id 可以防止同一个支付回调事件被重复处理。
前端可能只看到"支付中、支付成功、支付失败",但后端必须记录支付流水,处理回调幂等,确保订单状态和库存状态一致。支付不是页面跳转成功就结束,它涉及第三方回调、重复通知、金额校验、状态竞争。
7. 常用 SQL:先会查,再会改
7.1 查询所有启用分类
sql
SELECT id, name, status, sort_order
FROM mall_category
WHERE status = 1
ORDER BY sort_order ASC;
这条 SQL 对应前端分类导航或筛选器。WHERE status = 1 表示只查启用分类,ORDER BY sort_order 表示按后台配置排序。
7.2 查询已上架商品列表
sql
SELECT id, category_id, title, subtitle, status_code, created_at
FROM mall_product
WHERE status_code = 'ON_SALE'
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;
这和 GET /api/products?pageNum=1&pageSize=10 的底层思想接近。分页查询不能一次拉全部数据。LIMIT 10 OFFSET 0 表示取第一页 10 条。第 2 页通常是 LIMIT 10 OFFSET 10。
前端传 pageNum、pageSize,后端会转换成数据库分页参数。后续 MyBatis-Plus 会帮我们生成类似 SQL,但你必须先理解 SQL 本身。
7.3 按商品 ID 查询详情
sql
SELECT p.id,
p.category_id,
c.name AS category_name,
p.title,
p.subtitle,
p.description,
p.status_code,
p.created_by,
p.created_at,
p.updated_at
FROM mall_product p
JOIN mall_category c ON c.id = p.category_id
WHERE p.id = 1
AND p.status_code = 'ON_SALE';
这里使用了 JOIN,把商品表和分类表连接起来。ProductDetailResponse.categoryName 很可能就来自分类表 mall_category.name。前端只看到一个 JSON 字段,后端可能需要跨表查询和组装。
7.4 查询商品 SKU
sql
SELECT id, product_id, sku_code, spec_text, sale_price, available_stock, locked_stock
FROM mall_product_sku
WHERE product_id = 1
ORDER BY id ASC;
商品详情页展示规格和库存时,需要 SKU 数据。SPU 负责商品公共信息,SKU 负责具体规格、价格、库存。前端如果把它们都叫"商品",后端就很容易混乱,所以要尽早区分 SPU 和 SKU。
7.5 查询用户购物车
sql
SELECT ci.id,
ci.user_id,
ci.sku_id,
ci.quantity,
ci.checked,
s.spec_text,
s.sale_price,
p.title
FROM mall_cart_item ci
JOIN mall_product_sku s ON s.id = ci.sku_id
JOIN mall_product p ON p.id = s.product_id
WHERE ci.user_id = 1
ORDER BY ci.updated_at DESC;
购物车页面通常不只展示 sku_id 和数量,还要展示商品标题、规格、价格。因此后端查询购物车时会连接购物车表、SKU 表、商品表。
7.6 查询订单和订单项
sql
SELECT id, order_no, user_id, status_code, total_amount, created_at, expires_at
FROM mall_order
WHERE user_id = 1
ORDER BY created_at DESC;
SELECT order_id, product_title, spec_text, unit_price, quantity, line_amount
FROM mall_order_item
WHERE order_id = 1;
订单主表和订单项表是一对多关系。前端订单列表可能只展示主表摘要,订单详情页才展示订单项。后端设计接口时,也会区分列表响应和详情响应,避免列表接口返回过多数据。
7.7 写 SQL 时的前端类比思路
前端同学可以把一条 SELECT 想象成一次对数组的链式处理,但要记住数据库不是把所有数据拉到内存后再处理。比如:
sql
SELECT id, title
FROM mall_product
WHERE status_code = 'ON_SALE'
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;
如果用前端伪代码类比,大概像:
ts
// 前端侧示意代码
products
.filter(item => item.statusCode === 'ON_SALE')
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 10)
.map(item => ({ id: item.id, title: item.title }))
这个类比能帮助你理解 WHERE、ORDER BY、LIMIT、SELECT 的作用,但千万不要误以为数据库真的按 JavaScript 数组方式执行。MySQL 会根据索引、统计信息、优化器选择执行计划。比如 idx_mall_product_status_created_at 就是为了让按状态筛选和按创建时间排序更高效。
JOIN 也可以先类比成把两个数组按某个 key 关联起来。商品详情需要 mall_product.category_id 对上 mall_category.id,才能得到分类名称。但在真实数据库里,JOIN 的性能、关联字段索引、结果行数量都需要考虑。初学阶段先能读懂"谁和谁通过哪个字段关联",后续再逐步学习执行计划和索引优化。
另外,写 SQL 时建议先从最小查询开始,不要一上来写很长的多表查询。比如先查商品表确认商品存在,再查分类表确认分类名称,再写 JOIN 合并。这个习惯和前端调试类似:复杂页面渲染错了,不要一上来怀疑所有组件,先确认接口数据、再确认状态转换、最后确认渲染逻辑。数据库学习也是这样,先把单表查准,再把关联讲清,最后才考虑性能优化和事务一致性。每一次查询都要能回答三个问题:查哪张表,按什么条件过滤,返回字段给哪个接口使用,证据清楚。
8. 本地运行 / MySQL 验证
8.1 启动 MySQL 容器
当前项目提供了:
bash
docker compose -f docker-compose.dev.yml up -d mysql
根据项目配置,MySQL 宿主机端口默认是 3308,容器内部是 3306。Spring Boot 默认 JDBC URL 指向:
text
jdbc:mysql://localhost:3308/fullstack_mall
这就是为什么你本机如果用 MySQL 客户端连接,也要连 localhost:3308,不是 3306。
8.2 执行建表和种子数据
如果你使用命令行客户端,常见形式是:
bash
mysql -h 127.0.0.1 -P 3308 -u root -p < sql/01_schema.sql
mysql -h 127.0.0.1 -P 3308 -u root -p fullstack_mall < sql/02_seed.sql
具体密码以 docker-compose.dev.yml 中 MySQL 配置为准。也可以进入容器执行,或者使用 DBeaver、DataGrip、TablePlus 等图形化工具。
8.3 查看表是否创建成功
sql
USE fullstack_mall;
SHOW TABLES;
你应该看到类似:
text
mall_user
mall_category
mall_product
mall_product_sku
mall_cart_item
mall_order
mall_order_item
mall_payment
8.4 查看商品样例数据
sql
SELECT id, title, subtitle, status_code
FROM mall_product
ORDER BY id;
根据种子数据,你会看到类似"九成新 iPhone 15"、"Nintendo Switch OLED"、"Java 核心技术卷 I"、"深入理解计算机系统"等演示商品。它们状态不同,有的上架,有的草稿,有的下架。这样你可以验证公开接口为什么只返回部分商品。
8.5 用 SQL 和接口互相验证
学习后端时,不要只看接口,也不要只看数据库。建议你做闭环验证:
- 用 SQL 查
mall_product,确认有哪些ON_SALE商品; - 请求
GET /api/products,看接口是否只返回上架商品; - 用 SQL 查某个商品的
category_id; - 再查
mall_category,确认响应里的categoryName从哪里来; - 修改数据库中的某条测试数据;
- 重新请求接口,看响应是否变化;
- 如果响应没变化,再考虑是否存在 Redis 缓存或 profile 连错库。
这个闭环会帮助你从"只会看前端 Network"升级成"能沿着接口追到数据库"。
9. 常见错误
错误 1:把数据库表当成 Excel
表看起来像 Excel,但数据库不是 Excel。字段类型、索引、约束、事务、并发控制都不是普通表格能提供的。不要只关注数据长什么样,还要关注数据规则。
错误 2:金额用浮点数
前端 JavaScript 里 0.1 + 0.2 会有精度问题。数据库里金额应该用 DECIMAL,当前项目的价格和金额都使用 DECIMAL(12,2)。后端 Java 里也通常用 BigDecimal 处理金额。
错误 3:相信前端传来的用户 ID 和金额
创建商品的 created_by、订单的 user_id、订单金额、支付金额都应该由后端根据登录上下文和数据库数据生成或校验。前端传来的用户 ID 和金额都可以被篡改,不能直接相信。
错误 4:列表接口不分页
前端开发本地只有几条数据时,全部返回似乎没问题。但真实系统数据量会增长。当前项目的 ProductQueryRequest 默认 pageNum = 1、pageSize = 10,并限制 pageSize <= 100,这是保护服务的基本做法。
错误 5:只在业务代码里防重复,不建唯一索引
前端防重复点击和后端先查再插入都不能完全解决并发重复。唯一索引才是数据库层的最终保证。例如用户名唯一、SKU 编码唯一、购物车同用户同 SKU 唯一、订单幂等键唯一。
错误 6:不知道索引为什么存在
看到 idx_mall_product_status_created_at 不要只背"这是索引"。要问:哪个查询会用它?公开商品列表按状态筛选并按创建时间排序,所以这个索引有业务意义。
错误 7:把当前状态和历史快照混淆
商品表是当前商品资料,订单项表保存下单时快照。历史订单不能因为商品改名、改价而改变。订单、支付、账务类系统尤其重视历史事实。
错误 8:连错数据库或 profile
第 2 篇讲过,默认配置连 MySQL,local profile 连 H2 内存库。你用 SQL 改了 MySQL,但服务启用的是 local,接口当然不会变化。排查数据问题时先确认 profile 和 JDBC URL。
错误 9:看到没有外键就以为表没有关系
当前 SQL 没有显式 FOREIGN KEY,但 category_id、product_id、sku_id、order_id、user_id 都表达了业务关系。是否使用数据库外键是工程取舍,不等于业务上没有关联。
错误 10:在生产数据上随便执行 UPDATE / DELETE
本地学习可以大胆试,但真实环境必须非常谨慎。执行修改前要确认 WHERE 条件,最好先用相同条件 SELECT。没有 WHERE 的 UPDATE 或 DELETE 可能影响整张表。
10. 本章小练习
练习 1:画出表分组
打开 sql/01_schema.sql,把 8 张表按下面类别分组:
- 用户;
- 商品;
- 购物车;
- 订单;
- 支付。
然后用 Mermaid erDiagram 或手绘方式标出它们之间的关系。
练习 2:找唯一索引
在 sql/01_schema.sql 里搜索 UNIQUE KEY,回答:
- 哪些字段不能重复?
- 每个唯一索引背后的业务原因是什么?
- 哪个唯一索引和"防重复提交订单"有关?
练习 3:解释商品和 SKU
用自己的话解释:
mall_product保存什么;mall_product_sku保存什么;- 为什么价格和库存不直接放在商品表;
- 前端商品详情页如何同时展示 SPU 和 SKU 信息。
练习 4:写基础查询
写 SQL 查询:
- 所有启用分类;
- 所有已上架商品;
- 商品 ID 为 1 的所有 SKU;
- 用户 ID 为 1 的购物车;
- 用户 ID 为 1 的订单列表。
练习 5:解释订单快照
打开 mall_order_item 表结构,回答:
- 为什么保存
product_title; - 为什么保存
unit_price; - 如果只保存
sku_id,历史订单会有什么风险; - 前端展示订单详情时应该相信订单项快照,还是商品当前价格?
练习 6:SQL 与接口闭环
启动 MySQL 和后端服务后:
- 用 SQL 查已上架商品数量;
- 用 curl 请求
GET /api/products; - 对比接口返回的
total和 SQL 查询结果; - 如果不一致,检查 profile、数据源、缓存和查询条件。
11. 本篇总结
这一章我们补齐了前端转后端必须掌握的数据库入门知识。你现在应该知道:
- MySQL 是当前项目的持久化事实来源;
- 前端 state、localStorage、接口响应都只是某个范围内的数据副本;
sql/01_schema.sql定义了fullstack_mall数据库和 8 张核心表;mall_user保存用户、角色、状态和密码哈希;mall_category保存商品分类和排序;mall_product保存 SPU 商品信息和状态;mall_product_sku保存具体规格、价格、库存和乐观锁版本;mall_cart_item用唯一索引保证同用户同 SKU 不重复;mall_order保存订单主信息、状态、幂等键和生命周期时间;mall_order_item保存下单快照,不能只依赖商品当前资料;mall_payment保存支付流水、回调 ID 和支付状态;- 主键用于唯一定位记录,唯一索引用于防重复,普通索引用于提升查询,检查约束用于保护数据底线;
- 学后端要建立"接口字段 → Java DTO → 数据库表字段"的追踪能力。
从前端视角看,数据库可能像一个更复杂的 store;但从后端视角看,数据库是业务事实和一致性的核心。后续你学习 MyBatis-Plus、事务、库存、支付时,所有逻辑都会围绕这些表展开。
12. 下一章预告
下一篇是第 5 篇:MyBatis-Plus 入门:从 SQL 表到 Java Entity、Mapper、DbService。
我们会基于当前项目继续讲:
- Entity 如何映射数据库表;
- Mapper 是什么,为什么它像"数据库 API client";
- MyBatis-Plus 的
BaseMapper、ServiceImpl能帮我们省哪些代码; QueryWrapper/LambdaQueryWrapper如何表达查询条件;- 为什么 DbService 层不等于 Facade 层;
- 如何从
ProductController一路追到ProductMapper; - 如何用 SQL 思维读懂 MyBatis-Plus 生成的查询。
学完下一篇,你就能把今天看到的表结构和 Java 代码连接起来,真正理解"后端如何查数据库并返回 JSON"。