祈盟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(以分为单位)
- 禁止使用
DOUBLE 或 FLOAT 存储金额
- 所有金额运算在数据库层完成,不要在应用层用
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 |