性能优化:N+1查询改批量、串行远程调用并发化、紫鸟索引巡检批量回写
Build Backend JAR / build (push) Has been cancelled

This commit is contained in:
2026-08-25 19:42:43 +08:00
parent 338493ecaa
commit 5f519f9cd5
14 changed files with 425 additions and 53 deletions
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.brand.client;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.nanri.aiimage.config.BrandCheckProperties;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,6 +17,12 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
@Component
@RequiredArgsConstructor
@@ -24,9 +31,29 @@ public class BrandCheckClient {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
/** 品牌检查并发度:避免一次批量检查把第三方服务打满。 */
private static final int BRAND_CHECK_CONCURRENCY = 3;
private final BrandCheckProperties properties;
private volatile RestClient sharedRestClient;
private final ExecutorService checkExecutor = Executors.newFixedThreadPool(
BRAND_CHECK_CONCURRENCY, namedThreadFactory("brand-check"));
@PreDestroy
void shutdownCheckExecutor() {
checkExecutor.shutdownNow();
}
private static ThreadFactory namedThreadFactory(String prefix) {
AtomicInteger counter = new AtomicInteger();
return r -> {
Thread t = new Thread(r, prefix + "-" + counter.incrementAndGet());
t.setDaemon(true);
return t;
};
}
public BrandCheckResponse check(String brand) {
return check(brand, properties.getDefaultStrategy());
}
@@ -55,25 +82,47 @@ public class BrandCheckClient {
public BrandCheckBatchResult checkAll(List<String> brands, String strategy) {
List<String> distinctBrands = distinctNonBlank(brands);
List<CompletableFuture<BrandCheckOutcome>> futures = new ArrayList<>(distinctBrands.size());
for (String brand : distinctBrands) {
futures.add(CompletableFuture.supplyAsync(
() -> checkOneBrand(brand, strategy), checkExecutor));
}
List<Object> failedData = new ArrayList<>();
List<Object> queryFailedData = new ArrayList<>();
for (String brand : distinctBrands) {
try {
BrandCheckResponse response = check(brand, strategy);
if (response == null) {
queryFailedData.add(brand);
continue;
}
failedData.addAll(nullToEmpty(response.getFaildData()));
queryFailedData.addAll(nullToEmpty(response.getQueryFaildData()));
} catch (Exception ex) {
log.warn("[brand-check] request failed brand={} strategy={} err={}", brand, strategy, ex.getMessage());
queryFailedData.add(brand);
}
for (CompletableFuture<BrandCheckOutcome> future : futures) {
BrandCheckOutcome outcome = awaitOutcome(future);
failedData.addAll(outcome.failedData());
queryFailedData.addAll(outcome.queryFailedData());
}
return new BrandCheckBatchResult(distinctBrands, failedData, queryFailedData);
}
private BrandCheckOutcome checkOneBrand(String brand, String strategy) {
try {
BrandCheckResponse response = check(brand, strategy);
if (response == null) {
return new BrandCheckOutcome(List.of(), List.of(brand));
}
return new BrandCheckOutcome(nullToEmpty(response.getFaildData()), nullToEmpty(response.getQueryFaildData()));
} catch (Exception ex) {
log.warn("[brand-check] request failed brand={} strategy={} err={}", brand, strategy, ex.getMessage());
return new BrandCheckOutcome(List.of(), List.of(brand));
}
}
private BrandCheckOutcome awaitOutcome(CompletableFuture<BrandCheckOutcome> future) {
try {
return future.join();
} catch (CompletionException ex) {
log.warn("[brand-check] unexpected batch failure err={}",
ex.getCause() == null ? ex.getMessage() : String.valueOf(ex.getCause().getMessage()));
return new BrandCheckOutcome(List.of(), List.of());
}
}
private record BrandCheckOutcome(List<Object> failedData, List<Object> queryFailedData) {
}
public BrandCheckBatchResult checkTitleText(String titleText) {
return checkTitleText(titleText, properties.getDefaultStrategy());
}
@@ -35,6 +35,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
@@ -65,6 +66,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -82,6 +89,25 @@ public class BrandTaskService {
private static final String STATUS_FAILED = "failed";
private static final String STATUS_CANCELLED = "cancelled";
private static final String MODULE_TYPE = "BRAND";
/** 结果文件并发上传数:OSS/MinIO 上传互不依赖,3 并发平衡收益与内存占用。 */
private static final int RESULT_UPLOAD_CONCURRENCY = 3;
private final ExecutorService resultUploadExecutor = Executors.newFixedThreadPool(
RESULT_UPLOAD_CONCURRENCY, namedThreadFactory("brand-result-upload"));
@PreDestroy
void shutdownResultUploadExecutor() {
resultUploadExecutor.shutdownNow();
}
private static ThreadFactory namedThreadFactory(String prefix) {
AtomicInteger counter = new AtomicInteger();
return r -> {
Thread t = new Thread(r, prefix + "-" + counter.incrementAndGet());
t.setDaemon(true);
return t;
};
}
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
private final OssStorageService ossStorageService;
@@ -1036,10 +1062,27 @@ public class BrandTaskService {
throw new BusinessException("没有可上传的结果文件");
}
List<String> fullUrls = new ArrayList<>();
List<CompletableFuture<String>> uploadFutures = new ArrayList<>(entries.size());
for (OutputEntry entry : entries) {
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取 objectKey(见 OssStorageService.resolveObjectKey
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE);
fullUrls.add(ossStorageService.getPublicUrl(objectKey));
uploadFutures.add(CompletableFuture.supplyAsync(() -> {
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取 objectKey(见 OssStorageService.resolveObjectKey
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE);
return ossStorageService.getPublicUrl(objectKey);
}, resultUploadExecutor));
}
for (CompletableFuture<String> future : uploadFutures) {
try {
fullUrls.add(future.join());
} catch (CompletionException ex) {
Throwable cause = ex.getCause() == null ? ex : ex.getCause();
if (cause instanceof IOException ioe) {
throw ioe;
}
if (cause instanceof RuntimeException re) {
throw re;
}
throw new RuntimeException(cause);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("urls", fullUrls);
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.productcategory.service;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
@@ -309,10 +310,17 @@ public class ProductCategoryService {
if (rows.isEmpty()) {
return childCountById;
}
for (ProductCategoryEntity row : rows) {
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getParentId, row.getId()));
childCountById.put(row.getId(), childCount == null ? 0 : childCount.intValue());
List<Map<String, Object>> countRows = productCategoryMapper.selectMaps(new QueryWrapper<ProductCategoryEntity>()
.select("parent_id", "count(*) AS child_count")
.isNotNull("parent_id")
.groupBy("parent_id"));
for (Map<String, Object> countRow : countRows) {
Object parentId = countRow.get("parent_id");
Object childCount = countRow.get("child_count");
if (parentId == null || childCount == null) {
continue;
}
childCountById.put(((Number) parentId).longValue(), ((Number) childCount).intValue());
}
return childCountById;
}
@@ -69,6 +69,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Collectors;
@Slf4j
@Service
@@ -449,15 +450,24 @@ public class PublishTaskService {
}
TaskOptions options = readTaskOptions(task);
List<Long> successfulFileIds = successfulFiles.stream()
.map(PublishFileEntity::getId)
.toList();
Map<Long, List<PublishItemEntity>> itemsByFileId = successfulFileIds.isEmpty()
? Map.of()
: publishItemMapper.selectList(
new LambdaQueryWrapper<PublishItemEntity>()
.eq(PublishItemEntity::getTaskId, task.getId())
.in(PublishItemEntity::getFileId, successfulFileIds)
.orderByAsc(PublishItemEntity::getRowIndex))
.stream()
.collect(Collectors.groupingBy(PublishItemEntity::getFileId,
LinkedHashMap::new, Collectors.toList()));
List<PublishWorkbookService.WorkbookInput> inputs = new ArrayList<>();
int rowCount = 0;
for (PublishFileEntity file : successfulFiles) {
List<PublishItemEntity> items = publishItemMapper.selectList(
new LambdaQueryWrapper<PublishItemEntity>()
.eq(PublishItemEntity::getTaskId, task.getId())
.eq(PublishItemEntity::getFileId, file.getId())
.orderByAsc(PublishItemEntity::getRowIndex));
List<PublishRowDto> rows = items.stream().map(this::toRowDto).toList();
List<PublishRowDto> rows = itemsByFileId.getOrDefault(file.getId(), List.of())
.stream().map(this::toRowDto).toList();
rowCount += rows.size();
inputs.add(new PublishWorkbookService.WorkbookInput(
file.getSourceFilename(), file.getShopName(), options.publishCountry(), rows));
@@ -136,9 +136,12 @@ public class QueryAsinResolveService {
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 无有效店铺名");
}
List<QueryAsinEntity> allAsins = queryAsinMapper.selectList(
new LambdaQueryWrapper<QueryAsinEntity>().orderByAsc(QueryAsinEntity::getId));
List<QueryAsinCountryAsinsDto> preloadedQueryAsins = toCountryAsins(allAsins);
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(request.getUserId(), shopName));
vo.getItems().add(matchOneShop(shopName, preloadedQueryAsins));
}
return vo;
}
@@ -161,7 +164,8 @@ public class QueryAsinResolveService {
return count == null ? 0L : count;
}
private ProductRiskShopQueueItemVo matchOneShop(Long userId, String shopName) {
private ProductRiskShopQueueItemVo matchOneShop(String shopName,
List<QueryAsinCountryAsinsDto> preloadedQueryAsins) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName);
try {
@@ -180,7 +184,7 @@ public class QueryAsinResolveService {
item.setMatchStatus(matched.getMatchStatus());
item.setMatchMessage(matched.getMatchMessage());
item.setOpenStoreUrl(matched.getOpenStoreUrl());
item.setQueryAsins(loadQueryAsinsForShop(userId, shopName));
item.setQueryAsins(preloadedQueryAsins);
if (item.isMatched() && item.getQueryAsins().isEmpty()) {
item.setMatchMessage("紫鸟已匹配,但后台查询 ASIN 表暂无数据");
}
@@ -60,6 +60,7 @@ import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -80,6 +81,8 @@ public class ShopDataCrawlTaskService {
private static final int RESULT_PENDING = -1;
private static final int RESULT_FAILED = 0;
private static final int RESULT_SUCCESS = 1;
/** 批量 IN 查询单批上限。 */
private static final int ID_BATCH_SIZE = 500;
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
@@ -800,12 +803,24 @@ public class ShopDataCrawlTaskService {
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
Comparator.nullsLast(Comparator.naturalOrder())));
Map<Long, FileResultEntity> resultById = new HashMap<>();
List<Long> keptResultIds = new ArrayList<>();
for (ShopDataCrawlDailyMemberEntity member : members) {
if (!removedResultIds.contains(member.getResultId())) {
keptResultIds.add(member.getResultId());
}
}
for (List<Long> batch : toIdBatches(keptResultIds)) {
for (FileResultEntity result : fileResultMapper.selectBatchIds(batch)) {
resultById.put(result.getId(), result);
}
}
List<DailyMemberData> survivors = new ArrayList<>();
for (ShopDataCrawlDailyMemberEntity member : members) {
if (removedResultIds.contains(member.getResultId())) {
continue;
}
FileResultEntity result = fileResultMapper.selectById(member.getResultId());
FileResultEntity result = resultById.get(member.getResultId());
if (result == null || !Integer.valueOf(RESULT_SUCCESS).equals(result.getSuccess())) {
continue;
}
@@ -818,6 +833,14 @@ public class ShopDataCrawlTaskService {
return survivors;
}
private List<List<Long>> toIdBatches(List<Long> ids) {
List<List<Long>> batches = new ArrayList<>();
for (int start = 0; start < ids.size(); start += ID_BATCH_SIZE) {
batches.add(ids.subList(start, Math.min(start + ID_BATCH_SIZE, ids.size())));
}
return batches;
}
private ShopDataCrawlResultItemVo loadSnapshotForDailyMember(FileResultEntity result) {
ShopDataCrawlResultItemVo snapshot = taskResultItemService.getResultSnapshot(
result.getTaskId(), MODULE_TYPE, result.getId(), ShopDataCrawlResultItemVo.class);
@@ -280,9 +280,22 @@ public class ShopManageGroupService {
for (Long memberUserId : memberUserIds) {
uniqueIds.add(normalizePositiveId(memberUserId, "组员用户不能为空"));
}
List<AdminUserEntity> rawUsers = adminUserMapper.selectBatchIds(uniqueIds);
Map<Long, AdminUserEntity> userById = new LinkedHashMap<>();
for (AdminUserEntity user : rawUsers) {
userById.put(user.getId(), user);
}
Set<Long> boundUserIds = groupMemberMapper.selectList(
new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.in(ShopManageGroupMemberEntity::getUserId, uniqueIds)
.ne(currentGroupId != null,
ShopManageGroupMemberEntity::getGroupId, currentGroupId))
.stream()
.map(ShopManageGroupMemberEntity::getUserId)
.collect(Collectors.toSet());
List<AdminUserEntity> members = new ArrayList<>();
for (Long memberUserId : uniqueIds) {
AdminUserEntity user = adminUserMapper.selectById(memberUserId);
AdminUserEntity user = userById.get(memberUserId);
if (user == null) {
throw new BusinessException("组员用户不存在");
}
@@ -295,13 +308,7 @@ public class ShopManageGroupService {
if (!operatorCanBindAllNormals && (user.getCreatedById() == null || !user.getCreatedById().equals(leaderId))) {
throw new BusinessException("只能添加组长自己创建的普通员工账号");
}
LambdaQueryWrapper<ShopManageGroupMemberEntity> query = new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getUserId, user.getId());
if (currentGroupId != null) {
query.ne(ShopManageGroupMemberEntity::getGroupId, currentGroupId);
}
Long count = groupMemberMapper.selectCount(query);
if (count != null && count > 0) {
if (boundUserIds.contains(user.getId())) {
throw new BusinessException("该普通员工账号已加入其他分组");
}
members.add(user);
@@ -17,8 +17,10 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -50,6 +52,9 @@ public class SimilarAsinImagePrefetchService {
/** P2-11:预热线程池容量。与图片处理 CPU 槽位对齐,避免预热路径绕过主池放大并发。 */
private static final int PREFETCH_POOL_SIZE = 2;
/** 批量命中检查/批量 touch 单次 IN 上限,避免超长 SQL 参数列表。 */
private static final int CACHE_LOOKUP_BATCH_SIZE = 500;
/** 排队等待上一个 task future 时的最大等待时间(秒),避免被 hung future 永久卡住。 */
private static final long INFLIGHT_WAIT_SECONDS = 60L;
@@ -118,21 +123,40 @@ public class SimilarAsinImagePrefetchService {
int hit = 0;
int miss = 0;
int fail = 0;
Map<String, String> hashByUrl = new LinkedHashMap<>();
Set<String> cachedHashes = new LinkedHashSet<>();
for (String url : urls) {
try {
String urlHash = sha256Hex(url);
if (urlHash == null) {
fail++;
continue;
}
String urlHash = sha256Hex(url);
if (urlHash == null) {
fail++;
continue;
}
hashByUrl.put(url, urlHash);
}
if (!hashByUrl.isEmpty()) {
List<String> allHashes = new ArrayList<>(hashByUrl.values());
for (int start = 0; start < allHashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
List<String> batch = allHashes.subList(start,
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, allHashes.size()));
// 命中 DB cachebumping last_used_at 即可,不再下载。
Long existing = taskImageCacheMapper.selectCount(new LambdaQueryWrapper<TaskImageCacheEntity>()
.eq(TaskImageCacheEntity::getUrlHash, urlHash));
if (existing != null && existing > 0L) {
taskImageCacheMapper.touchLastUsed(urlHash);
hit++;
continue;
List<String> found = taskImageCacheMapper.selectUrlHashes(batch);
if (found != null) {
cachedHashes.addAll(found);
}
}
for (int start = 0; start < allHashes.size(); start += CACHE_LOOKUP_BATCH_SIZE) {
taskImageCacheMapper.touchLastUsedBatch(allHashes.subList(start,
Math.min(start + CACHE_LOOKUP_BATCH_SIZE, allHashes.size())));
}
hit = cachedHashes.size();
}
for (Map.Entry<String, String> entry : hashByUrl.entrySet()) {
String url = entry.getKey();
String urlHash = entry.getValue();
if (cachedHashes.contains(urlHash)) {
continue;
}
try {
ResizedImage thumb = imageEmbedder.fetchAndResizeForCache(url);
if (thumb == null || thumb.bytes() == null || thumb.bytes().length == 0) {
fail++;
@@ -3751,7 +3751,13 @@ public class SimilarAsinTaskService {
if (taskId == null || taskId <= 0) {
return;
}
// 只取 flush 需要的列parsed_payload_json/coze_* 等大字段不拉避免跨库大结果集传输每任务可达数百行大 JSON
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.select(TaskScopeStateEntity::getId,
TaskScopeStateEntity::getTaskId,
TaskScopeStateEntity::getModuleType,
TaskScopeStateEntity::getCozeStatus,
TaskScopeStateEntity::getStateJson)
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED)));
@@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
/**
* P2-12:图片缩略图缓存 mapper。提供 LRU 命中刷新、按 url_hash 直读字节两个轻量入口,
@@ -23,6 +24,20 @@ public interface TaskImageCacheMapper extends BaseMapper<TaskImageCacheEntity> {
@Update("UPDATE biz_task_image_cache SET last_used_at = NOW(3) WHERE url_hash = #{urlHash}")
int touchLastUsed(@Param("urlHash") String urlHash);
/**
* 批量命中检查:仅取 url_hash 列(避免把 image_bytes BLOB 拉回 JVM)。
*/
@Select("<script>SELECT url_hash FROM biz_task_image_cache WHERE url_hash IN " +
"<foreach collection='urlHashes' item='hash' open='(' separator=',' close=')'>#{hash}</foreach></script>")
List<String> selectUrlHashes(@Param("urlHashes") List<String> urlHashes);
/**
* 批量 bumping last_used_at。
*/
@Update("<script>UPDATE biz_task_image_cache SET last_used_at = NOW(3) WHERE url_hash IN " +
"<foreach collection='urlHashes' item='hash' open='(' separator=',' close=')'>#{hash}</foreach></script>")
int touchLastUsedBatch(@Param("urlHashes") List<String> urlHashes);
/**
* 按 url_hash 直读缩略图字节,命中时返回 BLOB;未命中返回 null。
* 选择只 select image_bytes 一列,避免把整行 entity(含 url 字符串)拉回 JVM。
@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.ziniao.memory.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.ziniao.memory.mapper.ZiniaoMemoryStoreMapper;
import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity;
@@ -14,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -181,6 +183,55 @@ public class ZiniaoMemoryStoreService {
return deleted;
}
/**
* 批量标 STALE 回写(索引巡检):调用方已持有完整行(含 id),不再逐条 findOne,
* 避免每轮刷新把数百行按"每个事务一条 SELECT + 一条 UPDATE"的方式打回数据库。
* 仅 payload 内容发生变化的行走这里。
*/
@Transactional
public int updateStaleMarks(List<ZiniaoMemoryStoreEntity> rows) {
if (rows == null || rows.isEmpty()) {
return 0;
}
int updated = 0;
for (ZiniaoMemoryStoreEntity row : rows) {
if (row == null || row.getId() == null) {
continue;
}
updated += ziniaoMemoryStoreMapper.updateById(row);
if (CACHE_TYPE_SHOP_INDEX_ENTRY.equals(row.getCacheType())) {
log.info("[ziniao-shop-index-db] mark-stale cacheKey={} id={} companyNameSample={} expiresAt={} bytes={}",
row.getCacheKey(), row.getId(), sampleCompanyNameForLog(row.getCompanyName()),
row.getExpiresAt(), row.getPayloadJson() == null ? 0 : row.getPayloadJson().length());
}
}
return updated;
}
/**
* 批量刷新过期时间(索引巡检):payload 未变化的行不重写 JSON,仅用一条 UPDATE 延长 TTL
* 保持"未过期行常驻表内"的原有语义。
*/
@Transactional
public void touchExpiryBatch(List<ZiniaoMemoryStoreEntity> rows, Duration ttl) {
if (rows == null || rows.isEmpty() || ttl == null || ttl.isZero() || ttl.isNegative()) {
return;
}
List<Long> ids = new ArrayList<>();
for (ZiniaoMemoryStoreEntity row : rows) {
if (row != null && row.getId() != null) {
ids.add(row.getId());
}
}
if (ids.isEmpty()) {
return;
}
ziniaoMemoryStoreMapper.update(null, new LambdaUpdateWrapper<ZiniaoMemoryStoreEntity>()
.in(ZiniaoMemoryStoreEntity::getId, ids)
.set(ZiniaoMemoryStoreEntity::getExpiresAt, LocalDateTime.now().plusSeconds(ttl.getSeconds()))
.set(ZiniaoMemoryStoreEntity::getUpdatedAt, LocalDateTime.now()));
}
@Transactional
public int deleteAllByType(String cacheType) {
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
@@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -590,10 +591,13 @@ public class ZiniaoShopIndexService {
if (entities.isEmpty()) {
return;
}
List<ZiniaoMemoryStoreEntity> staleUpdates = new ArrayList<>();
List<ZiniaoMemoryStoreEntity> expiryTouches = new ArrayList<>();
int staleCount = 0;
for (ZiniaoMemoryStoreEntity entity : entities) {
String rowKey = entity.getCacheKey();
if (activeCacheKeys.contains(rowKey)) {
// 活跃行已由本轮 refresh 的 put() 刷新过 TTL,无需再碰
continue;
}
ZiniaoShopIndexEntryDto existingEntry;
@@ -629,10 +633,31 @@ public class ZiniaoShopIndexService {
}
existingEntry.setStatus(STATUS_STALE);
existingEntry.setMessage("店铺索引已过期,请等待后台刷新");
existingEntry.setLastRefreshedAt(now);
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, rowKey, existingEntry, resolveEntryTtl());
// 不再更新 lastRefreshedAtSTALE 行查询路径不读该字段(仅 ACTIVE 行参与保鲜判断),
// 跳过可让已标 STALE 且内容不变的行不再被逐轮全量重写。
String newPayload;
try {
newPayload = objectMapper.writeValueAsString(existingEntry);
} catch (Exception ex) {
throw new BusinessException("写入紫鸟记忆存储失败", ex);
}
if (Objects.equals(newPayload, entity.getPayloadJson())) {
// 内容未变化:仅延长 TTL,保持"未过期行常驻"语义,不重写 JSON
expiryTouches.add(entity);
continue;
}
entity.setPayloadJson(newPayload);
entity.setExpiresAt(LocalDateTime.now().plus(resolveEntryTtl()));
entity.setUpdatedAt(LocalDateTime.now());
staleUpdates.add(entity);
staleCount++;
}
if (!staleUpdates.isEmpty()) {
ziniaoMemoryStoreService.updateStaleMarks(staleUpdates);
}
if (!expiryTouches.isEmpty()) {
ziniaoMemoryStoreService.touchExpiryBatch(expiryTouches, resolveEntryTtl());
}
if (staleCount > 0) {
log.info("[ziniao-index] marked stale shop_index rows count={}", staleCount);
}
@@ -388,7 +388,7 @@ class ShopDataCrawlTaskServiceRetentionTest {
when(taskDistributedLockService.acquire(MODULE_TYPE, TASK_ID))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(fileResultMapper.selectById(RESULT_ID)).thenReturn(currentRow);
when(fileResultMapper.selectById(200L)).thenReturn(previous);
when(fileResultMapper.selectBatchIds(List.of(200L))).thenReturn(List.of(previous));
when(fileResultMapper.selectList(any())).thenReturn(List.of());
when(dailyFileService.findByLatestResultId(RESULT_ID)).thenReturn(List.of(daily));
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of(removedMember));
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.ziniao.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity;
import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService;
import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoTransientCacheService;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexEntryDto;
@@ -17,6 +18,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -24,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
@@ -209,6 +212,110 @@ class ZiniaoShopIndexServiceTest {
assertEquals(Integer.valueOf(0), cursor.getNextApiKeyOffset());
}
@Test
void staleRowsWithoutPayloadChangeSkipRewriteAndOnlyTouchExpiry() throws Exception {
ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct");
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key));
when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L);
when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L)));
when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L)).thenReturn(List.of());
ZiniaoShopIndexEntryDto dto = new ZiniaoShopIndexEntryDto();
dto.setNormalizedShopName("shop-a");
dto.setCompanyName("acct");
dto.setStatus("STALE");
dto.setMessage("店铺索引已过期,请等待后台刷新");
dto.setLastRefreshedAt(1000L);
String payload = new ObjectMapper().writeValueAsString(dto);
ZiniaoMemoryStoreEntity row = new ZiniaoMemoryStoreEntity();
row.setId(1L);
row.setCacheType("SHOP_INDEX_ENTRY");
row.setCacheKey("n:shop-a");
row.setCompanyName("acct");
row.setPayloadJson(payload);
row.setExpiresAt(LocalDateTime.now().plusHours(12));
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)).thenReturn(List.of(row));
service.refreshAllShopIndex();
verify(ziniaoMemoryStoreService, never()).updateStaleMarks(anyList());
verify(ziniaoMemoryStoreService).touchExpiryBatch(anyList(), any(Duration.class));
verify(ziniaoMemoryStoreService, never()).put(any(), any(), any(), any());
}
@Test
void activeEntryMissingFromThisRoundGetsRewrittenAsStale() throws Exception {
ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct");
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key));
when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L);
when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L)));
when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L)).thenReturn(List.of());
ZiniaoShopIndexEntryDto dto = new ZiniaoShopIndexEntryDto();
dto.setNormalizedShopName("shop-a");
dto.setStatus("ACTIVE");
dto.setLastRefreshedAt(1000L);
String payload = new ObjectMapper().writeValueAsString(dto);
ZiniaoMemoryStoreEntity row = new ZiniaoMemoryStoreEntity();
row.setId(1L);
row.setCacheType("SHOP_INDEX_ENTRY");
row.setCacheKey("n:shop-a");
row.setCompanyName("acct");
row.setPayloadJson(payload);
row.setExpiresAt(LocalDateTime.now().plusHours(12));
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)).thenReturn(List.of(row));
service.refreshAllShopIndex();
ArgumentCaptor<List<ZiniaoMemoryStoreEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(ziniaoMemoryStoreService).updateStaleMarks(captor.capture());
assertEquals(1, captor.getValue().size());
ZiniaoMemoryStoreEntity updated = captor.getValue().get(0);
assertEquals(1L, updated.getId());
assertTrue(updated.getPayloadJson().contains("\"STALE\""));
assertEquals("店铺索引已过期,请等待后台刷新",
new ObjectMapper().readTree(updated.getPayloadJson()).get("message").asText());
verify(ziniaoMemoryStoreService, never()).touchExpiryBatch(anyList(), any(Duration.class));
}
@Test
void activeKeysFromThisRoundAreSkippedInStaleMarking() throws Exception {
ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct");
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key));
when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L);
when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L)));
when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L)).thenReturn(List.of(shop("s-1", "shop-1")));
ZiniaoShopIndexEntryDto activeDto = new ZiniaoShopIndexEntryDto();
activeDto.setNormalizedShopName("shop-1");
activeDto.setStatus("ACTIVE");
ZiniaoMemoryStoreEntity activeRow = new ZiniaoMemoryStoreEntity();
activeRow.setId(1L);
activeRow.setCacheType("SHOP_INDEX_ENTRY");
activeRow.setCacheKey("s:s-1");
activeRow.setPayloadJson(new ObjectMapper().writeValueAsString(activeDto));
activeRow.setExpiresAt(LocalDateTime.now().plusHours(12));
ZiniaoShopIndexEntryDto staleDto = new ZiniaoShopIndexEntryDto();
staleDto.setNormalizedShopName("shop-2");
staleDto.setStatus("ACTIVE");
ZiniaoMemoryStoreEntity staleRow = new ZiniaoMemoryStoreEntity();
staleRow.setId(2L);
staleRow.setCacheType("SHOP_INDEX_ENTRY");
staleRow.setCacheKey("n:shop-2");
staleRow.setPayloadJson(new ObjectMapper().writeValueAsString(staleDto));
staleRow.setExpiresAt(LocalDateTime.now().plusHours(12));
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000))
.thenReturn(List.of(activeRow, staleRow));
service.refreshAllShopIndex();
ArgumentCaptor<List<ZiniaoMemoryStoreEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(ziniaoMemoryStoreService).updateStaleMarks(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals("n:shop-2", captor.getValue().get(0).getCacheKey());
}
private ZiniaoShopIndexRefreshCursorDto capturedCursor() {
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(ziniaoTransientCacheService, times(2)).put(