大型电商项目异常治理--业务异常码架构篇

日志里的异常码乱成了一锅粥:订单服务抛 “120302 - 支付结果校验失败”,支付服务返回 “170103 - 订单号不存在”,就连之前刚规范好的营销服务,也跳出个 “139900 - 通用异常”—— 这串本该归类到具体模块的异常码,竟被开发图省事塞进了通用区间。

凌晨两点的猿媛电商技术部,应急灯还亮着。李建国盯着屏幕上滚动的日志,指尖在键盘上敲得飞快 —— 刚上线的 “618 预售付尾款” 活动,半小时内突然涌来 200 多起投诉:用户付完尾款,订单却显示 “支付失败”,可银行账单明明扣了钱。

日志里的异常码乱成了一锅粥:订单服务抛 “120302 - 支付结果校验失败”,支付服务返回 “170103 - 订单号不存在”,就连之前刚规范好的营销服务,也跳出个 “139900 - 通用异常”—— 这串本该归类到具体模块的异常码,竟被开发图省事塞进了通用区间。

更糟的是,跨服务调用链路里,异常码像断了线的珠子:支付回调到订单服务时,“170103” 突然变成 “-1”,没人知道中间哪个环节丢了关键信息。李建国揉着发酸的眼睛,突然意识到:单一服务的异常码规范只是开始,大型电商的复杂链路里,还藏着更难啃的硬骨头……

一、2018 年:初创期的 “单服务裸奔”—— 能跑就行,哪管规范

2018 年夏天,王磊拿着攒下的 20 万启动资金,在望京 SOHO 旁边租了个 10 平米的商住两居室,拉上刚毕业的李建国,成立了 “猿媛电商”。当时的目标很简单:做校园零食团购,让学生们在宿舍里点几下手机,就能买到便宜的薯片、可乐。

“建国,你先搭个能用的系统,不用太复杂,能下单、能收钱就行。” 王磊把一台二手笔记本电脑推给李建国,“学生们下周就要开学了,咱们得赶在开学前上线。”

李建国那时候刚从培训班毕业,只会基础的 Spring Boot 和 MySQL,哪懂什么架构设计。他对着网上的教程,花了三天三夜,搭了个最简单的单服务系统:

一个com.cxy.eshop包下,塞了所有代码 ——Controller、Service、Dao 混在一起,连分层都没有;数据库就一张order表,订单信息、用户信息、商品信息全存在里面,字段名起得乱七八糟,user_name和cust_name并存,后来连他自己都分不清哪个是哪个。

异常处理?根本没有。用户下单失败了,就返回 “下单出错了”,支付出问题了,就返回 “支付失败”,至于错在哪,没人知道。“当时觉得能跑就行,反正订单量少,学生们也不挑剔。” 李建国后来回忆起这段日子,总忍不住自嘲,“有次一个学生下单后没收到货,过来问我,我查了半天数据库,才发现是把‘清华园’写成了‘清华圆’,地址错了。”

那时候的单服务,就像个 “五脏俱全的小破屋”—— 虽然简陋,但胜在灵活。每天订单量最多几百单,服务器用的是阿里云 1 核 2G 的入门实例,跑得还挺顺畅。李建国一个人就能搞定所有开发和运维,白天写代码,晚上盯着服务器日志,偶尔出点小问题,改一行代码重新部署,就能解决。

王磊负责找货源、谈校园代理,每天开着二手面包车去批发市场进货。两人分工明确,第一个月就赚了 5 万块。庆功宴上,王磊拍着李建国的肩膀说:“建国,以后咱们做大了,就招更多人,你当技术负责人!”

李建国那时候还没意识到,这种 “裸奔” 的单服务,很快就会跟不上业务的脚步。

二、2019 年:业务扩张倒逼 “微服务拆分”—— 不拆不行,再拖就死了

2019 年初,“猿媛电商” 的业务突然爆发了。王磊谈下了北京 5 所高校的校园代理,还把业务从校园拓展到了周边的写字楼 —— 在写字楼里放 “智能零食柜”,白领们扫码就能下单,5 分钟就能取货。

订单量一下从每天几百单涨到了几万单,之前的单服务瞬间 “扛不住” 了:每天中午 12 点和下午 6 点的下单高峰,服务器 CPU 使用率直奔 100%,小程序加载半天出不来,用户在群里骂 “什么破系统,比蜗牛还慢”;库存数据经常出错,明明零食柜里还有 10 包薯片,小程序却显示 “已售罄”,有次一个白领连续下单 5 次都失败,直接投诉到了 12315;最严重的一次,支付回调延迟了 2 小时,用户付了钱却没收到取货码,几十个人围着零食柜吵,王磊不得不亲自去道歉,免费送了一周的零食,损失了好几千块。

“再这么下去,公司就得黄!” 王磊把李建国叫到办公室,桌子拍得震天响,“必须改系统,不能再用单服务了!”

李建国这才意识到问题的严重性。他连夜查资料,发现行业内做电商的,大多用 “微服务架构”—— 把原来的单服务拆成多个小服务,每个服务负责一块业务,互相独立,就算一个服务出问题,其他服务也不受影响。

“那咱们就拆!” 李建国咬了咬牙,开始对着行业架构图,梳理 “猿媛电商” 的业务模块。他发现核心业务可以分成 6 块:用户管理、商品管理、订单管理、支付、库存、物流。

但真要拆分的时候,才发现没那么简单。原来的单服务里,代码耦合得一塌糊涂 ——OrderService里既调用了支付接口,又操作了库存数据,还得处理物流通知。李建国只能一行行读代码,把不同业务的逻辑拆分开。

“那段时间天天熬夜,眼睛红得像兔子。” 李建国说,“有次拆订单和库存的逻辑,拆到凌晨 3 点,不小心把库存扣减的代码删了,导致第二天线上出现‘超卖’,多卖了 200 包薯片,最后只能给用户退款道歉。”

就这样磕磕绊绊拆了三个月,李建国终于把单服务拆成了 6 个核心微服务,还加了个网关服务,用来转发请求:

  • 用户服务(user-service):负责用户注册、登录、个人信息维护。之前用户信息存在order表里,拆出来后建了user表和user_address表,解决了 “一个表存所有信息” 的混乱;
  • 商品服务(product-service):管理商品信息,包括名称、价格、图片、保质期。之前商品信息和订单存在一起,拆出来后支持了 “商品上下架” 功能,运营终于不用再找李建国改代码就能上下架商品;
  • 订单服务(order-service):核心中的核心,负责生成订单、取消订单、查询订单。李建国把原来的OrderService拆成了OrderCreateService、OrderCancelService、OrderQueryService,职责更清晰;
  • 支付服务(pay-service):对接微信支付和支付宝,负责创建支付单、处理支付回调。之前支付逻辑嵌在订单服务里,拆出来后支持了 “多支付方式”,还加了支付日志,方便排查支付问题;
  • 库存服务(inventory-service):管理商品库存,负责库存扣减、库存查询。拆出来后加了并发控制,解决了 “超卖” 问题,这也是李建国最满意的一个服务;
  • 物流服务(logistics-service):对接第三方物流公司,负责生成物流单、通知物流状态。之前物流通知靠手动发短信,拆出来后自动通知,省了不少人力;
  • 网关服务(api-gateway):统一接收前端请求,转发到对应的微服务,还加了权限校验,防止非法请求。

拆分完成后,系统稳定性明显提升了 —— 中午下单高峰,服务器 CPU 使用率降到了 60%;库存超卖、支付回调延迟的问题,也很少出现了。王磊又招了几个程序员,分别负责不同的服务,李建国终于不用一个人扛所有活了。

但好景不长,随着业务继续扩张,新的问题又出现了。

三、2020-2022 年:微服务 “野蛮生长”—— 服务越拆越多,异常却越来越乱

2020 年,“猿媛电商” 又拓展了 “售后” 和 “营销” 业务:用户可以申请售后退款,还能领优惠券、参与满减活动。为了支撑这些新业务,李建国又新增了 4 个微服务:

  • 客服服务(customer-service):处理用户售后申请、投诉,客服小姐姐可以在系统里记录工单;
  • 营销服务(market-service):负责优惠券发放、满减活动、会员积分,这是提升用户复购的关键;
  • 履约服务(fulfill-service):连接订单和物流,订单支付后,通知仓库备货、发货;
  • 仓储服务(warehouse-service):管理仓库库存,负责捡货、打包,之前库存服务只管线上库存,现在线下仓库也需要专门的服务。

到 2022 年底,“猿媛电商” 的微服务数量已经达到 12 个,加上之前的用户、商品、订单等,总共 16 个服务。服务间的调用链路也越来越长,比如一个用户使用优惠券下单的完整流程:

  • 前端发起请求,经过网关转发到订单服务;
  • 订单服务调用用户服务,验证用户是否登录;
  • 调用商品服务,查询商品价格和库存状态;
  • 调用营销服务,验证优惠券是否有效、是否可用;
  • 调用库存服务,锁定商品库存(防止超卖);
  • 调用支付服务,创建支付单;
  • 用户支付成功后,支付服务调用订单服务的回调接口,更新订单状态;
  • 订单服务调用履约服务,创建履约单;
  • 履约服务调用仓储服务,通知仓库捡货;
  • 仓储服务完成捡货后,调用物流服务,生成物流单,通知快递公司取货。

这么长的链路,只要有一个服务抛出异常,整个流程就会卡住。更要命的是,每个服务的异常处理都 “各自为战”—— 没有统一的规范,每个程序员都按自己的习惯抛异常:

  • 用户服务的 “用户不存在”,返回 “-1”;
  • 商品服务的 “商品已下架”,返回 “goods_off_shelf”;
  • 营销服务的 “优惠券已过期”,返回 “coupon_expired_1001”;
  • 订单服务更乱,有时返回数字,有时返回字符串,遇到没处理的异常,直接抛 RuntimeException,前端收到的就是 “500 Internal Server Error”。

“那时候前端开发小美,每天都要拿着错误信息跑来问我:‘建国哥,这个 “-1” 是啥意思啊?这个 “coupon_expired_1001” 是哪个服务抛的啊?’” 李建国苦笑着说,“我也答不上来,只能让她去对应的服务日志里找,有时候查半天都找不到问题根源。”

2022 年双 11,线上出了个大故障:有用户反映,用优惠券下单后,支付成功了,却一直显示 “待支付”,优惠券也被锁定了,不能用也不能退。

李建国带着团队排查了整整 6 个小时,才找到问题所在:

  • 用户下单时,营销服务锁定优惠券成功,但返回异常码时,程序员手滑把 “200 - 锁定成功” 写成了 “201 - 锁定失败”;
  • 订单服务收到 “201” 异常码,以为优惠券锁定失败,就没更新订单状态,但实际上库存已经扣减了,优惠券也锁定了;
  • 用户支付成功后,支付服务调用订单服务回调接口,订单服务因为订单状态是 “待支付”,拒绝处理,返回 “order_status_error”;
  • 支付服务收到这个异常,不知道该怎么处理,就抛了个 RuntimeException,导致支付状态没同步到订单服务。

最后,李建国只能手动修改数据库,给用户解锁优惠券、更新订单状态,忙到凌晨 3 点才搞定。第二天,老板王磊把全部门的人叫到办公室,发了大火:“咱们现在每天订单量几十万,还这么搞异常处理,迟早要出大问题!必须定个规范,把这些异常管起来!”

也就是从那天起,李建国意识到:微服务光拆分还不够,必须做 “异常治理”,尤其是统一的错误码规范 —— 不然服务越多,乱得越厉害,最后整个系统都会变成 “一团乱麻”。

四、2023 年:异常治理的 “破局”—— 从错误码规范开始,给异常 “立规矩”

2023 年初,“猿媛电商” 年交易额突破 10 亿元,王磊把李建国提拔为技术经理,让他牵头做 “异常治理”。李建国做的第一件事,就是梳理所有服务的异常场景,制定统一的错误码规范。

“那时候我带着两个程序员,花了一个月时间,把 16 个服务的所有异常都过了一遍。” 李建国说,“每个服务有多少个异常场景,每个场景该返回什么错误码,都记在本子上,最后整理出了一个‘错误码规范文档’。”

这个规范的核心,是 “6 位数字错误码”,分三段式结构:

  • 前两位(服务标识):代表异常所属的微服务,比如用户服务是 10,商品服务是 11,订单服务是 12,营销服务是 13,这样一看前两位,就知道异常来自哪个服务;  系统级别错误:  

图片

    业务级别错误:

图片

  • 中间两位(模块标识):代表服务内的具体模块,比如订单服务的 “创建订单” 是 01,“取消订单” 是 02,“查询订单” 是 03,这样能快速定位到具体业务模块;

图片

  • 最后两位(异常序号):代表该模块下的具体异常,从 00 开始递增,比如订单服务 “创建订单” 模块下,“订单已存在” 是 00,“商品不存在” 是 01,“优惠券不可用” 是 02。

   比如:

  • 120100:12(订单服务)+01(创建订单)+00(订单已存在)→ 订单已存在;
  • 130201:13(营销服务)+02(优惠券锁定)+01(优惠券已过期)→ 优惠券已过期;
  • 110302:11(商品服务)+03(商品查询)+02(商品已下架)→ 商品已下架。为了让规范落地,李建国还做了三件事:
  • 开发统一的异常枚举包:在cxy-eshop-common工程里,为每个服务创建对应的异常枚举类,比如UserErrorCodeEnum、ProductErrorCodeEnum、OrderErrorCodeEnum,每个枚举值都按 “6 位错误码 + 错误信息” 定义,还加了详细注释,说明异常场景和处理建议;
// 订单服务异常枚举示例
public enum OrderErrorCodeEnum {
    /**
     * 创建订单 - 订单已存在
     * 场景:用户重复提交订单(如连续点击下单按钮)
     * 处理建议:前端做按钮置灰,后端加用户ID+商品ID幂等校验
     */
    ORDER_EXISTED("120100", "订单已存在,请勿重复提交"),
    /**
     * 创建订单 - 商品不存在
     * 场景:用户下单时,商品已被删除或下架
     * 处理建议:前端提示用户“商品已下架”,引导用户返回商品列表
     */
    PRODUCT_NOT_EXIST("120101", "商品不存在或已下架"),
    // 其他异常...
}
  • 做全公司培训:李建国组织了 3 场培训,给所有开发、测试、前端讲错误码规范,还搞了个 “错误码默写大赛”,默写全对的奖励零食大礼包,错一个的罚抄 10 遍规范文档。“那时候连客服小姐姐都知道,看到 13 开头的异常,就找营销服务的开发,效率提升了不少。”
  • 加代码审查:在代码提交前,必须检查异常处理是否符合规范,错误码是否正确,不符合的一律打回。有次新员工小陈把订单服务的 “取消订单” 异常码写成了 120300(正确是 120200),被李建国打回,罚他抄了 5 遍规范文档,从此再也没人敢随便写错误码。

五、异常治理前的 “异常迷宫”——try-catch 堆成山,排查故障靠 “猜”

错误码规范落地后,李建国以为异常治理能顺利推进,可没过多久就发现:新的问题又来了 —— 服务间调用的异常处理还是一团糟,尤其是 RPC 服务端和 Web 层,重复的 try-catch 代码堆得像座小山。

那天李建国翻营销服务的代码,看到MarketRemoteImpl里的lockUserCoupon方法,气得差点把键盘摔了:

@Override
public JsonResult<Boolean> lockUserCoupon(LockUserCouponRequest request) {
    try {
        log.info("lockUserCoupon request:{}", JSON.toJSONString(request));
        // 调用优惠券服务锁定优惠券
        Boolean result = couponService.lockUserCoupon(request);
        log.info("lockUserCoupon response:{}", result);
        return JsonResult.buildSuccess(result);
    } catch (MarketBizException e) {
        // 捕获营销业务异常
        log.error("lockUserCoupon biz error, request:{}", JSON.toJSONString(request), e);
        return JsonResult.buildError(e.getErrorCode(), e.getErrorMsg());
    } catch (DubboException e) {
        // 捕获Dubbo调用异常
        log.error("lockUserCoupon dubbo error, request:{}", JSON.toJSONString(request), e);
        return JsonResult.buildError("999999", "服务调用失败,请稍后再试");
    } catch (Exception e) {
        // 捕获系统未知异常
        log.error("lockUserCoupon system error, request:{}", JSON.toJSONString(request), e);
        return JsonResult.buildError(CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorCode(), 
                                    CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorMsg());
    }
}

“全公司 16 个服务,每个服务平均 10 个 RPC 接口,每个接口都这么写 try-catch,改个日志格式都要改 160 处!” 李建国把开发们叫到会议室,把这段代码投在屏幕上,“上次小陈改营销服务的异常返回格式,漏了 3 个接口,导致线上出现‘999999’和‘-1’两种系统错误码,用户投诉说‘你们系统怎么一会儿一个错?’”

更头疼的是 RPC 调用的异常流程。李建国画了张流程图,贴在会议室墙上:

  • 订单服务(Dubbo 消费者)调用营销服务(Dubbo 提供者)的lockUserCoupon接口;
  • 营销服务抛出MarketBizException(错误码 130201,优惠券已过期);
  • Dubbo 原生的ExceptionFilter把MarketBizException包装成RuntimeException,还丢了错误码;
  • 订单服务消费者收到RuntimeException,进入自己的catch (Exception e)块;
  • 订单服务返回 “-1 - 系统未知异常” 给 Web 层;
  • Web 层的 Controller 又套了一层 try-catch,最后返回给前端的还是 “系统未知异常”。

“用户明明是优惠券过期,却看到‘系统未知异常’,能不骂娘吗?” 测试林晓拿着测试报告补充,“我上周测了 20 个异常场景,有 15 个最后都显示‘系统未知异常’,根本没法验证异常处理是否正确。”

Web 层的问题也不小。订单服务的OrderController里,每个接口都裹着 try-catch:

@PostMapping("/createOrder")
public JsonResult<CreateOrderDTO> createOrder(@RequestBody CreateOrderRequest request) {
    try {
        log.info("createOrder request:{}", JSON.toJSONString(request));
        CreateOrderDTO result = orderService.createOrder(request);
        return JsonResult.buildSuccess(result);
    } catch (OrderBizException e) {
        log.error("createOrder biz error, request:{}", JSON.toJSONString(request), e);
        return JsonResult.buildError(e.getErrorCode(), e.getErrorMsg());
    } catch (Exception e) {
        log.error("createOrder system error, request:{}", JSON.toJSONString(request), e);
        return JsonResult.buildError("-1", "系统繁忙,请稍后再试");
    }
}

“前端小美跟我吐槽,同一个‘订单已存在’异常,在下单接口返回 120100,在取消订单接口却返回‘系统繁忙’,她都不知道该怎么统一提示用户。” 李建国揉了揉太阳穴,“必须彻底重构异常处理流程,把这些重复的 try-catch 全干掉!”

六、RPC 服务端异常治理:Dubbo 过滤器 “救场”—— 干掉 try-catch,异常透传不打折

李建国把 RPC 服务端治理的核心定为 “用 Dubbo 过滤器统一处理异常”。他翻了三天 Dubbo 官方文档,终于理清了思路:重写原生ExceptionFilter解决异常包装问题,再开发自定义过滤器统一日志和异常返回,两步走搞定 RPC 异常。

图片

第一步:定义统一业务异常 —— 给异常 “定家谱”

要让过滤器识别业务异常,首先得有统一的异常父类。李建国在cxy-eshop-common工程里新建了BaseBizException,所有业务异常都继承它:

package com.cxy.eshop.common.exception;
import lombok.Getter;
/**
 * 业务异常父类,所有业务异常必须继承此类
 * 用于Dubbo过滤器识别业务异常,避免被包装成RuntimeException
 */
@Getter
public class BaseBizException extends RuntimeException {
    /**
     * 错误码(6位数字,遵循错误码规范)
     */
    private final String errorCode;
    /**
     * 错误信息
     */
    private final String errorMsg;
    public BaseBizException(String errorCode, String errorMsg) {
        super(errorMsg);
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }
    public BaseBizException(ErrorCode errorCodeEnum) {
        super(errorCodeEnum.getErrorMsg());
        this.errorCode = errorCodeEnum.getErrorCode();
        this.errorMsg = errorCodeEnum.getErrorMsg();
    }
}

然后让各个服务的业务异常继承它,比如营销服务的MarketBizException:

package com.cxy.eshop.market.exception;
import com.cxy.eshop.common.exception.BaseBizException;
import com.cxy.eshop.common.exception.ErrorCode;
/**
 * 营销服务业务异常
 */
public class MarketBizException extends BaseBizException {
    public MarketBizException(String errorCode, String errorMsg) {
        super(errorCode, errorMsg);
    }
    public MarketBizException(ErrorCode errorCodeEnum) {
        super(errorCodeEnum);
    }
}

“这样一来,所有业务异常都有了‘家谱’,Dubbo 过滤器只要判断异常是不是 BaseBizException 的子类,就能识别业务异常了。” 李建国在技术分享会上解释。

第二步:重写 Dubbo 原生 ExceptionFilter—— 阻止异常 “被包装”

Dubbo 原生的ExceptionFilter会把自定义异常包装成RuntimeException,李建国要做的就是 “截胡”—— 在包装前把业务异常拎出来,直接返回错误码和信息。

他在cxy-eshop-common里新建DubboExceptionFilter,继承原生过滤器,重写invoke方法:

package com.cxy.eshop.common.dubbo;
import com.cxy.eshop.common.exception.BaseBizException;
import com.cxy.eshop.common.exception.JsonResult;
import org.apache.dubbo.common.constants.CommonConstants;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.*;
import org.apache.dubbo.rpc.filter.ExceptionFilter;
import org.apache.dubbo.rpc.service.GenericService;
import java.lang.reflect.Method;
/**
 * 重写Dubbo原生ExceptionFilter,解决业务异常被包装问题
 */
@Activate(group = CommonConstants.PROVIDER)
public class DubboExceptionFilter extends ExceptionFilter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        Result result = invoker.invoke(invocation);
        // 只处理有异常且非泛化调用的情况
        if (result.hasException() && GenericService.class != invoker.getInterface()) {
            try {
                Throwable exception = result.getException();
                // 关键:如果是业务异常,直接返回错误码和信息,不包装
                if (exception instanceof BaseBizException) {
                    BaseBizException bizException = (BaseBizException) exception;
                    JsonResult<Object> errorResult = JsonResult.buildError(
                            bizException.getErrorCode(), 
                            bizException.getErrorMsg()
                    );
                    // 用AsyncRpcResult包装,避免Dubbo二次处理
                    return new AsyncRpcResult(ResultType.NORMAL_VALUE, errorResult, invocation);
                }
                // 非业务异常,走原生逻辑(比如检查是否在方法声明的异常列表中)
                Method method = invoker.getInterface().getMethod(
                        invocation.getMethodName(), 
                        invocation.getParameterTypes()
                );
                Class<?>[] exceptionClasses = method.getExceptionTypes();
                for (Class<?> exceptionClass : exceptionClasses) {
                    if (exception.getClass().equals(exceptionClass)) {
                        return result;
                    }
                }
                // 未声明的非业务异常,包装成系统错误
                JsonResult<Object> systemError = JsonResult.buildError(
                        "-1", "系统未知异常,请联系管理员"
                );
                return new AsyncRpcResult(ResultType.NORMAL_VALUE, systemError, invocation);
            } catch (Throwable e) {
                // 防止过滤器自身出错
                return result;
            }
        }
        return result;
    }
}

要让这个过滤器生效,还得在cxy-eshop-common的resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter里加配置,用自定义过滤器覆盖原生的:

# 覆盖Dubbo原生ExceptionFilter,名称必须是exception
exception=com.cxy.eshop.common.dubbo.DubboExceptionFilter

“这里踩过坑!” 李建国在文档里加了个红色警告,“一开始用了别的名称,结果原生过滤器还在生效,业务异常还是被包装,后来查文档才知道,必须用‘exception’这个名称才能覆盖。”

第三步:新增 CustomerExceptionFilter—— 统一日志和耗时统计

解决了异常包装问题,李建国又开发了CustomerExceptionFilter,把日志打印、耗时统计、异常返回全统一了:

package com.cxy.eshop.common.dubbo;
import com.alibaba.fastjson.JSON;
import com.cxy.eshop.common.exception.BaseBizException;
import com.cxy.eshop.common.exception.CommonErrorCodeEnum;
import com.cxy.eshop.common.exception.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.dubbo.common.constants.CommonConstants;
import org.apache.dubbo.common.extension.Activate;
import org.apache.dubbo.rpc.*;
/**
 * 自定义Dubbo过滤器:统一日志、耗时统计、异常处理
 */
@Slf4j
@Activate(group = CommonConstants.PROVIDER, order = 100) // order越大,执行越靠后
public class CustomerExceptionFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        long startTime = System.currentTimeMillis();
        String serviceName = invoker.getInterface().getName();
        String methodName = invocation.getMethodName();
        String paramJson = JSON.toJSONString(invocation.getArguments());
        log.info("[Dubbo调用开始] service:{}, method:{}, param:{}", 
                serviceName, methodName, paramJson);
        try {
            // 调用目标方法
            Result result = invoker.invoke(invocation);
            long costTime = System.currentTimeMillis() - startTime;
            if (result.hasException()) {
                // 处理异常(此时业务异常已被DubboExceptionFilter处理,这里只处理系统异常)
                Throwable e = result.getException();
                log.error("[Dubbo调用异常] service:{}, method:{}, param:{}, costTime:{}ms", 
                        serviceName, methodName, paramJson, costTime, e);
                JsonResult<Object> errorResult = JsonResult.buildError(
                        CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorCode(),
                        CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorMsg()
                );
                result.setValue(errorResult);
                result.setException(null); // 屏蔽原始异常,避免泄露敏感信息
            } else {
                log.info("[Dubbo调用成功] service:{}, method:{}, costTime:{}ms, result:{}", 
                        serviceName, methodName, costTime, JSON.toJSONString(result.getValue()));
            }
            return result;
        } catch (BaseBizException e) {
            // 捕获方法内部主动抛出的业务异常
            long costTime = System.currentTimeMillis() - startTime;
            log.error("[Dubbo业务异常] service:{}, method:{}, param:{}, costTime:{}ms, errorCode:{}, errorMsg:{}", 
                    serviceName, methodName, paramJson, costTime, e.getErrorCode(), e.getErrorMsg(), e);
            JsonResult<Object> errorResult = JsonResult.buildError(e.getErrorCode(), e.getErrorMsg());
            return new AsyncRpcResult(ResultType.NORMAL_VALUE, errorResult, invocation);
        } catch (Exception e) {
            // 捕获系统异常
            long costTime = System.currentTimeMillis() - startTime;
            log.error("[Dubbo系统异常] service:{}, method:{}, param:{}, costTime:{}ms", 
                    serviceName, methodName, paramJson, costTime, e);
            JsonResult<Object> errorResult = JsonResult.buildError(
                    CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorCode(),
                    CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR.getErrorMsg()
            );
            return new AsyncRpcResult(ResultType.NORMAL_VALUE, errorResult, invocation);
        }
    }
}

配置这个过滤器也得两步:

  • 在META-INF/dubbo/org.apache.dubbo.rpc.Filter里注册:
customerExceptionFilter=com.cxy.eshop.common.dubbo.CustomerExceptionFilter
    • 在每个服务的application.yml里启用:
    dubbo:
      provider:
        filter: customerExceptionFilter # 启用自定义过滤器
        timeout: 3000

    第四步:消除 Dubbo 服务端模板代码 —— 代码清爽得像 “刚洗澡”

    过滤器部署完成后,李建国带头改造营销服务的MarketRemoteImpl。之前 18 行的lockUserCoupon方法,现在只剩 3 行:

    @Override
    public JsonResult<Boolean> lockUserCoupon(LockUserCouponRequest request) {
        // 直接调用业务方法,不用try-catch,过滤器会处理异常
        Boolean result = couponService.lockUserCoupon(request);
        return JsonResult.buildSuccess(result);
    }

    “太爽了!” 负责营销服务的小陈改完代码,兴奋地跑到李建国工位,“以前改个业务逻辑,还得小心翼翼别碰坏 try-catch,现在直接写核心代码,效率至少提升一倍!”

    李建国还特意做了个对比测试:在营销服务抛出 “130201 - 优惠券已过期” 异常,订单服务调用后,直接拿到了包含错误码的JsonResult,再也没有被包装成RuntimeException。前端收到异常后,准确显示 “优惠券已过期,请更换优惠券”,用户投诉量一下降了 40%。

    七、 Web 层异常治理:全局拦截器 “收尾”——Controller 告别 try-catch

    RPC 服务端搞定了,Web 层的问题还没解决。李建国看着订单服务OrderController里重复的 try-catch,决定用@RestControllerAdvice + @ExceptionHandler做全局异常拦截。

    第一步:开发全局异常拦截器 ——Web 层的 “异常保安”

    他在cxy-eshop-common里新建GlobalExceptionHandler,统一处理 Web 层所有异常:

    package com.cxy.eshop.common.exception;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.core.Ordered;
    import org.springframework.core.annotation.Order;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import javax.servlet.http.HttpServletRequest;
    import java.util.stream.Collectors;
    /**
     * Web层全局异常拦截器,所有Controller的异常都会被这里处理
     * Order设置为最高优先级,确保先于其他拦截器执行
     */
    @Slf4j
    @RestControllerAdvice // 对所有@RestController生效,自动返回JSON格式
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public class GlobalExceptionHandler {
        /**
         * 处理业务异常(BaseBizException及其子类)
         * 比如订单服务的OrderBizException、营销服务的MarketBizException
         */
        @ExceptionHandler(value = BaseBizException.class)
        public JsonResult<Object> handleBizException(BaseBizException e, HttpServletRequest request) {
            String requestUrl = request.getRequestURI();
            String method = request.getMethod();
            // 打印业务异常日志,包含请求地址、请求方法,方便排查
            log.error("[Web业务异常] url:{}, method:{}, errorCode:{}, errorMsg:{}",
                    requestUrl, method, e.getErrorCode(), e.getErrorMsg(), e);
            // 直接返回业务异常的错误码和信息,符合前端预期格式
            return JsonResult.buildError(e.getErrorCode(), e.getErrorMsg());
        }
        /**
         * 处理请求参数校验异常(比如@NotNull、@NotBlank注解触发的异常)
         * 之前这类异常需要在Controller里手动捕获,现在统一处理
         */
        @ExceptionHandler(value = MethodArgumentNotValidException.class)
        public JsonResult<Object> handleParamValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
            String requestUrl = request.getRequestURI();
            String method = request.getMethod();
            // 提取参数校验失败的信息(比如“订单号不能为空”)
            String errorMsg = e.getBindingResult().getFieldErrors().stream()
                    .map(fieldError -> fieldError.getField() + ":" + fieldError.getDefaultMessage())
                    .collect(Collectors.joining("; "));
            // 打印参数校验日志
            log.error("[Web参数校验异常] url:{}, method:{}, errorMsg:{}",
                    requestUrl, method, errorMsg, e);
            // 返回客户端通用参数错误码1002
            return JsonResult.buildError(CommonErrorCodeEnum.CLIENT_REQUEST_BODY_VALID_ERROR);
        }
        /**
         * 处理系统未知异常(所有未捕获的异常都会走到这里)
         */
        @ExceptionHandler(value = Exception.class)
        public JsonResult<Object> handleSystemException(Exception e, HttpServletRequest request) {
            String requestUrl = request.getRequestURI();
            String method = request.getMethod();
            // 打印系统异常日志,包含堆栈信息,方便定位问题
            log.error("[Web系统未知异常] url:{}, method:{}, errorMsg:{}",
                    requestUrl, method, e.getMessage(), e);
            // 返回系统未知异常码-1,避免暴露敏感信息
            return JsonResult.buildError(CommonErrorCodeEnum.SYSTEM_UNKNOWN_ERROR);
        }
    }

    写完代码,李建国特意加了三个关键处理逻辑:

    • 业务异常精准返回:直接提取BaseBizException的错误码和信息,保证前端拿到的异常格式统一;
    • 参数校验异常自动处理:之前需要在 Controller 里写@Valid + BindingResult手动判断,现在拦截器自动收集校验失败信息,返回 1002 客户端参数错误码;
    • 系统异常脱敏:只返回 “系统未知异常” 提示,不暴露堆栈信息,避免黑客利用漏洞。

    “以前参数校验要写这么多代码,” 李建国翻出之前的 Controller 代码给同事看,“现在一行都不用写,拦截器全搞定!”

    第二步:改造 Controller—— 代码 “瘦身”,告别 try-catch

    全局拦截器部署到cxy-eshop-common后,李建国带头改造订单服务的OrderController。之前 15 行的createOrder接口,现在只剩 5 行:

    @RestController
    @RequestMapping("/order")
    public class OrderController {
        @Autowired
        private OrderService orderService;
        /**
         * 提交订单接口
         * 改造后:无try-catch,无日志打印,无参数校验判断
         */
        @PostMapping("/createOrder")
        public JsonResult<CreateOrderDTO> createOrder(
                // @Valid触发参数校验,拦截器会处理校验失败异常
                @Valid @RequestBody CreateOrderRequest request) {
            // 只保留核心业务逻辑,异常全靠GlobalExceptionHandler处理
            CreateOrderDTO result = orderService.createOrder(request);
            return JsonResult.buildSuccess(result);
        }
        /**
         * 取消订单接口
         * 之前因try-catch漏写,导致返回“系统繁忙”,现在不会再出现
         */
        @PostMapping("/cancelOrder")
        public JsonResult<Boolean> cancelOrder(
                @RequestParam("orderId") String orderId,
                @RequestParam("userId") String userId) {
            Boolean result = orderService.cancelOrder(orderId, userId);
            return JsonResult.buildSuccess(result);
        }
    }

    负责订单服务的开发老周改完代码,兴奋地拍了拍李建国的肩膀:“建国,你这拦截器太牛了!以前改个订单状态逻辑,还得小心翼翼别碰错 try-catch,现在直接写业务代码,效率至少提了三成!”

    更让前端小美开心的是,异常格式终于统一了。之前同一个 “订单已存在” 异常,在下单接口返回{"errorCode":"120100","errorMsg":"订单已存在"},在取消订单接口返回{"errorCode":"-1","errorMsg":"系统繁忙"},现在不管哪个接口抛出OrderBizException,都返回统一格式,她再也不用写一堆 “if-else” 判断错误码了。

    “现在我只要根据 errorCode 就能写提示,12 开头就是订单服务的问题,13 开头就是营销服务,太省心了!” 小美特意跑来给李建国送了杯奶茶,“之前处理异常要写 50 行代码,现在 10 行就搞定!”

    第三步:落地踩坑与优化 —— 细节决定成败

    但全局拦截器落地时,还是出了小插曲。测试林晓在测 “查询订单详情” 接口时,发现传入非法的订单号(比如 “abc123”),拦截器返回的是 “系统未知异常”,而不是预期的 “订单号格式错误”。

    “建国哥,这不对啊!” 林晓拿着测试报告找到李建国,“订单号格式错误应该是客户端参数错误,返回 1004 才对,怎么返回 - 1 了?”

    李建国查了日志才发现,订单服务的getOrderDetail方法里,把 “订单号格式错误” 抛成了IllegalArgumentException,而不是自定义的OrderBizException,导致拦截器把它当成系统异常处理,返回了 - 1。

    “看来光有拦截器还不够,得规范异常抛出!” 李建国立刻组织开发们开了个短会,强调两条规则:

    • 业务相关异常必须抛自定义业务异常:比如参数格式错误、业务逻辑不满足(如订单已支付不能取消),必须抛对应服务的 BizException,指定明确错误码;
    • 非业务异常(如空指针)要提前预防:通过参数校验、判空等方式避免,实在无法避免的,在最外层 Service 抛BaseBizException(指定通用错误码),不让它走到系统异常拦截逻辑。

    会后,老周把getOrderDetail里的IllegalArgumentException改成了OrderBizException:

    public OrderDetailDTO getOrderDetail(String orderId) {
        // 订单号格式校验:如果不是数字,抛业务异常
        if (!orderId.matches("\\d+")) {
            throw new OrderBizException(OrderErrorCodeEnum.ORDER_ID_FORMAT_ERROR);
            // OrderErrorCodeEnum.ORDER_ID_FORMAT_ERROR的错误码是120601,含义“订单号格式错误”
        }
        // 后续业务逻辑...
    }

    再次测试,传入 “abc123” 的订单号,接口正确返回{"errorCode":"120601","errorMsg":"订单号格式错误,仅支持数字"},林晓这才满意地在测试报告上打了 “通过”。

    还有个坑是 “拦截器优先级”。客服服务的开发小张自己写了个CustomerExceptionHandler,优先级比全局拦截器还高,导致客服服务的业务异常被小张的拦截器处理,返回了非标准格式的错误信息。

    李建国查了代码,发现小张的拦截器没加@Order注解,默认优先级比全局拦截器低,可小张在@RestControllerAdvice里指定了basePackages = "com.cxy.eshop.customer",只处理客服服务的 Controller,反而覆盖了全局拦截器。

    “解决办法很简单,在全局拦截器的@RestControllerAdvice里也加 basePackages,并且把优先级设为最高。” 李建国帮小张修改了全局拦截器的注解:

    // 只处理公司内部服务的Controller,避免影响第三方依赖
    @RestControllerAdvice(basePackages = "com.cxy.eshop")
    @Order(Ordered.HIGHEST_PRECEDENCE) // 最高优先级,确保先执行
    public class GlobalExceptionHandler {
        // ...
    }

    这样一来,全局拦截器会优先处理所有com.cxy.eshop包下的 Controller 异常,小张的客服服务拦截器只作为补充,不会覆盖全局规则。

    八、异常治理落地效果 —— 从 “混乱” 到 “有序” 的蜕变

    经过三个月的攻坚,猿媛电商的异常治理终于全面落地。李建国做了个数据统计,效果让全公司都惊讶:

    • 代码冗余减少 60%:全公司 16 个服务,共删除重复 try-catch 代码约 8000 行,平均每个接口代码量减少 50%;
    • 故障排查时间缩短 80%:之前排查一个异常平均需要 2 小时,现在看错误码就能定位到服务和模块,平均 20 分钟就能解决;
    • 用户投诉量下降 75%:因 “系统未知异常” 导致的投诉从每月 120 起,降到了每月 30 起以下;
    • 开发效率提升 40%:新接口开发时间从平均 2 天,缩短到 1.2 天,不用再花时间写重复的异常处理代码。

    2023 年底的技术总结会上,李建国展示了治理前后的对比:

    • 治理前:用户下单用优惠券,因营销服务异常,返回 “系统繁忙”,用户不知道是优惠券过期;
    • 治理后:相同场景下,接口返回 “130201 - 优惠券已过期,请更换优惠券”,用户直接换券下单,转化率提升了 15%。

    老板王磊拿着这份数据,在全公司大会上表扬了技术部:“以前用户总说咱们系统‘不稳定’,现在很少听到这种抱怨了。异常治理不仅提升了用户体验,还帮公司省了不少售后成本,李建国这个技术经理,没白提!”

    2023 年底的公司年会上,王磊把 “年度技术贡献奖” 颁给了李建国,奖金 10 万元。“建国,你搞的异常治理,比加 10 台服务器还管用!” 王磊拍着他的肩膀说,“明年咱们要把业务拓展到全国,你继续牵头做架构优化,争取成为真正的架构师!”

    李建国拿着奖杯,看着台下的张萌(此时已经是他的未婚妻),眼眶有点湿润。他想起 2018 年刚入职时,那个连单服务都写不规范的自己;想起 2019 年拆分微服务时,熬夜改 bug 的日子;想起 2022 年双 11 排查故障时的焦虑。

    “从单服务到微服务,再到异常治理,其实就是公司成长的缩影。” 李建国在获奖感言里说,“技术从来不是孤立的,而是跟着业务走的 —— 业务需要什么,我们就做什么;哪里有问题,我们就解决哪里。这就是我们程序员的价值。”

    台下响起了热烈的掌声,张萌笑着给他比了个 “加油” 的手势。李建国知道,这只是开始,未来还有更多的技术挑战等着他,但他已经做好了准备 —— 跟着 “猿媛电商” 一起,继续成长,继续破局。

    台下的李建国看着身边的张萌(此时已经是他的妻子),心里满是感慨。他想起 2018 年刚入职时,那个连单服务异常都处理不好的自己;想起 2019 年拆分微服务时,熬夜改 bug 的焦虑;想起 2023 年推进异常治理时,遇到的各种阻力。

    “异常治理不是终点,而是新的起点。” 李建国在年会的最后说,“接下来我们还要做异常监控平台,把所有异常码汇总起来,实时预警;还要做异常溯源,让每个异常都能查到完整的调用链路。技术永远在进步,我们也得跟着进步,才能跟上公司发展的脚步。”

    散会后,张萌走过来,悄悄递给李建国一个保温杯:“别总熬夜了,现在系统稳定了,也该多陪陪我和孩子了。” 李建国笑着接过保温杯,里面是他最爱喝的菊花茶。他知道,未来还有更多技术挑战等着他,但有家人的支持,有团队的配合,他有信心把猿媛电商的技术架构做得更稳定、更强大 —— 就像猿媛电商的成长一样,从 “小破屋” 到 “高楼大厦”,一步一个脚印,踏实向前。

    猿媛电商的 6 位异常码规范刚在全服务落地满三周,双十一大促前的压力测试就炸出了新漏洞:一批用户在 “优惠券 + 满减” 叠加下单时,前端同时弹出 “160102 - 订单类型错误” 与 “150401 - 费用计算失败” 两个异常提示,后端日志里订单服务和营销服务的异常码各执一词,排查两小时才发现 —— 营销服务计算满减时超时,却未抛专属的 “150402 - 满减计算超时”,反而复用了订单服务的通用错误码,导致链路异常 “串线”。

    更棘手的挑战接踵而至:新增的生鲜业务要求异常码关联 “冷链中断” 等特殊场景,6 位编码的模块位已不够分配;海外站用户投诉 “错误提示全是中文”,多语言适配要如何与异常码绑定?

    李建国团队连夜启动 “异常码治理 2.0” 计划:既要给异常码加 “链路 ID” 实现跨服务溯源,还要设计可扩展的编码规则。可就在方案评审当天,运维团队突然上报 —— 生产环境出现 “异常码雪崩”,近千条错误日志里,不同服务的异常码竟指向同一个不存在的模块位…… 这场突如其来的危机,会让之前的治理成果功亏一篑吗?欢迎评论后续精彩故事。

    AI大模型学习福利

    作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

    一、全套AGI大模型学习路线

    AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

    二、640套AI大模型报告合集

    这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    三、AI大模型经典PDF籍

    随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。


    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    四、AI大模型商业化落地方案

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值