news 2026/10/26 1:02:52

单机题库管理系统实战:SQLite建模、组卷导出与备份避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
单机题库管理系统实战:SQLite建模、组卷导出与备份避坑

简介:万维题库管理系统单机版是一套面向教育机构、教师及个人学习者的本地化题库管理工具,适合无稳定网络或重视数据隐私的使用场景,无需联网即可独立运行,可完成试题创建、编辑、分类存储与检索,并支持导入导出、随机组卷、答案解析和难度设置,服务于日常教学、考试准备与自我测试。资源包共11个文件,约14.21MB,以exe安装程序为主,辅以dll、lib等运行库,ini、ins等配置文件及htm说明文档,构成较完整的单机安装与查阅资料。已有2257人学习下载,说明其在题库整理与备考场景中具备一定参考价值。读者可通过本地部署获得独立的题库环境,结合说明文档了解分类管理、试题格式和组卷规则,并自行做好数据备份,从而提升题目维护、试卷生成与自测复习的效率。

1. 单机题库管理系统到底解决什么问题:一间断网机房里的两小时

断网的机房里,四十台机器只有一台装着出卷软件,负责教务的人要在两小时内拼出三套难度不同的卷子——这种场景下,任何需要连服务器的题库管理方案都是负担。单机版题库管理系统就是为这种环境准备的:数据落在本地一个库文件里,不依赖网络、不依赖账号体系,装完就能录题、组卷、导出试卷。它的价值不在功能列表有多长,而在于把题库管理这件事压缩到一台机器上完成,单机部署意味着运维成本接近零,同时也意味着所有数据风险都集中在那一个文件上。适合谁?教研组里固定出卷的一两个人、培训机构的教务岗、需要离线保管题目的机房管理员。下面按我拆包的顺序讲:文件怎么摆、表怎么设计、题怎么导、卷怎么抽、哪里最容易翻车。

2. 拆开安装包:文件构成、运行依赖与第一次启动

拿到压缩包的第一件事不是双击 exe,而是先看清楚它把数据放在哪。单机版软件最容易出事的地方从来不是功能,而是「数据文件在哪、被谁锁着、备份有没有备到点子上」。这一步花十分钟搞清楚,后面能省掉很多返工。

2.1 目录结构与各组件的职责

常见的单机题库包,解压后大致是这么几块,具体名字各家不同,但职责划分基本一致:

目录/文件职责需要关注什么
主程序目录可执行文件与依赖库是否缺运行库、路径是否含中文
data / db 目录题库数据库文件体积最大、扩展名 .db/.sqlite/.mdb
template 目录试卷导出模板改排版只动这里,别动程序
export / output导出结果定期清理,容易撑爆磁盘
log 目录运行日志排错第一现场
config / ini连接串、路径配置迁移机器时必须同步改

判断哪个文件是数据库,最快的办法是按体积排序,再验证文件头。Linux 或 macOS 下:

# 按体积倒序列出安装目录,最大的几个文件里通常就有数据库 du -ah /opt/tiku | sort -rh | head -20 # 验证文件类型:SQLite 的前 16 字节固定是 "SQLite format 3" xxd -l 16 /opt/tiku/data/tiku.db

Windows 下用 PowerShell 的Get-ChildItem -Recurse | Sort-Object Length -Descending | Select-Object -First 20做同样的事,文件头可以用任意十六进制查看器确认。看到SQLite format 3就基本可以确定是嵌入式库;看到Standard Jet DB说明是 Access 的 mdb/accdb;如果目录里还躺着mysqld.exe或ibdata1,那就是把一整套本地 MySQL 塞进了安装包。

参数说明:du -ah的-a表示连文件一起列,不加只显示目录;sort -rh的-h让人类可读的 K/M/G 参与正确排序,少了这个参数会把 900K 排在 2M 后面,判断全错。

2.2 运行环境与本地数据库的选型差别

单机版的「单机」有两种实现路线,差别直接决定了你后面怎么备份、怎么迁移、能不能二次开发。

第一种是嵌入式库,SQLite 为主,少数用 Access。程序直接读写一个文件,没有后台服务,拷走文件就是拷走全部数据。优点是部署零负担、迁移就是复制粘贴;缺点是并发写很弱,两个人同时在同一台机器上开着程序录题,很可能出现「database is locked」。我一般把这种版本定位成单人工具,多人协作就别硬上。

第二种是本地服务型,安装包里带一个精简版数据库服务,程序通过本机端口连它。好处是并发和数据完整性更稳,坏处是迁移时要连带导库,重装系统忘了导就是灾难。判断方法很简单:任务管理器里有没有常驻的数据库进程,配置文件里的连接串是不是127.0.0.1:3306这类形式。看到本地端口连接串,备份策略就必须从「复制文件」升级成「导出 SQL」。

选型上没有绝对优劣。出卷人只有一位、题目规模在几万条以内,嵌入式库足够;如果教研组三个人共用一台机器轮班录题,服务型版本会更省心。真正的坑在于很多人拿到服务型版本,却按复制文件的习惯做备份,最后恢复出来是个半截库。

2.3 首次启动与库结构确认

先别急着录题,用只读方式把库打开看一眼表结构,确认软件版本和你的预期一致。SQLite 版本可以这样连:

import sqlite3, pathlib db = pathlib.Path("/opt/tiku/data/tiku.db") # mode=ro 只读打开,避免检查过程中误改数据;uri=True 才允许这种连接串写法 con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) cur = con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") tables = [r[0] for r in cur] print(tables) for t in tables: # 打印每张表的字段名,先摸清结构再决定要不要接自己的脚本 cols = [c[1] for c in con.execute(f"PRAGMA table_info({t})")] print(t, cols) con.close()

逻辑说明:sqlite_master是 SQLite 的元数据表,type='table'过滤掉索引和视图;PRAGMA table_info返回字段名、类型、是否主键。这一步的意义在于,后面写导入脚本、写统计 SQL、做二次开发,全部依赖这份字段清单,凭猜字段名写出来的脚本十有八九要返工。

参数说明:只读模式能防止误操作,但如果你的库设置了 WAL 模式且目录里存在-wal文件,只读打开可能读不到最新提交的数据,检查前先把程序正常退出一次,让 WAL 落盘。

启动顺序上,我的习惯是先建一个空题库、录三道题、组一张卷、导一次 Word,走通全流程再批量导入。这四步任何一步失败,问题都还在小范围内,比灌进去两万道题之后才发现导出模板报错要好收场得多。

3. 题库建模:科目、知识点、题目三张表的字段怎么定

单机题库用起来顺不顺,八成取决于建库时字段设计得够不够。软件自带的表结构改不动,但你完全可以建自己的中间表、导入表,甚至绕过界面直接写库——前提是先把「一道题包含哪些信息」这件事想清楚。

3.1 题目主表:一道题最少要存哪些字段

抛开软件自带结构,一道题在数据层面至少要有这些信息:题型、题干、选项、答案、解析、难度、区分度、来源、状态。很多单机版只存了前五个,结果后面想按难度组卷、想筛掉低质量题,全都没法做。

CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, qtype TEXT NOT NULL, -- single/multiple/judge/fill/short stem TEXT NOT NULL, -- 题干纯文本,公式留占位符 options TEXT, -- JSON: {"A":"...","B":"..."} answer TEXT NOT NULL, -- 单选"A",多选"AC",判断"T"/"F" analysis TEXT, -- 解析,导出时可选择性附带 difficulty INTEGER DEFAULT 3, -- 1-5,3 为中等 discrimination REAL DEFAULT 0.30, -- 区分度 0-1,低于 0.2 慎用 source TEXT, -- 来源标签:某年模拟卷/自编 fingerprint TEXT UNIQUE, -- 去重指纹,见 3.4 status INTEGER DEFAULT 1, -- 1 启用,0 停用 created_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE INDEX idx_q_type_diff ON question(qtype, difficulty);

逻辑说明:options用 JSON 存而不是拆成 A/B/C/D 四列,因为题型不同选项数量差别很大,拆列会导致大量空字段,而且多选题加选项要改表结构。JSON 在导出时解析一次就行,代价可接受。answer统一成大写字符串,多选题内部按字母序排好,这样比较答案时不用每次排序。

参数说明:difficulty用 1-5 的整数而不是 0-1 的小数,是因为人工标定时整数更容易达成一致,两个人对「0.6 和 0.65 有什么区别」很难统一,但「3 还是 4」能吵出一个结果。discrimination留了默认 0.3,历史题没有实测区分度时先用默认值,别填 0,填 0 会在组卷时被过滤掉。

如果软件自带表结构没有这些字段,常见做法是新建一张question_ext扩展表,用题目 id 关联,界面照常用,组卷时再 join 进来。这样升级软件不会把你的自定义字段冲掉。

3.2 知识点树:邻接表加递归查询

知识点在题库里天然是树形:一级章节、二级小节、具体考点。最省事的存法是邻接表,每条记录存自己的父节点。

CREATE TABLE knowledge ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, parent_id INTEGER REFERENCES knowledge(id) ); CREATE TABLE question_knowledge ( question_id INTEGER NOT NULL REFERENCES question(id), knowledge_id INTEGER NOT NULL REFERENCES knowledge(id), PRIMARY KEY (question_id, knowledge_id) );

一道题可以挂多个知识点,所以中间表是必需的。真正麻烦的是查询:想抽「第二章及其所有子考点」的题,就得递归往下找。

WITH RECURSIVE sub(id) AS ( SELECT id FROM knowledge WHERE id = :root_id UNION ALL SELECT k.id FROM knowledge k JOIN sub s ON k.parent_id = s.id ) SELECT q.id, q.stem, q.difficulty FROM question q JOIN question_knowledge qk ON qk.question_id = q.id WHERE qk.knowledge_id IN (SELECT id FROM sub) AND q.status = 1;

逻辑说明:递归 CTE 的锚点是根节点,递归部分每次把子节点接进来,直到没有新行。UNION ALL比UNION快,但要求数据里不能有环——知识点树出现环(A 的父是 B,B 的父又是 A)会让查询直接挂死,导入前必须查一遍。

参数说明::root_id是命名参数,用 Python 传参时写con.execute(sql, {"root_id": 12}),别用字符串拼接,中文知识点名拼接容易在引号上翻车。层级建议控制在四层以内,超过四层人工维护就开始出错,抽题时也很难判断粒度是否合适。

3.3 批量导入:Excel 模板与清洗脚本

手录一百道题还能忍,一千道就必须走批量导入。模板列我一般固定成:题型、题干、选项A-D、答案、解析、难度、知识点路径、来源。知识点用「第一章/第一节/函数单调性」这种斜杠路径表示,导入时按路径自动建树。

import pandas as pd, hashlib, json, re, sqlite3 def normalize(text: str) -> str: """全角转半角 + 去首尾空白 + 压缩连续空格,保证同一道题写法不同也能对上""" if text is None: return "" text = str(text).strip() text = text.translate(str.maketrans( "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()")) return re.sub(r"\s+", " ", text) def fingerprint(stem: str) -> str: """题干归一化后取 md5,作为去重指纹;只对题干做指纹,选项差异不算新题""" return hashlib.md5(normalize(stem).encode("utf-8")).hexdigest() df = pd.read_excel("题库导入模板.xlsx", dtype=str).fillna("") con = sqlite3.connect("/opt/tiku/data/tiku.db") ok, dup, bad = 0, 0, [] for i, row in df.iterrows(): stem = normalize(row["题干"]) qtype = row["题型"].strip() if not stem or qtype not in {"single", "multiple", "judge", "fill", "short"}: bad.append((i + 2, "题干为空或题型非法")) # +2 是 Excel 行号偏移 continue opts = {k: v for k in "ABCD" if (v := normalize(row.get(f"选项{k}", "")))} answer = "".join(sorted(normalize(row["答案"]).upper().replace(",", ""))) try: con.execute( "INSERT INTO question(qtype,stem,options,answer,analysis,difficulty," "discrimination,source,fingerprint) VALUES(?,?,?,?,?,?,?,?,?)", (qtype, stem, json.dumps(opts, ensure_ascii=False), answer, normalize(row["解析"]), int(row["难度"] or 3), 0.3, normalize(row["来源"]), fingerprint(stem))) ok += 1 except sqlite3.IntegrityError: dup += 1 # fingerprint 唯一约束触发,说明是重复题 con.commit() print(f"成功 {ok},重复 {dup},异常 {len(bad)}") for line, why in bad[:10]: print("第", line, "行:", why)

逻辑说明:先归一化再算指纹,是为了让「全角括号」和「半角括号」、「多余空格」这类差异不产生重复题。用INSERT配合唯一约束捕获重复,比先SELECT再判断要快得多,也不会有并发窗口。异常行收集起来一次性打印,不要遇到第一行错误就中断整个导入。

参数说明:dtype=str防止 pandas 把「1」这种答案自动转成数字 1;fillna("")避免 None 参与字符串运算报错;ensure_ascii=False保证中文选项存进去是可读的 JSON 而不是\uXXXX。难度列空值默认 3,这个默认值要在导入报告里体现,方便事后补标。

3.4 导入后的校验:三个必查项

导完不是结束,至少查三件事。第一查数量对不对,Excel 有多少行、库里新增多少条、重复多少条,三个数要对得上。第二查题型分布,如果模板里多选题标成了单选,数量上会立刻显形。第三查知识点覆盖率,有题没挂知识点的,组卷时永远抽不到,等于白导。

-- 没有挂知识点的题,属于"隐形题",必须补挂 SELECT COUNT(*) FROM question q WHERE q.status = 1 AND NOT EXISTS (SELECT 1 FROM question_knowledge qk WHERE qk.question_id = q.id); -- 各题型各难度的库存,组卷前先看家底够不够 SELECT qtype, difficulty, COUNT(*) AS cnt FROM question WHERE status = 1 GROUP BY qtype, difficulty ORDER BY qtype, difficulty;

第二张表的结果直接决定你能出什么卷。想要「单选 10 道、其中难题 2 道」,结果难度 5 的单选题一共只有 1 道,那组卷算法再聪明也变不出来,只能降级或者换知识点范围。提前看家底,比组卷时报错强。

4. 组卷与导出:从抽题规则到一份能直接印的卷子

组卷是这套系统里唯一有「算法味」的部分,也是最容易被当成黑匣子的地方。很多人抱怨抽出来的卷子偏,其实不是随机性不好,而是约束没表达清楚——你只说了要 10 道单选,没说难度怎么分布、知识点怎么覆盖,程序只能随便给。

4.1 把组卷要求写成可执行的约束

先把口头需求翻译成结构化规则。一张组卷规则表通常长这样:

字段含义示例
qtype题型single
count数量10
score每题分值2
diff_dist难度分布{1:1, 2:3, 3:4, 4:2}
knowledge_scope知识点范围第二章全部子节点
min_discrimination区分度下限0.2
exclude_recent排除最近 N 次用过的题2

难度分布写成字典而不是「中等偏难」这种描述,是因为后者没法执行。我一般要求出卷人把分布写成整数配额,加起来必须等于 count,写不出来的说明需求本身还没想清楚。

exclude_recent这一项值得单独说。单机版往往没有考试记录表,但可以自己加一张paper_history(paper_id, question_id, used_at),每次组卷写进去。没有这张表,连续出三次卷会重复抽到同一批题,学生一眼就认出来。这是血泪经验,不是理论。

4.2 分层配额加池内随机的抽题实现

抽题不要用「全库随机取 N 条再按难度筛」,那样各难度比例完全不可控。正确做法是先按难度配额,再在每个难度池里随机。

import random, sqlite3 from collections import defaultdict def allocate(n: int, dist: dict) -> dict: """最大余数法把 n 个名额按比例分配到各难度,保证总和恰好等于 n""" total = sum(dist.values()) raw = {k: n * v / total for k, v in dist.items()} base = {k: int(v) for k, v in raw.items()} rest = n - sum(base.values()) # 按小数部分从大到小补足剩余名额 for k in sorted(raw, key=lambda x: raw[x] - base[x], reverse=True)[:rest]: base[k] += 1 return base def pick(con, rule: dict, exclude_ids: set) -> list: quota = allocate(rule["count"], rule["diff_dist"]) chosen, shortfall = [], 0 for diff, need in quota.items(): rows = con.execute( "SELECT id FROM question WHERE qtype=? AND difficulty=? AND status=1 " "AND discrimination>=? " + (f"AND id NOT IN ({','.join('?'*len(exclude_ids))}) " if exclude_ids else ""), (rule["qtype"], diff, rule["min_discrimination"], *exclude_ids) ).fetchall() pool = [r[0] for r in rows] random.shuffle(pool) take = pool[:need] chosen += take shortfall += need - len(take) # 缺口用同题型其他难度回填,优先靠近目标难度的层 if shortfall > 0: rows = con.execute( "SELECT id FROM question WHERE qtype=? AND status=1 AND difficulty BETWEEN 2 AND 4", (rule["qtype"],)).fetchall() spare = [r[0] for r in rows if r[0] not in set(chosen) | exclude_ids] random.shuffle(spare) chosen += spare[:shortfall] return chosen

逻辑说明:allocate用最大余数法,避免出现「配额四舍五入后总数多了 1 道或少 1 道」的尴尬。每次抽题前shuffle再切片,等效于无放回随机抽样,比random.sample更容易接「池子不够就记缺口」的逻辑。缺口回填限定在难度 2-4,是为了不让一套卷子因为某层库存不足而突然冒出五道送分题。

参数说明:exclude_ids传近期用过的题号集合,SQL 里用占位符展开,不要拼字符串。min_discrimination设 0.2 是个经验值,低于这个数的题往往要么人人会、要么人人不会,放在卷子里没有区分作用。shortfall一定要打印出来,它是题库库存不足的直接信号,别默默吞掉。

4.3 从题号到 Word:导出环节的三个细节

抽完题只是拿到一堆 id,真正交付的是排版好的试卷。用 python-docx 生成时,三个细节最容易漏。

第一是题号重排。库里题号是全局 id,卷面上必须从 1 开始连续编号,而且大题之间要重置。第二是答案页分离,教师版和学生版共用一套排版,只在末尾追加答案区并分页。第三是选项对齐,选项长短不一时用制表位而不是空格,否则打印出来参差不齐。

from docx import Document from docx.shared import Pt from docx.enum.text import WD_BREAK def build_paper(questions: list, title: str, with_answer: bool, path: str): doc = Document() doc.styles["Normal"].font.name = "宋体" doc.styles["Normal"].font.size = Pt(10.5) doc.add_heading(title, level=1) for no, q in enumerate(questions, start=1): p = doc.add_paragraph(f"{no}. {q['stem']}") for key, val in (q["options"] or {}).items(): p = doc.add_paragraph(f"{key}. {val}") p.paragraph_format.left_indent = Pt(18) # 选项缩进,避免与题干齐平 p.add_run(f"({q['score']}分)") if with_answer: doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) # 答案单独起页 doc.add_heading("参考答案", level=2) for no, q in enumerate(questions, start=1): doc.add_paragraph(f"{no}. {q['answer']}") doc.save(path)

逻辑说明:题干和选项分成不同段落而不是塞进一段,是为了让 Word 里的自动编号和缩进可控;答案页用分页符强制另起一页,避免老师打印时把答案混进学生卷。分值追加在题干段末尾,比单独一行更省版面。

参数说明:left_indent = Pt(18)是大约两个汉字的宽度,可按字号微调;Pt(10.5)是五号字,试卷常用。字体名写「宋体」在 Windows 上没问题,跨平台生成时如果机器没装这个字体,Word 打开会回退,排版可能轻微走样,导出后务必人工翻一遍。

4.4 导出前的人工复核清单

算法能保证数量和分布,保证不了题目质量。每次导出前我会固定看四项:总分行不行(各题型分值乘数量加总是否等于卷面分)、知识点有没有扎堆(同一节抽了五道题就得换)、有没有题干残缺(导入时截断的题会露馅)、答案格式对不对(多选题答案是否排序去重)。这四项加起来不到五分钟,能挡掉大部分返工。

5. 单机版的避坑与排查:五个真实翻车现场

单机软件的问题有个共同特征:报错信息极少,界面上什么都不说,只能靠日志和文件状态反推。下面五条是我遇到过、且复现率很高的。

5.1 双击没反应,或者启动后一闪就退

现象:程序图标双击后毫无反应,任务管理器里进程出现又消失;换一台机器同样的包却能打开。

原因:九成是运行环境不齐。老一点的单机程序依赖特定版本的运行库,新系统默认没装;另一类是安装路径里有中文或空格,程序内部的相对路径拼接直接失败,异常又被吞掉,所以看不到任何提示。

解决:先把整个目录挪到纯英文短路径下,比如D:\tiku\,再试一次;仍然不行就去看 log 目录里最后一次写入的日志,或者用命令行启动主程序,让异常输出到控制台。命令行这招很土但极有效,图形界面吞掉的报错在控制台里往往一行就说清楚了。

5.2 录题录到一半提示数据库被锁定

现象:保存时弹出「database is locked」或者界面卡死几十秒后失败,重启程序又能用一会儿。

原因:SQLite 同一时刻只允许一个写事务。常见触发场景有三个:程序没正常退出,后台还挂着进程;数据库文件放在网盘同步目录里,同步客户端一直持有句柄;杀毒软件实时扫描正在写的库文件。

解决:先确认任务管理器里没有残留进程,再确认库文件不在任何同步盘目录下,最后把数据目录加进杀毒软件的白名单。这三步做完还有问题,就检查是不是自己写的导入脚本和界面同时开着——我干过这事,脚本在跑批量插入,界面上点了保存,锁就是这么来的。

5.3 导入的中文全变问号或乱码

现象:Excel 里好好的中文,导进库里查出来是???或者一堆方块。

原因:编码链路有三段,任何一段不一致都会乱。CSV 用 GBK 存、脚本按 UTF-8 读是第一段;数据库连接没声明编码是第二段;Word 模板里字体不支持某些字符是第三段。三段里前两段最常见。

解决:统一走 UTF-8。pandas 读 CSV 时显式写encoding="utf-8-sig"(带 BOM 的文件也能吃),SQLite 连接本身默认 UTF-8 不用额外设,写入前用normalize做一次全角半角转换。如果是 Excel 直接另存 CSV 导致的问题,改成 xlsx 格式读取,绕开编码这一层最简单。

5.4 批量导入跑到一半卡住,或者只进去一部分

现象:两万行的表,导到八千行不动了,强行关掉之后库里正好有八千条。

原因:两个可能。一是逐条INSERT没有包在事务里,SQLite 默认每条语句一次提交,两万次落盘能把磁盘 IO 拖到极慢;二是 Excel 里有合并单元格,pandas 读出来是 NaN,脚本在某个字段上抛异常中断,而前面的提交已经生效。

解决:把整个导入包进一个事务,con.execute("BEGIN")开始,最后统一commit(),中途出错就rollback(),保证要么全进要么全不进。合并单元格在读入后用ffill()向下填充,别让它变成空值。另外导入前先df.shape看一眼行数,和预期对不上就先处理表,别急着跑脚本。

5.5 备份出来的库文件恢复后打不开

现象:每天手动复制一份 db 文件当备份,真出事的时候拿备份一开,提示文件损坏或者数据少了最后几条。

原因:数据库开了 WAL 模式时,最新提交的数据可能还在-wal文件里,没合并进主库。只复制主文件,等于备份了一份过期快照;如果复制的那一刻正好有写入,还可能拿到半个页。

解决:不要用文件复制做备份。SQLite 用.backup命令或者VACUUM INTO,这两个都会拿到一致性的完整快照。

# 一致性备份:先落盘再复制,避免拿到 WAL 里的半截数据 sqlite3 /opt/tiku/data/tiku.db ".backup '/backup/tiku_$(date +%F_%H%M).db'" # 备份完立刻验证:能读出题目数量才算备份成功 sqlite3 /backup/tiku_2024-01-01_1200.db "SELECT COUNT(*) FROM question;"

参数说明:.backup是 SQLite 内置命令,会处理锁和 WAL,比cp安全得多;备份文件名带日期时间,避免覆盖。验证这一步别省,备份文件大小为 0 或者读不出表的情况我都见过,不验证等于没备份。保留份数按周算,留最近 14 份足够应付绝大多数误操作。

6. 把题库当资产管:指纹巡检、版本快照与只读出口

这套单机系统用满一年之后,真正值钱的不是软件本身,而是库里那几千道题。我后来养成的习惯是每月做一次「题库体检」,核心就三件事。

第一件是指纹巡检,查重复题和近重复题。完全重复靠fingerprint唯一约束已经挡住了,但改了几个字的变体挡不住。做法是把题干归一化后取前 40 个字符做分组,同组内超过一条的拉出来人工看:

SELECT substr(fingerprint,1,8) AS grp, COUNT(*) AS cnt FROM question WHERE status = 1 GROUP BY grp HAVING cnt > 1 ORDER BY cnt DESC LIMIT 50;

这个查询只做粗筛,fingerprint是整段 md5,分组没意义,实际用法是加一列stem_head(题干前 40 字归一化后的哈希)专门用来分组。粗筛出来的组挨个看,比全库肉眼扫快得多。

第二件是版本快照。每次大规模导入或批量改题之前,先.backup一份并打上标签,命名里写清楚这次改了什么,比如tiku_before_import_ch3.db。后悔药就是这么存的,等真改错了想回退,找得到那个文件比什么都强。

第三件是留一个只读出口。所有二次开发、统计报表、导出脚本一律用只读连接打开库,绝不给写权限。单机版的并发本来就弱,一个跑飞的脚本足以把库写坏。

动作频率工具验证方式
一致性备份每周.backup备份库 count 一下
指纹巡检每月分组 SQL人工抽查前 20 组
结构快照改表前VACUUM INTO打开确认字段齐全
全量导出每学期CSV/JSON抽样比对原文

最后说个教训。早些年我图省事,直接用文件管理器把 db 拖进备份文件夹,连续拖了大半年,直到有一次误删题目想恢复,才发现备份里最新的一条数据停在三个月前——因为程序一直开着,WAL 没合并,我复制的一直是个旧快照。从那以后我每次备份都强制走一遍.backup加 count 验证,两步,一分钟,再没翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 1:00:57

ESP32应用商店实战:WebAssembly与OTA动态加载方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:00:57

ESP32 WiFi配置免重刷:浏览器直改NVS键值实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 0:10:42

交通流预测三模型对比:SAEs、LSTM与GRU实战解析

简介:这份资源是一套基于深度学习的交通流预测项目代码包,面向交通工程、智能优化及神经网络方向的本科与研究生,可用于课程设计、毕业设计或科研复现。压缩包共含17个文件,约3.2MB,主要文件类型包括Python脚本、模型权…

作者头像 李华
网站建设 2026/10/26 0:10:42

视频批量自动化剪辑指南:OpenMontage核心原理与工程实践

OpenMontage 这个词,第一眼看过去其实挺分裂的。它把“Open”这种开源软件圈常见前缀,和“Montage”这种带着浓重艺术气息的电影术语放在了一起。但如果你真正用过这类工具,就会明白这种命名反而很精准——它想解决的,正是“如何把…

作者头像 李华
网站建设 2026/10/26 0:10:10

2026年程序员转型指南:AI赋能与架构升级

1. 行业现状与挑战解析2026年的科技行业正经历着前所未有的结构性调整。作为从业15年的技术老兵,我亲眼目睹了这次浪潮对35岁以上程序员的冲击。市场数据显示,头部互联网企业35岁以上技术人员占比已从2018年的42%降至2026年的28%,这个数字在中…

作者头像 李华
网站建设 2026/10/26 0:05:41

基于CNN的KDD99入侵检测源码解析与实战

简介:一套基于卷积神经网络的网络入侵检测系统完整实现,面向网络空间安全、人工智能相关专业的学生及毕业设计开发者,解决网络流量分类与异常检测问题。系统利用CNN提取流量数据局部特征,可区分正常与恶意流量,覆盖入侵…

作者头像 李华