SQLite WAL + BEGIN IMMEDIATE:给卡密兑换加一把原子锁
【摘要】 最近做一个卡密兑换小站时,踩了一个经典坑:库存用 JSON 文件存,两个请求同时兑换同一张卡密时都读到"未使用",结果一张卡发出了两份货——超卖了。 为什么 JSON 文件扛不住并发读-改-写三步不是原子的。请求 A 读文件、请求 B 读文件,两边拿到的是同一份快照;各自标记一张卡为已用、写回,后写的覆盖先写的,库存就凭空多出来一张。加文件锁能缓解,但代码复杂度和出错概率都上去了。 换 SQ...
最近做一个卡密兑换小站时,踩了一个经典坑:库存用 JSON 文件存,两个请求同时兑换同一张卡密时都读到"未使用",结果一张卡发出了两份货——超卖了。
为什么 JSON 文件扛不住并发
读-改-写三步不是原子的。请求 A 读文件、请求 B 读文件,两边拿到的是同一份快照;各自标记一张卡为已用、写回,后写的覆盖先写的,库存就凭空多出来一张。加文件锁能缓解,但代码复杂度和出错概率都上去了。
换 SQLite:WAL + 立即事务
把库存迁到 SQLite 后,我用了两个关键设置:
- WAL 模式:
PRAGMA journal_mode=WAL;读写不互斥,读性能好很多。 - 兑换时用
BEGIN IMMEDIATE:事务一开始就拿写锁,后来的兑换请求只能排队等,从根本上杜绝两个事务同时挑中同一张卡。
核心兑换逻辑(Python 标准库 sqlite3):
import sqlite3
def redeem(db_path, card_key, qty):
conn = sqlite3.connect(db_path, timeout=10)
try:
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("BEGIN IMMEDIATE") # 先拿写锁
row = conn.execute(
"SELECT stock FROM cards WHERE key=?", (card_key,)
).fetchone()
if row is None or row[0] < qty:
conn.execute("ROLLBACK") # 库存不足,整单回滚
return None
items = conn.execute(
"SELECT id, payload FROM pool WHERE status='free' LIMIT ?",
(qty,),
).fetchall()
if len(items) < qty:
conn.execute("ROLLBACK")
return None
ids = [i[0] for i in items]
conn.execute(
f"UPDATE pool SET status='used' WHERE id IN ({','.join('?'*len(ids))})",
ids,
)
conn.execute(
"UPDATE cards SET stock=stock-? WHERE key=?", (qty, card_key)
)
conn.execute("COMMIT")
return [i[1] for i in items]
finally:
conn.close()
三个容易被忽略的点
- 先查库存再扣减,且都在同一个事务里。把任何一步挪到事务外,并发窗口就又回来了。
- 库存不足要整单回滚,不能发一部分。
ROLLBACK后连接别忘了关,WAL 文件会自己 checkpoint。 timeout要给够。BEGIN IMMEDIATE拿不到锁会抛database is locked,连接层面的 busy timeout 能让短时排队自动等待,而不是直接报错。
小结
单机小站没必要一上来就上 MySQL/Redis。SQLite 的 WAL + BEGIN IMMEDIATE 用标准库就能实现兑换原子性,我这边 44 项并发与幂等回归测试全过,再没出现过超卖。对同类"库存池 + 按量发放"的场景,这套组合够用且省心。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)