目录
- 一、先看一个简单例子
- [二、问题出在 Serializer](#二、问题出在 Serializer)
- 三、为什么会出问题?
- [四、租户 B 再来](#四、租户 B 再来)
- 五、这就是「租户上下文污染」
- 六、正确写法
- [七、这样即使 Serializer 被复用,也没问题](#七、这样即使 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);
}
}
}
