← 返回题目列表

装饰器可以有状态吗?如何处理线程安全和幂等性?

高频 困难 第 13 / 25 题 更新于 2026/08/01
装饰器模式状态管理线程安全幂等性

简化版

装饰器可以有状态,但要谨慎。无状态装饰器最安全;有状态装饰器如缓存、限流、统计、去重需要明确状态作用域、并发保护、生命周期和幂等语义。线程共享的装饰器必须避免可变状态竞争,重复装饰或重复调用也要保证结果可预期。

详细版

装饰器并不要求一定无状态。很多真实装饰器都有状态:

  • 缓存装饰器保存缓存数据;
  • 限流装饰器保存令牌桶状态;
  • 指标装饰器保存计数器;
  • 去重装饰器保存请求 ID;
  • 缓冲装饰器保存临时数据。

问题在于状态会引入复杂度。如果装饰器是单例并被多个线程共享,内部 HashMap、计数器、布尔标记都可能出现并发问题。如果装饰器被重复包裹两次,缓存、重试、加密、计数可能产生重复效果。

工程上要考虑:

  • 状态是请求级、对象级还是全局级;
  • 是否需要线程安全集合或原子变量;
  • 是否允许重复装饰;
  • 异常时状态是否回滚;
  • 同一次请求重试是否幂等;
  • 生命周期结束时是否释放资源。

面试回答要强调:装饰器模式解决能力叠加,但状态管理仍然是工程设计问题,不能因为用了模式就忽略并发和幂等。

完整版教学

一、无状态装饰器为什么最简单

无状态装饰器只在调用前后增加逻辑,不保存跨调用状态。例如日志装饰器记录方法名和耗时,权限装饰器读取当前上下文做判断,参数校验装饰器检查输入。这类装饰器通常可以安全复用。

class LoggingDataSource implements DataSource {
    private final DataSource target;

    public String read() {
        long start = System.nanoTime();
        try {
            return target.read();
        } finally {
            logCost(start);
        }
    }
}

它内部只有 target,没有每次调用会改变的共享字段。多个线程同时调用时,只要 target 本身安全,日志装饰器通常不会额外制造数据竞争。

记忆钩子:无状态装饰器像透明外套,有状态装饰器像带口袋的外套,口袋里放什么会影响并发和生命周期。

二、有状态装饰器的典型场景

缓存装饰器是最常见的有状态装饰器。它需要保存 key 到 value 的映射。限流装饰器也有状态,需要记录令牌数、最后补充时间或窗口计数。指标装饰器保存计数器和耗时统计。去重装饰器保存已处理请求 ID。

class CacheDataSource implements DataSource {
    private final DataSource target;
    private final Map<String, String> cache = new HashMap<>();

    public String read(String key) {
        return cache.computeIfAbsent(key, target::read);
    }
}

这段示例在单线程里看起来没问题,但多线程下 HashMap 不是线程安全的,computeIfAbsent 也可能因为底层集合不安全而出问题。状态一旦被多个调用共享,就必须考虑并发语义。

三、状态作用域要先定义清楚

状态可以分为请求级、对象级、全局级。请求级状态只在一次调用链中有效,例如 Trace 上下文。对象级状态属于某个装饰器实例,例如某个数据源的本地缓存。全局级状态被多个实例共享,例如全局限流器。

状态作用域示例风险
请求级当前请求耗时、TraceId泄漏到其他请求
对象级某个装饰器实例的缓存并发竞争、生命周期
全局级全局限流计数器热点竞争、误伤租户

如果状态作用域没定义清楚,就会出现奇怪问题。比如本该按用户限流,却用全局计数器限流,最终一个用户的高频请求影响所有用户。

四、线程安全要看装饰器实例是否共享

如果每次请求创建一个新的装饰器实例,内部可变字段风险较小;如果装饰器是 Spring 单例、SDK 单例或静态全局对象,就要按共享对象处理。

每请求新建:
  RequestDecorator(requestContext)
  状态只在当前请求内

全局单例:
  CacheDecorator singleton
  多线程共享 cache/map/counter

共享装饰器中的计数器可以用 AtomicLong,缓存可以用 ConcurrentHashMap,复杂状态可以加锁或交给成熟组件。不要用普通 HashMapArrayList 承载高并发共享状态。

用数字例子看:100 个线程同时对一个普通 long countcount++,理论加 100 次,实际可能少于 100,因为 count++ 包含读、加、写 3 个步骤,不是原子操作。

五、幂等性为什么容易被装饰器破坏

幂等性指同一个操作执行一次和执行多次,最终结果一致。装饰器可能让幂等性变复杂。比如重试装饰器会让目标方法执行多次,如果目标方法是扣款或发券,就可能重复执行副作用。

RetryDecorator
  第 1 次调用 target.pay() -> 超时,但服务端可能已扣款
  第 2 次重试 target.pay() -> 再次扣款

重试装饰器必须和幂等 Key、结果查询、异常分类配合。不能对所有异常无脑重试。缓存装饰器也有幂等问题:写操作如果被缓存层吞掉异常或重复写缓存,可能造成读到旧数据。

装饰器增强的是调用过程,但目标方法的副作用仍然存在。越靠近写操作,越要谨慎处理幂等。

六、重复装饰会带来什么问题

如果同一个装饰器被包两次,结果可能不是简单重复。日志装饰器重复会打印两份日志;压缩装饰器重复可能压缩两次;加密装饰器重复会导致解密顺序要求更复杂;重试装饰器重复会放大请求次数。

单层重试:
  最多 3 次

双层重试:
  外层 3 次 * 内层 3 次 = 最多 9 次

这就是为什么装饰器链治理里要声明是否允许重复。对于重试、加密、缓存这类装饰器,通常要避免无意重复。对于日志、指标这类装饰器,也要明确重复的语义。

七、异常时状态如何恢复

有状态装饰器在异常时要考虑状态回滚或清理。比如限流装饰器先占用令牌,目标方法还没执行就失败,令牌是否返还?计数器加一后目标方法失败,失败计数和成功计数怎么记?缓冲装饰器写到一半失败,临时缓冲是否清理?

public Result call(Command command) {
    acquire();
    try {
        Result result = target.call(command);
        success.incrementAndGet();
        return result;
    } catch (Exception e) {
        failure.incrementAndGet();
        throw e;
    } finally {
        cleanupRequestState();
    }
}

finally 对有状态装饰器很重要。请求级状态如果不清理,在线程池复用线程时可能污染下一次请求。ThreadLocal 型装饰器尤其要注意 remove。

八、常见误区与追问

  • 误区:装饰器最好都无状态,所以有状态就不是装饰器。 缓存、限流、统计都可以是装饰器,只是需要额外治理状态。
  • 误区:Spring 单例装饰器里放 HashMap 没问题。 单例会被多线程共享,普通 HashMap 可能产生并发问题。
  • 误区:重试装饰器只是多调用几次,不影响业务语义。 对有副作用操作,重试必须配合幂等机制。
  • 追问:装饰器状态有哪些作用域? 请求级、对象级、全局级,作用域不同,并发和生命周期策略不同。
  • 追问:如何避免重复装饰? 在组装阶段记录装饰器名称和 repeatable 元数据,禁止不可重复装饰器重复出现。
  • 追问:ThreadLocal 装饰器要注意什么? 必须在 finally 中清理,避免线程复用导致上下文泄漏。
  • 追问:缓存装饰器如何保证线程安全? 使用并发集合、锁、成熟缓存组件,并明确失效和一致性策略。

九、加强记忆

装饰器状态管理记住“先定作用域,再定并发,再定幂等”。无状态最省心,有状态也很常见;一旦装饰器保存缓存、计数、令牌、上下文,就必须补上线程安全、重复装饰、异常清理和生命周期设计。