SQLite库存导入实战:预览差异、拒绝过期计划与整批回滚

库存文件校验通过后,还有两个容易遗漏的问题:预览时看到的旧值,提交时可能已经变了;第二条记录写入失败时,第一条可能已经留在数据库里。本文做一个完整的本地导入实验,把“看差异”和“写数据库”分开,同时保证提交时重新核对依据、失败时整批回滚。

适合已经会读JSON、正在给小批量导入脚本增加确认步骤的Python开发者。所有商品均为合成数据,实验只操作临时SQLite库,不连接网站或正式业务库。本次实测为macOS、Python 3.14.4、SQLite 3.53.2;源码要求Python 3.11+及SQLite 3.24+。

下载库存导入实验包:完整脚本、样本、运行结果与18项测试(ZIP,约10KB)

先运行一次,观察三条库存记录

解压后进入inventory-apply-v1目录,执行:

python3 run_lab.py
python3 -m unittest -v

演示脚本自动创建临时数据库,先放入两条旧记录,再导入两条输入,结束后清理临时目录。它不会在当前目录留下库存库。下面是实际得到的变化:

SKU 原数量 输入数量 成功后数量
001 10 7 7
B 不存在 2 2
OLD 1 文件未提供 1

这里的导入语义是按SKU覆盖绝对数量,不是增加库存。001没有变成17;文件里缺席的OLD也没有被删除。程序无法替业务猜出“7”到底是盘点结果还是新增入库数量,因此这项约定必须先确定,再选择SQL。

预览报告为新增1条、更新1条、保持缺席记录1条,预览前后数据库内容一致。首次提交后有3条记录;直接重用旧计划会被拒绝。重新预览相同输入,则变成新增0、更新0、相同2、保留缺席1。读者可以在包中的observed-results.json对照完整结果。

计划里保存前后值,也保存生成它的依据

plan用只读连接和读事务读取库存,生成差异文件。变化列表中,001的before与after分别如下;最后一项是单价的整数分:

{
  "sku": "001",
  "before": ["001", "O'Reilly书", 10, 1230],
  "after":  ["001", "O'Reilly书",  7, 1230]
}

仅保存这个差异还不够。计划另含数据库随机身份、整张库存表当前内容的摘要,以及原始输入文件字节的SHA-256。数据库身份用于拒绝把甲库的计划误用到乙库;库存摘要用于发现预览之后的数据变化;源文件摘要用于发现输入被替换或重新编辑。

摘要按已排序的库存记录计算。本例采用保守策略:即使变化发生在输入没有包含的OLD上,也要重新预览。代价是可能拒绝本来互不冲突的两次操作,但小批量人工导入时,这比悄悄缩小确认范围更容易解释。整个库存会读入内存,不适合直接扩展成百万行导入服务。

输入摘要比较的是原始字节,因此只调整JSON缩进也会让旧计划失效。这里追求“提交的就是刚审核的那个文件”,没有把语义相同的不同文件当成同一份材料。摘要与计划都不是签名,更不是操作权限凭证;本地操作者已经拥有数据库写权限。

自己做一轮可检查的预览和提交

在解压目录里用一个尚不存在的demo.db练习。先建立空库,再把seed.json作为初始库存导入:

python3 inventory_apply.py init demo.db
python3 inventory_apply.py plan demo.db seed.json --save seed-plan.json
python3 inventory_apply.py apply demo.db seed.json seed-plan.json

然后预览本次输入,打开计划看前后值,再明确提交:

python3 inventory_apply.py plan demo.db incoming.json --save plan.json
python3 -m json.tool plan.json
python3 inventory_apply.py apply demo.db incoming.json plan.json

初始化拒绝覆盖已有路径,计划保存也使用独占创建;重做练习时换新的数据库或计划文件名。普通预览与提交都要求数据库已存在,路径拼错会报错,不会顺手创建一个空库。如果初始化或计划保存中断,可能留下不完整文件,应保留诊断信息并换新练习路径;不要拿正式业务库尝试初始化。

输入是独立的JSON数组,并不是本站CSV预检文章输出的完整报告对象。接入前文流程时还需要显式转换其items及金额格式,并确定是否允许部分数据;本包没有静默取合格子集的逻辑。

字段 本例契约
sku 1—32位ASCII字母、数字、下划线或短横线;区分大小写,保留001的前导零
name 1—120字符,无首尾空白;单引号作为普通数据保存
quantity 0—1000000整数,不接受布尔值、负数或小数
unit_price 0.00—999999.99的固定两位字符串;拆成整数分,不经过float

每项恰好四个字段;重复SKU、重复JSON字段与未知字段都拒绝整批。输入上限为2MiB、10000项,计划文件读取也限制2MiB。大计划可能先触及文件上限,这个实验没有声称“任何一万条数据都能提交”。限制是资源与业务约定,不是库存真实性校验。

重新核对与写入必须在同一个事务里

提交路径先读取和校验输入,再执行BEGIN IMMEDIATE取得写事务,随后重新生成计划并与审核文件比较。只有两者一致才写入,最后统一提交。关键流程是:

BEGIN IMMEDIATE
  读取数据库身份和当前库存
  重新计算输入摘要、库存摘要与差异
  与已审核计划逐项比较
  不一致:ROLLBACK并报告计划过期
  一致:执行全部INSERT / UPDATE
COMMIT

如果把重新核对放在事务外,另一连接可能在核对完成之后、真正写入之前修改库存。本例提前获取写事务,避免在这个窗口里允许另一个写者插入。已有写事务占用数据库时,连接最多等待1秒,再返回忙错误;它不会无限重试或擅自覆盖。

源码显式选择SQLite事务控制方式,自己执行BEGIN、COMMIT与ROLLBACK;对Python 3.12及以上还固定使用兼容的事务控制设置,避免把行为依赖于未来默认值。预览只持有短读事务,返回计划后立即结束,不会在等待人审阅期间一直占用数据库连接。

写入使用参数绑定以及ON CONFLICT(sku) DO UPDATE,以输入值覆盖名称、数量和金额。这里没有用quantity + excluded.quantity,也没有用可能带删除再插入语义的替换写法。出现其他约束错误时,整批仍应失败。

故意让第二条失败,确认第一条真的回去了

演示中的失败分支给练习库增加一个合成触发器:当准备插入B时,数据库主动抛出ABORT错误。这不是预先把“失败”写进结果变量。按SKU排序执行时,001先从10更新为7,随后B触发错误;异常处理执行ROLLBACK。

重新打开数据库后,001仍为10,OLD仍为1,B不存在。脚本同时比较失败前后的全部库存记录,结果为一致。单条语句失败不等于先前语句自动撤销,正是外层事务及回滚保证了这次整批恢复。

实际测试情形 处理结果
预览后源文件改动 计划拒绝,无导入写入
预览后库存数据变化 计划拒绝,保留后来写入的数据
计划用到另一数据库或差异被改写 拒绝提交
第二项数据库约束失败 已执行的第一项更新一起回滚
另一个连接持有写事务 等待达到测试时限后报忙,不写入

18项测试还覆盖新库创建、预览不改数据库字节、重新打开数据库、数量覆盖与缺席保留、重新预览无变化、非法输入,以及真实命令行退出码。忙锁测试缩短了等待时间以快速触发分支;这些是功能回归,不是并发吞吐测试。

重跑之前,区分过期计划与未知结果

正常操作退出0;计划不匹配退出3;输入、文件或数据库错误退出1。第一次提交改变了库存,旧计划中的快照自然过期,第二次直接提交返回3。按相同输入重新预览后,差异为0,提交无变化计划不会把数量再加一次。

如果COMMIT已完成,但终端输出丢失,调用方看到的结果仍可能未知。这个命令行工具没有保存一份可重放的网络响应,也没有提供“每次调用只执行一次”的接口协议。应重新读取库存、生成计划,核对实际状态;需要HTTP重试语义时,另看本站的幂等键实验,不把二者混为一谈。

快照检测的是当前内容,不保留完整变更历史;一条记录被修改后又恢复原值,摘要无法告诉你发生过两次操作。触发器和表结构也不在摘要范围内,因此实验要求可信、按本包初始化的小型本地库。真实系统若允许其他程序修改结构,需要迁移版本与更完整的并发约定。

事务只覆盖这个SQLite数据库,不能同时回滚邮件、文件发布或远程API。本次没有做断电、强杀、网络盘和大数据量测试,也没有验证商品数量是否符合现实。接入正式流程前,还要补用户权限、生产备份、业务审计与实际库存规则;本文提供的是可运行、可检查失败分支的导入起点。

代码包SHA-256:0d997bec15bd96c8a3916c97408e2b44fa4721cefb81888943a07a1aed8feb0f。源码采用MIT许可。

参考依据与前置阅读

资料查阅与编辑日期:2026年10月24日。作者:玖柒。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享