在Java应用的缓存体系中,Ehcache凭借其进程内缓存的高性能表现被广泛使用。但当内存不足以容纳所有缓存数据时,Ehcache会将数据溢写到磁盘,这个溢写机制的性能与可靠性,很大程度上取决于底层的磁盘存储方案。SQLite作为一款零配置、单文件、支持事务的嵌入式数据库,正好可以承担这个角色。本文将通过一个完整的实战项目,演示如何将SQLite与Ehcache整合,构建一套内存加磁盘的两级缓存体系。

一、为什么二级缓存要选SQLite
先理解一下Ehcache的层次结构。Ehcache默认情况下数据全部存放在堆内存中,这被称为堆内缓存(Heap-based)。当配置了PersistenceConfiguration之后,Ehcache支持将数据溢写到堆外内存或者磁盘,形成事实上的两级缓存:热数据留在内存,冷数据落到磁盘。默认的磁盘存储是Ehcache自己实现的一套文件格式,写入和读取都由内部的DiskStore组件负责。
那为什么还要引入SQLite?主要有三点考虑。第一,默认的DiskStore文件在进程崩溃时可能损坏,虽然可以通过diskPersistent配置做持久化,但容错能力有限,而SQLite本身支持ACID事务,异常断电后数据库文件的完整性更有保障。第二,SQLite的数据文件是跨平台可读的标准格式,运维人员可以直接用命令行工具或图形化客户端打开排查问题,而Ehcache的原生磁盘文件是私有格式,出了问题只能靠日志猜。第三,如果你的项目本身已经在用SQLite做本地存储,把缓存数据也放进同一个体系,可以复用备份、迁移等基础设施。
需要说明的是,Ehcache官方并没有内置SQLite存储策略,整合方式通常有两种思路:一种是利用Ehcache的自定义CacheExtension或CacheLoaderWriter SPI,把SQLite作为缓存的后端存储;另一种是在业务层做双层封装,Ehcache作为一级缓存,SQLite作为二级缓存,读写时先查内存再查库。下面我们采用第二种思路,因为它实现简单、可控性强,更适合中小型项目。
二、项目搭建与依赖配置
创建一个Maven项目,引入Ehcache和SQLite的JDBC驱动。Ehcache建议使用2.10.x版本,它的API相对直观,社区资料也更丰富。SQLite驱动选择sqlite-jdbc,这是一个纯Java实现的JDBC驱动,无需安装任何本地依赖。
<dependencies>
<!-- Ehcache 核心依赖 -->
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>2.10.9.2</version>
</dependency>
<!-- SQLite JDBC 驱动 -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.44.1.0</version>
</dependency>
</dependencies>接下来在资源目录下创建ehcache.xml,配置一级缓存的容量上限和溢写策略。maxEntriesLocalHeap限制堆内元素数量,超过后按LRU策略淘汰,被淘汰的数据我们将在代码层写入SQLite,而不是让它直接丢失,这就是二级缓存的核心逻辑。
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">
<diskStore path="java.io.tmpdir/ehcache-sqlite-demo"/>
<cache name="userCache"
maxEntriesLocalHeap="1000"
eternal="false"
timeToIdleSeconds="300"
timeToLiveSeconds="600"
memoryStoreEvictionPolicy="LRU"
overflowToDisk="false">
</cache>
</ehcache>注意这里把overflowToDisk设为了false,因为我们不使用Ehcache自带的磁盘溢写,磁盘层完全由SQLite接管。这样职责边界非常清晰:Ehcache只管内存,SQLite只管磁盘,中间的同步由我们封装的组件负责。
三、封装两级缓存的读写逻辑
核心思路是写一个TwoLevelCache工具类,内部持有Ehcache的CacheManager和一个SQLite连接。读取时先查Ehcache,命中直接返回;未命中则查SQLite,查到后回填到Ehcache再返回。写入时同时写Ehcache和SQLite,其中SQLite写入采用INSERT OR REPLACE语法保证幂等。
先看SQLite侧的建表和初始化代码。缓存表设计了三个字段:cache_key存键,cache_value存序列化后的对象,expire_at存过期时间戳。用BLOB字段存序列化字节,可以缓存任意可序列化的Java对象。
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Element;
import java.io.*;
import java.sql.*;
import java.util.Arrays;
public class TwoLevelCache {
private final Cache cache;
private final Connection conn;
public TwoLevelCache() throws Exception {
// 初始化 Ehcache
CacheManager manager = CacheManager.create(
getClass().getResourceAsStream("/ehcache.xml"));
this.cache = manager.getCache("userCache");
// 初始化 SQLite,缓存文件放在用户目录下
File dbFile = new File(System.getProperty("user.home"),
"demo_cache.db");
this.conn = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = conn.createStatement();
st.execute("CREATE TABLE IF NOT EXISTS cache_store (" +
"cache_key TEXT PRIMARY KEY," +
"cache_value BLOB," +
"expire_at INTEGER)");
// 为过期时间建索引,方便定期清理
st.execute("CREATE INDEX IF NOT EXISTS idx_expire " +
"ON cache_store(expire_at)");
st.close();
}
/** 写入两级缓存 */
public void put(String key, Serializable value, int ttlSeconds)
throws Exception {
// 第一级:写入 Ehcache 堆内缓存
cache.put(new Element(key, value, ttlSeconds, ttlSeconds));
// 第二级:序列化后写入 SQLite
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(value);
}
long expireAt = System.currentTimeMillis()
+ ttlSeconds * 1000L;
try (PreparedStatement ps = conn.prepareStatement(
"INSERT OR REPLACE INTO cache_store "
+ "(cache_key, cache_value, expire_at) "
+ "VALUES (?, ?, ?)")) {
ps.setString(1, key);
ps.setBytes(2, bos.toByteArray());
ps.setLong(3, expireAt);
ps.executeUpdate();
}
}
/** 读取:先查内存,未命中再查 SQLite 并回填 */
public Object get(String key) throws Exception {
// 一级命中
Element e = cache.get(key);
if (e != null) {
return e.getObjectValue();
}
// 二级查询
try (PreparedStatement ps = conn.prepareStatement(
"SELECT cache_value FROM cache_store "
+ "WHERE cache_key = ? AND expire_at > ?")) {
ps.setString(1, key);
ps.setLong(2, System.currentTimeMillis());
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
byte[] data = rs.getBytes(1);
try (ObjectInputStream ois =
new ObjectInputStream(
new ByteArrayInputStream(data))) {
Object value = ois.readObject();
// 回填一级缓存
cache.put(new Element(key, value));
return value;
}
}
}
}
return null;
}
public void close() throws Exception {
cache.getCacheManager().shutdown();
conn.close();
}
}这段代码有几个细节值得展开。第一,SQLite的INSERT OR REPLACE语义是主键冲突时整行替换,天然适合缓存的覆盖写场景。第二,过期判断没有依赖定时任务,而是在查询时用expire_at > 当前时间做过滤,这是一种惰性过期策略,简单可靠;如果缓存量大,可以再配合一个低频的清理线程删除已过期行。第三,回填一级缓存时没有指定TTL,实际项目中建议根据业务设置合理的空闲时间,避免回填数据长期驻留内存。
四、性能测试与常见坑点
写一个简单的测试类,对比纯内存、两级缓存、直接查SQLite三种读取路径的耗时。在机械硬盘环境下,典型数据大致是:堆内读取在微秒级,回填后的二级缓存读取也是微秒级(因为命中了一级),未命中走SQLite的读取在0.5到2毫秒之间,这与SQLite单条索引查询的常规表现一致。可以看出,两级缓存的价值在于热点数据完全无磁盘IO,只有冷数据才付出一次磁盘查询的代价,而冷数据被再次访问后会自动升级为热数据。
public class CacheTest {
public static void main(String[] args) throws Exception {
TwoLevelCache tlc = new TwoLevelCache();
tlc.put("user:1001", "张三的资料对象", 600);
// 第一次读取:走 SQLite 二级缓存
long t1 = System.nanoTime();
Object v1 = tlc.get("user:1001");
long t2 = System.nanoTime();
// 第二次读取:命中 Ehcache 一级缓存
Object v2 = tlc.get("user:1001");
long t3 = System.nanoTime();
System.out.println("二级读取耗时: " + (t2 - t1) / 1000 + " 微秒");
System.out.println("一级读取耗时: " + (t3 - t2) / 1000 + " 微秒");
System.out.println("两次结果一致: " + v1.equals(v2));
tlc.close();
}
}整合过程中有几个容易踩的坑需要提醒。一是并发问题:SQLite的写操作在同一个连接上是串行的,多线程场景下建议对Connection加锁,或者采用单一写线程加队列的模型;如果并发写压力大,可以考虑开启WAL模式,即执行PRAGMA journal_mode=WAL;,读写可以并行进行。二是序列化兼容性:缓存对象一旦改了包名或字段结构,旧的反序列化会抛InvalidClassException,建议给实体类显式声明serialVersionUID,并在版本升级时评估是否需要清空缓存。三是文件路径权限:Windows下如果数据库文件放在C:\Program Files等受保护目录,写入会直接失败,Linux下则要注意运行用户的目录写权限,生产环境建议将数据库文件放在独立的、有备份策略的数据盘路径下。
最后总结一下这套方案的最佳使用场景:本地单机应用、桌面软件、数据量在百万级以内的服务端热点缓存,都可以放心使用。如果需要多节点共享缓存,SQLite就不合适了,应该转向Redis这样的集中式缓存方案。通过本文的实战演示,你应该已经掌握了Ehcache与SQLite整合的完整流程,可以在此基础上扩展统计、监控和定期清理等能力,构建出适合自己业务的持久化缓存体系。