ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeORM实战:用订单系统吃透CRUD、事务与查询优化

TypeORM实战:用订单系统吃透CRUD、事务与查询优化

做后端这些年,我养成了一个习惯:每接触一种新的技术栈或框架,不去跑官方那个 Hello World,而是拿"订单管理"当试刀石。原因很简单,订单系统几乎是所有 CRUD 场景的集大成者——它有多对一、一对多的实体关系,有金额这种容不得半点浮点误差的字段,有"创建订单→扣减库存→更新状态"这种必须保证原子性的操作链,还有列表查询、分页、多条件筛选这些每天都在写的需求。这一套组合拳打下来,一个框架的底子几斤几两基本就摸清了。

TypeORM 是我在 Node.js 生态里用得最多的 ORM,最近刚帮某项目重写了一个订单管理模块,从实体建模到上线踩坑一路走过来,发现了不少值得沉淀的东西。这篇就借着一个实际的订单管理模块,把完整的实现思路、关键代码和排错过程写清楚。适合刚接触 TypeORM、想找个完整案例练手的同学,也适合已经在用但被 N+1 查询、事务回滚、字段精度坑过的朋友。

1. 建模之前的思考:订单、订单项、产品与用户的关系边界

写 TypeORM 的第一步永远不是打开编辑器敲实体,而是先想清楚业务上有哪几张表,以及表之间的关系密度。我见过太多人一上来就照着别人的项目抄实体,结果订单表里堆了一堆冗余字段,或者把商品信息直接塞进订单项,过两个月需求一变整个库都得重构。

1.1 订单主表:金额与状态这两个字段怎么设计最稳妥

订单主表我命名为order_entity对应的OrderEntity。在字段设计里,最容易翻车的是金额和状态两个字段。

金额字段。订单总金额、实付金额这类字段,实体里一定用decimal,不要用float或double。浮点数在计算机里是二进制近似表示,0.1 + 0.2 这类经典问题在订单金额上绝对不可接受。TypeORM 里的写法是:

@Column({ type: "decimal", precision: 10, scale: 2 }) totalAmount: string;

这里有个细节很多人第一次用会懵:precision: 10表示总位数 10 位,scale: 2表示小数 2 位,也就是整数部分最多 8 位,对绝大多数电商订单来说绰绰有余。另外注意实体里这个字段的类型我写成了string,因为 TypeORM 从 MySQL 读 decimal 返回给 JavaScript 时是字符串,直接当 number 用会产生精度丢失,这一点后面专门讲。

状态字段。订单状态我建议用 varchar 存储状态码,配合代码里的枚举常量,而不是用数据库自身的 enum 类型。原因是订单状态几乎是整个系统里最活跃的字段,今天可能是"待付款/已付款/已发货/已完成",明天可能就要加"已取消/退款中/退款完成"。数据库 enum 一旦上线,新增取值就需要 DDL 变更,而 varchar + 代码枚举只改代码加个常量就行,风险完全可控。

1.2 订单项:为什么必须保存"商品快照"而不是只存商品 ID

订单项表OrderItemEntity是理解整个订单系统的关键。很多人一上手会把订单项设计成"订单 ID + 商品 ID + 数量",然后再 join 商品表拿名称和价格来展示。这个设计在开发期很爽,上线后必然出事。

原因很简单:商品名称、单价是会变的。今天商品打了八折,明天改名,后天直接下架,但已经成交的订单是交易凭证,任何时候翻出来都应该还原成交那一刻的原始信息。所以订单项除了记录productId用于追溯,还必须冗余保存一份快照字段:productName、productPrice、甚至商品缩略图 URL。TypeORM 里的写法:

@Entity("order_items") export class OrderItemEntity { @PrimaryGeneratedColumn() id: number; @Column() orderId: number; @Column() productId: number; @Column({ type: "varchar", length: 64 }) productName: string; @Column({ type: "decimal", precision: 10, scale: 2 }) productPrice: string; @Column({ type: "int" }) quantity: number; @Column({ type: "decimal", precision: 10, scale: 2 }) subTotal: string; }

下单时把商品当前的名称、当前价格写进去,之后商品表再怎么改都影响不到历史订单。快照思维是订单系统和一般 CRUD 系统最本质的区别。

1.3 关联关系与级联策略:一对多、多对一的取舍

实体之间的关联,我用的是 Data Mapper 模式下比较克制的写法。OrderEntity内部只保留customerId外键,不做 user 的强引用,需要用户信息的地方用 QueryBuilder 再去 join;OrderEntity和OrderItemEntity之间则明确建立一对多关系:

@Entity("orders") export class OrderEntity { @PrimaryGeneratedColumn() id: number; @Column({ type: "varchar", length: 32, unique: true }) orderNumber: string; @Column() customerId: number; @Column({ type: "varchar", length: 16, default: "pending" }) status: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @OneToMany(() => OrderItemEntity, (item) => item.order, { cascade: true }) items: OrderItemEntity[]; }

关于级联有三条经验。第一,订单项的 cascade 可以打开,因为订单项的生命周期完全依附于订单,订单删掉订单项必然一起删;第二,下单操作里如果涉及更新用户表(比如累计消费金额),千万不要把UserEntity也加进 cascade,否则一个 save 可能把用户整条记录覆盖掉;第三,外键约束必须开启,TypeORM 默认生成的关联会自动加外键,业务上即使觉得"反正数据不会错"也不能去掉,它是数据一致性的最后一道底线。

2. 环境搭建背后的门道:DataSource 配置与表结构管理

实体设计好之后,下一步是让 TypeORM 跑起来。很多教程直接贴一段 dataSource 配置就完事,但真实项目中,连接配置里藏着好几个决定"能不能顺利上线"的细节。

2.1 一份能用的 DataSource 配置长什么样

TypeORM 0.3.x 之后统一使用 DataSource 类管理连接,一个基础配置长这样:

import { DataSource } from "typeorm"; import { OrderEntity } from "./entities/order.entity"; import { OrderItemEntity } from "./entities/order-item.entity"; import { ProductEntity } from "./entities/product.entity"; import { UserEntity } from "./entities/user.entity"; export const AppDataSource = new DataSource({ type: "mysql", host: process.env.DB_HOST || "localhost", port: Number(process.env.DB_PORT) || 3306, username: process.env.DB_USER || "root", password: process.env.DB_PASSWORD || "", database: process.env.DB_NAME || "shop", charset: "utf8mb4", entities: [OrderEntity, OrderItemEntity, ProductEntity, UserEntity], migrations: ["./src/migrations/*.ts"], synchronize: false, logging: false, }); AppDataSource.initialize() .then(() => console.log("DataSource has been initialized")) .catch((err) => console.error("DataSource init error", err));

这里需要注意的是 entities 数组。TypeORM 允许传路径字符串,比如entities: ["src/entity/*.ts"],但生产构建后路径容易对不上;更稳的做法是显式 import 每个实体类,虽然代码略啰嗦,但永远不会出现"明明定义了实体却查不到表"这种神坑。

2.2 synchronize 与 Migrations:开发环境和生产环境的两种玩法

synchronize是最诱惑人也最危险的一个配置项。设成 true 时,TypeORM 每次启动都会比对实体定义和数据库表结构,自动帮你加列、加表,开发期简直不要太爽。但生产环境一旦开着它,一次不经意的实体改动就可能触发线上库结构的自动变更,轻则锁表,重则直接搞坏数据。我见过不止一次生产库字段类型被自动改掉导致索引失效的事故。

我的做法是:开发环境synchronize: true,数据库删了重建也不心疼;生产环境强制false,所有表结构调整走 migration。生成一条迁移记录的命令:

typeorm migration:generate ./src/migrations/AddOrderIndex -d ./src/data-source.ts typeorm migration:run -d ./src/data-source.ts

migration 文件本质上就是一个带 up/down 方法的类,TypeORM 会记录哪些已经执行过,保证同一条 DDL 不会重复跑。把迁移文件提交到代码仓库里,团队里任何一个人 pull 之后跑一下migration:run,环境就能对齐,这是多人协作里特别省心的一点。

2.3 字符集、时区、SQL 日志:三个容易被低估的配置项

配置项推荐值作用
charsetutf8mb4支持 emoji 等四字节字符,避免插入乱码报错
timezone+08:00(按业务场景显式指定)避免订单时间出现 8 小时偏移
logging开发 true / 生产 false排查 SQL 问题时能看到完整执行语句

字符集第一优先是 utf8mb4。很多项目默认 latin1 或 utf8,直到某天客户在订单备注里填了个 emoji,插入直接报错。utf8mb4 是 MySQL 里真正支持四字节 UTF-8 的字符集。

时区问题更隐蔽。MySQL 连接时区、Node 进程时区、服务器系统时区,三个东西如果不一致,订单的 createdAt 就会出现看起来"快了 8 小时"或"慢了 8 小时"的诡异现象。我的建议是数据库连接串里显式指定时区,同时所有实体字段统一用DateTime类型,存储层和展示层都不依赖系统默认时区。

还有一个配置是logging。开发环境建议打开,TypeORM 会打印每条 SQL 到终端,排查问题时能直接看到 ORM 到底执行了什么;生产环境一定要关掉或改用独立的慢查询日志,否则高并发下每条 SQL 都打日志,磁盘 IO 先扛不住。

3. 订单核心链路实现:从下单事务到列表查询

配置就绪后,开始写真正的业务。订单模块的核心链路有三块:创建订单、查询订单、状态流转。每块都有一两个必须注意的坑。

3.1 创建订单:一次事务里做完写入和扣库存

下单操作天然要求原子性:订单写入订单表、订单项写入明细表、商品库存扣减,这三件事要么全成功,要么全失败。TypeORM 数据源自带的事务回调写法,日常业务足够了:

async createOrder(customerId: number, items: CartItem[]): Promise<OrderEntity> { return await AppDataSource.transaction(async (manager) => { // 1. 创建订单主表记录 const order = await manager.save(OrderEntity, { orderNumber: await this.generateOrderNumber(), customerId, status: "pending", }); // 2. 批量创建订单项 const orderItems = items.map((item) => ({ orderId: order.id, productId: item.productId, productName: item.productName, productPrice: item.productPrice, quantity: item.quantity, subTotal: this.calcSubTotal(item.productPrice, item.quantity), })); await manager.save(OrderItemEntity, orderItems); // 3. 扣减库存,使用条件 UPDATE 保证原子性 const result = await manager .createQueryBuilder() .update(ProductEntity) .set({ stock: () => "stock - :qty" }) .where("id = :id AND stock >= :qty", { id: item.productId, qty: item.quantity }) .execute(); if (result.affected === 0) { throw new Error(`商品 ${item.productName} 库存不足`); } return order; }); }

几个关键点。事务回调里所有操作都要用回调传进来的 manager,千万不要在回调里又用全局的 repository 或AppDataSource.getRepository(),那样操作会脱离当前事务,一旦后面出错就回滚不了。扣库存的写法是"条件更新 + 检查 affected",用 UPDATE 语句里的stock >= :qty条件挡住超卖,用受影响行数判断有没有扣成功,这是高并发下最简单也最可靠的防超卖方案。相比"先查库存再判断"的两步走,这种单条 UPDATE 的操作不存在中间窗口期。

3.2 查询订单:多条件组合筛选与分页的正确姿势

订单列表是后台管理里最常见的需求:按订单号、客户手机号、订单状态、下单时间区间任意组合筛选,同时要分页返回总条数。TypeORM 的 QueryBuilder 是处理这种场景的主力:

async findOrders(params: OrderQueryParams) { const qb = AppDataSource.getRepository(OrderEntity) .createQueryBuilder("order") .leftJoinAndSelect("order.items", "item") .where("1 = 1"); if (params.orderNumber) { qb.andWhere("order.orderNumber LIKE :orderNumber", { orderNumber: `%${params.orderNumber}%`, }); } if (params.status) { qb.andWhere("order.status = :status", { status: params.status }); } if (params.startTime) { qb.andWhere("order.createdAt >= :startTime", { startTime: params.startTime }); } if (params.endTime) { qb.andWhere("order.createdAt <= :endTime", { endTime: params.endTime }); } const [list, total] = await qb .orderBy("order.createdAt", "DESC") .skip((params.page - 1) * params.pageSize) .take(params.pageSize) .getManyAndCount(); return { list, total, page: params.page, pageSize: params.pageSize }; }

getManyAndCount会同时执行一条分页查询和一条 COUNT 查询,避免分页数据与总数对不上。这里默认where("1 = 1")是个占位写法,让后面的 andWhere 可以无条件拼接,虽然看着有点糙,但比动态组装 where 字符串安全得多,QueryBuilder 的参数绑定已经帮我们处理了转义,不会出现 SQL 注入风险。

3.3 状态流转:把订单状态机收进 Service 层

订单状态最忌讳的做法是让 Controller 层直接对 status 字段赋值。那样业务逻辑散落在接口里,今天这个接口可以把 pending 改成 paid,明天那个接口又允许 completed 改回 pending,数据一致性毫无保障。我的做法是把每个状态流转动作单独写成一个 Service 方法,里面做完整校验:

async payOrder(orderId: number, paymentNo: string) { return await AppDataSource.transaction(async (manager) => { const order = await manager.findOneBy(OrderEntity, { id: orderId }); if (!order || order.status !== "pending") { throw new Error("当前订单状态不允许支付"); } order.status = "paid"; order.paymentNo = paymentNo; order.paidAt = new Date(); return await manager.save(OrderEntity, order); }); } async cancelOrder(orderId: number, reason: string) { return await AppDataSource.transaction(async (manager) => { const order = await manager.findOneBy(OrderEntity, { id: orderId }); if (!order || !["pending", "paid"].includes(order.status)) { throw new Error("当前订单状态不允许取消"); } order.status = "cancelled"; order.cancelReason = reason; return await manager.save(OrderEntity, order); }); }

每个状态流转都是独立的 Service 方法,外界只传业务参数(orderId、paymentNo、reason),不传魔改的 status。这样状态机的语义是"动作"而不是"赋值",订单任何来源的状态变更都有唯一的代码路径,排查问题的时候顺着调用链找就行。

正常情况下订单的状态流转是单向的,我把常用的流转关系整理成表格,写进代码注释里:

动作前置状态后置状态附带操作
创建订单-pending写入订单、明细、扣库存
支付pendingpaid记录支付流水、支付时间
取消pending / paidcancelled释放库存、记录原因
完成paidcompleted记录完成时间

4. 实测中的三个重坑:N+1、循环引用、金额精度

如果说建模和 API 属于"会了就能写"的部分,那么下面这几个坑属于"上线后迟早会遇到"的部分。我把它们单独拎出来讲,是因为每一个我都真实踩过,而且网上很少能找到一次性说全的答案。

4.1 N+1 查询:为什么订单列表越拉越慢

现象:订单列表接口一开始挺快,订单量大了之后响应时间从 200ms 涨到 2s。打开 SQL 日志一看,除了第一条查订单的 SQL,后面跟着上百条重复的单条查询。

原因:在OrderEntity里声明了items: OrderItemEntity[],但如果查询订单主表时没有指定 relations,TypeORM 默认不会把 items 查出来。真正的坑在于使用方——比如模板或者某个 service 里写order.items时,TypeORM 会在这一行触发一次"懒加载"查询。假设一页 20 条订单,每条订单 5 个订单项,页面上取一次 items 就是 20 次附加查询,这就是 N+1。

解法很简单:查询时就预加载关联,用前面代码里的leftJoinAndSelect("order.items", "item"),让 TypeORM 一次 join 把所有订单项带出来。优化前后 SQL 的数量从"1 + N"变成"1",在订单量大的时候性能差距是数量级的。我现在判断一个 ORM 用法是否合格,会先看它的列表接口有没有 N+1。

4.2 循环引用:给实体做序列化时直接栈溢出

现象:写了个导出订单接口,想把订单和客户信息一起返回前端,直接res.json(order),结果报Converting circular structure to JSON,或者接口返回了一个互相嵌套的 JSON 巨兽。

原因:实体之间如果建立了双向关系,比如OrderEntity里配了customer,UserEntity里又配了@OneToMany(() => OrderEntity) orders,那order.user.orders又会把 order 带回来,对象图是个环,JSON.stringify 直接爆栈。

解法:不要直接返回实体。接口层定义一个 OrderDTO,只挑要暴露的字段;如果图省事想少写几个 DTO,也可以用 class-transformer 的@Exclude把不需要的字段排除掉。但我的实际经验是:订单这种业务,老老实实写 DTO 映射最可控,因为你能清楚地看到接口到底返回了什么。映射代码虽然啰嗦,但它让接口边界变得非常清晰,也方便以后调整字段结构。

4.3 金额精度:decimal 字段到了 JavaScript 里变成什么

现象:订单金额算出来 19.99,存进数据库是 19.99,读出来却变成 19.989999999,前端展示金额一堆小数。

原因:前面提到过,MySQL 的 decimal 类型在 TypeORM 里映射时默认返回字符串。如果实体字段类型声明成 number,TypeORM 在解析时就会做一次隐式转换,而 JavaScript 的 Number 对高精度小数是有损的,浮点数转二进制再转回来就不是原来的值。再加上 subTotal 计算、总金额累加时如果直接用 number 相加,误差会被进一步放大。

解法分两路:第一,实体定义时金额字段就写成 string,数据库返回什么就存什么,展示时交给前端格式化;第二,任何金额的加减乘除运算,不碰浮点数,用 decimal.js 这类库来算。我见过太多生产事故的根源就是有人在代码里写0.1 + 0.2,订单金额领域绝对不值得冒这个险。

5. 维护进阶:索引、软删除与自动化填充

订单系统上线只是开始,后面的维护才是体现实力的地方。下面这几个点是我在做后续迭代时逐步加上的,每一个都让系统往"不用半夜被叫起来修数据"的方向前进了一步。

5.1 索引设计:为订单高频查询场景建好复合索引

刚开始我天真地以为主键索引加唯一索引就够了,订单量上来之后,后台筛选接口慢得让人挠头。查看慢查询日志,发现拖垮响应的大多集中在两类查询:按状态 + 时间区间筛选,按客户号 + 下单时间倒序。于是补了两个复合索引:

@Index("IDX_ORDER_STATUS_CREATEDAT", ["status", "createdAt"]) @Index("IDX_ORDER_CUSTOMER_CREATEDAT", ["customerId", "createdAt"])

选择复合索引字段的顺序有讲究:最常等值查询的字段放前面,范围查询的时间字段放后面。status 和 customerId 是等值条件,createdAt 是排序和范围条件,这个顺序能让 B+ 树索引在过滤完前导字段之后直接利用有序性完成排序,避免额外的文件排序开销。索引不是越多越好,但订单表上加这两个复合索引的收益是立竿见影的。

5.2 软删除与快照:订单数据的"后悔药"

订单几乎是所有业务数据里最不能被物理删除的。客户取消订单、管理员误删、退款要求追溯……如果订单行直接在表里消失了,对账和审计都会陷入困境。TypeORM 提供了现成的软删除支持:

@DeleteDateColumn() deletedAt: Date | null;

实体加了这个字段之后,调用repository.softDelete(id)或 QueryBuilder 的.softDelete()时,TypeORM 不会真的删除记录,而是把 deletedAt 填上时间戳,并且所有默认查询会自动带上deletedAt IS NULL的条件。

不过要注意:订单项在订单取消或退款时需要释放关系和库存,我一般不会用软删除来掩盖业务上的"数据不对",软删除只作为防误删的兜底。订单数据还有一层类似快照的保障:订单项里的商品名称、价格、规格参数快照,加上订单表里的实付金额、支付流水号,共同构成一条完整的交易证据链。设计表的时候多留几个冗余字段,长期维护会感谢自己。

5.3 实体监听器:把更新时间、订单号生成这类脏活收口

TypeORM 的实体装饰器提供了一组监听器钩子:@BeforeInsert、@BeforeUpdate、@AfterLoad等等。订单系统里最典型的应用是订单号生成和更新时间的自动填充。

订单号我会安排在@BeforeInsert里生成,策略是日期 + 随机数 + 自增序列。随机数和自增序列组合是为了降低并发冲突概率,但千万不能只依赖随机数——订单量上来之后碰撞率会出乎意料地高。更稳妥的做法是用独立的订单序列表或直接靠数据库的 AUTO_INCREMENT 生成自增 ID 后再拼接成订单号,确保唯一性,同时给 orderNumber 建唯一索引做兜底校验。

更新时间其实@UpdateDateColumn已经能自动维护,但有些业务字段(比如最后一次状态变更原因)需要额外设置。监听器是统一收口的好地方,把每次改动都在同一套逻辑里处理,比在 Service 层到处手写new Date()健壮得多。

用 TypeORM 把订单管理模块重写一遍之后,我最大的感受是,ORM 真正的价值不在替你省写 SQL,而在于它逼你先想清楚模型边界和关系。订单项快照、事务里扣库存、状态机收口到 Service 层,这些框架之外的经验,才是这个实例真正值钱的部分。如果你也正准备用 TypeORM 写自己的第一个模块,建议从订单这种"关系密集 + 事务敏感"的业务入手,跑完一遍之后,再去看其他 CRUD 接口会觉得从容很多。过程中遇到奇怪的行为,记得先开 SQL 日志确认 ORM 到底执行了哪条语句,大多数谜团在 SQL 面前都会现出原形。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进