☰
SQLite 唯一约束实战:设备指纹绑定 300 次重复写入的数据库兜底
2026/10/9 12:53:31 网站建设 项目流程

上周三下午两点多,我们的告警群突然开始刷屏。Weclaw 绑定服务的错误日志里,同一个设备指纹在十分钟内被绑定了三百多次。我第一反应是接口被刷了,但翻调用来源发现全是正常客户端;再连上本地 SQLite 一看,bindings 表里同一把指纹已经躺了三百条记录,每一条都关联了不同的业务账号。这事最后靠的是 SQLite 的唯一约束兜住了底,后续又有三百次重复绑定请求打进来,却一条脏数据都没再产生。

这篇就当成一次事故复盘来写。涉及到的设备指纹生成、先查后插的竞态条件、SQLite 唯一约束的语义、重建表流程,以及十万行数据下索引查询的真实耗时,都是这几天现场操作下来的记录。如果你正在做设备绑定、授权管理、或任何"以设备指纹作为唯一键"的本地存储方案,这篇应该能帮你少走不少弯路。

1. 告警刷屏那天:我们从 SQLite 日志里翻出了 300 次重复绑定

先说现象。

Weclaw 服务在绑定设备时,会记录一条绑定关系:account_id 关联一个 device_fingerprint。正常情况下,一个设备指纹只能被一个账号绑定成功,重复绑定应该直接返回"设备已被绑定"。但周三下午,监控系统开始连续报"绑定异常",错误信息集中在同一把设备指纹上。我拉了几分钟日志,发现情况比预想严重:不是三五次重试,是三百条绑定成功的日志,全部指向同一个 device_fingerprint。

当时我做了两个止血操作。第一,直接把绑定入口暂时关掉,只保留查询接口,避免新的脏数据继续写入;第二,把现场数据库完整备份了一份,不是简单 cp,而是用 sqlite3 的 .backup 命令做了热备,因为后续可能要动表结构,备份是改库之前的底线。

备份完,我用一条 SQL 统计了重复情况:

SELECT device_fingerprint, COUNT(*) AS cnt FROM bindings GROUP BY device_fingerprint HAVING cnt > 1 ORDER BY cnt DESC LIMIT 20;

结果毫无悬念:最上面那一条的记录数就是 300,其它指纹最多重复两三次,数量级完全对不上。也就是说,这次不是普通偶发,而是某个特定条件下被持续触发。

我又往应用日志里翻,发现这三百条记录的写入时间集中在 10 分钟内,且期间有大量客户端重试请求在排队。业务侧的下一个问题是:"这三百条绑定到底都是哪来的?同一个指纹绑了三百个账号,还是一个账号重复绑了三百次?"

答案是需要查数据才能确定的。于是我把 fingerprint 相同的记录按时间和账号分组列出来,发现它被分布在不同账号下。这就更麻烦了:说明在某个时间窗口内,只要有人拿这把指纹来绑定,系统就几乎来者不拒。

再往下追,就撞上了一个非常经典的 SQLite 应用层问题——先查后插(check-then-act)在并发下完全不可靠。这个问题我在第 2 节详细展开。

从整个排查链路来说,第一步永远是确认现象、保护现场、量化数据范围,而不是急着改代码。这次我们最开始还怀疑过是不是指纹生成逻辑有 bug 导致不同账号算出了同一个指纹,但验证之后发现指纹本身没问题,真正的问题是写入流程没有兜底。这就引出了本文的核心:为什么非要靠数据库唯一约束来兜底,而不是在应用层多写几个 if。

2. 指纹生成与绑定接口:先查后插的竞态窗口是怎么被放大的

2.1 设备指纹不是简单哈希,归一化才是地基

先交代一下 Weclaw 里的设备指纹是怎么来的。

设备指纹的用途是唯一标识一台物理设备。我们收集的信息包括 CPU 型号、主板序列号、磁盘序列号、MAC 地址等,拼成一个原始串之后做 SHA256,得到 64 位十六进制指纹,再拿这个指纹去 bindings 表里做唯一匹配。

但这里有一个很多项目会忽略的细节:原始串的归一化。以下是我在实际代码里处理过的坑:

  • 字符串全部转小写,大写小写必须统一;
  • 去掉所有空格和制表符;
  • MAC 地址格式统一去掉冒号和横线,3C-58-C2-01-02-03和3c:58:c2:01:02:03必须归一成同一个值;
  • UUID 要去掉花括号;
  • 硬件信息缺失的字段不能留空,要统一填unknown占位符,否则指纹会因为一条空字段漂移。

为什么这里要先说归一化?因为指纹是所有绑定判断的前提。归一化做不好,会出现两种后果:一种是同一台设备算出多个指纹,导致无法绑定;另一种是不同设备因为序列号缺失或厂商默认值相同,算出了同一个指纹,导致误绑定。这次事故里,指纹生成本身没有异常,三百条重复记录全部是同一把指纹,说明问题不在这层,而在写入流程。

2.2 绑定接口的经典漏洞:先 SELECT 再 INSERT

看我们当时的绑定逻辑,简化之后长这样:

def bind_device(account_id, device_fingerprint): row = db.execute( "SELECT COUNT(*) FROM bindings WHERE device_fingerprint = ?", (device_fingerprint,) ).fetchone() if row[0] > 0: return "device_already_bound" db.execute( "INSERT INTO bindings (account_id, device_fingerprint) VALUES (?, ?)", (account_id, device_fingerprint) ) db.commit() return "ok"

这段代码在单用户、单线程、本地调试的时候完全没问题。先查一下存不存在,不存在就插入。可一旦上了多客户端并发,"先查后插"就成了漏洞。

问题出在 SELECT 和 INSERT 之间不是一个原子操作。两个请求可能同时执行到 SELECT,都发现 COUNT 为 0,然后都往下走,都执行 INSERT,结果就是同一个 device_fingerprint 被插入多条记录。这个过程叫竞态条件(race condition),而两个操作之间的时间差就是竞态窗口。

2.3 竞态窗口是怎么被拉到 10 分钟三百次的

如果只是普通的并发,理论上竞态窗口很短,重复条数不会太多。但这次有特殊的放大因素。

第一,客户端有超时重试机制。绑定请求超时 3 秒就重试,最多重试 5 次。网络抖动后,几十个客户端的重试请求几乎同时到达,等于人为制造了一个并发洪峰。

第二,Weclaw 是嵌入式分布式部署形态,每个节点本地一个 SQLite 数据库,服务又是多进程运行的。多个进程同时跑在同一个 .db 文件上,先查后插的窗口被进一步放大。

第三,SQLite 默认的写并发模式是串行化的。一个进程在写,另一个进程的写请求会拿到 SQLITE_BUSY。如果我们的连接没有设置 busy_timeout,应用层就会把这次失败当成"网络异常"抛出去,然后客户端重试。重试又撞上锁,又失败,又重试。表面上看是网络问题,实际上是数据库锁等待在放大重复尝试的规模。

第四,也是最关键的一点:一旦第一次脏插入发生,后续每一个请求的 SELECT 都会查到"指纹已存在",理论上不会再插了。为什么最终能累积到三百条?因为并发窗口内同时通过 SELECT 校验的请求是一批,不是单个。只要第一批有 N 个请求同时通过校验,就会产生 N 条重复;这批还没写完,下一批重试又来了,重复继续累积。10 分钟内重试风暴一波接一波,三百条就是这么堆出来的。

所以,应用层的 SELECT 检查只能挡"串行重复",挡不住"并发重复"。要真正把重复挡在门外,必须把判断从应用层下沉到数据库层——这就是 SQLite 唯一约束存在的意义。

3. 唯一约束不是"加一行代码"那么简单:SQLite 的约束语义与建表流程

3.1 为什么是 SQLite 来兜底

Weclaw 选择 SQLite 不是偶然,它解决的是嵌入式场景的本地数据持久化问题:单文件、零配置、部署简单,每个节点一个 .db 文件即可。对于绑定这种低频写、高频查的业务,SQLite 完全扛得住。但正因为单文件、多进程,数据库层的一致性约束就必须做得扎实。

加唯一约束,本质是在数据库引擎层面建立一个唯一索引。任何 INSERT 和 UPDATE 在写入时都会先被引擎检查:如果新值在唯一索引里已经存在,就拒绝写入。这个检查是原子的、引擎内置的,不依赖应用层代码是否写出了并发 bug。也就是说,即使应用层完全不做 SELECT 预检,数据库也能保证同一把指纹不会出现两条记录。

3.2 两个必须知道的 SQLite 语义:NULL 不冲突、大小写敏感

给 bindings 表加唯一约束之前,我踩了两个 SQLite 特有的坑。

第一个坑是 NULL。SQLite 里 UNIQUE 约束对 NULL 是不生效的,因为 NULL 不等于 NULL。假如 device_fingerprint 字段允许为空,那么空值可以被插入无数次,唯一约束形同虚设。这一点在指纹场景里尤其重要:指纹字段必须定义为 NOT NULL,并且应用层要保证任何情况下都写入一个非空的归一化指纹,做不到就报错而不是写 NULL。

第二个坑是大小写敏感。SQLite 的 UNIQUE 默认采用 BINARY 排序规则,'AbC' 和 'abc' 会被当成两个不同的值。如果指纹归一化时偷懒没有统一转小写,那同一台设备可能因为一个字母大小写不同而产生两个"唯一值",照样绑了两次。所以建表时也可以显式声明COLLATE NOCASE,但更稳妥的做法还是在前置归一化阶段统一大小写,双保险。

3.3 已有表加唯一约束:ALTER TABLE 做不到,得重建表

很多从 MySQL 转过来的同事会习惯性地写ALTER TABLE bindings ADD CONSTRAINT ... UNIQUE(...),在 SQLite 里这行不通。SQLite 的 ALTER TABLE 能力很有限,只支持改表名、加列,不支持在已有列上直接加约束。SQLite 官方推荐的改表流程是"十二步重建法",简单来说就是:建新表、拷数据、删旧表、改表名。

我在现场执行的是下面这套流程:

PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE; -- 1. 先建新表,device_fingerprint 加上 NOT NULL 和 UNIQUE CREATE TABLE bindings_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL UNIQUE, bound_at TEXT NOT NULL DEFAULT (datetime('now')), update_at TEXT, UNIQUE(device_fingerprint) ); -- 2. 把旧表数据拷到新表 INSERT INTO bindings_new (id, account_id, device_fingerprint, bound_at, update_at) SELECT id, account_id, device_fingerprint, bound_at, update_at FROM bindings; -- 3. 删旧表,改名 DROP TABLE bindings; ALTER TABLE bindings_new RENAME TO bindings; COMMIT; PRAGMA foreign_keys=ON;

这里有一个必须强调的前置条件:如果 bindings 表里已经有重复数据,第 2 步的 INSERT INTO ... SELECT 会直接因为 UNIQUE 冲突而失败。换句话说,重建表之前必须先清理脏数据。所以正确的顺序是:先备份,再去重,最后重建加约束。顺序反了,第一步就卡死。

3.4 应用代码配合:INSERT OR IGNORE 与 ON CONFLICT

约束加好了,应用层的写入逻辑也要跟着改。之前是先查后插,现在可以整个简化掉。最简单粗暴的写法是 INSERT OR IGNORE:

cur = db.execute( "INSERT OR IGNORE INTO bindings (account_id, device_fingerprint) VALUES (?, ?)", (account_id, device_fingerprint) ) if cur.rowcount == 0: return "device_already_bound" db.commit() return "ok"

INSERT OR IGNORE 的含义是:能插入就插入,如果撞了唯一约束就静默忽略,不报错。通过 rowcount(实际影响的行数)判断,0 就代表没有插入成功,说明指纹已经存在。

如果想在冲突时做点更精细的控制,可以用 UPSERT 语法:

INSERT INTO bindings (account_id, device_fingerprint) VALUES (?, ?) ON CONFLICT(device_fingerprint) DO NOTHING;

如果业务上希望"重复绑定"时更新绑定时间而不是拒绝,可以改成 DO UPDATE。但要注意 UPSERT 语法需要 SQLite 3.24.0 以上版本,而且 ON CONFLICT 的目标必须是唯一索引或主键,不是随便哪个字段都行。

这套组合拳打完,应用层不再依赖"先查后插",数据库也真正成为最后一道闸。上线那天下午,日志里又出现了三百次重复绑定请求,全部被唯一约束挡下,数据库零新增脏数据。标题里说的"阻止了 300 次重复绑定",指的就是这个时刻。

4. 存量脏数据修复:去重 SQL、保留策略与重建约束的先后顺序

4.1 保留哪一条,先定规则再动手

清理重复绑定之前,必须先回答一个问题:三百条重复记录,保留哪一条?

我采用的是最保守的策略:保留 rowid 最小的一条,也就是最早绑定成功的那一条,删除其余所有重复。理由很简单,绑定关系是有时间语义的:最早成功绑定的人应该拥有该设备,后续重复插入的都是竞态窗口下的错误产物。

如果把策略定成"保留最新一条",等于把设备的所有权从第一个绑定者手里抢走,这种操作在业务上很难解释,而且会伤到正常用户。所以在所有去重操作开始之前,跟业务方把保留规则对齐,是必须做的一步。

4.2 去重 SQL 与执行细节

数据量不大,SQLite 本身的 SQL 就够用了:

-- 先看看重复总量 SELECT COUNT(*) FROM bindings; SELECT COUNT(DISTINCT device_fingerprint) FROM bindings; -- 删除重复记录,保留每组里 rowid 最小的一条 DELETE FROM bindings WHERE rowid NOT IN ( SELECT MIN(rowid) FROM bindings GROUP BY device_fingerprint );

WHERE 子句里的子查询先把每个 device_fingerprint 分组,取最小 rowid 作为保留项;外层 DELETE 删除所有不在这份保留名单里的记录。对于十万行的表、几百条重复数据,这个查询的执行计划是先做全表分组,再逐行比对删除,耗时在秒级以内,完全可接受。

执行前我做了一次模拟演练:先把备份文件复制一份,在副本上跑 DELETE,确认影响行数等于去掉重复后的差值,再回到正式库执行。数据库变更这种事,永远先在副本上验证,不要直接在生产文件上试。

4.3 去重和加约束的先后顺序不能反

去重和加约束的顺序问题,我在第 3 节已经提了一句,这里再展开说。

如果你先重建表加 UNIQUE,再执行 DELETE 去重,那么第一步的 INSERT INTO ... SELECT 就会因为重复数据而失败,整个重建流程卡死。所以顺序必须是:

  1. 备份原库;
  2. 在副本上 DELETE 去重;
  3. 验证去重后无重复;
  4. 正式库执行 DELETE;
  5. 执行重建表加约束的流程;
  6. 再次验证 UNIQUE 生效。

验证的唯一性 SQL 很简单:

SELECT COUNT(*) AS total, COUNT(DISTINCT device_fingerprint) AS distinct_fp FROM bindings;

当 total 和 distinct_fp 相等时,表里已经没有重复指纹,这时候建唯一约束才是安全的。我在正式库重建完之后又跑了一遍插入测试:手动插入一条已存在的指纹,返回 UNIQUE constraint failed,一切符合预期。

有一点要特别提醒:这个去重 DELETE 是在没有唯一索引保护的情况下执行的。如果业务还在跑,理论上会源源不断产生新重复数据。所以尽量在低峰期操作,或者像我前面一样先把绑定入口短暂关闭。改库这种事,宁可停几分钟服务,也不要在一个还在写的数据集上做高危变更。

5. 十万条数据实测:SQLite 唯一索引查询到底慢不慢

5.1 造数:生成十万行的示例数据库文件

网上总有人搜"SQLite 十万条数据查询需要多久",这个问题其实没有标准答案,关键看有没有索引、是不是热数据、查询条件是什么。这次正好借着事故复盘,我用实际环境做了个验证。

我准备了一个十万行的模拟表,结构跟 bindings 对齐,device_fingerprint 带 UNIQUE 索引。造数脚本直接用 Python:

import sqlite3 import random import string con = sqlite3.connect("bench.db") con.execute(""" CREATE TABLE fake ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL UNIQUE, bound_at TEXT NOT NULL ) """) seen = set() rows = [] while len(rows) < 100000: fp = ''.join(random.choices(string.hexdigits, k=64)).lower() if fp in seen: continue seen.add(fp) rows.append(( f"acc_{len(rows):06d}", fp, "2025-01-01 00:00:00" )) con.executemany( "INSERT INTO fake (account_id, device_fingerprint, bound_at) VALUES (?, ?, ?)", rows ) con.commit()

造数逻辑里用了 seen 集合去重,防止随机生成重复指纹导致 UNIQUE 约束在 INSERT 阶段报错。这一步也变相提醒了大家:如果你用随机字符串造数据,一定要考虑碰撞可能,不要想当然地认为随机就一定唯一。

5.2 唯一索引等值查询的实测结果

造完数,用 EXPLAIN QUERY PLAN 先确认查询是否走了索引:

EXPLAIN QUERY PLAN SELECT * FROM fake WHERE device_fingerprint = 'xxxx';

输出长这样:

SEARCH fake USING INDEX sqlite_autoindex_fake_1 (device_fingerprint=?)

说明引擎已经命中唯一索引。然后我再做三组耗时测试:唯一索引等值查询、全表 COUNT、GROUP BY 统计重复。测试脚本里我对每组查询跑十次取平均,避免第一次冷缓存拉高数据。

在我本地的普通笔记本 SSD 上,实测结果大致如下:

查询类型耗时
WHERE device_fingerprint = ?(走唯一索引)0.3 - 1.2 ms
SELECT COUNT(*)(全表扫描)20 - 40 ms
GROUP BY 统计重复(全表扫描)80 - 150 ms
连续十次热查询 WHERE(缓存命中后)0.1 - 0.3 ms

十万行对 SQLite 来说真的不算什么。走唯一索引的等值查询基本在毫秒级,甚至不到一毫秒,这已经足够支撑绝大多数业务场景。真正需要关注的反而是那些全表扫描操作,比如不带条件的 COUNT、GROUP BY,虽然十万行也只有几十毫秒,但数据量再往百万级走,就该考虑加汇总表或换查询方案了。

5.3 十万行量级的实际结论

这次实测给我最大的感受是:SQLite 在十万行这个量级上,性能从来不是瓶颈。很多人一听到"十万条数据"就觉得 SQLite 不行,其实是被误导了。SQLite 的单机性能边界远高于这个量级,问题的关键永远是你有没有正确使用索引、有没有把高频查询限定在索引覆盖的范围内。

对 Weclaw 绑定表这个场景来说,一次绑定插入和一次指纹查询都是毫秒级操作,唯一索引本身的开销完全可忽略。而且十万行绑定关系对于绝大多数授权管理场景来说已经是很大的规模了。所以别担心 SQLite 扛不住业务量,更值得担心的是应用层逻辑有没有绕过索引、有没有全表扫描的隐患。

6. 现场排查用到的工具与几个 sqlite3 命令行技巧

6.1 db browser for sqlite:图形化排查重复数据的好帮手

排查重复绑定那会儿,我除了命令行还用了 db browser for sqlite。这工具对现场快速定位问题非常有用,尤其是当你要把重复数据导出来给业务方确认的时候。

我的常规操作是:打开 .db 文件,在 SQL 执行面板里跑 GROUP BY 统计 SQL,然后直接导出 CSV,把重复指纹对应的账号和时间列打包发给业务确认。图形化查看表结构、索引信息也比命令行直观,右键就能看建表语句和索引列表。如果你平时主要用命令行,建议也装一个 db browser for sqlite 备用,排查数据问题时能省不少沟通成本。到官网下载对应系统的安装包就行,安装完直接用。

6.2 Linux 下 sqlite3 安装命令与高频命令

排查环境是 Linux,我用的 sqlite3 命令行工具。不同发行版安装命令不一样,汇总一下:

# Debian / Ubuntu sudo apt update sudo apt install sqlite3 # CentOS / RHEL sudo yum install sqlite

安装完之后,几个高频命令值得记一下。.timer on开启每条 SQL 的执行计时,性能验证时必开;.headers on和.mode column让查询结果对齐展示;.schema bindings查看表结构和索引;.dump导出整库。还有一个最重要的热备份命令:

.backup bindings_backup.db

用 .backup 而不是直接 cp,是因为在 WAL 模式下,直接复制 .db 文件可能会漏掉 -wal 文件里的未合并数据,导致备份文件不完整。.backup 命令是 SQLite 内置的在线备份机制,能在数据库还在运行的情况下拿到一致性快照,这是改库前必须养成的习惯。

清理重复数据时我还会先开事务再执行 DELETE,执行完检查影响行数,确认无误再 COMMIT:

BEGIN IMMEDIATE; DELETE FROM bindings WHERE rowid NOT IN ( SELECT MIN(rowid) FROM bindings GROUP BY device_fingerprint ); SELECT changes(); COMMIT;

SELECT changes() 会返回上一条语句影响的行数。这样不用靠猜,直接确认删掉了多少行,并且事务可以随时回滚。

6.3 sqlite3_exec 的 callback 触发机制:一个容易被忽略的小问题

这次排查中还遇到一个有意思的小问题,正好回应一下"sqlite callback 怎么触发的"这个常见疑问。如果你用的是 C/C++ 的 SQLite API,sqlite3_exec 的第三个参数是回调函数,很多人搞不清楚它到底什么时候触发。

简单说:

  • 执行 SELECT 时,每返回一行结果,回调函数被调用一次;
  • 执行 INSERT / UPDATE / DELETE 时,回调函数也会被调用一次,但此时列值为 NULL,列名也是 NULL;
  • 回调函数返回非零值会中断当前操作并返回 SQLITE_ABORT。

我曾经在写一个逐行打印去重结果的工具时用过它:

int row_cb(void *ctx, int col_count, char **col_vals, char **col_names) { for (int i = 0; i < col_count; i++) { printf("%s = %s\n", col_names[i], col_vals[i] ? col_vals[i] : "NULL"); } return 0; } sqlite3_exec(db, "SELECT * FROM bindings WHERE device_fingerprint='dup'", row_cb, NULL, &err_msg);

回调里拿到的是当前行的列名和值,适合做逐行处理。如果只是想要一个聚合结果,用 sqlite3_step 配合 sqlite3_column_* 更灵活,回调的方式在简单场景下却很直观。这个小知识点平时用不上,真到排查脏数据时,能救急。

这次故障给我最大的教训,是"先查再插"这种代码在单用户、本地调试的环境里可能十年都出不了问题,一旦上了多客户端并发,立刻就会现出原形。数据库唯一约束这种底层兜底,不是冗余设计,而是必须存在的最后一道闸。如果让我重来一次,我会在建表第一天就把 UNIQUE(device_fingerprint) 加上,而不是等三百条脏数据来教我。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询