4、状态访问路径分析:直接访问路径、间接访问路径、缓存穿透路径、回源路径
状态访问路径,说白了就是数据从「存的地方」到「用的地方」这条路怎么走。我见过太多团队一上来就堆缓存、上分布式,结果性能没上去,反而把系统搞复杂了。其实,先把访问路径理清楚,很多问题就迎刃而解。
我个人习惯把状态访问路径分成四种:直接访问、间接访问、缓存穿透和回源。这四种路径,每一种都有它的适用场景和坑。
核心观点: 80%的性能问题,根源在于访问路径选择不当。路径越短,延迟越低;路径越长,一致性越难保证。
4.1 直接访问路径
直接访问,就是应用程序直接读写状态存储。没有中间层,没有缓存,没有代理。你想想看,这其实是最简单、最可靠的路径。
举个例子,一个单机应用,状态存在本地内存里,读写就是指针操作,延迟在纳秒级。这就是典型的直接访问。
// 直接访问路径示例:本地内存状态
public class LocalStateStore {
private Map<String, Object> stateMap = new ConcurrentHashMap<>();
public Object get(String key) {
return stateMap.get(key); // 直接访问,无中间层
}
public void set(String key, Object value) {
stateMap.put(key, value); // 直接写入
}
}
直接访问的优点很明显:延迟最低、实现简单、没有缓存一致性问题。但缺点也致命——无法水平扩展。一旦应用实例增多,每个实例都有自己的状态,数据就散架了。
我的经验: 直接访问路径最适合「无状态应用」中的本地缓存,或者单机部署的小工具。千万别在分布式系统里用直接访问来共享状态,否则你会被数据不一致搞疯。
4.2 间接访问路径
间接访问,就是中间加了一层。最常见的间接访问路径是:应用 → 缓存 → 数据库。应用不直接读写数据库,而是先走缓存。
为什么要加这一层?说白了,就是为了扛住高并发。数据库的并发能力有限,缓存(比如 Redis)能轻松扛住每秒几万甚至几十万的请求。
// 间接访问路径示例:缓存 + 数据库
public class CacheAsideStateStore {
private Cache cache; // 缓存层
private Database db; // 数据库层
public Object get(String key) {
Object value = cache.get(key); // 先查缓存
if (value == null) {
value = db.get(key); // 缓存未命中,查数据库
cache.set(key, value); // 回填缓存
}
return value;
}
}
间接访问路径的典型模式有:
- Cache-Aside(旁路缓存):应用同时感知缓存和数据库,自己维护数据同步
- Read-Through(读穿透):缓存层自动从数据库加载数据,应用只跟缓存打交道
- Write-Through(写穿透):写入缓存时,缓存自动同步写入数据库
- Write-Behind(写回):写入缓存后,异步批量写入数据库
| 模式 | 读性能 | 写性能 | 一致性 | 复杂度 |
|---|---|---|---|---|
| Cache-Aside | 高 | 中 | 弱 | 低 |
| Read-Through | 高 | 中 | 弱 | 中 |
| Write-Through | 中 | 低 | 强 | 中 |
| Write-Behind | 中 | 高 | 最终 | 高 |
避坑指南: 我曾经在一个高并发项目中用了 Write-Behind 模式,结果缓存宕机,还没来得及写入数据库的几千条订单数据全丢了。从那以后,我对「最终一致性」有了刻骨铭心的理解——最终一致性意味着「最终可能会丢」。
4.3 缓存穿透路径
缓存穿透,指的是请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库上,缓存形同虚设。
为什么会这样?最常见的原因是:恶意攻击或业务漏洞。比如一个电商系统,有人不断请求一个不存在的商品 ID,缓存里没有,数据库里也没有,每次请求都穿透到数据库。
// 缓存穿透的典型场景
public Object getProduct(String productId) {
Object product = cache.get(productId);
if (product == null) {
// 缓存未命中,查数据库
product = database.get(productId);
if (product != null) {
cache.set(productId, product); // 存在则回填
}
// 问题:如果数据库也不存在,product 为 null,不缓存 null
// 下次同样的请求,依然穿透
}
return product;
}
解决缓存穿透,我常用的方法有三种:
- 缓存空对象:即使数据库查不到,也缓存一个空值(比如 "NULL"),设置较短的过期时间
- 布隆过滤器:在缓存前面加一层布隆过滤器,判断 key 是否存在。不存在的 key 直接拒绝
- 参数校验:在入口层校验请求参数,不合法的直接返回
性能数据: 一次缓存穿透,意味着一次网络开销(查缓存)+ 一次数据库查询。如果数据库查询耗时 50ms,1000 次穿透就是 50 秒的数据库压力。而缓存空对象,只需要 1ms 就能挡住后续的穿透请求。
4.4 回源路径
回源路径,指的是缓存未命中时,从原始数据源(数据库、远程服务、文件系统等)重新加载数据的路径。回源路径是间接访问路径的「兜底方案」。
回源路径最怕什么?回源风暴。当缓存中大量 key 同时过期,或者缓存宕机重启,所有请求同时回源,数据库瞬间被打爆。
// 回源路径的优化:互斥锁防止回源风暴
public Object getWithMutex(String key) {
Object value = cache.get(key);
if (value == null) {
// 尝试获取分布式锁
if (lock.tryLock(key)) {
try {
// 双重检查,防止其他线程已经回源
value = cache.get(key);
if (value == null) {
value = database.get(key); // 只有拿到锁的线程回源
cache.set(key, value);
}
} finally {
lock.unlock(key);
}
} else {
// 没拿到锁的线程等待
Thread.sleep(50);
return getWithMutex(key); // 递归重试
}
}
return value;
}
回源路径的优化策略:
- 互斥回源:同一时刻只有一个线程回源,其他线程等待
- 异步回源:回源操作异步执行,不阻塞请求线程
- 预回源:在缓存过期前主动刷新,避免过期瞬间的回源高峰
- 回源降级:回源失败时返回旧缓存数据(即使过期),而不是直接报错
我的习惯: 在设计回源路径时,我总会问自己一个问题:「如果缓存全部失效,系统能扛住吗?」如果答案是否定的,那就需要做限流、降级或者预回源。别等到线上出问题了才想起来。
4.5 四种路径的对比与选择
四种路径,没有的好坏,关键看场景。我整理了一张对比表,方便你快速决策:
| 路径类型 | 延迟 | 吞吐量 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 直接访问 | 极低 | 极高 | 强 | 单机应用、本地缓存 |
| 间接访问 | 低 | 高 | 弱/最终 | 高并发读、读写分离 |
| 缓存穿透 | 高 | 低 | — | 需要避免的场景 |
| 回源路径 | 中 | 中 | 最终 | 缓存未命中的兜底 |
下面这张图,把四种路径的关系画清楚了:
4.6 路径选择的实战原则
说了这么多,到底怎么选?我总结了几条实战原则:
- 能不绕路就不绕路:如果直接访问能满足性能要求,就别加缓存。多一层就多一份复杂度。
- 缓存穿透必须防:不管用哪种间接访问路径,缓存穿透防护是标配。布隆过滤器 + 空对象缓存,二选一或组合使用。
- 回源路径要限流:回源路径是系统的「最后一道防线」,必须加限流和熔断。我习惯在回源路径上设置单独的线程池,避免回源请求占满业务线程。
- 监控路径延迟:每条路径的延迟都要监控。直接访问超过 1ms 就要警惕,间接访问超过 10ms 就要优化,回源路径超过 100ms 必须告警。
我曾经踩过的坑: 有一次线上事故,就是因为回源路径没有限流。缓存集群一台机器宕机,大量 key 回源,数据库连接池被打满,导致整个服务不可用。事后复盘,如果在回源路径上加一个简单的信号量限流,最多就是部分请求返回旧数据,不至于全站挂掉。
嗯,状态访问路径的分析,说白了就是搞清楚数据怎么流、流到哪里会堵。下一层,我们会把这些路径跟具体的缓存策略结合起来,看看怎么在实际项目中做性能调优。