Files
conti-backend/docs/03-persistence.md
T
2026-08-17 15:31:27 +08:00

357 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03. 持久层方案
## 决策
**MySQL 8.4 LTS**(生产用 Azure Database for MySQL Flexible Server,见 [09-build-deploy.md](./09-build-deploy.md)+ Spring Data JPA / Hibernate + Flyway 做 schema 迁移。
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
- Kotlin + Spring Boot 生态里 JPA 是最主流、文档和踩坑资料最多的组合,团队上手成本低。
- 大部分 domain 模块(`identity-store``webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
MySQL 一侧需要注意的是:**MySQL 里 schema 和 database 是同一个东西**`CREATE SCHEMA` 就是 `CREATE DATABASE` 的别名)。下文说"每个 domain 一个 schema"时,物理上就是"同一个 MySQL 实例里的一个 database"。这跟 PostgreSQL 的 "一个 database 里多个 schema" 不是一回事,很多网上的多 schema 方案不能直接照搬,包括权限模型(见后面的跨 domain 规则)。
## 结构约定
```
platform-persistence/
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
VersionedEntity # BaseEntity + @Version 乐观锁,有并发更新的表继承它
PageResult<T> # 统一分页返回封装
JpaAuditingConfig # 开启 Spring Data JPA Auditing
DomainFlywayConfig # 按 domain database 分别建 Flyway 实例
domains/xxx/
src/main/kotlin/.../xxx/infrastructure/persistence/
XxxEntity # JPA entity
XxxJpaRepository # : JpaRepository<XxxEntity, Long>
src/main/resources/db/migration/xxx/
V1__init.sql # Flyway migration,目录名 = database 名(下划线形式)
```
迁移目录名统一用**下划线形式、与 database 名一致**`identity_store``webview_ticket`),不用模块名的中划线形式(`identity-store`)——因为 `DomainFlywayConfig` 里是拿 database 名直接拼 `classpath:db/migration/$schema`,两边不一致会静默地一条迁移都不执行。
## 全局 JPA / 数据源配置
```yaml
spring:
datasource:
url: >-
jdbc:mysql://${DB_HOST}:3306/?sslMode=REQUIRED
&connectionTimeZone=UTC&preserveInstants=true
&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD} # 来自 K8s Secret,见 07-config-governance.md
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 拿不到连接就快速失败,不要让请求线程堆在这里
max-lifetime: 570000 # 略小于 MySQL 的 wait_timeout,避免用到已被服务端关闭的连接
transaction-isolation: TRANSACTION_READ_COMMITTED
jpa:
open-in-view: false # 必须显式关掉,Boot 默认是 true
hibernate:
ddl-auto: validate # 表结构只由 Flyway 改,Hibernate 只做校验
properties:
hibernate:
jdbc:
time_zone: UTC
batch_size: 50
order_inserts: true
order_updates: true
query:
fail_on_pagination_over_collection_fetch: true
flyway:
enabled: false # 关掉 Boot 的单实例自动配置,改由 DomainFlywayConfig 接管
```
几条不显眼但会实际出事的配置:
- **`open-in-view: false`**Boot 默认 `true`,意思是数据库连接会一直持有到视图渲染完(对我们来说是到 JSON 序列化完)。后果是连接被无谓占用、懒加载在 Controller 层还能"碰巧成功"从而掩盖 N+1 问题。关掉之后,`application` 层事务外访问懒加载字段会直接抛 `LazyInitializationException`——这是好事,问题会在开发期暴露而不是压测时暴露。
- **`ddl-auto: validate`**:绝不能是 `update``update` 会在应用启动时按 Entity 反推 DDL 去改生产库,且它的改法不可预测、无法评审、无法回滚。表结构的唯一事实来源是 Flyway 脚本。
- **`transaction-isolation: TRANSACTION_READ_COMMITTED`**MySQL 默认是 REPEATABLE READ,我们显式降到 READ COMMITTED。理由:RR 下的一致性读快照在整个事务期间不变,长一点的事务会读到过时数据;RR 还会用更多的 gap lock,并发插入时更容易死锁。绝大多数 Web 业务不需要 RR 的可重复读语义,需要防并发覆盖的地方我们用乐观锁(下一节)显式处理,比依赖隔离级别更清楚。
- **`rewriteBatchedStatements=true`**MySQL 驱动默认**不会**把 JDBC batch 真的合成一条多值 `INSERT`,只配 `hibernate.jdbc.batch_size` 是没用的,必须在 JDBC URL 上开这个开关。
### 时区:全链路 UTC
数据库里只存 UTC,时区转换只在客户端做。三处配置必须一起生效,缺一处就会出现"写进去和读出来差几个小时":
1. 时间列一律用 `datetime(6)`Kotlin 侧一律用 `Instant`(不用 `LocalDateTime`,它不带时区信息,语义上表达不了"某个时刻")。
2. `spring.jpa.properties.hibernate.jdbc.time_zone=UTC`——Hibernate 写库时按 UTC 转换。
3. JDBC URL 上 `connectionTimeZone=UTC&preserveInstants=true`——驱动层按 UTC 解释。
不用 MySQL 的 `timestamp` 类型:它会按会话时区自动转换(结果依赖服务器/连接的时区设置,是上面这类 bug 的常见来源),而且有 2038 年上限。API 层的时间格式约定见 [06-api-design.md](./06-api-design.md)。
## `BaseEntity` / `VersionedEntity` 示例
```kotlin
// platform-persistence/src/main/kotlin/.../BaseEntity.kt
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
var createdAt: Instant = Instant.EPOCH
@LastModifiedDate
@Column(nullable = false)
var updatedAt: Instant = Instant.EPOCH
@CreatedBy
@Column(updatable = false, length = 64)
var createdBy: String? = null
@LastModifiedBy
@Column(length = 64)
var updatedBy: String? = null
}
// platform-persistence/src/main/kotlin/.../VersionedEntity.kt
@MappedSuperclass
abstract class VersionedEntity : BaseEntity() {
@Version
@Column(nullable = false)
var version: Long = 0
}
// platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
class JpaAuditingConfig(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) {
@Bean
fun auditorAware(): AuditorAware<String> = AuditorAware {
// StoreContextHolder 是 @RequestScope bean,定时任务/启动流程里没有请求上下文,
// 这里必须容忍拿不到的情况,否则后台任务写库会直接抛 BeanCreationException。
runCatching { storeContextHolder.`object`.userId?.toString() }
.getOrNull()
.let { Optional.ofNullable(it) }
}
}
```
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
**继承 `BaseEntity` 的表,建表脚本必须带全 `created_at` / `updated_at` / `created_by` / `updated_by` 四列**,且前两列 `not null`——漏一列,第一次插入就会直接失败。这是最容易在新表上重复踩的坑,建表脚本 review 时优先看这一条。
### 乐观锁(`@Version`
只要一条记录可能被两个请求同时改(门店信息编辑、票据状态流转、库存类数据),Entity 就继承 `VersionedEntity`,表上加一列 `version bigint not null default 0`。Hibernate 在 `update` 时自动带上 `where version = ?``version + 1`,更新影响行数为 0 时抛 `ObjectOptimisticLockingFailureException`
`application` 层要显式处理这个异常,转成业务错误码返回给 APP("数据已被他人修改,请刷新后重试"),而不是让它落到 `GlobalExceptionHandler` 变成 500。并发冲突的重试策略见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## Entity + Repository + Migration 示例(`identity-store` 里的门店表)
```kotlin
// infrastructure/persistence/StoreEntity.kt
@Entity
@Table(name = "store", schema = "identity_store")
class StoreEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false, length = 128)
var name: String,
@Column(name = "code", nullable = false, length = 32)
var code: String,
@Enumerated(EnumType.STRING) // 存字符串,不存序号,见下面的说明
@Column(nullable = false, length = 16)
var status: StoreStatus,
) : VersionedEntity()
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
fun findByCode(code: String): StoreEntity?
fun findByStatus(status: StoreStatus): List<StoreEntity>
}
```
```sql
-- src/main/resources/db/migration/identity_store/V1__init.sql
-- 注意:不要在迁移脚本里写 create database / usedatabase 由 Flyway 实例的
-- defaultSchema 指定(见 DomainFlywayConfig),脚本里一律用不带库名的表名。
create table store (
id bigint not null auto_increment,
name varchar(128) not null,
code varchar(32) not null,
status varchar(16) not null,
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_store_code (code),
key idx_store_status (status)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
约定说明:
- **字符集固定 `utf8mb4` + `utf8mb4_0900_ai_ci`**。MySQL 的 `utf8` 是三字节的历史遗留别名,存不了 emoji 和部分生僻汉字,一律不用。
- **索引命名**:唯一索引 `uk_<表名>_<列名>`,普通索引 `idx_<表名>_<列名>`,多列用下划线连接(`idx_store_status_created_at`)。唯一约束写成 `unique key uk_xxx (...)` 而不是列上的 `unique`,是为了让它有个能在日志和慢查询里认出来的名字。
- **InnoDB 单个索引键最长 3072 字节**,`utf8mb4` 下一个字符最多 4 字节,所以 `varchar(768)` 是能整列建索引的上限。要给长文本建索引时用前缀索引(`key idx_x_url (url(255))`)。
- **枚举用 `@Enumerated(EnumType.STRING)`**,绝不用默认的 `ORDINAL`——`ORDINAL` 存的是枚举常量的下标,以后在枚举中间插入一个值,历史数据的含义会整体错位,且这种错位没有任何报错。
- **金额用 `decimal(18, 4)`**Kotlin 侧 `BigDecimal`,永远不用 `double`/`float`
- **布尔用 `tinyint(1)`**Hibernate 对 Kotlin `Boolean` 的默认映射)。
- **外键**:同一个 database 内部可以用物理外键;**跨 database 一律只做逻辑关联**(存 ID,不建 `foreign key` 约束),否则模块边界在数据库层就被焊死了,将来任何一个域想单独拆库都要先拆约束。
- **软删除**:不做全局的 `@SQLDelete` + `@Where` 软删除(它会污染所有查询、和唯一索引冲突、还容易被忘记)。确实需要保留历史的表,显式加 `status``deleted_at` 列并在每个查询里显式过滤。
## Flyway:每个 domain database 一个独立实例
Boot 自动配置的 Flyway 只有一个实例、一张 `flyway_schema_history`。如果各 domain 目录各自从 `V1__init.sql` 开始编号,这个单实例扫到两个 `V1` 会直接报 `Found more than one migration with version 1`,启动失败。所以关掉自动配置,按 database 各建一个 Flyway 实例,各自维护自己那张历史表、各自的版本序列:
```kotlin
// platform-persistence/.../DomainFlywayConfig.kt
@Configuration
class DomainFlywayConfig {
companion object {
// 新增一个 domain 时,这里加一行 —— 见 01-project-structure.md 的脚手架说明
val DOMAIN_SCHEMAS = listOf(
"identity_store",
"workbench",
"webview_ticket",
)
}
@Bean
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
DOMAIN_SCHEMAS.forEach { schema ->
Flyway.configure()
.dataSource(dataSource)
.schemas(schema) // MySQL 下即 database;不存在时会自动创建
.defaultSchema(schema) // 历史表和脚本里的裸表名都落在这个 database
.table("flyway_schema_history")
.locations("classpath:db/migration/$schema")
.load()
.migrate()
}
return DomainFlywayMigrations
}
// 必须让 EntityManagerFactory 等迁移跑完再初始化,
// 否则 ddl-auto=validate 会在建表之前校验,启动直接失败。
// Boot 自带的这个依赖关系只认名为 flyway/flywayInitializer 的 bean,自定义实例要自己挂。
@Bean
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
}
object DomainFlywayMigrations // 只是一个用来表达依赖顺序的标记 bean
```
规则:
- 各 domain 目录内版本号独立递增,`identity_store/V2__xxx.sql``workbench/V2__yyy.sql` 互不冲突。
- **迁移脚本一旦合入主干就不可修改**(Flyway 会校验 checksum,改了会导致其他环境启动失败)。写错了就再加一个 `V(n+1)` 修正。
- 迁移脚本必须**前向兼容**:滚动更新期间新旧两个版本的应用会同时连着同一个库,所以不能有"旧代码见到就会崩"的改动。加列不删列、分两个版本走 expand-contract,规则和发布流程的配合见 [09-build-deploy.md](./09-build-deploy.md)。
- **运行账号和迁移账号分开**:Flyway 用的账号需要 DDL 权限,应用运行时只需要 DML。生产上把迁移放在部署流程里用单独的账号执行(见 09),运行时账号不给 `CREATE`/`DROP`/`ALTER`——这样即使应用被注入了 DDL,也执行不了。
## 跨 domain 数据访问规则
**每个 domain 一个独立的 database**`identity_store` / `workbench` / `webview_ticket` 各自建库,不允许跨 domain 直接 join 表。需要别的域的数据时,走对方 `-contract` 模块暴露的接口或领域事件(见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),不查对方的表。
```kotlin
// 正确做法:workbench 通过 identity-store 的契约模块获取门店信息
@Service
class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // 来自 domains/identity-store-contract
private val tileRepository: WorkbenchTileRepository,
) {
fun listTiles(userId: Long): List<TileResponse> {
val stores = storeQueryService.listStoresByUserId(userId) // 走契约接口,不查表
val tiles = tileRepository.findByUserId(userId)
return buildTiles(tiles, stores)
}
}
```
### 这条规则的强制力:哪些是硬约束、哪些不是
这里要说清楚,避免高估它的保护力度:
1. **JPQL 层面是硬约束**。JPQL 引用的是 Entity 类,`workbench` 想写 `join StoreEntity s` 就得 `import ...identitystore...StoreEntity`——而 [01-project-structure.md](./01-project-structure.md) 的 Gradle 依赖规则让 `workbench` 模块根本编译不到这个类。这一条编译期就挡死了,不需要靠自觉。
2. **原生 SQL 是软约束**`@Query(nativeQuery = true)` 里的表名是字符串,写 `join identity_store.store s` 完全能编译通过。而且——这一点必须讲清楚——**MySQL 里跨 database join 是完全合法的**,只要连接用的账号对两个库都有权限就能执行成功。我们是模块化单体,整个进程共用一个 `DataSource` 和一个数据库账号,这个账号必然对所有 domain 的库都有权限。所以在 MySQL 下,"跨库 join 会报错"这个说法**不成立**,别指望数据库替我们拦住它。
> 顺带说明:网上很多"多 schema 隔离"的方案是按 PostgreSQL 写的。PG 里 schema 是 database 内部的命名空间,可以按 schema 单独 `REVOKE` 权限从而做成硬约束;MySQL 里 schema 就是 database,我们这种单账号单数据源的结构做不到同样的效果。
兜底手段只能是流程性的:**原生 SQL 一律在 code review 里重点看**,并且加一条测试扫描所有 `@Query(nativeQuery = true)` 的字符串里有没有出现其他 domain 的 database 名(写法见 [10-testing.md](./10-testing.md))。这道防线不完美,但配合第 1 条已经覆盖了绝大多数实际会发生的情况——正常人不会为了 join 一张表专门去写原生 SQL 绕过编译错误。
3. **如果将来需要把它升级成硬约束**:做法是每个 domain 一个 `DataSource` + 一个只 GRANT 本库的数据库账号,那时跨库 join 会因为权限不足而真的失败。代价是多套 `EntityManagerFactory`/`TransactionManager`、多个连接池(连接数要重新算),并且跨 domain 的本地事务彻底不可能——最后一条其实是好事,但整体复杂度明显上升。现阶段不做,等到真的出现跨域乱查的实际问题、或者某个域准备独立拆库时再上。
代价那一面也要认:确实需要跨 domain 做一次性数据修复或报表查询时,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
## 查询规范
### N+1 与抓取策略
- **所有 `@ManyToOne` / `@OneToOne` 显式写 `fetch = FetchType.LAZY`**。JPA 规范里这两种关联默认是 `EAGER`,意味着查一个 Entity 会顺带把关联对象也查出来,列表查询时就是典型的 N+1。
- 确实需要一次带出关联数据时,用 `@EntityGraph` 或 JPQL 的 `join fetch` 显式声明,不要靠懒加载在循环里触发。
- **分页 + `join fetch` 集合属性会导致 Hibernate 把全表拉进内存再分页**。上面配置里的 `fail_on_pagination_over_collection_fetch: true` 让这种写法直接抛异常而不是悄悄变慢。
- 开发环境开 `spring.jpa.properties.hibernate.generate_statistics=true` 或用 [datasource-proxy](https://github.com/jdbc-observations/datasource-proxy) 观察每个请求实际发了多少条 SQL;关键列表接口在测试里断言 SQL 条数,比事后压测发现要早得多。
### 分页
统一用 Spring Data 的 `Pageable` + 我们自己的 `PageResult<T>` 返回(字段名和 APP 侧约定见 [06-api-design.md](./06-api-design.md)),不直接把 Spring 的 `Page` 序列化给前端——`Page` 的 JSON 结构由 Spring 版本决定,升级 Boot 时会变,属于把框架内部结构写进 API 契约。
```kotlin
data class PageResult<T>(
val list: List<T>,
val pageNum: Int,
val pageSize: Int,
val total: Long,
val hasMore: Boolean,
)
```
翻很深的页(`offset` 很大)在 MySQL 上会越来越慢,因为它必须先扫过前面所有行再丢弃。App 上的列表基本都是"下拉加载更多",这种场景优先用**游标分页**(按 `id``created_at``lastId``where id < :lastId order by id desc limit :size`),不用 `offset`
### 批量写
`hibernate.jdbc.batch_size` + JDBC URL 上的 `rewriteBatchedStatements=true` 两个都配上,批量 update/delete 才会真的合并。
但**批量 insert 有个 MySQL 特有的坑**`@GeneratedValue(strategy = IDENTITY)` 下 Hibernate 必须逐条插入才能拿回自增主键,JDBC batch 会被直接禁用,配了也没用。所以:常规写入保持 `IDENTITY` 不变(简单、够用);真正的大批量导入场景(几千行以上)绕开 JPA,直接用 `JdbcTemplate.batchUpdate` 或一条多值 `INSERT`。不要为了让 JPA 能批量插入就把主键策略换成 `TABLE` 生成器——那会引入一张全局竞争的序列表,得不偿失。
## 连接池容量怎么算
`maximum-pool-size` 不是越大越好,要和数据库实例的连接上限对齐:
```
所有 Pod 的连接总数 = maximum-pool-size × Pod 副本数(含滚动更新期间的临时多余副本)
必须 ≤ MySQL 实例的 max_connections 预留(运维/迁移/监控账号,留 20 左右)
```
Azure Database for MySQL Flexible Server 的 `max_connections` 由 SKU 规格决定(跟内存挂钩),扩副本前要先确认这个数字。按当前 3 副本、滚动更新时最多 4 副本估算,`maximum-pool-size: 15` 对应峰值 60 个连接。
池子大小本身的经验值是"略大于并发执行 SQL 的线程数",不是"等于 Tomcat 线程数"——大部分请求线程在等下游 HTTP(见 [05-integration-layer.md](./05-integration-layer.md))而不是等数据库。池子配得过大反而会让数据库承受更多并发、整体延迟变差。
## 事务
- `@Transactional` 只加在 `application` 层(见 [02-layering.md](./02-layering.md)),不加在 Controller 或 repository 上。
- **事务里不要调外部 HTTP**。一次 F6 调用可能耗时几秒,事务开着就意味着数据库连接和行锁被占几秒。把外部调用挪到事务外,或者用 `@TransactionalEventListener(AFTER_COMMIT)`
- 只读查询加 `@Transactional(readOnly = true)`:Hibernate 会跳过脏检查,省掉一次快照比对。
- 详细的事务边界、传播行为、幂等与并发冲突处理见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## 待补充
- 复杂查询是否引入 QueryDSL/jOOQ`Specification` 不够用时再决定)。
- 各 domain 的实际表结构,等开发到对应模块时再补。
## 参考链接
- [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/)
- [Flyway 官方文档](https://documentation.red-gate.com/fd)
- [Spring Data JPA Auditing](https://docs.spring.io/spring-data/jpa/reference/auditing.html)
- [MySQL 8.4 参考手册:字符集与排序规则](https://dev.mysql.com/doc/refman/8.4/en/charset.html)
- [MySQL Connector/J:时区处理](https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html)
- [HikariCP: About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)