
做个人投资工具时,很多人第一反应是先把数据存在浏览器里。
用 localStorage 保存一份 JSON,页面加载时读出来,用户修改后再写回去。这个方案足够快,也足够简单。对于一个 demo,它几乎是最短路径。
StockTracker 最早也可以这么做。它是一个本地优先的个人投资记录和 AI 投研工具,不需要账号,不默认上传云端。用户真正关心的是交易记录、持仓成本、分红、手续费、收益口径和 AI 分析历史能不能长期可靠地保存下来。
但越往后做,我越觉得:本地优先不等于把数据随便塞进浏览器存储。
如果一个工具开始承载真实交易账本、分析历史和可追溯的 Agent 调用链路,那它需要一个更像数据库的数据底座。
最后我选择了 SQLite。
localStorage 适合起步,不适合做账本
localStorage 最大的优点是简单。
它没有服务端,没有数据库连接,没有迁移脚本。前端直接读写:
读取:localStorage.getItem(...)写入:localStorage.setItem(...)
对设置项、主题、侧边栏展开状态、临时偏好来说,这很合适。
但投资账本不是这种数据。
一只股票可能包含:
- 基本信息
- 多笔买入和卖出
- 分红记录
- 手续费和税费
- 成本口径
- AI 分析结果
- 用户后续对话
- Agent 执行 Trace
这些数据不是简单的 UI 状态,而是一组会被不断读取、计算、复盘和解释的事实。
如果它只存在浏览器里,会遇到几个问题:
- 清浏览器数据可能丢失账本
- 换浏览器或换设备不好迁移
- 服务端 AI 分析无法自然读取同一份数据
- 对话历史、分析历史和调试 Trace 很难查询
- 数据结构变复杂后,单个 JSON blob 会越来越难维护
所以在 StockTracker 里,我后来把 localStorage 降级成兜底层,而不是主存储。
真正的主存储是本机 SQLite。
先比较几种持久化方案
在决定用 SQLite 之前,其实有几条路可以选。
第一种是继续使用 localStorage。它最简单,所有逻辑都在浏览器里,不需要服务端 API,也不需要数据库依赖。但它更像一个浏览器级别的键值存储,适合保存偏好和轻量状态,不适合长期承载真实账本。它没有查询能力,容量和可靠性都受浏览器环境影响,也很难和服务端 AI 分析共享同一份数据。
第二种是 IndexedDB。它比 localStorage 更像浏览器端数据库,容量更大,也支持索引和事务。对于纯前端离线应用,它是一个合理选择。但 StockTracker 的 AI 分析、Agent Runtime 和行情聚合都在服务端 API Route 里运行。如果核心数据只在 IndexedDB 中,服务端要读取这些数据就必须依赖前端先上传,数据边界会变得别扭。
第三种是直接保存 JSON 文件。这个方案很透明,用户也容易理解:我的数据就是一个文件。导入导出也很自然。但 JSON 文件缺少并发写入保护、索引、查询、局部更新和事务语义。等到系统开始保存 AI 分析历史、对话消息和 Agent Trace 时,单个 JSON 文件会越来越像一个手写数据库。
第四种是 MongoDB。它在 JavaScript / Node.js 生态里很常见,很多全栈项目会用它保存文档型数据。对于内容、配置、用户资料、事件记录这类结构变化较快的数据,MongoDB 的文档模型很舒服。
但它不是“前端数据库”。MongoDB 通常运行在服务端或云端,浏览器不能像访问 localStorage 或 IndexedDB 那样直接把它当成本地存储使用。对 StockTracker 这种默认本地优先、无需账号、数据不上传云端的个人工具来说,引入 MongoDB 意味着要额外维护一个数据库服务,或者依赖云数据库,这和产品边界不太一致。
第五种是上 PostgreSQL、MySQL 或其他云数据库。它们当然更强,适合多用户、权限系统、远程同步和复杂报表。但 StockTracker 的默认形态不是一个多人 SaaS,而是一个本地优先的个人工作台。为了一个单用户本地账本引入完整数据库服务,部署、备份、迁移和运维都会变重。
SQLite 处在中间位置。
它不像 localStorage 那样被锁在浏览器里,也不像 PostgreSQL 那样需要单独运行服务。它就是一个本地数据库文件,但又提供 SQL、事务、索引和结构化查询。
简单对比一下:
| 方案 | 优点 | 问题 | 更适合 |
|---|---|---|---|
localStorage | 极简单,前端直接读写 | 容量、可靠性、查询能力都有限 | UI 偏好、临时状态 |
| IndexedDB | 浏览器内数据库,支持索引 | 服务端不容易直接读取 | 纯前端离线应用 |
| JSON 文件 | 透明、易导入导出 | 缺少事务和查询能力 | 配置、备份、轻量数据 |
| MongoDB | 文档模型灵活,Node 生态常见 | 需要服务端或云数据库 | Web 后端、全栈应用 |
| PostgreSQL / MySQL | 强查询、多用户、生态成熟 | 部署和运维成本高 | SaaS、团队系统、复杂后台 |
| SQLite | 单文件、无需服务、支持 SQL 和事务 | 不适合高并发多写入 | 本地优先个人软件 |
对 StockTracker 来说,核心需求不是高并发,而是本地可靠、容易备份、能被服务端 AI 读取、能保存可查询历史。
这就是 SQLite 比其他方案更合适的原因。
SQLite 到底是什么
SQLite 不是一个“简化版 MySQL”。
它更准确的定位是:嵌入式关系数据库引擎。
PostgreSQL 或 MySQL 通常是独立服务。应用通过网络连接数据库服务,数据库服务负责管理进程、连接、权限、缓存和数据文件。
SQLite 不一样。
它没有独立服务进程。应用直接链接 SQLite 引擎,由这个引擎读写本地数据库文件:
应用进程 -> SQLite 库 -> 本地 .sqlite 文件
所以 SQLite 的部署形态非常轻:
没有数据库服务要启动没有端口要暴露没有连接池要维护没有账号体系要配置数据就是一个文件
但它不是简单的文件读写封装。SQLite 仍然提供关系数据库该有的很多能力:
- SQL 查询
- 表和索引
- 事务
- 约束
JOIN- 聚合查询
- JSON 函数
- 崩溃恢复
这也是它适合个人软件的原因:它保留了数据库的核心能力,但把部署和运维成本降到了很低。
在 StockTracker 里,Node.js 侧使用的是 better-sqlite3。它是同步 API,这一点一开始看起来有点反直觉,因为 Node.js 里大家习惯异步 I/O。
但对这个项目来说,同步 SQLite 反而更直接。
SQLite 读写的是本地文件,单次操作很短;StockTracker 也不是高并发写入服务。同步 API 让存储层代码更线性,事务和错误处理也更清楚。
如果未来变成多人在线服务,这个选择当然要重新评估。但在当前的本地优先单用户场景里,它是匹配的。
SQLite 的价值不是“小”,而是边界清楚
SQLite 经常被当成轻量数据库来介绍。
这当然没错。它不需要单独启动数据库服务,只是一个文件。StockTracker 默认数据库路径就是:
data/finance.sqlite
用户也可以通过环境变量改掉:
FINANCE_SQLITE_PATH=/absolute/path/to/finance.sqlite
但对我来说,SQLite 在这个项目里的价值不只是“小”。
更重要的是它的边界很清楚:
数据在本机文件可以备份路径可以指定应用可以自部署不需要账号体系不默认上传云端
这和 StockTracker 的产品形态是匹配的。
它不是多人协作后台,也不是券商交易终端,更不是需要高并发写入的 SaaS。它更像一个个人工作台:数据主要属于一个人,读多写少,最重要的是稳定、可迁移、可解释。
这种场景里,引入 PostgreSQL 或 MySQL 反而会让系统变重。
SQLite 刚好在中间:比浏览器存储可靠,比完整数据库服务轻。
为什么这些机制适合投资账本
投资账本有一个特点:写入频率不高,但每次写入都很重要。
用户不会像日志系统那样每秒写入几千条记录。更常见的是:
新增一笔买入修改一笔卖出记录一次分红更新一次费率配置保存一次 AI 分析结果追加几条聊天消息
这些写入量不大,但不能写一半。
SQLite 的事务能力在这里很合适。比如保存一次 AI 对话时,系统可能需要创建会话、写入用户消息、写入助手回复、保存 Agent Run。如果这些操作需要保持一致,就应该放在清楚的存储边界里,而不是散落在多个浏览器状态更新中。
SQLite 的索引也很实用。
投资组合本身可以是一个 payload,但 AI 历史和对话消息需要按时间、会话和用户查询。用索引把这些查询路径固定下来,比每次从一个巨大 JSON 里遍历要稳定得多。
单文件也是一个优势。
对个人工具来说,备份和迁移应该足够朴素:
复制 data/finance.sqlite迁移到新机器应用继续读取
这比导出一堆分散的浏览器状态更可控,也比维护一个远程数据库实例更轻。
还有一个容易被忽略的点:SQLite 支持 JSON 函数。
StockTracker 的 portfolios.payload 是 JSON 字符串,但恢复逻辑里仍然可以查询里面的股票数量:
json_array_length(json_extract(payload, '$.stocks')) > 0
这让系统可以在“文档式 payload”和“SQL 查询”之间取得一个折中。
核心账本不必一开始就完全关系化,但数据库仍然能在必要时理解其中一部分结构。
核心账本不一定要过早拆表
用了 SQLite 之后,很容易产生一种冲动:既然有了关系数据库,就应该把所有东西都拆成表。
但我现在不这么看。
对个人投资工具来说,核心组合数据在很多时候更像一份完整文档,而不是一组已经稳定的关系模型。
一次用户操作通常会改变整份组合状态:
- 新增一只股票
- 增加一笔交易
- 修改一条分红
- 更新费率配置
- 导入一份备份
应用层会重新计算持仓、成本、收益和展示状态。对这类数据来说,把整份组合作为一个版本化 payload 写入,反而更直接。
它带来几个好处:
- 前端状态模型和存储模型更接近
- 导入导出 JSON 备份很自然
- 财务计算逻辑集中在应用层,不被 SQL 查询分散
- 早期迭代时,领域模型变化不需要频繁写数据库迁移
当然,这不是说关系化不好。
如果未来需要跨股票统计交易、按时间筛选所有分红、做复杂报表,交易明细拆表会更合适。但在当前阶段,过早把所有领域对象拆成关系表,反而会把系统绑在一个还没稳定的 schema 上。
所以这里的取舍是:
核心账本:文档式 payload可查询历史:结构化表
这比“全 JSON”或“全关系表”都更适合现在的复杂度。
需要查询的历史,必须结构化
核心组合可以先用 JSON payload,但 AI 相关的历史数据不能都塞进同一个 blob。
原因很简单:这些数据天然需要查询和回放。
用户会需要:
- 按时间查看分析历史
- 按标的筛选个股分析
- 回到某个 AI 对话会话
- 展示某个会话里的所有消息
- 打开 Agent Trace 看当时调用了哪些 Skill
- 删除某段对话或清空历史
这些都不适合放进一个巨大的 JSON payload 里。
这里 SQLite 的价值就体现出来了:它让系统可以在不引入远程数据库的情况下,拥有真正的查询能力。
对一个本地 AI 投研工具来说,这类索引和查询能力很关键。
因为 AI 功能一旦从“问一次答一次”变成“可回看、可调试、可继续推进的工作流”,历史状态就必须从临时上下文变成可查询数据。
调用过程也应该被保存下来
做 AI Agent 时,最容易忽略的是 Trace。
很多系统只保存最终回答。用户看到一段分析文字,但系统自己并没有保留这段回答是怎么来的:
Planner 识别了什么意图?调用了哪些 Skill?每个 Skill 返回了什么?上下文里塞了多少数据?哪一步出错了?
这在 demo 阶段问题不大。但如果 Agent 要长期服务一个真实投资账本,就不能只保存最终答案。
我更倾向于把一次 Agent 运行看成一条可以回看的记录。它至少应该保存当时的意图、执行计划、调用过的工具、关键结果、上下文规模和错误信息。
这样做之后,Agent Trace 就不再只是开发时打印在控制台里的日志,而是可以被用户或开发者回看的系统资产。
当一段 AI 分析看起来不对时,我可以追问:
- 是 Planner 识别错了?
- 是 Skill 结果错了?
- 是行情数据过期?
- 是上下文太少?
- 还是模型在最后组织语言时推断过头?
没有持久化 Trace,这些问题都只能靠猜。
有了 SQLite,它们至少有机会被复盘。
本地优先也要考虑恢复路径
本地优先还有一个细节:浏览器状态可能丢。
StockTracker 里,前端会生成一个本机 deviceId,并把 SQLite 里的用户空间命名成:
local:<deviceId>
正常情况下,浏览器通过这个 ID 去读写自己的本地组合数据。
但如果浏览器的 localStorage 丢了,新的 deviceId 会变出来。此时 SQLite 文件里可能还存在旧的本地用户数据。
所以数据库层需要有恢复逻辑:当当前浏览器生成的新身份没有数据时,可以尝试从本机 SQLite 文件里找回最近的非空本地账本。
如果找到了,就把它作为恢复数据返回,并让前端把浏览器里的本机身份对齐到旧数据。
这个设计不复杂,但它很实用。
它承认了一个现实:本地应用不只要考虑“正常读写”,还要考虑用户清理浏览器、换浏览器、迁移文件之后如何找回数据。
如果只有 localStorage,这条恢复路径基本不存在。
API 层要隐藏存储细节
StockTracker 的前端状态层并不直接碰 SQLite。
它通过服务端 API 读写数据,API 再去调用 SQLite store。
这样做看起来多了一层,但边界更清楚:
前端:管理 UI 状态和乐观更新API:处理请求、校验 userId、统一错误响应SQLite store:负责持久化、恢复、查询
这也避免了一个常见问题:把数据库操作散落在各个组件和业务函数里。
一旦存储层需要调整,比如增加表、迁移字段、处理坏数据、替换路径,改动可以集中在存储模块和少数 API 入口里。
对一个本地优先应用来说,这种边界比“少写一层代码”更重要。
SQLite 不是为了显得工程化
引入 SQLite 不是为了让项目看起来更像“正经后端”。
它解决的是几个很具体的问题:
- 真实投资账本需要比浏览器存储更可靠的落点
- 数据文件需要能被备份和迁移
- AI 历史、聊天记录和 Agent Trace 需要查询
- 本地优先不应该等于数据不可恢复
- 服务端 AI 分析需要读到同一份本地数据
也正因为如此,我没有把所有东西都关系化。
组合账本仍然用 JSON payload,保留领域模型迭代的弹性;AI 历史和运行记录则拆成表,保证查询和回放能力。
这才是我觉得 SQLite 在 StockTracker 里最合适的位置:
它不是临时缓存。它也不是复杂后台数据库的替代品。它是个人软件里那本可以长期保存、可以备份、可以被系统反复读取和解释的本地账本。
当一个个人工具开始承载真实数据时,最重要的不是立刻上云,也不是把架构做大。
很多时候,先把本机那一个 SQLite 文件设计好,系统就已经稳了一大半。