系统地学习了下 SQLite 中关于主键、外键、组合键、关系表等核心关系型数据库概念,以及kotlin ROOM数据库的大部分常用知识点。
1. 主键
定义:
主键是表中唯一标识每一行记录的一个列或一组列。它必须满足以下条件:
- 唯一性:任意两行的主键值都不能相同。
- 非空性 :主键的值不能为
NULL。 - 每个表只能有一个主键。
作用:
- 确保数据的实体完整性。
- 作为表中记录的唯一标识符,便于查询和关联。
- 大多数数据库会自动为主键列创建索引,以提高基于主键的查询速度。
在 SQLite 中的创建:
sqlite
-- 在创建表时定义单个列为主键(推荐)
CREATE TABLE users (
user_id INTEGER PRIMARY KEY, -- 主键
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL
);
注意: 在 SQLite 中,INTEGER PRIMARY KEY 列会自动成为 AUTOINCREMENT 的候选(但不完全是)。简单来说,如果你不指定值,它会自动分配一个比当前最大值大1的值。但官方推荐仅在确实需要防止重用已删除的ID时才使用 AUTOINCREMENT 关键字。
2. 外键
sql
-- 开启外键支持(每次连接都需要执行)
PRAGMA foreign_keys = ON;
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
order_date TEXT NOT NULL,
-- user_id 列引用 users 表的 user_id 主键列
user_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
定义:
外键是表中的一列(或一组列),其值必须匹配另一个表(或本表)的主键 或唯一约束列的值。它建立了两个表之间的"引用"关系。
作用:
外键的核心使命是维护 "引用完整性" ,确保 orders 表中的每一个 user_id 都能在 users 表中找到对应的主人。没有它,数据库里就会出现"幽灵订单"。外键不帮你建立,它只验证 你建立的是否合法。当插入了一条不存在的user_id的order记录,就会抛出异常(如 FOREIGN KEY constraint failed)阻止插入。
- 维护数据之间的引用完整性。确保数据的一致性和有效性,防止出现"孤儿"记录。
- 定义表与表之间的关联关系(一对一、一对多)。
除了阻止插入,外键还会在其他操作上防御:
| 操作 | 父表 (users) |
子表 (orders) |
是否被外键约束阻止? | 原因 |
|---|---|---|---|---|
| 插入 | 无影响 | 尝试插入 user_id=999 |
是 | users 表中没有ID为999的用户 |
| 删除 | 尝试删除 user_id=1 |
无影响 | 是 (默认行为) | 如果 orders 中还有小明的订单,删除他会导致订单失去主人。 |
| 更新 | 尝试将 user_id=1 改为 100 |
无影响 | 是 (默认行为) | orders 表中指向1的订单会指向一个不存在的ID。 |
在 SQLite 中的创建:
SQLite 默认关闭 外键约束,需要先开启。android kotlin room数据库开启PRAGMA foreign_keys = ON;的办法:
kotlin
@Database(entities = [User::class, Book::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"my_database"
)
.addCallback(DatabaseCallback()) // 此处添加Callback
.build()
INSTANCE = instance
instance
}
}
// 定义Callback
private class DatabaseCallback : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// 每次数据库打开时执行,确保外键开启
db.execSQL("PRAGMA foreign_keys = ON;")
}
}
}
}
外键约束行为
可以在定义外键时指定当被引用的主键被更新或删除时,本表的外键该如何处理。
sql
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE -- 当 users 表中的 user 被删除时,自动删除其所有 orders
ON UPDATE NO ACTION -- 当 users.user_id 更新时,不做操作(默认报错)
);
-- 其他常见动作:
-- ON DELETE SET NULL 父表记录删除,子表外键设为 NULL
-- ON DELETE RESTRICT 阻止删除父表记录(如果有子记录引用)
-- ON DELETE SET DEFAULT 父表记录删除,子表外键设为默认值
-- ON UPDATE CASCADE 很有用。当父表主键更改时,自动同步更新所有子表的外键值。这在需要更改ID时能保持数据关联不断裂。
-- ON UPDATE SET NULL / SET DEFAULT / RESTRICT: 逻辑同 ON DELETE 类似。
3. 组合主键
定义:
由两个或更多列共同构成的主键。这组列的组合值必须唯一,但单独每一列的值可以重复。
使用场景:
当单个列无法唯一标识一行时。例如,一个学生选课表,一个学生可以选多门课,一门课可以被多个学生选。
sql
CREATE TABLE student_courses (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrollment_date TEXT,
grade TEXT,
-- 学生ID和课程ID共同唯一确定一条选课记录
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (course_id) REFERENCES courses(id)
);
在上表中,(1, 101) 和 (1, 102) 是允许的(同一个学生选不同课),(1, 101) 和 (2, 101) 也是允许的(不同学生选同一门课),但第二个 (1, 101) 是不允许的。
4. 组合外键
定义:
由两个或更多列 共同构成的外键。这组列的值必须匹配另一个表中组合主键或组合唯一约束的对应列的值。
使用场景:
通常与组合主键配对使用,用于在多对多关系表或复杂引用中维护引用完整性。
sql
-- 假设我们有一个使用组合主键的父表
CREATE TABLE project_tasks (
project_code TEXT NOT NULL,
task_number INTEGER NOT NULL,
description TEXT,
PRIMARY KEY (project_code, task_number)
);
-- 子表使用组合外键来引用父表的组合主键
CREATE TABLE time_logs (
log_id INTEGER PRIMARY KEY,
project_code TEXT NOT NULL,
task_number INTEGER NOT NULL,
hours REAL,
FOREIGN KEY (project_code, task_number) -- 组合外键
REFERENCES project_tasks(project_code, task_number)
ON DELETE CASCADE
);
5. 关系表
关系表是实现多对多关系的核心手段。它本身通常没有业务主键,其主键就是两个外键的组合。
-
想象一个大学选课的场景:
- 一个学生 (比如小明)可以选多门课(高等数学、计算机基础、英语)。
- 一门课程 (比如高等数学)也可以被多个学生选(小明、小红、小刚)。
这就是一个典型的 "多对多" 关系。学生和课程之间,不是简单的一对一或一对多。
为什么不能把数据直接塞进已有的表?
我们先看两种错误的设计,就能明白问题所在:
❌ 错误设计1:在学生表里硬塞课程
| 学生ID | 姓名 | 已选课程ID |
|---|---|---|
| 1 | 小明 | 101, 103 |
| 2 | 小红 | 101, 102 |
- 问题 :
已选课程ID这个字段存储了多个值,这违反了数据库设计的第一范式(1NF)。你无法高效地查询"都有谁选了课101?",也无法为"课程ID"建立索引或外键。
❌ 错误设计2:在课程表里硬塞学生
| 课程ID | 课程名 | 选课学生ID |
|---|---|---|
| 101 | 高等数学 | 1, 2 |
| 102 | 计算机基础 | 2 |
- 问题:和上面一样,字段存储了多个值,结构混乱,难以维护。
这两种方式,都像是把一篇篇文章的所有标签,用逗号拼在一个字段里------这会导致查询极其低效且不灵活。
正确的解决方案:使用"关系表"
我们需要的,是一张纯粹的、只负责"记录关系"的表。它就像一本选课登记册 ,只记一件事:"谁选了哪门课"。
第一步:创建两张独立的主表
sql
-- 学生表 (主表)
CREATE TABLE students (
student_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
-- 课程表 (主表)
CREATE TABLE courses (
course_id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
第二步:创建核心的"关系表"
sql
-- 选课关系表 (核心)
CREATE TABLE student_courses (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
-- 组合主键:防止同一个学生重复选同一门课
PRIMARY KEY (student_id, course_id),
-- 外键约束:确保登记的学生是真实存在的
FOREIGN KEY (student_id) REFERENCES students(student_id) ON DELETE CASCADE,
-- 外键约束:确保登记的课程是真实存在的
FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE
);
这张表 (student_courses) 的特点:
- 它没有业务主键:它的每一行只代表一个纯粹的"关系",不需要像订单ID、文章ID那样的独立编号。
- 它的主键是两个外键的组合 :
(student_id, course_id)。这保证了数据唯一性。例如,(1, 101)这条记录只能出现一次,避免了小明重复选同一门高数课。 - 它的字段都是外键 :分别指向
students和courses表的主键,确保了每条关系都有效。
现在,我们用 student_courses 关系表来记录选课情况:
| student_id | course_id | |
|---|---|---|
| 1 | 101 | -- 小明选了高等数学 |
| 1 | 103 | -- 小明选了大学英语 |
| 2 | 101 | -- 小红选了高等数学 |
| 2 | 102 | -- 小红选了计算机基础 |
| 3 | 102 | -- 小刚选了计算机基础 |
你看,这张表就像一个"连接器"或"交换机":
- 从"学生"出发:查看关系表,能立刻知道小明(ID 1)关联了课程 101 和 103。
- 从"课程"出发:查看关系表,能立刻知道高等数学(ID 101)关联了学生 1 和 2。
查询语句:
sql
-- 查询"小明选了哪些课?"
SELECT s.name, c.title
FROM students s -- 这里定义了别名
JOIN student_courses sc ON s.student_id = sc.student_id
JOIN courses c ON sc.course_id = c.course_id -- 这里定义了别名
WHERE s.name = '小明';
-- 结果:
-- 小明 | 高等数学
-- 小明 | 大学英语
-- 查询"高等数学这门课有哪些学生选?"
SELECT c.title, s.name
FROM courses c
JOIN student_courses sc ON c.course_id = sc.course_id
JOIN students s ON sc.student_id = s.student_id
WHERE c.title = '高等数学';
-- 结果:
-- 高等数学 | 小明
-- 高等数学 | 小红
6. 索引
-
主键:SQLite 会自动为主键(包括组合主键)创建索引。
-
外键 :SQLite 不会 自动为外键列创建索引。但强烈建议手动创建,因为在连接查询或检查约束时,外键列上的索引能极大提升性能。
sqlCREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_article_tags_article_id ON article_tags(article_id); CREATE INDEX idx_article_tags_tag_id ON article_tags(tag_id);
创建索引的权衡 :索引会加速读查询(SELECT) ,但会减慢写操作(INSERT, UPDATE, DELETE),因为索引本身也需要维护。不应盲目地为所有列创建索引。
覆盖索引: 如果一个索引包含了查询所需的所有字段,数据库可以直接从索引中获取数据,无需回表查询,效率极高。
sql
CREATE INDEX idx_covering ON orders(user_id, order_date);
-- 对于查询: SELECT order_date FROM orders WHERE user_id = 123;
-- 如果只建 (user_id) 索引,需要根据id去主表找order_date。
-- 而 (user_id, order_date) 这个索引已经包含了全部所需数据。
android room数据库如何定义:
kotlin
@Entity(indices = [
Index(value = ["user_id"]), // 单列索引
Index(value = ["last_name", "first_name"], unique = true) // 复合唯一索引
])
data class User(
@PrimaryKey val id: Int,
val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String
)
7. 其他约束
除了主键(PRIMARY KEY)和外键(FOREIGN KEY),还有:
-
UNIQUE约束 : 确保某列(或列组合)的值在整个表中是唯一的。与主键的区别在于,UNIQUE列允许存在多个NULL值(在SQLite中)。sqlCREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, -- 确保邮箱不重复 username TEXT UNIQUE ); -
CHECK约束: 允许你定义必须满足的逻辑条件,否则插入或更新会被拒绝。sql
CREATE TABLE products ( id INTEGER PRIMARY KEY, price REAL CHECK (price >= 0), -- 价格不能为负 quantity INTEGER CHECK (quantity IN (0, 1, 10, 100)) -- 数量只能是特定值 ); -
NOT NULL约束 : 你已常用,确保列不能包含NULL值。
8. 自引用外键与树形结构
外键不仅可以引用其他表,还可以引用自身表的主键。这在存储树形或层级数据(如组织架构、评论回复)时非常有用。
sql
CREATE TABLE employees (
emp_id INTEGER PRIMARY KEY,
name TEXT,
manager_id INTEGER,
FOREIGN KEY (manager_id) REFERENCES employees(emp_id) ON DELETE SET NULL
);
-- manager_id 引用了同一张表的 emp_id,表示该员工的上级。
9. 事务(Transactions)
这是保证数据完整性的关键机制。将一系列操作放在一个事务中,可以确保它们要么全部成功,要么全部失败,不会出现中间状态。
sql
BEGIN TRANSACTION; -- 开始事务
-- 一系列操作,例如:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 如果任何一步失败,可以 ROLLBACK(回滚)
COMMIT; -- 如果全部成功,提交事务
在Room中,你可以使用 @Transaction 注解来标记一个Dao方法,使其自动在事务中执行。
使用 @Transaction 注解(推荐,更清晰)
这是最简洁、最符合 Room 风格的方式。你只需在 DAO 接口或抽象类的方法上添加 @Transaction 注解,该方法中的所有数据库操作就会自动在一个事务中执行。
kotlin
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts WHERE id = :id")
suspend fun getAccount(id: String): Account?
@Update
suspend fun updateAccount(account: Account)
// 关键:使用 @Transaction 注解标记一个事务方法
@Transaction
suspend fun transferMoney(fromAccountId: String, toAccountId: String, amount: Double) {
// 1. 查询转出账户
val fromAccount = getAccount(fromAccountId) ?: throw Exception("转出账户不存在")
// 2. 查询转入账户
val toAccount = getAccount(toAccountId) ?: throw Exception("转入账户不存在")
// 3. 检查余额是否充足
if (fromAccount.balance < amount) {
throw Exception("余额不足")
}
// 4. 执行转账更新(这两个更新操作将在同一个事务中)
fromAccount.balance -= amount
toAccount.balance += amount
updateAccount(fromAccount)
updateAccount(toAccount)
// 如果在此方法的任何地方抛出异常,整个事务将自动回滚,所有更改都不会生效。
}
}
使用示例:
kotlin
// 在 ViewModel 或 Repository 中调用
viewModelScope.launch {
try {
accountDao.transferMoney("userA", "userB", 100.0)
Log.d("Transfer", "转账成功!")
} catch (e: Exception) {
Log.e("Transfer", "转账失败:${e.message}")
}
}
10. ROOM相关
10.1 SQLite数据类型与Room类型映射
理解SQLite灵活的类型系统对于避免存储问题和优化性能很重要。
-
SQLite的存储类 :
INTEGER,REAL,TEXT,BLOB,NULL。它采用"动态类型",但声明类型 (你在CREATE TABLE中写的类型)会影响亲和性,建议明确声明。 -
Room中的映射:Room编译器会将Kotlin/Java类型转换为合适的SQLite类型。
kotlin@Entity data class ExampleEntity( @PrimaryKey val id: Long, // 映射为 INTEGER val name: String, // 映射为 TEXT val weight: Double, // 映射为 REAL val timestamp: java.util.Date, // 需要 TypeConverter,通常存为 INTEGER (毫秒) 或 TEXT (ISO8601) val blobData: ByteArray? // 映射为 BLOB )
-
Type Converters(类型转换器)
kotlin
// 1. 定义你的转换器类
class DateConverters {
/**
* 将 Date 对象转换为 Long 时间戳(存入数据库)
* 如果 date 为 null,则存入 null
*/
@TypeConverter
fun dateToTimestamp(date: java.util.Date?): Long? {
return date?.time // .time 属性返回自 1970年1月1日 以来的毫秒数
}
/**
* 从数据库读取 Long 时间戳,并将其转换回 Date 对象
* 如果 timestamp 为 null,则返回 null
*/
@TypeConverter
fun timestampToDate(timestamp: Long?): java.util.Date? {
return timestamp?.let { java.util.Date(it) }
}
}
// 2. 在你的 Entity 中直接使用 Date 类型
@Entity
data class Message(
@PrimaryKey val id: Long,
val content: String,
val sentAt: java.util.Date, // 直接在这里声明 Date 类型!
val readAt: java.util.Date? // 可空类型也可以
)
// 3. 在你的 AppDatabase 类上应用这些转换器
@Database(entities = [Message::class], version = 1)
@TypeConverters(DateConverters::class) // 关键!告诉 Room 使用这个转换器
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}
// 4. 在 Dao 中操作时,你感知到的完全是 Date 对象
@Dao
interface MessageDao {
@Insert
suspend fun insert(message: Message)
// 查询今天之后的所有消息
@Query("SELECT * FROM Message WHERE sentAt > :today")
suspend fun getMessagesAfter(today: java.util.Date): List<Message>
}
#### 10.2 在Room中使用原始SQL处理复杂操作
虽然Room的抽象很好用,但有时直接执行SQL更灵活,例如执行`PRAGMA`、创建触发器或复杂连接查询。
```kotlin
@Dao
interface MyDao {
// 用 @RawQuery 执行动态或非常规SQL
@RawQuery
fun arbitraryQuery(query: SimpleSQLiteQuery): Int
// 在需要时,可以这样使用:
fun disableForeignKeys() {
arbitraryQuery(SimpleSQLiteQuery("PRAGMA foreign_keys = OFF"))
}
}
10.3 数据库加密
如果你的应用存储了用户的敏感信息(如聊天记录、健康数据),数据库文件本身需要进行加密。
-
常见方案 :使用 SQLCipher 库对SQLite数据库进行透明的加密/解密。
-
在Room中集成SQLCipher:
-
添加依赖:
implementation 'net.zetetic:android-database-sqlcipher:x.x.x' -
在打开数据库时,使用SQLCipher提供的支持类替换Room的默认Factory。
kotlinval passphrase = "your-secure-passphrase".toByteArray() val factory = SupportFactory(passphrase) val db = Room.databaseBuilder(context, AppDatabase::class.java, "encrypted.db") .openHelperFactory(factory) // 关键:使用SQLCipher的Factory .build()
-
10.4 数据库迁移(Database Migration)
当你的实体类发生变化(如增加字段、修改表名)时,必须提供迁移路径来更新数据库结构,否则应用升级会崩溃。
-
Room的迁移机制 :通过
Room.databaseBuilder().addMigrations(MIGRATION_1_2, MIGRATION_2_3)添加。 -
一个标准的迁移示例:
kotlin// 版本 1 到 2 的迁移:为 User 表添加一个 age 列 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0") } } // 构建数据库时添加迁移 Room.databaseBuilder(...) .addMigrations(MIGRATION_1_2) .build() -
重要提醒:
- 务必增量式定义迁移(1->2, 2->3),而非直接(1->3)。
- 复杂迁移(如修改主键)可能需要创建新表并复制数据。
- 测试!使用
Room.inMemoryDatabaseBuilder创建测试数据库验证迁移逻辑。
10.5 预填充数据库
如果你的应用需要一个初始就包含数据(如城市列表、预定义分类)的数据库,Room支持从打包在 assets/ 目录下的数据库文件进行预填充。
-
步骤:
-
使用工具(如 SQLite浏览器 )或程序创建好包含初始数据的数据库文件(例如
prepopulated.db)。 -
将该文件放在项目的
assets/databases/目录下。 -
在构建数据库时,调用
.createFromAsset("databases/prepopulated.db")。kotlinRoom.databaseBuilder(context, AppDatabase::class.java, "myapp.db") .createFromAsset("databases/prepopulated.db") .build()
-
10.6 @Embedded
对于自定义类,如果其目的是组合多个需要被独立查询 的字段(如 Address 包含 city, street),更推荐使用 Room 的 @Embedded 注解,它将字段扁平化地展开到宿主表中,每个子字段都可被直接索引和查询。
kotlin
data class Address(
val city: String,
val street: String,
val postalCode: String
)
@Entity
data class Company(
@PrimaryKey val id: Long,
val name: String,
@Embedded // 这将把 address 的字段(city, street, postalCode)直接作为 Company 表的列
val headquarters: Address
)
// 现在你可以直接查询: SELECT * FROM Company WHERE city = '北京'
11. 其他
11.1 触发器(Triggers)(高级但有用)
触发器是一种在指定表发生特定事件(INSERT、UPDATE、DELETE)时,自动执行的特殊程序。常用于实现复杂的审计日志、数据同步或业务规则。
sql
-- 创建一个触发器,在删除用户前,将其信息备份到另一张表
CREATE TRIGGER backup_user_before_delete BEFORE DELETE ON users
BEGIN
INSERT INTO users_backup (user_id, username, deleted_at)
VALUES (OLD.user_id, OLD.username, DATETIME('now'));
END;
11.2 视图(Views)
视图是一个虚拟表,它是基于一个或多个实际表的查询结果。它可以简化复杂查询、隐藏底层表结构、提供安全的数据访问层。
sql
-- 创建一个视图,展示订单的概要信息
CREATE VIEW order_summary AS
SELECT o.order_id, u.name AS user_name, o.order_date, o.total_amount
FROM orders o
JOIN users u ON o.user_id = u.user_id;
-- 之后可以像查询普通表一样查询视图
SELECT * FROM order_summary WHERE user_name = '小明';
在Room中,你可以使用 @DatabaseView 注解来定义视图。