在移动端和桌面应用开发中,SQLite常作为轻量级本地缓存供网络层使用。缓存的价值在于减少重复请求,但如果数据只增不减,过期条目会逐步拖慢查询和写入。设计一套合理的过期策略,需要在业务访问模式与磁盘占用之间寻找平衡点。本文从实际项目出发,用一张带时间戳的缓存表,逐步实现可落地的清理方案。

先看基础建表语句。缓存表至少要包含键、值、创建时间和过期时间四个字段。其中过期时间建议使用整型时间戳,这样在SQL中直接做数值比较,速度比字符串日期快得多。
一、设计带过期时间的缓存表
缓存表的核心字段是expires_at,它表示这条缓存数据的有效截止时刻。写入时应用层负责计算这个时间戳,例如设置五分钟缓存,就取当前时间加上300秒。使用UTC时间戳可以避开时区转换带来的歧义,也方便在代码中统一处理。
建表语句如下:
CREATE TABLE IF NOT EXISTS cache_data (
cache_key TEXT PRIMARY KEY,
cache_value TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_cache_expires ON cache_data (expires_at);
这里为expires_at单独建索引非常关键。后续的定时清理语句会频繁按照这个字段筛选过期行,没有索引的话,随着数据量增长,DELETE操作会变成全表扫描,性能直线下降。另外,cache_value如果可能存储较大的JSON或二进制数据,可以调整为TEXT或BLOB类型,但索引策略并不需要立刻改变。
二、查询时的惰性删除
惰性删除也叫懒删除,思路很直接:在读取某条缓存时先检查它是否过期,如果过期就顺手从表中删掉,然后向业务层返回“数据不存在”。这样每次只操作一条记录,代价很低,而且没有任何定时器或后台任务。
下面是一段Python示例,展示了读取缓存时如何判断并删除过期数据:
import sqlite3
import time
def get_cache(conn, key, now=None):
if now is None:
now = int(time.time())
cur = conn.execute(
"SELECT cache_value, expires_at FROM cache_data WHERE cache_key = ?",
(key,)
)
row = cur.fetchone()
if row is None:
return None
value, expires_at = row
if expires_at < now:
conn.execute("DELETE FROM cache_data WHERE cache_key = ?", (key,))
conn.commit()
return None
return value
代码中的关键判断是expires_at < now,也就是当前时间戳大于过期时间。注意在SQLite中比较整型时间戳非常高效,不需要处理日期字符串。这个策略最大的优点是实现简单,不会引发大范围的锁竞争,适合访问频率低的缓存场景。
不过惰性删除有明显的短板:如果很多缓存数据写入后永远不被读取,它们就会一直停在表里,成为空间垃圾。换言之,惰性删除只清理“被看过的过期数据”,对“没人关心的过期数据”无能为力。因此,生产环境不能只依赖这一种方式。
三、定期批量清理过期数据
为了处理长期未访问的过期行,需要提供批量清理机制。最简单的做法是启动应用时执行一条DELETE语句,把当前时间之前的所有缓存记录删掉。一条SQL就可以解决:
DELETE FROM cache_data
WHERE expires_at < strftime('%s', 'now');
这里使用strftime('%s', 'now')获取当前Unix时间戳,与整型字段直接比较。如果应用层有统一的时间源,也可以把时间作为参数传入SQL,这样便于在测试时模拟不同的“当前时间”。
批量清理要注意数据量和锁表问题。如果缓存表已经积累了上百万条过期记录,一条DELETE会把所有垃圾一次性删除,事务会持续数秒,甚至阻塞其他读写操作。更稳妥的做法是分批删除,例如每批只删除一千行,然后提交事务,循环执行直到没有过期数据。
下面是一个分批清理的实现:
import sqlite3
import time
def clean_expired(conn, batch_size=1000):
now = int(time.time())
total = 0
while True:
cur = conn.execute(
"DELETE FROM cache_data WHERE expires_at < ? "
"LIMIT ?",
(now, batch_size)
)
conn.commit()
if cur.rowcount == 0:
break
total += cur.rowcount
return total
注意,SQLite的DELETE LIMIT语法需要3.32.0以上版本支持,如果使用的旧版本,可以改写子查询。分批删除的好处是每个事务执行时间极短,对在线业务几乎没有影响,同时还能在循环中增加日志输出,便于观察清理进度。
四、组合策略与性能优化
实际项目里,优雅的方案往往是把惰性删除和定期批量清理结合起来。查询时发现过期数据就顺手删除,这样高频访问的垃圾会被及时清理;后台定时任务再兜底,清理那些从未被访问过的记录。两种方式互补,基本可以控制缓存表的增长速度。
为了进一步提升性能,可以考虑将SQLite切换到WAL模式。WAL模式允许读操作与写操作在一定程度上并发执行,批量清理时不会长时间阻塞业务查询。启用方法很简单,每次打开连接后执行PRAGMA journal_mode = WAL;即可。对于缓存这种读多写少的场景,收益非常明显。
另外,在设计表结构时可以增加一个created_at字段,虽然它不是过期清理的直接依据,但可以帮助排查数据堆积原因。如果某段时间缓存写入量异常,可以通过创建时间定位到具体业务动作。而在条件允许的情况下,也可以将过期的清理时间安排在业务低峰期,避免在请求最密集的时段执行大批量DELETE。
最后,如果某个业务场景的缓存数据天然带有很强的时间分区属性,比如只保存最近一周的数据,那么还可以考虑按时间分表,过期后直接DROP TABLE。这种方式比DELETE更彻底,也不会留下碎片,但实现复杂度较高,更适合数据量极大的特殊场景。
综合来看,SQLite的缓存过期策略并不复杂,关键是在“删除时机”和“删除粒度”之间做出取舍。从一张带时间戳的缓存表开始,先实现查询时的惰性删除,再叠加定时批量清理,最后根据监控数据调整清理频率和批次大小,就能得到一套稳定高效的方案。