别把 ThreadLocal 写进 Jackson Serializer

目录

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

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

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

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


一、先看一个简单例子

假设有两个租户:

租户 小数位
A 0 位
B 2 位

同一笔金额:

text 复制代码
43.89

正常应该是:

text 复制代码
A → 44
B → 43.89

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

java 复制代码
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();
    }
}

请求结束后清理:

java 复制代码
TenantContext.setScale(scale);

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

这个做法本身没问题。


二、问题出在 Serializer

我们写了一个 Serializer:

java 复制代码
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);
    }
}

看起来很合理:

text 复制代码
ThreadLocal
   ↓
获取 scale
   ↓
保存到 Serializer

但是这里埋了一个坑。


三、为什么会出问题?

因为:

Jackson 会缓存并复用 Serializer。

第一次租户 A 来:

text 复制代码
A → scale=0

Jackson 创建 Serializer:

text 复制代码
Serializer
scale=0

然后:

text 复制代码
43.89 → 44

A 请求结束:

java 复制代码
TenantContext.clear();

但是注意:

text 复制代码
ThreadLocal → 清掉了
Serializer → 还在

Serializer 里面的:

text 复制代码
scale=0

并没有消失。


四、租户 B 再来

B:

text 复制代码
scale=2

设置:

java 复制代码
TenantContext.setScale(2);

然后序列化。

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

text 复制代码
scale=2

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

text 复制代码
Serializer@1859039536

直接复用。

于是 B 使用的还是:

text 复制代码
scale=0

最终:

text 复制代码
B:
43.89 → 44

但 B 明明应该得到:

text 复制代码
43.89

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

整个过程其实非常简单:

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

所以问题不是:

ThreadLocal 不能用。

而是:

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


六、正确写法

不要让 Serializer 保存 scale。

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

java 复制代码
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);
    }
}

区别就在这里。

错误:

text 复制代码
createContextual()
      ↓
读取 ThreadLocal
      ↓
保存到 Serializer

正确:

text 复制代码
serialize()
      ↓
读取 ThreadLocal
      ↓
马上使用

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

现在还是同一个 Serializer:

text 复制代码
Serializer@1582785598

A 来:

text 复制代码
ThreadLocal = 0
      ↓
serialize()
      ↓
读取 0
      ↓
43.89 → 44

B 来:

text 复制代码
ThreadLocal = 2
      ↓
serialize()
      ↓
读取 2
      ↓
43.89 → 43.89

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


八、记住一个原则

以后看到这种代码:

text 复制代码
ThreadLocal
     ↓
读取
     ↓
写进单例 / Cache / Serializer

就要警惕。

因为:

text 复制代码
ThreadLocal
= 当前请求的临时数据

Serializer
= Jackson 可能长期复用的对象

生命周期不一样。

最简单的记忆方式:

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

所以这类代码:

java 复制代码
private Integer scale;
private String tenantId;
private String currentUser;

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


九、总结

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

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

错误:

text 复制代码
ThreadLocal → Serializer 成员变量

正确:

text 复制代码
Serializer → serialize() 时读取 ThreadLocal

再记住两个生命周期:

text 复制代码
ThreadLocal
当前请求

Serializer
可能跨请求复用

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

这不只是 Jackson 的问题。

以后遇到:

text 复制代码
ThreadLocal + 单例
ThreadLocal + Cache
ThreadLocal + Serializer
ThreadLocal + 复用对象

都应该检查一下:

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

十、测试代码

bash 复制代码
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);
        }
    }
}
相关推荐
Song_RsHt1 小时前
Gateway (上):路由与转发,微服务的统一入口
java·笔记·后端·学习·gateway
paopaokaka_luck1 小时前
校园新闻推荐系统(DeepSeek新闻助手、个性化推荐算法、 Redis热词排行榜、ECharts运营分析、互动与实时通知、新闻运营闭环)
java·spring boot·mysql·spring·tomcat·echarts·intellij idea
yuniko-n1 小时前
【JUC】线程池
java·开发语言
小天源1 小时前
智叟云集政策监测技术方案——原文解析、证据提取与可核验摘要
java·服务器·数据库
凤山老林1 小时前
从零搭建企业 OAuth2 授权中心:Spring Boot 集成 Spring Authorization Server 与
数据库·spring boot·spring
yuzhiboyouye1 小时前
我现在得后端状况以及需要做的
java
小羊没烦恼!1 小时前
在.net中读写config文件的各种方法
java·大数据·运维·服务器·前端·数据库·oracle
流烟默2 小时前
Spring Boot 异常响应被序列化为 XML 的排查与修复
xml·spring boot·后端
余额瞒着我当琳2 小时前
哈希表底层原理解析,直接定址法,哈希冲突,负载因子,哈希函数,开放定址法
java·c++·哈希算法·散列表