业务系统性能改造手册 · 请求减量
用缓存截断热点读取
从业务时效容忍度和读取成本推导订单缓存边界,显式实现旁路读取、提交后失效与异常回退,并用数据库减负、尾延迟、内存和数据偏差共同验收。
订单详情是热点接口,就一定应该加缓存吗?如果用户正在确认刚完成的状态变化,返回旧数据可能比慢一点更糟;如果一次详情查询本来只查主键、数据库也没有压力,缓存增加的序列化、内存和失效路径未必划算。
02-01 已经把业务意图、入口请求和数据库成本连起来。只有清单中那些读取频繁、数据可复用、下游成本明确,并且业务允许有限时效偏差的候选,才值得进入缓存设计。
小编对缓存的理解比较克制:它是用一段明确的数据偏差窗口和额外运维成本,换取数据库读取减少。本文围绕订单详情给出一个 Cache-Aside 示例;同一热点键失效时的并发回源留给 02-03。
一、先决定哪些查询可以接受旧数据
“读多写少”只是筛选条件,不是上线理由。每个候选还要回答:结果是否由稳定业务键唯一确定,计算或查询成本是否值得复用,调用方要求当前值还是允许短时旧值,读错旧值会造成什么后果。
订单查询可以按使用目的拆开:
| 查询场景 | 可能的时效要求 | 缓存判断 |
|---|---|---|
| 用户查看历史已完成订单 | 通常允许有限偏差,但需业务确认 | 候选 |
| 用户提交后等待状态变化 | 对变化可见时间敏感 | 谨慎,必要时旁路 |
| 客服处理争议订单 | 可能要求读取当前状态 | 默认旁路,除非流程明确允许 |
| 写操作前的业务校验 | 往往影响不变量 | 不把普通查询缓存当权威数据源 |
表里的结论不是行业规则。项目需要把“允许有限偏差”换成可验收的业务承诺,例如状态变化后多久必须对某类调用方可见。没有真实要求时,用 ${MAX_ACCEPTABLE_STALENESS} 占位,不能先拍一个 TTL 再反推业务接受它。
适合进入试验的对象应同时具备证据:02-01 的成本链证明该查询确实造成可减少的数据库工作;访问分布证明结果存在复用;业务方确认偏差窗口;缓存失效或不可用时有明确行为。任一条件缺失,先保留为候选。
二、键和值要表达业务边界
缓存键至少区分租户或数据权限边界、资源类型、业务主键与值结构版本:
1order:detail:v${SCHEMA_VERSION}:${TENANT_SCOPE}:${ORDER_ID}示例只表达组成方式。实际键需采用无歧义编码,并避免把敏感标识直接暴露到不受控日志。授权条件不能只存在于查询参数里却不进入键或读取校验,否则不同权限范围可能复用到同一结果。
值也不建议直接序列化数据库实体。缓存面向查询契约,保存调用方需要的稳定字段,并携带足以判断兼容性和新旧程度的信息:
1{
2 "schemaVersion": "${SCHEMA_VERSION}",
3 "orderVersion": "${ORDER_VERSION}",
4 "cachedAt": "${TIMESTAMP}",
5 "found": true,
6 "payload": {
7 "orderId": "${ORDER_ID}",
8 "status": "${STATUS}",
9 "totalAmount": "${AMOUNT}"
10 }
11}schemaVersion 用来拒绝不兼容的旧结构,orderVersion 用于观测和偏差核验。它们不会自动提供强一致性;是否允许返回该版本,仍由具体读取场景决定。
2.1 TTL 从偏差窗口推导
TTL 的上界先受业务可接受偏差约束,再结合写后失效成功率、热点持续时间、内存预算和数据库回源能力调整。示例配置保留变量:
1orderCache:
2 enabled: ${TRUE_OR_FALSE}
3 schemaVersion: ${VERSION}
4 detailTtl: ${DURATION_NOT_GREATER_THAN_ACCEPTABLE_STALENESS}
5 negativeTtl: ${SHORT_DURATION}
6 expiryJitter: ${RANGE_FOR_SPREADING_BULK_EXPIRY}
7 bypassCallers: ${CALLERS_REQUIRING_CURRENT_STATE}
8 maxSerializedValueBytes: ${MEASURED_LIMIT}随机抖动可以减少大量不同键同时到期,却解决不了单个热点键到期时的并发回源。后一个问题需要按键协调或其他受控刷新策略,留到下一篇展开。
2.2 空值只缓存“确定不存在”
不存在的订单若每次都访问数据库,会形成穿透。可以短时缓存一个显式 found: false 的哨兵,但前提是数据库已经给出确定的“不存在”,而不是查询超时、连接失败或权限校验失败。
空值 TTL 通常应独立且更谨慎,因为订单可能随后创建成功。非法 ID、越权访问和明显不可能的参数先在入口校验与鉴权层拒绝,不能靠负缓存承担安全防护。
三、把读取、写入和失败路径写在明面上
下面的 Java 示例不依赖某个具体 Redis 客户端版本。CacheClient、序列化器和指标接口由项目适配;${...} 参数来自已确认配置。示例假设业务接受 TTL 范围内的旧值,要求当前状态的调用方会走 requireCurrent=true 旁路。
1import java.time.Duration;
2import java.time.Instant;
3import java.util.Optional;
4
5record OrderView(String orderId, long version, String status, String totalAmount) {}
6
7record CachedOrder(
8 String schemaVersion,
9 long orderVersion,
10 Instant cachedAt,
11 boolean found,
12 OrderView payload) {
13
14 static CachedOrder found(String schemaVersion, OrderView value) {
15 return new CachedOrder(schemaVersion, value.version(), Instant.now(), true, value);
16 }
17
18 static CachedOrder missing(String schemaVersion) {
19 return new CachedOrder(schemaVersion, -1, Instant.now(), false, null);
20 }
21}
22
23interface CacheClient {
24 Optional<CachedOrder> get(String key);
25 void set(String key, CachedOrder value, Duration ttl);
26 void delete(String key);
27}
28
29record AccessScope(String subjectId, String tenantId, String viewPolicyId) {
30 AccessScope {
31 if (subjectId == null || subjectId.isBlank()
32 || tenantId == null || tenantId.isBlank()
33 || viewPolicyId == null || viewPolicyId.isBlank()) {
34 throw new IllegalArgumentException("trusted access scope is incomplete");
35 }
36 }
37}
38
39interface TrustedAccessContext {
40 // 只能由已验证的登录态生成;未经校验的请求参数不能直接成为 AccessScope。
41 AccessScope currentScope();
42}
43
44interface Authorizer {
45 // 拒绝时抛出项目定义的受控鉴权异常。
46 void requireReadOrder(AccessScope scope, String orderId);
47 void requireUpdateOrder(AccessScope scope, String orderId);
48}
49
50interface OrderReader {
51 Optional<OrderView> findByScopeAndId(AccessScope scope, String orderId);
52}
53
54interface OrderWriter {
55 // 实现必须在数据库事务提交成功后返回。
56 OrderView updateAndCommit(AccessScope scope, String orderId, String nextStatus);
57}
58
59interface CacheMetrics {
60 void increment(String event);
61}
62
63interface CacheTtlPolicy {
64 // 每次调用都在已确认的上下界内生成 TTL;抖动范围不得突破业务偏差上限。
65 Duration detailTtl();
66 Duration negativeTtl();
67}
68
69interface InvalidationFailureRecorder {
70 // 适配层应持久记录待重试键和已提交版本,而不是只留进程内日志。
71 void recordForRetry(String key, long committedVersion);
72}
73
74interface DatabaseFallbackPolicy {
75 // 无安全容量时抛出项目定义的受控异常,不能让缓存故障无限放大到数据库。
76 void acquirePermitOrThrow();
77}
78
79final class OrderQueryService {
80 private final CacheClient cache;
81 private final TrustedAccessContext accessContext;
82 private final Authorizer authorizer;
83 private final OrderReader reader;
84 private final OrderWriter writer;
85 private final CacheMetrics metrics;
86 private final CacheTtlPolicy ttlPolicy;
87 private final InvalidationFailureRecorder invalidationFailures;
88 private final DatabaseFallbackPolicy fallbackPolicy;
89 private final String schemaVersion;
90
91 OrderQueryService(
92 CacheClient cache,
93 TrustedAccessContext accessContext,
94 Authorizer authorizer,
95 OrderReader reader,
96 OrderWriter writer,
97 CacheMetrics metrics,
98 CacheTtlPolicy ttlPolicy,
99 InvalidationFailureRecorder invalidationFailures,
100 DatabaseFallbackPolicy fallbackPolicy,
101 String schemaVersion) {
102 this.cache = cache;
103 this.accessContext = accessContext;
104 this.authorizer = authorizer;
105 this.reader = reader;
106 this.writer = writer;
107 this.metrics = metrics;
108 this.ttlPolicy = ttlPolicy;
109 this.invalidationFailures = invalidationFailures;
110 this.fallbackPolicy = fallbackPolicy;
111 this.schemaVersion = schemaVersion;
112 }
113
114 Optional<OrderView> find(String orderId, boolean requireCurrent) {
115 validateOrderId(orderId);
116 AccessScope scope = accessContext.currentScope();
117 authorizer.requireReadOrder(scope, orderId);
118 String key = key(scope, orderId);
119
120 if (!requireCurrent) {
121 try {
122 Optional<CachedOrder> cached = cache.get(key);
123 if (cached.isPresent()) {
124 CachedOrder value = cached.get();
125 if (schemaVersion.equals(value.schemaVersion())) {
126 if (value.found() && value.payload() != null) {
127 metrics.increment("cache_hit");
128 return Optional.of(value.payload());
129 }
130 if (!value.found()) {
131 metrics.increment("negative_cache_hit");
132 return Optional.empty();
133 }
134 }
135 metrics.increment("cache_incompatible_value");
136 } else {
137 metrics.increment("cache_miss");
138 }
139 } catch (RuntimeException cacheReadFailure) {
140 metrics.increment("cache_read_error");
141 // 是否允许回源还要经过项目的数据库容量保护;本示例继续走受控回源入口。
142 }
143 } else {
144 metrics.increment("cache_bypass_current_read");
145 }
146
147 Optional<OrderView> loaded = loadFromDatabaseWithCapacityGuard(scope, orderId);
148 try {
149 if (loaded.isPresent()) {
150 cache.set(key, CachedOrder.found(schemaVersion, loaded.get()), ttlPolicy.detailTtl());
151 } else {
152 cache.set(key, CachedOrder.missing(schemaVersion), ttlPolicy.negativeTtl());
153 }
154 } catch (RuntimeException cacheWriteFailure) {
155 metrics.increment("cache_write_error");
156 // 数据库结果仍可返回,缓存失败不能伪装成命中。
157 }
158 return loaded;
159 }
160
161 OrderView updateStatus(String orderId, String nextStatus) {
162 validateOrderId(orderId);
163 AccessScope scope = accessContext.currentScope();
164 authorizer.requireUpdateOrder(scope, orderId);
165 OrderView committed = writer.updateAndCommit(scope, orderId, nextStatus);
166 try {
167 cache.delete(key(scope, orderId));
168 metrics.increment("cache_invalidation_success");
169 } catch (RuntimeException invalidationFailure) {
170 metrics.increment("cache_invalidation_error");
171 // 数据库已提交,不能回滚成“写失败”;持久记录失败键供失效重试。
172 try {
173 invalidationFailures.recordForRetry(key(scope, orderId), committed.version());
174 } catch (RuntimeException recordFailure) {
175 metrics.increment("cache_invalidation_record_error");
176 }
177 }
178 return committed;
179 }
180
181 private Optional<OrderView> loadFromDatabaseWithCapacityGuard(AccessScope scope, String orderId) {
182 fallbackPolicy.acquirePermitOrThrow();
183 metrics.increment("database_load");
184 return reader.findByScopeAndId(scope, orderId);
185 }
186
187 private String key(AccessScope scope, String orderId) {
188 return "order:detail:v" + schemaVersion + ":"
189 + scope.tenantId() + ":" + scope.viewPolicyId() + ":" + orderId;
190 }
191
192 private void validateOrderId(String orderId) {
193 if (orderId == null || orderId.isBlank()) {
194 throw new IllegalArgumentException("orderId is required");
195 }
196 }
197}这里有一条不能省略的安全边界:租户命名空间不等于授权。AccessScope 必须由认证适配层根据已验证的登录态生成,不能把请求里的 tenantId 原样塞进去;Authorizer 则在构造缓存键、读取缓存和查询数据库之前,逐次校验当前主体是否能访问该订单。写入和失效也经过同一套可信上下文与显式鉴权,避免读路径修了、写路径仍能越权。
缓存键纳入 tenantId 和稳定的 viewPolicyId,用于隔离租户以及字段可见范围不同的响应;订单归属、临时授权和动态角色等会变化的条件,仍由 Authorizer 在每次命中前重新判断,不能只靠键隔离。如果所有权限看到的载荷完全相同,可以去掉 viewPolicyId,但必须保留命中前鉴权,并把“统一载荷”写成可验证的接口契约。
示例使用 Java record 压缩数据对象写法;目标 Java 版本不支持时,应替换为等价不可变类。CacheTtlPolicy 负责在配置上下界内为每次写入生成 TTL 抖动,任何结果都不能超过业务确认的最大偏差。InvalidationFailureRecorder 的适配实现需要持久化失败键;记录本身失败时,指标和告警必须让运维侧看见这个事实。
DatabaseFallbackPolicy 只定义本篇需要的门禁:无安全容量时拒绝回源。具体超时、限流和隔离策略属于第 5 章。生产实现不能在 Redis 故障时无限制把全部流量转给数据库;容量保护未就绪时,缓存不可用应触发受控失败或降级,不能假装回退永远安全。
3.1 数据库提交后删除缓存,仍然不是强一致
写路径先完成数据库提交,再删除缓存。若先删除再提交,提交前的并发读取很容易把旧数据库值重新写回缓存。提交后删除缩小了常见窗口,但仍存在竞态:某个读请求在提交前读到旧数据库值,却在删除动作之后才把旧值写入缓存。
因此这个方案只能承诺业务确认过的偏差窗口。TTL 是最终兜底,删除失败要进入告警和可追溯重试;对当前状态有强要求的调用方绕过普通缓存。若业务不能接受这段窗口,就不能把本示例当作一致性方案,需要另行设计版本校验或可靠失效机制。
不要在数据库事务提交前宣称缓存已经失效。框架事务边界若包在外层,普通方法返回不等于事务已经提交;示例特意把接口命名为 updateAndCommit,要求适配层兑现这个时序。
四、异常发生时,先守住数据库
缓存带来的主要运行风险不是“没命中”,而是大量请求同时回到数据库。需要分别记录:
- 读取失败:区分正常 miss、超时、连接错误和反序列化失败;只有容量允许时才回源;
- 写缓存失败:当前数据库读取结果可以返回,但后续请求会继续 miss;
- 删除失败:数据库已写成功,缓存可能继续返回旧值,必须告警、重试并纳入偏差监控;
- 结构不兼容:按 miss 处理并记录版本错误,不让反序列化异常扩散到业务响应;
- 批量到期:用分散过期降低同一时刻回源规模,同时观察数据库余量;
- 单热点键到期:本文只建立监控和容量边界,按键合并留到
02-03。
缓存开关也应支持按查询类型或调用方关闭,而不是只能整套服务重启。回退时先确认数据库能承接恢复后的读流量;直接全量关缓存,可能把一个缓存故障变成数据库故障。
五、收益要和偏差、内存一起验收
使用与前三篇相同的环境、数据快照、负载模型和采样口径,只改变缓存方案。测试至少覆盖稳定命中、冷启动、正常失效、缓存不可用和结构版本切换;单热点并发失效作为下一篇的专门场景。
命中率口径先写清楚:
1cache_read_attempts = cache_hit + negative_cache_hit + cache_miss
2 + cache_incompatible_value + cache_read_error
3cache_hit_rate = cache_hit / cache_read_attempts
4cache_effective_hit_rate = (cache_hit + negative_cache_hit) / cache_read_attempts主动旁路不进入读取尝试,单独报告;结构不兼容和读取异常则进入分母,避免故障时命中率被抬高。命中率高也不自动代表成功;如果命中的都是低成本查询,数据库 QPS 和尾延迟可能没有明显变化。
验收表可以直接引用 02-01 的请求成本链:
1cacheAcceptance:
2 experimentId: ${EXPERIMENT_ID}
3 cachePolicy:
4 schemaVersion: ${VERSION}
5 detailTtl: ${DURATION}
6 negativeTtl: ${DURATION}
7 expiryJitter: ${RANGE}
8 traffic:
9 logicalIntents: ${COUNT}
10 entryRequests: ${COUNT}
11 comparableToBaseline: ${TRUE_OR_FALSE_WITH_REASON}
12 cache:
13 lookups: ${COUNT}
14 hits: ${COUNT}
15 negativeHits: ${COUNT}
16 misses: ${COUNT}
17 bypasses: ${COUNT}
18 incompatibleValues: ${COUNT}
19 readErrors: ${COUNT}
20 writeErrors: ${COUNT}
21 invalidationErrors: ${COUNT}
22 evictions: ${COUNT_OR_UNKNOWN}
23 memoryUsedBytes: ${MEASURED_VALUE}
24 averageSerializedValueBytes: ${MEASURED_VALUE}
25 database:
26 queryCount: ${COUNT}
27 queryDuration: ${P95_P99_OR_QUERY_REFERENCE}
28 connectionAcquireWait: ${METRIC_REFERENCE}
29 business:
30 completedActions: ${COUNT}
31 requestP95: ${VALUE}
32 requestP99: ${VALUE}
33 terminalCounts: ${COUNTS}
34 observedStaleness:
35 samplesCompared: ${COUNT}
36 maxObservedWindow: ${DURATION_OR_UNKNOWN}
37 violations: ${COUNT}
38 decision:
39 benefit: ${DATABASE_AND_LATENCY_CHANGE}
40 cost: ${MEMORY_AND_OPERATIONAL_COST}
41 acceptedBoundary: ${CALLERS_AND_STALENESS_PROMISE}
42 rollbackCondition: ${CONDITION}内存不能只用“键数 × JSON 大小”拍估值。序列化格式、键长度和缓存实现自身开销都会影响占用,报告应记录实际内存指标和抽样后的序列化值大小。命中率、数据库查询数、P95/P99、错误率、失效错误和偏差样本放在一起,才知道减负有没有换来不可接受的数据错误或运行成本。
到这里,缓存方案的边界应该很具体:哪些订单查询允许复用,哪些调用方必须旁路;键和值怎样兼容;读取、写后失效和异常分别怎么走;TTL 与偏差窗口怎样对应;收益、内存和错误用什么口径验收。尚未解决的是同一热点键失效后,大量请求在同一时刻穿过这套路径的问题,下一篇再处理它。