data_server 的 app/db/schema.sql、app/db/collection_schema.sql,user_manager 的 app/db/schema.sql)。
系统是混合持久化:data_server 只用 PostgreSQL(无 fallback、无后端开关),user_manager 仍完全用 SQLite。
一图看清:谁持有哪个库
三个物理库分布在两个进程里:local_auth.db 归 user_manager,两个 PostgreSQL 库都归 data_server。collection_server 自身不持有任何数据库——它是客户端直连的无状态薄网关,靠用户 Bearer + 服务断言(X-Rakull-Service-Assertion)调 data_server 内部接口,由 data_server 进程唯一读写 rakull_collection。
| 服务(进程) | 引擎 / 连接 | 库 | 表数量 |
|---|---|---|---|
user_manager | SQLite · LOCAL_AUTH_DATABASE_PATH | local_auth.db | 5 |
data_server | PostgreSQL · DATA_DATABASE_URL | rakull_data | 58 |
data_server | PostgreSQL · COLLECTION_DATABASE_URL(独立连接工厂) | rakull_collection | 2 |
三条必须记住的架构关系
- 实线是调用/持有,虚线全是跨库逻辑引用。 虚线不带任何数据库级约束,目标行是否存在数据库不保证,只能在应用层保证。
user_id是贯穿三库的身份与归属键,但不存在物理外键。 它由 SQLite 侧users.id发出,跨引擎被两个 PostgreSQL 库引用(文章、收藏、反馈等都按它归属用户)。多用户隔离完全靠应用代码在查询里带user_id,不是靠数据库。- 两个 PostgreSQL 库物理隔离,禁止跨库 JOIN。
rakull_collection只通过article_id+sentence_index逻辑引用rakull_data里的句子;收藏卡把句子正文快照成front/back,正是因为不能跨库取原文。
分库实体关系
rakull_data(PostgreSQL,内容库)
58 张表覆盖文章阅读、情景训练、旅程、词汇与自适应学习。下图是日常核心阅读链路的 6 张表:
- 实线是 PostgreSQL 强制的物理外键(全库共 41 个,此处只画核心链路)。删文章会级联删句子、weekly 记录与收藏。
- 唯一的虚线是
feedback:它和其它表同库,却故意不声明外键,只在应用层用article_id+sentence_index当上下文 ID,因此被反馈的文章/句子可独立删除而不拖累反馈。 - 文章归属
articles.user_id;is_public/guest_visible决定可见性。可见性是读权限概念,与所有权(能否写)是两回事。
rakull_data 一节。
local_auth.db(SQLite,账号与赞助库)
五条线全是同库物理外键(连接统一开 PRAGMA foreign_keys=ON 与 WAL)。注意 donations 挂了两条:user_id 随用户 CASCADE,审核管理员 reviewed_by 用 SET NULL,基数因此不同。users 上后 7 个赞助字段是 donations 的汇总缓存(每次重算而非累加)。这个库对 PostgreSQL 侧的唯一引用就是跨引擎 user_id(已在顶部拓扑图画虚线)。
字段与索引见数据库表字段参考的 local_auth.db 一节。
rakull_collection(PostgreSQL,收藏域)
库内只有这一条物理外键。collection_items 还引用三类外部 ID,全是跨库逻辑引用(顶部拓扑图虚线):collections.user_id 指向 SQLite 的 users,article_id + sentence_index 指向另一个 PG 库 rakull_data。复习调度的 SM-2 状态(due / interval / ease / reps / card_version 乐观锁)都内联在 item 行上,不依赖内容库。
字段与索引见数据库表字段参考的 rakull_collection 一节。
关系库与对象存储如何配合
结构化行在关系库里,但音频、视频、图片、上传文件这些字节块不在业务表中。关系库与对象存储通过rakull_data 里的 media_objects 表对接——它是两条存储轴的交汇点:既是被外键引用的关系行,又是字节的账本;而字节真正落在哪由 OBJECT_STORE 决定,业务表始终只持有“指针”。
组合规则:
- 一张元数据表,两种字节落点。
media_objects行永远在 PostgreSQL;local模式字节内联在data BYTEA(删行即删字节),cos模式data为NULL、只在storage_key存对象 key,读取时换成 1 小时有效的预签名 URL,删除按 key 引用计数(同一句子、同一声音的 TTS 是内容寻址的共享对象,用cache_hash去重,不能在仍被引用时删桶内对象)。 - 关系库到字节是“弱关联”,不是级联。
sentences.audio_object_id → media_objects.id ON DELETE SET NULL:媒体对象删除后句子仍在,只是丢音频。字节因此可有独立于句子的生命周期与共享复用。 - 通用文件上传走另一条
files通道,不登记media_objects。/v1/data/files通用上传、视频转写源等落到UPLOAD_DIR或 COS,对外只给不透明 handle(拒绝..、绝对路径等穿越);关系库里没有这些字节,句柄由业务侧持有。部分上传媒体(upload_audio/video/image)则仍经 objects 通道登记成media_objects行。 - 切换字节落点对上层无感知。 路由只依赖
Storage.objects/Storage.files接口,local与cos是同接口的两套实现,由OBJECT_STORE装配;local 目录布局与 COS key 一一镜像,迁移即按相同 key 搬运。 - 隔离在对象轴同样成立。 对象 key 的 scope 分
sys(TTS、情景音频等系统生成、内容寻址、跨用户共享)与u{uid}(用户私有上传);关系行按user_id隔离,私有对象按 key 前缀隔离。
初始化、迁移与种子数据
三个 schema SQL 是新数据库的事实来源(collection_server 无 schema 文件):
rakull_server/data_server/app/db/schema.sql(PostgreSQL 库rakull_data)rakull_server/data_server/app/db/collection_schema.sql(PostgreSQL 库rakull_collection)rakull_server/user_manager/app/db/schema.sql(SQLite 库local_auth.db)
| 服务 / 库 | bootstrap 与迁移职责 |
|---|---|
data_server(rakull_data、rakull_collection) | app/db/bootstrap.py 的 init_db() 对两个库分别执行 schema.sql / collection_schema.sql;存储工厂 get_storage() 启动时也会再跑一次幂等建表(CREATE TABLE/INDEX/TRIGGER IF NOT EXISTS),随后幂等导入情景与旅程夹具目录。没有运行时补列机制:旧 SQLite 时代的 PRAGMA table_info 补列和 _migrate_collection() 已删除,schema.sql 头部明确不携带历史增量迁移,只面向全新建库。PostgreSQL 外键始终强制,不需要 SQLite 那样的连接开关 |
| 旧 SQLite → PostgreSQL 一次性迁移 | 停服并完成首次 bootstrap 后,在 data_server 目录运行 scripts/migrate_sqlite_to_pg.py --data-sqlite data.db --collection-sqlite collection.db [--replace](旧文件就在服务根目录):以只读方式打开旧库,按 PostgreSQL schema 中实际存在的表/列逐列复制(身份列用 OVERRIDING SYSTEM VALUE 并重置序列,加载期间禁用用户触发器、临时摘除外键),单库单事务、失败整体回滚;目标库已含种子行时需 --replace 先 TRUNCATE。#82 之前遗留在 ../collection_server/collection.db 的历史收藏可用 --legacy-collection-sqlite 按并集重排主键后一并迁入;完整步骤见安装与快速开始 |
user_manager(local_auth.db) | 仍是 SQLite:app/db/migrations.py 的 apply_migrations() 用 PRAGMA table_info 检查并补充旧库缺少的 7 个 users 赞助字段;schema 用 INSERT OR IGNORE 预置邀请码;bootstrap 调用 app/db/seed.py 幂等写入哈希后的 root 管理员 |