T1.5: 台账异步落库(ArrayBlockingQueue(10000) offer 不阻塞;满200/500ms 单线程批量 saveAll+按日 upsert;队满原子计数+ERROR 不抛;关停 flush 余量;hibernate batch_size=200)

This commit is contained in:
chenxing
2026-09-21 09:29:48 +08:00
parent 26f5787000
commit 28b9bfca3c
4 changed files with 386 additions and 0 deletions
@@ -0,0 +1,267 @@
package com.yunhai.platform.gateway.log;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import com.yunhai.platform.gateway.filter.SanitizeFilter;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 台账异步落库(T1.5,有界队列 + 背压):全量请求观测(GET/401 也记),
* 台账故障绝不拖垮请求线程——台账是观测设施,不能反杀业务。
* <ul>
* <li>{@link ArrayBlockingQueue} 容量 {@value #QUEUE_CAPACITY},{@link #record} 只 offer 不阻塞;</li>
* <li>单刷盘线程:满 {@value #BATCH_SIZE} 条或 {@value #FLUSH_INTERVAL_MS}ms 触发一批
* JPA 批量插入 {@code open_api_log},成功后按日 upsert {@code open_api_stat_daily};</li>
* <li>offer 失败(队列满)→ 原子计数 +1 + ERROR 日志(含累计丢弃数),绝不抛异常;</li>
* <li>关停钩子:应用停止时 flush 余量(尽力而为)。</li>
* </ul>
* 日统计维度 caller:台账 caller 为 NULL(401 无 appKey)时归入 {@value #STAT_CALLER_UNKNOWN}
* (stat 表该列 NOT NULL,观测聚合需要一个兜底桶)。
*/
@Component
public class AccessLogService {
public static final int QUEUE_CAPACITY = 10_000;
public static final int BATCH_SIZE = 200;
public static final long FLUSH_INTERVAL_MS = 500L;
public static final String STAT_CALLER_UNKNOWN = "unknown";
/** ANON 判定下台账 caller 口径 */
public static final String CALLER_ANONYMOUS = "anonymous";
private static final Logger log = LoggerFactory.getLogger(AccessLogService.class);
private final ArrayBlockingQueue<OpenApiLog> queue;
private final AtomicLong dropped = new AtomicLong();
private final AtomicBoolean flushPending = new AtomicBoolean();
@Autowired
private OpenApiLogRepository logRepository;
@Autowired
private OpenApiStatDailyRepository statRepository;
/** 刷盘任务派发器:生产 = 单刷盘线程;测试注入同线程执行器作同步触发点 */
private Executor flushDispatcher;
private ScheduledExecutorService scheduler;
public AccessLogService() {
this(QUEUE_CAPACITY);
}
/** 测试构造:缩小容量以复现背压(生产路径固定 QUEUE_CAPACITY) */
AccessLogService(int capacity) {
this.queue = new ArrayBlockingQueue<>(capacity);
}
void setFlushDispatcher(Executor flushDispatcher) {
this.flushDispatcher = flushDispatcher;
}
int queueSize() {
return queue.size();
}
long droppedCount() {
return dropped.get();
}
@PostConstruct
void start() {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "gw-access-log-flush");
t.setDaemon(true);
return t;
});
if (flushDispatcher == null) {
flushDispatcher = scheduler;
}
scheduler.scheduleWithFixedDelay(() -> {
try {
flushOnce();
} catch (RuntimeException e) {
log.error("[AccessLog] scheduled flush failed", e);
}
}, FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stop() {
if (scheduler != null) {
scheduler.shutdown();
try {
scheduler.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 关停钩子:flush 余量(尽力而为)
try {
while (flushOnce() > 0) {
// 排空
}
} catch (RuntimeException e) {
log.error("[AccessLog] shutdown flush failed", e);
}
}
/**
* 请求线程入口:只入队,绝不同步写库、绝不阻塞。
*/
public void record(OpenApiLog entry) {
if (entry.getTs() == null) {
entry.setTs(LocalDateTime.now());
}
if (!queue.offer(entry)) {
long total = dropped.incrementAndGet();
log.error("[AccessLog] queue FULL, entry dropped. accumulatedDropped={}", total);
return;
}
if (queue.size() >= BATCH_SIZE) {
scheduleFlush();
}
}
/**
* 按卡口径从请求组装并记录一条台账(T1.6 链上过滤器消费):
* path = 剥段后路径;caller = ANON 记 anonymous、PASS 记验签 appKey、
* 否则尽力取 {@code X-App-Key} 头(401 无 appKey 记 NULL);status = 网关出口状态。
*
* @param backendCode 提供方业务 code(响应为 JSON 信封时尽力解析,否则 null)
*/
public void recordRequest(HttpServletRequest req, int status, Integer backendCode, int costMs) {
OpenApiLog entry = new OpenApiLog();
entry.setTs(LocalDateTime.now());
entry.setTargetCode(truncate(targetOf(req), 32));
entry.setCallerAppKey(truncate(callerOf(req), 64));
entry.setMethod(req.getMethod());
Object stripped = req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH);
entry.setPath(truncate(stripped instanceof String ? (String) stripped : req.getRequestURI(), 256));
entry.setStatus(status);
entry.setBackendCode(backendCode);
entry.setCostMs(costMs);
record(entry);
}
private void scheduleFlush() {
Executor dispatcher = flushDispatcher;
if (dispatcher == null) {
return; // 未初始化(单元/异常场景):500ms 周期兜底,不阻塞请求线程
}
if (flushPending.compareAndSet(false, true)) {
dispatcher.execute(() -> {
flushPending.set(false);
try {
flushOnce();
} catch (RuntimeException e) {
log.error("[AccessLog] batch flush failed", e);
}
});
}
}
/** 同步触发点(满批/周期/关停共用):排空至多一批 → 批量插入 → 按日 upsert 统计 */
synchronized int flushOnce() {
List<OpenApiLog> batch = new ArrayList<>(BATCH_SIZE);
queue.drainTo(batch, BATCH_SIZE);
if (batch.isEmpty()) {
return 0;
}
logRepository.saveAll(batch);
upsertDaily(batch);
return batch.size();
}
private void upsertDaily(List<OpenApiLog> batch) {
Map<String, OpenApiStatDaily> grouped = new LinkedHashMap<>();
for (OpenApiLog e : batch) {
String caller = e.getCallerAppKey() == null ? STAT_CALLER_UNKNOWN : e.getCallerAppKey();
String key = e.getTs().toLocalDate() + "|" + e.getTargetCode() + "|" + caller;
OpenApiStatDaily s = grouped.get(key);
if (s == null) {
s = new OpenApiStatDaily();
s.setStatDate(e.getTs().toLocalDate());
s.setTargetCode(e.getTargetCode());
s.setCallerAppKey(caller);
grouped.put(key, s);
}
s.setTotal(s.getTotal() + 1);
if (isErr(e)) {
s.setErrTotal(s.getErrTotal() + 1);
}
s.setAvgCostMs((int) ((long) s.getAvgCostMs() * (s.getTotal() - 1) + e.getCostMs()) / s.getTotal());
}
for (OpenApiStatDaily fresh : grouped.values()) {
OpenApiStatDaily existing = statRepository.findFirstByStatDateAndTargetCodeAndCallerAppKey(
fresh.getStatDate(), fresh.getTargetCode(), fresh.getCallerAppKey());
if (existing != null) {
int total = existing.getTotal() + fresh.getTotal();
existing.setErrTotal(existing.getErrTotal() + fresh.getErrTotal());
existing.setAvgCostMs((int) (((long) existing.getAvgCostMs() * existing.getTotal()
+ (long) fresh.getAvgCostMs() * fresh.getTotal()) / total));
existing.setTotal(total);
statRepository.save(existing);
} else {
statRepository.save(fresh);
}
}
}
private static boolean isErr(OpenApiLog e) {
return e.getStatus() >= 400 || (e.getBackendCode() != null && e.getBackendCode() != 0);
}
/** target = 规整化 URI 首段;取不到记 unknown(列 NOT NULL,台账一条都不能丢在写入端) */
private static String targetOf(HttpServletRequest req) {
Object uri = req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI);
String path = uri instanceof String ? (String) uri : req.getRequestURI();
if (path == null || path.length() < 2) {
return STAT_CALLER_UNKNOWN;
}
int second = path.indexOf('/', 1);
return second < 0 ? path.substring(1) : path.substring(1, second);
}
private static String callerOf(HttpServletRequest req) {
Object verdict = req.getAttribute(SignVerifyFilter.ATTR_VERDICT);
if (SignVerifyFilter.VERDICT_ANON.equals(verdict)) {
return CALLER_ANONYMOUS;
}
if (SignVerifyFilter.VERDICT_PASS.equals(verdict)) {
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
if (appKey instanceof String && !((String) appKey).trim().isEmpty()) {
return (String) appKey;
}
}
String header = req.getHeader("X-App-Key");
return header == null || header.trim().isEmpty() ? null : header.trim(); // 401 无 appKey = NULL
}
private static String truncate(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -3,5 +3,11 @@ package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
public interface OpenApiStatDailyRepository extends JpaRepository<OpenApiStatDaily, Long> {
/** uk_stat 维度定位(T1.5 按日 upsert):stat_date + target_code + caller_app_key */
OpenApiStatDaily findFirstByStatDateAndTargetCodeAndCallerAppKey(
LocalDate statDate, String targetCode, String callerAppKey);
}
+6
View File
@@ -15,6 +15,12 @@ spring:
hibernate:
# 表结构由 docs/sql/01_schema.sql 交付,应用不自动建表
ddl-auto: none
properties:
hibernate:
# 台账批量刷盘(T1.5):一批 JPA batch insert
jdbc:
batch_size: 200
order_inserts: true
redis:
# 密钥链(sign:app:{appKey})走共享 Redis db0——由平台配置层注入
host: #
@@ -0,0 +1,107 @@
package com.yunhai.platform.gateway.log;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* AccessLogService 三用例(T1.5 TDD):offer 入队成功路径(请求线程零写库);
* 满 200 条触发批量 flush(同步触发点测,不起真线程等待——注入同线程 dispatcher,
* 一批 JPA saveAll + 按日 upsert stat_daily);队列满 offer 失败 → 丢弃计数 +1 且绝不抛异常。
*/
class AccessLogServiceTest {
private AccessLogService service(int capacity, OpenApiLogRepository logRepo,
OpenApiStatDailyRepository statRepo) {
AccessLogService svc = new AccessLogService(capacity);
ReflectionTestUtils.setField(svc, "logRepository", logRepo);
ReflectionTestUtils.setField(svc, "statRepository", statRepo);
svc.setFlushDispatcher(Runnable::run); // 同步触发点:不真起线程
return svc;
}
private OpenApiLog entry(String target, String caller, int status) {
OpenApiLog e = new OpenApiLog();
e.setTs(LocalDateTime.now());
e.setTargetCode(target);
e.setCallerAppKey(caller);
e.setMethod("GET");
e.setPath("/oapi/x");
e.setStatus(status);
e.setCostMs(10);
return e;
}
@Test
void offer入队成功且请求线程零写库() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(AccessLogService.QUEUE_CAPACITY, logRepo, statRepo);
svc.record(entry("usermsg", "100617", 200));
assertThat(svc.queueSize()).as("入队 1 条").isEqualTo(1);
assertThat(svc.droppedCount()).isZero();
verifyNoInteractions(logRepo, statRepo); // 红线:不得在请求线程直接写库
}
@Test
void 满200条触发批量flush并upsert日统计() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(AccessLogService.QUEUE_CAPACITY, logRepo, statRepo);
for (int i = 0; i < AccessLogService.BATCH_SIZE; i++) {
svc.record(entry("usermsg", "100617", i == 199 ? 500 : 200)); // 末条 HTTP≥400 计 err
}
ArgumentCaptor<List<OpenApiLog>> batchCap = ArgumentCaptor.forClass(List.class);
verify(logRepo).saveAll(batchCap.capture());
assertThat(batchCap.getValue()).as("一批 200 条批量插入").hasSize(200);
assertThat(svc.queueSize()).as("flush 后队列清空").isZero();
ArgumentCaptor<OpenApiStatDaily> statCap = ArgumentCaptor.forClass(OpenApiStatDaily.class);
verify(statRepo).save(statCap.capture());
OpenApiStatDaily stat = statCap.getValue();
assertThat(stat.getTargetCode()).isEqualTo("usermsg");
assertThat(stat.getCallerAppKey()).isEqualTo("100617");
assertThat(stat.getTotal()).isEqualTo(200);
assertThat(stat.getErrTotal()).as("status≥400 计入 err").isEqualTo(1);
assertThat(stat.getAvgCostMs()).isEqualTo(10);
}
@Test
void 队列满丢弃计数加一且不抛异常() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(2, logRepo, statRepo); // 测试容量:小队列复现背压
svc.record(entry("usermsg", "a", 200));
svc.record(entry("usermsg", "a", 200));
assertThat(svc.queueSize()).isEqualTo(2);
assertThatCode(() -> {
svc.record(entry("usermsg", "a", 200)); // 第 3 条 = 队列满
svc.record(entry("usermsg", "a", 200));
}).as("绝不阻塞/抛出,反杀业务不可接受").doesNotThrowAnyException();
assertThat(svc.droppedCount()).as("累计丢弃数").isEqualTo(2);
assertThat(svc.queueSize()).isEqualTo(2);
verify(logRepo, never()).saveAll(any());
}
}