实战②:数据建模与 API 契约设计

bee2026-10-08118 分钟0 次阅读
把领域模型落成表结构:完整 DDL、索引与约束设计、字段类型选型理由;再把接口清单落成契约:DTO/VO 分层、统一响应与错误码、幂等与分页规范。
1 / 210
小节
〇、30 秒看懂
2 / 210

上一篇把「要建什么」定死了,这一篇交付两样能拿去干活的东西:一张能跑的表和一份能照着写代码的接口说明书。听起来朴素,实际是全站最容易翻车的两步——字段类型选错,半年后财务对不上账;对象在各层之间不换乘,一次改表就打爆前端。这一篇每个决定都带上「错了会怎样」的现场,并且每一条都能亲手跑出来。

3 / 210

先把五个词一句话解释掉:

4 / 210
  • DDL(建表语句):CREATE TABLE 那一整段话,告诉数据库「这张表有哪些列、每列什么类型、哪些值不许重复」
  • 索引(index):一本字典前面的目录页。查得快了,但每次增删改都得同步维护目录,所以写会变慢
  • DTO / Entity / VO:三种长得很像但使命不同的对象——DTO 收用户传来的请求,Entity 对应数据库一行,VO 是给前端看的响应
  • 幂等(idempotent):同一件事做十次,结果和做一次完全一样。判断标准只有一条:重复执行有没有额外副作用
  • 契约(contract):前后端共同签字的请求/响应说明书,规定字段名、类型、必填、出错给哪个码
5 / 210
类比

同一批食材,三种样子。 你去菜市场买「西红柿炒蛋」,摊主给你的是采购单(带泥的西红柿、整盒鸡蛋、还附一张价签)——这就是 DTO:按外面的规矩收进来。进后厨后它们被洗好切配、装进标了菜名的备料盒——这就是 Entity:按仓库的格子归位,多了批次号和保质期。出菜时端上桌的是一盘成品,看不见泥土也看不见价签——这就是 VO:只把该给人看的样子端出去。很多项目图省事「一个实体类传到底」,等于把这盒带泥的原材料连盒子一起端上客人餐桌:内部字段、删除标记、密码哈希全曝光。第六节会把这条链写成真实代码。

6 / 210
类比

同一张电影票不能出两次。 你在窗口打印失败,转身让工作人员「再打一次」,正确的系统不会给你第二张座位号相同的票,而是把第一次那张重新递给你——它认的是票号,不是「你这次来问」。BeeOrder 的下单幂等就是这个结构:客户端为一次下单意图生成一个 idempotentKey,数据库对它加唯一索引;重复提交撞在索引上,程序捕获冲突后查出第一单原样返回。「先查一遍有没有、没有再插」那套写法看着聪明,两个人同时查到「没有」就各下一单——窗口前面排两个人同时喊「再打一次」,座位就真没了。第八节专讲这件事。

7 / 210
架构图
图 · API 契约的七个要素
图 · API 契约的七个要素
8 / 210

这张图就是本篇的地图:左下两支(资源与路径、方法语义)和右上两支(状态码、错误码表)由第五、七节落地,中间的分页与幂等落在第九、八节,最下面那条「版本与兼容」是全篇反复强调的那句「接口只能增不能改」。看的时候顺手对照一件事:任何一个要素没写清,联调那天一定有人来问你——第七节的错误码表和第十三节的报错速查,就是提前把这些问答写好。

9 / 210

学完这一篇,你应该能回答三个问题:

10 / 210
  1. 为什么订单项非要冗余存商品名和单价?商品改名之后,历史订单会发生什么?
  2. 为什么金额必须 DECIMAL(12,2) + BigDecimal,而不能用 double?举一个具体的算错的例子。
  3. 「先查一下有没有重复,再决定插不插」这段代码错在哪?把它改成什么才算真正的幂等?
11 / 210
小节
一、建模总原则:先有领域,后有表
12 / 210

上一篇我们把 BeeOrder 的领域模型、状态机和接口清单定死了。这一篇要把它落成两样能交付的东西:数据库表结构与API 契约。但在动手写 CREATE TABLE 之前,必须先把顺序摆正——表结构是领域模型的落地结果,不是设计的起点。

13 / 210

很多项目的坏味道,都是从「先设计表」开始的:拿到需求先画 ER 图,字段怎么方便怎么来,结果业务规则无处安放,越写越拧巴。正确顺序是:先想清业务规则和聚合边界,再让表去承载它们。

14 / 210

三条建表原则:

15 / 210
  • 先领域后表结构:领域对象(User / Order / Inventory)是主语,表只是它的持久化形态。领域里没有的概念,表里也不该凭空冒出来。
  • 以聚合为单位建表:一个聚合根 = 一张主表 + 若干从表,从表通过外键指向聚合根,对外只能通过聚合根改动。order 与 order_item 是同一聚合,user 与 order 是两个聚合。
  • 跨聚合用 ID 引用,不建强外键:order.user_id 只存用户 ID,不加 FOREIGN KEY 约束。这不是偷懒,而是为了解耦与分布式扩展留后路——强外键在高并发下会带来额外的锁与级联风险。
16 / 210
小节
1.1 三范式与反范式的取舍:订单快照为什么必须冗余
17 / 210

第三范式(3NF)要求消除冗余:一个事实只存一处。但订单场景里,我们故意违反 3NF——order_item 冗余存了 product_name 和 product_price。

18 / 210
对照表
场景范式选择原因
用户、商品的基础信息遵循 3NF一处维护、多处引用,改一次全局生效
订单项的商品名与单价反范式(冗余快照)历史订单必须冻结「下单那一刻」的事实
订单总额 total_amount冗余汇总避免每次查询都 SUM(order_item),且总额本身就是业务事实
19 / 210

讲透这条,需要一个现场:5 月 1 日,用户以 5999 元买下一台「蜂巢智能音箱」;6 月 1 日,运营把它改名为「蜂巢音箱 Pro」并降价到 4999 元。如果 order_item 只存 product_id,那么用户 7 月查看历史订单、平台结算 6 月的退款、财务开具发票时,程序 join 到 product 表读到的都是新名字、新价格——用户会质问「我买的明明是 5999」,对账会彻底对不上。

20 / 210

快照冗余的不是「重复数据」,而是「那个时刻的事实」。 凡是「曾经是什么价、什么名、什么地址」,都必须在业务发生的那一刻冻结下来。理解了这一点,你就理解了为什么订单系统天然是「反范式」的重灾区。

21 / 210

把两种设计并排放上桌,账单一次算清——左边是教科书式的严格 3NF,右边是 BeeOrder 采用的按需冗余:

22 / 210
架构图
图 · 表设计:严格范式 vs 冗余快照
图 · 表设计:严格范式 vs 冗余快照
23 / 210

读这张图别急着站队,只比两笔账:左边的「省」省在存储,赔掉的是历史事实与查询性能;右边的「费」费在多存两个字段,买到的是永不变样的历史和一页一查。落到手上只剩一个判断句:这个字段三年后还应该显示当时的值吗?该,就快照;不该(比如商品营销文案),就老老实实 join 实时读。

24 / 210
提示

反范式不是随心所欲地冗余,而是有原则地冗余——只冗余「会随时间改变、且历史值必须保留」的字段。商品的描述文案可以 join 实时读,商品的下单价格必须快照。

25 / 210
小节
二、完整建表 DDL
26 / 210

八个表分散在两个聚合域里,先看全局再逐张落地:

27 / 210
架构图
图 1 · BeeOrder 数据模型
图 1 · BeeOrder 数据模型
28 / 210

BeeOrder 用 MySQL 8,引擎一律 InnoDB(要事务、要行锁),字符集 utf8mb4(要存 emoji 和中文),时间用 DATETIME(3)(毫秒精度)。其中三件事直接写进建表语句,剩下一件「连接与运行配置」要落在 application.yml 上——先把配置文件骨架交给生成器:

29 / 210
生成器
生成器先把 application.yml 的骨架搭出来application.yml1 / 5
勾「数据源」起步,再依次加上 server、jpa、logging、profile,看五段配置如何拼成一份文件;注意它给的时区参数是默认起点,第十节会把全项目的约定改成统一 UTC
产物
server:
  port: 8080

spring:
  application:
    name: beeorder
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER:root}          # ${} 占位符:环境变量优先,冒号后是默认值
    password: ${DB_PASS:}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      max-lifetime: 1740000            # 必须小于 MySQL 的 wait_timeout
      pool-name: beeHikari
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
30 / 210

生成结果里对照记两笔:DATETIME(3) 的毫秒精度要靠连接参数保住;生成器写的 serverTimezone=Asia/Shanghai 只是默认起点,先别改——第十节讲清楚为什么全项目要统一成 UTC。

31 / 210
小节
2.1 user 用户表
32 / 210
sql
CREATE TABLE `user` (  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户 ID,自增主键',  `phone`       VARCHAR(20)     NOT NULL                COMMENT '手机号,登录账号',  `password`    CHAR(60)        NOT NULL                COMMENT 'BCrypt 哈希,定长 60,永不存明文',  `nickname`    VARCHAR(32)     NOT NULL DEFAULT ''     COMMENT '昵称',  `status`      TINYINT         NOT NULL DEFAULT 1      COMMENT '1 正常 0 禁用',  `deleted`     TINYINT         NOT NULL DEFAULT 0      COMMENT '逻辑删除 0 否 1 是',  `create_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  `update_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3)                                ON UPDATE CURRENT_TIMESTAMP(3)        COMMENT '更新时间',  PRIMARY KEY (`id`),  UNIQUE KEY `uk_user_phone` (`phone`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4  COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表';
33 / 210
对照表
字段类型为什么这么选
idBIGINT UNSIGNED自增主键即聚簇索引,写入有序、页分裂少;unsigned 扩大可用范围
phoneVARCHAR(20) + 唯一索引登录账号必须唯一,20 位足够容纳国际号码
passwordCHAR(60)BCrypt 输出定长 60,用 CHAR 省去长度前缀;永不存明文
status / deletedTINYINT取值只有两三个,1 字节足够,比 VARCHAR 更省空间
create_timeDATETIME(3)毫秒精度,便于排查并发问题与稳定排序
34 / 210
小节
2.2 product 商品表
35 / 210
sql
CREATE TABLE `product` (  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品 ID',  `name`        VARCHAR(128)    NOT NULL                COMMENT '商品名',  `subtitle`    VARCHAR(255)    NOT NULL DEFAULT ''     COMMENT '副标题',  `price`       DECIMAL(12,2)   NOT NULL                COMMENT '单价(元),精确金额',  `status`      TINYINT         NOT NULL DEFAULT 1      COMMENT '1 上架 0 下架',  `extra_info`  JSON            NULL                    COMMENT '扩展信息(标签/图片),结构不定',  `deleted`     TINYINT         NOT NULL DEFAULT 0      COMMENT '逻辑删除',  `create_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  `update_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3)                                ON UPDATE CURRENT_TIMESTAMP(3)        COMMENT '更新时间',  PRIMARY KEY (`id`),  KEY `idx_product_status` (`status`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表';
36 / 210
对照表
字段类型为什么这么选
priceDECIMAL(12,2)金额必须精确,double 会因为二进制表示丢失精度(见第四节现场)
extra_infoJSON标签、图片等结构不定;MySQL 8 原生 JSON 可校验、可函数查询
statusTINYINT上架/下架二值,适合做低基数过滤
37 / 210
小节
2.3 inventory 库存表
38 / 210
sql
CREATE TABLE `inventory` (  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '库存 ID',  `product_id`  BIGINT UNSIGNED NOT NULL                COMMENT '商品 ID',  `available`   INT UNSIGNED    NOT NULL DEFAULT 0      COMMENT '可售数量,无符号天然拒绝负值',  `locked`      INT UNSIGNED    NOT NULL DEFAULT 0      COMMENT '锁定数量(下单未支付)',  `version`     INT UNSIGNED    NOT NULL DEFAULT 0      COMMENT '乐观锁版本号',  `update_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3)                                ON UPDATE CURRENT_TIMESTAMP(3)        COMMENT '更新时间',  PRIMARY KEY (`id`),  UNIQUE KEY `uk_inventory_product` (`product_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存表';
39 / 210
对照表
字段类型为什么这么选
product_idBIGINT UNSIGNED + 唯一索引一商品一库存行;唯一索引既是约束又加速按商品查询
availableINT UNSIGNED无符号列在严格模式下, 减到负数会直接报错,是数据库层的兜底防线
lockedINT UNSIGNED下单锁库存、支付转扣减、超时释放,都围绕它变化
versionINT UNSIGNED备用的乐观锁版本号,为后续并发方案留余地
40 / 210
小节
2.4 inventory_log 库存流水表
41 / 210
sql
CREATE TABLE `inventory_log` (  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '流水 ID',  `product_id`  BIGINT UNSIGNED NOT NULL                COMMENT '商品 ID',  `change_qty`  INT             NOT NULL                COMMENT '变动数量,扣减为负、回补为正',  `type`        VARCHAR(16)     NOT NULL                COMMENT 'DEDUCT 扣减 / RELEASE 释放 / RESTORE 回补',  `ref_id`      BIGINT UNSIGNED NOT NULL                COMMENT '关联单号(订单 ID 等)',  `create_time` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  PRIMARY KEY (`id`),  KEY `idx_invlog_product_time` (`product_id`, `create_time`),  UNIQUE KEY `uk_invlog_ref_type` (`ref_id`, `type`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存流水表';
42 / 210
对照表
字段类型为什么这么选
change_qtyINT(有符号)一条流水同时表达方向:扣减为负、回补为正
typeVARCHAR(16)存枚举名,排障时一眼看懂这笔流水是什么动作
(ref_id, type)联合唯一索引同一单号、同一动作只能写一次,天然实现扣减幂等
43 / 210
小节
2.5 order 订单表
44 / 210

order 是 SQL 关键字,表名必须加反引号——这是本篇第一个必须记住的坑(详见第十节)。

45 / 210
sql
CREATE TABLE `order` (  `id`             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单 ID',  `order_no`       VARCHAR(32)     NOT NULL                COMMENT '对外业务订单号',  `user_id`        BIGINT UNSIGNED NOT NULL                COMMENT '下单用户 ID',  `total_amount`   DECIMAL(12,2)   NOT NULL                COMMENT '订单总额(元)',  `status`         VARCHAR(16)     NOT NULL                COMMENT 'CREATED/PAID/SHIPPED/COMPLETED/CLOSED/REFUNDED',  `idempotent_key` VARCHAR(64)     NOT NULL                COMMENT '客户端幂等键',  `receiver_name`  VARCHAR(32)     NOT NULL DEFAULT ''     COMMENT '收货人',  `receiver_phone` VARCHAR(20)     NOT NULL DEFAULT ''     COMMENT '收货电话',  `receiver_addr`  VARCHAR(255)    NOT NULL DEFAULT ''     COMMENT '收货地址',  `pay_time`       DATETIME(3)     NULL                    COMMENT '支付时间',  `ship_time`      DATETIME(3)     NULL                    COMMENT '发货时间',  `close_time`     DATETIME(3)     NULL                    COMMENT '关闭时间',  `expire_time`    DATETIME(3)     NOT NULL                COMMENT '超时关单时间 = 创建 + 30 分钟',  `deleted`        TINYINT         NOT NULL DEFAULT 0      COMMENT '逻辑删除',  `create_time`    DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  `update_time`    DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3)                                   ON UPDATE CURRENT_TIMESTAMP(3)        COMMENT '更新时间',  PRIMARY KEY (`id`),  UNIQUE KEY `uk_order_no` (`order_no`),  UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`),  KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`),  KEY `idx_order_status_expire` (`status`, `expire_time`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表';
46 / 210
对照表
字段类型为什么这么选
order_noVARCHAR(32) + 唯一索引对外展示的业务单号,避免暴露自增 ID 的规模与增长速度
statusVARCHAR(16)存枚举名,与状态机一字不差,排障直观(故不是 tinyint)
idempotent_keyVARCHAR(64)客户端幂等键;(user_id, idempotent_key) 唯一约束实现下单幂等
expire_timeDATETIME(3) + 联合索引超时关单的扫描依据,落库存储避免每次重复计算
total_amountDECIMAL(12,2)订单总额是业务事实,冗余存储避免每次 SUM 订单项
47 / 210
小节
2.6 order_item 订单项表
48 / 210
sql
CREATE TABLE `order_item` (  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单项 ID',  `order_id`      BIGINT UNSIGNED NOT NULL                COMMENT '订单 ID',  `product_id`    BIGINT UNSIGNED NOT NULL                COMMENT '商品 ID',  `product_name`  VARCHAR(128)    NOT NULL                COMMENT '下单时商品名快照',  `product_price` DECIMAL(12,2)   NOT NULL                COMMENT '下单时单价快照',  `quantity`      INT             NOT NULL                COMMENT '购买数量',  `amount`        DECIMAL(12,2)   NOT NULL                COMMENT '小计 = 单价 × 数量',  `create_time`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  PRIMARY KEY (`id`),  KEY `idx_item_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单项表';
49 / 210
对照表
字段类型为什么这么选
product_name / product_priceVARCHAR + DECIMAL(快照)历史订单不被商品改名、改价影响——第一节讲的「那个时刻的事实」
amountDECIMAL(12,2)小计 = 单价 × 数量,冗余存储避免每次查询重复计算
order_id普通索引按订单查明细是最高频的读,必须有索引
50 / 210
小节
2.7 payment 支付记录表
51 / 210
sql
CREATE TABLE `payment` (  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '支付记录 ID',  `order_id`      BIGINT UNSIGNED NOT NULL                COMMENT '订单 ID',  `trade_no`      VARCHAR(64)     NOT NULL                COMMENT '支付网关流水号,回调幂等键',  `amount`        DECIMAL(12,2)   NOT NULL                COMMENT '支付金额',  `channel`       VARCHAR(16)     NOT NULL DEFAULT 'MOCK' COMMENT '支付渠道',  `status`        VARCHAR(16)     NOT NULL                COMMENT 'INIT/SUCCESS/FAILED',  `callback_time` DATETIME(3)     NULL                    COMMENT '回调到达时间',  `create_time`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',  `update_time`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3)                                  ON UPDATE CURRENT_TIMESTAMP(3)        COMMENT '更新时间',  PRIMARY KEY (`id`),  UNIQUE KEY `uk_payment_trade_no` (`trade_no`),  KEY `idx_payment_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '支付记录表';
52 / 210
对照表
字段类型为什么这么选
trade_noVARCHAR(64) + 唯一索引网关流水号唯一,是支付回调幂等的核心防线
statusVARCHAR(16)INIT/SUCCESS/FAILED,与订单状态机配合完成「只成功一次」
callback_timeDATETIME(3)记录回调实际到达时间,便于与网关对账
53 / 210

八张表到齐了,先别急着看索引——把一次真实下单在表之间的行走路线看一遍:哪一步落哪张表、哪一步不该碰别的表。这张动图是第三节到第九节所有设计的共同底稿:

54 / 210
原理动画
动图 · 一次下单在八张表里的落点
动图 · 一次下单在八张表里的落点
55 / 210
小节
三、索引设计:每一个都要能说出理由
56 / 210

索引不是「给常用字段都加一个」。每一张二级索引都会让写入变慢(每次 insert/update 都要维护一棵 B+ 树),所以索引的设计准则是「只为真实的高频查询路径服务」。

57 / 210

先给实际索引 DDL——除了建表语句里内联的那些,订单表还要补两条联合索引:

58 / 210
sql
-- 我的订单列表:按用户 + 状态 + 时间倒序ALTER TABLE `order`  ADD KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`);-- 定时关单扫描:先按状态筛,再按过期时间取最早的若干条ALTER TABLE `order`  ADD KEY `idx_order_status_expire` (`status`, `expire_time`);
59 / 210
小节
3.1 最左前缀:为什么 (user_id, status, create_time) 能省下两条索引
60 / 210

联合索引 (user_id, status, create_time) 的排序是「先按 user_id 排,user_id 相同再按 status 排,再相同才按 create_time 排」。所以它只能被从最左列开始、连续命中的条件使用:

61 / 210
对照表
查询条件能否命中索引说明
WHERE user_id = ?命中用最左列 user_id
WHERE user_id = ? AND status = ?命中前两列连续命中
WHERE user_id = ? AND status = ? ORDER BY create_time DESC完美命中三列全用上,排序也能走索引
WHERE status = ?不命中跳过了最左列 user_id,索引对 status 不再有序
62 / 210

最后一行就是「最左前缀」的铁律:联合索引的列顺序,决定它能服务哪些查询。把选择性最高的列(user_id)放最左,能让更多查询吃到索引。

63 / 210
坑

WHERE status = ? 这种单个低基数列的查询,别指望联合索引帮忙。真需要按状态全表筛,就单独为它建 (status, ...) 开头的索引,或者接受全表扫描。

64 / 210
小节
3.2 哪些字段不该建索引
65 / 210
对照表
不该建索引的字段原因
deleted基数只有 2,选择性极差;应作为联合索引的从属列而非独立索引
status(单独)百万级订单里区分度低,单列索引几乎没用;要用作联合索引首列时另说
create_time(单独)范围查询会扫过大量行;作为联合索引末列更有效
extra_info(JSON)对 JSON 建索引膨胀大、写法受限;真需要检索就抽成独立列
频繁更新的字段每次 UPDATE 都要维护索引 → 写入放大,得不偿失
66 / 210
提示

一个经验法则是——索引服务于「读多写少 + 高选择性」的条件。反过来说,如果一个字段几乎每次写都要变,或者查询时命中的行数超过全表 20%,索引基本帮不上忙。

67 / 210
小节
四、字段选型清单
68 / 210

把上一节的逐表说明汇总成一张总表,方便对照:

69 / 210
对照表
业务字段选型备选理由(为什么不选备选)
金额DECIMAL(12,2)DOUBLE / FLOAT浮点用二进制表示小数,无法精确表达 0.1,累加会漂移
状态VARCHAR(16) 存枚举名TINYINT 存数字可读、可自解释、排障直观;代价是略多几个字节,值
时间DATETIME(3)(存 UTC)TIMESTAMP / BIGINT与业务时区解耦、无 2038 问题、毫秒精度
主键 IDBIGINT UNSIGNED 自增UUID / 雪花自增写入有序、聚簇索引友好;对外另给 order_no
扩展字段JSON 类型VARCHAR / TEXTMySQL 8 原生 JSON 有校验、有函数、语义清晰
删除标记deleted TINYINT物理删除用户、商品可逻辑删;订单是财务凭证,绝不物理删
乐观锁version INT无版本、纯行锁为「读-改-写」场景留余地,行锁与乐观锁可按需切换
70 / 210
小节
4.1 浮点误差现场:为什么金额必须用 DECIMAL
71 / 210

不用被「精度」两个字吓到,跑一行代码就懂了:

72 / 210
代码对照
代码java
System.out.println(0.1 + 0.2);                                        // 0.30000000000000004System.out.println(new java.math.BigDecimal("0.1")        .add(new java.math.BigDecimal("0.2")));                       // 0.3System.out.println(2.99 * 100);                                       // 298.99999999999994
解读
  • 第一行:double 无法精确表示 0.1 和 0.2,相加得到 0.30000000000000004
  • 第二行:用 BigDecimal 字符串构造,得到精确的 0.3
  • 第三行:连「元转分」这种最常见的换算,浮点都能算错
73 / 210

数据库侧的应对就是 DECIMAL(12,2):12 位总长度含 2 位小数,最大可存到 99,999,999,999.99,足够业务用;Java 侧对应 BigDecimal。在金融相关代码里,任何一处 double 都可能是资损的起点。

74 / 210
警告

float / double 只能用于「本来就是近似值」的场景,例如坐标、评分、比例。凡是「钱、库存、数量」,一律精确类型。

75 / 210
小节
五、API 契约设计:把接口清单落成合同
76 / 210
原理动画
动图 · 一个接口契约的诞生
动图 · 一个接口契约的诞生
77 / 210

上一篇给了 15 个接口的清单,这一篇要给每个接口补上「请求长什么样、响应长什么样、权限是什么」,让它成为前后端都能照着写代码的契约。URL 遵循 REST 规范:名词复数、层级表达归属、过滤分页走查询串、路径里不放动词。

78 / 210
对照表
方法路径权限请求要点响应要点
POST/api/v1/auth/register公开手机号 + 密码新用户 id
POST/api/v1/auth/login公开手机号 + 密码JWT + 用户信息
GET/api/v1/auth/me登录无(从 JWT 取)当前用户 DTO
GET/api/v1/products公开page / size / keyword分页商品 VO
GET/api/v1/products/{id}公开路径参数 id商品详情 VO
GET/api/v1/inventory/{productId}公开路径参数 productId可售 / 锁定数量
POST/api/v1/orders登录CreateOrderRequest + 幂等键新建订单 VO
GET/api/v1/orders登录OrderQuery(状态/时间/分页)分页订单 VO
GET/api/v1/orders/{id}登录路径参数 id订单详情 VO
POST/api/v1/orders/{id}/cancel登录路径参数 id更新后的订单 VO
POST/api/v1/payments登录orderId + 渠道支付单与跳转信息
POST/api/v1/payments/callback公开(验签)网关回调报文固定成功应答
GET/api/v1/payments/{orderId}登录路径参数 orderId支付记录 VO
GET/api/v1/notifications登录page / size分页通知 VO
GET/api/v1/admin/stats/orders管理员begin / end date按天汇总 VO
79 / 210

一份契约光有表格还不够,得让调用方「看一眼示例就会用」。以下是三个最关键接口的完整报文。

80 / 210

下单请求——注意客户端幂等键 idempotentKey 是必填:

81 / 210
json
POST /api/v1/ordersAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...Content-Type: application/json{  "receiverName": "张三",  "receiverPhone": "13800138000",  "receiverAddr": "深圳市南山区科技园 1 号楼 802",  "idempotentKey": "9f2c1e0a-3b7d-4c11-8e2a-create-order",  "items": [    { "productId": 1001, "quantity": 2 }  ]}
82 / 210

下单成功响应——data 里是 OrderVO,不含 userId、不含 deleted 等内部字段:

83 / 210
json
{  "code": 0,  "message": "ok",  "data": {    "orderNo": "202610071530001234",    "status": "CREATED",    "totalAmount": 11998.00,    "expireTime": "2026-10-07T07:30:00Z",    "items": [      { "productId": 1001, "productName": "蜂巢智能音箱",        "productPrice": 5999.00, "quantity": 2, "amount": 11998.00 }    ]  },  "traceId": "a1b2c3d4",  "timestamp": 1759827000000}
84 / 210

业务失败响应——库存不足返回错误码 1001,data 里带上诊断信息,方便前端提示与排障:

85 / 210
json
{  "code": 1001,  "message": "库存不足",  "data": { "productId": 1001, "available": 1, "requested": 2 },  "traceId": "e5f6a7b8",  "timestamp": 1759827004321}
86 / 210
内核实验
TeaVM契约落地成一等公民:请求如何被分派未启动
用 /users/42 对照理解 @PathVariable 与 DTO 请求体的解析差异
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
87 / 210

契约定死之后,请求进入 Spring 的那条「解析→校验→分派」链路就有了确定的形状:路径参数走 @PathVariable,请求体走 @RequestBody + Bean Validation,两者在 Controller 方法签名上合流。

88 / 210

下面四个实验把这条链路拆开给你看。实验一:校验失败的一站——把上面那份成功报文换成少写一个必填字段,看请求死在哪、返回长什么样:

89 / 210
内核实验
TeaVM参数校验失败的请求走完的路未启动
选「参数校验失败」这一档:注意它连 Service 都没进就折返了,再切回「成功链路」对比两次的站点数
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 210

实验二:对象在各层的换乘。第六节那张「采购单 → 备料盒 → 成品」不是比喻而已,它对应一次真实的类型转换;切到「越层调用代价」能看到不换乘的下场:

91 / 210
内核实验
TeaVMDTO / Entity / VO 的换乘现场未启动
先选「对象在各层的形态」看同一次下单里三种对象的字段差别,再切「事务边界落在哪」确认编排为什么必须收在 Service
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
92 / 210

实验三:Java 对象怎么变成你看到的那段 JSON。契约里的响应体不是自动出现的——消息转换器(HttpMessageConverter,负责把返回值按 Accept 头翻成 JSON/字符串的那个组件)决定它长什么样:

93 / 210
内核实验
TeaVM响应体是怎么被「翻译」出来的未启动
依次切「JSON 序列化」和「Accept 头协商」,看清同一个 Controller 方法为何能给出不同格式;最后用「406 从哪里来」体验没人接手翻译的场面
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
94 / 210

实验四:异常出口。第七节错误码表的全部意义就在这——业务异常必须有唯一出口,否则前端拿到的是 HTML 错误页而不是信封:

95 / 210
内核实验
TeaVM一次校验失败如何变成 code=4001未启动
选「校验失败 400」看 Bean Validation 抛出的东西被谁接住;再切「@ControllerAdvice 兜底」理解为什么未捕获异常不会裸奔给用户
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
96 / 210
要点

契约的价值在于「先定后写」。契约一旦冻结,前端可以立刻用 mock 数据开工,后端照契约实现;联调时若对不上,先看契约而不是互相甩锅——这就是动画里那六步所强调的「契约先行」。

97 / 210

把上面三段报文倒回去对照动图看第二遍,每一帧正好落在一段东西上:第 1 帧的用例就是 #43 第二节那张「提交订单」故事卡;第 2 帧产出的东西是第五节那份 CreateOrderRequest(字段、类型、约束一起定死);第 3 帧对应 OrderVO 加错误码表;第 4 帧就是本节那张接口清单;第 5、6 帧是前后端各写各的、最后拿 curl 对上。读哪一帧觉得空,就说明那一件的交付物还没写出来:

98 / 210
原理动画
动图 · 对照三段报文再走一遍六步
动图 · 对照三段报文再走一遍六步
99 / 210

契约是纸面的,工具是活的。把应用启动到内核控制台,亲手把这份契约里最要命的几条路径跑一遍:同一个幂等键的两种到达方式(重复与首达)、分页 SQL 的改写、以及消息转换器对同一份响应的翻译:

100 / 210
内核控制台
101 / 210
小节
六、DTO / VO / Entity 三分:为什么不能把 Entity 直接返回
102 / 210

很多项目图省事,Controller 直接把数据库实体 Order 返回给前端。这在 demo 里没问题,上了生产就是三颗雷:

103 / 210
对照表
直接返回 Entity 的风险具体后果
安全泄露Entity 含 password、deleted、内部标记,序列化出去即泄密
强耦合表结构一改(加字段/改字段名),前端接口跟着崩
循环引用Order ↔ OrderItem 双向引用,Jackson 序列化直接栈溢出
104 / 210

所以 BeeOrder 严格做三层隔离:Entity 贴数据库、DTO 收请求、VO 出响应。三者字段可以高度相似,但职责与生命周期完全不同。

105 / 210

把同一次下单里三种对象的「换乘」点成一条线看,就明白三隔离挡住的是什么——注意最后一格:不换乘的下场不是报错打回,而是把内部字段直接送出大门:

106 / 210
交互图解
流程一次请求里三种对象的换乘站1 / 5
→
→
→
→
客户端 JSON
请求体原文,对后端来说只是文本
全部看懂了每过一层换一次装:DTO 进不了数据库,Entity 出不了大门——每换一次装,少一条泄露的路、少一条耦合的线
107 / 210

请求侧 DTO 承载「格式与校验规则」,把非法输入挡在 Controller 门口:

108 / 210
java
package com.beeorder.order.dto;import jakarta.validation.Valid;import jakarta.validation.constraints.*;import java.util.List;public class CreateOrderRequest {    @NotBlank(message = "收货人不能为空")    @Size(max = 32)    private String receiverName;    @NotBlank(message = "收货电话不能为空")    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")    private String receiverPhone;    @NotBlank(message = "收货地址不能为空")    @Size(max = 255)    private String receiverAddr;    @NotEmpty(message = "订单项不能为空")    @Size(max = 50, message = "单个订单最多 50 个商品")    @Valid    private List<Item> items;    @NotBlank(message = "幂等键不能为空")    @Size(max = 64)    private String idempotentKey;    public static class Item {        @NotNull(message = "商品 ID 不能为空")        private Long productId;        @NotNull @Min(1) @Max(999)        private Integer quantity;        // getters / setters 省略    }    // getters / setters 省略}
109 / 210

响应侧 VO 只暴露「前端需要的、且安全的」字段,并在这里完成脱敏:

110 / 210
java
package com.beeorder.order.vo;import java.math.BigDecimal;import java.time.LocalDateTime;import java.util.List;public class OrderVO {    private String orderNo;          // 只给业务单号,不给自增 id    private String status;           // 状态机枚举名    private BigDecimal totalAmount;    private List<OrderItemVO> items;    private String receiverName;    private String receiverPhone;    // 输出时脱敏为 138****8000    private LocalDateTime createTime;    private LocalDateTime payTime;    // 注意:不含 userId(前端无需知道)、不含 deleted、不含 version}
111 / 210

Entity 转 VO 用一层薄薄的转换器,保持手写、可控、可调试:

112 / 210
java
package com.beeorder.order.converter;public final class OrderConverter {    private OrderConverter() {}    public static OrderVO toVO(Order order, List<OrderItem> items) {        OrderVO vo = new OrderVO();        vo.setOrderNo(order.getOrderNo());        vo.setStatus(order.getStatus());        vo.setTotalAmount(order.getTotalAmount());        vo.setReceiverName(order.getReceiverName());        vo.setReceiverPhone(MaskUtil.phone(order.getReceiverPhone()));  // 脱敏        vo.setCreateTime(order.getCreateTime());        vo.setPayTime(order.getPayTime());        vo.setItems(items.stream().map(OrderConverter::toItemVO).toList());        return vo;    }}
113 / 210
对照表
转换方案优点缺点适用
手写转换器零依赖、逻辑可见、断点可调试字段多时重复代码多BeeOrder:字段少、需要脱敏等定制逻辑
MapStruct编译期生成、字段多时省力引入注解处理器、复杂映射仍需自定义大项目、DTO 与 Entity 近乎一一对应时
114 / 210
说明

BeeOrder 选择手写转换器。原因很实在——我们的转换里夹着脱敏、状态翻译、字段拼装这类定制逻辑,MapStruct 遇到这些还是得写自定义方法,不如统一手写,简单直接。

115 / 210
小节
七、统一响应与错误码
116 / 210

所有接口统一包一层信封 Result<T>,让前端只判断一个 code 就能分流:

117 / 210
java
package com.beeorder.common.response;public class Result<T> {    private int code;          // 0 表示成功    private String message;    private T data;    private String traceId;    // 链路追踪 ID,排障用    private long timestamp;    public static <T> Result<T> ok(T data) {        Result<T> r = new Result<>();        r.code = 0;        r.message = "ok";        r.data = data;        r.timestamp = System.currentTimeMillis();        return r;    }    public static <T> Result<T> fail(ErrorCode ec) {        Result<T> r = new Result<>();        r.code = ec.getCode();        r.message = ec.getMessage();        r.timestamp = System.currentTimeMillis();        return r;    }    // getters / setters 省略}
118 / 210

错误码用枚举集中管理,每个码绑定 HTTP 状态,这样监控、网关、日志都能看懂:

119 / 210
java
package com.beeorder.common.response;public enum ErrorCode {    OK(0, "ok", 200),    STOCK_NOT_ENOUGH(1001, "库存不足", 409),    ORDER_NOT_FOUND(1002, "订单不存在", 404),    ORDER_STATUS_ILLEGAL(1003, "订单状态不允许该操作", 409),    DUP_PAY_CALLBACK(2001, "重复支付回调,已忽略", 200),    PAY_AMOUNT_MISMATCH(2002, "支付金额与订单不符", 400),    UNAUTHORIZED(3001, "未认证或令牌已过期", 401),    FORBIDDEN(3002, "无权访问该资源", 403),    PARAM_INVALID(4001, "参数校验失败", 400),    INTERNAL_ERROR(5000, "系统繁忙,请稍后重试", 500);    private final int code;    private final String message;    private final int httpStatus;    ErrorCode(int code, String message, int httpStatus) {        this.code = code;        this.message = message;        this.httpStatus = httpStatus;    }    // getters 省略}
120 / 210

BeeOrder 的错误码按「段」划分,一看前缀就知道归属:1xxx 业务域(订单/库存/支付)、2xxx 支付回调、3xxx 认证授权、4xxx 参数校验、5xxx 系统级。

121 / 210
对照表
错误码含义HTTP 状态触发场景
1001库存不足409下单时 available < 请求数量
1002订单不存在404查询或取消一个不存在的订单
1003订单状态非法409对已关闭订单发起支付
2001重复支付回调200同一 trade_no 二次回调(幂等,返回成功)
2002支付金额不匹配400回调金额 ≠ 订单金额
3001未认证401Token 缺失或过期
3002无权限403访问他人的订单
4001参数校验失败400Bean Validation 校验不通过
5000系统内部错误500未捕获的运行时异常
122 / 210
坑

「一切皆 200,错误码藏在 body 里」是很多团队的历史包袱。它让前端省事,却让网关的重试策略、监控的错误率统计、缓存的失效判断全部失灵。业务错误该给的 HTTP 状态就要给,信封里的 code 只是给业务层补充更细的语义。

123 / 210
小节
八、幂等设计:让重复请求无副作用(重点)
124 / 210

幂等是分布式系统里最容易出错、也最容易被忽略的一环。BeeOrder 有两个必须幂等的写入点:下单与支付回调。

125 / 210

先看清「重复到达」在系统里长什么样——同一个幂等键的第二次请求,整条路会在唯一索引上折返。这张动图的每一帧,后面 8.1 的代码里都能找到对应行:

126 / 210
原理动画
动图 · 幂等键的一生
动图 · 幂等键的一生
127 / 210
小节
8.1 下单幂等:客户端幂等键 + 唯一索引
128 / 210

用户手抖点了两次「提交订单」,或者网络超时后客户端自动重试,都会让同一个下单意图到达两次。防重最可靠的手段不是「先查有没有」,而是用数据库的唯一约束把重复挡在门外:

129 / 210
java
@Transactionalpublic OrderVO createOrder(Long userId, CreateOrderRequest req) {    try {        // 组装并插入订单;(user_id, idempotent_key) 上有唯一索引 uk_order_idem        Order order = assemble(userId, req);        orderMapper.insert(order);        return doCheckout(order, req);          // 扣库存、写订单项    } catch (DuplicateKeyException e) {        // 唯一键冲突 = 这是一次重复提交:查出首次结果直接返回        Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey());        log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo());        return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId()));    }}
130 / 210
代码对照
代码sql
-- 幂等的基石:同一用户、同一幂等键只能有一行UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`)
解读
  • idempotent_key 由客户端生成(如 UUID),一次「下单意图」对应一个键,重试时复用
  • (user_id, idempotent_key) 唯一索引保证:重复插入必然失败,DuplicateKeyException 就是「重复」的信号
  • 捕获冲突后不要报错,而是返回首次的结果——对用户而言,两次点击得到同一个订单,这就是幂等

类比:幂等键就是印在票角的那个票号。 窗口重打一张票之前先看票号,同一个号只认第一次——所以「再打一次」拿到的是同一张座位,而不是第二个座位。把这条规矩从纸面挪进数据库,靠的就是上面那两行:唯一索引替你把重复钉死。少了它,两个人同时喊「再打一次」,系统就会真给你两张:多一行订单,还多扣一次库存。

131 / 210

不过先别急着翻页:上面的代码是「事后接住」的写法,很多人第一反应会写成更直觉的「先查一遍,没有再插」。把它放进两个线程里逐行走一遍,那个毫秒宽的窗口就藏不住了:

132 / 210
单步调试台
单步台为什么「先查再插」挡不住并发的重复提交1 / 6
两个线程按真实交错顺序执行同一段代码——盯住第 7 行:两个线程都会走到那里
被调试的代码
1String key = req.getIdempotentKey();
2Order exist = orderMapper.selectByUserIdAndIdemKey(userId, key);
3if (exist != null) {
4 return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId()));
5}
6Order order = assemble(userId, req);
7orderMapper.insert(order);
8return doCheckout(order, req);
此刻的变量
—
调用栈
1OrderService.createOrder(OrderService.java:38)
2OrderController.create(OrderController.java:38)
1线程 A 先到:从请求里取出幂等键。此刻它只是一个字符串,数据库对它一无所知。
133 / 210

结论摆在这:先查再插的每一步单看都没错,错在它替数据库做了一个数据库从没答应过的承诺。8.2 的支付回调走的是同一套思路,只是防线换成了 trade_no。

134 / 210
小节
8.2 支付回调幂等:唯一约束 + 状态机双重校验
135 / 210

支付网关的回调经常重发(网关没收到你的成功应答就会重试)。防重靠两道防线:payment.trade_no 唯一约束挡住重复落库,订单状态机挡住重复流转:

136 / 210
java
@Transactionalpublic void handleCallback(PayCallbackDTO dto) {    Payment pay = paymentMapper.selectByTradeNo(dto.getTradeNo());    if (pay != null && PayStatus.SUCCESS.name().equals(pay.getStatus())) {        log.info("重复支付回调,已忽略 tradeNo={}", dto.getTradeNo());        return;                                   // 第一道:已成功,直接返回    }    // 第二道:条件更新,只有 CREATED 才能转 PAID    int rows = orderMapper.markPaid(dto.getOrderId(), OrderStatus.CREATED, OrderStatus.PAID);    if (rows == 0) {        log.warn("订单状态不允许支付,忽略 orderId={}", dto.getOrderId());        return;    }    paymentMapper.updateStatus(dto.getTradeNo(), PayStatus.SUCCESS);    inventoryService.commitLocked(dto.getOrderId());   // 锁定库存转实扣}
137 / 210

三个幂等点的字段设计汇总:

138 / 210
对照表
幂等场景幂等键约束方式
下单order.idempotent_key(客户端 UUID)唯一键 (user_id, idempotent_key)
支付回调payment.trade_no(网关流水号)唯一键 uk_payment_trade_no + 状态校验
库存扣减inventory_log 的 (ref_id, type)唯一键 uk_invlog_ref_type
139 / 210
要点

幂等的本质是「用不可变的事实去约束可变的操作」。trade_no、idempotent_key 都是外部给定的、天然唯一的事实,把它们做成唯一索引,就得到了一道不依赖应用逻辑的、并发安全的防线。

140 / 210
小节
九、分页与查询规范
141 / 210

列表类接口统一返回 PageResult<T>,绝不返回裸数组——前端需要 total 才能渲染分页器:

142 / 210
java
package com.beeorder.common.response;import java.util.List;public class PageResult<T> {    private List<T> list;    private long total;        // 总条数    private int page;          // 当前页(从 1 开始)    private int size;          // 每页条数    private int pages;         // 总页数    private boolean hasNext;   // 是否还有下一页    public static <T> PageResult<T> of(List<T> list, long total, int page, int size) {        PageResult<T> r = new PageResult<>();        r.list = list;        r.total = total;        r.page = page;        r.size = size;        r.pages = (int) ((total + size - 1) / size);        r.hasNext = (long) page * size < total;        return r;    }    // getters / setters 省略}
143 / 210

条件查询统一收进一个查询对象,避免 Controller 方法签名堆满散参数:

144 / 210
java
package com.beeorder.order.dto;import java.time.LocalDateTime;public class OrderQuery {    private String status;             // 可选:按状态筛选    private LocalDateTime beginTime;   // 可选:创建时间起点    private LocalDateTime endTime;     // 可选:创建时间终点    private Integer page = 1;    private Integer size = 20;         // 服务端上限 100    private Long userId;               // 由 JWT 注入,禁止前端传入!    public int getOffset() {        return (Math.max(page, 1) - 1) * Math.min(size, 100);    }    // getters / setters 省略}
145 / 210

分页参数约定,写进团队规范:

146 / 210
对照表
参数默认上限说明
page1—从 1 开始,小于 1 一律按 1 处理
size20100超过 100 直接截断到 100
sortcreate_time desc白名单只允许白名单字段,防止注入与低效排序
147 / 210
坑

不限制 size 上限,攻击者一个 ?size=1000000 就能拖走整张表,把数据库和内存一起打爆。分页大小必须由服务端设上限,这在任何对外接口上都不是可选项。

148 / 210
小节
十、三个必须写进团队约定的坑
149 / 210
小节
10.1 order 是 SQL 关键字,表名必须加反引号
150 / 210
sql
-- 错误:MySQL 解析器会把 order 当成 ORDER BY 的语法部分,直接报语法错误SELECT * FROM order WHERE id = 1;      -- ERROR 1064-- 正确:用反引号包起来,声明它是一个标识符SELECT * FROM `order` WHERE id = 1;
151 / 210

这条同样适用于 MyBatis:实体类上的 @TableName("order")、XML 里手写的每条 SQL,都必须带反引号。漏一个地方,就可能在某次上线后收到一条 You have an error in your SQL syntax。

152 / 210
小节
10.2 金额禁止用 double
153 / 210

第四节已经演示过浮点误差现场。团队约定:金额一律 DECIMAL + BigDecimal,元转分、分转元都要用 BigDecimal 的 movePointLeft/Right,禁止出现 double。

154 / 210
小节
10.3 时间统一 UTC 还是本地时区
155 / 210

这是团队最容易扯皮的地方,BeeOrder 的约定是数据库存 UTC、应用层用 LocalDateTime(视作 UTC)、展示层按用户时区渲染:

156 / 210
  • 数据库:MySQL 会话设置 time_zone = '+00:00',JDBC URL 加 connectionTimeZone=UTC
  • 应用层:统一用 LocalDateTime/Instant,不混用 java.util.Date
  • 展示层:前端拿到 ISO-8601 带 Z 的字符串,按浏览器本地时区渲染
157 / 210

好处是:跨时区部署时不会出现「同一订单在不同机器上时间不一样」,夏令时切换也不会错位。

158 / 210
决策
决策BeeOrder 的订单表要不要逻辑删除?
159 / 210
总结

这一篇把上一篇的「合同」落成了两样可交付物。表结构上,我们以聚合为单位建了 8 张表,讲透了为什么订单项要冗余价格快照、金额为什么必须 DECIMAL、索引为什么只用最左前缀;API 契约上,我们把 15 个接口补齐了请求/响应/权限,用 Result<T> 统一信封、用分节错误码统一失败语义、用唯一索引 + 状态机实现了下单与支付回调的幂等。至此,BeeOrder 的「地基」已经完整——下一篇我们要在这套表和契约之上,写真正跑得动的核心业务:下单、原子扣库存、支付回调、超时关单。

160 / 210
小节
十一、沙盘:分页上限这根弹簧
161 / 210

第九节定了「size 服务端上限 100」。这个数到底该定多少?定小了正常用户抱怨,定大了数据库替你加班——而且攻击者永远先看你是不是没设。下面这个沙盘把「单次抓取条数」做成三档开关,切一档同屏看响应时间、连接占用和 total 的算法代价:

162 / 210
沙盘
沙盘分页 size 上限怎么定
运行结果
GET /api/v1/orders?size=100 → 21ms
应用内存峰值:96MB
走 idx_order_user_status_time,排序不吃 filesort
COUNT(*) 命中覆盖索引:1.8ms
超过 100 直接截断并回显 size=100,前端能看出来
BeeOrder 的选择:一页最多一百行,人眼一次也看不完一百行。截断要在响应里可见,别静默改。
163 / 210
提示

判断上限的口径不是「我最多能给多少」,而是「一屏渲染得完多少 + 网络扛得住多大」。真需要批量导出,走异步任务生成文件,绝不要把它做成一个带大 size 的同步查询。

164 / 210
小节
十二、随堂自测
165 / 210

先来一道热身题,考第四节那个浮点现场:

166 / 210
随堂自测
随堂自测商品单价 0.1 元,购物车里有 3 件。有人用 `double` 累加得到 0.30000000000000004,改成 `BigDecimal` 后写成 `new BigDecimal(0.1).add(...)` 仍然带一长串小数。为什么?
先自己选一个,选中立刻告诉你对不对
167 / 210

再来一道综合题,把第二、三、八节串起来:

168 / 210
随堂自测
随堂自测压测脚本用同一个 userId 复用同一个 idempotentKey 并发打了 50 次 POST /api/v1/orders,其中两次携带不同的 items。日志里出现 49 条 DuplicateKeyException。下列哪种处理是对的?
先自己选一个,选中立刻告诉你对不对
169 / 210
小节
十三、常见报错速查
170 / 210

下表每一行的「报错原文」都可以整段复制去搜索。新手在这一篇最容易撞上的墙有三类:唯一键冲突看不懂、分页数字对不上、时间和钱的精度莫名漂移。

171 / 210
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.springframework.dao.DuplicateKeyException: Duplicate entry '10015-9f2c1e0a' for key 'order.uk_order_idem'唯一索引挡下了重复插入。绝大多数情况下这不是 Bug,而是幂等生效;少数情况是真的重复数据被塞进了不该有的列先看索引名:uk_order_idem 是幂等键(应捕获后返回首次结果),uk_payment_trade_no 是回调重发(应记日志后返回成功),uk_inventory_product 才可能是真脏数据(查商品是否被重复建档)本篇第八节 · #31 事务 internals
org.springframework.dao.DataIntegrityViolationException: ... Column 'receiver_addr' cannot be null这张网兜住的是所有违反完整性约束的写入:非空列为空、外键找不到父行、字段超长被拒。Spring 把 MySQL 的一串 errno 统一包成这一个异常,所以光看类名定位不到具体哪一条别看类名,看 Cause by: 后面那句:java.sql.SQLIntegrityConstraintViolationException: Column 'xxx' cannot be null 里的列名就是答案。若 cause 是 Data too long for column,那是长度不够而非空值本篇第二节 · #25 校验与异常
SQL Error: 1690, SQLState: 22003 — BIGINT UNSIGNED value is out of range in '(beeorder.inventory.available - ?)'available 建成了 INT UNSIGNED,无符号列一旦被减成负数就直接报错。这其实是好事:它替你在数据库层挡住了超卖的最后一步别把列改成有符号来「消掉报错」!应在 SQL 里加条件 WHERE available >= #{qty},让扣不动的时候影响行数为 0,由 Service 抛「库存不足」(错误码 1001)本篇第三节 · #45 防超卖三种方案
Caused by: java.sql.SQLException: Incorrect string value: '\xF0\x9F\x98\x80' for column 'nickname'\xF0\x9F\x98\x80 是一个 emoji 的四字节 UTF-8 编码,而 utf8 字符集最多存三字节表和连接都用 utf8mb4(本篇建表语句已统一),JDBC URL 不要再写 characterEncoding=utf8;改完记得对已有表执行 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4本篇第二节 · #22 MySQL 字符集
分页返回 total: 1000000,但翻到最后一页只有 3 条;或者反过来 total 比实际少count 与 list 两条 SQL 的条件不一致:最常见的三条是 list 带了 deleted = 0 而 count 忘了、list join 了 order_item 导致行数翻倍、count 复用了带 LIMIT 的语句强制让两者共用同一段 <where> 片段(MyBatis <sql id="orderWhere"> + <include refid>);join 一对多时用 COUNT(DISTINCT o.id);分页插件配 optimizeCountSql 时确认它没把你的 group by 吃掉本篇第九节 · #29 MyBatis 实战
用户在浏览器看到的下单时间是 08:00,运营后台同一单显示 00:00,相差正好 8 小时三层各用了各的时区:数据库会话是 UTC,某台机器 JVM 默认 Asia/Shanghai,还有一处把 LocalDateTime 直接当本地时间格式化输出守住第十节的约定:库里存 UTC、JDBC 加 connectionTimeZone=UTC、应用内部一律 Instant/LocalDateTime(视作 UTC)、只在展示层转用户时区;跨层不许出现 Date 与 SimpleDateFormat本篇第十节 · #35 日志与时区排查
夏令时切换那天,定时关单提前一小时跑,把还没到点的订单关了存储用 UTC 是对的,但算「30 分钟后」这件事不能靠裸的 now.plusMinutes(30) 与本地时钟比较;本地时间戳在 DST 切换日会重复或跳空一小时过期时间统一用 UTC 计算并与 expire_time 比较;涉及人类作息的需求(如「每天早九点」)才用 ZonedDateTime 带时区运算,并在测试里显式覆盖 DST 那一天本篇第十节 · #40 异步与定时任务
报表按天汇总的金额比明细少几分钱,逐月累积差到几元每一步都在 double 上做累加,误差单向叠加;或者 Java 侧算完总额再让数据库重新汇总,两边舍入口径不同金额一律 DECIMAL(12,2) + BigDecimal,舍入策略全项目统一成一处(例如 RoundingMode.HALF_UP 且只在最终展示时舍入);元分换算用 movePointRight/Left,不乘 100本篇第四节 · #45 金额与库存一致性
172 / 210
提示

这一篇的报错有个共同特征——大多数不会以红字异常出现(分页数字不对、时间差 8 小时、金额少几分钱都是静默的)。所以判据不能是「有没有报错」,而是「有没有对账」:上线前先拿一批真实数据做一次明细与汇总的双向核对,比任何告警都管用。

173 / 210

表里第一条 DuplicateKeyException 是新手的头号心理阴影——红字、堆栈,订单却没多。把它当成一次现场勘查逐帧来读:谁在拦、拦得对不对、你的代码该在哪一层接住它:

174 / 210
报错急救
报错急救DuplicateKeyException: Duplicate entry '10015-9f2c1e0a-3b7d-4c11-8e2a' for key 'order.uk_order_idem'
下单接口报 DuplicateKeyException,但订单只有一张

用户双击了提交按钮,第二次请求带着同一个幂等键到达。日志里出现这段栈,而订单数没变——先别慌,这是一次现场勘查。

org.springframework.dao.DuplicateKeyException:
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '10015-9f2c1e0a-3b7d-4c11-8e2a' for key 'order.uk_order_idem'
### The error may exist in com/beeorder/order/mapper/OrderMapper.java
### SQL: INSERT INTO `order` (order_no, user_id, status, idempotent_key, total_amount) VALUES (?, ?, ?, ?, ?)
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:97)
at com.beeorder.order.service.OrderService.createOrder(OrderService.java:42)
at com.beeorder.order.web.OrderController.create(OrderController.java:38)
Caused by: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '10015-9f2c1e0a-3b7d-4c11-8e2a' for key 'order.uk_order_idem'
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
175 / 210
小节
十四、动手练习
176 / 210

三档难度。第一档给全套能跑的代码,照着敲就能看见返回 JSON。

177 / 210
小节
第一档 · 照做
178 / 210

目标:把 order_item 这张表、它的实体与 Mapper、以及一个带校验的下单接口骨架真正跑起来,并亲眼看到唯一索引和 Bean Validation 各自挡下什么。

179 / 210

第一步,建表脚本 src/main/resources/schema.sql(只留本次要用的四张表,MySQL 8 直接执行):

180 / 210
sql
CREATE DATABASE IF NOT EXISTS beeorder DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;USE beeorder;CREATE TABLE `user` (  `id`       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  `phone`    VARCHAR(20)     NOT NULL,  `password` CHAR(60)        NOT NULL,  `nickname` VARCHAR(32)     NOT NULL DEFAULT '',  PRIMARY KEY (`id`),  UNIQUE KEY `uk_user_phone` (`phone`)) ENGINE = InnoDB;CREATE TABLE `product` (  `id`    BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  `name`  VARCHAR(128)    NOT NULL,  `price` DECIMAL(12,2)   NOT NULL,  PRIMARY KEY (`id`)) ENGINE = InnoDB;CREATE TABLE `order` (  `id`             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  `order_no`       VARCHAR(32)     NOT NULL,  `user_id`        BIGINT UNSIGNED NOT NULL,  `total_amount`   DECIMAL(12,2)   NOT NULL,  `status`         VARCHAR(16)     NOT NULL,  `idempotent_key` VARCHAR(64)     NOT NULL,  `receiver_name`  VARCHAR(32)     NOT NULL,  `create_time`    DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),  PRIMARY KEY (`id`),  UNIQUE KEY `uk_order_no` (`order_no`),  UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`)) ENGINE = InnoDB;CREATE TABLE `order_item` (  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  `order_id`      BIGINT UNSIGNED NOT NULL,  `product_id`    BIGINT UNSIGNED NOT NULL,  `product_name`  VARCHAR(128)    NOT NULL,  `product_price` DECIMAL(12,2)   NOT NULL,  `quantity`      INT             NOT NULL,  `amount`        DECIMAL(12,2)   NOT NULL,  PRIMARY KEY (`id`),  KEY `idx_item_order` (`order_id`)) ENGINE = InnoDB;INSERT INTO `user` (`phone`, `password`, `nickname`) VALUES ('13800138000', '$2a$10$placeholder', '小明');INSERT INTO `product` (`id`, `name`, `price`) VALUES (1001, '蜂巢智能音箱', 5999.00);
181 / 210

第二步,实体类(com.beeorder.order.domain.OrderItem)。注意 amount 用 BigDecimal,字段类型和列类型一一对应:

182 / 210
java
package com.beeorder.order.domain;import java.math.BigDecimal;public class OrderItem {    private Long id;    private Long orderId;    private Long productId;    private String productName;    private BigDecimal productPrice;    private Integer quantity;    private BigDecimal amount;    // getters / setters 省略}
183 / 210

第三步,Mapper 接口(com.beeorder.order.mapper.OrderItemMapper)。简单 SQL 用注解,@Options 让自增主键回填到对象上:

184 / 210
java
package com.beeorder.order.mapper;import com.beeorder.order.domain.OrderItem;import org.apache.ibatis.annotations.Insert;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Options;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Select;import java.util.List;@Mapperpublic interface OrderItemMapper {    @Insert("INSERT INTO order_item (order_id, product_id, product_name, product_price, quantity, amount) "          + "VALUES (#{orderId}, #{productId}, #{productName}, #{productPrice}, #{quantity}, #{amount})")    @Options(useGeneratedKeys = true, keyProperty = "id")    int insert(OrderItem item);    @Select("SELECT id, order_id AS orderId, product_id AS productId, "          + "product_name AS productName, product_price AS productPrice, quantity, amount "          + "FROM order_item WHERE order_id = #{orderId}")    List<OrderItem> selectByOrderId(@Param("orderId") Long orderId);}
185 / 210

第四步,Controller:只做「收请求 → 校验 → 交给 Service → 包装信封」,一行业务逻辑都不写(OrderService 留给 #45,这里先用占位实现跑通链路):

186 / 210
java
package com.beeorder.order.controller;import com.beeorder.common.response.Result;import com.beeorder.order.dto.CreateOrderRequest;import jakarta.validation.Valid;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api/v1/orders")public class OrderController {    private final OrderService orderService;    public OrderController(OrderService orderService) {        this.orderService = orderService;    }    @PostMapping    public Result<OrderVO> create(@Valid @RequestBody CreateOrderRequest req) {        // userId 必须来自 JWT,绝不能由前端传入        return Result.ok(orderService.createOrder(10015L, req));    }}
187 / 210

第四步半,补一个够跑通链路的最小 Service(真正的编排与扣库存留给 #45,这里只演示幂等这一段)。注意 DuplicateKeyException 是被接住的,而不是抛给用户的 500:

188 / 210
java
package com.beeorder.order.service;import com.beeorder.order.dto.CreateOrderRequest;import com.beeorder.order.mapper.OrderItemMapper;import com.beeorder.order.mapper.OrderMapper;import com.beeorder.order.vo.OrderVO;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.dao.DuplicateKeyException;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Servicepublic class OrderService {    private static final Logger log = LoggerFactory.getLogger(OrderService.class);    private final OrderMapper orderMapper;    private final OrderItemMapper orderItemMapper;    public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper) {        this.orderMapper = orderMapper;        this.orderItemMapper = orderItemMapper;    }    @Transactional    public OrderVO createOrder(Long userId, CreateOrderRequest req) {        try {            var order = assemble(userId, req);          // 生成 orderNo、算 totalAmount、置 CREATED            orderMapper.insert(order);            req.getItems().forEach(item ->                    orderItemMapper.insert(toSnapshot(order.getId(), item)));  // 名称与单价在此冻结            return OrderConverter.toVO(order, orderItemMapper.selectByOrderId(order.getId()));        } catch (DuplicateKeyException e) {            // 撞在 uk_order_idem 上 = 重复提交,查出首次结果原样返回            var exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey());            log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo());            return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId()));        }    }    // assemble / toSnapshot 留给练习第二档实现}
189 / 210

第五步,启动后连续提交两次同样的请求,观察第二次发生什么:

190 / 210
bash
curl -s -X POST http://localhost:8080/api/v1/orders \  -H "Content-Type: application/json" \  -d '{"receiverName":"张三","receiverPhone":"13800138000","receiverAddr":"深圳市南山区科技园 1 号楼 802","idempotentKey":"demo-key-001","items":[{"productId":1001,"quantity":2}]}'
191 / 210

预期响应(第一次,HTTP 200):

192 / 210
json
{ "code": 0, "message": "ok", "data": { "orderNo": "202610071530001234", "status": "CREATED", "totalAmount": 11998.00 }, "traceId": null, "timestamp": 1759827000000 }
193 / 210

第六步,做两个实验:① 原样重放同一条 curl(idempotentKey 不变)——你会看到 Service 捕获 DuplicateKeyException 后返回同一个 orderNo,这就是幂等;② 把 receiverPhone 改成 "12345" 再发一次——请求根本进不到 Service,直接被 Bean Validation 挡成 400,code 为 4001,message 是 DTO 上写的那句「手机号格式不正确」。

194 / 210

验收清单:① 说得出第二次重放为什么不产生新订单,靠的是哪两行 DDL;② 说得出第六步的实验 ② 死在哪一站、谁把它翻译成 4001;③ 故意把 amount 的类型改成 Double 并重跑,解释为什么第十节要把这条写进团队约定。

195 / 210
小节
第二档 · 变体
196 / 210

每条只改一处,观察结论完全不同:

197 / 210
  1. 给 order 表再加一条 UNIQUE KEY uk_receiver_phone (receiver_phone),然后让两个不同用户用同一个收货电话下单。你会观察到:第二个请求报 DuplicateKeyException——同一个异常类可能来自完全不同的索引。于是你不得不学会「先读索引名再判断性质」,这正是第十三节第一条的读法。
  2. 把 OrderQuery.getOffset() 里的 Math.min(size, 100) 删掉,再用 ?size=100000 请求一次。你会观察到:接口不再截断,响应时间从 21ms 变成秒级、返回体膨胀——第十一节沙盘第三档的数字就在你手上重现了。
  3. 给 CreateOrderRequest.Item.quantity 加上 @Min(1) 后,用 "quantity": 0 请求。你会观察到:400 + code=4001,而 items 数组里那个嵌套对象的错误信息也照样能被报出来,前提是外层字段上有 @Valid——去掉它,嵌套校验就静默失效,这个坑比报错本身更常见。
198 / 210

提示:做完第 2 条回头对照第五节实验二的 layer「越层调用代价」那一档,两者讲的是同一种「省一步、赔全局」。

199 / 210
小节
第三档 · 造一个
200 / 210

给自己做一个「契约守卫」小工具,以后任何人改接口都要过它这一关。

201 / 210

需求:

202 / 210
  • 扫描所有 @RestController,导出一份简版 OpenAPI(路径、方法、请求 DTO 字段与约束、响应 VO 字段)
  • 与仓库里冻结的 api-contract.json 对比,发现删除字段、改字段类型、改必填性、改 HTTP 方法这四类破坏性变更就让构建失败
  • 顺带校验两条本篇定的硬规则:金额字段必须是 BigDecimal(不许 Double/double);响应 VO 不许出现 password、deleted、version 这类内部字段
  • 输出一张报告:接口、变更类型、影响的调用方、建议的兼容做法(新增可选字段而非改名)
203 / 210

验收清单:① 手动把一个 VO 字段改名,构建应当失败并报出该接口;② 新增一个可选字段,构建通过;③ 把某个金额字段改成 Double,第二条规则应抓到它;④ 在 OrderVO 里临时加一个 userId,第三条规则应抓到它并指出「前端无需知道」。

204 / 210
小节
十五、要点自查
205 / 210
自检

不看上文,说出 BeeOrder 三处幂等点各自的幂等键与约束方式,并解释为什么「先查再插」不算幂等。

206 / 210
自检

订单项为什么要冗余商品名与单价?如果只存 product_id,商品改名之后哪些环节会出问题?

207 / 210
自检

联合索引 (user_id, status, create_time) 能服务哪三种查询、服务不了哪一种?用一句话讲清「最左前缀」。

208 / 210
自检

金额为什么必须 DECIMAL + BigDecimal?给出一个具体的浮点算错的例子,并说清舍入应该在哪一层做。

209 / 210
自检

接口契约的七个要素是哪七个?缺哪一个会让联调当天必然有人来找你?

210 / 210
口诀

领域在前表在后,会变的事实要快照,钱用 Decimal 时间用 UTC,唯一索引管幂等,信封管出口,分页必设上限。