在构建桌面端工具、嵌入式服务或小型后端系统时,数据访问层往往既要应付高频的重复读取,又要保证断电后数据不丢。SQLite作为单文件嵌入式数据库,部署简单且支持标准SQL,但每次查询都有文件IO与解析开销;Caffeine则是基于Java的本地缓存,读写延迟可低至微秒级。把两者放在同一个应用里分工协作,能兼顾速度与持久化。

为什么需要SQLite与Caffeine配合而非二选一
很多团队在选型时会纠结:既然有了Caffeine这种内存缓存,是不是就可以不用数据库?答案是否定的。Caffeine中的数据存在于JVM堆内,一旦进程退出,所有缓存条目都会消失。对于配置信息、用户脱机数据、历史记录这类需要重启后依然可用的内容,仅靠本地缓存无法满足持久化要求。而SQLite以单个文件存储,支持事务和索引,非常适合嵌入式场景,但它的每次读操作都涉及磁盘访问与SQL执行计划生成,当并发读放大到每秒数千次时,响应时间会明显变长。
将Caffeine放在SQLite之前形成一层访问屏障,可以让百分之九十以上的重复查询直接在内存完成。比如一个每日活跃用户只有几百人的内部系统,每个人登录后都会拉取自己的权限列表和偏好设置,这些数据在十分钟内的变化概率极低。如果每次请求都走SQLite,数据库文件会被频繁读取;改用Caffeine缓存后,只有首次或缓存失效时才访问SQLite,整体吞吐能力提升明显。要注意的是,这种配合并不是把SQLite当作Caffeine的备份那么简单,而是明确职责:SQLite负责可靠存储与复杂检索,Caffeine负责削减热点访问。
从架构角度看,二者配合也属于典型的多级存储思路。嵌入式应用通常没有独立的Redis节点,本地缓存就是第一级,嵌入式数据库是第二级。相比引入外部缓存服务,这种组合零额外运维,特别适合单机或边缘计算环境。不过它要求开发者自己处理缓存与数据库之间的状态同步,例如写操作必须先落盘再清理或更新缓存,否则会出现脏读。
Caffeine回源SQLite的三种集成模式
最基础的集成方式是手动编程式缓存。在业务代码里先调用Caffeine的getIfPresent方法,若返回空再执行SQLite查询,并把结果填回缓存。这种写法直观,但容易在多个业务方法中重复相似逻辑。下面示例展示了一个简单的用户配置读取:
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class ConfigService {
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, java.util.concurrent.TimeUnit.MINUTES)
.build();
private final String dbUrl = "jdbc:sqlite:app.db";
public String getConfig(String key) {
String value = cache.getIfPresent(key);
if (value != null) {
return value;
}
try (Connection conn = DriverManager.getConnection(dbUrl)) {
PreparedStatement ps = conn.prepareStatement("SELECT val FROM config WHERE k=?");
ps.setString(1, key);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
value = rs.getString("val");
cache.put(key, value);
}
} catch (Exception e) {
e.printStackTrace();
}
return value;
}
}
第二种模式是利用Caffeine的loadingCache自动回源。通过build(key -> loadFromSqlite(key))定义加载函数,业务侧只需调用get,未命中时框架自动执行SQLite查询并写入缓存。这种方式消除了手动判空代码,也方便统一设置异常时的默认值。它的缺点是加载逻辑耦合在缓存构建处,若SQLite连接需要动态切换就比较麻烦。
第三种模式是异步刷新结合写穿透。对一致性要求不极端的场景,可以用refreshAfterWrite让Caffeine在后台线程异步从SQLite重新加载,而前台请求仍返回旧值,避免雪崩。写操作时则采用写穿透:先更新SQLite,再调用cache.invalidate或cache.put。这样读请求大多命中内存,写请求保证落盘,适合报表类或日志类应用。
SQLite侧应如何调优以支撑缓存回源
当Caffeine的命中率不是百分百时,SQLite会周期性承接回源流量。如果数据库本身慢,回源就会成为毛刺。首先应开启WAL模式,它允许读和写并发进行,而不像默认回滚日志那样读写互斥。执行PRAGMA journal_mode=WAL;后,缓存未命中时的查询不会被写入阻塞,对读多写少的应用效果明显。
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA cache_size=-8000;
其次要控制事务粒度。回源查询通常是单条主键查询,本身很快,但如果业务在回源时顺带做批量写,应把写操作合并到同一个事务里提交,减少fsync次数。上面代码中的synchronous=NORMAL在WAL下只在检查点才做完整同步,兼顾了安全与性能。另外cache_size设为负数表示以KB为单位的页面缓存,给SQLite留几兆内存可避免频繁读文件。
最后要注意连接管理。SQLite在单进程内不建议开大量连接,通常一个连接配合读写锁即可。在Java里用Connection池时要限制上限,避免多线程同时写导致SQLITE_BUSY。缓存回源一般是读,冲突较少,但后台刷新线程若做写操作,就要用busy_timeout重试而不是立即报错。经过这些调整,SQLite在本地缓存背后的存在感会降低,系统整体表现更接近纯内存应用。
缓存一致性及常见误区
最常见的误区是认为本地缓存只是提速,不需要考虑和数据库一致。实际上当用户在界面修改了设置,若只更新SQLite而忘了清Caffeine,其他请求会持续读到旧值,这种Bug在测试环境因为重启频繁反而难发现。正确做法是在写接口里明确调用cache.invalidate(key),或者采用先写库再删缓存的顺序,防止并发读把旧值重新填回。
另一个误区是无限放大maximumSize。有人觉得内存够大就设成十万条目,但Caffeine的淘汰是近似LRU,条目过多会导致弱引用队列与时钟探针维护成本上升,反而拖慢读写。应结合业务数据量设置合理上限,并配合expireAfterWrite让冷数据自动退出。对于SQLite中会被全表扫描的小表,也可以考虑启动时预热缓存,减少首屏卡顿。
还需要警惕缓存穿透:当查询一个不存在的key时,Caffeine和SQLite都没有数据,若每次都打向数据库,恶意或异常请求会压垮SQLite。解决方案是对空结果也缓存一个短生命周期的占位符,或者在Caffeine层用weakKeys配合布隆过滤器。这样即便有人用随机key探测,也不会每次都触发文件IO。
SQLiteCaffeinelocal_cache修改时间:2026-08-16 00:00:45