十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Overleaf history-v1 深度解析:knex PostgreSQL 数据库迁移与 Global Blob 共享机制

Overleaf history-v1 深度解析:knex PostgreSQL 数据库迁移与 Global Blob 共享机制 Overleaf history-v1 深度解析:knex PostgreSQL 数据库迁移与 Global Blob 共享机制【免费下载链接】overleafA web-based collaborative LaTeX editor项目地址: https://gitcode.com/GitHub_Trending/ov/overleaf本篇围绕 Overleaf 仓库中 services/history-v1/README.md 讲解的两大运维主题展开:其一,history-v1 服务如何使用 knex 管理 PostgreSQL 数据库迁移(建表、加列、索引的完整工作流);其二,跨项目共享文件内容的 Global Blob 机制——其列表存放于 MongoDB、服务启动时加载进内存,以及如何安全地向该列表添加或移除 blob(含demoted降级的完整流程)。读完本文,你可以独立完成 history-v1 的迁移开发,并理解源码层面 Global Blob 读写、去重与备份的实际行为,避免因误操作导致项目历史数据不可用。1. 服务背景:history-v1 的混合存储架构在理解 README 的两大主题之前,有必要先明确 history-v1 使用了哪些存储组件,因为 README 的每一条操作建议都建立在这个架构之上:MongoDB:存放 blob/chunk 的元数据。storage/lib/mongodb.js 中定义了核心集合,与本文强相关的是projectHistoryGlobalBlobs(Global Blob 列表)、projectHistoryBlobs(项目级 blob 元数据)、projectHistoryChunks(变更块元数据);PostgreSQL:存放数字 ID 项目(即 WriteFull 迁移后的新项目)的 blob/chunk 元数据,并通过 knex 管理其表结构迁移;对象存储(S3/GCS):通过 overleaf/object-persistor 抽象,存放 blob 的实际内容,分为全局 bucket(blobStore.globalBucket)与项目 bucket(blobStore.projectBucket),对应环境变量OVERLEAF_EDITOR_BLOBS_BUCKET与OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET(见 config/custom-environment-variables.json)。从源码结构看,同一套 BlobStore 按项目 ID 形态选择元数据后端:数字 ID 走 Postgres 后端,24 位十六进制字符串 ID 走 Mongo 后端,该路由逻辑见 storage/lib/blob_store/index.js 中的getBackend()。这一点对后文理解全局 blob 为什么放在 Mongo 集合而不是 Postgres 表很关键。2. 数据库迁移:使用 knex 管理 PostgreSQL SchemaREADME 的 Database migrations 一节指出:history 服务使用 knex 管理 PostgreSQL 迁移,并给出两条核心命令。2.1 两条核心命令创建一个新的迁移文件:npx knex migrate:make migration_name应用(执行)所有未运行的迁移:npx knex migrate:latest在仓库中,这两条命令有对应的便捷入口:package.json 的 scripts 中定义了migrate: knex migrate:latest,即yarn migrate等价于npx knex migrate:latest;依赖中声明了knex^2.4.0与pg驱动。2.2 knexfile.js 的实际配置knexfile.js 非常简洁,但几个细节决定了迁移行为:const baseConfig { client: postgresql, connection: config.herokuDatabaseUrl || config.databaseUrl, pool: { min: parseInt(config.databasePoolMin, 10), max: parseInt(config.databasePoolMax, 10), }, migrations: { tableName: knex_migrations, }, }连接串优先级:优先使用herokuDatabaseUrl(Heroku 平台注入的DATABASE_URL),否则回退到databaseUrl;开发环境的具体值在 config/development.json 中为postgres://postgres:postgrespostgres/write_latex_dev;连接池:池大小由databasePoolMin/databasePoolMax控制,config/default.json 的默认值是2与10;迁移记录表:knex_migrations用于记录已执行的迁移,因此迁移只会执行一次,天然幂等。2.3 当前仓库中已存在的迁移与表结构migrations/ 目录下的迁移文件完整记录了 history-v1 的 PostgreSQL schema 演进,是理解服务内部结构最直接的活文档:20220228163642_initial.js(初始迁移)创建了核心表:chunks:已封存的变更块,字段id、doc_id、end_version、end_timestamp,带end_version 0约束及(doc_id, end_version)唯一索引;old_chunks:被删除的变更块,含deleted_at时间戳;pending_chunks:尚未封存的活跃变更块;blobs:Postgres 侧的 blob 元数据,主键为二进制hash_bytes,含byte_length、string_length与一个global布尔标记;project_blobs:项目级 blob 元数据,主键(project_id, hash_bytes);序列docs_id_seq,用于为新文档分配 ID。值得注意的是该初始迁移全部使用CREATE TABLE IF NOT EXISTS,注释明确说明若表已存在,本迁移是 noop,即它被设计为对存量数据库做 schema 复制而非破坏性重建。20221026201437_chunk_start_version.js随后为chunks、pending_chunks、old_chunks三张表补充了start_version列;紧随其后的 20221027201324_unique_start_version.js 对其建立唯一约束。20221118213808_delete_global_blobs_table.js是一条很有信息量的迁移:它直接DROP TABLE IF EXISTS blobs,且down为空、注释标注 Not reversible。结合上文blobs表上那个global布尔列可以推断:全局 blob 元数据最初存放在 Postgres 的blobs表中,后来统一迁到了 MongoDB 的projectHistoryGlobalBlobs集合,这张表随之被废弃删除。这也解释了为什么 README 中 Global Blob 的操作全部针对 Mongo 集合而非 Postgres 表。20250415210802_add_chunks_closed.js展示了典型的增量演进方式:为chunks增加closed BOOLEAN NOT NULL DEFAULT FALSE列,并提供可回滚的down实现(DROP COLUMN)。storage/scripts/global-blobs-db-cleanup/ 目录还保留了当年清理 Postgres 全局 blob 数据时使用的四步 SQL 脚本与回滚脚本,其 README 说明了执行顺序(01 建 hash 临时表 → 02 打 global 标记 → 03 建新表 → 04 换表,rollback.sql可逆转 03 步)。2.4 迁移的工程实践建议迁移文件以时间戳前缀命名,knex migrate:make会自动生成;up必须可安全重放,down若不可逆应显式留空并注释说明(仓库中delete_global_blobs_table迁移即此惯例);对存量大表的操作优先使用IF NOT EXISTS/IF EXISTS保护,或拆分为加列 回填 加约束多个迁移;迁移在 CI 与容器中通过yarn migrate或npx knex migrate:latest执行,执行前请确认DATABASE_URL/herokuDatabaseUrl指向正确实例。3. Global Blob:跨项目共享 blob 的设计与实现README 的 Global blobs 一节给出定义:Global blob 是在多个项目之间共享的 blob;其列表存放在 MongoDB 的projectHistoryGlobalBlobs集合中,并在服务启动时被读入内存;修改该列表必须谨慎。3.1 启动时加载:从 Mongo 到内存 Map服务启动链路印证了启动时读取这一行为:app.js 的app.setup()先连接 MongoDB,再调用loadGlobalBlobs(),成功后才挂载 API 路由并打印Global blobs loaded:app.setup async function appSetup() { await mongodb.client.connect() logger.info(Connected to MongoDB) await loadGlobalBlobs() logger.info(Global blobs loaded) ... }storage/lib/blob_store/index.js 中loadGlobalBlobs()的实现将集合文档载入进程级Map:/** type {Mapstring, { blob: core.Blob, demoted: boolean}} */ const GLOBAL_BLOBS new Map() async function loadGlobalBlobs() { const blobs await mongodb.globalBlobs.find() for await (const blob of blobs) { GLOBAL_BLOBS.set(blob._id, { blob: new Blob(blob._id, blob.byteLength, blob.stringLength), demoted: Boolean(blob.demoted), }) } }由此可得 Global Blob 集合的文档结构(以_id为 blob 的 SHA-1 哈希):字段含义_idblob 的十六进制哈希(内容寻址的键)byteLength内容的字节长度stringLength内容的 UTF-8 字符长度(可为 null,表示未知/不可编辑文本)demoted布尔,是否已降级(见 3.3 节)内存加载的含义:Global Blob 列表在进程生命周期内是静态的——对 Mongo 集合的任何增删改,都必须重启 history-v1 服务才能生效。这正是 README 在添加/移除流程中反复要求Restart the history service的原因,也是操作必须谨慎的根本原因。3.2 读取路径:全局 bucket 优先getBlobLocation()决定每个哈希对应的物理位置:function getBlobLocation(projectId, hash) { if (GLOBAL_BLOBS.has(hash)) { return { bucket: config.get(blobStore.globalBucket), key: makeGlobalKey(hash), // aa/bb/其余部分 } } else { return { bucket: config.get(blobStore.projectBucket), key: makeProjectKey(projectId, hash), } } }即:只要哈希在GLOBAL_BLOBS中(无论是否 demoted),读取一律走全局 bucket;否则走项目 bucket。getBlob()读取元数据时同样先查GLOBAL_BLOBS,命中即直接返回内存中的Blob元对象,完全绕开数据库查询——这是 Global Blob 对读路径的性能收益。3.3 写入路径:demoted 标志决定写项目副本还是复用全局副本写入路径的核心判断在_findBlobBeforeInsert():async _findBlobBeforeInsert(hash) { const globalBlob GLOBAL_BLOBS.get(hash) if (globalBlob ! null !globalBlob.demoted) { return globalBlob.blob // 未降级:视为已存在,不再写入项目副本 } const blob await this.backend.findBlob(this.projectId, hash) return blob }putString()/putFile()在写入前都会调用它:未降级的全局 blob 被当作项目里已存在的 blob直接返回,项目内不会产生任何副本(去重效果);而一旦demoted: true,该判断失效,系统会像写入普通新 blob 一样,把内容写入项目 bucket并插入项目元数据表。未降级 → 不写项目副本;已降级 → 写项目副本但读仍走全局副本——这一组合语义与 README 移除流程中的描述(write new instances of this blob to project blobs, but still read from the global blob)完全对应。说明:README 原文在移除步骤中写作 set thedemotedproperty tofalse,与源码语义不一致(源码中demoted: true才是已降级,demoted: false/缺省才是正常全局 blob,见 blob_store/index.js 的_findBlobBeforeInsert与测试用例)。执行操作时应以源码语义为准:将demoted置为true。3.4 备份行为对 demoted 的依赖demoted 标志还影响备份子系统:storage/scripts/backup.mjs 中,遇到globalBlob !globalBlob.demoted的 blob 会打印 Blob is a global blob and not demoted, skipping 直接跳过——正常全局 blob 由全局桶统一保障,不随项目备份;storage/lib/backupGenerator.mjs 中,只有!GLOBAL_BLOBS.has(hash) || GLOBAL_BLOBS.get(hash).demoted的哈希才会进入项目的备份清单;验收测试 test/acceptance/js/storage/backup.test.mjs 的用例 should back up global blobs if they are demoted 验证了:一个demoted: true的全局 blob 会被写入项目 blob 路径并出现在备份中。这解释了移除流程为何需要先降级、再复制、最后才从集合中删除:在 blob 从集合中彻底移除之前,它必须先是 demoted 状态,才能保证所有引用项目已经拥有(或正在获得)自己可备份、可独立读取的副本。3.5 配套运维脚本导出列表:storage/scripts/export_global_blobs.mjs 可将全局 blob 列表导出为 CSV(hash,path,byteLength,stringLength,demoted),运行方式如文件头注释所示:node storage/scripts/export_global_blobs.mjs --output global_blobs.csv。变更前先导出快照,是 README 所称 carefully 的具体落地手段;向项目复制副本:移除流程第 3 步 Copy the blob to all projects that need it 的对应工具是 storage/tasks/copy_project_blobs.js,它读取全局 blob 文件(支持--global-blobs、--min-project-id、--max-project-id、--batch-size、--concurrency参数),按项目 ID 区间分批处理,把全局 bucket 中需要的 blob 复制到各项目的 bucket 中并维护重试(默认批 1000、最多重试 10 次、重试间隔 5s)。4. 向 Global Blob 列表添加一个 blobREADME 给出的标准三步流程:在projectHistoryGlobalBlobs集合中插入该 blob 的记录;重启 history 服务;删除对应的项目级 blob 副本。结合源码,各步骤的实际效果与注意点如下:第 1 步——插入集合记录。字段结构如 3.1 节表格。测试代码 test/acceptance/js/storage/blob_store.test.js 展示了标准写法:await mongodb.globalBlobs.insertOne({ _id: globalBlobHash, byteLength: globalBlobString.length, stringLength: globalBlobString.length, })同时该 blob 的内容必须已经存在于全局 bucket(测试中通过persistor.sendStream(bucket, key, stream)按makeGlobalKey的aa/bb/其余路径写入,如键2e/65/efe2a145dda7ee51d1741299f848e5bf752e)。第 2 步——重启服务。只有重启后,loadGlobalBlobs()才会把新记录载入GLOBAL_BLOBS;此后_findBlobBeforeInsert()命中该哈希,各项目写入相同内容时不再各自落项目副本,读取也切换为全局 bucket 路径。第 3 步——删除项目级副本。这一步产生实际的空间收益:此前各项目已经写入的同哈希 blob(存在于项目 bucket 与projectHistoryBlobs/Postgresproject_blobs元数据)可以安全清理,因为读路径已经保证走全局副本。注意顺序不能颠倒:必须先让全局列表生效,再删项目副本,否则读路径会找不到内容。5. 从 Global Blob 列表移除一个 blob(demoted 降级流程)README 强调移除trickier:一旦全局 blob 不可用,所有需要它的项目都必须立即拥有自己的副本。README 的完整步骤与源码机制逐条对应如下:将demoted置为降级状态(源码语义为true)(见 3.3 节关于 README 笔误的说明)。效果:读路径不变(仍读全局 bucket),写路径开始为项目生成独立副本——新项目写入该哈希时会落到项目 bucket;同时该 blob 从此进入项目备份清单(见 3.4 节);重启 history 服务,使GLOBAL_BLOBS中的demoted标志生效;把 blob 复制到所有需要它的项目。批量执行可使用 storage/tasks/copy_project_blobs.js;可先按项目查询引用关系确定范围,并用--min-project-id/--max-project-id分区间推进;从projectHistoryGlobalBlobs集合中删除该记录;再次重启 history 服务。此后GLOBAL_BLOBS不再包含该哈希,getBlobLocation()回落到项目 bucket 路径,读取完全依赖各项目副本。该流程的验收语义在测试中同样可见:blob_store.test.js 的 a demoted global blob 用例断言了降级后的行为组合——getBlob/getString/getStream仍返回全局副本的内容(读走全局),而putString会向项目 bucket 写入新副本(写落项目),两者同时成立。6. 小结:操作清单与风险提示操作步骤源码依据执行 schema 迁移npx knex migrate:latest(或yarn migrate)knexfile.js、package.json新建迁移npx knex migrate:make namemigrations 目录命名即时间戳前缀添加全局 blob插集合 → 重启 → 删项目副本_findBlobBeforeInsert移除全局 blob降级 → 重启 → 复制到各项目 → 删集合记录 → 重启backup.mjs、copy_project_blobs.js变更前留档node storage/scripts/export_global_blobs.mjs --output xxx.csvexport_global_blobs.mjs核心风险点只有一条:Global Blob 列表是进程内静态快照,任何变更都不热生效,且读路径在集合记录删除前始终优先指向全局 bucket。因此所有变更都应遵循先保证副本可用、再改列表、最后重启的顺序,并在操作前用导出脚本留存当前列表快照。【免费下载链接】overleafA web-based collaborative LaTeX editor项目地址: https://gitcode.com/GitHub_Trending/ov/overleaf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表