业务系统性能改造手册 · 请求减量
合并并发回源请求
在热点缓存失效时,用按缓存键隔离的单飞机制让并发请求共享一次数据库加载,并把等待超时、加载失败、取消和条目清理变成可测试的行为。
举个简化场景:一个热点订单的缓存刚好失效,二十个请求几乎同时到达。二十次 Redis 查询都返回 miss,随后二十个线程一起查数据库。缓存平时的命中率可能很好,数据库却仍会在这个短窗口承受尖峰。
02-02 已经处理了缓存对象、数据偏差和失效路径。本篇只补它留下的一个缺口:同一缓存键正在回源时,后来的请求复用这个进行中的结果。小编把这类机制理解为“合并同一时刻的重复工作”。它不延长 TTL,也不改变业务允许的数据偏差。
本文实现单个应用实例内的按键合并。多实例之间仍可能各自回源一次;分布式锁、集中刷新和跨实例协调不在本篇展开。
一、合并点放在 miss 之后、数据库之前
请求仍然先执行参数校验、从可信登录态取得 AccessScope、完成订单级鉴权,再读取缓存。只有缓存未命中且允许回源时,才进入单飞协调器:
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 用条件删除守住任务交接
下面是与缓存客户端无关的最小实现:
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-02 的 AccessScope、鉴权顺序和缓存键约定,省略这些接口已经展示过的定义:
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 用竞态测试证明它没有“看起来能跑”
单线程成功用例几乎测不出单飞机制的问题。至少要覆盖以下行为:
- 同一键并发进入时,加载器只执行一次,所有未超时请求得到同一结果;
- 两个不同键可以同时开始加载,不被全局锁串行化;
- 加载器抛异常时,所有等待者收到失败,条目随后清理;
- 某个等待者先超时,其他等待者和共享加载仍能完成;
- 共享加载超过硬上限时,等待者收到加载超时,条目被条件删除;
- 加载结束和硬超时竞争时,旧任务不能删除同键的新
Flight。
下面的最小测试不依赖 JUnit,便于先验证并发语义;项目落地时可改成现有测试框架。为了避免把机器调度抖动当业务结论,测试用 CountDownLatch 控制事件顺序,而不是只靠 sleep 猜执行时机。
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、等待时间和实例数尚未确定,因此报告保留实测变量:
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,处理那些无法通过缓存和合并消除的数据库工作。