问卷调查系统的数据存储方案选择,往往会被数据量不大的表象迷惑。如果一开始就用Excel或CSV保存结果,一旦题型增多、选项调整频繁,文件方案会在多人提交、按用户维度统计时暴露写入冲突、查询困难、数据类型丢失等问题。SQLite作为嵌入式关系型数据库,不需要单独部署数据库服务,但能提供完整的关系模型、约束和事务能力,对中小规模问卷项目来说是一个非常务实的落地方案。

本文会从一个真实的问卷存储需求出发,拆解表结构设计、答案写入与事务优化,以及后续统计导出时常用的SQL和Python代码。代码保持简单可运行,所有示例都可以直接放入Python脚本或SQLite命令行工具验证。
一、为什么SQLite适合问卷调查结果存储
问卷项目的写入模式通常是阶段性批量提交,读取则集中在后台查看和统计分析。这种读多写少、单次数据量不大的场景,正是SQLite的舒适区。它把整个数据库保存在一个本地文件中,备份时直接复制文件即可,迁移到新服务器也只需要移动该文件。与需要独立服务的MySQL或PostgreSQL相比,SQLite几乎没有部署成本,也不依赖网络端口,适合嵌入到桌面工具、内部管理系统或轻量级Web应用里。
SQLite在并发写入方面确实不及服务型数据库,因为它默认是单写多读模型。不过,开启WAL模式后,读写可以同时进行,读操作不会阻塞写操作,写操作之间仍然串行,但问卷提交频率通常远达不到SQLite的处理上限。另一个容易忽略的点是,SQLite从3.9版本开始支持JSON1扩展,可以方便地处理多选题答案、题目扩展字段等半结构化数据,这让它在设计问卷结果表时灵活性更高。
实际使用中,建议在初始化连接时打开WAL模式,并把同步等级调整为NORMAL,这样在保证崩溃安全的前提下可以明显提升写入性能。对于需要频繁写入的场景,还可以通过合并事务、使用预编译语句来进一步减少磁盘同步次数。
二、问卷数据库表结构设计
设计问卷存储表之前,先要理清几个核心实体:问卷本身、题目、选项、提交记录和答案。问卷和题目是一对多关系,题目和选项是一对多关系,提交记录归属于某个问卷,答案则关联到具体的提交记录和题目。这样拆分的好处是,后续调整选项、增加题目或者修改问卷结构时,不需要改动历史数据。
CREATE TABLE surveys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL REFERENCES surveys(id),
question_text TEXT NOT NULL,
question_type TEXT NOT NULL CHECK (question_type IN ('single', 'multiple', 'text')),
sort_order INTEGER DEFAULT 0
);
CREATE TABLE options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL REFERENCES questions(id),
option_text TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL REFERENCES surveys(id),
user_id TEXT,
submitted_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
response_id INTEGER NOT NULL REFERENCES responses(id),
question_id INTEGER NOT NULL REFERENCES questions(id),
option_id INTEGER,
content TEXT
);
上面的建表语句中,answers表同时包含option_id和content字段。单选和填空题可以直接使用其中一种:单选题写入option_id,填空题写入content。多选题则需要额外处理,因为一个答案会对应多个选项。可以选择在content字段中保存JSON数组,例如["选项A","选项B"],也可以创建一张answer_options关联表来记录每个答案命中的选项。JSON方案实现简单,生成报表时用JSON1扩展函数解析;关联表方案更规范,方便用SQL直接做选项级别的统计。对于问卷结果量在几十万以内且不需要高频多选统计的项目,JSON方案足够稳定;如果多选题统计需求很多,建议一开始就采用关联表。
还需要注意,user_id使用TEXT类型而不是INTEGER,是因为问卷可能允许匿名提交,或者用户标识来自微信、邮箱等混合字符串。题目的question_type通过CHECK约束限制在单选、多选、文本三种,避免脏数据进入数据库,这是SQLite相对纯文件方案的重要优势之一。
三、结果写入与事务优化
一份问卷提交通常包括一条responses记录和多条answers记录。如果不在同一个事务中写入,一旦中途程序崩溃或网络中断,就可能出现只有提交记录而没有答案、或者答案不完整的情况。Python的sqlite3模块默认会在DML语句执行前隐式开启事务,但显式调用commit和rollback能让逻辑更清晰。
import sqlite3
def submit_survey(db_path, survey_id, user_id, answers):
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute("PRAGMA journal_mode=WAL;")
cur.execute(
"INSERT INTO responses (survey_id, user_id) VALUES (?, ?)",
(survey_id, user_id)
)
response_id = cur.lastrowid
for question_id, option_id, content in answers:
cur.execute(
"INSERT INTO answers (response_id, question_id, option_id, content) VALUES (?, ?, ?, ?)",
(response_id, question_id, option_id, content)
)
conn.commit()
return response_id
except Exception:
conn.rollback()
raise
finally:
conn.close()
上面这段代码把所有答案插入放在同一个事务中,任何一步出错都会回滚。如果一次性导入大量历史问卷,可以用executemany批量执行插入,并将所有插入放在一个事务中,不要每一行都提交。例如先调用cur.executemany("INSERT INTO answers ...", answer_rows),最后再conn.commit()。这样可以避免频繁的磁盘同步,批量导入数千条答案时性能差距能达到数倍甚至数十倍。
另一个优化点是预编译语句。SQLite在执行SQL前需要解析和生成执行计划,使用参数化的execute方法可以让SQLite缓存编译后的语句,减少重复解析。同时,参数化查询还能防止SQL注入。即使问卷数据来自可信内部系统,也建议保持这个习惯。
四、统计查询与导出实战
问卷结果写入后,最常见的需求是查看每道单选题各选项的选择人数和占比,多选题的单独选项命中次数,以及填空题的原始文本汇总。这些统计都可以用标准SQL完成,不需要额外引入分析工具。以单选题为例,可以把题目表、选项表和答案表做连接,然后按题目和选项分组计数。
SELECT q.id,
q.question_text,
o.option_text,
COUNT(a.id) AS choice_count
FROM questions q
JOIN options o ON o.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id AND a.option_id = o.id
WHERE q.survey_id = ?
AND q.question_type = 'single'
GROUP BY q.id, o.id
ORDER BY q.sort_order, choice_count DESC;
这条SQL用LEFT JOIN确保即使某个选项无人选择,也会出现在结果中并且计数为0。对于多选题,如果答案存储在content字段的JSON数组中,可以使用SQLite的json_each函数将数组展开后再统计;如果使用关联表,则直接对关联表做分组计数即可。填空题的汇总相对简单,按问题分组查询所有content,或使用GROUP_CONCAT将同一问题的多个答案拼接起来。
导出功能方面,SQLite命令行工具自带的.mode csv和.output file.csv可以快速导出查询结果。如果在Python中处理,使用标准库csv结合查询结果写出即可,下面是一个导出某个问卷全部答案的示例。
import sqlite3, csv
def export_answers(db_path, survey_id, csv_path):
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute(
"SELECT r.id, r.user_id, q.question_text, a.content "
"FROM responses r "
"JOIN answers a ON a.response_id = r.id "
"JOIN questions q ON q.id = a.question_id "
"WHERE r.survey_id = ? ORDER BY r.id, q.id",
(survey_id,)
)
with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow(['response_id', 'user_id', 'question', 'answer'])
writer.writerows(cur.fetchall())
conn.close()
导出的CSV文件默认带UTF-8 BOM头,这是为了让Excel直接打开时中文不乱码。如果只是临时查看,也可以省略utf-8-sig编码。对于需要自定义报表格式的场景,把查询结果按题目维度重新组织后再写入CSV或Excel会更灵活,但底层仍然依赖上面这些基础查询。
整体来看,SQLite在问卷调查结果存储这个实战项目里,用很小的运维成本换来了完整的关系模型、事务安全和SQL统计能力。数据量在中小规模时表现稳定,当未来业务增长到需要服务型数据库时,建表结构和写入逻辑也可以平滑迁移到MySQL或PostgreSQL,开发成本不会白费。