第三章 实体定义与字段优化

说实话,Room 的实体定义看着很简单,不就是写个数据类加个 @Entity 嘛。但我当年第一个线上项目,就因为实体定义太随意,导致数据库查询慢得像蜗牛。后来才明白——实体就是数据库的骨架,骨架歪了,后面怎么优化都吃力。

这一章,我就把踩过的坑和总结的套路全摊开。你读完应该能少走半年弯路。

实体定义优化决策流程 定义 @Entity 实体类 主键策略?自增 vs 业务主键 自增主键(autoGenerate=true) 业务主键(手动赋值) 字段类型、索引、嵌入式对象优化 添加索引(@Index)提升查询 忽略字段(@Ignore)减少开销 每一步都影响查询性能 建议提前规划

3.1 主键选择:自增还是业务主键?

Room 要求每个实体至少有一个主键,这个没商量。但主键怎么选,我见过太多人随便写个 id 就完事。其实这里面门道不少。

核心原则:主键越短越小,B-Tree 索引越紧凑,插入和查询越快。

我个人习惯:能用 Long 自增就别用 String。字符串主键不仅占空间,而且比较起来比整型慢一个数量级。我之前有个用户表,用了 UUID 做主键,结果用户量一过百万,插入速度直线下降。后来改成自增 Long,写入性能提升了 3 倍。

当然,有些场景必须用业务主键,比如从服务器同步过来的数据主键已经固定。这时候记得把 primaryKeys 设好,并考虑联合主键的情况。

主键类型对比
类型优点缺点推荐场景
自增 Long插入快、索引小、无冲突依赖数据库生成,不适合分布式本地数据、单机应用
业务主键(String)可读性高,与服务器一致占用大,比较慢需与后台同步的数据
联合主键避免重复组合查询时需匹配所有字段关联表、关系中间表
注意:不要用 autoGenerate=true 和业务主键混用。我曾经接手一个项目,实体里既写了 @PrimaryKey(autoGenerate=true) 又手动赋 id 值,结果 Room 生成的主键和手动传的冲突,插入时疯狂报 SQLite 约束错误。

3.2 字段类型与大小:少即是多

Room 的字段类型映射其实挺灵活,但灵活性也容易让人犯错。你想想看,一个用户头像 URL,你用 String 没问题;但一个性别状态,用 String 存 "male" / "female" 就太浪费了。为什么不直接用 Int 枚举?或者更直接的 Boolean?

说白了,字段越小,SQLite 每次读取的页数就越少。 我调过一块业务表,里面七八个字段都是字符串,最大的一个字段存了 2000 字符的备注。其实备注 90% 情况下都是空的。后来我把备注单独拆到另一张表,主查询从 300ms 降到了 45ms。

我的经验:能用 Int 不用 String,能用 Long 不用 Double(如果你的数值不需要小数)。布尔值用 Int 存 0/1 最省空间。日期时间建议用 Long 存时间戳,而不是 String 存格式化文本。

另外,注意 @NonNull 注解。加了之后,Room 会生成非空约束,插入时自动检查,省去你在业务层判空。但也要合理使用——我见过有人所有字段全都 @NonNull,结果数据结构改了,很多旧数据插入时就崩溃。

3.3 索引:加速查询的利器,但不是越多越好

索引能让你在 WHERE、ORDER BY、JOIN 的时候快很多。但索引不是免费的——每次插入、更新、删除,SQLite 都要同步维护索引树。所以只给真正需要频繁查询的字段加索引。

举个例子,我有一个用户表,按用户状态(status)过滤查询的频率很高。但 status 只有 0、1、2 三种值。这种低基数字段加索引效果其实不好——因为查询会匹配到大量数据,索引树几乎退化。反而在 status + createTime 联合索引上效果更好。

@Entity(indices = {
    @Index(value = {"status", "createTime"}),
    @Index(value = {"email"}, unique = true)
})
public class User {
    @PrimaryKey(autoGenerate = true)
    public long id;
    public int status;
    public long createTime;
    @ColumnInfo(name = "email")
    public String email;
}
索引设计三原则:
1. 高频查询字段再加,低频查询别加。
2. 联合索引的字段顺序:最常过滤的放前面。
3. 唯一约束(unique = true)会自动创建索引,不用重复写。

我曾经跟一个同事一起调优,他发现某个查询特别慢,给三个字段都加了单独索引。结果插入速度从 2ms 变成了 20ms。我帮他改成单个联合索引后,查询同样快,插入速度也回到 3ms。这就是索引要适度。

3.4 嵌入式对象:拆分脏数据,复用干净结构

@Embedded 能让你把一个对象拆成多个扁平字段存到同一张表。这样做的好处是:结构清晰,字段可复用。

比如地址信息,在很多实体里都会用到。我可以定义一个 Address 类,然后在 User 和 Order 里都用 @Embedded 嵌入。这样每个地址字段(省份、城市、街道)各自独立列,但代码里是一个对象,维护起来非常爽。

但是要注意:不要滥用嵌入式对象。如果一个对象有 10 个字段,嵌入后表就会多出 10 列。如果这些列绝大多数场景都不需要,那还不如用 @Relation 做一对一关联,按需查询。我踩过这个坑——有个详情页,嵌入了一个 15 字段的配置对象,结果列表查询每次都把整个表 30 列读出来,性能差到炸。

建议:嵌入式对象只适用于“几乎每次查询都需要”的字段。如果不确定,先做一下查询频率统计再决定。

3.5 忽略不必要的字段:别让 Room 替你存所有

你写的实体类里,有些字段只是运行时用的临时数据,不需要存数据库。比如一个计算出来的属性,或者从其他表联合查询出来的结果。这时候一定要加上 @Ignore。

@Entity
public class Order {
    @PrimaryKey
    public long orderId;
    public double amount;
    public long userId;

    @Ignore
    public String tempCalculatedField;  // 不存储
}

不加 @Ignore 的后果是什么?Room 会认为这个字段也需要持久化——它会试着创建列,如果类型不匹配就报错,或者存下一个你不知道的值。我见过一个团队,因为忘记加 @Ignore,导致数据库升级时多了一堆冗余列,查询性能缓慢还排查了很久。

另外,如果你用 Kotlin,注意 data class 里默认所有构造参数都会被 Room 当成列。如果你用了默认参数或者临时属性,记得用 @Ignore 或者放在类体里而不是构造中。

3.6 定制列名与类型转换器

有时候数据库的列名和 Java 字段名不一致,比如后端下发的字段是 created_at,你习惯用 createdAt。这时候 @ColumnInfo(name = "created_at") 就派上用场了。

另外,对于自定义类型(比如 Date、枚举、List<String>),Room 不知道怎么存,你需要写一个 @TypeConverter。我建议:将复杂类型存为 JSON 字符串,因为 SQLite 处理文本很快,而且可读性强。千万别为了省那点空间去用 BLOB——序列化和反序列化的开销会让你怀疑人生。

public class Converters {
    @TypeConverter
    public static Date fromTimestamp(Long value) {
        return value == null ? null : new Date(value);
    }
    @TypeConverter
    public static Long dateToTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }
}
曾经踩过的坑:给 List<String> 写 TypeConverter 时,用了默认的 Gson 序列化,但没处理空 list。结果空 list 存成了 "null" 字符串,读取时 Gson 给你返回一个包含 "null" 字符串的 List……后来我统一加了判空处理。

本章小结

实体定义看起来基础,但它是数据库性能的根基。我调过很多 Room 的性能问题,十有八九是实体设计不合理——主键太大、字段冗余、索引缺位或过量。你可以回头检查一下自己的实体类,按这几点快速过一遍:

  • 主键选对了吗?有没有用字符串存整数能解决的场景?
  • 字段类型是否最精简?能不能用 int 代替 String?
  • 高频查询的字段加索引了吗?联合索引顺序对吗?
  • 嵌入式对象是否真的需要所有字段平铺?
  • 临时属性加 @Ignore 了吗?

改掉这些问题,就算不碰数据库配置,性能也能上一个台阶。嗯,这一章就到这。记住,好的实体定义是高性能 Room 的起跑线。