实际项目里遇到一个典型需求:订单状态查询频率很高,但每笔订单的变更相对低频。如果全部请求直接打SQLite,写锁会拖慢读,导致接口响应时间不稳定。于是引入KeyDB作为缓存层,SQLite负责持久化。运行过程中踩了不少多线程相关的坑,整理成这篇实战记录。

一、SQLite在多线程环境中的连接与锁策略
SQLite本身支持多线程访问,但默认的锁机制是数据库级写锁,同一时间只能有一个线程执行写事务。多个线程同时调用写入操作时,后到的线程会立即收到SQLITE_BUSY错误,而不是等待。解决这个问题第一件事就是给每个线程建立独立的连接对象,并且设置足够长的busy_timeout。千万不要在线程间共享同一个sqlite3.Connection,因为连接内部有事务状态和游标缓存,共享会导致数据错乱甚至崩溃。
打开WAL模式后,读写不再互相阻塞,读事务可以继续读取旧版本数据,写事务写入WAL文件,只有在检查点发生时才会把WAL合并回主数据库。对于多线程场景,建议把synchronous设为NORMAL,这样在WAL模式下既能保证持久性,又减少fsync次数。另外,事务要尽量短,不要在一个事务里做耗时操作,否则写锁持有时间过长会引发大量线程等待。
import sqlite3
conn = sqlite3.connect('app.db', timeout=5)
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA synchronous=NORMAL')
conn.execute('PRAGMA busy_timeout=5000')
conn.execute('PRAGMA cache_size=-20000')
conn.close()
这里把busy_timeout设置为5000毫秒,意味着当写锁被占用时,其他写线程会最多等待5秒而不是直接报错。cache_size给到20MB可以减少页缓存抖动,适合频繁访问同一批数据的场景。
二、KeyDB的多线程模型与缓存优势
KeyDB是Redis的一个高性能分支,最大的特点是把命令执行从单线程改为多线程模型。它内部将数据按key哈希到不同线程,每个线程独立处理自己分片上的命令,从而充分利用多核CPU。与Redis 6.0的IO多线程不同,KeyDB连命令执行本身也是并行的,所以在高并发读写时吞吐提升非常明显。在项目中,我们把KeyDB部署在SQLite前面作为第一级缓存,读请求先访问KeyDB,命中后直接返回,连SQLite都不用碰。
KeyDB兼容Redis协议,现有Redis客户端可以无缝接入。对于需要频繁更新的计数器、用户会话、商品库存等热数据,直接存放在KeyDB中,配合expire过期时间可以自动回收。需要注意KeyDB的线程数可以通过配置文件中的server-threads参数调整,默认是0表示自动检测CPU核数。实际压测发现,对于纯内存读写,KeyDB多线程吞吐是单线程Redis的数倍,但线程数也不是越多越好,通常设置为CPU核心数或略少即可。
import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
r.set('user:1001', 'alice', ex=60)
print(r.get('user:1001'))
上面的代码使用标准redis-py客户端连接KeyDB,因为协议兼容所以无需额外适配。设置ex=60表示缓存60秒后自动过期,避免脏数据长期存在。
三、多线程读写分离与缓存一致性处理
这个项目的核心思想是读路径走KeyDB,写路径先更新SQLite再让缓存失效或更新。在多线程并发下,简单的双写会带来一致性风险:线程A更新数据库后删缓存,线程B在A删缓存之前已经读到了旧缓存并返回给客户端,随后A删缓存,但B已经把旧值返回了。对于我们的业务来说,这种短暂的不一致可以接受,因为最终缓存会被后续请求重新填充。如果需要强一致,则要引入分布式锁或者版本号机制,但复杂度会明显上升。
为了减少缓存穿透和击穿,我们在查询未命中时使用了互斥锁,保证同一个key只有一个线程去查SQLite并回填缓存,其他线程等待。写入时先提交SQLite事务,成功后再删除KeyDB中的对应key。删除操作失败时,可以依赖key的过期时间兜底。所有SQLite写入必须通过一个写线程池串行或少量并发执行,避免写锁竞争。经过调整,8个线程混合读写时,平均读响应在1毫秒左右,写成功率接近100%。
import sqlite3
import redis
import threading
cache = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
lock = threading.Lock()
def get_user(user_id):
key = f'user:{user_id}'
val = cache.get(key)
if val is not None:
return val
with lock:
val = cache.get(key)
if val is not None:
return val
with sqlite3.connect('app.db', timeout=5) as conn:
row = conn.execute('SELECT name FROM users WHERE id=?', (user_id,)).fetchone()
if row:
cache.set(key, row[0], ex=60)
return row[0]
return None
def update_user(user_id, name):
with sqlite3.connect('app.db', timeout=5) as conn:
conn.execute('UPDATE users SET name=? WHERE id=?', (name, user_id))
conn.commit()
cache.delete(f'user:{user_id}')
这里用一把全局互斥锁防止缓存击穿,实际生产环境可以用更细粒度的锁或者singleflight模式。更新操作先写数据库,再删缓存,保证后续读请求能拿到最新值。
四、调优参数与排查记录
SQLite有几个参数对多线程表现影响很大:busy_timeout建议5000毫秒以上;journal_mode=WAL必须开启;cache_size可以给到20MB以上减少页缓存抖动;wal_autocheckpoint默认1000页,可以适当调大减少检查点频率,但会增加WAL文件体积。如果写入压力很大,可以考虑定期执行wal_checkpoint(TRUNCATE)来回收WAL空间。另一个常见问题是SQLite数据库文件被放在网络文件系统上,这会导致锁机制失效,必须放在本地磁盘。
KeyDB方面,maxmemory不能设置为超过物理内存,否则会触发OOM。淘汰策略建议使用allkeys-lru或者volatile-lru,根据缓存数据的过期性质选择。多线程下还要关注客户端连接池的配置,连接数太少会排队,太多会加重KeyDB线程切换负担。我们在压测中遇到过因为Redis客户端连接池没有设置max_connections导致线程达到上限后大量超时,后来改为每个工作线程持有独立连接并复用,问题解决。
from concurrent.futures import ThreadPoolExecutor
def worker(i):
update_user(1000 + i, f'user_{i}')
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(worker, range(1000)))
这段代码模拟8个线程同时写入1000条记录。在没有WAL和busy_timeout的情况下,几乎必现SQLITE_BUSY错误;配置之后写入可以稳定跑完。总结这个组合方案:SQLite负责落盘和复杂查询,KeyDB负责热数据和高频读写,两者通过读写分离和缓存删除策略协同。它适合中小型项目或者单机部署场景,不需要引入独立的数据库服务器,却能获得接近内存数据库的读取性能。大型分布式系统可能需要换成PostgreSQL和Redis Cluster,但思路是一样的:分层、异步、控制锁粒度。