iii 实战(Ch. 3):用 SQLite 数据库为 Linkly 短链接服务构建持久化存储
2026/9/14 6:14:29 网站建设 项目流程

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)基础上,引入databaseworker(SQLite)作为持久化事实源(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,所有链接全部消失——服务看起来能工作,但没有持久性。

解决路径有两条,本教程选择了第二条:

  1. iii-state自持久化:它本身支持store_method: file_basedfile_path落盘;
  2. 引入专用databaseworker:提供持久化存储 + 完整的 SQL 查询能力,同时让iii-state继续充当读路径上的快速缓存。

选择专用数据库的理由很实际:链接只是数据的一种,点击事件(每次跳转都产生一条带时间戳的记录)同样需要落盘,而且你可能希望对它们跑聚合查询(比如"某个短码被点击了多少次")。一个 SQL 数据库同时满足"持久化"与"可查询"两个诉求,而iii-state只负责把高频读请求挡在内存里。

从系统架构看,worker 之间没有直接 import:linkworker 通过worker.trigger调用databaseworker 暴露的函数(database::executedatabase::query),所有调用都流经 engine。这与 Ch. 1 中link::create调用state::set的通信模型完全一致——在 iii 里,能力是以"函数"为接口、以 worker 为边界组合出来的。

添加 database worker 并配置连接池

在项目根目录(config.yaml所在目录)执行:

iii worker add database mkdir -p data

mkdir -p data是为了准备 SQLite 数据文件所在目录。iii worker add database会把databaseworker 写进config.yamlworkers列表,就像之前iii worker add iii-httpiii worker add iii-state一样。

接着把config.yamldatabaseworker 的配置调整为:

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)都通过这个逻辑名引用数据库primary
url数据库连接串;sqlite:./data/iii.db表示使用位于项目data/目录下的 SQLite 文件sqlite:./data/iii.db
pool.max连接池最大连接数(常规连接池语义)10
pool.acquire_timeout_ms获取连接的超时上限,超时即报错(常规连接池语义)5000
pool.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(): Promise<void> { 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会在保存文件时热重载 worker,ensureSchema()实际会在每次代码变更后重新执行,此时两条语句只会静默通过。

写路径: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 }; });

三个关键细节:

  1. SQL 参数化INSERT语句使用?占位符 +params数组传参,由databaseworker 负责绑定,避免 SQL 注入风险——这是所有database::execute/database::query调用的推荐写法;
  2. URL 规范化保留/^https?:\/\//i.test(...)的逻辑从 Ch. 1 延续,保证Location头是绝对地址,不会被相对解析到/s/:code之下;
  3. 双写顺序:先落数据库(事实源),再写缓存(读加速)。即使缓存写入失败,数据仍已在数据库;下次resolve未命中缓存时会自动回源数据库补缓存(见下一节)。

读路径:link::resolve 缓存优先、回源补缓存

直接替换原有的link::resolve

worker.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(旁路缓存)模式,流程分三段:

  1. 查缓存state::get命中直接返回,日志记录found: true,完全不触碰数据库——这是短链接场景的高频路径,绝大多数跳转应在此层完成;
  2. 缓存未命中回源:调用database::query执行SELECT url FROM links WHERE code = ?。注意返回值结构是{ rows: [...] },代码用rows[0]?.url ?? null兜底空结果(短码不存在时返回null,与 Ch. 1 行为保持一致);
  3. 回填缓存(warm the cache):数据库命中后立即state::set写回缓存,让下一次读取直接命中。这是"冷链接被首次访问后自动变热"的关键一步。

worker.trigger两处泛型参数也展示了 iii 的函数调用契约:第一个类型参数是请求 payload 结构,第二个是响应结构。database::query的输入是{ db, sql, params },输出是{ rows }

点击跟踪:link::record_click

有了数据库,就可以为每次跳转记账。在link::resolve下方新增link::record_click

worker.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_click

worker.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, we'll 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-queueclicks队列上异步执行,用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,参数以key=value形式传递:

iii trigger database::query db=primary sql="SELECT COUNT(*) AS clicks FROM clicks WHERE code = 'iii'"

预期输出:

{ "rows": [{ "clicks": 3 }], "row_count": 1 }

clicks字段为 3,说明三次跳转各产生了一行带时间戳的记录。至此可以验证持久化的完整闭环:

  1. 数据可查links表里有iii这条记录;
  2. 数据可数clicks表聚合出点击数 3;
  3. 数据跨重启存活:重启 engine 后再查询,数据依然在(这正是引入databaseworker 的目的)。

一个实用技巧:iii trigger--help对函数 ID 同样生效,例如运行iii trigger database::query --help可以查看database::query接受的全部参数,不需要翻文档。

小结与下一步

本章的最终架构可以概括为一句分层结论:

  • databaseworker(SQLite)是事实源links表保存链接,clicks表累积每次点击,全部落盘、可跨重启存活、可跑 SQL;
  • iii-state是读路径加速层link::resolve缓存优先,未命中才回源数据库并回填缓存;
  • 写路径双写link::create同时写库与写缓存;点击记录由http::redirect在重定向返回前同步写入。

遗留的瓶颈也很明确:点击写入仍挂在重定向热路径上,数据库慢则跳转慢。这正是下一章 Ch. 4: Make it durable 要解决的问题——把link::record_click的调用改投递到iii-queueclicks队列,让重定向立即返回,并在后台完成写入。整套改造过程也体现了 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询