소스 검색

Optimize paginated product search

CRK 2 주 전
부모
커밋
d85ad36b06

+ 124 - 19
server/src/main/java/com/singsong/customer144/Customer144CatalogService.java

@@ -26,7 +26,6 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.PriorityQueue;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.Executor;
@@ -59,6 +58,7 @@ public class Customer144CatalogService {
                     return size() > QUERY_TRANSLATION_CACHE_SIZE;
                 }
             });
+    private final Map<String, SearchCacheEntry> searchResultCache = new LinkedHashMap<>(16, 0.75f, true);
     private volatile Instant lastFullSync;
 
     public Customer144CatalogService(Customer144Properties properties, Customer144Client client,
@@ -125,11 +125,13 @@ public class Customer144CatalogService {
                     .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
                     .filterOptions(ProductService.filterOptions(browseItems)).build();
         }
-        int required = page > Integer.MAX_VALUE / pageSize ? Integer.MAX_VALUE : page * pageSize;
+        String cacheKey = searchCacheKey(request, parsedQueries, fuzzyLookup);
+        SearchCacheEntry cached = cachedSearch(cacheKey);
+        if (cached != null) return cachedPage(cached, page, pageSize);
+
         Comparator<ScoredProduct> bestFirst = comparator(request.getSort(), hasQuery)
                 .thenComparing(item -> item.getProduct().getId());
-        PriorityQueue<ScoredProduct> best = new PriorityQueue<>(Math.max(1, required), bestFirst.reversed());
-        long[] total = {0};
+        List<ScoredProduct> matches = new ArrayList<>();
         Set<String> visualOptions = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
         Set<String> yearOptions = new TreeSet<>(Comparator.reverseOrder());
         for (Customer144Product source : candidates(parsedQueries, fuzzyLookup)) {
@@ -140,16 +142,9 @@ public class Customer144CatalogService {
             if (hasQuery && score <= 0) continue;
             collectFilterOptions(product, visualOptions, yearOptions);
             if (!matchesFilters(product, request.getFilters())) continue;
-            total[0]++;
-            ScoredProduct candidate = new ScoredProduct(product, score, matchedIndex, orderScore);
-            if (best.size() < required) {
-                best.offer(candidate);
-            } else if (required > 0 && bestFirst.compare(candidate, best.peek()) < 0) {
-                best.poll();
-                best.offer(candidate);
-            }
+            matches.add(new ScoredProduct(source.getId(), product, score, matchedIndex, orderScore));
         }
-        if (!hasQuery && total[0] == 0 && !hasActiveFilters(request.getFilters())) {
+        if (!hasQuery && matches.isEmpty() && !hasActiveFilters(request.getFilters())) {
             Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize);
             List<ProductResponse> browseItems = browsePage.getItems().stream()
                     .map(mapper::toProduct).collect(Collectors.toList());
@@ -157,19 +152,104 @@ public class Customer144CatalogService {
                     .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
                     .filterOptions(ProductService.filterOptions(browseItems)).build();
         }
-        List<ScoredProduct> ordered = new ArrayList<>(best);
-        ordered.sort(bestFirst);
-        int from = Math.min((page - 1) * pageSize, ordered.size());
-        List<ProductResponse> items = ordered.subList(from, ordered.size()).stream()
+        matches.sort(bestFirst);
+        int from = pageOffset(page, pageSize, matches.size());
+        int to = Math.min(from + pageSize, matches.size());
+        List<ProductResponse> items = matches.subList(from, to).stream()
                 .map(ScoredProduct::getProduct).collect(Collectors.toList());
         Map<String, List<String>> filterOptions = new LinkedHashMap<>();
         filterOptions.put("visual", new ArrayList<>(visualOptions));
         filterOptions.put("year", new ArrayList<>(yearOptions));
+        List<Long> orderedIds = matches.stream().map(ScoredProduct::getRemoteId).collect(Collectors.toList());
+        cacheSearch(cacheKey, orderedIds, filterOptions);
         return PaginatedResponse.<ProductResponse>builder()
-                .items(items).total(total[0]).page(page).pageSize(pageSize)
+                .items(items).total(matches.size()).page(page).pageSize(pageSize)
                 .filterOptions(filterOptions).build();
     }
 
+    private PaginatedResponse<ProductResponse> cachedPage(SearchCacheEntry cached, int page, int pageSize) {
+        int from = pageOffset(page, pageSize, cached.getOrderedIds().size());
+        int to = Math.min(from + pageSize, cached.getOrderedIds().size());
+        List<Long> pageIds = cached.getOrderedIds().subList(from, to);
+        List<ProductResponse> items = snapshotStore.findAllByIds(pageIds).stream()
+                .map(mapper::toProduct).collect(Collectors.toList());
+        return PaginatedResponse.<ProductResponse>builder()
+                .items(items).total(cached.getOrderedIds().size()).page(page).pageSize(pageSize)
+                .filterOptions(cached.getFilterOptions()).build();
+    }
+
+    private int pageOffset(int page, int pageSize, int total) {
+        long offset = (long) Math.max(page - 1, 0) * pageSize;
+        return (int) Math.min(offset, total);
+    }
+
+    private String searchCacheKey(ProductSearchRequest request,
+                                  List<ProductNameSearch.ParsedQuery> parsedQueries,
+                                  boolean fuzzyLookup) {
+        StringBuilder key = new StringBuilder(fuzzyLookup ? "fuzzy" : "exact");
+        for (ProductNameSearch.ParsedQuery query : parsedQueries) {
+            appendCachePart(key, query.getNormalized());
+            for (String term : query.getTerms()) appendCachePart(key, term);
+            if (fuzzyLookup) {
+                for (String term : query.getLookupTerms()) appendCachePart(key, term);
+            }
+        }
+        appendCachePart(key, request.getSort());
+        Map<String, List<String>> filters = request.getFilters();
+        if (filters != null) {
+            List<String> filterNames = new ArrayList<>(filters.keySet());
+            Collections.sort(filterNames);
+            for (String filterName : filterNames) {
+                appendCachePart(key, filterName);
+                List<String> values = filters.get(filterName);
+                if (values == null) continue;
+                List<String> sortedValues = new ArrayList<>(values);
+                sortedValues.sort(String.CASE_INSENSITIVE_ORDER);
+                for (String value : sortedValues) appendCachePart(key, value);
+            }
+        }
+        return key.toString();
+    }
+
+    private void appendCachePart(StringBuilder key, String value) {
+        String safe = value == null ? "" : value.trim();
+        key.append('|').append(safe.length()).append(':').append(safe);
+    }
+
+    private SearchCacheEntry cachedSearch(String key) {
+        long now = System.currentTimeMillis();
+        synchronized (searchResultCache) {
+            SearchCacheEntry cached = searchResultCache.get(key);
+            if (cached == null) return null;
+            if (cached.getExpiresAt() > now) return cached;
+            searchResultCache.remove(key);
+            return null;
+        }
+    }
+
+    private void cacheSearch(String key, List<Long> orderedIds,
+                             Map<String, List<String>> filterOptions) {
+        int maxEntries = Math.max(properties.getSearchCacheSize(), 0);
+        long ttlMillis = properties.getSearchCacheTtl() == null
+                ? 0L : Math.max(properties.getSearchCacheTtl().toMillis(), 0L);
+        if (maxEntries == 0 || ttlMillis == 0L) return;
+        SearchCacheEntry entry = new SearchCacheEntry(orderedIds, filterOptions,
+                System.currentTimeMillis() + ttlMillis);
+        synchronized (searchResultCache) {
+            searchResultCache.put(key, entry);
+            while (searchResultCache.size() > maxEntries) {
+                String eldest = searchResultCache.keySet().iterator().next();
+                searchResultCache.remove(eldest);
+            }
+        }
+    }
+
+    private void clearSearchCache() {
+        synchronized (searchResultCache) {
+            searchResultCache.clear();
+        }
+    }
+
     private void collectFilterOptions(ProductResponse product, Set<String> visuals, Set<String> years) {
         if (product.getTags() != null && product.getTags().getVisual() != null) {
             product.getTags().getVisual().stream()
@@ -324,6 +404,7 @@ public class Customer144CatalogService {
                 log.info("客户144商品今日增量同步完成,updated_after={},新增或更新 {} 条",
                         updatedAfter, persisted);
             }
+            clearSearchCache();
             lastFullSync = Instant.now();
         } catch (Exception exception) {
             log.warn("客户144商品快照同步失败,本轮已分页落库 {} 条,未执行旧商品清理: {}",
@@ -717,19 +798,43 @@ public class Customer144CatalogService {
     }
 
     private static final class ScoredProduct {
+        private final Long remoteId;
         private final ProductResponse product;
         private final int relevance;
         private final int firstMatchedTermIndex;
         private final int termOrder;
-        private ScoredProduct(ProductResponse product, int relevance, int firstMatchedTermIndex, int termOrder) {
+        private ScoredProduct(Long remoteId, ProductResponse product, int relevance,
+                              int firstMatchedTermIndex, int termOrder) {
+            this.remoteId = remoteId;
             this.product = product;
             this.relevance = relevance;
             this.firstMatchedTermIndex = firstMatchedTermIndex;
             this.termOrder = termOrder;
         }
+        private Long getRemoteId() { return remoteId; }
         private ProductResponse getProduct() { return product; }
         private int getRelevance() { return relevance; }
         private int getFirstMatchedTermIndex() { return firstMatchedTermIndex; }
         private int getTermOrder() { return termOrder; }
     }
+
+    private static final class SearchCacheEntry {
+        private final List<Long> orderedIds;
+        private final Map<String, List<String>> filterOptions;
+        private final long expiresAt;
+
+        private SearchCacheEntry(List<Long> orderedIds, Map<String, List<String>> filterOptions,
+                                 long expiresAt) {
+            this.orderedIds = Collections.unmodifiableList(new ArrayList<>(orderedIds));
+            Map<String, List<String>> copiedOptions = new LinkedHashMap<>();
+            filterOptions.forEach((name, values) -> copiedOptions.put(name,
+                    Collections.unmodifiableList(new ArrayList<>(values))));
+            this.filterOptions = Collections.unmodifiableMap(copiedOptions);
+            this.expiresAt = expiresAt;
+        }
+
+        private List<Long> getOrderedIds() { return orderedIds; }
+        private Map<String, List<String>> getFilterOptions() { return filterOptions; }
+        private long getExpiresAt() { return expiresAt; }
+    }
 }

+ 2 - 0
server/src/main/java/com/singsong/customer144/Customer144Properties.java

@@ -19,6 +19,8 @@ public class Customer144Properties {
     private Duration refreshInterval = Duration.ofMinutes(30);
     private Duration connectTimeout = Duration.ofSeconds(5);
     private Duration readTimeout = Duration.ofSeconds(45);
+    private int searchCacheSize = 12;
+    private Duration searchCacheTtl = Duration.ofMinutes(5);
     private String translationBaseUrl = "http://47.103.220.151:3002/v1";
     private String translationApiKey;
     private String translationModel = "gpt-5.6";

+ 40 - 18
server/src/main/java/com/singsong/customer144/Customer144SnapshotStore.java

@@ -85,7 +85,7 @@ public class Customer144SnapshotStore {
             List<String> group = normalizedGroups.get(groupIndex);
             for (int termIndex = 0; termIndex < group.size(); termIndex++) {
                 if (termIndex > 0) sql.append(" OR ");
-                sql.append("LOWER(search_text) LIKE ? ESCAPE '\\'");
+                sql.append("LOWER(name_search_text) LIKE ? ESCAPE '\\'");
                 parameters.add(likePattern(group.get(termIndex)));
             }
             sql.append(')');
@@ -146,6 +146,18 @@ public class Customer144SnapshotStore {
         return repository.findById(id).flatMap(this::deserialize);
     }
 
+    public List<Customer144Product> findAllByIds(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) return Collections.emptyList();
+        Map<Long, Customer144Product> productsById = repository.findAllById(ids).stream()
+                .map(this::deserialize)
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .collect(Collectors.toMap(Customer144Product::getId, product -> product,
+                        (left, right) -> left, LinkedHashMap::new));
+        return ids.stream().map(productsById::get).filter(product -> product != null)
+                .collect(Collectors.toList());
+    }
+
     public List<Customer144Product> similarPool() {
         return repository.findTop200ByOrderByRemoteIdAsc().stream()
                 .map(this::deserialize)
@@ -296,9 +308,10 @@ public class Customer144SnapshotStore {
     private synchronized boolean persistTranslations(
             Customer144SnapshotRepository.CandidateSnapshot candidate, Customer144Product product) {
         return jdbcTemplate.update("UPDATE customer144_products SET translated_title = ?, " +
-                        "translated_description = ?, search_text = ? WHERE remote_id = ? AND payload = ?",
+                        "translated_description = ?, search_text = ?, name_search_text = ? " +
+                        "WHERE remote_id = ? AND payload = ?",
                 product.getTranslatedTitle(), product.getTranslatedDescription(), searchText(product),
-                candidate.getRemoteId(), candidate.getPayload()) == 1;
+                nameSearchText(product), candidate.getRemoteId(), candidate.getPayload()) == 1;
     }
 
     @Transactional
@@ -316,13 +329,14 @@ public class Customer144SnapshotStore {
             Timestamp timestamp = Timestamp.valueOf(syncedAt);
             List<Object[]> rows = new ArrayList<>();
             for (Customer144Product product : products) {
-                rows.add(new Object[]{product.getId(), searchText(product), serialize(product), timestamp,
+                rows.add(new Object[]{product.getId(), searchText(product), nameSearchText(product),
+                        serialize(product), timestamp,
                         product.getTranslatedTitle(), product.getTranslatedDescription(),
                         Boolean.TRUE.equals(product.getInStock())});
             }
             jdbcTemplate.batchUpdate("INSERT INTO customer144_products " +
-                    "(remote_id, search_text, payload, synced_at, translated_title, translated_description, in_stock) " +
-                    "VALUES (?, ?, ?, ?, ?, ?, ?)", rows);
+                    "(remote_id, search_text, name_search_text, payload, synced_at, translated_title, " +
+                    "translated_description, in_stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows);
         }
         discoverTags(products);
         if (TransactionSynchronizationManager.isSynchronizationActive()) {
@@ -344,16 +358,16 @@ public class Customer144SnapshotStore {
             Timestamp timestamp = Timestamp.valueOf(syncedAt);
             for (Customer144Product product : products) {
                 int updated = jdbcTemplate.update("UPDATE customer144_products SET " +
-                                "search_text = ?, payload = ?, synced_at = ?, translated_title = ?, " +
-                                "translated_description = ?, in_stock = ? WHERE remote_id = ?",
-                        searchText(product), serialize(product), timestamp,
+                                "search_text = ?, name_search_text = ?, payload = ?, synced_at = ?, " +
+                                "translated_title = ?, translated_description = ?, in_stock = ? WHERE remote_id = ?",
+                        searchText(product), nameSearchText(product), serialize(product), timestamp,
                         product.getTranslatedTitle(), product.getTranslatedDescription(),
                         Boolean.TRUE.equals(product.getInStock()), product.getId());
                 if (updated == 0) {
                     jdbcTemplate.update("INSERT INTO customer144_products " +
-                                    "(remote_id, search_text, payload, synced_at, translated_title, " +
-                                    "translated_description, in_stock) VALUES (?, ?, ?, ?, ?, ?, ?)",
-                            product.getId(), searchText(product), serialize(product), timestamp,
+                                    "(remote_id, search_text, name_search_text, payload, synced_at, translated_title, " +
+                                    "translated_description, in_stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+                            product.getId(), searchText(product), nameSearchText(product), serialize(product), timestamp,
                             product.getTranslatedTitle(), product.getTranslatedDescription(),
                             Boolean.TRUE.equals(product.getInStock()));
                 }
@@ -379,16 +393,16 @@ public class Customer144SnapshotStore {
             Timestamp timestamp = Timestamp.valueOf(syncedAt);
             for (Customer144Product product : products) {
                 int updated = jdbcTemplate.update("UPDATE customer144_products SET " +
-                                "search_text = ?, payload = ?, synced_at = ?, translated_title = ?, " +
-                                "translated_description = ?, in_stock = ? WHERE remote_id = ?",
-                        searchText(product), serialize(product), timestamp,
+                                "search_text = ?, name_search_text = ?, payload = ?, synced_at = ?, " +
+                                "translated_title = ?, translated_description = ?, in_stock = ? WHERE remote_id = ?",
+                        searchText(product), nameSearchText(product), serialize(product), timestamp,
                         product.getTranslatedTitle(), product.getTranslatedDescription(),
                         Boolean.TRUE.equals(product.getInStock()), product.getId());
                 if (updated == 0) {
                     jdbcTemplate.update("INSERT INTO customer144_products " +
-                                    "(remote_id, search_text, payload, synced_at, translated_title, " +
-                                    "translated_description, in_stock) VALUES (?, ?, ?, ?, ?, ?, ?)",
-                            product.getId(), searchText(product), serialize(product), timestamp,
+                                    "(remote_id, search_text, name_search_text, payload, synced_at, translated_title, " +
+                                    "translated_description, in_stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+                            product.getId(), searchText(product), nameSearchText(product), serialize(product), timestamp,
                             product.getTranslatedTitle(), product.getTranslatedDescription(),
                             Boolean.TRUE.equals(product.getInStock()));
                 }
@@ -507,6 +521,14 @@ public class Customer144SnapshotStore {
                 .trim();
     }
 
+    private String nameSearchText(Customer144Product product) {
+        return Stream.of(product.getTitle(), product.getTranslatedTitle())
+                .filter(value -> value != null && !value.trim().isEmpty())
+                .map(value -> value.trim().toLowerCase(Locale.ROOT))
+                .distinct()
+                .collect(Collectors.joining(" "));
+    }
+
     private Stream<String> searchableValues(Customer144Product product) {
         return Stream.of(product.getTitle(), product.getTranslatedTitle(), product.getSkuNum(), product.getProductCode(),
                 product.getEnglishDescription(), product.getTranslatedDescription(), product.getBrand(),

+ 2 - 0
server/src/main/resources/application.yml

@@ -49,6 +49,8 @@ app:
     refresh-interval: 30m
     connect-timeout: 5s
     read-timeout: 45s
+    search-cache-size: ${CUSTOMER144_SEARCH_CACHE_SIZE:12}
+    search-cache-ttl: ${CUSTOMER144_SEARCH_CACHE_TTL:5m}
     translation-base-url: ${TRANSLATION_BASE_URL:http://47.103.220.151:3002/v1}
     translation-api-key: ${TRANSLATION_API_KEY:}
     translation-model: ${TRANSLATION_MODEL:gpt-5.6}

+ 2 - 0
server/src/main/resources/db/migration/V15__customer144_name_search_text.sql

@@ -0,0 +1,2 @@
+ALTER TABLE customer144_products
+ADD COLUMN name_search_text TEXT DEFAULT '' NOT NULL;

+ 8 - 0
server/src/main/resources/db/postgresql/V16__customer144_product_name_search.sql

@@ -0,0 +1,8 @@
+UPDATE customer144_products
+SET name_search_text = LOWER(TRIM(CONCAT_WS(' ',
+    NULLIF(payload::jsonb ->> 'title', ''),
+    NULLIF(translated_title, '')
+)));
+
+CREATE INDEX IF NOT EXISTS idx_customer144_name_search_text_trgm
+    ON customer144_products USING GIN (LOWER(name_search_text) gin_trgm_ops);

+ 49 - 0
server/src/test/java/com/singsong/customer144/Customer144CatalogServiceTest.java

@@ -97,6 +97,8 @@ class Customer144CatalogServiceTest {
         Customer144Product wickerContainer = product(1);
         wickerContainer.setTitle("Wicker Container");
         when(store.candidates(anyList())).thenReturn(Collections.singletonList(wickerContainer));
+        when(store.findAllByIds(Collections.singletonList(1L)))
+                .thenReturn(Collections.singletonList(wickerContainer));
         when(translationService.translateToEnglishOrNull("藤编容器")).thenReturn("wicker container");
         Customer144CatalogService service = service(
                 mock(Customer144Client.class), store, 500, Runnable::run, translationService);
@@ -113,6 +115,53 @@ class Customer144CatalogServiceTest {
         assertThat(second.getItems()).extracting(ProductResponse::getNameEn)
                 .containsExactly("Wicker Container");
         verify(translationService).translateToEnglishOrNull("藤编容器");
+        verify(store, times(2)).candidates(anyList());
+        verify(store).findAllByIds(Collections.singletonList(1L));
+    }
+
+    @Test
+    void subsequentPagesReuseStableSearchOrderAndLoadOnlyRequestedIds() {
+        Customer144SnapshotStore store = mock(Customer144SnapshotStore.class);
+        List<Customer144Product> products = new ArrayList<>();
+        for (long id = 1; id <= 45; id++) {
+            Customer144Product product = product(id);
+            product.setTitle("Gift " + String.format("%02d", id));
+            products.add(product);
+        }
+        when(store.candidates(anyList())).thenReturn(products);
+        when(store.findAllByIds(anyList())).thenAnswer(invocation -> {
+            List<Long> ids = invocation.getArgument(0);
+            Map<Long, Customer144Product> byId = products.stream()
+                    .collect(java.util.stream.Collectors.toMap(Customer144Product::getId, product -> product));
+            return ids.stream().map(byId::get).collect(java.util.stream.Collectors.toList());
+        });
+        Customer144CatalogService service = service(mock(Customer144Client.class), store);
+        ProductSearchRequest request = new ProductSearchRequest();
+        request.setQuery("gift");
+        request.setPageSize(20);
+
+        PaginatedResponse<ProductResponse> first = service.search(request).orElseThrow(AssertionError::new);
+        request.setPage(2);
+        PaginatedResponse<ProductResponse> second = service.search(request).orElseThrow(AssertionError::new);
+        request.setPage(3);
+        PaginatedResponse<ProductResponse> third = service.search(request).orElseThrow(AssertionError::new);
+
+        assertThat(first.getItems()).hasSize(20);
+        assertThat(second.getItems()).hasSize(20);
+        assertThat(third.getItems()).hasSize(5);
+        List<String> expectedIds = products.stream().map(product -> "c144-" + product.getId())
+                .sorted().collect(java.util.stream.Collectors.toList());
+        assertThat(second.getItems()).extracting(ProductResponse::getId)
+                .containsExactlyElementsOf(expectedIds.subList(20, 40));
+        assertThat(third.getItems()).extracting(ProductResponse::getId)
+                .containsExactlyElementsOf(expectedIds.subList(40, 45));
+        verify(store).candidates(Collections.singletonList(Collections.singletonList("gift")));
+        verify(store).findAllByIds(expectedIds.subList(20, 40).stream()
+                .map(id -> Long.valueOf(id.substring("c144-".length())))
+                .collect(java.util.stream.Collectors.toList()));
+        verify(store).findAllByIds(expectedIds.subList(40, 45).stream()
+                .map(id -> Long.valueOf(id.substring("c144-".length())))
+                .collect(java.util.stream.Collectors.toList()));
     }
 
     @Test

+ 21 - 3
server/src/test/java/com/singsong/customer144/Customer144SnapshotStoreTest.java

@@ -92,9 +92,9 @@ class Customer144SnapshotStoreTest {
         ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
         verify(jdbcTemplate).query(sql.capture(), parameters.capture(),
                 org.mockito.ArgumentMatchers.<org.springframework.jdbc.core.RowMapper<Customer144Product>>any());
-        assertThat(sql.getValue()).contains("(LOWER(search_text) LIKE ? ESCAPE '\\' OR " +
-                        "LOWER(search_text) LIKE ? ESCAPE '\\') AND " +
-                        "(LOWER(search_text) LIKE ? ESCAPE '\\')")
+        assertThat(sql.getValue()).contains("(LOWER(name_search_text) LIKE ? ESCAPE '\\' OR " +
+                        "LOWER(name_search_text) LIKE ? ESCAPE '\\') AND " +
+                        "(LOWER(name_search_text) LIKE ? ESCAPE '\\')")
                 .doesNotContain("Christmas", "圣诞节", "Metal");
         assertThat(parameters.getValue()).containsExactly("%christmas%", "%圣诞节%", "%metal%");
         assertThat(sql.getValue()).doesNotContain("in_stock");
@@ -165,6 +165,24 @@ class Customer144SnapshotStoreTest {
         verify(objectMapper, times(2)).readValue(anyString(), eq(Customer144Product.class));
     }
 
+    @Test
+    void findAllByIdsPreservesCachedSearchOrder() throws Exception {
+        Customer144SnapshotRepository repository = mock(Customer144SnapshotRepository.class);
+        ObjectMapper objectMapper = mock(ObjectMapper.class);
+        Customer144Snapshot first = snapshot("first");
+        Customer144Snapshot second = snapshot("second");
+        when(first.getRemoteId()).thenReturn(1L);
+        when(second.getRemoteId()).thenReturn(2L);
+        when(repository.findAllById(Arrays.asList(2L, 1L))).thenReturn(Arrays.asList(first, second));
+        when(objectMapper.readValue("first", Customer144Product.class)).thenReturn(product(1L, "first"));
+        when(objectMapper.readValue("second", Customer144Product.class)).thenReturn(product(2L, "second"));
+
+        List<Customer144Product> products = store(repository, objectMapper,
+                mock(Customer144TranslationService.class)).findAllByIds(Arrays.asList(2L, 1L));
+
+        assertThat(products).extracting(Customer144Product::getId).containsExactly(2L, 1L);
+    }
+
     @Test
     void scanReadsSnapshotInBoundedStablePages() throws Exception {
         Customer144SnapshotRepository repository = mock(Customer144SnapshotRepository.class);