# 祈盟SDK 架构硬性规则 > 适用对象:所有 AI 生成的本项目代码 > 违反这些规则的 PR 一律不通过 > 最后更新:2026-06-30 --- ## R1:批量查询规则 ### 必须遵守 - 任何接口接收批量入参时,必须在接口入口校验 `count(params) <= 2000` - 超过 2000 条的请求返回 `400 BatchSizeExceeded` - 不允许"为了业务方便"放宽这个限制 - 不允许用 OR 条件拼 > 1000 条的 SQL(必须用 IN 分批 或 临时表 JOIN) ### 推荐实现(Java) ```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 dedupIds = request.getUserIds().stream() .distinct() .collect(Collectors.toList()); return userDao.findByIds(dedupIds); } ``` ### 错误示范(5/26 事故真实代码) ```java // 错误:直接拼 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 分批) ```java List> batches = Lists.partition(userIds, 1000); List result = new ArrayList<>(); for (List batch : batches) { result.addAll(userDao.findByIds(batch)); } return result; ``` ### 正确实现(临时表 JOIN,性能远优于 2w 个 OR) ```sql -- 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) ```java 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> supplier = Retry.decorateCheckedSupplier( retry, () -> fetchData() ); ``` ### 推荐实现(Python 自研) ```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 事故真实代码) ```python # 错误:无退避连续重试,把已死的 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 抓取任务) ```python # 每次执行前先校验 Cookie 有效性 def fetch_orders(): if not is_cookie_valid(): logging.info("Cookie 已失效,重新登录") re_login() return do_fetch() ``` ### 错误示范(6/16 事故真实配置) ```python # 错误:客户端缓存 > 服务端会话 SDK_SESSION_HOURS = 14 # 服务端 PHPSESSID 有效期 SCRIPT_CACHE_HOURS = 24 # 脚本缓存时长,比服务端还长 # 结果:第 14h 静默中断,无任何报错 ``` ### 正确配置 ```python 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) ```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 限流) ```java // 使用 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`(以分为单位) - 禁止使用 `DOUBLE` 或 `FLOAT` 存储金额 - 所有金额运算在数据库层完成,不要在应用层用 `double` 计算 ### 推荐 DDL ```sql -- 方案 A: DECIMAL(适合报表展示) amount DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '金额(元)' -- 方案 B: BIGINT(适合高频计算,避免小数运算) amount_cents BIGINT NOT NULL DEFAULT 0 COMMENT '金额(分)' ``` ### 错误 DDL(SDK 长期隐患) ```sql -- 错误:DOUBLE 精度溢出 amount DOUBLE(11,2) -- 0.1 + 0.2 在 double 下不是 0.3 -- 累计计算后误差放大 ``` ### 推荐应用层写法 ```java // 错误: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 之前的设计) ```sql -- 错误:每次查都全表扫描 SELECT COUNT(DISTINCT user_id) FROM active_log WHERE active_time >= NOW() - INTERVAL 30 MINUTE -- 直播平台每秒调一次 → DB 爆炸 ``` ### 正确的预聚合表(v4.25 设计) ```sql -- 正确:读预聚合表,加 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:幂等性规则 ### 必须遵守 - 所有写操作必须支持幂等(重复执行不产生副作用) - 必须使用业务唯一键做幂等控制 - 不允许"假设上游只会调一次" ### 推荐实现 ```sql -- 唯一索引 + INSERT IGNORE CREATE UNIQUE INDEX uk_order_no ON orders (order_no); INSERT IGNORE INTO orders (order_no, amount, ...) VALUES (?, ?, ...); ``` ```java // 或 ON DUPLICATE KEY UPDATE String sql = "INSERT INTO player_active_record (...) " + "VALUES (...) " + "ON DUPLICATE KEY UPDATE active_time = NOW()"; ``` --- ## R10:日志规则 ### 必须遵守 - 所有关键操作必须有日志(登录、抓取、接口调用、异常) - 日志必须包含:时间、用户、接口、参数摘要、结果、耗时 - 异常日志必须包含完整堆栈 - 不允许"只 log.info 一行"就算日志 - 不允许在生产环境删日志代码("方便后续快速排查") ### 推荐日志格式 ```java 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 |