业务系统性能改造手册 · 请求减量

用缓存截断热点读取

2026-08-055 min read请求减量
摘要

从业务时效容忍度和读取成本推导订单缓存边界,显式实现旁路读取、提交后失效与异常回退,并用数据库减负、尾延迟、内存和数据偏差共同验收。

订单详情是热点接口,就一定应该加缓存吗?如果用户正在确认刚完成的状态变化,返回旧数据可能比慢一点更糟;如果一次详情查询本来只查主键、数据库也没有压力,缓存增加的序列化、内存和失效路径未必划算。

02-01 已经把业务意图、入口请求和数据库成本连起来。只有清单中那些读取频繁、数据可复用、下游成本明确,并且业务允许有限时效偏差的候选,才值得进入缓存设计。

小编对缓存的理解比较克制:它是用一段明确的数据偏差窗口和额外运维成本,换取数据库读取减少。本文围绕订单详情给出一个 Cache-Aside 示例;同一热点键失效时的并发回源留给 02-03

一、先决定哪些查询可以接受旧数据

缓存是用可接受的数据时效换取下游减负适用对象应从业务偏差容忍度推导 “读多写少”只是筛选条件,不是上线理由。每个候选还要回答:结果是否由稳定业务键唯一确定,计算或查询成本是否值得复用,调用方要求当前值还是允许短时旧值,读错旧值会造成什么后果。

订单查询可以按使用目的拆开:

查询场景可能的时效要求缓存判断
用户查看历史已完成订单通常允许有限偏差,但需业务确认候选
用户提交后等待状态变化对变化可见时间敏感谨慎,必要时旁路
客服处理争议订单可能要求读取当前状态默认旁路,除非流程明确允许
写操作前的业务校验往往影响不变量不把普通查询缓存当权威数据源

表里的结论不是行业规则。项目需要把“允许有限偏差”换成可验收的业务承诺,例如状态变化后多久必须对某类调用方可见。没有真实要求时,用 ${MAX_ACCEPTABLE_STALENESS} 占位,不能先拍一个 TTL 再反推业务接受它。

适合进入试验的对象应同时具备证据:02-01 的成本链证明该查询确实造成可减少的数据库工作;访问分布证明结果存在复用;业务方确认偏差窗口;缓存失效或不可用时有明确行为。任一条件缺失,先保留为候选。

二、键和值要表达业务边界

缓存键至少区分租户或数据权限边界、资源类型、业务主键与值结构版本:

text
1order:detail:v${SCHEMA_VERSION}:${TENANT_SCOPE}:${ORDER_ID}

示例只表达组成方式。实际键需采用无歧义编码,并避免把敏感标识直接暴露到不受控日志。授权条件不能只存在于查询参数里却不进入键或读取校验,否则不同权限范围可能复用到同一结果。

值也不建议直接序列化数据库实体。缓存面向查询契约,保存调用方需要的稳定字段,并携带足以判断兼容性和新旧程度的信息:

json
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 的上界先受业务可接受偏差约束,再结合写后失效成功率、热点持续时间、内存预算和数据库回源能力调整。示例配置保留变量:

yaml
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 旁路。

java
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

缓存开关也应支持按查询类型或调用方关闭,而不是只能整套服务重启。回退时先确认数据库能承接恢复后的读流量;直接全量关缓存,可能把一个缓存故障变成数据库故障。

五、收益要和偏差、内存一起验收

使用与前三篇相同的环境、数据快照、负载模型和采样口径,只改变缓存方案。测试至少覆盖稳定命中、冷启动、正常失效、缓存不可用和结构版本切换;单热点并发失效作为下一篇的专门场景。

命中率口径先写清楚:

text
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 的请求成本链:

yaml
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 与偏差窗口怎样对应;收益、内存和错误用什么口径验收。尚未解决的是同一热点键失效后,大量请求在同一时刻穿过这套路径的问题,下一篇再处理它。