如何在 Room 中实现一对多或 Many-to-Many 关系映射?

解读

国内面试中,这道题考察的是“数据库设计→实体建模→Room 注解→级联查询→性能与一致性”的完整闭环。
面试官通常不会只让你背“@ForeignKey + @Relation”,而是追问:

  1. 实体类怎么拆?中间表要不要显式建 Entity?
  2. 查询返回的 POJO 怎么写?会不会触发 N+1?
  3. 事务、级联删除、外键冲突策略如何保障一致性?
  4. 数据量大时,如何一次 SQL 把主表+从表全部拉回来,避免主线程卡顿?
    答出“实体拆分 + 外键约束 + 索引 + 事务 + 一次性 JOIN”才算过关。

知识点

  1. Room 的实体(Entity)必须有一张主表,主键 @PrimaryKey(autoGenerate = true)。
  2. 一对多:从表持外键,@ForeignKey 指定 parentColumns / childColumns,并打开 onDelete = CASCADE。
  3. 多对多:必须显式创建“中间表(junction table)”作为第三个 Entity,两个外键分别指向两张主表;中间表主键可设为 (leftId, rightId) 的复合主键。
  4. 查询返回的 POJO 用 @Embedded 主表 + @Relation 从表,Room 会自动生成两条 SQL:先查主表,再按 IN 语句批量查从表,属于“1+N 拆表”策略。
  5. 若数据量大,手写 @Query 一次性多表 JOIN,返回自定义的 JOIN POJO,避免 N+1;同时给外键字段加索引,防止全表扫描。
  6. 插入/更新/删除要放在 @Transaction 方法里,保证外键一致性;删除主表记录时,Room 默认不级联,必须显式声明 CASCADE 或手动先删从表。
  7. 编译期校验:Room 会检查外键字段类型、索引、NOT NULL,报错直接阻断编译,国内项目需特别注意混淆规则 -keep 住所有 Entity 和 POJO。

答案

以“订单 Order — 订单明细 OrderItem”一对多为例:

  1. 实体类
@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
)
  1. 返回给 UI 的 POJO
data class OrderWithItems(
    @Embedded val order: Order,
    @Relation(
        parentColumn = "order_id",
        entityColumn = "order_id"
    )
    val items: List<OrderItem>
)
  1. DAO
@Transaction
@Query("SELECT * FROM t_order WHERE order_id = :orderId")
suspend fun queryOrderWithItems(orderId: Long): OrderWithItems

多对多(学生 Student — 课程 Course):

  1. 中间表
@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
)
  1. 查询 POJO
data class StudentWithCourses(
    @Embedded val student: Student,
    @Relation(
        parentColumn = "stu_id",
        entityColumn = "course_id",
        associateBy = Junction(StuCourseCrossRef::class)
    )
    val courses: List<Course>
)
  1. DAO 同样用 @Transaction 包查询,保证原子性。

拓展思考

  1. 分页场景:一对多返回的 @Relation 列表可能很大,可拆两次查询,先分页主表,再按主表 ID 集合批量查从表,避免一次性把内存打爆。
  2. 多对多双向查询:既要“学生-课程”,也要“课程-学生”,可再写一个 CourseWithStudents POJO,复用同一张中间表,体现 DRY 原则。
  3. 外键索引方向:中间表两个外键都要加索引,否则按 courseId 查询学生时会全表扫描;国内低端机 IO 敏感,必须提前在 Entity 里声明。
  4. 级联策略选择:CASCADE 虽然方便,但误删主表会把从表一起带走;生产环境可改用 RESTRICT,业务层先检查引用再删,更安全。
  5. 数据库升级:新增中间表时,Migration 里要补建表语句,并给旧数据补默认值;国内渠道包多,灰度前一定要在 testInstrumentation 里做完整迁移测试。