做一个小型拍卖竞价系统,很多人第一反应是上MySQL或者PostgreSQL,觉得并发出价必须依赖重量级数据库。实际上,如果你的系统规模在日活几千到几万的量级,SQLite配合合理的表结构设计和事务控制,完全可以撑住一场热闘的线上拍卖。本文用一个完整的实战项目,带你从建表到竞价核心逻辑,一步步用SQLite实现一个稳定可靠的拍卖竞价系统。

一、系统需求分析与表结构设计
先明确系统的核心需求:用户可以对拍品出价,每次出价必须高于当前最高价,出价要记录时间和出价人;拍品有起拍价、开始时间和截止时间;拍卖结束后价高者得。围绕这些需求,我们至少需要四张表:用户表、拍品表、出价记录表和一个用于记录成交结果的订单表。
用户表比较简单,除了基本的用户名和密码哈希,建议加上信用分或者保证金状态字段,因为拍卖系统里恶意出价是很常见的问题。拍品表是核心,关键字段包括起拍价、当前最高价、加价幅度、状态以及截止时间。这里有个设计要点:当前最高价可以冗余存储在拍品表里,而不是每次都去出价表里查最大值,这样查询性能会好很多。
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
deposit REAL DEFAULT 0, -- 保证金余额
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE items (
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
seller_id INTEGER NOT NULL REFERENCES users(user_id),
title TEXT NOT NULL,
description TEXT,
start_price REAL NOT NULL, -- 起拍价
min_increment REAL DEFAULT 1.0, -- 最小加价幅度
current_price REAL, -- 当前最高价,冗余字段
current_winner_id INTEGER, -- 当前最高出价人
status TEXT DEFAULT 'pending', -- pending/active/ended
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
CHECK (status IN ('pending', 'active', 'ended'))
);
CREATE TABLE bids (
bid_id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(item_id),
user_id INTEGER NOT NULL REFERENCES users(user_id),
amount REAL NOT NULL, -- 出价金额
bid_time TEXT DEFAULT (datetime('now')),
UNIQUE (item_id, amount) -- 同一拍品同价出价去重
);
CREATE INDEX idx_bids_item ON bids(item_id, amount DESC);注意bids表上的联合唯一索引(item_id, amount),它的作用不只是避免重复出价,更是并发场景下的最后一道防线。即使业务逻辑判断出现漏洞,两个用户同时以相同价格出价,数据库也会拒绝第二个插入操作。而那个idx_bids_item降序索引,则服务于最常用的查询场景——按价格从高到低列出某拍品的出价历史。
二、竞价核心逻辑:事务与并发控制
竞价系统最容易出问题的地方是并发:两个用户在同一毫秒出价,都读到当前最高价是100元,然后各自提交101元,如果不加控制,最终会出现两条"最高价"。SQLite是单写者模型,天然避免了部分并发写冲突,但你的业务逻辑仍然是"先读后写"的模式,如果不放进同一个事务里,逻辑上的竞态条件依然存在。
SQLite的默认事务模式是DEFERRED,也就是BEGIN时不上锁,直到第一次写操作才升级为写锁。这在竞价场景里有坑:如果两个连接都先读了拍品价格(此时都是读锁),然后都要更新,其中一个会得到SQLITE_BUSY错误。解决办法是用BEGIN IMMEDIATE,它在事务开始时就直接获取写锁,把读和写变成原子操作。
-- 核心出价逻辑:整个流程放在一个 IMMEDIATE 事务里
BEGIN IMMEDIATE;
-- 1. 检查拍品状态和截止时间
SELECT current_price, min_increment, status, end_time, seller_id
FROM items WHERE item_id = :item_id;
-- 假设读到的结果在应用层校验通过:
-- status = 'active',now < end_time,:amount >= current_price + min_increment
-- 且 :user_id != seller_id(禁止卖家自己出价)
-- 2. 写入出价记录
INSERT INTO bids (item_id, user_id, amount)
VALUES (:item_id, :user_id, :amount);
-- 3. 更新拍品的当前最高价和出价人
UPDATE items
SET current_price = :amount,
current_winner_id = :user_id
WHERE item_id = :item_id;
COMMIT;在Python代码里,可以用异常处理来兜底。如果COMMIT阶段因为唯一索引冲突抛出IntegrityError,说明有并发出价撞价了,直接告知用户出价失败即可;如果是SQLITE_BUSY,可以设置busy_timeout让SQLite自动重试等待,而不是立即报错。
import sqlite3
def place_bid(conn, item_id, user_id, amount):
try:
cur = conn.execute(
"PRAGMA busy_timeout = 3000" # 等待写锁最多3秒
)
cur.execute("BEGIN IMMEDIATE")
row = cur.execute(
"SELECT current_price, min_increment, status, end_time, seller_id "
"FROM items WHERE item_id = ?", (item_id,)
).fetchone()
if row is None:
raise ValueError("拍品不存在")
price, inc, status, end_time, seller = row
if status != "active":
raise ValueError("拍卖未在进行中")
if user_id == seller:
raise ValueError("不能竞拍自己的拍品")
if amount < (price or row and 0) + inc:
raise ValueError("出价必须高于当前最高价加最小加价幅度")
cur.execute(
"INSERT INTO bids (item_id, user_id, amount) VALUES (?, ?, ?)",
(item_id, user_id, amount)
)
cur.execute(
"UPDATE items SET current_price = ?, current_winner_id = ? "
"WHERE item_id = ?",
(amount, user_id, item_id)
)
conn.commit()
return True
except sqlite3.IntegrityError:
conn.rollback()
raise ValueError("出价冲突,价格已被他人抢先")
except Exception:
conn.rollback()
raise三、定时结拍与性能优化技巧
拍卖必须有截止机制。常见的做法有两种:一种是靠应用层的定时任务扫描end_time到期的拍品并更新状态;另一种更优雅,是在每次有人访问拍品详情或出价时,惰性检查是否过期。推荐两者结合——定时任务兜底,惰性检查保证实时性。结拍时把status改为ended,如果current_winner_id不为空,就生成一条成交记录。
-- 定时结拍脚本,每次处理100条到期的拍品
UPDATE items
SET status = 'ended'
WHERE status = 'active'
AND end_time <= datetime('now')
AND item_id IN (
SELECT item_id FROM items
WHERE status = 'active' AND end_time <= datetime('now')
LIMIT 100
);
-- 为已结拍且有人出价的拍品生成订单
INSERT INTO orders (item_id, winner_id, final_price)
SELECT item_id, current_winner_id, current_price
FROM items
WHERE status = 'ended' AND current_winner_id IS NOT NULL
AND item_id NOT IN (SELECT item_id FROM orders);性能方面,SQLite有几个针对竞价这种读写混合场景的优化点。第一是开启WAL模式,它允许读写并发进行,读请求不再阻塞写请求,对频繁查询出价历史同时又有新出价写入的场景提升明显。第二是合理设置synchronous参数,WAL模式下设为NORMAL就能兼顾安全与速度。第三是定期执行PRAGMA wal_checkpoint(TRUNCATE)回收WAL文件,避免文件无限膨胀。
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -8000; -- 使用8MB页缓存 PRAGMA foreign_keys = ON; -- 别忘了开启外键约束
最后说几点部署建议。SQLite数据库文件务必放在SSD上,竞价写入是随机小写入,机械硬盘会明显拖慢事务提交。如果系统是多进程部署(比如多个Gunicorn worker),每个进程各自持有连接即可,SQLite的文件锁会协调好写冲突,但一定要设置合理的busy_timeout。对于真正高并发的热门拍品,还可以在应用层加一个内存级的出价队列,把瞬时并发出价串行化后再批量落库,这样数据库压力会小很多。这一套方案跑在一个树莓派上都绰绰有余,足够应对绝大多数中小型拍卖业务了。