设计一套生产级 API 接口:从契约、安全、幂等到限流,我踩过的坑都在这了

0 阅读25分钟

上周帮朋友做模拟面试,被问到一道老牌场景题:"如果让你从零设计一套对外开放的 API 接口,你会怎么做?"

这题几乎是大厂后端面试的必考题,但大部分人答不好——要么上来就堆 @PostMapping,要么把 RESTful 八股背一遍就完事。我在实际项目里从零搭过两套开放平台,也救过不少"接口裸奔"的火,这篇文章就把完整的答案拆开揉碎讲清楚:从设计原则、契约规范,到安全、幂等、限流、可观测,每个点都配上生产可用的代码。

无论你是准备面试,还是真的要给团队搭一套 API,这篇都能直接用。

一、先别急着写代码:动手前的四件事

实际开发中我们经常遇到一种情况:需求一来,打开 IDEA 就是干。结果接口写完,前端说字段不对,安全说没鉴权,运维说没法监控——返工三遍。

所以第一件事是澄清场景,我会先问四个问题:

  1. 谁调用? 内部服务、前端,还是第三方开放平台?这直接决定安全等级。
  2. 并发量级? QPS 是 100 还是 10 万?决定要不要网关限流、缓存、异步。
  3. 一致性要求? 能不能容忍重复下单、少扣一次库存?决定幂等做到什么程度。
  4. 迭代节奏? 接口会不会频繁变更?决定版本策略和兼容方案。

这四点没问清楚就动手,大概率返工。面试里也是一样——先澄清是加分项,上来就写 Controller 是减分项。

基于这四问,我给自己定了五条设计铁律,后面所有方案都围绕它们展开:

  • 语义清晰:见名知意,符合 RESTful 规范,最大化降低对接成本;
  • 容错优先:默认外部调用不可靠,幂等、限流、降级全部前置设计;
  • 安全底线:鉴权、脱敏、防篡改、防重放,一层不能少;
  • 可观测可治理:接口上线第一天就要能监控、能排查、能管控;
  • 兼容迭代:支持平滑版本升级,绝不强制客户端同步改造。

二、一张图看清:一个请求要过几道关

API 设计不是写一个接口,而是覆盖「接入 → 业务 → 治理 → 运维」的全生命周期。一个请求从客户端打到数据库,要过这些关:

image.png

各层的职责划分:

架构层级核心职责关键技术点
接入层(网关)流量入口、统一管控HTTPS、路由转发、身份鉴权、全局限流、黑白名单、灰度
业务层(服务)业务逻辑、接口语义RESTful 规范、统一返回体、参数校验、幂等控制、异常处理
治理层稳定性保障限流熔断、降级兜底、链路追踪、指标监控
交付层对接与维护OpenAPI 文档、SDK、版本管理、错误码规范、变更通知

一句话原则:能放网关的别放业务,能在入口拦住的别打到数据库。

下面逐层拆解。

三、契约设计:接口"长什么样",先立规矩

契约是调用方看到的第一张脸,这一层决定接口"好不好用"。

3.1 RESTful 资源建模,外加一个务实例外

核心规则就几条:

  • 资源用名词复数,动作用 HTTP Method 表达:GET 查、POST 增、PUT 全量改、PATCH 局部改、DELETE 删;
  • 路径层级不超过 3 级,/users/{id}/orders 到头了;
  • 统一小写,多单词用短横线:/user-info 而不是 /userInfo;
  • 版本号放 URL 路径:/api/v1/xxx。

以订单为例:

动作方法URL幂等
创建订单POST/api/v1/orders否,靠 requestId 做幂等
查询订单GET/api/v1/orders/{id}是
分页查询GET/api/v1/orders?pageNo=1&pageSize=20是
取消订单POST/api/v1/orders/{id}/cancel是(状态机保证)
修改备注PATCH/api/v1/orders/{id}否

反面教材见过太多了:

# ❌ 动词进路径、命名风格混乱
POST /api/v1/getUserInfo
POST /api/v1/createOrder
GET  /api/v1/DeleteUser

💡 务实比教条重要:纯 RESTful 遇到复杂场景会很别扭。取消订单、支付这类"动作",直接用 /orders/{id}/cancel 反而更清晰;批量查询、导出这种条件复杂的场景,退化为 POST /api/v1/orders/batch-query 也完全可以——还能避免 GET 参数超长。

HTTP 方法的语义差异,面试也常考:

方法语义幂等安全典型状态码
GET查询资源✅✅200 / 404
POST创建资源❌❌201 / 409
PUT全量更新✅❌200 / 412
PATCH局部更新❌❌200
DELETE删除资源✅❌204 / 404

3.2 统一返回体:前端最感激你的设计

所有接口返回格式完全统一,调用方一套解析逻辑通吃:

{
  "code": 0,
  "message": "success",
  "data": { "orderId": "1001", "status": "CREATED" },
  "traceId": "a1b2c3d4",
  "timestamp": 1717888888888
}

Java 侧的实现,泛型 + 静态工厂方法,业务代码极简:

/**
 * 统一返回体
 * 亮点:code 是业务码(与 HTTP 状态码解耦),traceId 随响应返回,排障利器
 */
@Data
public class Result<T> {
    private int code;        // 业务状态码
    private String message;  // 面向人的提示
    private T data;          // 业务数据,失败为 null,禁止裸 null 歧义
    private long timestamp;  // 便于排查时序
    private String traceId;  // 🔥 全链路追踪 ID

    public static <T> Result<T> ok(T data) {
        return build(ErrorCode.SUCCESS, data);
    }

    public static <T> Result<T> fail(ErrorCode ec) {
        return build(ec, null);
    }

    private static <T> Result<T> build(ErrorCode ec, T data) {
        Result<T> r = new Result<>();
        r.code = ec.getCode();
        r.message = ec.getMsg();
        r.data = data;
        r.timestamp = System.currentTimeMillis();
        r.traceId = MDC.get("traceId");   // 从日志上下文取,与链路对齐
        return r;
    }
}

错误码要分段管理,别乱编号,看到码的第一段就知道问题出在哪一层:

区间含义示例
0成功SUCCESS
1xxxx参数/校验类10001 参数缺失、10002 重复请求
2xxxx权限/安全类20001 未登录、20002 无权限
3xxxx业务类30001 库存不足
4xxxx系统/第三方40001 系统繁忙、40002 RPC 超时

按模块分(2xxxx 用户、3xxxx 订单)或者用阿里风格的 A0400/B0500 前缀也都可行,团队统一就好,核心是业务码与 HTTP 状态码解耦、每个码文档化。

💡 返回码双层设计:HTTP 状态码表达传输层语义(200/401/429/500),方便网关和基础设施统一处理;body 里的 code 表达业务语义,方便调用方精确定位。

3.3 分页:游标分页,别踩深分页的坑

所有列表接口必须强制分页,上限 size <= 100,禁止无 limit 全表查询。但普通分页有个经典坑:

-- ❌ 深分页:limit 100000, 20,数据库要扫描并丢弃前 10 万行
-- ✅ 游标分页:用上次的最后一条 id 做游标
SELECT * FROM orders
WHERE user_id = ?
  AND id < ?        -- cursor:上一页最后一条的 id
ORDER BY id DESC
LIMIT 20;

游标分页没有 offset,配合索引就是一次范围扫描,性能天差地别。C 端无限滚动场景用它准没错。

3.4 参数校验:第一道业务防火墙

校验的原则三条:脏参数绝不允许穿透到 Service 打数据库。落地方式:

  • 参数不超过 6 个,超出就封装 DTO;
  • 用 @Valid + JSR-303 注解做声明式校验;
  • 校验失败统一走全局异常,返回明确错误码。
@Data
public class CreateOrderReq {
    @NotNull(message = "用户ID不能为空")
    private Long userId;

    @NotBlank(message = "商品编码不能为空")
    private String skuCode;

    @Min(value = 1, message = "购买数量至少为1")
    @Max(value = 999, message = "单次购买不得超过999")
    private Integer count;

    @NotBlank(message = "幂等令牌不能为空")
    private String requestId;   // 👈 幂等/防重的钥匙,后面详聊
}

分组校验也很实用:新增不校验 id,更新必须校验 id,一个 DTO 两种场景:

@PostMapping("/users")
public Result<Long> create(@RequestBody @Validated(CreateGroup.class) UserCreateReq req) { ... }

@PutMapping("/users/{id}")
public Result<Void> update(@PathVariable Long id,
        @RequestBody @Validated(UpdateGroup.class) UserUpdateReq req) { ... }

Controller 保持薄,业务逻辑放 Service:

@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {

    @Resource
    private OrderService orderService;

    @PostMapping
    @Idempotent(key = "#req.requestId", expire = 30)
    public Result<OrderVO> createOrder(
            @RequestHeader("X-Request-Id") String requestId,
            @Valid @RequestBody CreateOrderReq req) {
        return Result.ok(orderService.createOrder(req, requestId));
    }

    @GetMapping("/{id}")
    public Result<OrderVO> detail(@PathVariable Long id) {
        return Result.ok(orderService.detail(id));
    }
}

3.5 全局异常处理:千万别把堆栈甩给前端

所有异常收敛成同一种响应格式,而且要分层处理:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /** 参数校验异常:精准定位到字段,前端能直接标红 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        log.warn("参数校验失败 msg={}", msg);
        return Result.fail(ErrorCode.PARAM_ERROR.withMsg(msg));
    }

    /** 业务异常:可预期,WARN 级别,不打 error 堆栈污染告警 */
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBiz(BusinessException e) {
        log.warn("业务异常 traceId={} code={}", MDC.get("traceId"), e.getCode());
        return Result.fail(e.getErrorCode());
    }

    /** 兜底异常:堆栈只进日志,对外只给 traceId */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e, HttpServletRequest req) {
        String traceId = MDC.get("traceId");
        log.error("系统异常 uri={} traceId={}", req.getRequestURI(), traceId, e);
        return Result.fail(ErrorCode.SYSTEM_ERROR.withMsg("系统繁忙,请稍后重试[" + traceId + "]"));
    }
}

⚠️ 这条是安全红线:生产环境把异常堆栈返回给调用方,等于把系统架构、依赖版本拱手送给攻击者。堆栈只进日志,对外的永远是"系统繁忙 + traceId"。用户报障甩过来一个 ID,你直接查全链路日志,效率拉满。

能把参数异常 / 业务异常 / 系统异常分开处理的人,面试官一看就知道线上排过障。

四、安全设计:别只会说"加 HTTPS"

很多人以为 HTTPS 就够了。其实 HTTPS 只解决传输层窃听,应用层的参数篡改、身份伪造、重放攻击,它一个都管不了。记住一句话:

🔐 加密 ≠ 防篡改,签名 ≠ 保密。 加密只能防"看得到",签名才能防"改得了",防重放才能防"发两遍"。

我的方案是四道防线,层层递进:HTTPS 传输加密 → JWT/OAuth2 身份认证 → 参数签名防篡改 → 时间戳 + nonce 防重放。

4.1 签名 + 防重放的完整链路

给每个合作方发 AppKey + AppSecret,调用方把参数按字典序排序拼接,连同 timestamp、nonce、请求体摘要一起算 HMAC-SHA256 签名:

image.png

签名工具类核心代码:

public final class SignatureUtil {

    /** 1. 参数按字典序拼接:k1=v1&k2=v2(空值、sign 本身不参与) */
    public static String buildSignContent(Map<String, String> params) {
        return params.entrySet().stream()
                .filter(e -> StringUtils.hasText(e.getValue()) && !"sign".equals(e.getKey()))
                .sorted(Map.Entry.comparingByKey())
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("&"));
    }

    /** 2. HMAC-SHA256 签名(比 MD5 更抗长度扩展攻击) */
    public static String sign(String content, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return Hex.encodeHexString(mac.doFinal(content.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new BusinessException(ErrorCode.SIGN_ERROR);
        }
    }

    /** 3. 常量时间比较,防时序攻击 ⭐ */
    public static boolean safeEquals(String a, String b) {
        return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8),
                                     b.getBytes(StandardCharsets.UTF_8));
    }
}

服务端校验,顺序很重要:先身份 → 再时效 → 再防重 → 最后验签,出错时最容易定位是哪一步挂的:

public boolean verify(SignRequest req) {
    // ① appKey 是否合法
    String secret = appKeyRepo.getSecret(req.getAppKey());
    if (secret == null) throw new BusinessException(ErrorCode.APP_KEY_INVALID);

    // ② timestamp 是否在有效窗口内(±5 分钟),防过期重放
    long diff = Math.abs(System.currentTimeMillis() - req.getTimestamp());
    if (diff > 5 * 60 * 1000) throw new BusinessException(ErrorCode.REQUEST_EXPIRED);

    // ③ nonce 一次性:Redis SETNX 原子占位,同一个包只能用一次
    String nonceKey = "sign:nonce:" + req.getAppKey() + ":" + req.getNonce();
    Boolean first = redis.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(6));
    if (Boolean.FALSE.equals(first)) throw new BusinessException(ErrorCode.REPLAY_ATTACK);

    // ④ 服务端按同一规则重算签名并比对(签名材料包含 body 摘要,防请求体篡改)
    String raw = buildSignContent(req.getParams()) + req.getTimestamp() + req.getNonce()
            + DigestUtils.sha256Hex(req.getBody());
    String expect = SignatureUtil.sign(raw, secret);
    if (!SignatureUtil.safeEquals(expect, req.getSign())) {
        throw new BusinessException(ErrorCode.SIGN_MISMATCH);
    }
    return true;
}

几个容易忽略但含金量很高的细节:

⭐ 技术亮点一:MessageDigest.isEqual 做常量时间比较。普通 equals 是逐字符短路返回的,攻击者可以根据响应耗时逐字节"猜"签名——这叫时序攻击。常量时间比较堵死这条路。

⭐ 技术亮点二:nonce 必须落 Redis,只验签不验 nonce 等于没防重放。签名再对,人家把抓到的合法请求原样重发一万次,你照样崩。

⭐ 技术亮点三:大请求体别把整段文本拼进签名串,CPU 会飙。对 body 算个 SHA-256 摘要再参与签名即可。

⭐ 技术亮点四:nonce 的 SETNX 是原子操作;如果非要用 EXISTS + SETEX 两步走,记得包进 Lua 脚本,否则并发下两个相同 nonce 可能都通过判断——典型的 check-then-act 竞态。

4.2 其他必答的安全点

风险解决方案关键点
越权访问每次查询校验数据归属别只信前端传的 userId,从登录态取并校验 owner
SQL 注入MyBatis #{},禁止 ${}Code Review 卡点
敏感数据泄露手机号/身份证/银行卡脱敏返回和日志都要脱敏
恶意刷接口网关限流 + 验证码 + IP 黑名单区分正常用户限流和恶意攻击
密钥安全AppSecret 配置中心管理,定期轮换禁止硬编码、禁止固定密钥

脱敏别在业务代码里零散处理,容易漏。用 Jackson 自定义序列化器 + 注解,声明式搞定:

@Sensitive(SensitiveType.PHONE)   // 序列化时自动变成 138****5678
private String phone;

五、幂等设计:这道题拉开差距的地方

聊到这里,好用的部分讲完了,接下来是"崩不崩"的部分。幂等是重中之重,也是面试官最爱深挖的点。

先厘清一个高频误区:幂等 ≠ 并发控制。并发控制解决"同时改",幂等解决"改多次"。幂等的本质是——同一个请求执行 1 次和执行 100 次,系统状态完全一样。就像电梯按钮,你按 10 次也只来一部电梯。

重复请求从哪来?一定要答全:

  • 🙋 前端重复提交:按钮没置灰、用户狂点;
  • 🌐 网络超时重试:客户端没收到响应,自动重发;
  • 🚪 网关/RPC 自动重试:Nginx、Feign 超时重传;
  • 📨 MQ 重复消费:at-least-once 语义决定消息可能投递多次。

不做幂等的后果不用我多说:重复下单、重复扣款、重复发券——每一个都是 P0 事故。

5.1 五大方案怎么选

方案适用场景优点坑点
🥇 唯一索引创建类(下单、支付流水)最简单,数据库兜底最稳依赖业务唯一键(如 biz_no)
🥈 Token 预领取前端防重复提交提交前先拿 token,天然只生效一次需额外一次请求
🥉 状态机 + 乐观锁更新类(订单流转)带条件 UPDATE 天然幂等,最稳需严谨设计状态流转
Redis SETNX / Lua通用场景性能好,可做成注解零侵入要注意失败释放、原子性
分布式锁通用兜底灵活注意锁粒度、超时、可重入

实战里我的标准搭配是:Redis 前置拦截 + 数据库唯一索引兜底,双保险。Redis 挂了还有唯一索引兜着。

5.2 注解式幂等:AOP + Redis Lua(生产级实现)

自己手写每个接口的幂等逻辑太蠢,封装成注解,业务代码零侵入:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String key();                   // 幂等键,支持 SpEL:#req.requestId
    long expire() default 30;       // 令牌有效期(秒)
    String message() default "请求正在处理,请勿重复提交";
}

切面实现,用 Lua 脚本保证原子性,还区分了「处理中」和「已完成」两种状态:

@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private final StringRedisTemplate redis;

    // ✅ Lua 保证「判断 + 占位」原子执行,杜绝并发下的 check-then-act 漏洞
    private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>("""
        local v = redis.call('GET', KEYS[1])
        if v == 'PROC' then return -1 end          -- 处理中
        if v == 'DONE' then return -2 end          -- 已完成
        redis.call('SET', KEYS[1], 'PROC', 'EX', ARGV[1])
        return 1
        """, Long.class);

    @Around("@annotation(idem)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
        String key = "idem:" + SpelParser.parse(idem.key(), pjp);
        Long r = redis.execute(SCRIPT, List.of(key), String.valueOf(idem.expire()));

        if (r == -1) throw new BusinessException(ErrorCode.REPEAT_PROCESSING, idem.message());
        if (r == -2) return Result.ok(null);   // 🎯 幂等成功:直接返回,不重复执行

        try {
            Object ret = pjp.proceed();
            redis.opsForValue().set(key, "DONE", idem.expire(), TimeUnit.SECONDS);
            return ret;
        } catch (Throwable t) {
            redis.delete(key);   // ⚠️ 失败必须释放,否则接口永久堵死
            throw t;
        }
    }
}

用法一行注解:

@PostMapping("/pay")
@Idempotent(key = "#req.orderNo", expire = 60)
public Result<PayVO> pay(@Valid @RequestBody PayReq req) { ... }

⭐ 答出这三点就赢了:① 用 Lua 保证"判断 + 占位"的原子性;② 区分「处理中」和「已完成」两种状态;③ 异常必须删 key——这是我之前踩过的真坑,第一次请求失败后 key 还在,用户修正参数重试永远被拒绝,接口等于废了。

💡 更进一步的体验优化:成功后把响应结果整个缓存进 key 里,重复请求直接返回首次的结果。用户重复点击下单,拿到的是同一个订单号,而不是一句冷冰冰的"请勿重复提交"。

5.3 状态流转场景:状态机 + 乐观锁

更新类接口(订单支付、取消),最稳的方案是把"判断状态"和"更新"合并成一条带条件的 SQL:

-- 只有当前状态是"待支付"时才允许流转为"已支付"
UPDATE orders
SET status = 'PAID', update_time = NOW(), version = version + 1
WHERE id = #{id} AND status = 'UNPAID' AND version = #{version};

-- 影响行数为 0:说明状态已变更(重复请求或非法请求),直接返回,不重复发货

由数据库保证原子性,天然扛并发,比"先查后改"安全得多。

5.4 前端防重:Token 预领取

还有一个常被问到的变体——进入表单页时先领一个一次性 Token:

image.png

为什么用 DEL 而不是 GET + DELETE?一行 Lua 搞定原子判断加删除,连分布式锁都省了。用对数据结构,少写十行业务。

最后补一个概念辨析:防重提交是事前减少重复请求,幂等是事后保证结果一致,两者配合使用,不是二选一。

六、限流与降级:接口活着才是硬道理

公网 API 不限流,等于把家门钥匙挂在门口。

6.1 分布式限流:Redis + Lua 令牌桶

单机限流用 Guava RateLimiter 够了,集群环境必须上 Redis + Lua。令牌桶的好处是平滑限流且允许一定突发:

-- KEYS[1]=桶key  ARGV: rate(每秒生成)、capacity、now(ms)、requested
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or tonumber(ARGV[2])
local ts     = tonumber(b[2]) or ARGV[3]
local now    = tonumber(ARGV[3])
tokens = math.min(ARGV[2], tokens + (now - ts) / 1000 * ARGV[1])  -- 按时间差补充令牌
if tokens < tonumber(ARGV[4]) then
    return 0                                    -- ⛔ 令牌不足
end
redis.call('HMSET', KEYS[1], 'tokens', tokens - ARGV[4], 'ts', now)
redis.call('EXPIRE', KEYS[1], 60)
return 1

计算全部在 Redis 内完成,分布式、无误差。同样封装成 @RateLimit(qps = 100) 注解 + AOP,业务无感。

6.2 固定窗口的坑:临界突刺

面试如果被问"为什么不用固定窗口计数",画个图就赢了:

固定窗口缺陷(临界突刺):
        │← 窗口1 →│← 窗口2 →│
   100个│         │100个    │
   请求 │█ █ █ █ █│█ █ █ █ █│
────────┴─────────┴─────────┴──→ t
              ↑
        窗口交界处:1秒内可能放过 200 个请求!💥
滑动窗口:请求时间戳连续记录在 ZSet 中,任意1秒内统计值都不会超阈值 ✅

滑动窗口用 Redis ZSet 实现,Lua 一把梭:

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)   -- 踢出窗口外的旧记录
local count = redis.call('ZCARD', key)
if count < tonumber(ARGV[3]) then
    redis.call('ZADD', key, now, now .. '-' .. math.random())
    redis.call('PEXPIRE', key, window)
    return 1
end
return 0

6.3 限流维度与降级兜底

限流要分层:AppKey 总量 → 接口级 → 用户级 → IP 级,超限返回 HTTP 429 + Retry-After 头,调用方一看就知道多久后再试。达到 80% 阈值就告警,准备扩容。

降级和隔离也不能少:

  • 熔断降级:内部服务慢了,Sentinel/Resilience4j 熔断器打开,非核心接口直接返回缓存的兜底数据,不死扛导致雪崩;注意核心接口不能降级;
  • 线程池隔离:核心接口独立线程池,避免非核心接口拖垮整个服务;
  • 超时与重试风暴:上游抖动时重试会放大流量压垮下游,超时时间阶梯化,重试用指数退避 + 抖动,并且只重试幂等接口。

七、性能设计:好用还要跑得快

优化点做法
📄 分页强制化禁止无 limit 全表查询,上限 size <= 100,深分页改游标分页
🎯 按需返回支持 fields=id,name 字段裁剪,别动辄返回 50 个字段
📦 大列表压缩Nginx 开 gzip,返回体减小 70%+
🚀 缓存高频读接口加 Redis 分层缓存,注意缓存穿透/击穿/雪崩
🖼️ 大文件走 OSS 直传,接口只返回 URL
🔗 批量接口POST /users/batch-get,避免 N 次循环调用
📤 大数据导出分批处理,避免 OOM;异步导出走文件下载

八、兼容与演进:今天的设计别坑明天的自己

接口一旦上线就是"产品",对外就是一份合同,不能随便改。版本方案对比:

方案示例评价
URL 路径(推荐)/api/v1/users → /api/v2/users最直观,网关最好路由,日志排查方便
HeaderAccept: application/vnd.app.v2+json更"优雅",URL 干净,开放平台常用,但排查成本高
请求参数/users?version=2不推荐,容易漏

兼容原则一张表:

原则做法
🧷 只增不改不删禁止改字段含义/类型、禁止删字段;新增字段默认可空
🔄 版本升级小改加字段走 v1;破坏性变更升 v2
⏳ 废弃过渡期旧版本至少保留 3~6 个月(至少一个季度),响应头加 Deprecation 和 Sunset 字段,提前邮件周知,并监控旧版本调用量
🎚 灰度发布网关按 AppKey 或 userId % 100 路由到新版本,支持秒级回滚
🤝 契约测试出参变更先过消费者契约测试(如 Spring Cloud Contract)
📜 枚举兜底枚举新增值时,老调用方要有兜底分支(else -> UNKNOWN)

还有一个隐蔽的坑:接口出参用 VO,别直接吐 Entity。

// ❌ 反面教材:直接返回 Entity,数据库加个字段全接口炸裂,还可能泄露内部字段
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) { ... }

// ✅ 正确姿势:VO 隔离 + Swagger 文档注解
@Operation(summary = "查询用户详情")
@GetMapping("/api/v1/users/{id}")
public Result<UserVO> getUser(@PathVariable Long id) { ... }

九、可观测性:出事了能快速定位

接口上线只是开始,能不能快速排障才是生死线。三件套:

  • 🆔 TraceID 全链路透传:网关/入口生成 → MDC → 线程池透传(TaskDecorator + TransmittableThreadLocal)→ MQ 消息头透传 → 随响应返回给调用方。一条 grep 定位问题,跨系统扯皮时甩 ID 就完事;
  • 📊 日志分层:access.log(耗时/状态码/入参摘要)+ biz.log(关键状态变更)+ error.log,日志里必带 traceId、userId、requestPath、method、costTime;
  • 📈 监控告警:QPS、P99 延迟、错误率、错误码分布、限流拒绝数、幂等命中数、慢 SQL、线程池/连接池水位上 Grafana 大盘;错误率 > 1%、P99 > 500ms 即时告警。

文档也别忽视:代码即文档,用 SpringDoc/Knife4j 注解自动生成 OpenAPI 文档,接口改了文档自动更新,永不脱节。变更必须提前通知,上线前前后端一起接口评审 + Mock 联调。对外的开放平台,最好再提供 Java/Python SDK,把签名、重试、幂等注入都封装好——对接方十行代码搞定接入,粘性自然就高。

十、两个进阶场景,答出来直接封神

10.1 面试官追问:"高并发下单怎么设计?"

image.png

核心思路五步:网关限流先挡一波 → Redis 预扣库存挡住大部分无效请求 → MQ 异步创建订单削峰 → 数据库最终写入保证一致 → 用户轮询或查询接口获取结果。再往下追问一致性,就聊本地消息表 / RocketMQ 事务消息 / 第三方回调的"状态机 + 主动查询 + 定时补偿"。

10.2 面试官追问:"导出大报表耗时很长,接口怎么设计?"

绝不同步阻塞。 接口立即返回 202 Accepted,body 里给一个 taskId,调用方用 /api/v1/tasks/{taskId} 轮询进度;对方有回调能力的话,支持提前注册 webhook,任务完成主动通知。体验拉满,还不占用连接数。

十一、技术难点速查总表(建议收藏)

#技术难点典型现象解决方案关键要点
1重复提交/幂等重复扣款、重复下单、重复发货Token/Lua 幂等 + 唯一索引兜底 + 状态机Lua 保证原子;区分处理中/已完成;异常必须删 key
2幂等判断非原子高并发下 GET+SET 之间被打穿Redis Lua 脚本或 SET NX EX 一条命令杜绝 check-then-act 竞态
3请求被重放/篡改合法请求被截获重发、金额被改HTTPS + HMAC-SHA256 签名 + timestamp + noncenonce 必须落 Redis;签名含 body 摘要;常量时间比较
4垂直/水平越权A 用户查到 B 用户数据鉴权注解 + 数据归属校验每次查数据校验 owner,别信前端传的 id
5高并发雪崩秒杀/大促接口被打垮,级联影响下游网关令牌桶限流 + Sentinel 熔断降级 + 线程池隔离429 + Retry-After;阈值压测得出;核心接口不降级
6限流突刺固定窗口临界双倍放行ZSet 滑动窗口 / 令牌桶 Lua热点 key 拆分 + 本地短窗预占
7超时与重试风暴上游抖动,重试放大流量压垮下游超时阶梯化 + 指数退避 + 抖动重试 + 熔断只重试幂等接口
8事务与幂等一致性先落库还是先删幂等 key?幂等 key 与业务同库同事务优先;跨库则"先占位 → 业务 → 终态标记",异常删 keykey 的 TTL 要大于业务最大处理时长
9深分页/大结果集LIMIT 100000,20 拖垮 DB游标分页 + 覆盖索引;异步导出走文件列表接口禁止全量返回
10版本不兼容改字段导致老客户端崩溃URL 版本化 + 只增不改不删 + 契约测试 + 灰度旧版本保留过渡期,监控调用量
11敏感数据泄露日志/响应暴露手机号、身份证脱敏注解序列化 + VO 隔离 + 全局异常兜底返回和日志都脱敏;绝不回传堆栈
12数据一致性库存扣了订单没建本地消息表 / RocketMQ 事务消息 / 最终一致性第三方回调用状态机 + 主动查询 + 定时补偿
13跨服务排障难出问题日志成孤岛,定位靠猜统一 traceId 贯穿(MDC + TTL + MQ header)+ 全量日志 + 告警响应头返回 traceId 给调用方

最后放一张防御体系全景图,把核心几道防线串起来:

image.png

十二、面试官高频追问(提前备好,直接加分)

追问一句话回答
GET 和 POST 怎么选?查询/幂等用 GET,变更用 POST;GET 可缓存,URL 会被日志/CDN 记录且有长度限制(浏览器实现而非协议规定),敏感参数绝不走 GET
返回码用 HTTP 状态码还是业务码?双层:HTTP 表达传输层语义(200/401/429/500),body 里 code 表达业务语义,方便网关统一处理
接口超时了,钱扣没扣?超时 ≠ 失败。用唯一业务流水号做幂等,让调用方安全重试;同时提供对账查询接口兜底
如何保证消息/请求顺序?业务层面尽量设计成无序;必须顺序则按 bizId 路由到同一分区/串行队列
幂等 key 过期了但业务还没完成?key 的 TTL 要大于业务最大处理时长;超长业务用「处理中」状态 + 异步轮询结果接口
调用方坚持自己生成幂等 key,怎么防冲突?强制加 AppKey 租户前缀,如 app_123:order_submit:xxx,隔离租户防伪造撞库,同时限制 key 总长度
突发 10 倍流量怎么办?限流 + 熔断 + 降级(返回兜底/缓存数据)+ MQ 异步削峰 + 弹性扩容
限流算法怎么选?单机用 Guava RateLimiter;分布式用 Redis + 令牌桶 Lua;要绝对平滑用 ZSet 滑动窗口
幂等和防重提交的区别?防重是事前减少重复请求,幂等是事后保证结果一致,两者配合使用
REST 和 GraphQL 怎么选?内部聚合查询多、字段裁剪需求强用 GraphQL;通用 CRUD 用 REST 更省心
gRPC 和 REST 怎么选?内部服务间高吞吐、强契约用 gRPC/Dubbo;跨团队、跨语言、浏览器友好用 REST + JSON
接口响应慢怎么排查?traceId 串链路 → SkyWalking 看哪一跳慢 → 慢 SQL / GC / 锁竞争逐个排除

结尾:一张速记卡 + 三句大实话

考前 3 分钟,扫一眼这个六字诀:

╔══════════════════════════════════════════════════════╗
║  API 接口设计 · 六字诀                                 ║
╠══════════════════════════════════════════════════════╣
║  契  → 契约先行:OpenAPI 文档即代码                     ║
║  路  → 资源路由:RESTful 名词复数 + /v1 版本            ║
║  核  → 参数校验:DTO + @Valid,脏参数不进 Service       ║
║  安  → 安全防护:JWT + 签名 + 加密 + 防重放(四防线)   ║
║  幂  → 幂等一致:Token / 唯一索引 / 状态机             ║
║  观  → 可观测演进:TraceID + 监控 + 版本兼容            ║
╚══════════════════════════════════════════════════════╝

如果这篇文章对你有帮助,点个「赞」和「在看」,也可以关注我的公众号,后面会持续更新这个「场景设计题」系列——分布式事务、秒杀系统、短链设计都在排队了。

最后分享三句我压箱底的大实话:

  1. 好的 API 设计,本质是在设计一个不可信网络下的可靠契约——对外友好、对内可控;
  2. 幂等必须有兜底且异常要放锁,任何变更都要向后兼容——这两条是我用线上事故换来的;
  3. 落地建议:别想着一步到位,先把统一返回体 + 全局异常 + 幂等注解这三件套接入你们项目,一晚上就能搞定,收益立竿见影。

互动时间:你们项目里的接口幂等是怎么做的?是 Redis 拦截、唯一索引,还是压根没做靠运气?有没有被重复请求坑过的经历?评论区聊聊,我挨个回。