iii Linkly 教程:用 database Worker 与 SQLite 让短链接服务和点击数据持久化
2026/9/14 18:11:12 网站建设 项目流程

iii Linkly 教程:用 database Worker 与 SQLite 让短链接服务和点击数据持久化

【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii

本篇是 iii 框架 Linkly 短链接系列教程第三章的完整技术解析:在第一章把链接存进内存版state的基础上,引入内置的databaseworker(SQLite)作为持久化存储,让每条短链接和每次点击都落成可 SQL 查询的数据行,同时保留state作为数据库前的快速读缓存。读完并跟完本指南,你将掌握在 iii 引擎中通过iii worker add热加入 worker、通过database::execute/database::query函数执行 SQL、实现「写穿透 + 读缓存」存储架构,以及用iii trigger命令行直接对生产数据跑查询的完整流程。

背景:为什么需要数据库

Linkly 的链接数据在第一章被存进了state(内置 KV store worker),且当时配置为内存模式(in-memory)。这意味着引擎一重启,所有链接和解析记录全部丢失。本章的目标是:

  • databaseworker(SQLite)保存持久记录:每条链接一行,且每当有人访问短码时,再追加一条带时间戳的点击记录;
  • state保留在链路中,但角色降级为数据库前的快速读缓存(hot cache)。

state本身其实也支持独立持久化:把其配置的store_method设为file_based并提供file_path即可。从源码看,内置 KV store 在 kv.rs 中读取store_method配置项,取值为in_memoryfile_based,遇到未知值会记录警告并回退到in_memory。本章刻意选择专门的databaseworker,因为它在持久化之上还额外提供了 SQL 查询能力——这对后面统计点击量这类分析型需求是刚需。

添加 database worker

缓存很快,但你还需要一份能对之执行 SQL 的持久记录:每条链接,以及每次有人跟随短码时的带时间戳行。命令如下:

iii worker add database mkdir -p data

由于引擎从第一章起就在运行,databaseworker 一经添加就会被立即启动,并且其配置文件自动生成在./config/database.yaml(配置机制参见 Configuration)。打开这个文件:生成的默认配置已经指向./data/iii.db的 SQLite 数据库,无需修改。注意databaseworker 在首次运行时会自动创建./data/iii.db。另外,databaseworker 支持的不止 SQLite 一种后端,其他支持的数据库类型可查阅databaseworker 的官方文档。

生成的配置内容如下:

# config/database.yaml id: database name: Database value: databases: primary: pool: acquire_timeout_ms: 5000 idle_timeout_ms: 30000 max: 10 url: sqlite:./data/iii.db

参数含义:

参数说明
databases.primary-具名数据库实例,代码中通过名字primary引用
pool.max10连接池最大连接数
pool.acquire_timeout_ms5000从池中获取连接的超时时间(毫秒)
pool.idle_timeout_ms30000空闲连接回收超时(毫秒)
urlsqlite:./data/iii.db数据库连接 URL,sqlite:前缀指定 SQLite 后端,路径相对引擎工作目录

schema 将由我们的 worker 代码自己来定义。下面分步骤改造link/src/index.ts

定义数据库引用

先在link/src/index.ts顶部附近添加DB常量:

import { registerWorker } from "iii-sdk"; import { Logger } from "@iii-dev/helpers/observability"; const DB = "primary"; // Matches db name in config/database.yaml

DB的值必须与config/database.yamldatabases下的具名键一致,本例即primary

让链接存储持久化

接下来改造既有的link::createlink::resolve两个函数,使它们读写新数据库,同时把stateworker 当作热缓存。

建表(Create a schema)

link/src/index.ts末尾添加ensureSchema()函数,在 worker 启动时创建两张表。databaseworker 通过database::execute函数接收 SQL:

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:短链接记录,code(短码,主键)、url(目标地址)、created_at(创建时间);
  • clicks:点击流水,id自增主键、code对应短码、clicked_at点击时间戳。每次点击追加一行,天然形成可聚合的历史。

数据库写入(write-through)

改造link::create:既写数据库(持久记录),又写state(热缓存),即典型的 write-through 策略:

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(?占位符 +params数组)而非字符串拼接,避免 SQL 注入;
  • 先落库再写缓存:即使缓存写入失败,数据仍持久存在,缓存只是加速层;
  • 时间戳统一用new Date().toISOString()的 ISO 8601 格式存入TEXT列,便于排序与范围查询。

数据库读取(read-through 缓存)

改造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 }; });

读路径的行为矩阵:

场景走查缓存查数据库结果
缓存命中直接返回,最快路径
缓存未命中、库中存在回填缓存后返回,下次变快
缓存未命中、库中不存在返回{ url: null }不回填,避免缓存穿透放大

注意worker.trigger的泛型参数声明了请求 payload 与响应类型:database::query返回{ rows: [...] }结构的类型化响应,让 TypeScript 在编译期就能检查字段。

添加点击统计

既然有了数据库,就可以开始记录点击了。新增link::record_click函数,把点击写入数据库(把它加在link::resolve下方):

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 }; }, );

http::redirect调用link::record_click

更新http::redirect,在返回 302 重定向之前直接触发点击记录

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 Trigger is slow because it waits on link::record_click's completion, we'll move its work 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 } }; });

注意:点击的数据库写入给每次重定向都增加了延迟——await会等待link::record_click完成才返回 302。下一章会把这次写入移到持久队列上,既消除延迟,又获得数据库故障时的恢复能力。这也是本章刻意保留的「钩子」:先用最直白的方式跑通端到端数据流,再做异步化优化。

验证点击统计

保存文件(引擎热加载 worker 源码),创建一条链接,并模拟点击三次:

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可以按db=... sql="..."的形式把参数传给任意已注册的函数:

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

预期输出:

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

row_count为 1、clicks为 3,说明三次点击全部落库。另外有个小技巧:--help对函数 id 同样有效,运行iii trigger database::query --help即可查看database::query接受哪些参数。

小结与架构要点

本章结束后,Linkly 的数据架构是:

  • 数据库是事实来源(source of truth)links表保存全部短链接,clicks表保存每次重定向的带时间戳行,引擎重启后数据仍在;
  • state是读加速层:命中即返回,未命中回源数据库并回填;
  • 写入在热路径上http::redirect同步等待点击落库,慢的数据库写入会拖慢重定向。

这正是下一章 Ch. 4: Make it durable 要解决的问题——把点击写入移入持久队列,让重定向保持快速,并顺带获得数据库失败时的重试与恢复语义。配合 CLI(见 CLI Reference 中iii worker add一节)与引擎内置 KV/Queue store 的实现(engine/src/builtins/kv.rs),你可以进一步查看存储参数在引擎侧的解析逻辑。

【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询