← 返回题目列表

装饰器模式中如何处理异常、副作用和资源释放?

高频 困难 第 15 / 25 题 更新于 2026/08/01
装饰器模式异常处理副作用资源释放

简化版

装饰器处理异常时要保持语义透明:不该吞的异常不能吞,副作用要有明确提交或回滚策略,资源必须在 finally 或等价机制中释放。日志、缓存、事务、压缩、加密等装饰器都要考虑异常路径,否则增强逻辑可能改变目标对象原本的业务语义。

详细版

装饰器包在目标对象外面,天然能在调用前后做事。但这也带来一个风险:装饰器可能改变异常传播、副作用执行顺序和资源生命周期。

例如:

  • 日志装饰器吞掉异常,调用方误以为成功;
  • 缓存装饰器在目标方法失败后仍写入缓存;
  • 压缩装饰器写到一半失败,底层流没有关闭;
  • 计数装饰器只统计成功,不统计失败;
  • 重试装饰器对非幂等写操作重复执行;
  • 加密装饰器异常后留下半成品文件。

好的装饰器应该明确:

  • 哪些异常继续抛出;
  • 哪些异常可以转换;
  • 调用前副作用失败时是否继续;
  • 调用后副作用失败时是否影响主流程;
  • 资源释放放在哪里;
  • 是否需要补偿或清理半成品。

面试中要强调:装饰器不是随便加 before/after,异常路径才是检验工程质量的关键。

完整版教学

一、为什么装饰器特别容易改变异常语义

装饰器包住目标对象后,可以捕获目标对象抛出的异常。捕获异常本身没问题,问题是捕获后怎么处理。如果装饰器把异常吞掉并返回默认值,调用方看到的语义就和目标对象原本语义不同。

public String read() {
    try {
        return target.read();
    } catch (IOException e) {
        return ""; // 危险:调用方可能以为读到了空内容
    }
}

这段代码看似增强了“容错”,但它把读取失败和读取到空字符串混为一谈。调用方无法区分真实空内容和异常失败。除非接口契约明确允许这种降级,否则装饰器不应该随意吞异常。

记忆钩子:装饰器可以加行为,但不能偷偷改契约。

二、异常处理的三种策略

装饰器处理异常通常有三种策略:透传、转换、降级。透传是保持原异常继续往外抛;转换是把底层异常包装成领域异常;降级是返回默认结果或备用结果。

策略含义适合场景
透传原样或近似原样抛出日志、指标、简单增强
转换包装成更上层异常适配底层库、统一错误模型
降级返回备用结果明确允许降级的读场景

转换异常时要保留 cause,不要丢失原始堆栈:

catch (IOException e) {
    throw new StorageException("read failed", e);
}

降级要非常谨慎,尤其不能对写操作随意降级。写订单失败不能返回成功,扣款失败不能返回已支付。

三、调用前副作用失败怎么办

装饰器常在调用前做副作用,例如权限检查、限流扣令牌、打开资源、创建临时文件、记录开始事件。这些动作失败时,要决定是否继续调用目标对象。

调用前动作:
  权限校验失败 -> 不应继续
  限流失败 -> 不应继续
  指标开始记录失败 -> 通常不应影响主流程
  打开输出流失败 -> 不能继续写入

不同副作用的关键性不同。权限、限流、资源准备属于前置条件,失败就不能继续。指标、非关键日志属于旁路能力,失败通常只记录内部告警,不应影响主流程。

这个判断要写进装饰器契约,否则不同开发者会用不同方式处理异常,系统行为会不一致。

四、调用后副作用失败怎么办

调用后副作用更微妙。比如目标方法成功了,但装饰器写审计日志失败,是否要让整个调用失败?目标方法成功写库后,缓存更新失败,是否要抛异常?这取决于副作用是否属于业务成功的一部分。

目标方法成功:
  写审计日志失败 -> 有些合规场景必须失败,有些业务可异步补偿
  更新缓存失败 -> 通常不应让主写入回滚,但要保证后续缓存一致性
  发送通知失败 -> 常用消息表或重试补偿

不能简单说 after 失败就吞掉或抛出。要按业务关键性分级。缓存更新失败通常可通过删除缓存、延迟重试、消息补偿解决;合规审计失败可能必须阻断主流程。

五、资源释放必须覆盖成功和失败路径

装饰器常用于资源增强,例如缓冲流、压缩流、加密流。这类装饰器必须正确释放资源。Java IO 装饰器中,关闭外层流通常会级联关闭内层流,但自定义装饰器不能默认正确。

public void write(String data) throws IOException {
    TempFile temp = createTempFile();
    try {
        String compressed = compress(data);
        target.write(compressed);
        temp.commit();
    } catch (IOException e) {
        temp.rollback();
        throw e;
    } finally {
        temp.close();
    }
}

如果没有 finally,压缩失败、写入失败、提交失败都可能留下临时文件或未关闭句柄。资源泄漏在低流量下不明显,在高并发下会变成文件句柄耗尽、内存增长、连接池耗尽。

六、用数字例子看资源泄漏的危害

假设一个装饰器每次失败泄漏 1 个文件句柄,系统每分钟有 20 次失败。1 小时泄漏:

20 * 60 = 1200 个文件句柄

如果系统文件句柄上限是 4096,几个小时就可能耗尽。此时表面错误可能是“无法打开文件”或“连接失败”,根因却是装饰器异常路径没有释放资源。

类似地,如果每次失败泄漏 256 KB 缓冲区,10000 次失败就是:

256 KB * 10000 = 2,560,000 KB ≈ 2.44 GB

异常路径不是边角料,它常常决定系统是否能长期稳定运行。

七、装饰器和重试副作用的边界

重试装饰器尤其需要关注副作用。如果目标操作是读操作,重试通常比较安全;如果目标操作是写操作,必须配合幂等键、结果查询或事务机制。

安全倾向较高:
  查询库存
  拉取配置
  读取缓存

风险较高:
  扣款
  创建订单
  发放优惠券
  发送短信

对写操作做重试时,装饰器不能只知道“异常了再来一次”,还要知道异常是否发生在服务端已执行之后。网络超时不代表服务端没成功,重复提交可能造成双写。

八、常见误区与追问

  • 误区:装饰器捕获异常后返回默认值就是增强健壮性。 如果接口契约没有允许降级,这会掩盖真实失败。
  • 误区:after 逻辑失败不重要。 审计、缓存、通知等后置副作用是否关键,要按业务语义判断。
  • 误区:资源释放交给底层对象就行。 自定义装饰器创建的临时资源必须自己负责清理。
  • 追问:日志装饰器异常该不该影响主流程? 普通日志通常不影响,合规审计日志可能必须影响,要看契约。
  • 追问:缓存更新失败怎么办? 常见策略是删除缓存、异步补偿、短 TTL 或重试,不应悄悄留下错误缓存。
  • 追问:重试装饰器为什么危险? 对非幂等写操作,重试可能重复执行副作用。
  • 追问:异常转换要注意什么? 保留原始 cause 和上下文,避免丢失排查信息。

九、加强记忆

装饰器异常治理记住“契约透明、资源必清、副作用分级”。能透传就透传,需要转换就保留 cause,允许降级才降级;凡是创建了资源,就在失败路径清理;凡是做了副作用,就明确它失败时是否影响主流程。