缓存组件重构:一个接口塞了 50 个方法

random-pic-api

一个接口,五十几个方法

打开缓存组件的 CacheService 接口,我看到的是一个长达 573 行的定义——五十几个方法,从基本的 get/set/delete,到 Hash 操作的 hget/hset,到 List 操作的 lpush/lpop,到 Set 操作,再到各种带过期时间的变体。它像是把 Redis 的所有命令直接翻译成了 Java 方法,然后用一个接口兜住。

这种设计有几个后果。

实现类被迫实现所有方法。 即使某个实现只支持 String 操作,它也得把 Hash、List、Set 的方法全部写上——哪怕方法体是 throw new UnsupportedOperationException()。这是典型的”胖接口”问题,违反了接口隔离原则(ISP)。

使用者不知道什么时候该用什么。 五十几个方法摊在一个接口里,光 IDE 的自动补全列表就要翻好几页。想缓存一个简单的 KV 对,要在一堆 putHashpushLeftaddSet 里找到正确的 set 方法。

扩展新能力时改的就是这个接口。 每次想加一个缓存操作类型,比如 Bitmap 或者 Geo,这个接口就要加方法。然后所有实现类都要跟着改。接口越来越胖,改一次影响面越来越大。

问题的根源是把”缓存”当成了一个单一概念。但实际场景中,”缓存”是多种数据结构的集合——有人用 String,有人用 Hash 存对象字段,有人用 List 做消息队列,有人用 Set 做去重。它们之间没有内在联系,只是恰好都由 Redis 提供而已。强行把它们捏在一个接口里,是混淆了”基础设施”和”抽象层级”的边界。

拆开的思路

说白了,解决方案就是拆。按操作类型拆成不同的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 最基础的 KV 操作 —— 90% 的场景用这个就够了
public interface BasicCacheOperations {
boolean hasKey(String key);
<T> T get(String key);
<T> void set(String key, T value);
<T> void set(String key, T value, Duration expiration);
void delete(String key);
boolean expire(String key, Duration expiration);
}

// Hash 操作 —— 存对象字段的场景
public interface HashCacheOperations {
<T> void putHash(String key, String hashKey, T value);
<T> T getHash(String key, String hashKey);
<T> Map<String, T> getHashAll(String key);
void deleteHash(String key, String... hashKeys);
}

// List 操作 —— 消息队列、时间线等场景
public interface ListCacheOperations {
<T> void pushLeft(String key, T value);
<T> void pushRight(String key, T value);
<T> T popLeft(String key);
<T> T popRight(String key);
<T> List<T> range(String key, int start, int end);
}

业务方只注入自己需要的接口:

1
2
3
4
5
6
7
8
9
@Service
public class UserService {
// 只需要 KV 缓存,就只注入这个
private final BasicCacheOperations cache;

public UserService(BasicCacheOperations cache) {
this.cache = cache;
}
}

这样做有三个好处。只引入需要的能力,不会看到一堆无关的方法。实现类只实现一个窄接口,代码量小、测试容易。新增操作类型不需要改已有接口,比如加 Bitmap 就加一个 BitmapCacheOperations,对现有代码零影响。

实现层可以根据实际使用的 Redis 客户端(Redisson、Jedis、CtgJedis)提供不同实现,但都遵循同样的接口契约。模板方法模式在这里也很自然——公共逻辑(key 前缀校验、序列化、异常处理)放在抽象基类中,具体的数据操作由子类实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public abstract class AbstractCacheTemplate implements BasicCacheOperations {

protected abstract void doSet(String key, byte[] value, Duration expiration);
protected abstract byte[] doGet(String key);

@Override
public final <T> void set(String key, T value, Duration expiration) {
validateKey(key);
validateValue(value);
String finalKey = buildKey(key);
try {
doSet(finalKey, serialize(value), expiration);
} catch (Exception e) {
throw new CacheException("设置缓存失败, key=" + finalKey, e);
}
}
}

这样具体的 Redis 客户端实现(Redisson、Jedis)只需要实现 doSetdoGet 两个方法,key 校验、序列化、异常处理全部在基类里搞定。新的客户端接入时,关注点只有”怎么连上 Redis”和”怎么读写数据”,不用考虑上层逻辑。

静态工具类的罪与罚

缓存组件里还有一个让我很在意的东西——CacheUtil。它是一个静态工具类,内部持有一个单例的 CacheService 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CacheUtil {
private static volatile CacheService cacheService;

public static void setCacheService(CacheService service) {
cacheService = service;
}

public static <T> T getObject(String key) {
if (StringUtils.isEmpty(key)) return null;
return cacheService.get(key);
}
// ... 还有几十个类似的静态方法,每一个都有相同的空值检查逻辑
}

这种写法的第一个问题是和 Spring 的依赖注入理念冲突。在 Spring 管理的容器里,你用 CacheUtil.setCacheService(service) 手动设置了一个静态单例,这意味着:

  • 无法通过构造函数注入,单元测试很难 mock;
  • 如果 CacheService 还没初始化就被调用,直接 NPE;
  • 如果有多个不同配置的缓存实例,静态工具类没法区分。

第二个问题是代码重复CacheUtil 里每个方法都在重复做同样的事——检查 key 是否为空,检查 cacheService 是否为空,调用对应的 CacheService 方法。五十几个方法,同样的逻辑重复了五十几次。写的时候不觉得,改的时候就知道疼了。

其实 CacheUtil 出现的原因我大概能猜到——大概是有同事觉得每次都注入 CacheService 太麻烦,写个静态工具类一行搞定。但这是用”方便”换”正确性”的典型操作。更方便的做法是保留 CacheService 作为 Spring Bean,需要用的地方直接注入。如果某些不能走依赖注入的场景(比如静态工具类、非 Spring 管理的类),可以通过 SpringContextHolder 获取 Bean,但不要反过来——不要因为方便就把核心能力静态化。

泛型放错了位置

上面说的都是接口太大、工具类太僵的问题。但看完 CacheServiceCacheUtil 的代码,还有一个更隐蔽的设计问题——泛型参数 T 放在了接口上,而不是方法上。

CacheService 的定义是这样的:

1
2
3
4
5
6
7
public interface CacheService<T> {
T getObject(String key);
void setObject(String key, T value);
Map<String, T> getMapItems(String key);
List<T> getListItem(String key);
// ... 其他方法
}

<T> 在接口级别,意味着一个 CacheService 实例只能操作一种类型。你注入 CacheService<User>,那就只能存取 User 对象;注入 CacheService<Order>,就只能存取 Order 对象。

但缓存不是这样用的。同一个 Redis 实例里,key "user:1001" 存的是 User,key "order:5002" 存的是 Order,key "config:site" 存的是 SiteConfig。一个缓存组件天然要处理多种类型。把 <T> 放在接口级别,等于是给一个多类型的容器强加了单类型的约束。

这个设计缺陷直接导致了 CacheUtil 里的各种怪现象。

第一,注入时用了裸类型。 CacheUtil 是这样注入 CacheService 的:

1
2
@Resource
private CacheService cacheService; // 裸类型,没有泛型参数

如果你写成 CacheService<User>,那就只能操作 User;写成 CacheService<Object>,那 getObject 返回的就是 Object,调用方还是要自己强转。无论怎么写都不对,所以干脆不写泛型参数——裸类型。而 Java 的裸类型是为了兼容 Java 5 之前的老代码存在的,IDEA 会给你一个黄色警告,但在当时的设计下,这个警告没法消掉。

第二,到处是强制类型转换。 因为注入的是裸类型,getObject 返回的是 Object,所以 CacheUtil 的每个 getter 方法都在做强转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static <T> T getObject(String key) {
return !StringUtils.isEmpty(key) ? (T) cacheUtil.cacheService.getObject(buildKey(key)) : null;
}

public static <T> T getMapItem(String key, String itemid) {
return !StringUtils.isEmpty(key) && !StringUtils.isEmpty(itemid)
? (T) cacheUtil.cacheService.getMapItem(buildKey(key), itemid)
: null;
}

public static <T> T lpop(String key) {
return !StringUtils.isEmpty(key)
? (T) cacheUtil.cacheService.lpop(buildKey(key))
: null;
}

每一个 (T) 都是一个潜在的 ClassCastException——只不过它不在这个工具类里炸,而是在调用方拿到对象、当成某个类型使用的时候炸。编译器帮不了你,因为强制转换绕过了所有类型检查。

第三,假装接受 Class 参数但根本不用。 CacheUtil 里有两个方法接受 Class<T> 参数,看起来像是要做类型安全的反序列化:

1
2
3
4
5
6
7
8
9
10
11
12
public static <T> T getObjectByte(String key, Class<T> clazz) {
byte[] bytes = getObjectByte(key);
return bytes == null ? null : ObjectUtil.deserialize(bytes);
}

public static <T> T getObject(String key, Class<T> clazz) {
if (!StringUtils.isEmpty(key)) {
return getObject(key);
} else {
return null;
}
}

clazz 参数传进来了,但方法体里完全没用到。Java 原生序列化(ObjectUtil.deserialize)不需要传入目标类型,它直接从字节流里还原对象的完整类型信息。getObject(String, Class) 更离谱——直接调了 getObject(String)clazz 形同虚设。这两个方法的存在反而会误导调用方,让人觉得”我传了类型,返回的就是这个类型,很安全”,实际上没有任何类型安全保障。

更有意思的是 getObjectByte 方法上注释的原文:*”涉及的子类都需要添加,否则无法序列化;所以这里没有生效此参数”*。作者自己也知道 clazz 参数没生效,但还是把它留在了方法签名里。这种”留个口子但不用”的做法,后来者看了会困惑:这个参数到底该不该传?传了有什么用?

第四,藏着 ClassCastException 的定时炸弹。 看这个方法:

1
2
3
4
5
6
7
public static <T> List<T> getObjectList(String key) {
if (!StringUtils.isEmpty(key)) {
return getObject(key);
} else {
return null;
}
}

getObject(key) 的返回类型是 T(单个对象),但这里直接把它赋值给了 List<T>。编译器不会报错,因为方法级的 <T> 遮盖了实际类型——Java 的泛型擦除让这段代码编译通过,但运行时如果缓存里存的不是 List,就是 ClassCastException。而且这个异常不在这里抛,在调用方遍历这个 List 的时候才抛——排查起来够你喝一壶的。

怎么改。 根因是 <T> 放错了位置。缓存组件的接口不应该在接口级别定义泛型,而应该在方法级别:

1
2
3
4
5
6
7
// 正确做法:泛型在方法上
public interface CacheService {
<T> T getObject(String key);
<T> void setObject(String key, T value);
<T> Map<String, T> getMapItems(String key);
// ...
}

但光这样还不够——遇到 JSON 反序列化时,你还是需要告诉框架目标类型是什么(不然反序列化成 LinkedHashMap 还是 User?)。所以对于需要反序列化的方法,加上 Class<T> 参数,并且真正使用它:

1
2
3
4
5
public interface CacheService {
<T> T getObject(String key, Class<T> type);
<T> void setObject(String key, T value);
<T> Map<String, T> getMapItems(String key, Class<T> valueType);
}

这样 CacheUtil 就不需要裸类型和强制转换了。Class<T> 参数不仅用于反序列化,也是编译期的类型证明——调用方传了 User.class,编译器就能推断出返回 User,不需要任何强转。

这是接口分离之外的另一个重构维度。但正如开头说的渐进式策略,把接口级的 <T> 移到方法级属于不兼容变更——所有引用了 CacheService<User> 的地方都要改。所以它应该和接口拆分一起做,放在第二阶段,同时保留旧接口并标记 @Deprecated,给业务方足够的迁移时间。

序列化:一个被忽略的性能点

框架里缓存组件的序列化用的是 Java 原生序列化:

1
2
3
4
public <T> void set(String key, T value) {
byte[] bytes = ObjectUtil.serialize(value);
redis.set(key, bytes);
}

Java 原生序列化的问题不只是慢——序列化后的数据体积通常是 JSON 的 2-5 倍,跨语言不兼容,而且反序列化有安全风险(反序列化漏洞)。对于一个缓存组件来说,序列化的性能直接影响缓存的读写速度。如果每次 get 都比 JSON 慢几毫秒,那在高并发场景下,这些毫秒就变成了秒。

换成 JSON 序列化并不复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class JsonSerializer implements CacheSerializer {
private final ObjectMapper objectMapper;

public JsonSerializer(ObjectMapper objectMapper) {
// 复用 Spring 的 ObjectMapper,保持序列化行为一致
this.objectMapper = objectMapper;
}

@Override
public byte[] serialize(Object obj) {
try {
return objectMapper.writeValueAsBytes(obj);
} catch (JsonProcessingException e) {
throw new SerializationException("缓存序列化失败", e);
}
}

@Override
public <T> T deserialize(byte[] data, Class<T> type) {
try {
return objectMapper.readValue(data, type);
} catch (IOException e) {
throw new SerializationException("缓存反序列化失败", e);
}
}
}

复用 Spring 的 ObjectMapper 而不是自己 new 一个的好处是:保持了序列化行为的一致性。Spring 的 ObjectMapper 已经配置好了日期格式、时区、各种 Jackson 模块,你直接用就好。

但有一个需要注意的细节:JSON 序列化会丢失类型信息。用原生序列化时,deserialize(bytes) 拿到的是原始对象。用 JSON 时,deserialize(bytes, User.class) 需要额外传入目标类型。对于复杂的泛型对象(如 List<User>),还需要 TypeReference。这也是为什么缓存接口的 get 方法需要接受一个 Class<T> 参数的原因。

连接池:容易被忽略的基础设置

缓存组件的连接池配置比较随意。Jedis 模式下:

1
2
3
4
// 当前配置
maxTotal: 10
maxIdle: 10
minIdle: 3

一个微服务在高并发下几十个线程同时访问 Redis,maxTotal=10 意味着很多线程在等连接。更合理的值取决于实际负载,但至少 maxTotal 和 CPU 核心数、业务线程数有个合理的比例关系。建议的基线:

1
2
3
4
5
6
7
8
framework:
cache:
redisson:
pool:
max-active: 64 # 视并发量调整
max-idle: 32
min-idle: 10
max-wait: 3000ms

还要注意连接泄露的问题。每次缓存操作应该在 finally 块中归还连接。最好的做法是不要在接口暴露连接对象,让连接的管理完全封装在实现层内部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不好的做法:暴露连接对象
Jedis jedis = pool.getResource();
try {
jedis.set(key, value);
} finally {
jedis.close();
}

// 好的做法:封装在实现内部
public <T> void set(String key, T value) {
try (Jedis jedis = pool.getResource()) {
// 连接获取和归还由 try-with-resources 保证
jedis.set(key, serialize(value));
}
}

还有一种情况要警惕——RedissonClient 是线程安全的,不需要每次操作都获取新连接。但 Jedis 对象不是线程安全的,不能跨线程共享。不同的 Redis 客户端有不同的线程模型,缓存抽象层要做好隔离,不能让上层业务感知到这些差异。

密码不应该明文躺着

缓存配置里 Redis 密码直接明文写在 yaml 里:

1
2
3
4
spring:
data:
redis:
password: myPlainTextPassword

最低限度的改进是用环境变量:

1
2
3
4
spring:
data:
redis:
password: ${REDIS_PASSWORD}

如果框架有内置的加密能力(比如国密 SM4),可以支持密文配置:

1
2
3
4
spring:
data:
redis:
password: "{cipher}base64EncodedEncryptedPassword"

ConfigDecryptor 在启动时检测 {cipher} 前缀,自动解密。这样配置文件里不会出现明文密码,即使配置文件泄露,攻击者也拿不到真实的 Redis 密码。

渐进式重构

这个缓存组件的改动原则和 Response 一样:保持向后兼容,分阶段迁移

第一阶段,把 @Value 注入改成 @ConfigurationProperties,统一配置管理。这一步对业务方完全透明。

第二阶段,拆出 BasicCacheOperationsHashCacheOperations 等新接口,同时保留旧的 CacheService 接口——让它继承新接口,标记 @Deprecated

1
2
3
4
@Deprecated
public interface CacheService extends BasicCacheOperations, HashCacheOperations, ListCacheOperations {
// 旧方法保留,代理到新接口
}

第三阶段,替换序列化方式,从 Java 原生序列化切换到 JSON。同时需要兼容已有的缓存数据——要么做数据迁移,要么对无法反序列化的 key 做降级处理(删除旧缓存、写入新格式)。

第四阶段,加监控。缓存命中率、平均延迟、连接池利用率,这些指标在出现性能问题之前就应该被收集起来。Prometheus 的 TimerCounter 就能满足需求:

1
2
3
4
5
6
7
8
9
10
@Component
public class CacheMetrics {
private final Counter hitCounter;
private final Counter missCounter;
private final Timer operationTimer;

public void recordHit() { hitCounter.increment(); }
public void recordMiss() { missCounter.increment(); }
public Timer.Sample startTimer() { return Timer.start(); }
}

有了这些指标,你才知道缓存的命中率有没有因为业务变更而下降,某个 key 的读写延迟有没有突然飙升。这些信息在没有监控之前,只有等业务方反馈”系统变慢了”才能知道。


下一篇聊配置管理。@ConfigurationProperties 的嵌套校验怎么做、共享配置怎么分层、Spring Boot 配置加载顺序里的坑——为什么 application.yml 不能随意拆分。这是我在项目中改动最多、见效最快的一个方向。