java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > ThreadLocal和Jackson Serializer的正确写法

别把 ThreadLocal 写进 Jackson Serializer(最新推荐)

作者:Knight_AL

本文剖析了在 Spring Boot 多租户场景下,将 ThreadLocal数据误存入Jackson Serializer导致的租户上下文污染问题,文章指出正确做法是在序列化执行时动态读取 ThreadLocal,而非初始化时捕获,强调短生命周期数据不可注入长生命周期对,感兴趣的朋友跟随小编一起看看吧

最近排查一个 SaaS 多租户问题:

同一笔金额,租户 A 显示正确,租户 B 却用了 A 的配置。

最后发现,问题不是 ThreadLocal,而是:

把 ThreadLocal 里的临时数据,保存到了 Jackson Serializer 里。

一、先看一个简单例子

假设有两个租户:

租户小数位
A0 位
B2 位

同一笔金额:

43.89

正常应该是:

A → 44
B → 43.89

我们用 ThreadLocal 保存当前租户的小数位:

public final class TenantContext {
    private static final ThreadLocal<Integer> SCALE =
            new ThreadLocal<>();
    public static void setScale(int scale) {
        SCALE.set(scale);
    }
    public static int getScale() {
        return SCALE.get();
    }
    public static void clear() {
        SCALE.remove();
    }
}

请求结束后清理:

TenantContext.setScale(scale);
try {
    mapper.writeValueAsString(invoice);
} finally {
    TenantContext.clear();
}

这个做法本身没问题。

二、问题出在 Serializer

我们写了一个 Serializer:

public class BakedScaleSerializer
        extends JsonSerializer<Amount>
        implements ContextualSerializer {
    private final Integer scale;
    public BakedScaleSerializer() {
        this(null);
    }
    private BakedScaleSerializer(Integer scale) {
        this.scale = scale;
    }
    @Override
    public JsonSerializer<?> createContextual(
            SerializerProvider prov,
            BeanProperty property) {
        int current = TenantContext.getScale();
        return new BakedScaleSerializer(current);
    }
    @Override
    public void serialize(
            Amount value,
            JsonGenerator gen,
            SerializerProvider serializers)
            throws IOException {
        BigDecimal result = value.value.setScale(scale,RoundingMode.HALF_UP);
        gen.writeNumber(result);
    }
}

看起来很合理:

ThreadLocal
   ↓
获取 scale
   ↓
保存到 Serializer

但是这里埋了一个坑。

三、为什么会出问题?

因为:

Jackson 会缓存并复用 Serializer。

第一次租户 A 来:

A → scale=0

Jackson 创建 Serializer:

Serializer
scale=0

然后:

43.89 → 44

A 请求结束:

TenantContext.clear();

但是注意:

ThreadLocal → 清掉了
Serializer → 还在

Serializer 里面的:

scale=0

并没有消失。

四、租户 B 再来

B:

scale=2

设置:

TenantContext.setScale(2);

然后序列化。

我们可能以为 Jackson 会重新创建 Serializer:

scale=2

实际上它发现已经有 Serializer 了:

Serializer@1859039536

直接复用。

于是 B 使用的还是:

scale=0

最终:

B:
43.89 → 44

但 B 明明应该得到:

43.89

五、这就是「租户上下文污染」

整个过程其实非常简单:

租户 A
ThreadLocal = 0
     ↓
复制到 Serializer
     ↓
Serializer 被缓存
     ↓
A 请求结束
ThreadLocal.clear()
     ↓
Serializer 里的 0 还在
     ↓
租户 B
ThreadLocal = 2
     ↓
继续复用旧 Serializer
     ↓
仍然使用 0

所以问题不是:

ThreadLocal 不能用。

而是:

不能把当前请求的数据保存到一个可能跨请求复用的对象里。

六、正确写法

不要让 Serializer 保存 scale。

每次真正序列化的时候,再去读取当前租户配置:

public class LiveScaleSerializer
        extends JsonSerializer<Amount> {
    @Override
    public void serialize(
            Amount value,
            JsonGenerator gen,
            SerializerProvider serializers)
            throws IOException {
        int scale = TenantContext.getScale();
        BigDecimal result =
                value.value.setScale(
                        scale,
                        RoundingMode.HALF_UP
                );
        gen.writeNumber(result);
    }
}

区别就在这里。

错误:

createContextual()
      ↓
读取 ThreadLocal
      ↓
保存到 Serializer

正确:

serialize()
      ↓
读取 ThreadLocal
      ↓
马上使用

七、这样即使 Serializer 被复用,也没问题

现在还是同一个 Serializer:

Serializer@1582785598

A 来:

ThreadLocal = 0
      ↓
serialize()
      ↓
读取 0
      ↓
43.89 → 44

B 来:

ThreadLocal = 2
      ↓
serialize()
      ↓
读取 2
      ↓
43.89 → 43.89

Serializer 自己没有保存任何租户信息,所以怎么复用都没关系。

八、记住一个原则

以后看到这种代码:

ThreadLocal
     ↓
读取
     ↓
写进单例 / Cache / Serializer

就要警惕。

因为:

ThreadLocal
= 当前请求的临时数据
Serializer
= Jackson 可能长期复用的对象

生命周期不一样。

最简单的记忆方式:

临时数据放 ThreadLocal,长期复用的对象不要记住临时数据。

所以这类代码:

private Integer scale;
private String tenantId;
private String currentUser;

如果这些值是「每个请求都可能不一样」的,就要特别小心。

九、总结

这个 Bug 可以用一句话概括:

A 租户的配置,被保存进了 Jackson 的 Serializer,而 Serializer 又被 B 租户复用了。

错误:

ThreadLocal → Serializer 成员变量

正确:

Serializer → serialize() 时读取 ThreadLocal

再记住两个生命周期:

ThreadLocal
当前请求
Serializer
可能跨请求复用

不要让短生命周期的数据,进入长生命周期对象。

这不只是 Jackson 的问题。

以后遇到:

ThreadLocal + 单例
ThreadLocal + Cache
ThreadLocal + Serializer
ThreadLocal + 复用对象

都应该检查一下:

有没有把上一个请求的数据,带到了下一个请求?

十、测试代码

package com.example.aichat.demo;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 演示「别把 ThreadLocal 写进 Serializer」。
 *
 * 在 IDE 里直接运行本类的 {@link #main(String[])}。
 * 同一把 ObjectMapper(Spring 里通常是单例)连续序列化两个租户的单据。
 */
public class TenantSerializerDemo {
    private static final BigDecimal RAW = new BigDecimal("43.89");
    public static void main(String[] args) throws Exception {
        System.out.println("同一笔金额: " + RAW);
        System.out.println("租户 A 期望保留 0 位 -> 44");
        System.out.println("租户 B 期望保留 2 位 -> 43.89");
        System.out.println();
        runBakedIntoSerializer();
        System.out.println();
        runReadThreadLocalEachTime();
    }
    /**
     * 错误写法:createContextual 把当时的 scale 拷进 Serializer 字段。
     * Jackson 构建 Invoice 的 BeanSerializer 时调用一次,然后长期缓存。
     */
    private static void runBakedIntoSerializer() throws Exception {
        System.out.println("========== 错误写法:scale 存进 Serializer ==========");
        BakedScaleSerializer.CONTEXTUAL_CALLS.set(0);
        ObjectMapper mapper = new ObjectMapper();
        printRound(mapper, "A", 0, new InvoiceBaked(new Amount(RAW)), "44");
        printRound(mapper, "B", 2, new InvoiceBaked(new Amount(RAW)), "43.89");
        System.out.println("createContextual 总共被调用 "
                + BakedScaleSerializer.CONTEXTUAL_CALLS.get()
                + " 次(第二次请求没有新建 Serializer)");
    }
    /**
     * 正确写法:Serializer 不保存租户配置,serialize 时从 ThreadLocal 现取。
     */
    private static void runReadThreadLocalEachTime() throws Exception {
        System.out.println("========== 正确写法:serialize 时现取 ThreadLocal ==========");
        ObjectMapper mapper = new ObjectMapper();
        printRound(mapper, "A", 0, new InvoiceLive(new Amount(RAW)), "44");
        printRound(mapper, "B", 2, new InvoiceLive(new Amount(RAW)), "43.89");
    }
    private static void printRound(ObjectMapper mapper, String tenant, int scale, Object invoice, String expected)
            throws Exception {
        System.out.println("租户 " + tenant + " 请求开始, TenantContext.setScale(" + scale + ")");
        TenantContext.setScale(scale);
        try {
            String json = mapper.writeValueAsString(invoice);
            String actual = extractAmount(json);
            System.out.println("  期望: " + expected);
            System.out.println("  实际: " + actual + "    json=" + json);
            if (!expected.equals(actual)) {
                System.out.println("  --> 租户上下文被污染:这个 Serializer 还留着上一个租户的 scale");
            }
        } finally {
            TenantContext.clear();
            System.out.println("租户 " + tenant + " 请求结束, TenantContext.clear()");
        }
    }
    private static String extractAmount(String json) {
        int colon = json.indexOf(':');
        int end = json.indexOf('}');
        return json.substring(colon + 1, end).trim();
    }
    /** 当前请求的临时配置。请求结束必须 clear。 */
    static final class TenantContext {
        private static final ThreadLocal<Integer> SCALE = new ThreadLocal<>();
        static void setScale(int scale) {
            SCALE.set(scale);
        }
        static int getScale() {
            Integer scale = SCALE.get();
            if (scale == null) {
                throw new IllegalStateException("当前线程没有租户 scale");
            }
            return scale;
        }
        static void clear() {
            SCALE.remove();
        }
    }
    static final class Amount {
        public final BigDecimal value;
        Amount(BigDecimal value) {
            this.value = value;
        }
    }
    /** 金额是单据上的字段。Jackson 缓存的是整份 Invoice 的序列化器。 */
    static final class InvoiceBaked {
        @JsonSerialize(using = BakedScaleSerializer.class)
        public Amount amount;
        InvoiceBaked(Amount amount) {
            this.amount = amount;
        }
    }
    static final class InvoiceLive {
        @JsonSerialize(using = LiveScaleSerializer.class)
        public Amount amount;
        InvoiceLive(Amount amount) {
            this.amount = amount;
        }
    }
    /**
     * 危险:把 ThreadLocal 复制到自己的成员变量。这个实例会被 Jackson 缓存复用。
     */
    public static final class BakedScaleSerializer extends JsonSerializer<Amount> implements ContextualSerializer {
        static final AtomicInteger CONTEXTUAL_CALLS = new AtomicInteger();
        private final Integer scale;
        public BakedScaleSerializer() {
            this(null);
        }
        private BakedScaleSerializer(Integer scale) {
            this.scale = scale;
        }
        @Override
        public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
            int call = CONTEXTUAL_CALLS.incrementAndGet();
            int current = TenantContext.getScale();
            BakedScaleSerializer baked = new BakedScaleSerializer(current);
            System.out.println("  [Jackson] createContextual 第 " + call + " 次, ThreadLocal.scale="
                    + current + ", 写入 Serializer@" + System.identityHashCode(baked));
            return baked;
        }
        @Override
        public void serialize(Amount value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            int digits = scale == null ? 2 : scale;
            BigDecimal rounded = value.value.setScale(digits, RoundingMode.HALF_UP);
            System.out.println("  [Serializer@" + System.identityHashCode(this)
                    + "] 使用成员变量 scale=" + digits + ", 金额 " + value.value + " -> " + rounded.toPlainString());
            gen.writeNumber(rounded);
        }
    }
    /**
     * 安全:不保存 scale。每次序列化都读当前线程的 ThreadLocal。
     */
    public static final class LiveScaleSerializer extends JsonSerializer<Amount> {
        @Override
        public void serialize(Amount value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            int digits = TenantContext.getScale();
            BigDecimal rounded = value.value.setScale(digits, RoundingMode.HALF_UP);
            System.out.println("  [Serializer@" + System.identityHashCode(this)
                    + "] 从 ThreadLocal 现取 scale=" + digits + ", 金额 " + value.value + " -> " + rounded.toPlainString());
            gen.writeNumber(rounded);
        }
    }
}

到此这篇关于别把 ThreadLocal 写进 Jackson Serializer(最新推荐)的文章就介绍到这了,更多相关 ThreadLocal和Jackson Serializer的正确写法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文