尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Go + Gin + SQLite 个人记账 API 第5天:数据模型重构与并发写入优化
这是我30天自驱项目的第五天记录。项目目标很简单从零开始用Go Gin SQLite搭一个本地优先的个人记账 API 服务并且每天留下一份能在第二天接着用的进度快照。Day 05 这个节点有意思的地方在于它不是第 1 天那种万物新奇的热血时刻也不是最后一周的交付冲刺而是最容易养成习惯或者彻底放弃的分岔口。今天早上我打开电脑第一反应不是写代码而是把前四天做的东西摊开复盘了一遍然后才动手改数据模型、查一个偶发超时问题、补当日验收脚本。这篇文章记录的就是这个过程里我认为最值得分享的三件事数据结构为什么要重做、SQLite 并发写入的坑长什么样、以及当日验收这个环节怎么落地。如果你也在做连续打卡类的技术项目或者你正准备用 SQLite 搭一个小服务并担心它撑不住这篇记录大概率能帮到你。1. 第5天为什么要先做节奏复盘而不是继续闷头写码1.1 30天打卡项目的第5天魔咒连续做 30 天记录型项目最大的敌人不是技术难度而是节奏塌方。我观察过很多类似的打卡项目也踩过同款坑第 1、2 天靠新鲜感驱动一天能写十几个小时第 3 天开始进入什么都会一点、什么都不精的烦躁期第 4 天往往忍不住推翻重写到了第 5 天新鲜感耗尽剩下来的全是需要靠纪律去啃的硬骨头。这天如果我还像前两天那样闷头加功能最后大概率会变成为了打卡而打卡——写了很多看似在推进的代码实际上只是在原地堆积。所以 Day 05 我把上午的第一个小时专门留给了复盘不动代码只看三样东西已经实现的功能清单、数据库表结构、以及接口在真实调用下的表现。复盘得到的最重要结论是功能面看起来不算少但底层的数据模型已经撑不住下一步了。这个发现如果不做复盘可能要等到写预算统计功能时才会以更痛苦的方式爆发。1.2 前4天进度回看功能做了不少但数据结构撑不住了前四天我完成的大致内容基础的项目骨架、Gin 路由初始化、SQLite 连接封装、一个能用的记账流水增删改查接口、一个最简单的分类列表接口以及一个粗糙的月度汇总查询。汇总下来大概是这样的进度日期当天目标实际产出遗留问题Day 01搭骨架跑通第一个 APIGin 项目初始化/health 接口可用无Day 02设计数据库完成流水表 CRUDtransactions 表 categories 表流水接口可跑表结构偏简单note 字段混用Day 03月度汇总查询按月份 group by 分类的汇总接口没有分账本概念汇总口径埋雷Day 04分类管理接口分类的增删改查与流水表的关联设计不合理Day 05数据模型改版 并发问题排查见本文见本文这个表一列出来问题就很明显了我最初以为个人记账嘛一张流水表加一张分类表就够了。但真正开始考虑多账户、预算、账本这些需求时旧的 transactions 表结构就像是给单人单车修的路突然要跑卡车——不是不能跑但一定会翻。2. 数据模型改版实录从一张流水表打天下到分账本分类别2.1 原设计为什么撑不住最早我拍脑袋设计了两张表核心是 transactions 和 categories它们的结构大致如下CREATE TABLE transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, amount DECIMAL(10,2) NOT NULL, note TEXT DEFAULT , created_at DATETIME NOT NULL, FOREIGN KEY (category_id) REFERENCES categories(id) ); CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL DEFAULT expense, -- expense / income created_at DATETIME NOT NULL );这个设计在第 4 天之前一直没让我觉得难受因为它非常直观一条流水一个分类一笔金额。但当我认真想后续要加的功能时三个问题立刻跳出来了。第一没有账户概念。现实中的记账一定涉及钱从哪个账户出去现金、银行卡、信用卡。我原来那张表完全不知道钱在哪进哪出所谓的余额统计根本无从谈起。第二没有账本概念。个人记账和家庭记账混在一起或者日常开支和旅行费用混在一起在原有的表结构里只能靠 note 字段写备注查询口径一团乱麻。第三分类设计过于简单没有层级没有图标也没有和账户之间建立真正的约束。这里我想说一个经验小项目的表结构设计宁可一开始多想一步也不要急着先跑起来。先跑起来再说这句话放在原型验证阶段没错但一旦数据开始积累改表结构的成本会指数上升。第 2 天我急着把 CRUD 跑通省掉了这一步思考第 5 天就不得不花一整块时间还债。2.2 迁移顺序不能乱确定要改结构之后我没有直接在原表上 ALTER TABLE而是重新设计了一套分账本、分账户、分分类的方案。新表大体如下CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT DEFAULT , created_at DATETIME NOT NULL ); CREATE TABLE accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT cash, initial_balance DECIMAL(12,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, FOREIGN KEY (book_id) REFERENCES books(id) ); CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER, -- 支持二级分类 name TEXT NOT NULL, type TEXT NOT NULL DEFAULT expense, icon TEXT DEFAULT , created_at DATETIME NOT NULL, FOREIGN KEY (parent_id) REFERENCES categories(id) ); CREATE TABLE transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, account_id INTEGER NOT NULL, category_id INTEGER, amount DECIMAL(12,2) NOT NULL, note TEXT DEFAULT , created_at DATETIME NOT NULL, FOREIGN KEY (book_id) REFERENCES books(id), FOREIGN KEY (account_id) REFERENCES accounts(id), FOREIGN KEY (category_id) REFERENCES categories(id) );表结构只是第一步。真正的坑在迁移顺序。直接在一张已经被使用了一百多条数据的表上加外键列SQLite 默认是干不动的——它不像 MySQL 那样支持完整的 ALTER TABLE ADD CONSTRAINT。我这次的完整顺序是这样的备份。用 sqlite3 自带的.backup命令把整个库文件备份到独立路径这是所有操作的前提忘掉这一步的人后来都在哭着找数据。先建新表 books 和 accounts并把一个默认账本和默认账户插进去拿到新的 ID。给旧 transactions 表先加上 book_id 和 account_id 两个整数列这一步单纯的 ADD COLUMN 是可以做到的。把默认账本 ID 和账户 ID 回填到旧数据的这两个新列上。因为旧表已经没有存在价值正确的做法不是继续在旧表上打补丁而是用CREATE TABLE new_transactions (...)建新结构然后INSERT INTO new_transactions ... SELECT ... FROM transactions搬数据。删旧表把 new_transactions 重命名为 transactions手动把外键、索引重建一遍最后执行PRAGMA foreign_key_check。这套顺序看着简单但第 5 步和第 6 步之间藏着一个容易忽略的细节SQLite 默认不会自动创建外键索引。如果你不在 account_id、book_id 上补CREATE INDEX后面做账本维度筛选时会全表扫描数据量一大必卡。实际执行时我还犯了个小错在迁移脚本里先删了旧表才去跑PRAGMA foreign_key_checkSQLite 直接报了约束错误不得不从备份恢复重来。所以顺序里那句先检查再删表是我用一次恢复备份换来的教训。2.3 旧数据字段映射的坑这次迁移里最隐蔽的问题不是主外键而是 note 字段。第 2、3 天写接口的时候我很随意地把对方账户支付方式关联单据这些信息全部塞进了 note 的 JSON 字符串里比如一条流水可能是{payee:小区超市,payment:wx,ref:ticket-001}。这在当时能跑就行的思路下非常顺手因为它不需要改表。可一旦进入基于字段的统计和筛选JSON 内嵌的做法就会让 SQL 变得极其拧巴。新模型下我把真正需要参与筛选的字段拆成了独立列note 只保留纯文本备注。迁移时用 SQLite 内置的 JSON 函数做了数据清洗INSERT INTO new_transactions (book_id, account_id, category_id, amount, note, created_at) SELECT 1, 1, category_id, amount, json_extract(note, $.payee) || || COALESCE(json_extract(note, $.ref), ), created_at FROM old_transactions;这段 SQL 如果把 json_extract 返回 NULL 的字段直接拼接结果是 NULL会导致整行 note 变为 NULL需要在搬完数据后做一个 NOT NULL 校验。这也是为什么我拿到新表后的第一个检查动作不是统计行数而是SELECT COUNT(*) FROM new_transactions WHERE note IS NULL;数据迁移这种事最怕的不是麻烦而是看起来搬成功了。每次迁移后必须至少做两层验证一层是行数对得上另一层是抽几条真实业务数据肉眼对比字段内容。两层都过了才敢把旧表删掉。3. 并发写入踩坑SQLITE 锁等待没有你以为的那么简单3.1 症状账本列表接口偶发超时数据模型改版完之后我顺手把账本列表接口接上去自己按了几个请求一切正常。于是开始跑压测脚本模拟真实使用节奏大概 20 个并发请求、持续压了半分钟就发现了一个偶发问题。接口本身大多数时候返回都在 20 毫秒以内但每过十几秒就会出现一次 600 到 1300 毫秒的尖峰。单独 curl 一次永远复现不了必须得是足够多的并发请求砸过来才出现。进一步看服务端日志里面混着一条熟悉又陌生的报错SQLITE_BUSY: database is locked。最初我很难理解个人记账 API不过是每小时写几十条流水怎么会被抢锁这里就涉及 SQLite 的一个核心机制它在同一时刻只允许一个写事务真正落盘。哪怕你的服务是单实例、单数据库文件只要程序里存在两个并发的写请求就有可能在某个瞬间撞上锁。3.2 复现路径用 10 个线程同时写坏事了为了彻底确认我写了一段极简的复现程序一次性开 10 个 goroutine每个循环往事务表里插入 100 条记录不加任何节流。func TestConcurrentWrite(t *testing.T) { db, err : sql.Open(sqlite3, file:fin.db?_journal_modeWAL_busy_timeout3000) if err ! nil { t.Fatal(err) } defer db.Close() var wg sync.WaitGroup for i : 0; i 10; i { wg.Add(1) go func(n int) { defer wg.Done() for j : 0; j 100; j { _, err : db.Exec( INSERT INTO transactions(book_id, account_id, amount, note, created_at) VALUES(1,1,?,?,datetime(now)), float64(j)float64(n), fmt.Sprintf(worker-%d-%d, n, j)) if err ! nil { t.Logf(worker %d error: %v, n, err) return } } }(i) } wg.Wait() }这个测试跑到一半日志里就开始出现database is locked。而且错误出现得非常随机有时候是 worker 7 报错有时候是 worker 3 报错没有固定规律。这让我意识到锁问题不能用运气来对待必须有确定性的策略。这里多说一句个人项目和服务端专业项目之间最大的差别不是功能复杂度而是你愿不愿意认真对待这种偶发但不致命的问题。如果你只是自己用遇到database is locked重试一次就行了但这既然是 30 天项目我希望 Day 5 解决完这个坑之后后面再也不用回头看它。3.3 解法对比WAL 模式、busy_timeout、写队列针对 SQLite 并发写的问题网上能搜到的方案基本就三类。我把它们拉了一个表逐一对比方案原理优点缺点我的结论WAL 模式写入追加到 -wal 文件读不走原库文件读写并发大幅改善读不会被写阻塞仍只有一个写者多出 wal/shm 文件备份要一并处理启用作为基础配置busy_timeout锁冲突时等待指定毫秒而非立刻报错配置简单一条连接串参数搞定只是等待锁释放长事务下仍可能超时启用设 5 秒业务层写队列所有写请求排队单线程处理从根上消除写锁竞争需要自己实现队列且要防堆积最终采用先说 WAL 模式。它解决的是读写互相阻塞这个大问题。默认的 rollback journal 模式里写事务进行时所有读操作都会拿不到数据文件WAL 模式则是写操作写到追加的日志文件里读操作照常读主库文件两者不再抢同一个资源。开启方式很简单PRAGMA journal_modeWAL;或者写在连接串里file:fin.db?_journal_modeWAL_busy_timeout5000busy_timeout则是告诉 SQLite遇到锁别急着报错先等我个 5 秒。这个配置能让偶发的短暂锁冲突悄悄过去。但这两个方案都没有解决一个本质问题多个写事务仍然要抢占同一个写锁。只是从立刻失败变成了等一会儿再失败。真正一劳永逸的做法是在应用层把写操作收拢成单线程。在我这个 Go 项目里我用一个带缓冲的 channel 做写入队列所有写操作提交到一个 goroutine 里顺序执行而读操作继续走连接池、享受 WAL 带来的并发读能力。这样既不会把整个数据库访问串行化到让读接口也变慢又保证了写操作绝不互相打架。写队列的细节值得展开一下因为它并不是简单地把 db 操作包在一个函数里就完事。我的做法是定义写请求结构体包含待执行的 SQL、参数、以及返回结果的 channel单独起一个 goroutine循环从 channel 里取任务以普通方式执行 SQL把结果放到返回 channel业务层暴露的写接口实际是把请求塞进 channel 并等待返回。这套模式的核心收益是**写任务在数据库这一层的并发度降到了 1但业务层响应依然同步、可预测。**代价是写吞吐存在上限但对个人记账场景来说单线程写入每秒执行上百条 SQL 绰绰有余。4. 被忽略的当日验收环节我把接口测试写成了半个功能4.1 手工 curl 为什么靠不住排查完并发问题今天的核心代码工作差不多结束了。这时候正常人可能会想收工去写明天的计划。但我给自己定了一个规矩从 Day 05 开始执行每天的代码改动必须配一份当日的验收脚本手工 curl 不算验收。理由是手工 curl 最大的问题是测过不等于测对。你随手敲一条请求看到 200 就以为接口没问题但可能你根本没有带上真实业务数据等到第二天、第三天接口被别的模块调用时才发现当年那条数据早就被你 INSERT 成了非法状态。接口这种东西真正需要的不是每次改完都人工点一遍而是一个可以反复执行的自动化验证集合。这也是我把 验收脚本 当做一个功能来写的原因而不是把这个环节当成额外负担敷衍过去。4.2 当日验收清单Day 05 版本这天的验收脚本我用了 Bash curl 临时实现覆盖五类检查建账本、开账户、记流水、按账本查流水、以及清理测试数据。核心代码如下#!/usr/bin/env bash set -euo pipefail BASEhttp://127.0.0.1:8080 # 建账本 BOOK_ID$(curl -s -X POST $BASE/api/books -H Content-Type: application/json \ -d {name:验收账本,description:Day05 daily check} | jq -r .id) # 建账户 ACCOUNT_ID$(curl -s -X POST $BASE/api/books/$BOOK_ID/accounts \ -H Content-Type: application/json \ -d {name:测试卡,type:bank,initial_balance:0} | jq -r .id) # 记三笔流水 for i in 1 2 3; do curl -s -X POST $BASE/api/books/$BOOK_ID/transactions \ -H Content-Type: application/json \ -d {\account_id\:$ACCOUNT_ID,\amount\:$i.0,\note\:\验收流水 $i\} /dev/null done # 查询并校验金额 SUM$(curl -s $BASE/api/books/$BOOK_ID/summary?account_id$ACCOUNT_ID | jq -r .total_expense) if [ $SUM ! 6.0 ]; then echo FAIL: expected 6.0, got $SUM exit 1 fi echo PASS: Day05 acceptance整个脚本跑一次不到三秒但它保证了我这次对数据模型、并发配置的改动没有破坏基础业务链。脚本里我用了set -euo pipefail这样任何一个 curl 或者 jq 出错脚本都会立刻失败而不是在错误状态里继续往下跑。这里有一个很细节的点值得提验收脚本里的测试数据必须清理。我最初的做法是在脚本末尾直接删掉测试账本但连续跑了几次之后发现只要某一次脚本执行中途失败测试数据就会残留导致下次验收金额对不上。后来我改了策略在创建测试账本、账户的时候就给它们打上特殊标识比如名称前缀__accept__然后在脚本开头先清理所有前缀匹配的旧数据再执行正式验证。这样即使上次跑挂了这一次也会从干净状态开始。4.3 第 5 天留档今日 commit 的结构与注释习惯验收脚本跑通后最后一步是留档。30 天项目最怕的不是写不出代码而是第二天打开代码仓库看着前一天的内容想不起来当时为什么那么写。所以今天的 commit 我刻意分成了三个逻辑独立的提交而不是一把梭全塞进一次提交feat: redesign table schema for books/accounts/categories只包含数据库迁移文件、索引和初始化脚本。fix: handle sqlite concurrent write with serial write queue只包含并发写入队列的代码和相关测试。test: add daily acceptance script for Day 05只包含验收脚本和使用说明。这三个提交的粒度刚好对应今天三个核心任务。对于存储和迁移这类改动我在 commit message 里会把迁移脚本和执行顺序写清楚因为这个信息只有当天的人知道等过了 10 天再回头看哪怕是我自己也保不准会忘掉先备份再删旧表这个关键动作。回看这一天的折腾最大的变化不在代码量上而在工作方法上。Day 05 之前我是想到哪写到哪Day 05 之后我给自己立了一条规矩**任何可能影响数据结构的改动都必须先写迁移脚本再写接口代码最后配验收脚本。**顺序反过来的话一旦接口已经能写坏数据后面再修结构就得多擦一遍屁股。明天 Day 06 的计划也顺手列好了给 transactions 接口补上分页参数、把汇总查询从按月扩展到自定义时间范围然后继续把验收脚本覆盖到分类管理接口。写到这里我反而有点期待明天的记录毕竟数据模型这块最硬的骨头已经啃完了。
RELATED

相关推荐

WordPress模板开发实战:从主循环到上线部署全流程

WordPress模板开发实战:从主循环到上线部署全流程

WordPress模板开发这两年问我的人特别多,尤其是那些用现成主题改到崩溃、最后决定自己动手的朋友。其实模板设计这件事,核心并不在“写代码”本身,而在于你懂不懂WordPress的页面渲染逻辑——它跟你写一个普通HTML网站是完全不同的思路。这篇…

📅 2026/10/5 10:44:01
智慧园区安环能一体化AI大模型平台:架构设计与落地避坑

智慧园区安环能一体化AI大模型平台:架构设计与落地避坑

简介:智慧园区安环能一体化人工智能大模型数字化平台规划设计方案是一份面向园区管理者、信息化规划人员与方案设计人员的演示文稿资料,针对当前园区普遍存在的信息孤岛、环保监管不足、安全隐患频发和能耗管理粗放等痛点,提出了“一网一云一…

📅 2026/10/5 10:44:01
2026年10月4日充电桩行业日报:充电量暴涨60%却还排队3小时,下半场拼的不是桩,是调度 | 慧知开源充电桩平台

2026年10月4日充电桩行业日报:充电量暴涨60%却还排队3小时,下半场拼的不是桩,是调度 | 慧知开源充电桩平台

开头:先看两条"打脸"的消息 兄弟们,今天先说一件特别拧巴的事。 一边是数据暴涨:国家能源局10月2日发布,全国6.27万根高速充电桩,国庆首日充电量2804.69万千瓦时,同比暴涨60.4%,创下节…

📅 2026/10/5 10:39:01
MORE NEWS

更多资讯

📰

轻型AI中台落地实践:消除重复录入与财务对账难题

前阵子帮一家成长型公司落地了一套“轻型AI中台”,目标是解决两个让他们头疼很久的问题:业务数据重复录入、财务月度对账困难。项目周期不到两个月,用的全是开源和轻量工具,没有搞那种大而全的企业级中台。这篇文章就把整个项目的…

📰

WorkBuddy接入Ollama本地模型:从无输出到70 tok/s的调优实战

大概半年前,我决定把 WorkBuddy 从云端 API 迁到本地大模型上。原因很简单:不想每写一段代码都提心吊胆盯着额度,也想试试完全离线跑 AI 编程助手是什么体验。结果第一天差点把我劝退——模型明明加载成功了,WorkBuddy 聊天窗口转…

📰

ChatGLM LoRA微调实战:从环境搭建到避坑验证

简介:本资源是一套面向AI工程师与NLP方向学习者的ChatGLM大模型微调实战工程包,聚焦LoRA、PEFT、量化训练等主流轻量微调技术在文本生成、语音识别、图像分类等多任务场景的落地实践。压缩包共148个文件,涵盖58个Python训练/推理脚本、36个Ju…

📰

AI辅助论文写作全流程实战:从需求说明书到初稿打磨

用AI辅助写论文这件事,我算是个老用户了——各大模型刚面向公众那阵,我就开始把它塞进自己改论文的工作流里。半年多下来,我前前后后帮人改了二十来篇稿子,硕士毕业论文、期刊投稿、本科课程论文都碰过,于是踩出了一些…

📰

生产级AI Agent工程化:七要素与七个决策点

AI Agent这词在技术圈火了大半年,真正上手做过生产级 Agent 的人其实没有想象中那么多。我接触过不少团队、也评测过不少开源项目,大家的状态基本一样:demo 跑得飞快,一聊到工程实现就开始卡壳。所谓工程实现,不是把 L…

📰

SAP委外采购订单创建:BAPI_PO_CREATE1核心参数与组件传参实战

做SAP集成的年头久了,你会发现一个规律:MM模块里但凡要用接口创建业务单据,采购订单永远排得到前三名,而采购订单里最容易被问到的场景就是委外加工。前阵子正好帮一个项目把“手工录入委外PO”改成“系统间接口自动创建”&#x…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬