feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes. - Added guidelines for parallel aggregation using a dedicated thread pool and context propagation. - Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment. - Included examples and best practices for implementing these concepts in the application.
This commit is contained in:
+238
-36
@@ -2,7 +2,7 @@
|
||||
|
||||
## 决策
|
||||
|
||||
Spring Data JPA + Hibernate 作为默认 ORM,Flyway 做 schema 迁移。
|
||||
**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,主要考虑:
|
||||
|
||||
@@ -10,23 +10,82 @@ Spring Data JPA + Hibernate 作为默认 ORM,Flyway 做 schema 迁移。
|
||||
- 大部分 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 继承
|
||||
PageResult<T> # 统一分页返回封装
|
||||
JpaAuditingConfig # 开启 Spring Data JPA Auditing
|
||||
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,按 domain 分子目录
|
||||
V1__init.sql # Flyway migration,目录名 = database 名(下划线形式)
|
||||
```
|
||||
|
||||
## `BaseEntity` 示例
|
||||
迁移目录名统一用**下划线形式、与 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
|
||||
@@ -50,19 +109,41 @@ abstract class BaseEntity {
|
||||
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 {
|
||||
class JpaAuditingConfig(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
) {
|
||||
@Bean
|
||||
fun auditorAware(): AuditorAware<String> = AuditorAware {
|
||||
Optional.ofNullable(StoreContextHolder.currentUserIdOrNull()?.toString())
|
||||
// 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
|
||||
@@ -76,13 +157,13 @@ class StoreEntity(
|
||||
@Column(nullable = false, length = 128)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "code", nullable = false, unique = true, length = 32)
|
||||
@Column(name = "code", nullable = false, length = 32)
|
||||
var code: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Enumerated(EnumType.STRING) // 存字符串,不存序号,见下面的说明
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: StoreStatus,
|
||||
) : BaseEntity()
|
||||
) : VersionedEntity()
|
||||
|
||||
@Repository
|
||||
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
|
||||
@@ -93,57 +174,175 @@ interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
|
||||
|
||||
```sql
|
||||
-- src/main/resources/db/migration/identity_store/V1__init.sql
|
||||
create schema if not exists identity_store;
|
||||
-- 注意:不要在迁移脚本里写 create database / use,database 由 Flyway 实例的
|
||||
-- defaultSchema 指定(见 DomainFlywayConfig),脚本里一律用不带库名的表名。
|
||||
|
||||
create table identity_store.store (
|
||||
id bigint generated always as identity primary key,
|
||||
name varchar(128) not null,
|
||||
code varchar(32) not null unique,
|
||||
status varchar(16) not null,
|
||||
created_at timestamp not null,
|
||||
updated_at timestamp not null,
|
||||
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)
|
||||
);
|
||||
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;
|
||||
```
|
||||
|
||||
Flyway 版本号(`V1`、`V2`…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。
|
||||
约定说明:
|
||||
|
||||
- **字符集固定 `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 独立 schema**:即使同一个数据库实例,各 `domains/*` 的表也归属各自 schema,不允许跨 domain 直接 `join` 表——需要数据时通过对方模块暴露的 `application` 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分)。
|
||||
**每个 domain 一个独立的 database**:`identity_store` / `workbench` / `webview_ticket` 各自建库,不允许跨 domain 直接 join 表。需要别的域的数据时,走对方 `-contract` 模块暴露的接口或领域事件(见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),不查对方的表。
|
||||
|
||||
```kotlin
|
||||
// 错误示范:workbench 直接 join identity_store 的表
|
||||
@Query("""
|
||||
select w from WorkbenchTileEntity w
|
||||
join StoreEntity s on s.id = w.storeId -- 跨 schema 直接 join,禁止
|
||||
""")
|
||||
fun findTilesWithStoreInfo(): List<WorkbenchTileEntity>
|
||||
|
||||
// 正确做法:workbench 通过 identity-store 暴露的接口获取门店信息
|
||||
// 正确做法:workbench 通过 identity-store 的契约模块获取门店信息
|
||||
@Service
|
||||
class WorkbenchAppService(
|
||||
private val storeQueryService: StoreQueryService, // identity-store 模块对外暴露的接口
|
||||
private val storeQueryService: StoreQueryService, // 来自 domains/identity-store-contract
|
||||
private val tileRepository: WorkbenchTileRepository,
|
||||
) {
|
||||
fun listTiles(userId: Long): List<TileResponse> {
|
||||
val stores = storeQueryService.listStoresByUserId(userId) // 走 application 层调用,不查表
|
||||
val stores = storeQueryService.listStoresByUserId(userId) // 走契约接口,不查表
|
||||
val tiles = tileRepository.findByUserId(userId)
|
||||
return buildTiles(tiles, stores)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录:为什么坚持"每个 domain 独立 schema"
|
||||
### 这条规则的强制力:哪些是硬约束、哪些不是
|
||||
|
||||
模块化单体最容易被破坏的地方就是数据库——代码层面 Gradle 依赖规则挡住了跨 domain import 类,但如果两个 domain 的表都在同一个 schema 下,写 SQL 的时候很容易"顺手 join 一下",这条规则完全不受编译器约束,只能靠约定。所以我们把 schema 拆开:`workbench` 的 `DataSource` 配置的默认 schema 是 `workbench`,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。
|
||||
这里要说清楚,避免高估它的保护力度:
|
||||
|
||||
代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
|
||||
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)。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
|
||||
- 复杂查询是否引入 QueryDSL/jOOQ(`Specification` 不够用时再决定)。
|
||||
- 各 domain 的实际表结构,等开发到对应模块时再补。
|
||||
|
||||
@@ -152,3 +351,6 @@ class WorkbenchAppService(
|
||||
- [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)
|
||||
|
||||
Reference in New Issue
Block a user