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:一条记录的某个字段,例如 titlestatus_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 utf8mb4CHARACTER SET utf8mb4 都与字符编码有关。简单说,utf8mb4 能正确保存中文和 emoji。前端同学应该很熟悉乱码问题:页面、接口、数据库任何一层编码不一致,都可能出现中文变问号。后端建库时指定字符集,就是为了从源头避免这类问题。

CREATE DATABASE IF NOT EXISTS 表示如果数据库不存在就创建,已经存在则不报错。USE fullstack_mall 表示后续建表都在这个数据库里执行。

3.4 字段类型:BIGINTVARCHARDECIMALDATETIME

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_atupdated_at,订单和支付还会有 paid_atclosed_at 等业务时间。

3.5 NOT NULLNULL、默认值

字段后面的 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_categorymall_productmall_product_sku
  • 购物车:mall_cart_item
  • 订单:mall_ordermall_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_idcategory_iduser_idorder_idsku_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_atpaid_atcancelled_atclosed_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

前端传 pageNumpageSize,后端会转换成数据库分页参数。后续 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 }))

这个类比能帮助你理解 WHEREORDER BYLIMITSELECT 的作用,但千万不要误以为数据库真的按 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 = 1pageSize = 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_idproduct_idsku_idorder_iduser_id 都表达了业务关系。是否使用数据库外键是工程取舍,不等于业务上没有关联。

错误 10:在生产数据上随便执行 UPDATE / DELETE

本地学习可以大胆试,但真实环境必须非常谨慎。执行修改前要确认 WHERE 条件,最好先用相同条件 SELECT。没有 WHEREUPDATEDELETE 可能影响整张表。

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 的 BaseMapperServiceImpl 能帮我们省哪些代码;
  • QueryWrapper / LambdaQueryWrapper 如何表达查询条件;
  • 为什么 DbService 层不等于 Facade 层;
  • 如何从 ProductController 一路追到 ProductMapper
  • 如何用 SQL 思维读懂 MyBatis-Plus 生成的查询。

学完下一篇,你就能把今天看到的表结构和 Java 代码连接起来,真正理解"后端如何查数据库并返回 JSON"。

相关推荐
橘子星1 小时前
别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念
前端·javascript
苏三说技术1 小时前
为什么越来越多人使用WebFlux?
后端
大白要努力!1 小时前
纯前端实现 PDF 加水印工具 —— 零后端、支持中文、实时预览
前端·pdf·html
石小石Orz1 小时前
TRAE SOLO实战:实现一个桌面3D助手
前端·人工智能
蜡台1 小时前
使用 uni-popup 实现数据选择器Data-Picker
前端·javascript·html·uniapp·uni-popup·data-picker
拆房老料1 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件
blns_yxl1 小时前
Promise封装Fetch + 重试机制(HTML+JS)
前端·javascript·html
武子康1 小时前
Ask/Allow 不是安全边界:企业 Coding Agent 必须建立四层治理(Policy / Scoped Credential / Sandbox / Provenance)
人工智能·后端·agent