04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化

本篇是"前端工程师转后端"系列第 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、事务、库存、订单、支付章节都会基于这些表继续展开。

本篇解决这些问题:

  1. 数据库、表、行、列和前端对象有什么关系;
  2. 为什么后端不能只把数据放内存或 Redis;
  3. MySQL 在当前项目中承担什么角色;
  4. CREATE TABLE、字段类型、NOT NULL、默认值是什么意思;
  5. 主键、唯一索引、普通索引分别解决什么问题;
  6. 商品、分类、SKU、购物车、订单、支付这些表如何组成商城业务;
  7. 为什么订单项要保存"快照"字段;
  8. 如何写最基础 SQL 查询本项目数据;
  9. 如何用 Docker MySQL 和 mysql 客户端验证;
  10. 前端转后端学习数据库时最容易踩哪些坑。

学完本篇,你不需要马上成为 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 里的表才是用户、商品、订单、支付这些核心业务数据的权威记录。

可以用这张图理解数据层级:

flowchart TD A[前端组件 state\n临时 UI 状态] --> B[前端请求 payload\n提交给后端] B --> C[Controller Request DTO\n服务端输入白名单] C --> D[业务校验和转换] D --> E[MySQL 表\n业务事实来源] E --> F[查询与组装] F --> G[Response DTO\n对外输出契约] G --> H[前端页面渲染] E -. 可选读取加速 .-> R[Redis 缓存] R -. 命中时返回副本 .-> F

前端转后端学习数据库时,建议先建立一个朴素判断:凡是"刷新页面不能丢、换浏览器不能丢、服务重启不能丢、多人同时访问要一致"的数据,都应该进入持久化数据库或由数据库派生出来。商品、订单、支付显然都属于这一类。

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 的价值在于:

  1. 持久化:服务重启后数据仍在;
  2. 结构化:字段类型、长度、是否允许为空都有约束;
  3. 可查询:可以按条件、排序、分页查询;
  4. 一致性:事务可以保证一组修改要么都成功,要么都失败;
  5. 约束:主键、唯一索引、检查约束能防止部分脏数据;
  6. 并发控制:多个请求同时修改数据时,数据库提供锁、隔离级别、版本控制等机制。

前端同学可以把 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。

它们之间的关系可以画成这样:

erDiagram mall_user ||--o{ mall_product : creates mall_category ||--o{ mall_product : contains mall_product ||--o{ mall_product_sku : has mall_user ||--o{ mall_cart_item : owns mall_product_sku ||--o{ mall_cart_item : selected mall_user ||--o{ mall_order : places mall_order ||--o{ mall_order_item : contains mall_product_sku ||--o{ mall_order_item : snapshot_from mall_order ||--|| mall_payment : paid_by

注意,当前 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,数据库里是多张表:

flowchart LR A["GET /api/products/{id}"] --> B["ProductController.detail"] B --> C[ProductFacade.getPublishedProduct] C --> D[查询 mall_product] C --> E[查询 mall_category] C --> F[可选查询 mall_product_sku] D --> G[ProductDetailResponse.id/title/subtitle/status] E --> H[ProductDetailResponse.categoryName] F --> I[后续商品详情 SKU 信息] G --> J[ApiResponse] H --> J I --> J J --> K[前端渲染商品详情页]

这个图要表达的不是当前方法一定只执行这些 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 和接口互相验证

学习后端时,不要只看接口,也不要只看数据库。建议你做闭环验证:

  1. 用 SQL 查 mall_product,确认有哪些 ON_SALE 商品;
  2. 请求 GET /api/products,看接口是否只返回上架商品;
  3. 用 SQL 查某个商品的 category_id;
  4. 再查 mall_category,确认响应里的 categoryName 从哪里来;
  5. 修改数据库中的某条测试数据;
  6. 重新请求接口,看响应是否变化;
  7. 如果响应没变化,再考虑是否存在 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,回答:

  1. 哪些字段不能重复?
  2. 每个唯一索引背后的业务原因是什么?
  3. 哪个唯一索引和"防重复提交订单"有关?

练习 3:解释商品和 SKU

用自己的话解释:

  1. mall_product 保存什么;
  2. mall_product_sku 保存什么;
  3. 为什么价格和库存不直接放在商品表;
  4. 前端商品详情页如何同时展示 SPU 和 SKU 信息。

练习 4:写基础查询

写 SQL 查询:

  1. 所有启用分类;
  2. 所有已上架商品;
  3. 商品 ID 为 1 的所有 SKU;
  4. 用户 ID 为 1 的购物车;
  5. 用户 ID 为 1 的订单列表。

练习 5:解释订单快照

打开 mall_order_item 表结构,回答:

  1. 为什么保存 product_title;
  2. 为什么保存 unit_price;
  3. 如果只保存 sku_id,历史订单会有什么风险;
  4. 前端展示订单详情时应该相信订单项快照,还是商品当前价格?

练习 6:SQL 与接口闭环

启动 MySQL 和后端服务后:

  1. 用 SQL 查已上架商品数量;
  2. 用 curl 请求 GET /api/products;
  3. 对比接口返回的 total 和 SQL 查询结果;
  4. 如果不一致,检查 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"。

相关推荐
笃行35014 小时前
集中式与分布式,KingbaseES 的四种形态共用一套内核
前端
金銀銅鐵15 小时前
为什么枚举类中可以定义抽象方法
java·后端·ai编程
陈随易15 小时前
Vue 编译器换掉 source map,快了三四成
前端·后端·程序员
ZhengEnCi16 小时前
12 种高级 UI 交互效果及对应 AI 提示词:从按压回弹到底部抽屉的物理感教学
前端·css·交互设计
txxzjmzlh16 小时前
SpringBoot 统⼀功能处理
java·spring boot·后端
nice_lcj52018 小时前
Java Lambda 表达式入门:怎么写、怎么用、常用方法有哪些
java·开发语言·后端·面试
IT_陈寒19 小时前
Redis大key删除为什么会导致服务卡顿?我的线上事故复盘
前端·人工智能·后端
系统集成-鹰翔19 小时前
ERP图片改名后仍命中识别缓存:缓存键、十分钟到期与调用计数
javascript·经验分享·后端
用户83562907805119 小时前
使用 Python 在 Excel 中添加和自定义迷你图
后端·python
码事漫谈19 小时前
给AI看一颗❤️,它就开始写递归了
后端