ARCHITECTURE-RULES.md 11 KB

祈盟SDK 架构硬性规则

适用对象:所有 AI 生成的本项目代码 违反这些规则的 PR 一律不通过 最后更新:2026-06-30


R1:批量查询规则

必须遵守

  • 任何接口接收批量入参时,必须在接口入口校验 count(params) <= 2000
  • 超过 2000 条的请求返回 400 BatchSizeExceeded
  • 不允许"为了业务方便"放宽这个限制
  • 不允许用 OR 条件拼 > 1000 条的 SQL(必须用 IN 分批 或 临时表 JOIN)

推荐实现(Java)

@PostMapping("/api/v1/sdk/query")
public Result query(@RequestBody QueryRequest request) {
    if (request.getUserIds() == null || request.getUserIds().isEmpty()) {
        return Result.fail("INVALID_PARAMS", "参数不能为空");
    }
    if (request.getUserIds().size() > 2000) {
        return Result.fail("BATCH_SIZE_EXCEEDED", "单次查询最多2000条");
    }
    // 去重
    List<String> dedupIds = request.getUserIds().stream()
        .distinct()
        .collect(Collectors.toList());
    return userDao.findByIds(dedupIds);
}

错误示范(5/26 事故真实代码)

// 错误:直接拼 OR 条件,2w 条条件打 MySQL
String sql = "SELECT * FROM user WHERE " +
    IntStream.range(0, userIds.size())
        .mapToObj(i -> "(user_id=" + userIds.get(i) + 
                       " AND game_id='" + gameId + "')")
        .collect(Collectors.joining(" OR "));
// 结果:MySQL CPU 90%+ 持续 8 分钟

正确实现(IN 分批)

List<List<String>> batches = Lists.partition(userIds, 1000);
List<User> result = new ArrayList<>();
for (List<String> batch : batches) {
    result.addAll(userDao.findByIds(batch));
}
return result;

正确实现(临时表 JOIN,性能远优于 2w 个 OR)

-- Step 1: 把查询条件写入临时表
CREATE TEMPORARY TABLE tmp_query (
    user_id VARCHAR(64),
    game_id VARCHAR(32),
    INDEX idx_user_game (user_id, game_id)
);

-- Step 2: 批量插入
INSERT INTO tmp_query VALUES (?, ?), (?, ?), ...;

-- Step 3: JOIN 查询(MySQL 优化器能走索引)
SELECT u.* FROM user u
JOIN tmp_query t ON u.user_id = t.user_id AND u.game_id = t.game_id;

-- Step 4: 清理
DROP TEMPORARY TABLE tmp_query;

R2:重试规则

必须遵守

  • 任何重试必须使用指数退避:30s -> 1min -> 2min -> 4min -> 5min(封顶)
  • 超过 5 次后熔断,返回缓存值或默认值
  • 不允许"快速连续重试"
  • 重试触发前必须判断"失败原因"(瞬时故障 vs 持续故障)

推荐实现(Resilience4j)

RetryConfig config = RetryConfig.custom()
    .maxAttempts(5)
    .intervalFunction(IntervalFunction.ofExponentialBackoff(
        30000L,    // 初始 30s
        2.0,       // 每次翻倍
        300000L))  // 封顶 5min
    .retryOnException(e -> e instanceof TransientException)
    .build();

Retry retry = Retry.of("sdkFetch", config);
CheckedSupplier<List<Data>> supplier = Retry.decorateCheckedSupplier(
    retry,
    () -> fetchData()
);

推荐实现(Python 自研)

import time
import random

def retry_with_backoff(func, max_attempts=5):
    delays = [30, 60, 120, 240, 300]  # 秒
    for attempt in range(max_attempts):
        try:
            return func()
        except TransientException as e:
            if attempt == max_attempts - 1:
                # 最后一次失败,熔断:返回缓存或默认值
                return get_cached_or_default()
            delay = delays[attempt] + random.uniform(0, 5)
            logging.warning(f"重试 {attempt+1}/{max_attempts},"
                          f"等待 {delay}s,原因:{e}")
            time.sleep(delay)
        except PermanentException:
            # 持续故障(DB挂/参数错),不重试
            raise

错误示范(5/26 事故真实代码)

# 错误:无退避连续重试,把已死的 DB 反复打死
for i in range(5):
    try:
        fetch_data()
        break
    except Exception as e:
        log.error(f"retry {i}")
# 5 次重试全部打在已经扛不住的 DB 上

R3:缓存时长规则

必须遵守

  • 客户端/脚本缓存时长 必须 小于 服务端会话有效期
  • 任何缓存必须有失效校验逻辑(每次使用前检查是否过期)
  • 不允许"假设缓存永远有效"
  • 推荐预留 10%-20% 的安全余量

推荐实现(Python 抓取任务)

# 每次执行前先校验 Cookie 有效性
def fetch_orders():
    if not is_cookie_valid():
        logging.info("Cookie 已失效,重新登录")
        re_login()
    return do_fetch()

错误示范(6/16 事故真实配置)

# 错误:客户端缓存 > 服务端会话
SDK_SESSION_HOURS = 14       # 服务端 PHPSESSID 有效期
SCRIPT_CACHE_HOURS = 24      # 脚本缓存时长,比服务端还长
# 结果:第 14h 静默中断,无任何报错

正确配置

SDK_SESSION_HOURS = 14
SCRIPT_CACHE_HOURS = SDK_SESSION_HOURS - 2  # 预留 2h 余量 = 12h
SCRIPT_CACHE_HOURS = 12

R4:接口限流规则

必须遵守

  • 所有对外接口必须配置 QPS 限流(推荐 Nginx/网关层)
  • 默认限流值:1000 QPS(按接口可调)
  • 超过限流返回 429 Too Many Requests
  • 限流触发时必须有降级方案(返回缓存值/默认值/友好提示)

推荐配置(Nginx)

limit_req_zone $binary_remote_addr zone=sdk_api:10m rate=1000r/s;

server {
    location /api/v1/sdk/ {
        limit_req zone=sdk_api burst=2000 nodelay;
        limit_req_status 429;
        proxy_pass http://sdk_backend;
    }
}

推荐配置(Spring Boot 限流)

// 使用 Resilience4j RateLimiter
RateLimiterConfig config = RateLimiterConfig.custom()
    .limitForPeriod(1000)
    .limitRefreshPeriod(Duration.ofSeconds(1))
    .timeoutDuration(Duration.ofMillis(500))
    .build();

RateLimiter rateLimiter = RateLimiter.of("sdkApi", config);

R5:金额存储规则

必须遵守

  • 金额字段必须使用 DECIMAL(15,2)BIGINT(以分为单位)
  • 禁止使用 DOUBLEFLOAT 存储金额
  • 所有金额运算在数据库层完成,不要在应用层用 double 计算

推荐 DDL

-- 方案 A: DECIMAL(适合报表展示)
amount DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '金额(元)'

-- 方案 B: BIGINT(适合高频计算,避免小数运算)
amount_cents BIGINT NOT NULL DEFAULT 0 COMMENT '金额(分)'

错误 DDL(SDK 长期隐患)

-- 错误:DOUBLE 精度溢出
amount DOUBLE(11,2)
-- 0.1 + 0.2 在 double 下不是 0.3
-- 累计计算后误差放大

推荐应用层写法

// 错误:double 累加
double total = 0.0;
for (Order order : orders) {
    total += order.getAmount();  // 精度累积误差
}

// 正确:用 BigDecimal
BigDecimal total = BigDecimal.ZERO;
for (Order order : orders) {
    total = total.add(order.getAmount());
}

R6:实时统计规则

必须遵守

  • 直播平台读的"统计类"数据,必须从预聚合表读,不读明细表
  • 预聚合表由凌晨任务(02:00-05:00)更新
  • 实时数据(如"最近 30 分钟活跃")才允许读明细表
  • 不允许"为了实时性"让统计接口查明细表

错误的实时统计(v4.24 之前的设计)

-- 错误:每次查都全表扫描
SELECT COUNT(DISTINCT user_id) FROM active_log
WHERE active_time >= NOW() - INTERVAL 30 MINUTE
-- 直播平台每秒调一次 → DB 爆炸

正确的预聚合表(v4.25 设计)

-- 正确:读预聚合表,加 Redis 缓存
SELECT active_count FROM stat_account_summary
WHERE stat_date = ? AND game_id = ? AND promoter_id = ?

-- 配合 Redis 缓存(5 分钟 TTL)
String cacheKey = "stat:" + date + ":" + gameId + ":" + promoterId;

R7:跨部门信息同步规则

必须遵守

  • 任何"接口模式 vs 抓取模式"的切换必须同步通知产品部和发行部
  • 任何"数据源变更"必须同步通知产品部和发行部
  • 不允许"群里通知一下就完事"
  • 通知必须包含:变更原因、变更时间、影响范围、回滚方案、负责人

通知模板

【变更通知】

变更原因:[例如:5/26 抓取任务异常,临时切换为接口模式]
变更时间:[例如:2026-05-26 20:30]
变更内容:[例如:订单数据从爬虫抓取切换为 SDK 接口读取]
影响范围:[例如:订单数据延迟从 1h 变为 5min]
回滚方案:[例如:执行 rollback.sh,恢复爬虫模式]
负责人:[姓名 + 联系方式]
监控验证:[例如:72h 内订单数据完整性 100%]

抄送:技术部 / 产品部 / 发行部 / 客服部

R8:监控告警规则

必须遵守

  • 所有新接口上线 = 必须有监控(不是"出事后补")
  • 所有新数据源接入 = 必须有告警
  • 所有批量任务 = 必须有完成/失败告警
  • 所有外部依赖(DB/Redis/第三方接口)= 必须有可用性监控

推荐监控指标

指标 阈值 告警级别
接口 QPS > 1000 P2
接口错误率 > 1% P1
接口 P99 延迟 > 3s P1
MySQL CPU > 70% P1
MySQL 慢查询 > 100 条/min P1
Redis 带宽 > 800Mbps P1
抓取任务执行时长 > 1h P2
抓取任务连续失败 > 3 次 P0

R9:幂等性规则

必须遵守

  • 所有写操作必须支持幂等(重复执行不产生副作用)
  • 必须使用业务唯一键做幂等控制
  • 不允许"假设上游只会调一次"

推荐实现

-- 唯一索引 + INSERT IGNORE
CREATE UNIQUE INDEX uk_order_no ON orders (order_no);

INSERT IGNORE INTO orders (order_no, amount, ...) VALUES (?, ?, ...);
// 或 ON DUPLICATE KEY UPDATE
String sql = "INSERT INTO player_active_record (...) " +
             "VALUES (...) " +
             "ON DUPLICATE KEY UPDATE active_time = NOW()";

R10:日志规则

必须遵守

  • 所有关键操作必须有日志(登录、抓取、接口调用、异常)
  • 日志必须包含:时间、用户、接口、参数摘要、结果、耗时
  • 异常日志必须包含完整堆栈
  • 不允许"只 log.info 一行"就算日志
  • 不允许在生产环境删日志代码("方便后续快速排查")

推荐日志格式

log.info("SDK接口调用|userId={}|gameId={}|action={}|result={}|cost={}ms",
    userId, gameId, action, result, cost);

附录:规则速查表

规则 核心约束 历史事故
R1 批量查询 单次 <= 2000,禁止 OR 拼 SQL 5/12、5/26
R2 重试 指数退避 30s 起步,5 次熔断 5/26
R3 缓存时长 客户端 < 服务端,每次校验 6/16
R4 接口限流 QPS <= 1000,超限 429 5/12、5/26
R5 金额存储 DECIMAL(15,2) 或 BIGINT,禁用 DOUBLE 长期隐患
R6 实时统计 走预聚合表,不查明细 v4.24 之前
R7 跨部门通知 正式通知,不群里说 6/16
R8 监控告警 上线即有,不出事后补 5/12、5/26
R9 幂等性 写操作必须幂等 -
R10 日志 关键操作全打日志 6/16