抽奖活动的参与记录是看似简单却对写入吞吐和去重要求很高的数据场景。一次抽奖可能瞬间涌入大量请求,每个请求都需要记录用户、活动、时间、结果等信息。如果直接使用远程数据库,网络开销可能成为瓶颈;而在单机或中小型活动中,SQLite凭借零配置、事务完整和单文件存储的特点,能够承担比多数人预期强得多的写入负载,前提是表结构与写入方式需要针对性地调优。本文从实际项目出发,拆解如何用SQLite可靠存储抽奖参与记录,包括建表、并发写入、防重复和统计查询。

一、抽奖记录表的结构设计
抽奖系统通常包含活动表、用户表和参与记录表三张核心表。活动表存放抽奖的基本信息,用户表保存参与者的唯一标识,记录表则是每次抽奖动作的落点。设计时最容易犯的错误是直接把所有字段塞进一张宽表,比如把活动名称和用户昵称也冗余到记录表里。冗余虽然能减少联表查询,但一旦活动信息或用户资料发生变化,就需要同步更新海量历史记录,得不偿失。因此参与记录表只应保存外键和参与结果,展示时再通过关联查询获取名称。
主键的选择直接影响写入性能。SQLite中如果声明INTEGER PRIMARY KEY,该列会作为底层rowid的别名,插入时使用自增整数不会触发B树的随机分裂,速度和空间利用率都优于文本主键或UUID。参与记录表的建表语句可以参考下面这段SQL:
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL
);
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
openid TEXT NOT NULL UNIQUE
);
CREATE TABLE participation_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
participate_time TEXT NOT NULL DEFAULT (datetime('now','localtime')),
result TEXT NOT NULL DEFAULT '未中奖',
FOREIGN KEY (activity_id) REFERENCES activities(id),
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE (activity_id, user_id)
);
时间字段使用TEXT格式存储便于直接阅读,但不利于范围比较和索引排序。如果抽奖记录量级较大,建议改用INTEGER存储Unix时间戳,比较和计算都更高效。外键约束在SQLite中默认关闭,需要执行PRAGMA foreign_keys=ON才能生效。对于高并发写入场景,外键检查会带来额外开销,很多项目选择在应用层保证引用完整性,而在数据库层只保留逻辑关联。这里最关键的约束是UNIQUE (activity_id, user_id),它从数据库层面阻止了同一用户在同一活动中重复参与。需要注意的是,SQLite的UNIQUE约束允许NULL值重复,因此这两个字段必须设置为NOT NULL。
二、写入性能与并发控制优化
SQLite的默认日志模式是DELETE,在这种模式下,写事务会锁定整个数据库文件,其他写操作必须排队等待。对于抽奖活动这种短时间内写入密集的场景,锁竞争会迅速拖垮响应速度。解决办法是切换到WAL模式,即执行PRAGMA journal_mode=WAL。WAL模式把修改先写入独立的WAL文件,读操作可以直接读取原数据库文件,读写之间互不阻塞;多个写操作虽然仍串行,但锁的粒度从整个文件变为更轻量的WAL追加,整体吞吐量明显提升。
事务提交策略对性能的影响往往被低估。如果你像下面这样每插入一条记录就提交一次,磁盘同步的开销会非常大:
import sqlite3
conn = sqlite3.connect('lottery.db')
for item in records:
conn.execute("INSERT INTO participation_records (activity_id, user_id) VALUES (?, ?)", item)
conn.commit() # 每次插入都提交,性能极差
正确做法是使用一个事务包裹所有插入,或者利用executemany批量执行。开启事务后,SQLite只需要在事务结束时做一次同步,写入速度可以提升数倍甚至数十倍。同时设置busy_timeout可以让连接在遇到锁冲突时等待一段时间,而不是立即抛出database is locked错误。完整的连接初始化与批量写入代码示例如下:
import sqlite3
conn = sqlite3.connect('lottery.db', timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=5000")
def batch_record(records):
sql = """INSERT OR IGNORE INTO participation_records
(activity_id, user_id, result) VALUES (?, ?, ?)"""
with conn: # with conn 会自动提交或回滚事务
conn.executemany(sql, records)
另一个容易被忽略的点是连接复用。SQLite打开连接时会读取文件头并进行一些初始化工作,如果每次请求都新建连接、用完关闭,这部分开销会累积。建议在应用启动时创建一个全局连接,配合连接池或单线程队列使用。对于多线程环境,可以设置check_same_thread=False,但需要注意SQLite的写操作本身是串行的,多线程并发写并不会真正并行执行,应尽量避免多个线程同时持有写连接。
三、防重复参与与统计查询
用户重复点击抽奖按钮、前端重试或者网络超时后重新提交,都可能导致同一用户在同一活动中产生多条参与记录。如果采用“先查询是否存在再插入”的逻辑,在高并发下会出现竞态条件:两个请求同时查到不存在,然后都执行插入,最终仍然产生重复数据。数据库层面的联合唯一索引配合INSERT OR IGNORE能够原子地完成防重,插入时如果违反唯一约束则直接忽略,不会报错也不会产生重复行。
统计查询是抽奖系统的重要功能,比如查询某个活动的中奖用户、统计参与人数、按用户参与次数排名等。以下SQL展示了常见统计需求的实现:
-- 查询某活动中奖用户列表
SELECT u.openid, r.result, r.participate_time
FROM participation_records r
JOIN users u ON u.id = r.user_id
WHERE r.activity_id = 1 AND r.result = '一等奖';
-- 统计每个活动的参与人数
SELECT activity_id, COUNT(*) AS total
FROM participation_records
GROUP BY activity_id;
-- 使用窗口函数统计用户参与次数排名
SELECT user_id, COUNT(*) AS times,
RANK() OVER (ORDER BY COUNT(*) DESC) AS rank
FROM participation_records
GROUP BY user_id;
随着记录量增长,查询性能会逐渐下降。如果在participation_records表上只依赖主键索引,按activity_id过滤时就会触发全表扫描。建议为高频查询条件建立复合索引,例如CREATE INDEX idx_records_activity_result ON participation_records (activity_id, result)。可以使用EXPLAIN QUERY PLAN检查查询是否命中了索引,如果看到SCAN TABLE字样,说明需要调整索引设计。
四、数据归档与日常维护
抽奖记录表属于典型的时间驱动增长型数据。一个活动结束后,相关的参与记录往往不会再被频繁访问,但数据量却会持续累积。当单表达到百万甚至千万行时,即使索引设计合理,查询和写入性能也会受到B树深度和磁盘IO的影响。一种实用的做法是按月或按活动拆表,例如participation_records_202501,新数据写入当月表,历史数据保留在原表或移动到归档表。SQLite支持ATTACH DATABASE将归档库挂载到当前连接,跨库查询也很方便。
备份是数据安全的底线。SQLite单文件数据库在DELETE模式下直接复制文件是安全的,但WAL模式下数据可能尚未checkpoint到主库文件,直接复制可能导致遗漏。推荐使用SQLite提供的备份API,Python中可以通过conn.backup()方法实现一致性备份。也可以定期执行VACUUM INTO将数据库压缩并导出到新文件。对于抽奖记录这类写多读少的数据,建议每天低峰期做一次备份,并保留最近七天的快照。
整体来看,SQLite在中小型抽奖系统中完全能够承担参与记录的存储任务,关键是避免默认配置下的低效用法。开启WAL模式、批量提交事务、设置合理的超时时间、利用唯一索引防重,再加上定期的数据归档和备份,单机环境下支撑每秒数百次写入并不困难。当活动规模增长到需要多节点部署时,再考虑迁移到PostgreSQL或MySQL,而本文中的表结构和写入逻辑也可以平滑移植。