
做 SaaS 出海这几年,国际化早就不是往 resources 目录下扔几个 messages_zh_CN.properties 就能打发的事。早期单租户项目跑着还行,一旦租户量上来、业务线拆分、文案变更频繁,静态资源文件的短板就全暴露了。
最要命的是发布节奏被文案绑架。运营修个错别字、加个新语种,开发就得改文件、走 CI/CD、重启服务。遇到几十种语言、几百个租户,维护成本直接呈指数级膨胀。更麻烦的是多租户定制需求,金融租户和电商租户对同一个 order.status.pending 的文案偏好完全不同,硬塞在同一个文件里最后只能是一团乱麻。再加上传统 ResourceBundle 对复数变体、占位符格式化支持有限,前后端各自拼接字符串,线上经常冒出 user.profile.name 天未登录 这种语义割裂的提示。运维侧更头疼,翻译质量没版本追溯,缺文案直接透出 key,前端体验直接垮掉。
说到底,我们需要的不是个简单的翻译工具,而是一套能按租户隔离、支持热更新、前后端契约清晰的动态基础设施。下面这套方案是在生产环境里趟过几轮坑后沉淀下来的,重点解决热加载一致性、多租户路由和前后端协同这几个硬骨头。
存储选型与多租户隔离策略
早期我们也想过用静态文件配合 Nacos 做配置推送,但实际压测下来,静态文件在多租户场景下根本撑不住。每次更新都要全量替换 JVM 内存里的缓存,不仅占用大,还容易引发短暂的 GC 停顿。换成动态存储(DB 或配置中心)配合本地多级缓存,是目前性价比最高的路线。
动态方案的优势很明显:文案变更走配置中心或管理后台,推送到各节点后毫秒级感知;多租户隔离靠数据库加复合索引就能轻松搞定,不用维护成百上千个文件;版本回滚、灰度发布、操作审计这些运维刚需也能顺带补齐。代价是网络 IO 和缓存一致性问题,但只要在应用层做好本地缓存降级和异步刷新,这部分开销完全在可控范围内。
多租户隔离我们不走物理分库分表那套,太笨重。逻辑隔离加缓存路由更轻量:
表结构就一张 i18n_resource,核心字段 tenant_id、locale、msg_code、msg_content、version。查询链路设计成漏斗式:优先查租户定制文案,没有就 fallback 到租户所属行业的默认文案,再没有走平台全局文案,最后兜底英文或返回原始 key。数据库层面直接上唯一索引 uk_tenant_locale_code(tenant_id, locale, msg_code),查询基本走索引覆盖。
租户上下文透传这块,网关统一从 JWT 或 Header 解析 X-Tenant-ID,塞到 ThreadLocal(建议封装成 TenantContextHolder)。i18n 组件每次查缓存直接读上下文,业务代码完全无感。
Spring Boot 核心定制
Spring Boot 默认的 MessageSourceAutoConfiguration 绑死了 ResourceBundleMessageSource,我们需要把它替换掉。自定义实现不用搞得太复杂,继承 AbstractMessageSource 足够,重点重写区域解析和热加载的衔接。
替换 MessageSource
java
public class DynamicMessageSource extends AbstractMessageSource {
private final I18nCacheService cacheService;
// 本地缓存已解析的 MessageFormat,避免运行时反复 parse 带来的性能损耗
private final ConcurrentMap<String, MessageFormat> formatCache = new ConcurrentHashMap<>();
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
String tenantId = TenantContextHolder.getTenantId();
String content = cacheService.getMessage(tenantId, locale, code);
if (content == null) {
return null; // 返回 null 触发 Spring 内置的 parent/fallback 机制
}
String cacheKey = tenantId + ":" + locale.toLanguageTag() + ":" + code;
return formatCache.computeIfAbsent(cacheKey, k -> new MessageFormat(content, locale));
}
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
// 无占位符的文案直接返回字符串,跳过 MessageFormat 解析
return cacheService.getMessage(TenantContextHolder.getTenantId(), locale, code);
}
// 热刷新时需要清空此缓存
public void clearFormatCache() {
formatCache.clear();
}
}
配置类里用 @Bean @Primary 替换默认的 MessageSource,Spring MVC 的 @MessageSource 注入就会自动走我们这套逻辑。
区域解析链(LocaleResolver)
默认的解析器不够灵活,业务端经常需要按 Header、Cookie、URL 参数、浏览器 Accept-Language 的优先级来定语言。自定义一个 PriorityLocaleResolver 就能搞定:
java
@Component
public class PriorityLocaleResolver implements LocaleResolver {
private final Locale defaultLocale = Locale.US;
@Override
public Locale resolveLocale(HttpServletRequest request) {
// 1. 业务约定 Header
String header = request.getHeader("X-Preferred-Language");
if (StringUtils.hasText(header)) return parseLocale(header);
// 2. Cookie 降级
Cookie[] cookies = request.getCookies();
if (cookies != null) {
Optional<Cookie> opt = Arrays.stream(cookies)
.filter(c -> "lang".equalsIgnoreCase(c.getName())).findFirst();
if (opt.isPresent()) return parseLocale(opt.get().getValue());
}
// 3. URL 参数
String param = request.getParameter("lang");
if (StringUtils.hasText(param)) return parseLocale(param);
// 4. 浏览器默认
return request.getLocale() != null ? request.getLocale() : defaultLocale;
}
private Locale parseLocale(String lang) {
try {
return Locale.forLanguageTag(lang.replace("_", "-"));
} catch (Exception e) {
return defaultLocale;
}
}
@Override
public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
// SaaS 场景一般禁止服务端随意改写,由网关或客户端控制
}
}
注意 Locale.forLanguageTag 对 zh_CN 这种下划线格式不友好,顺手做个替换。这套解析链对移动端、Web 端、API 网关都通用,业务层不用掺和。
零停机热加载方案
热加载最怕两件事:一是刷新期间读线程阻塞,TP99 飙升;二是新旧数据交替时出现脏读或空指针。生产环境里别用 ReentrantReadWriteLock 包整个缓存,i18n 是典型的读多写少场景,写锁竞争会把读线程全堵死。
我们走 AtomicReference + 事件驱动的无锁替换路线。
数据流与实现
java
// 事件定义
public class I18nRefreshEvent extends ApplicationEvent {
public I18nRefreshEvent(Object source) { super(source); }
}
@Service
public class I18nRefreshListener implements ApplicationListener<I18nRefreshEvent> {
// 核心引用:读操作永远指向一个完整的、不可变的缓存实例
private final AtomicReference<Map<String, ConcurrentHashMap<String, String>>> cacheRef
= new AtomicReference<>(new ConcurrentHashMap<>());
private final DynamicMessageSource dynamicMessageSource;
@Override
public void onApplicationEvent(I18nRefreshEvent event) {
// 1. 在后台线程构建新缓存,不阻塞业务读
Map<String, ConcurrentHashMap<String, String>> newCache = loadFromStorage();
// 2. 原子替换,O(1) 操作。读线程最多在切换瞬间看到一次旧版本
cacheRef.getAndSet(newCache);
// 3. 清理 Spring MessageFormat 缓存,确保下次请求使用新文案
dynamicMessageSource.clearFormatCache();
}
private Map<String, ConcurrentHashMap<String, String>> loadFromStorage() {
// 实际逻辑:分批拉取 DB/Nacos 数据,按 tenant:locale 分组
// 此处省略具体查询逻辑
return new ConcurrentHashMap<>();
}
public String get(String tenantId, String locale, String code) {
String langKey = tenantId + "_" + locale.toLanguageTag();
Map<String, ConcurrentHashMap<String, String>> currentCache = cacheRef.get();
ConcurrentHashMap<String, String> targetMap = currentCache.get(langKey);
if (targetMap == null) {
// fallback 到全局语言包
targetMap = currentCache.get("GLOBAL_" + locale.toLanguageTag());
}
return targetMap != null ? targetMap.get(code) : null;
}
}
为什么这样写稳?
读操作直接 cacheRef.get(),拿到的永远是一个完整的 Map 引用。写操作在内存里拼好新 Map 后一次性替换。中间没有锁,没有分段拷贝,切换瞬间读线程要么拿到旧包,要么拿到新包,不会出现 ConcurrentModificationException 或读到半截数据。i18n 场景最终一致性即可,几百毫秒的差异业务完全无感。
配置中心(Nacos/Apollo)监听到变更,直接 publish 一个 ApplicationEvent 就行。Spring 的异步事件监听器默认同步执行,建议配合 @Async 或自定义 TaskExecutor 把拉库操作丢到后台线程池,避免阻塞事件派发主线程。
前后端协同与兜底契约
后端 i18n 如果只停留在 MessageSource 层面,前端照样得硬拼字符串。生产环境里,我们要求把翻译字典直接打在响应体里,让前端按需挂载。
网关层或全局 ResponseBodyAdvice 统一拦截:
json
{
"code": 200,
"data": { "userId": "1001", "status": "PENDING" },
"i18n": {
"zh-CN": {
"common.save": "保存",
"order.status.pending": "待处理 (剩余 {0} 分钟)"
}
},
"meta": { "locale": "zh-CN", "fallback": ["en-US"] }
}
前端拿到后直接塞进 Vue/React 的全局状态或 Provider 里。首屏加载时只拉当前语言包,路由切换时按需增量更新。网络开销压到最低,离线场景也能靠 ServiceWorker 兜底。
占位符与复数规范
别自己搞字符串拼接。统一走 ICU MessageFormat 标准:
- 位置参数:
{0},{1} - 日期/数字格式化:
{0, date, short} - 复数变体:
{count, plural, one{# 条数据} other{# 条数据}}
Java 侧 MessageFormat 原生支持基础语法,复杂复数建议引入 com.ibm.icu:icu4j。前后端必须对齐语法,否则解析结果对不上。
Fallback 与容错
缓存没命中时,链路必须按顺序降级:租户指定语言 → 租户默认语言 → 全局指定语言 → 全局默认语言 → 原始 key。绝对不允许返回 null 或空串,前端拿 null 渲染组件会直接白屏或布局塌陷。关键业务文案(如支付状态、合同条款)缺失时,同步打日志并触发告警,运营侧能及时介入。
生产避坑清单
这套东西跑线上大半年,踩过不少暗礁。挑几个高频的写出来,能省不少排查时间。
1. MessageFormat 的单引号陷阱
很多运营同学在后台录入文案时喜欢打英文单引号,比如 Let's go。在 MessageFormat 里,单引号是转义字符。不处理的话,解析器会把 s 当作占位符直接吞掉,或者抛 IllegalArgumentException。录入侧必须加过滤:要么强制转义成 Let''s go,要么在 resolveCode 里包一层 content.replace("'", "''")。嫌麻烦就统一上 ICU4j 的严格模式,报错比线上炸锅好。
2. 字符集与排序规则
数据库表必须用 utf8mb4,排序规则选 utf8mb4_0900_ai_ci(MySQL 8.0+)。否则遇到 emoji、阿拉伯语 RTL 排版、或者带音调的欧洲语言,插入直接截断。JDBC 连接串别忘了加 characterEncoding=utf8&useUnicode=true,Spring Boot 的 server.servlet.encoding.charset 也检查一遍,别留坑。
3. 内存治理与缓存淘汰
别把所有翻译全塞进 JVM。500 租户 × 20 语言 × 3000 词条,纯字符串对象加 Map Entry 轻松吃掉 2GB+ 堆内存。本地缓存建议上 Caffeine:
- 设
maximumSize(50_0000),淘汰策略用默认的W-TinyLFU,命中率稳在 95% 以上。 - 启动只加载
GLOBAL基础包,租户文案走 Cache-Aside 懒加载。 - 配合 Prometheus 监控
jvm_memory_used_bytes和 Caffeine 的eviction指标,超阈值直接告警,别等 OOM 了才去抓 dump。
4. 热刷新防抖与分片
配置中心一次推送几千条变更,直接全量拉库会打满数据库连接池。拉取逻辑必须分片:按租户或语言包拆分批次,加随机抖动延迟。网关层如果接了 CDN 或边缘节点,记得设合理的 Cache-Control: max-age,源头减少回源压力。
生产环境里没有银弹,只有取舍。动态 i18n 的本质是把复杂度留在中间件和本地缓存层,把确定性交给业务。这套架构砍掉了重启发版的等待时间,文案命中率稳在 98% 以上,TP99 压在 15ms 内,堆内存峰值没超过 15%。代码层面看着不复杂,但缓存淘汰策略、单引号转义、无锁切换的边界条件,都是线上真金白银砸出来的经验。
落地时按需裁剪就行,别为了造轮子堆砌功能。先把热加载和租户隔离跑通,占位符和复数规范对齐,剩下的交给时间打磨。遇到具体卡点可以贴配置和堆栈,一起盘。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
