缓存失效与并发写入交织时,如何避免旧值回填并保障业务一致性?**
缓存删了就一致吗:一次旧值回填竞争的排查与架构演进
更新库再删缓存仍会脏:问题不在删除,而在删除之后的回填。
[读者] 后端与架构工程师,正在做缓存改造
[痛点] 库更新了、缓存也删了,线上仍读到旧值
[现在读] 因为你正要为一个偶发脏读定责收口
[读完] 能定位竞争窗口,并用四层防线收敛不一致
[旧方案] 写库后删缓存,Cache-Aside
|
v
[新需求] 工单状态、退款金额、权限判断要可核查
|
v
[冲突] 删除与回填是两个动作,中间可被插队
|
v
[后果] 旧值驻留缓存,业务按旧状态做决策
我是老李,带一支交易中台的后端团队。半年前我们把工单详情从主库搬到了 Redis,理由很朴素:工单详情读多写少,列表页和详情页反复刷新,主库压力太大。
真正的变化来自业务。工单从“帮助文档式的记录”变成了“状态驱动的流程入口”:客服按状态决定能不能回复,风控按状态决定要不要拦截,财务按关联金额做对账。数据结构没变,读它的用途变了,一致性等级也就跟着变了。
我们的第一版方案标准得不能再标准:数据库作为权威数据源,更新数据库之后删除缓存;读路径未命中则回源并回填。功能测试和单机联调都通过,缓存命中率也很体面。
删缓存和回填缓存,究竟是哪一个动作决定了一致性?
是把 TTL 调短、给删除多加几次重试就够了,还是必须为数据引入版本水位与可靠的失效事件?
如果继续把“删缓存”当成一致性保证,下一次脏读会不会正好落在退款金额或者权限判断上?
01、故事
团队做的是工单中台,主库一张 ticket 表:id、status、assignee、amount、version。读多写少,典型的缓存候选。任务是给工单详情加一层读缓存,同时保证状态类字段不会读到过期值。
V1 的写路径只有两步:更新主库,删除缓存。读路径也只有两步:命中直接返回,未命中回源并回填,顺手设一个过期时间。这套写法在文档里出现过无数次,我们照着抄了一遍。
问题是从一次客服投诉开始的:客服看到工单是“待处理”,点进去回复,系统提示“工单已关闭”。刷新几次,又正常了。接下来一周,类似现象在“已关闭 → 处理中”“已完成 → 待处理”之间反复出现,方向高度一致:缓存里的状态比主库旧。
它不可稳定复现,重启应用不一定好,过一会儿又自己好了。这个“自己好了”最要命,它几乎把问题指向了过期时间:脏数据是被 TTL 收走的,不是被我们的删除逻辑收走的。
真正的冲突到这里才浮出来:我们写下是一条删除语句,线上跑的却是“删除”与“回填”两条路径的并发竞争。
缓存为读提速
写后失效登场
竞争藏在两步
旧值悄然回填
02、问题
旧方案失效得并不突然。Cache-Aside 本身没有错,错在我们把它当成了一致性保证:删除只能保证“此刻之后不再命中旧值”,它不保证“此刻之后不会有人把旧值写回来”。
业务影响是分等级的。帮助文档式的字段读到旧值,用户顶多骂一句;工单状态读到旧值,客服会按错误状态操作流程,风控会按错误状态放行或拦截;如果金额与权限也走缓存,那就是资损与越权。
技术表现是三条并发行为:缓存值与主库值不一致,且缓存里的 version 更小;同一个 key 在短时间内出现“新值 → 旧值”的回退;TTL 到期后自动自愈,说明陈旧有上界,且上界与写入路径无关。
于是我们把“完成”重新定义成可验证的四条标准:
- 在受控并发用例下,不允许出现已知旧版本覆盖新版本的回填;
- 任何缓存 key 都必须有过期时间,陈旧时间有明确上界;
- 失效动作不因进程崩溃、事务回滚或消息发送失败而丢失;
- 退款金额与权限校验这类路径,可以绕过缓存回源,并以此作为权威结果。
删除只是动作
一致才是目标
影响先从业务
倒推技术表现
03、原理
先把竞争拆成时序看一遍,这是理解后面所有改动的唯一入口:
- 请求 A 读缓存未命中;
- 请求 A 回源主库,读到状态 v1;
- 请求 B 更新主库为状态 v2,提交;
- 请求 B 删除缓存;
- 请求 A 把 v1 写回缓存;
- 此后所有读请求都命中 v1,直到 TTL 到期。
A 和 B 没有任何一方“做错”,删除也确实成功了。问题在于删除和回填之间没有互斥关系:删除作用在“此刻之前的内容”,回填决定“此刻之后的内容”。
反直觉判断:把版本号写进缓存值里,解决不了旧值回填。删除发生后,缓存值不存在了,版本比较的基准也一起消失;回填方没有任何东西可比。版本基准必须存放一个不会被这次失效删掉的地方,比如独立的版本水位 key,或者回源时以主库版本为基准。
反直觉判断:TTL 不是性能参数,而是正确性参数。它决定的是“最坏情况下不一致能存活多久”。把 TTL 调大换来命中率,代价是把不一致窗口一起调大,这笔账在状态类字段上通常是不划算的。
还有一个容易被忽略的边界:数据库提交与缓存失效之间必然存在一个短暂窗口。这个窗口无法靠加锁消除(那等于放弃缓存的意义),只能靠版本与水位把它压缩成“不会被错误放大”的窗口。
删与填分两步
中间皆可插队
版本须在值外
窗口才有上界
04、架构
[输入] 工单读写请求
|
v
[模块] 应用服务 / 缓存层 / 版本水位 / 失效事件通道
|
v
[数据/状态] 主库行(权威+version) | 缓存值 | 水位key | outbox表
|
v
[处理] 提交后失效 | 原子比较回填 | 事件可靠投递 | TTL 收敛
|
v
[输出] 有界陈旧的读结果 / 关键业务权威回源
四层防线各管一段:基础层以主库为权威、失效动作挪到事务提交之后;版本层用只增不减的版本水位,在回填前做原子比较,拦住已知旧版本;事件层用 outbox 或 CDC 把失效做成“不丢且幂等”的事件;兜底层用统一 TTL 给陈旧定上界,关键业务直接回源。
边界:缓存只覆盖“可容忍短暂陈旧”的读路径,权威数据永远在主库;写后立即读的场景不承诺读到最新值,除非走权威接口。
收益:读延迟与主库读压力下降,同时把不一致从“随机、不可解释”变成“有上界、可计数”。
代价:回填多一次水位读取与一次 Lua 调用;引入 outbox 表与消费链路,架构复杂度与运维面上升;跨可用区的失效延迟会变成新的观测对象。
适用条件:读多写少、能接受秒级到 TTL 级的陈旧、业务可以接受读扩散。反过来,结算、扣款、权限判定这类场景不适用,必须回源并以业务事务为准。
四层各守一段
基础只做失效
版本拦住回填
事件兜底善后
05、实战一次
环境与依赖:JDK 17、Spring Boot 3.x、Spring Data Redis、MySQL 8.x、StringRedisTemplate + JdbcTemplate。具体版本号与 API 细节需按当前官方文档核验。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/ticket
username: app
password: app
data:
redis:
host: 127.0.0.1
port: 6379
create table ticket (
id varchar(32) primary key,
status varchar(32) not null,
assignee varchar(32),
amount decimal(12,2),
version bigint not null default 0
);
insert into ticket(id, status, assignee, amount, version)
values ('T-1001', 'PROCESSING', 'u23', 128.00, 1);
V1 的核心实现如下,读路径回源回填,写路径更新主库后删除缓存:
package com.example.ticket;
import java.math.BigDecimal;
public record Ticket(String id, String status, String assignee, BigDecimal amount, long version) {}
package com.example.ticket;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
@Service
public class TicketService {
private static final Duration TTL = Duration.ofMinutes(10);
private final JdbcTemplate jdbc;
private final StringRedisTemplate redis;
private final ObjectMapper mapper = new ObjectMapper();
public TicketService(JdbcTemplate jdbc, StringRedisTemplate redis) {
this.jdbc = jdbc;
this.redis = redis;
}
public Ticket get(String id) {
String key = "ticket:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return read(cached);
}
Ticket ticket = load(id);
if (ticket != null) {
redis.opsForValue().set(key, write(ticket), TTL);
}
return ticket;
}
@Transactional
public void updateStatus(String id, String status) {
jdbc.update("update ticket set status = ?, version = version + 1 where id = ?", status, id);
redis.delete("ticket:" + id);
}
private Ticket load(String id) {
List<Ticket> rows = jdbc.query(
"select id, status, assignee, amount, version from ticket where id = ?",
(rs, i) -> new Ticket(rs.getString("id"), rs.getString("status"),
rs.getString("assignee"), rs.getBigDecimal("amount"), rs.getLong("version")),
id);
return rows.isEmpty() ? null : rows.get(0);
}
private String write(Ticket ticket) {
try {
return mapper.writeValueAsString(ticket);
} catch (JsonProcessingException e) {
throw new IllegalStateException("serialize ticket failed", e);
}
}
private Ticket read(String json) {
try {
return mapper.readValue(json, Ticket.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("deserialize ticket failed", e);
}
}
}
Controller 只需要两个端点:
package com.example.ticket;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TicketController {
private final TicketService ticketService;
public TicketController(TicketService ticketService) {
this.ticketService = ticketService;
}
@GetMapping("/tickets/{id}")
public Ticket get(@PathVariable String id) {
return ticketService.get(id);
}
@PostMapping("/tickets/{id}/status")
public void updateStatus(@PathVariable String id, @RequestParam String status) {
ticketService.updateStatus(id, status);
}
}
启动与请求:
mvn spring-boot:run
curl -s localhost:8080/tickets/T-1001
curl -s -X POST "localhost:8080/tickets/T-1001/status?status=CLOSED"
curl -s localhost:8080/tickets/T-1001
未在当前环境实测,以下为预期结果:
{"id":"T-1001","status":"PROCESSING","assignee":"u23","amount":128.00,"version":1}
更新状态后再次读取的示例输出:
{"id":"T-1001","status":"CLOSED","assignee":"u23","amount":128.00,"version":2}
单线程下这条链路完全跑通,功能没问题。但它留下三个坑:删除发生在事务提交之前、回填时没有任何版本比较、key 的过期时间只在一个写入口设置。这三个坑,接下来会各自咬我们一口。
先跑通再谈完
最小闭环成型
功能测试通过
竞争留待复现
06、排查
第一条诊断链:并发读写下的旧值回填。
现象:客服看到“待处理”,实际已关闭;缓存里的 version 比主库小。
怀疑:删除失败,或者 Redis 主从延迟导致删不掉。
检查:写一个受控并发用例,读线程在回源之后人为停一下再回填,写线程在中间更新并删除。同时在三个阶段打点:读库值、删缓存、回填。
证据(示例日志):
[t1] A cache miss, load db -> ticket:T-1001 status=PROCESSING version=1
[t2] B update db -> status=CLOSED version=2
[t3] B delete cache -> ticket:T-1001
[t4] A fill cache -> ticket:T-1001 status=PROCESSING version=1
[t5] B commit
根因:删除与回填没有互斥关系,回填方手里明明有 version,却没有任何一方拿它做比较。
错误尝试: 我们先是把删除改成了“删除 + 失败重试三次”,问题依旧。为什么错:删除本身是成功的,脏值来自删除之后的回填;重试提高的是“删除的成功率”,而脏值产生于“回填缺少约束”,方向完全错了。
修复:引入版本水位 + 原子比较回填,见下一章。
第二条诊断链:失效动作早于事务提交。
现象:刚改完状态马上查,偶尔读到旧状态,过一会儿自己恢复。
怀疑:Redis 复制延迟。
检查:在 @Transactional 方法里打印删除动作与提交动作的先后顺序,并在主库确认提交时刻。
证据(示例日志):
[db] delete cache ticket:T-1001
[db] transaction commit
根因:删除发生在提交之前。此刻其他读连接看到的主库仍是旧值,任何一次并发回源都会把旧值读出来并回填,而它读到的“旧值”在它眼里完全合法。
修复:把失效动作移到事务提交之后(afterCommit),或改为提交后发事件驱动失效。
第三条诊断链:key 永不过期。
现象:某张工单连续几天显示旧状态,重启应用无效。
怀疑:缓存删除没生效。
检查:直接查该 key 的 TTL。
证据:ttl ticket:T-1002 返回 -1,表示 key 存在但没有过期时间。
根因:主写路径设了 TTL,但另有一处补数脚本用了不带过期时间的 set,脏数据因此没有上界,也永远不会自愈。
修复:收敛写入入口,统一设置 TTL,并对 ttl = -1 的 key 建立告警。
现象先于猜测
证据先于结论
错误尝试排除
根因才有落点
07、优化
针对第 06 章三条证据,V2 只做三件事,每件都对应一个根因。
根因一:删除早于提交。修改:把失效挪到事务提交之后。原因:提交后其他连接才能看到新值,此时删除才有意义。新行为:删除晚于提交执行。
@Transactional
public void updateStatus(String id, String status) {
jdbc.update("update ticket set status = ?, version = version + 1 where id = ?", status, id);
long newVersion = jdbc.queryForObject(
"select version from ticket where id = ?", Long.class, id);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redis.execute(MARK_VERSION, List.of("ticket:" + id + ":ver"),
Long.toString(newVersion));
redis.delete("ticket:" + id);
}
});
}
根因二:回填没有比较基准。修改:引入只增不减的版本水位,回填前用 Lua 原子比较。原因:水位 key 不随数据缓存一起删除,删除之后比较基准仍然存在。新行为:旧版本回填被拒绝。
-- lua/mark_version.lua
-- KEYS[1] = 水位 key, ARGV[1] = 新版本号
local cur = redis.call('GET', KEYS[1])
if (not cur) or tonumber(cur) < tonumber(ARGV[1]) then
redis.call('SET', KEYS[1], ARGV[1])
end
return redis.call('GET', KEYS[1])
-- lua/fill.lua
-- KEYS[1] = 数据 key, KEYS[2] = 水位 key
-- ARGV[1] = 版本号, ARGV[2] = 值, ARGV[3] = TTL 秒
local wm = redis.call('GET', KEYS[2])
if wm and tonumber(wm) > tonumber(ARGV[1]) then
return 0
end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
private static final RedisScript<Long> MARK_VERSION = RedisScript.of(
new ClassPathResource("lua/mark_version.lua"), Long.class);
private static final RedisScript<Long> FILL = RedisScript.of(
new ClassPathResource("lua/fill.lua"), Long.class);
public Ticket get(String id) {
String key = "ticket:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return read(cached);
}
Ticket ticket = load(id);
if (ticket != null) {
redis.execute(FILL, List.of(key, key + ":ver"),
Long.toString(ticket.version()), write(ticket),
Long.toString(TTL.toSeconds()));
}
return ticket;
}
根因三:存在不带过期时间的写入口。修改:写入口收敛到一处并统一 TTL。原因:没有 TTL 的 key 等于把不一致的上界拉到无穷。新行为:任何缓存值都有过期时间。
兜底层补一条:退款金额与权限校验走权威接口,直接读主库。
public Ticket getAuthoritative(String id) {
return load(id);
}
验证方式:跑同一个受控并发用例(读线程在回源后停顿,写线程中途更新并删除),观察两件事:一是最终缓存值是 v2,二是 Lua 返回 0 的次数大于零,说明确实有旧版本回填被拦下。这里给出的是验证方法,具体数值需要在你们自己的环境里实测,不做预设。
需要说清楚的遗留问题:水位 key 本身如果丢失(例如整个实例被清空重建),比较基准就不存在了,此时回源读到的是主库当前值,回填是安全的;但如果读的是延迟副本,就会出现新的风险。所以水位 key 建议不设 TTL,冷启动时用一次全量预热把水位补齐。另外,提交与失效之间的那段窗口依然存在,只是不再被错误放大。
根因决定改法
改法决定代价
水位守在外面
时间守住上界
08、演进
[同一输入] 并发读写同一张工单
|
+--[V1] 事务内删缓存 / 旧值可回填 / 代价低,脏值无界
|
+--[V2] 提交后失效 + 版本水位原子比较 / 旧值被拒 / 代价是水位管理与一次额外调用
|
[Trade-off]
得到:有界陈旧、可计数的拒绝回填、失效不再静默丢失
失去:一次水位读取、一条事件链路、一个 outbox 表
边界:水位需与数据同源,缓存整体清空时需回源取基准
正确性:V1 只能降低不一致概率;V2 能拒绝“已知旧版本”的回填。注意这个措辞,V2 消除的是已被版本水位覆盖的那类竞争,而不是所有竞争。
稳定性:V1 的问题是不可观测、不可复现、靠 TTL 自愈;V2 把不确定性变成了两个可观测指标——拒绝回填次数与最大陈旧时间。
复杂度:V1 是一个方法里的两行代码;V2 多了水位生命周期、Lua 脚本、事务同步回调与事件链路。复杂度上升是真实的,不是可以忽略的代价。
成本:多一次 Redis 往返、一份水位 key 的存储、一个 outbox 表的写入与消费。这些成本换来的是可解释的失效链路。
适用范围:状态类、配置类数据适合 V2;纯展示、可容忍长陈旧的帮助文档类数据,V1 加合理 TTL 就够,不必强上水位。
遗留问题:afterCommit 里的删除如果抛异常,事务已经提交、删除却失败了,此时必须有补救——要么落 outbox 重试,要么用 CDC 从 binlog 驱动失效。这一步在 V2 里只是被指出来,还没有真正闭环。
同一输入两版
行为各自不同
取舍写进边界
遗留留给下轮
09、洞见
9.1 缓存是延迟组件,不是一致性组件
缓存的能力圈很清楚:降低读取延迟、降低主库负载。它不天然提供数据库事务级的一致性。把“删缓存”写进写路径,本质是尽力而为的失效,不是一致性证明。
反直觉判断:命中率越高,脏值存活时间可能越长。命中的读请求越多,回源与回填的次数也越多,旧值被写回的窗口出现频次随之上升。高命中率会掩盖问题,而不是消除问题。
9.2 版本信息必须活在“被删除的东西”之外
V1 的表里有 version,看起来是版本化设计,但回填时没人拿它做比较。即使把 version 一起写进缓存值,一旦缓存被删掉,比较基准也跟着消失。
反直觉判断:在缓存值里保存版本号,等于把判断依据存放在一个随时会被删掉的对象里。真正管用的是缓存之外那份只增不减的版本水位,或者回源时以主库当前版本为基准。
9.3 失效链路的可靠性优先于失效的及时性
失效可以是异步的,但异步就必须回答两个问题:事件会不会丢,重复消费会不会错。Outbox、CDC、重试与幂等消费解决的正是这两件事。
反直觉判断:把失效异步化并不必然让一致性变差。一个同步执行但失败后无人知晓的删除,往往比一个延迟几百毫秒但保证送达的失效事件更危险,因为前者是静默失败。
9.4 一致性要分级,不能全局加锁
帮助文档可以容忍短暂不一致;工单状态需要版本控制与可靠失效;退款金额与权限校验必须以权威数据源和业务事务为准。三个等级、三种成本,用一把锁解决所有问题,等于把性能优化全部退回原点。
缓存只管提速
不保数据正确
版本活在删外
事件重于命中
10、系统落地
原来我们手上只有一件东西:Cache-Aside 的删除动作,以及一句“更新库后删缓存”的口头约定。
本篇新增了四样:事务提交后的可靠失效入口、只增不减的版本水位与原子比较回填、outbox/CDC 组成的失效事件通道、统一 TTL 与权威回源接口。
现在这套读路径能做到:拒绝已知旧版本的缓存回填;失效动作不因事务回滚或进程崩溃而静默丢失;每个 key 的陈旧时间有明确上界;退款金额与权限校验可以随时绕开缓存拿到权威结果。
还缺三块:水位 key 的持久化与冷启动预热策略;失效事件的端到端度量(产生、投递、消费、失败);多副本或多活场景下水位自身的收敛问题——不同机房的水位不一致时,比较结果就不再可信。
下一步的演进方向是把一致性等级写进接口契约:每个读接口标明它承诺的是“有界陈旧”还是“权威读取”,然后用五类度量驱动演进——过期读取比例、最大数据陈旧时间、缓存失效延迟、旧版本回填拒绝次数、回源失败率。度量到位之后,TTL 该设多长、哪些数据要不要上水位,都变成可以用数据回答的问题,而不是靠争论。
原有删缓存而已
新增水位与事件
现在能拒回填
还缺分级度量
11、小结
Q1 → 决定一致性的是删除之后的回填,而不是删除本身
Q2 → 选版本水位 + 可靠失效事件,TTL 只是上界而非方案
Q3 → 会,退款金额与权限校验是下一个脏读现场,必须回源
状态 → 可交付:旧值回填被拒、失效事件不丢、陈旧时间有上界的读路径
这套方案的定位要说清楚:它把缓存不一致从“随机且不可解释”变成了“有上界且可计数”,而不是把它消灭。数据库提交与缓存失效之间的窗口仍在,只是不再被回填放大成长期脏读。
一问定窗口
二问定机制
三问定边界
状态可交付
12、作业
12.1 理解题:为什么把版本号存进缓存值里,仍然阻止不了旧值回填?
参考答案:因为回填发生在缓存被删除之后。删除把数据值连同它携带的版本号一起清掉了,回填方拿不到任何可比较的基准,只能盲写。版本基准必须存放在一个不会被这次失效删掉的地方,例如独立的版本水位 key,或者回源时直接以主库当前版本作为基准。
12.2 实战题:把 V1 的删除动作改成“提交后才失效”,需要注意哪些实现要点?
参考答案:用事务同步回调在 afterCommit 阶段执行失效,或先提交再发事件;不要在事务内删除。afterCommit 中要处理自身失败:本地删除失败必须落 outbox 重试或由 CDC 补偿,绝不能吞异常;同时要保证重复失效是幂等的,删除一个不存在的 key 应当视为成功。
12.3 排障题:线上某张工单连续几天显示旧状态,重启应用无效,如何定位?
参考答案:先查该 key 的 TTL,若返回 -1 说明没有过期时间,脏值没有上界;再排查所有写入口,找是否存在不带 TTL 的 set 或补数脚本;然后把缓存里的 version 与主库 version 对比,确认是“旧版本驻留”还是“读取副本延迟”;修复后统一写入入口并建立 ttl = -1 的告警。
12.4 架构判断题:团队建议“把 TTL 从 10 分钟调到 30 秒”来解决不一致,你是否同意?
参考答案:部分同意,但不能当作解决方案。缩短 TTL 只是缩小不一致上界、降低脏读存活时长,竞争窗口本身没有任何改变,高频并发下依旧会发生。正确做法是先把失效挪到提交后、加上版本水位与原子比较回填,再用 TTL 作为兜底;TTL 的具体取值应该由业务能容忍的最大陈旧时间来定,而不是由命中率来定。
题在纸上练
手在线上验
答案非唯一
判断有边界
13、思考
回到本篇的核心冲突:删除与回填是两个独立动作,任何把它们当成一个原子的假设,都会在并发下破功。
由此可以抽出三条可复用的判断。只要一条读路径存在“回源 + 写回”,它就必须回答:我写回的,是不是已知最新的值?只要失效是一个独立动作,它就必须回答:这次失效失败了,谁会知道?只要系统选择容忍陈旧,它就必须回答:最坏情况下,能陈旧多久?
这三个问题答不上来,缓存改造就只是在赌概率。缓存性能是优化项,业务正确性才是约束条件。
删缓存非一致
版本守在删外
失效须得可靠
陈旧终有上界

浙公网安备 33010602011771号