This is an automated email from the ASF dual-hosted git repository. dmsolr pushed a commit to branch perf/upstream-snapshot-publish in repository https://gitbox.apache.org/repos/asf/shenyu.git
commit 54d43f31eb134f67d3b09eee0905f16868650998 Author: Luke Haochao Zhuang <[email protected]> AuthorDate: Tue Oct 20 17:26:05 2026 +0800 perf(loadbalancer): publish read-only healthy upstream snapshots to avoid per-request list copies findUpstreamListBySelectorId previously created a defensive ArrayList copy of the healthy CopyOnWriteArrayList on every routing lookup. Move the copy cost to the write side: UpstreamCheckTask now re-publishes an immutable List.copyOf snapshot inside its lock whenever the healthy set structurally changes (config submit, health transition, removal), and the request path takes the snapshot reference directly with zero allocation. - add healthySnapshot map + publishHealthySnapshot/getHealthySnapshot to UpstreamCheckTask - findUpstreamListBySelectorId returns the published snapshot (null when no entry) - add UpstreamSnapshotPublishTest: read-only snapshot, removal visibility, health transition republish, concurrent readers/writers regression, and a ThreadMXBean allocation comparison against the legacy per-request copy --- .../loadbalancer/cache/UpstreamCacheManager.java | 11 +- .../loadbalancer/cache/UpstreamCheckTask.java | 41 ++- .../cache/UpstreamSnapshotPublishTest.java | 288 +++++++++++++++++++++ 3 files changed, 334 insertions(+), 6 deletions(-) diff --git a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java index 2c80c83fa4..9ccd654c73 100644 --- a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java +++ b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCacheManager.java @@ -26,7 +26,6 @@ import org.apache.shenyu.common.utils.MapUtils; import org.apache.shenyu.common.utils.Singleton; import org.apache.shenyu.loadbalancer.entity.Upstream; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -122,12 +121,16 @@ public final class UpstreamCacheManager { /** * Find upstream list by selector id list. * + * <p>Returns the read-only snapshot published by the health-check task on + * configuration and health-state updates, so the request path does not copy + * the healthy list per lookup. Callers must not mutate the returned list; + * elements keep the same shared-instance semantics as before.</p> + * * @param selectorId the selector id - * @return the list + * @return the immutable snapshot list, or null when the selector has no entry */ public List<Upstream> findUpstreamListBySelectorId(final String selectorId) { - List<Upstream> upstreamList = task.getHealthyUpstream().get(selectorId); - return Objects.isNull(upstreamList) ? null : new ArrayList<>(upstreamList); + return task.getHealthySnapshot(selectorId); } /** diff --git a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCheckTask.java b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCheckTask.java index 114aff74c0..5e24024535 100644 --- a/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCheckTask.java +++ b/shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/cache/UpstreamCheckTask.java @@ -55,6 +55,14 @@ public final class UpstreamCheckTask implements Runnable { private final Map<String, List<Upstream>> unhealthyUpstream = Maps.newConcurrentMap(); + /** + * Read-only snapshots of {@link #healthyUpstream}, re-published inside {@link #lock} + * on every structural or health-state change. Request-path readers take the snapshot + * reference directly, so routing lookups no longer copy the whole healthy list per + * request while writers own the (bounded, low-frequency) copy cost. + */ + private final Map<String, List<Upstream>> healthySnapshot = Maps.newConcurrentMap(); + private final Object lock = new Object(); private final AtomicBoolean checkStarted = new AtomicBoolean(false); @@ -290,6 +298,9 @@ public final class UpstreamCheckTask implements Runnable { List<Upstream> list = MapUtils.computeIfAbsent(map, selectorId, k -> new CopyOnWriteArrayList<>()); if (!list.contains(upstream)) { list.add(upstream); + if (healthyUpstream == map) { + publishHealthySnapshot(selectorId); + } } } } @@ -304,8 +315,8 @@ public final class UpstreamCheckTask implements Runnable { public void removeFromMap(final Map<String, List<Upstream>> map, final String selectorId, final Upstream upstream) { synchronized (lock) { List<Upstream> list = map.get(selectorId); - if (CollectionUtils.isNotEmpty(list)) { - list.remove(upstream); + if (CollectionUtils.isNotEmpty(list) && list.remove(upstream) && healthyUpstream == map) { + publishHealthySnapshot(selectorId); } } } @@ -319,8 +330,21 @@ public final class UpstreamCheckTask implements Runnable { synchronized (lock) { healthyUpstream.remove(selectorId); unhealthyUpstream.remove(selectorId); + healthySnapshot.remove(selectorId); } } + + /** + * Re-publish an immutable snapshot of the healthy list for the selector. + * Must be called while holding {@link #lock} so readers never observe a + * snapshot torn across concurrent structural changes. + * + * @param selectorId selectorId + */ + private void publishHealthySnapshot(final String selectorId) { + List<Upstream> live = healthyUpstream.get(selectorId); + healthySnapshot.put(selectorId, Objects.isNull(live) ? List.of() : List.copyOf(live)); + } /** * Print healthy and unhealthy check log. @@ -365,6 +389,19 @@ public final class UpstreamCheckTask implements Runnable { public List<Upstream> getHealthyUpstreamListBySelectorId(final String selectorId) { return getHealthyUpstream().get(selectorId); } + + /** + * Get the published read-only healthy upstream snapshot of the selector. + * The snapshot is re-published on configuration and health-state updates and + * must not be mutated by callers; it may be empty but never contains a + * removed target after the removal has been published. + * + * @param selectorId selectorId + * @return immutable snapshot list, or null when the selector has no entry + */ + public List<Upstream> getHealthySnapshot(final String selectorId) { + return healthySnapshot.get(selectorId); + } /** * Get unhealthy upstream map. diff --git a/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamSnapshotPublishTest.java b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamSnapshotPublishTest.java new file mode 100644 index 0000000000..28b71953ef --- /dev/null +++ b/shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/cache/UpstreamSnapshotPublishTest.java @@ -0,0 +1,288 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.loadbalancer.cache; + +import org.apache.shenyu.loadbalancer.entity.Upstream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.lang.management.ManagementFactory; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tests for the read-only healthy-upstream snapshot publication that replaces + * the per-request defensive copy in {@code findUpstreamListBySelectorId}. + */ +public class UpstreamSnapshotPublishTest { + + @Test + public void testReturnedListIsReadOnlySnapshot() { + final UpstreamCacheManager cacheManager = UpstreamCacheManager.getInstance(); + final String selectorId = "SNAPSHOT_READONLY_TEST"; + cacheManager.submit(selectorId, buildUpstreams(2)); + try { + List<Upstream> snapshot = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertNotNull(snapshot); + Assertions.assertEquals(2, snapshot.size()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> snapshot.add(Upstream.builder().url("intruder:8080").status(true).healthCheckEnabled(false).build())); + Assertions.assertThrows(UnsupportedOperationException.class, () -> snapshot.remove(0)); + } finally { + cacheManager.removeByKey(selectorId); + } + } + + @Test + public void testRemovedTargetIsNotARoutingCandidateInNewSnapshot() { + final UpstreamCacheManager cacheManager = UpstreamCacheManager.getInstance(); + final String selectorId = "SNAPSHOT_REMOVAL_TEST"; + List<Upstream> upstreams = buildUpstreams(3); + cacheManager.submit(selectorId, upstreams); + try { + List<Upstream> before = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertEquals(3, Objects.requireNonNull(before).size()); + + UpstreamCheckTask task = getUpstreamCheckTask(cacheManager); + Assertions.assertNotNull(task); + task.triggerRemoveOne(selectorId, upstreams.get(1)); + + List<Upstream> after = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertEquals(2, Objects.requireNonNull(after).size()); + Assertions.assertTrue(after.stream().noneMatch(u -> u.getUrl().equals(upstreams.get(1).getUrl())), + "removed target must not remain a routing candidate"); + // an already handed-out snapshot stays coherent, it is not mutated in place + Assertions.assertEquals(3, before.size()); + } finally { + cacheManager.removeByKey(selectorId); + } + Assertions.assertNull(cacheManager.findUpstreamListBySelectorId(selectorId), + "removeByKey must clear the published snapshot"); + } + + @Test + public void testHealthTransitionRepublishesSnapshot() { + final UpstreamCacheManager cacheManager = UpstreamCacheManager.getInstance(); + final String selectorId = "SNAPSHOT_HEALTH_TRANSITION_TEST"; + List<Upstream> upstreams = buildUpstreams(2); + cacheManager.submit(selectorId, upstreams); + try { + UpstreamCheckTask task = getUpstreamCheckTask(cacheManager); + Assertions.assertNotNull(task); + // simulate a health transition: healthy -> unhealthy + upstreams.get(0).setHealthy(false); + task.putToMap(task.getUnhealthyUpstream(), selectorId, upstreams.get(0)); + task.removeFromMap(task.getHealthyUpstream(), selectorId, upstreams.get(0)); + List<Upstream> afterDown = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertEquals(1, Objects.requireNonNull(afterDown).size()); + // and back: unhealthy -> healthy + upstreams.get(0).setHealthy(true); + task.putToMap(task.getHealthyUpstream(), selectorId, upstreams.get(0)); + task.removeFromMap(task.getUnhealthyUpstream(), selectorId, upstreams.get(0)); + List<Upstream> afterUp = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertEquals(2, Objects.requireNonNull(afterUp).size()); + } finally { + cacheManager.removeByKey(selectorId); + } + } + + @Test + public void testConcurrentReadersAndWritersNeverSeeTornState() throws InterruptedException { + final UpstreamCacheManager cacheManager = UpstreamCacheManager.getInstance(); + final String selectorId = "SNAPSHOT_CONCURRENCY_TEST"; + final int baseSize = 20; + cacheManager.submit(selectorId, buildUpstreams(baseSize)); + UpstreamCheckTask task = getUpstreamCheckTask(cacheManager); + Assertions.assertNotNull(task); + final List<Upstream> churnUpstreams = buildUpstreams(5, "churn"); + final List<Throwable> failures = new CopyOnWriteArrayList<>(); + final AtomicBoolean stop = new AtomicBoolean(false); + final CountDownLatch started = new CountDownLatch(6); + ExecutorService pool = Executors.newFixedThreadPool(6); + try { + for (int r = 0; r < 4; r++) { + pool.execute(newReaderLoop(cacheManager, selectorId, failures, stop, started)); + } + pool.execute(newChurnWriterLoop(task, selectorId, churnUpstreams, failures, stop, started)); + pool.execute(newConfigChurnWriterLoop(cacheManager, selectorId, baseSize, failures, stop, started)); + Assertions.assertTrue(started.await(5, TimeUnit.SECONDS)); + TimeUnit.MILLISECONDS.sleep(1500); + stop.set(true); + } finally { + pool.shutdown(); + Assertions.assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + } + Assertions.assertTrue(failures.isEmpty(), "concurrent readers/writers must not fail: " + failures); + // after churn ends, removed targets must not remain routing candidates + for (Upstream churn : churnUpstreams) { + task.triggerRemoveOne(selectorId, churn); + } + List<Upstream> finalSnapshot = cacheManager.findUpstreamListBySelectorId(selectorId); + Assertions.assertNotNull(finalSnapshot); + for (Upstream churn : churnUpstreams) { + Assertions.assertTrue(finalSnapshot.stream().noneMatch(u -> u.getUrl().equals(churn.getUrl())), + "removed churn target must not remain a routing candidate"); + } + cacheManager.removeByKey(selectorId); + } + + @Test + @SuppressWarnings("deprecation") + public void testSnapshotLookupAllocatesFarLessThanPerRequestCopy() { + final UpstreamCacheManager cacheManager = UpstreamCacheManager.getInstance(); + final String selectorId = "SNAPSHOT_ALLOCATION_TEST"; + final int upstreamCount = 100; + final int iterations = 50_000; + cacheManager.submit(selectorId, buildUpstreams(upstreamCount)); + try { + UpstreamCheckTask task = getUpstreamCheckTask(cacheManager); + Assertions.assertNotNull(task); + java.lang.management.ThreadMXBean bean = ManagementFactory.getThreadMXBean(); + Assumptions.assumeTrue(bean instanceof com.sun.management.ThreadMXBean, + "thread allocation measurement not available on this JVM"); + com.sun.management.ThreadMXBean sunBean = (com.sun.management.ThreadMXBean) bean; + Assumptions.assumeTrue(sunBean.isThreadAllocatedMemorySupported()); + sunBean.setThreadAllocatedMemoryEnabled(true); + long threadId = Thread.currentThread().getId(); + + long sink = 0; + // warm up both paths + for (int i = 0; i < 5_000; i++) { + sink += cacheManager.findUpstreamListBySelectorId(selectorId).size(); + sink += new ArrayList<>(task.getHealthyUpstreamListBySelectorId(selectorId)).size(); + } + + long before = sunBean.getThreadAllocatedBytes(threadId); + for (int i = 0; i < iterations; i++) { + sink += cacheManager.findUpstreamListBySelectorId(selectorId).size(); + } + final long snapshotAllocation = sunBean.getThreadAllocatedBytes(threadId) - before; + + before = sunBean.getThreadAllocatedBytes(threadId); + for (int i = 0; i < iterations; i++) { + sink += new ArrayList<>(task.getHealthyUpstreamListBySelectorId(selectorId)).size(); + } + final long copyAllocation = sunBean.getThreadAllocatedBytes(threadId) - before; + + Assertions.assertTrue(sink > 0); + Assertions.assertTrue(copyAllocation > 0, "the legacy per-request copy must allocate"); + Assertions.assertTrue(snapshotAllocation * 10 < copyAllocation, + "snapshot lookup should be allocation-free compared to the copy, snapshot=" + + snapshotAllocation + " bytes, copy=" + copyAllocation + " bytes"); + } finally { + cacheManager.removeByKey(selectorId); + } + } + + private Runnable newReaderLoop(final UpstreamCacheManager cacheManager, final String selectorId, + final List<Throwable> failures, final AtomicBoolean stop, final CountDownLatch started) { + return () -> { + started.countDown(); + long sink = 0; + while (!stop.get()) { + try { + List<Upstream> snapshot = cacheManager.findUpstreamListBySelectorId(selectorId); + if (Objects.nonNull(snapshot)) { + for (Upstream upstream : snapshot) { + sink += upstream.getUrl().length(); + } + } + } catch (Throwable t) { + failures.add(t); + return; + } + } + if (sink < 0) { + failures.add(new IllegalStateException("unreachable")); + } + }; + } + + private Runnable newChurnWriterLoop(final UpstreamCheckTask task, final String selectorId, + final List<Upstream> churnUpstreams, final List<Throwable> failures, + final AtomicBoolean stop, final CountDownLatch started) { + return () -> { + started.countDown(); + while (!stop.get()) { + try { + for (Upstream churn : churnUpstreams) { + task.triggerAddOne(selectorId, churn); + task.triggerRemoveOne(selectorId, churn); + } + } catch (Throwable t) { + failures.add(t); + return; + } + } + }; + } + + private Runnable newConfigChurnWriterLoop(final UpstreamCacheManager cacheManager, final String selectorId, + final int baseSize, final List<Throwable> failures, + final AtomicBoolean stop, final CountDownLatch started) { + return () -> { + started.countDown(); + while (!stop.get()) { + try { + // configuration churn: full replacement and re-submission + cacheManager.submit(selectorId, buildUpstreams(baseSize)); + TimeUnit.MILLISECONDS.sleep(5); + } catch (Throwable t) { + failures.add(t); + return; + } + } + }; + } + + private List<Upstream> buildUpstreams(final int count) { + return buildUpstreams(count, "upstream"); + } + + private List<Upstream> buildUpstreams(final int count, final String prefix) { + List<Upstream> upstreams = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + upstreams.add(Upstream.builder() + .protocol("http://") + .url(prefix + i + ":8080") + .status(true) + .healthCheckEnabled(false) + .build()); + } + return upstreams; + } + + private UpstreamCheckTask getUpstreamCheckTask(final UpstreamCacheManager manager) { + try { + Field field = UpstreamCacheManager.class.getDeclaredField("task"); + field.setAccessible(true); + return (UpstreamCheckTask) field.get(manager); + } catch (Exception e) { + return null; + } + } +}
