如何解决设备突然炸裂的问题?

摘要:大家好,我是晓凡。 写在前面 一到月初或者月末(某些业务操作大规模爆发的时候),手机狂震,生产告警狂轰滥炸:xxx接口超时、用户中心 CPU 飙到 98%…… 运维在群里疯狂 @ 你,你却只能回一句“我本地是好的”。 别问,问就是接口设计欠
大家好,我是晓凡。 写在前面 一到月初或者月末(某些业务操作大规模爆发的时候),手机狂震,生产告警狂轰滥炸:xxx接口超时、用户中心 CPU 飙到 98%…… 运维在群里疯狂 @ 你,你却只能回一句“我本地是好的”。 别问,问就是接口设计欠下的技术债。 下面,晓凡总结成 18 条可落地的接口设计“军规”。每条都配上“作死写法”与“保命写法”。 军规 1:路径必须永久不变 反面教材 @RestController @RequestMapping("/getUserInfoByIdV2.3_beta") public class UserController { ... } 产品说“V2.3_beta”只是临时版本,结果半年后,死活不敢下线。 正面写法 @RestController @RequestMapping("/users") public class UserController { @GetMapping("/{uid}") public UserDTO get(@PathVariable Long uid) { ... } } 版本号放到 Header:Accept: application/vnd.myapp.v2+json 路由一旦上线,就是“墓碑”,永远不许动,哪怕老板喊重构。 军规 2:命名只准用名词,禁止动词 反面教材 @PostMapping("/createOrder") @PostMapping("/addOrder") @PostMapping("/insertOrder") 同一个业务三个入口,新人入职三天就开始迷路。 正面写法 @PostMapping("/orders") public OrderDTO create(@RequestBody CreateOrderCommand cmd) { ... } HTTP 动词已经表达“创建”语义,别再动词叠 buff。 军规 3:统一用复数 反面教材 @GetMapping("/order/{id}") @GetMapping("/orders") 单复数混用,前端拼接 URL 得写 if/else,特别容易出错。 正面写法 @GetMapping("/orders/{id}") @GetMapping("/orders") 集合与成员保持一致,前端直接模板字符串 ${host}/orders/${id},代码干净又整洁。 军规 4:分页参数必须“三件套” 反面教材 @GetMapping("/orders") public List<Order> list(@RequestParam int offset, @RequestParam int limit) { ... } 参数名随心所欲,前端封装 不知道骂了你多少次。 正面写法 @GetMapping("/orders") public PageResult<OrderDTO> list( @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int perPage) { long total = orderMapper.count(); List<OrderDTO> data = orderMapper.selectPage((page - 1) * perPage, perPage); return PageResult.<OrderDTO>builder() .data(data) .totalCount(total) .hasNext(page * perPage < total) .build(); } 返回统一包装: @Data @Builder public class PageResult<T> { private List<T> data; private long totalCount; private boolean hasNext; } 军规 5:字段命名一律小写加下划线 反面教材 {"userName":"Jack","userAge":18} 前端 axios 自动把下划线转小驼峰,结果文档对不上,联调 2 小时。 正面写法 @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class UserDTO { private String userName; private Integer userAge; } 返回即: {"user_name":"Jack","user_age":18} 前后端一人一把尺子,永远对得上。 军规 6:枚举值禁止用魔法数字 反面教材 if (order.getStatus() == 3) { ... } // 3 代表啥?鬼知道 DB 改个值,线上直接 500。 正面写法 public enum OrderStatus { CREATED(10), PAID(20), SHIPPED(30), DONE(40); private final int code; OrderStatus(int code) { this.code = code; } public int getCode() { return code; } } 实体与数据库均存 code: @Converter(autoApply = true) public class OrderStatusConverter implements AttributeConverter<OrderStatus, Integer> { public Integer convertToDatabaseColumn(OrderStatus s) { return s.getCode(); } public OrderStatus convertToEntityAttribute(Integer c) { return Arrays.stream(OrderStatus.values()) .filter(e -> e.getCode() == c) .findFirst() .orElseThrow(() -> new IllegalArgumentException("unknown code " + c)); } } 代码里只有枚举,没有魔法数字。 军规 7:接收参数必须 DTO,禁止 Map 反面教材 @PostMapping("/orders") public OrderDTO create(@RequestBody Map<String,Object> map) { Integer skuId = (Integer) map.get("skuId"); // 强转爆炸 } Map 一把梭,编译期 0 提示,运行时 ClassCastException 随机出现。 正面写法 @PostMapping("/orders") public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd) { ... } @Data public class CreateOrderCommand { @NotNull private Long skuId; @NotNull @Min(1) private Integer quantity; } 校验失败自动 400,错误信息一目了然: {"field":"quantity","message":"must be greater than or equal to 1"} 军规 8:统一返回包装,禁止裸奔 反面教材 @GetMapping("/orders/{id}") public OrderDTO get(@PathVariable Long id) { ... } 成功返回对象,失败返回字符串,前端得写三行 if 判断类型。 正面写法 @RestControllerAdvice public class CommonResponseAdvice implements ResponseBodyAdvice<Object> { public boolean supports(MethodParameter returnType, Class converterType) { return true; } public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body instanceof CommonResult) return body; // 避免二次包装 return CommonResult.success(body); } } public class CommonResult<T> { private int code; private String msg; private T data; public static <T> CommonResult<T> success(T data) { return new CommonResult<>(0, "ok", data); } } 前端唯一判断 code === 0,其余按错误弹窗。 军规 9:错误码必须分段 反面教材 new RuntimeException("订单不存在"); 日志里只有一行文字,定位靠天意。 正面写法 @Getter @AllArgsConstructor public enum ErrorEnum { ORDER_NOT_FOUND(20001, "订单不存在"), SKU_NOT_AVAILABLE(20002, "商品库存不足"); private final int code; private final String message; } 全局异常处理: @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public CommonResult<Void> handle(BizException ex) { return CommonResult.fail(ex.getErrorEnum().getCode(), ex.getErrorEnum().getMessage()); } } 前端按码弹窗,20001 跳转“订单列表”,20002 跳转“商品详情”。 军规 10:接口必须幂等 反面教材 @PostMapping("/orders") public OrderDTO create(@RequestBody CreateOrderCommand cmd) { return orderService.create(cmd); // 每次调用都插新订单 } 用户狂点按钮,瞬间 5 单,客服哭晕。 正面写法 @PostMapping("/orders") public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd, HttpServletRequest request) { String idempotencyKey = request.getHeader("Idempotency-Key"); if (idempotencyKey == null) throw new BizException(ErrorEnum MISSING_KEY); return idempotencyService.execute(idempotencyKey, () -> orderService.create(cmd)); } Redis 缓存 24h 唯一 KEY,重复请求直接返回第一次结果,0 重复订单。 军规 11:日期格式只准 ISO8601 反面教材 {"createTime":"06/18/2025 09:05:12"} 万一有国外项目,同事一脸懵:这是 6 月还是 18 月? 正面写法 @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") private OffsetDateTime createTime; 返回即: {"create_time":"2025-06-18T09:05:12+00:00"} 前端 new Date('2025-06-18T09:05:12+00:00') 直接解析,时区 0 歧义。 军规 12:Long 主键后端转 String 反面教材 {"orderId":9223372036854775807} JS 最大安全整数 2^53-1,订单号精度丢失,用户 A 的订单跑到用户 B。 正面写法 @JsonSerialize(using = ToStringSerializer.class) private Long orderId; 返回即: {"order_id":"9223372036854775807"} 前端字符串传,精度不丢失。 军规 13:批量接口必须限制数量 反面教材 @PostMapping("/orders/batch") public List<OrderDTO> batch(@RequestBody List<Long> ids) { ... } 对方一次丢 10w 个 id,线程池直接拉满。 正面写法 @PostMapping("/orders/batch") public List<OrderDTO> batch(@RequestBody @Size(max = 100) List<Long> ids) { ... } 超过 100 直接 400,爱用不用。 军规 14:文件上传必须预签名 反面教材 @PostMapping("/upload") public String upload(MultipartFile file) { ... } 1G 视频直接把带宽打爆,Tomcat OOM。 正面写法 @GetMapping("/upload/token") public UploadTokenDTO token(@RequestParam String suffix) { String key = "private/" + UUID.randomUUID() + suffix; String uploadUrl = ossClient.generatePresignedUrl(key, ExpirationEnum.TEN_MINUTES); return new UploadTokenDTO(uploadUrl, key); } 前端拿到直传 OSS,服务端只存 key,流量 0 占用。 军规15:禁止把「内部错误码」直接抛给前端 反面教材 catch (Exception e) { log.error("RPC失败", e); return CommonResult.fail(999, e.getMessage()); // 999 是什么?只有我自己懂 } 结果: 前端拿到 {code:999, msg:"Read timed out executing POST http://stock-service/lock"},直接把超时堆栈展示给用户,页面弹出「Read timed out…」——用户一脸懵,黑客倒开心,内网地址全暴露。 正面写法 1.对外错误码只保留「用户可理解」枚举,统一收敛: @AllArgsConstructor public enum FrontEndErrorEnum { STOCK_UNAVAILABLE(5100, "商品库存不足"), SYSTEM_BUSY(5101, "系统繁忙,请稍后重试"), UNKNOWN_ERROR(5999, "网络走神了,稍后再试"); final int code; final String message; } 2.全局异常层做「内外翻译」——任何底层异常都不准穿透: @RestControllerAdvice public class ErrorTranslator { @ExceptionHandler(Exception.class) public CommonResult<Void> handle(Exception ex) { log.error("Fetal error", ex); // 详细堆栈只写日志 if (ex instanceof FeignException) { // 下游超时 return CommonResult.fail(FrontEndErrorEnum.SYSTEM_BUSY); } return CommonResult.fail(FrontEndErrorEnum.UNKNOWN_ERROR); } } 3.前端拿到的是: {"code":5100,"msg":"商品库存不足"} 既安全又友好,还方便做国际化——以后想换提示语,只改枚举即可 用户只需要知道「怎么办」,不需要知道「为什么炸了」。把堆栈留在日志,把尊严留给产品。 军规 16:对外暴露 Swagger,对内必须加注解 反面教材 @RestController public class OrderController { @PostMapping("/orders") public OrderDTO create(CreateOrderCommand cmd) { ... } } 文档靠口口相传,字段一旦改名,测试小姐姐提刀来找。 正面写法 @Tag(name = "订单模块") @RestController public class OrderController { @Operation(summary = "创建订单") @ApiResponse(responseCode = "200", description = "成功") @PostMapping("/orders") public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd) { ... } } 启动后 http://localhost:8080/swagger-ui.html 实时可见,改字段即报错,0 沟通成本。 军规 17:关键接口必须打印入参出参 反面教材 @PostMapping("/orders") public OrderDTO create(@RequestBody CreateOrderCommand cmd) { return orderService.create(cmd); } 线上出错,日志只有一行“NullPointerException”,想复现?随缘。 正面写法 @PostMapping("/orders") public OrderDTO create(@RequestBody CreateOrderCommand cmd) { log.info("create order req: {}", cmd); OrderDTO dto = orderService.create(cmd); log.info("create order rsp: {}", dto); return dto; } 配合 Logback 异步 + 脱敏: <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>2048</queueSize> <appender-ref ref="FILE"/> </appender> 性能损耗 < 5%,问题排查速度大大提升。 军规 18:发版前必须做向后兼容扫描 反面教材 // V1 public class UserDTO { private String name; } // V2 直接把 name 改成 username public class UserDTO { private String username; } 旧应用直接解析失败,用户体验非常不好 正面写法 加新字段,不动旧字段: public class UserDTO { private String name; // deprecated private String username; // 新字段 } 使用 @Deprecated 注解,Swagger 自动标灰。 配套单元测试: @Test void v1ClientShouldStillSeeNameField() throws Exception { mockMvc.perform(get("/users/1") .header("Accept", "application/vnd.myapp.v1+json")) .andExpect(jsonPath("$.name").exists()) .andExpect(jsonPath("$.username").doesNotExist()); } 上线后观察 7 日,旧字段无调用再下线。 小结 接口设计不是炫技,而是写“半年后看了自己之前写的代码,还敢重构的代码勇气”。 这 18 条军规,一半来自我的踩坑,一半来自“别人踩过的坑”。 别嫌啰嗦,真正上线 0 告警的那天,你会来感谢我。 愿下次手机响起,只是外卖到了,不是 502。 我是晓凡,再小的帆也能远航 我们下期再见ヾ(•ω•`)o (●'◡'●)