第三章 实体定义与字段优化
说实话,Room 的实体定义看着很简单,不就是写个数据类加个 @Entity 嘛。但我当年第一个线上项目,就因为实体定义太随意,导致数据库查询慢得像蜗牛。后来才明白——实体就是数据库的骨架,骨架歪了,后面怎么优化都吃力。
这一章,我就把踩过的坑和总结的套路全摊开。你读完应该能少走半年弯路。
3.1 主键选择:自增还是业务主键?
Room 要求每个实体至少有一个主键,这个没商量。但主键怎么选,我见过太多人随便写个 id 就完事。其实这里面门道不少。
我个人习惯:能用 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();
}
}
本章小结
实体定义看起来基础,但它是数据库性能的根基。我调过很多 Room 的性能问题,十有八九是实体设计不合理——主键太大、字段冗余、索引缺位或过量。你可以回头检查一下自己的实体类,按这几点快速过一遍:
- 主键选对了吗?有没有用字符串存整数能解决的场景?
- 字段类型是否最精简?能不能用 int 代替 String?
- 高频查询的字段加索引了吗?联合索引顺序对吗?
- 嵌入式对象是否真的需要所有字段平铺?
- 临时属性加
@Ignore了吗?
改掉这些问题,就算不碰数据库配置,性能也能上一个台阶。嗯,这一章就到这。记住,好的实体定义是高性能 Room 的起跑线。