ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

iii 实战(Ch. 3):用 SQLite 数据库为 Linkly 短链接服务构建持久化存储

2026/9/14 6:14:32 拓冰建站 浏览量
iii 实战(Ch. 3):用 SQLite 数据库为 Linkly 短链接服务构建持久化存储 iii 实战Ch. 3用 SQLite 数据库为 Linkly 短链接服务构建持久化存储【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii本教程章节基于 iii 官方 Linkly 教程第 3 章《Persist everything》在内存版短链接服务Ch. 1: Foundations基础上引入databaseworkerSQLite作为持久化事实源source of truth让链接与每次点击都拥有可跨重启存活的、可用 SQL 查询的落盘记录同时保留iii-state作为前置热缓存。读完本文你将掌握如何用iii worker add database添加数据库 worker、如何在config.yaml中配置连接池与 SQLite 数据源、如何通过database::execute/database::query在 worker 内执行 SQL、如何实现写库 写缓存双写与缓存优先、回源补缓存的读取模式以及如何用iii trigger database::query直接从命令行查询持久化数据。为什么需要把链接从内存搬到数据库在前一章Ch. 2结束时Linkly 的链接存放在iii-state中且 Ch. 1 出于教学目的把它的store_method配成了in_memory。这意味着每次重启 engine所有链接全部消失——服务看起来能工作但没有持久性。解决路径有两条本教程选择了第二条iii-state自持久化它本身支持store_method: file_based加file_path落盘引入专用databaseworker提供持久化存储 完整的 SQL 查询能力同时让iii-state继续充当读路径上的快速缓存。选择专用数据库的理由很实际链接只是数据的一种点击事件每次跳转都产生一条带时间戳的记录同样需要落盘而且你可能希望对它们跑聚合查询比如某个短码被点击了多少次。一个 SQL 数据库同时满足持久化与可查询两个诉求而iii-state只负责把高频读请求挡在内存里。从系统架构看worker 之间没有直接 importlinkworker 通过worker.trigger调用databaseworker 暴露的函数database::execute、database::query所有调用都流经 engine。这与 Ch. 1 中link::create调用state::set的通信模型完全一致——在 iii 里能力是以函数为接口、以 worker 为边界组合出来的。添加 database worker 并配置连接池在项目根目录config.yaml所在目录执行iii worker add database mkdir -p datamkdir -p data是为了准备 SQLite 数据文件所在目录。iii worker add database会把databaseworker 写进config.yaml的workers列表就像之前iii worker add iii-http、iii worker add iii-state一样。接着把config.yaml中databaseworker 的配置调整为workers: # ... - name: database config: databases: primary: pool: acquire_timeout_ms: 5000 idle_timeout_ms: 30000 max: 10 url: sqlite:./data/iii.db逐项拆解这份配置配置项含义本教程取值databases.primary数据库别名worker 内部以及外部调用方如linkworker都通过这个逻辑名引用数据库primaryurl数据库连接串sqlite:./data/iii.db表示使用位于项目data/目录下的 SQLite 文件sqlite:./data/iii.dbpool.max连接池最大连接数常规连接池语义10pool.acquire_timeout_ms获取连接的超时上限超时即报错常规连接池语义5000pool.idle_timeout_ms空闲连接的最大存活时间常规连接池语义30000两点值得注意首次运行自动建库databaseworker 第一次启动时会自动创建./data/iii.db不需要手工sqlite3初始化文件不止 SQLitedatabaseworker 支持多种数据库url 连接串决定了后端类型。本文以 SQLite 为例零外部依赖、开箱即用生产环境可按需替换为其他数据库连接串。在 link worker 中定义数据库常量数据库已就绪接下来改造link/src/index.ts。先在文件顶部附近加一个常量让所有 SQL 调用都引用同一个逻辑数据库名import { registerWorker } from iii-sdk; import { Logger } from iii-dev/observability; const DB primary; // Matches db name in config.yaml这样做的意义数据库别名primary只在config.yaml与这里出现后续无论是改连接串还是切换数据库后端linkworker 的代码都无需变动——它只认逻辑名不认物理路径。启动时建表ensureSchema()databaseworker 负责执行 SQL但表结构由使用方linkworker自己定义。在link/src/index.ts末尾追加ensureSchema()它通过database::execute函数在启动时创建两张表async function ensureSchema(): Promisevoid { await worker.trigger({ function_id: database::execute, payload: { db: DB, sql: CREATE TABLE IF NOT EXISTS links (code TEXT PRIMARY KEY, url TEXT NOT NULL, created_at TEXT NOT NULL), }, }); await worker.trigger({ function_id: database::execute, payload: { db: DB, sql: CREATE TABLE IF NOT EXISTS clicks (id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, clicked_at TEXT NOT NULL), }, }); } ensureSchema() .then(() logger.info(database: ready)) .catch((err) logger.error(database: schema init failed, { error: String(err) }));两张表的职责划分links短码 → 原始 URL 的持久映射。code TEXT PRIMARY KEY以短码为主键天然去重url非空created_at记录创建时间便于追溯。clicks每次跳转产生一行。id INTEGER PRIMARY KEY AUTOINCREMENT自增主键保证行唯一code记录被访问的短码注意它没有外键约束是宽泛的记录表允许对同一短码累计多行clicked_at记录点击时间戳。CREATE TABLE IF NOT EXISTS保证幂等engine 每次启动、worker 每次重载都会执行一次但不会重复建表。由于tsx watch会在保存文件时热重载 workerensureSchema()实际会在每次代码变更后重新执行此时两条语句只会静默通过。写路径link::create 双写数据库与缓存改造link::create让链接创建时同时写入数据库持久记录与iii-state热缓存worker.registerFunction(link::create, async (payload: { url: string; code?: string }) { const code payload.code ?? makeCode(); const url /^https?:\/\//i.test(payload.url) ? payload.url : https://${payload.url}; await worker.trigger({ function_id: database::execute, payload: { db: DB, sql: INSERT INTO links (code, url, created_at) VALUES (?, ?, ?), params: [code, url, new Date().toISOString()], }, }); await worker.trigger({ function_id: state::set, payload: { scope: links, key: code, value: { url } }, }); logger.info(link created, { code, url }); return { code, url }; });三个关键细节SQL 参数化INSERT语句使用?占位符 params数组传参由databaseworker 负责绑定避免 SQL 注入风险——这是所有database::execute/database::query调用的推荐写法URL 规范化保留/^https?:\/\//i.test(...)的逻辑从 Ch. 1 延续保证Location头是绝对地址不会被相对解析到/s/:code之下双写顺序先落数据库事实源再写缓存读加速。即使缓存写入失败数据仍已在数据库下次resolve未命中缓存时会自动回源数据库补缓存见下一节。读路径link::resolve 缓存优先、回源补缓存直接替换原有的link::resolveworker.registerFunction(link::resolve, async (payload: { code: string }) { const cached await worker.trigger{ scope: string; key: string }, { url: string } | null({ function_id: state::get, payload: { scope: links, key: payload.code }, }); if (cached) { logger.info(link resolved, { code: payload.code, found: true }); return { url: cached.url }; } const { rows } await worker.trigger { db: string; sql: string; params: string[] }, { rows: Array{ url: string } } ({ function_id: database::query, payload: { db: DB, sql: SELECT url FROM links WHERE code ?, params: [payload.code] }, }); const url rows[0]?.url ?? null; if (url) { await worker.trigger({ function_id: state::set, payload: { scope: links, key: payload.code, value: { url } }, }); } logger.info(link resolved, { code: payload.code, found: !!url }); return { url }; });这是典型的Cache-Aside旁路缓存模式流程分三段查缓存state::get命中直接返回日志记录found: true完全不触碰数据库——这是短链接场景的高频路径绝大多数跳转应在此层完成缓存未命中回源调用database::query执行SELECT url FROM links WHERE code ?。注意返回值结构是{ rows: [...] }代码用rows[0]?.url ?? null兜底空结果短码不存在时返回null与 Ch. 1 行为保持一致回填缓存warm the cache数据库命中后立即state::set写回缓存让下一次读取直接命中。这是冷链接被首次访问后自动变热的关键一步。worker.trigger两处泛型参数也展示了 iii 的函数调用契约第一个类型参数是请求 payload 结构第二个是响应结构。database::query的输入是{ db, sql, params }输出是{ rows }。点击跟踪link::record_click有了数据库就可以为每次跳转记账。在link::resolve下方新增link::record_clickworker.registerFunction( link::record_click, async (payload: { code: string; clicked_at: string }) { await worker.trigger({ function_id: database::execute, payload: { db: DB, sql: INSERT INTO clicks (code, clicked_at) VALUES (?, ?), params: [payload.code, payload.clicked_at], }, }); return { recorded: true }; }, );它的职责单一往clicks表插一行。时间戳clicked_at由调用方http::redirect传入而不是在函数内部生成——这为下一章把该调用移入队列留好了接口消息里带上已生成的时间戳出队执行时依然是原始点击时刻而不是被处理的时刻。在重定向热路径上记录点击更新http::redirect在返回 302 之前直接触发link::record_clickworker.registerFunction(http::redirect, async (req) { const code req.path_params.code; const { url } await worker.trigger{ code: string }, { url: string | null }({ function_id: link::resolve, payload: { code }, }); if (!url) { return { status_code: 404, body: { error: link not found }, headers: { Content-Type: application/json }, }; } // This await is slow and unnecessary, well move it to a queue soon await worker.trigger({ function_id: link::record_click, payload: { code, clicked_at: new Date().toISOString() }, }); return { status_code: 302, headers: { Location: url } }; });流程是解析短码走缓存优先的link::resolve→ 未命中返回 404 → 命中则插入点击记录 → 返回 302 跳转。代码注释已经点明这里的取舍点击写入发生在重定向热路径上await会让每次跳转都等待一次数据库写完成。单机 SQLite 写一条记录通常很快但一旦数据库变慢或故障跳转就会被拖慢。这是本章有意保留的设计债——下一章Ch. 4: Make it durable会把这次写入挪到iii-queue的clicks队列上异步执行用TriggerAction.Enqueue({ queue: clicks })替代直接触发让重定向立即返回同时获得失败重试与死信队列兜底。端到端验证造链接、点三次、SQL 数点击数保存文件后tsx watch会自动重载创建一条链接并连续跟随三次curl -s -X POST http://127.0.0.1:3111/links \ -H Content-Type: application/json -d {url:https://iii.dev,code:iii} for n in $(seq 1 3); do curl -s -o /dev/null http://127.0.0.1:3111/s/iii; done现在持久化历史可以用 SQL 直接查询。iii trigger不仅能调用你自己注册的函数也能调用databaseworker 暴露的database::query参数以keyvalue形式传递iii trigger database::query dbprimary sqlSELECT COUNT(*) AS clicks FROM clicks WHERE code iii预期输出{ rows: [{ clicks: 3 }], row_count: 1 }clicks字段为 3说明三次跳转各产生了一行带时间戳的记录。至此可以验证持久化的完整闭环数据可查links表里有iii这条记录数据可数clicks表聚合出点击数 3数据跨重启存活重启 engine 后再查询数据依然在这正是引入databaseworker 的目的。一个实用技巧iii trigger的--help对函数 ID 同样生效例如运行iii trigger database::query --help可以查看database::query接受的全部参数不需要翻文档。小结与下一步本章的最终架构可以概括为一句分层结论databaseworkerSQLite是事实源links表保存链接clicks表累积每次点击全部落盘、可跨重启存活、可跑 SQLiii-state是读路径加速层link::resolve缓存优先未命中才回源数据库并回填缓存写路径双写link::create同时写库与写缓存点击记录由http::redirect在重定向返回前同步写入。遗留的瓶颈也很明确点击写入仍挂在重定向热路径上数据库慢则跳转慢。这正是下一章 Ch. 4: Make it durable 要解决的问题——把link::record_click的调用改投递到iii-queue的clicks队列让重定向立即返回并在后台完成写入。整套改造过程也体现了 iii 的组合式架构linkworker 只新增了对databaseworker 两个函数database::execute/database::query的调用既没有改写 engine也没有改动iii-state能力边界通过 worker 与函数天然划清。【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考