如何在 Room 中实现一对多或 Many-to-Many 关系映射?
解读
国内面试中,这道题考察的是“数据库设计→实体建模→Room 注解→级联查询→性能与一致性”的完整闭环。
面试官通常不会只让你背“@ForeignKey + @Relation”,而是追问:
- 实体类怎么拆?中间表要不要显式建 Entity?
- 查询返回的 POJO 怎么写?会不会触发 N+1?
- 事务、级联删除、外键冲突策略如何保障一致性?
- 数据量大时,如何一次 SQL 把主表+从表全部拉回来,避免主线程卡顿?
答出“实体拆分 + 外键约束 + 索引 + 事务 + 一次性 JOIN”才算过关。
知识点
- Room 的实体(Entity)必须有一张主表,主键 @PrimaryKey(autoGenerate = true)。
- 一对多:从表持外键,@ForeignKey 指定 parentColumns / childColumns,并打开 onDelete = CASCADE。
- 多对多:必须显式创建“中间表(junction table)”作为第三个 Entity,两个外键分别指向两张主表;中间表主键可设为 (leftId, rightId) 的复合主键。
- 查询返回的 POJO 用 @Embedded 主表 + @Relation 从表,Room 会自动生成两条 SQL:先查主表,再按 IN 语句批量查从表,属于“1+N 拆表”策略。
- 若数据量大,手写 @Query 一次性多表 JOIN,返回自定义的 JOIN POJO,避免 N+1;同时给外键字段加索引,防止全表扫描。
- 插入/更新/删除要放在 @Transaction 方法里,保证外键一致性;删除主表记录时,Room 默认不级联,必须显式声明 CASCADE 或手动先删从表。
- 编译期校验:Room 会检查外键字段类型、索引、NOT NULL,报错直接阻断编译,国内项目需特别注意混淆规则 -keep 住所有 Entity 和 POJO。
答案
以“订单 Order — 订单明细 OrderItem”一对多为例:
- 实体类
@Entity(tableName = "t_order")
data class Order(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "order_id")
val orderId: Long = 0,
val orderNo: String
)
@Entity(
tableName = "t_order_item",
foreignKeys = [
ForeignKey(
entity = Order::class,
parentColumns = ["order_id"],
childColumns = ["order_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("order_id")]
)
data class OrderItem(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "item_id")
val itemId: Long = 0,
@ColumnInfo(name = "order_id")
val orderId: Long,
val sku: String,
val qty: Int
)
- 返回给 UI 的 POJO
data class OrderWithItems(
@Embedded val order: Order,
@Relation(
parentColumn = "order_id",
entityColumn = "order_id"
)
val items: List<OrderItem>
)
- DAO
@Transaction
@Query("SELECT * FROM t_order WHERE order_id = :orderId")
suspend fun queryOrderWithItems(orderId: Long): OrderWithItems
多对多(学生 Student — 课程 Course):
- 中间表
@Entity(
tableName = "t_stu_course",
primaryKeys = ["stu_id", "course_id"],
foreignKeys = [
ForeignKey(entity = Student::class, parentColumns = ["stu_id"], childColumns = ["stu_id"], onDelete = CASCADE),
ForeignKey(entity = Course::class, parentColumns = ["course_id"], childColumns = ["course_id"], onDelete = CASCADE)
],
indices = [Index("course_id")]
)
data class StuCourseCrossRef(
@ColumnInfo(name = "stu_id") val stuId: Long,
@ColumnInfo(name = "course_id") val courseId: Long
)
- 查询 POJO
data class StudentWithCourses(
@Embedded val student: Student,
@Relation(
parentColumn = "stu_id",
entityColumn = "course_id",
associateBy = Junction(StuCourseCrossRef::class)
)
val courses: List<Course>
)
- DAO 同样用 @Transaction 包查询,保证原子性。
拓展思考
- 分页场景:一对多返回的 @Relation 列表可能很大,可拆两次查询,先分页主表,再按主表 ID 集合批量查从表,避免一次性把内存打爆。
- 多对多双向查询:既要“学生-课程”,也要“课程-学生”,可再写一个 CourseWithStudents POJO,复用同一张中间表,体现 DRY 原则。
- 外键索引方向:中间表两个外键都要加索引,否则按 courseId 查询学生时会全表扫描;国内低端机 IO 敏感,必须提前在 Entity 里声明。
- 级联策略选择:CASCADE 虽然方便,但误删主表会把从表一起带走;生产环境可改用 RESTRICT,业务层先检查引用再删,更安全。
- 数据库升级:新增中间表时,Migration 里要补建表语句,并给旧数据补默认值;国内渠道包多,灰度前一定要在 testInstrumentation 里做完整迁移测试。