SQLite作为一款轻量级嵌入式数据库,被大量用在桌面软件、移动端应用和小型服务里。它的并发模型比较特殊:整个数据库在同一时刻只允许一个写操作,多个连接同时读写时,容易出现一个连接的更新悄悄覆盖另一个连接更新的情况。比如两个用户同时修改同一条订单备注,后提交的人完全不知道自己把先提交的内容覆盖掉了。解决这个问题最实用的手段之一就是乐观锁,配合版本号字段来控制并发更新。这篇文章结合一个具体的实战项目,把原理、建表、SQL写法和多语言代码实现完整讲一遍。

一、为什么SQLite需要乐观锁:先看数据是怎么丢的
先看一个典型的丢数据场景。假设有一张商品表,库存初始值是100。两个进程同时读到这条记录,A进程扣减库存20,B进程扣减库存30。由于SQLite的写锁是库级别的,两个写事务会串行执行,但问题不在于锁冲突,而在于读和写之间没有任何校验机制。
A执行的语句是UPDATE product SET stock = 80 WHERE id = 1,B执行的是UPDATE product SET stock = 70 WHERE id = 1。两条语句都能成功,谁后执行谁的结果生效,最终库存是70或者80,而正确答案应该是100减去50等于50。数据库层面没有任何错误,也没有任何警告,数据就这样丢了。这就是典型的丢失更新问题。
有人会说,把SQL改成UPDATE product SET stock = stock - 20 WHERE id = 1这种相对更新的形式不就安全了吗?确实,对于单纯的数值增减,相对更新是有效的,因为SQLite会保证单条UPDATE语句的原子性。但业务场景远不止扣库存这么简单:更新订单状态时需要校验前置状态、修改配置时需要基于旧值做合并、编辑文档时需要提交完整的新内容。这些场景都要求读出来的旧值在提交时仍然有效,此时就必须引入版本号来校验。乐观锁的本质就是:提交更新时带着读取时的版本号,数据库校验版本号没被别人改过才允许更新成功。
二、版本号字段设计与核心SQL写法
实现乐观锁的第一步是在表结构中增加版本号字段。通常命名为version或者rev,类型用INTEGER,默认值从1开始。下面是一个订单表的建表语句示例:
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'created',
remark TEXT DEFAULT '',
amount REAL NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
核心的更新语句写法是:在WHERE条件中同时匹配主键和版本号,更新成功的同时把版本号加一。SQL如下:
-- 客户端读取到的记录 version = 3,提交时带上这个版本号
UPDATE orders
SET status = 'paid',
remark = '用户已付款',
version = version + 1,
updated_at = datetime('now')
WHERE id = 123
AND version = 3;
这条语句的关键在于WHERE条件里的AND version = 3。如果在这条UPDATE执行之前,另一个连接已经把version改成了4,那么这条语句会匹配不到任何行,影响行数为0。调用方通过判断影响行数就能知道:要么重试整个读改写流程,要么报错退出。这个判断必须由应用程序完成,SQLite本身不会因为影响行数为0而报错,这是新手最容易忽略的一点。
还有一种做法是不用独立的version字段,而是利用updated_at时间戳充当版本号,更新时在WHERE中校验时间戳是否一致。这种方案能省一个字段,但精度受限于时间戳的分辨率,SQLite的datetime('now')只精确到秒,同一秒内的并发修改会漏判。除非业务上能保证修改频率极低,否则老老实实用整数版本号更可靠。
三、Go语言完整实现:带重试的乐观锁封装
下面用一个库存扣减的例子演示Go语言的完整实现。使用database/sql标准库配合github.com/mattn/go-sqlite3驱动,核心逻辑封装成一个带重试的函数。代码中建议开启WAL模式,它允许读写并发,能显著减少SQLITE_BUSY错误的出现频率:
package main
import (
"database/sql"
"errors"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
var ErrConflict = errors.New("乐观锁冲突,数据已被其他事务修改")
func deductStock(db *sql.DB, productID int64, count int) error {
// 最多重试5次
const maxRetry = 5
for i := 0; i < maxRetry; i++ {
// 第一步:读取当前库存和版本号
var stock, version int
err := db.QueryRow(
"SELECT stock, version FROM products WHERE id = ?", productID,
).Scan(&stock, &version)
if err != nil {
return err
}
if stock < count {
return fmt.Errorf("库存不足,当前库存%d,需要%d", stock, count)
}
// 第二步:带版本号条件更新
res, err := db.Exec(
"UPDATE products SET stock = ?, version = version + 1 WHERE id = ? AND version = ?",
stock-count, productID, version,
)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 1 {
return nil // 更新成功
}
// 影响行数为0说明版本号变了,进入下一轮重试
log.Printf("第%d次尝试发生版本冲突,准备重试", i+1)
}
return ErrConflict
}
func main() {
db, err := sql.Open("sqlite3", "shop.db?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := deductStock(db, 1, 10); err != nil {
log.Fatal(err)
}
log.Println("扣减成功")
}
这段代码有几个细节值得注意。第一,重试次数不能无限,通常3到5次即可,超过后应该向调用方返回明确的冲突错误,由上层决定如何处理。第二,连接串里的_busy_timeout=5000设置了5秒的锁等待时间,避免写锁被占用时立刻抛出busy错误。第三,读和更新是两条独立的语句,中间没有包在一个事务里,这是刻意为之:如果把读和写包进一个事务并且用BEGIN IMMEDIATE提前拿写锁,就变成了悲观锁的思路,乐观锁靠的就是提交时校验,而不是提前占锁。
四、Python实现与重试策略设计
Python的实现思路完全一样,这里用内置的sqlite3模块演示,重点展示指数退避的重试策略。当并发冲突频繁时,多个进程同时立即重试会造成反复冲突,加入随机延迟可以有效缓解:
import sqlite3
import random
import time
def update_order_status(conn, order_id, new_status):
"""带乐观锁和指数退避的订单状态更新"""
for attempt in range(5):
cur = conn.execute(
"SELECT status, version FROM orders WHERE id = ?", (order_id,)
)
row = cur.fetchone()
if row is None:
raise ValueError(f"订单 {order_id} 不存在")
old_status, version = row
# 业务校验:只有 created 状态的订单才能支付
if old_status != 'created':
raise RuntimeError(f"当前状态 {old_status} 不允许此操作")
cur = conn.execute(
"""UPDATE orders SET status = ?, version = version + 1,
updated_at = datetime('now')
WHERE id = ? AND version = ?""",
(new_status, order_id, version)
)
if cur.rowcount == 1:
conn.commit()
return True
# 版本冲突,回滚后随机延迟再重试
conn.rollback()
delay = (0.05 * (2 ** attempt)) + random.uniform(0, 0.05)
time.sleep(delay)
raise RuntimeError("多次重试后仍发生版本冲突")
重试策略的设计需要结合业务场景。对于库存扣减这类高频操作,指数退避加随机抖动是标准做法;对于后台管理这类低频操作,简单固定间隔甚至不重试直接提示用户刷新页面也可以接受。还有一点要强调:rowcount必须在commit之前判断,而且冲突后一定要rollback,否则残留的事务状态可能干扰下一轮读写的隔离性。
五、乐观锁与悲观锁如何选择
解决并发更新还有另一条路:悲观锁。SQLite本身不支持SELECT FOR UPDATE语法,但可以用BEGIN IMMEDIATE开启一个立即获取写锁的事务,在事务内完成读取、计算、更新,提交前其他连接的写操作都会被阻塞。这种方式实现简单,逻辑不需要重试,适合冲突概率高、事务耗时短的场景。
两者的选择标准可以参考下表:
| 对比维度 | 乐观锁 | 悲观锁(BEGIN IMMEDIATE) |
|---|---|---|
| 冲突概率低时 | 性能好,几乎无额外开销 | 频繁加锁,写吞吐下降 |
| 冲突概率高时 | 大量重试,可能全部失败 | 稳定,请求排队执行 |
| 实现复杂度 | 需要版本字段和重试逻辑 | 只需正确使用事务 |
| 长事务风险 | 无,不持有锁 | 可能长时间阻塞其他写入 |
实际项目中两者也常常结合使用:核心的强一致写操作用BEGIN IMMEDIATE保证成功率,而跨多个界面步骤的长流程编辑用乐观锁,因为用户编辑一份数据可能耗时几分钟,任何锁方案都会把系统拖垮,版本号校验提交时失败提示用户重新加载是最合理的体验。SQLite的写并发能力虽然有限,但配合WAL模式加上合理的并发控制策略,支撑中小规模业务的多人同时操作完全没问题。
SQLite乐观锁版本号控制SQLite并发更新修改时间:2026-09-15 00:24:47