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

合并并发回源请求

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

在热点缓存失效时,用按缓存键隔离的单飞机制让并发请求共享一次数据库加载,并把等待超时、加载失败、取消和条目清理变成可测试的行为。

举个简化场景:一个热点订单的缓存刚好失效,二十个请求几乎同时到达。二十次 Redis 查询都返回 miss,随后二十个线程一起查数据库。缓存平时的命中率可能很好,数据库却仍会在这个短窗口承受尖峰。

02-02 已经处理了缓存对象、数据偏差和失效路径。本篇只补它留下的一个缺口:同一缓存键正在回源时,后来的请求复用这个进行中的结果。小编把这类机制理解为“合并同一时刻的重复工作”。它不延长 TTL,也不改变业务允许的数据偏差。

本文实现单个应用实例内的按键合并。多实例之间仍可能各自回源一次;分布式锁、集中刷新和跨实例协调不在本篇展开。

一、合并点放在 miss 之后、数据库之前

请求合并位于缓存 miss 之后数据库之前让同一业务键的并发请求共享一次 请求仍然先执行参数校验、从可信登录态取得 AccessScope、完成订单级鉴权,再读取缓存。只有缓存未命中且允许回源时,才进入单飞协调器:

text
1请求 2 -> 校验与鉴权 3 -> 读取缓存 4 ├─ 命中:直接返回 5 └─ 未命中:按 cacheKey 进入 single-flight 6 ├─ 首个请求:再次检查缓存 -> 查询数据库 -> 回填缓存 7 └─ 跟随请求:等待同一个结果

合并键应直接复用 02-02 的缓存键,而不是只用 orderId。示例中的键包含结构版本、租户和 viewPolicyId,因此不同租户或字段可见策略不会共享载荷。每个调用方都要在进入协调器前独立鉴权;命名空间隔离不能替代授权。

首个请求成为加载者后还要再次读取缓存。原因很实际:多个线程在协调器外先后看到 miss,其中一个线程可能已经完成回填,另一个线程才拿到加载者资格。少这次检查,就会制造一次本可避免的数据库查询。

二、把超时和清理写进单飞实现

按键登记一个进行中的任务并不复杂,容易出错的是请求不再等待、加载失败或者任务迟迟不结束之后,谁负责完成结果和移除条目。

2.1 调用方等待和共享加载是两种超时

调用方等待超时与共享加载超时必须分开并用条件删除避免旧任务清掉新任务 调用方等待上限和共享加载上限不是一回事。

  • waitTimeout 约束当前请求愿意等待多久。某个跟随请求超时,只结束它自己的等待,不能调用 cancel 把其他请求仍在复用的任务取消。
  • loadTimeout 约束共享加载最多占用协调条目多久。超过该时间后,协调器让所有仍在等待的请求收到同一个加载超时,并用条件删除清理当前条目。

loadTimeout 不能代替 JDBC 查询超时。Java 线程中断对底层驱动只是尽力而为;数据库调用必须另有可验证的语句或事务超时,而且应早于协调器的硬超时。否则旧查询可能在后台继续运行,新请求又启动下一次回源。

2.2 用条件删除守住任务交接

下面是与缓存客户端无关的最小实现:

java
1import java.time.Duration; 2import java.util.Objects; 3import java.util.concurrent.Callable; 4import java.util.concurrent.CompletableFuture; 5import java.util.concurrent.ConcurrentHashMap; 6import java.util.concurrent.ExecutionException; 7import java.util.concurrent.Executor; 8import java.util.concurrent.FutureTask; 9import java.util.concurrent.ScheduledExecutorService; 10import java.util.concurrent.ScheduledFuture; 11import java.util.concurrent.TimeUnit; 12import java.util.concurrent.TimeoutException; 13import java.util.concurrent.atomic.AtomicReference; 14 15final class FlightWaitTimeoutException extends RuntimeException { 16 FlightWaitTimeoutException(Object key, Duration timeout) { 17 super("waited too long for key=" + key + ", timeout=" + timeout); 18 } 19} 20 21final class SharedLoadTimeoutException extends RuntimeException { 22 SharedLoadTimeoutException(Object key, Duration timeout) { 23 super("shared load timed out for key=" + key + ", timeout=" + timeout); 24 } 25} 26 27final class SharedLoadException extends RuntimeException { 28 SharedLoadException(Throwable cause) { 29 super(cause); 30 } 31} 32 33final class KeyedSingleFlight<K, V> { 34 private final ConcurrentHashMap<K, Flight<V>> inFlight = new ConcurrentHashMap<>(); 35 private final Executor loaderExecutor; 36 private final ScheduledExecutorService scheduler; 37 38 KeyedSingleFlight(Executor loaderExecutor, ScheduledExecutorService scheduler) { 39 this.loaderExecutor = Objects.requireNonNull(loaderExecutor); 40 this.scheduler = Objects.requireNonNull(scheduler); 41 } 42 43 V execute( 44 K key, 45 Duration waitTimeout, 46 Duration loadTimeout, 47 Callable<V> loader) { 48 requirePositive(waitTimeout, "waitTimeout"); 49 requirePositive(loadTimeout, "loadTimeout"); 50 Objects.requireNonNull(key); 51 Objects.requireNonNull(loader); 52 53 Flight<V> candidate = new Flight<>(); 54 Flight<V> existing = inFlight.putIfAbsent(key, candidate); 55 Flight<V> selected = existing == null ? candidate : existing; 56 57 if (existing == null) { 58 startLoader(key, candidate, loadTimeout, loader); 59 } 60 return await(key, selected.result, waitTimeout); 61 } 62 63 int inFlightCount() { 64 return inFlight.size(); 65 } 66 67 boolean hasInFlight(K key) { 68 return inFlight.containsKey(key); 69 } 70 71 private void startLoader( 72 K key, 73 Flight<V> flight, 74 Duration loadTimeout, 75 Callable<V> loader) { 76 FutureTask<Void> worker = new FutureTask<>(() -> { 77 try { 78 flight.result.complete(loader.call()); 79 } catch (Throwable failure) { 80 flight.result.completeExceptionally(failure); 81 } finally { 82 inFlight.remove(key, flight); 83 ScheduledFuture<?> watchdog = flight.watchdog.get(); 84 if (watchdog != null) { 85 watchdog.cancel(false); 86 } 87 } 88 return null; 89 }); 90 flight.worker.set(worker); 91 92 ScheduledFuture<?> watchdog; 93 try { 94 watchdog = scheduler.schedule(() -> { 95 SharedLoadTimeoutException timeout = 96 new SharedLoadTimeoutException(key, loadTimeout); 97 if (flight.result.completeExceptionally(timeout)) { 98 inFlight.remove(key, flight); 99 worker.cancel(true); 100 } 101 }, loadTimeout.toNanos(), TimeUnit.NANOSECONDS); 102 } catch (RuntimeException rejected) { 103 inFlight.remove(key, flight); 104 flight.result.completeExceptionally(rejected); 105 return; 106 } 107 flight.watchdog.set(watchdog); 108 109 try { 110 loaderExecutor.execute(worker); 111 } catch (RuntimeException rejected) { 112 watchdog.cancel(false); 113 inFlight.remove(key, flight); 114 flight.result.completeExceptionally(rejected); 115 } 116 } 117 118 private V await(K key, CompletableFuture<V> result, Duration waitTimeout) { 119 try { 120 return result.get(waitTimeout.toNanos(), TimeUnit.NANOSECONDS); 121 } catch (TimeoutException timeout) { 122 // 这里只放弃当前请求的等待,不取消共享任务。 123 throw new FlightWaitTimeoutException(key, waitTimeout); 124 } catch (InterruptedException interrupted) { 125 Thread.currentThread().interrupt(); 126 throw new SharedLoadException(interrupted); 127 } catch (ExecutionException failed) { 128 Throwable cause = failed.getCause(); 129 if (cause instanceof RuntimeException runtime) { 130 throw runtime; 131 } 132 if (cause instanceof Error error) { 133 throw error; 134 } 135 throw new SharedLoadException(cause); 136 } 137 } 138 139 private static void requirePositive(Duration duration, String name) { 140 if (duration == null || duration.isZero() || duration.isNegative()) { 141 throw new IllegalArgumentException(name + " must be positive"); 142 } 143 } 144 145 private static final class Flight<V> { 146 private final CompletableFuture<V> result = new CompletableFuture<>(); 147 private final AtomicReference<FutureTask<Void>> worker = new AtomicReference<>(); 148 private final AtomicReference<ScheduledFuture<?>> watchdog = new AtomicReference<>(); 149 } 150}

putIfAbsent 决定谁负责加载。同一键只会选出一个 Flight;不同键拥有不同条目,不会争用一把全局锁。加载成功、抛异常、执行器拒绝任务和硬超时都会尝试 remove(key, flight)。这里必须使用带值的条件删除,旧任务结束时才不会误删该键后来创建的新任务。

加载异常通过同一个 CompletableFuture 传播给当前所有等待者。它不是可以缓存的业务空值:数据库明确返回不存在,可以正常完成 Optional.empty();连接异常、查询超时和反序列化错误则应异常完成,不能伪装成“订单不存在”。

2.3 接回订单缓存读取路径

单飞协调器不应接收任意请求租户参数。下面的适配片段延续 02-02AccessScope、鉴权顺序和缓存键约定,省略这些接口已经展示过的定义:

java
1import java.time.Duration; 2import java.util.Optional; 3 4final class CoalescedOrderQueryService { 5 private final TrustedAccessContext accessContext; 6 private final Authorizer authorizer; 7 private final CacheClient cache; 8 private final CacheMetrics metrics; 9 private final OrderReader reader; 10 private final DatabaseFallbackPolicy fallbackPolicy; 11 private final KeyedSingleFlight<String, Optional<OrderView>> singleFlight; 12 private final Duration waitTimeout; 13 private final Duration loadTimeout; 14 private final String schemaVersion; 15 16 CoalescedOrderQueryService( 17 TrustedAccessContext accessContext, 18 Authorizer authorizer, 19 CacheClient cache, 20 CacheMetrics metrics, 21 OrderReader reader, 22 DatabaseFallbackPolicy fallbackPolicy, 23 KeyedSingleFlight<String, Optional<OrderView>> singleFlight, 24 Duration waitTimeout, 25 Duration loadTimeout, 26 String schemaVersion) { 27 this.accessContext = accessContext; 28 this.authorizer = authorizer; 29 this.cache = cache; 30 this.metrics = metrics; 31 this.reader = reader; 32 this.fallbackPolicy = fallbackPolicy; 33 this.singleFlight = singleFlight; 34 this.waitTimeout = waitTimeout; 35 this.loadTimeout = loadTimeout; 36 this.schemaVersion = schemaVersion; 37 } 38 39 Optional<OrderView> find(String orderId) { 40 validateOrderId(orderId); 41 AccessScope scope = accessContext.currentScope(); 42 authorizer.requireReadOrder(scope, orderId); 43 String cacheKey = key(scope, orderId); 44 45 Optional<Optional<OrderView>> firstRead = readCompatible(cacheKey); 46 if (firstRead.isPresent()) { 47 return firstRead.get(); 48 } 49 50 return singleFlight.execute(cacheKey, waitTimeout, loadTimeout, () -> { 51 Optional<Optional<OrderView>> secondRead = readCompatible(cacheKey); 52 if (secondRead.isPresent()) { 53 return secondRead.get(); 54 } 55 56 fallbackPolicy.acquirePermitOrThrow(); 57 Optional<OrderView> loaded = reader.findByScopeAndId(scope, orderId); 58 writeCache(cacheKey, loaded); 59 return loaded; 60 }); 61 } 62 63 private Optional<Optional<OrderView>> readCompatible(String cacheKey) { 64 Optional<CachedOrder> found; 65 try { 66 found = cache.get(cacheKey); 67 } catch (RuntimeException cacheReadFailure) { 68 metrics.increment("cache_read_error"); 69 return Optional.empty(); 70 } 71 if (found.isEmpty()) { 72 return Optional.empty(); 73 } 74 CachedOrder cached = found.get(); 75 if (!schemaVersion.equals(cached.schemaVersion())) { 76 metrics.increment("cache_incompatible_value"); 77 return Optional.empty(); 78 } 79 if (cached.found() && cached.payload() != null) { 80 return Optional.of(Optional.of(cached.payload())); 81 } 82 if (!cached.found()) { 83 return Optional.of(Optional.empty()); 84 } 85 return Optional.empty(); 86 } 87 88 private void writeCache(String cacheKey, Optional<OrderView> loaded) { 89 // 复用 02-02 已定义的正值/空值 TTL、结构版本和写失败处理。 90 } 91 92 private String key(AccessScope scope, String orderId) { 93 return "order:detail:v" + schemaVersion + ":" 94 + scope.tenantId() + ":" + scope.viewPolicyId() + ":" + orderId; 95 } 96 97 private void validateOrderId(String orderId) { 98 if (orderId == null || orderId.isBlank()) { 99 throw new IllegalArgumentException("orderId is required"); 100 } 101 } 102}

双层 Optional 用来区分“没有可用缓存”与“命中了确定不存在的负缓存”:外层为空时继续受控回源,外层有值且内层为空时才返回业务空值。不兼容结构和缓存读取异常都会记录指标并按 miss 处理;首次读取失败后,请求仍会进入单飞,只有加载者通过 DatabaseFallbackPolicy 才能访问数据库。加载者的二次缓存读取若再次失败,也会走同一容量门禁,不会绕过保护直接回源。负缓存 TTL 和缓存写入失败继续复用上一篇策略,本篇不重复铺开。

共享载荷必须只依赖合并键中已经表达的范围。若查询结果还会随主体、动态权限或其他输入变化,要么把稳定的结果维度加入键,要么不要共享该结果。即使键包含 viewPolicyId,订单级动态权限仍要在每个请求进入缓存和单飞之前重新鉴权。

2.4 用竞态测试证明它没有“看起来能跑”

单线程成功用例几乎测不出单飞机制的问题。至少要覆盖以下行为:

  1. 同一键并发进入时,加载器只执行一次,所有未超时请求得到同一结果;
  2. 两个不同键可以同时开始加载,不被全局锁串行化;
  3. 加载器抛异常时,所有等待者收到失败,条目随后清理;
  4. 某个等待者先超时,其他等待者和共享加载仍能完成;
  5. 共享加载超过硬上限时,等待者收到加载超时,条目被条件删除;
  6. 加载结束和硬超时竞争时,旧任务不能删除同键的新 Flight

下面的最小测试不依赖 JUnit,便于先验证并发语义;项目落地时可改成现有测试框架。为了避免把机器调度抖动当业务结论,测试用 CountDownLatch 控制事件顺序,而不是只靠 sleep 猜执行时机。

java
1import java.time.Duration; 2import java.util.ArrayList; 3import java.util.List; 4import java.util.concurrent.CountDownLatch; 5import java.util.concurrent.ExecutorService; 6import java.util.concurrent.Executors; 7import java.util.concurrent.Future; 8import java.util.concurrent.ScheduledExecutorService; 9import java.util.concurrent.TimeUnit; 10import java.util.concurrent.atomic.AtomicInteger; 11 12final class KeyedSingleFlightTest { 13 public static void main(String[] args) throws Exception { 14 ExecutorService loaderPool = Executors.newFixedThreadPool(4); 15 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); 16 ExecutorService callers = Executors.newFixedThreadPool(8); 17 try { 18 KeyedSingleFlight<String, String> singleFlight = 19 new KeyedSingleFlight<>(loaderPool, scheduler); 20 sameKeyLoadsOnce(singleFlight, callers); 21 distinctKeysLoadConcurrently(singleFlight, callers); 22 failureCleansEntry(singleFlight); 23 waiterTimeoutDoesNotCancelSharedLoad(singleFlight, callers); 24 sharedLoadTimeoutCleansEntry(singleFlight); 25 oldFlightCannotDeleteReplacement(); 26 } finally { 27 callers.shutdownNow(); 28 loaderPool.shutdownNow(); 29 scheduler.shutdownNow(); 30 } 31 } 32 33 private static void distinctKeysLoadConcurrently( 34 KeyedSingleFlight<String, String> singleFlight, 35 ExecutorService callers) throws Exception { 36 CountDownLatch bothStarted = new CountDownLatch(2); 37 CountDownLatch release = new CountDownLatch(1); 38 Future<String> first = callers.submit(() -> singleFlight.execute( 39 "order-a", Duration.ofSeconds(2), Duration.ofSeconds(3), () -> { 40 bothStarted.countDown(); 41 release.await(); 42 return "a"; 43 })); 44 Future<String> second = callers.submit(() -> singleFlight.execute( 45 "order-b", Duration.ofSeconds(2), Duration.ofSeconds(3), () -> { 46 bothStarted.countDown(); 47 release.await(); 48 return "b"; 49 })); 50 51 check(bothStarted.await(1, TimeUnit.SECONDS), "distinct keys were serialized"); 52 release.countDown(); 53 check("a".equals(first.get()) && "b".equals(second.get()), 54 "distinct key result mismatch"); 55 } 56 57 private static void failureCleansEntry( 58 KeyedSingleFlight<String, String> singleFlight) { 59 try { 60 singleFlight.execute( 61 "failing-order", 62 Duration.ofSeconds(1), 63 Duration.ofSeconds(2), 64 () -> { throw new IllegalStateException("database failed"); }); 65 throw new AssertionError("load failure should propagate"); 66 } catch (IllegalStateException expected) { 67 check("database failed".equals(expected.getMessage()), "failure was replaced"); 68 } 69 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 70 String recovered = singleFlight.execute( 71 "failing-order", 72 Duration.ofSeconds(1), 73 Duration.ofSeconds(2), 74 () -> "recovered"); 75 check("recovered".equals(recovered), "cleaned key could not load again"); 76 } 77 78 private static void sameKeyLoadsOnce( 79 KeyedSingleFlight<String, String> singleFlight, 80 ExecutorService callers) throws Exception { 81 AtomicInteger loads = new AtomicInteger(); 82 CountDownLatch loaderStarted = new CountDownLatch(1); 83 CountDownLatch releaseLoader = new CountDownLatch(1); 84 List<Future<String>> results = new ArrayList<>(); 85 86 for (int i = 0; i < 8; i++) { 87 results.add(callers.submit(() -> singleFlight.execute( 88 "tenant-a:view-a:order-1", 89 Duration.ofSeconds(2), 90 Duration.ofSeconds(3), 91 () -> { 92 loads.incrementAndGet(); 93 loaderStarted.countDown(); 94 releaseLoader.await(); 95 return "order-view"; 96 }))); 97 } 98 99 check(loaderStarted.await(1, TimeUnit.SECONDS), "loader did not start"); 100 releaseLoader.countDown(); 101 for (Future<String> result : results) { 102 check("order-view".equals(result.get()), "unexpected shared result"); 103 } 104 check(loads.get() == 1, "same key loaded " + loads.get() + " times"); 105 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 106 } 107 108 private static void waiterTimeoutDoesNotCancelSharedLoad( 109 KeyedSingleFlight<String, String> singleFlight, 110 ExecutorService callers) throws Exception { 111 CountDownLatch loaderStarted = new CountDownLatch(1); 112 CountDownLatch releaseLoader = new CountDownLatch(1); 113 114 Future<String> impatient = callers.submit(() -> singleFlight.execute( 115 "tenant-a:view-a:order-2", 116 Duration.ofMillis(20), 117 Duration.ofSeconds(3), 118 () -> { 119 loaderStarted.countDown(); 120 releaseLoader.await(); 121 return "late-result"; 122 })); 123 124 check(loaderStarted.await(1, TimeUnit.SECONDS), "loader did not start"); 125 Future<String> patient = callers.submit(() -> singleFlight.execute( 126 "tenant-a:view-a:order-2", 127 Duration.ofSeconds(2), 128 Duration.ofSeconds(3), 129 () -> "must-not-run")); 130 131 try { 132 impatient.get(); 133 throw new AssertionError("short waiter should time out"); 134 } catch (java.util.concurrent.ExecutionException expected) { 135 check(expected.getCause() instanceof FlightWaitTimeoutException, 136 "wrong timeout type: " + expected.getCause()); 137 } 138 139 releaseLoader.countDown(); 140 check("late-result".equals(patient.get()), "shared load was cancelled by waiter"); 141 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 142 } 143 144 private static void sharedLoadTimeoutCleansEntry( 145 KeyedSingleFlight<String, String> singleFlight) { 146 CountDownLatch neverReleased = new CountDownLatch(1); 147 try { 148 singleFlight.execute( 149 "stuck-order", 150 Duration.ofSeconds(1), 151 Duration.ofMillis(50), 152 () -> { 153 neverReleased.await(); 154 return "unreachable"; 155 }); 156 throw new AssertionError("shared load should time out"); 157 } catch (SharedLoadTimeoutException expected) { 158 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 159 } 160 } 161 162 private static void oldFlightCannotDeleteReplacement() throws Exception { 163 ExecutorService loaderPool = Executors.newFixedThreadPool(2); 164 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); 165 ExecutorService callers = Executors.newFixedThreadPool(2); 166 CountDownLatch firstWorkerExited = new CountDownLatch(1); 167 try { 168 java.util.concurrent.Executor trackingExecutor = command -> 169 loaderPool.execute(() -> { 170 try { 171 command.run(); 172 } finally { 173 firstWorkerExited.countDown(); 174 } 175 }); 176 KeyedSingleFlight<String, String> singleFlight = 177 new KeyedSingleFlight<>(trackingExecutor, scheduler); 178 String key = "replacement-order"; 179 CountDownLatch oldStarted = new CountDownLatch(1); 180 CountDownLatch releaseOld = new CountDownLatch(1); 181 182 Future<String> oldCaller = callers.submit(() -> singleFlight.execute( 183 key, Duration.ofSeconds(1), Duration.ofMillis(50), () -> { 184 oldStarted.countDown(); 185 boolean released = false; 186 while (!released) { 187 try { 188 releaseOld.await(); 189 released = true; 190 } catch (InterruptedException ignoredForRaceTest) { 191 // 测试专门模拟不响应中断的底层调用。 192 } 193 } 194 return "old"; 195 })); 196 197 check(oldStarted.await(1, TimeUnit.SECONDS), "old loader did not start"); 198 try { 199 oldCaller.get(); 200 throw new AssertionError("old flight should time out"); 201 } catch (java.util.concurrent.ExecutionException expected) { 202 check(expected.getCause() instanceof SharedLoadTimeoutException, 203 "wrong old flight result: " + expected.getCause()); 204 } 205 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 206 207 CountDownLatch replacementStarted = new CountDownLatch(1); 208 CountDownLatch releaseReplacement = new CountDownLatch(1); 209 Future<String> replacement = callers.submit(() -> singleFlight.execute( 210 key, Duration.ofSeconds(2), Duration.ofSeconds(3), () -> { 211 replacementStarted.countDown(); 212 releaseReplacement.await(); 213 return "replacement"; 214 })); 215 check(replacementStarted.await(1, TimeUnit.SECONDS), 216 "replacement loader did not start"); 217 check(singleFlight.hasInFlight(key), "replacement was not registered"); 218 219 releaseOld.countDown(); 220 check(firstWorkerExited.await(1, TimeUnit.SECONDS), "old worker did not exit"); 221 check(singleFlight.hasInFlight(key), "old worker deleted replacement flight"); 222 223 releaseReplacement.countDown(); 224 check("replacement".equals(replacement.get()), "replacement result mismatch"); 225 awaitNoFlights(singleFlight, Duration.ofSeconds(1)); 226 } finally { 227 callers.shutdownNow(); 228 loaderPool.shutdownNow(); 229 scheduler.shutdownNow(); 230 } 231 } 232 233 private static void awaitNoFlights( 234 KeyedSingleFlight<?, ?> singleFlight, 235 Duration timeout) { 236 long deadline = System.nanoTime() + timeout.toNanos(); 237 while (singleFlight.inFlightCount() != 0 && System.nanoTime() < deadline) { 238 Thread.onSpinWait(); 239 } 240 check(singleFlight.inFlightCount() == 0, "entry was not cleaned before deadline"); 241 } 242 243 private static void check(boolean condition, String message) { 244 if (!condition) { 245 throw new AssertionError(message); 246 } 247 } 248}

这些用例使用有界等待检查异步清理,不假定 CompletableFuture 唤醒调用方和 ConcurrentHashMap 删除发生在同一个原子步骤。最后一个用例还刻意让旧加载在硬超时后忽略中断:同键的新 Flight 登记后再释放旧加载,并等待旧 worker 完整退出,验证条件删除不会移除替代任务。测试时序由 latch 和执行器包装控制,没有用固定休眠碰运气。

三、压测要制造“失效风暴”,不能只看平均命中率

稳定高命中场景看不出合并收益。压测需要选择一个已经鉴权、可缓存且读取成本明确的热点订单,预热后让该键失效,再在短时间内对同一键发起并发读取。同时保留一组不同订单键,用来检查按键隔离和执行器容量。

建议保持 01-01 的环境与采样协议,只切换请求合并开关。真实并发、TTL、等待时间和实例数尚未确定,因此报告保留实测变量:

yaml
1singleFlightAcceptance: 2 experimentId: ${EXPERIMENT_ID} 3 topology: 4 applicationInstances: ${COUNT} 5 loaderThreadsPerInstance: ${COUNT} 6 policy: 7 waitTimeout: ${DURATION} 8 loadTimeout: ${DURATION} 9 jdbcQueryTimeout: ${DURATION} 10 storm: 11 hotKeyCount: ${COUNT} 12 requestsPerHotKey: ${COUNT} 13 arrivalWindow: ${DURATION} 14 distinctControlKeys: ${COUNT} 15 result: 16 logicalRequests: ${COUNT} 17 sharedLoadLeaders: ${COUNT} 18 sharedLoadFollowers: ${COUNT} 19 databaseLoads: ${COUNT} 20 databasePeakQps: ${VALUE} 21 waiterTimeouts: ${COUNT} 22 sharedLoadTimeouts: ${COUNT} 23 loadFailures: ${COUNT} 24 requestP95: ${DURATION} 25 requestP99: ${DURATION} 26 terminalCounts: ${COUNTS} 27 maxInFlightEntries: ${COUNT} 28 entriesAfterRecovery: ${COUNT} 29 isolation: 30 distinctKeysStartedConcurrently: ${COUNT} 31 distinctKeyLatencyChange: ${VALUE} 32 decision: 33 databaseLoadReduction: ${BEFORE_AFTER_WITH_COUNTS} 34 latencyCost: ${FOLLOWER_WAIT_AND_TAIL_CHANGE} 35 acceptedBoundary: ${SCOPE} 36 rollbackCondition: ${CONDITION}

指标要区分加载者、跟随者、调用方等待超时、共享加载超时和加载失败。只记录“合并命中次数”无法判断数据库是否真的少查,也看不出请求是否只是从数据库等待转成了单飞等待。多实例场景下,理论下限通常是每个实例各有一次加载,最终结论必须带上实例拓扑,不能把单实例结果包装成集群事实。

还有一个容易被忽略的容量边界:不同键虽然没有共享锁,仍会竞争加载执行器和数据库连接。执行器必须有明确并发上限与拒绝行为;参数应根据数据库余量和实测服务时间确定。线程池与连接池的统一预算会在 03-03 详细处理,本篇只要求把执行器参数和拒绝计数写入实验记录。

四、什么时候不该合并

单飞适合“同一结果可以安全共享、加载成本较高、并发 miss 确实重叠”的读取。以下场景应保持独立执行或重新设计键:

  • 请求要求读取各自时刻的当前状态,等待共享旧加载会破坏语义;
  • 结果随主体权限或未进入键的输入变化;
  • 查询很便宜、并发重叠很少,协调、排队和观测成本高于收益;
  • 加载无法设置可靠超时,后台任务可能长期占用数据库资源;
  • 写操作或带副作用操作没有独立的幂等与业务状态设计,不能因“同一个键”就复用结果。

请求合并也不能替代缓存。缓存复用已经完成的结果,单飞只复用正在进行的工作;任务完成并清理后,下一个 miss 仍会正常回源。验收时看到数据库尖峰下降,同时等待超时、尾延迟、失败传播和条目清理都符合约定,这项改造才算成立。之后进入 03-01,处理那些无法通过缓存和合并消除的数据库工作。