extra字段超长截断-码点陷阱

Java字符串截断的隐藏陷阱:你用的substring可能切碎了Emoji

问题背景

项目中有一个消息投递履历的异步消费逻辑,需要将消息的 extra 扩展字段保存到数据库,数据库字段定义为 VARCHAR(500)。为了防止超长字符串写入数据库报错,需要在入库前对 extra 做截断处理。

项目中的其他字段截断逻辑一直是这么写的:

// bizNo 截断
if (deliveryHistory.getBizNo().length() > 32) {
    deliveryHistory.setBizNo(deliveryHistory.getBizNo().substring(0, 32));
}

// target 截断
if (StringUtils.isNotEmpty(content.getTarget()) && content.getTarget().length() > 64) {
    deliveryHistory.setTarget(content.getTarget().substring(0, 64));
}

看起来没什么问题,length() 判断长度,substring(0, n) 截断,简单直接。

于是在给 extra 加截断逻辑时,我一开始也打算沿用同样的写法:

String extra = deliveryHistory.getExtra();
if (StringUtils.isNotEmpty(extra) && extra.length() > 500) {
    deliveryHistory.setExtra(extra.substring(0, 500));
}

等等——extra 是一个扩展字段,业务方可以往里面塞各种内容,包括用户昵称、备注信息,其中很可能包含 Emoji 表情。而 Java 的 String.length() 返回的是 char 的数量,不是字符的数量。这就有坑了。

陷阱揭秘:char 与 Code Point

Java 内部使用 UTF-16 编码存储字符串。对于 BMP(Basic Multilingual Plane,基本多文种平面)内的字符,即码点值 ≤ 0xFFFF 的字符,一个 char 就能表示;但对于 BMP 之外的字符——比如大部分 Emoji 表情、生僻汉字——它们的码点值 > 0xFFFF,在 UTF-16 中需要用一对 char(即 surrogate pair,代理对)来表示。

来个直观的例子:

String emoji = "";
System.out.println(emoji.length());        // 输出: 2
System.out.println(emoji.codePointCount(0, emoji.length())); // 输出: 1

一个 Emoji 表情 length() 返回 2,因为它由两个 char(代理对)组成;但 codePointCount() 返回 1,因为它只是一个 Unicode 码点。

这意味着什么?如果我们用 substring(0, 500) 来截断,当第 500 个 char 恰好落在一个代理对的中间时:

String text = "Hello" + "".repeat(100); // 大量 Emoji
String truncated = text.substring(0, 500);
// 如果截断位置刚好切在代理对中间,结果就是:
// 1. 末尾出现一个孤立的 high surrogate(高代理项)
// 2. 这个 surrogate 无法还原成任何有效字符
// 3. 写入数据库或展示时可能出现乱码、问号、甚至异常

更严重的是,如果数据库使用的是按字符计数而不是按字节计数的 VARCHAR 语义,那么 substring(0, 500) 截出来的 500 个 char,实际可能只有 400 多个”真正的字符”,既没有充分利用字段长度,又可能在末尾留下乱码。

正确姿势:基于码点截断

Java 提供了基于码点(Code Point)的 API,可以安全地按”真正的字符”来截断字符串:

String extra = deliveryHistory.getExtra();
if (StringUtils.isNotEmpty(extra)) {
    int maxChars = 500;
    int codePointCount = extra.codePointCount(0, extra.length());
    if (codePointCount > maxChars) {
        int truncateIndex = extra.offsetByCodePoints(0, maxChars);
        String truncated = extra.substring(0, truncateIndex);
        deliveryHistory.setExtra(truncated);
        log.warn("Extra truncated from {} code points to {}", codePointCount, maxChars);
    }
}

逐行解释:

方法 作用
extra.codePointCount(0, extra.length()) 计算字符串中的码点数量,即”真正的字符个数”
extra.offsetByCodePoints(0, maxChars) 从位置 0 开始,向后偏移 maxChars 个码点,返回对应的 char 索引
extra.substring(0, truncateIndex) char 索引截断,因为 truncateIndex 是码点边界,所以不会切断代理对

关键在于 offsetByCodePoints——它会自动跳过代理对,确保返回的索引落在码点边界上,不会把一个 Emoji 切成两半。

对比两种截断方式

用一个包含 Emoji 的字符串来做对比:

String text = "Hi你好";

//  按 char 截断
text.substring(0, 3);   // "Hi\ufffd" — Emoji 被切成了半个,末尾是乱码

//  按码点截断
int idx = text.offsetByCodePoints(0, 3);
text.substring(0, idx);  // "Hi" — 完整保留了 Emoji

再来看一个更极端的场景:

String text = "A" + "‍‍‍" + "B"; // 家庭 Emoji,由多个码点组成(ZWJ 序列)
System.out.println(text.length());        // 11(7个char用于ZWJ序列 + 1个A + 1个B + surrogate...)
System.out.println(text.codePointCount(0, text.length())); // 取决于具体组合

注意:ZWJ(Zero Width Joiner)序列是另一个更复杂的话题,多个码点组合显示为一个 Emoji。基于码点的截断能保证不切断代理对,但可能会切断 ZWJ 序列导致 Emoji 显示异常。不过对于 “截断超长字符串入库” 这个场景,码点级别的安全已经足够——至少不会产生无效的 UTF-16 字符串。

回顾项目中其他字段的截断

既然知道了 substringchar 截断的风险,那项目中其他字段的截断是否也需要改?不一定:

字段 内容 是否含 Emoji 截断方式 是否安全
bizNo 业务流水号,纯字母数字 不可能 substring(0, 32) 安全
target 投递目标,手机号/邮箱等 不可能 substring(0, 64) 安全
content 消息内容 可能含 Emoji substring(0, 200) ️ 有风险
extra 扩展字段,JSON 可能含 Emoji 码点截断 安全

对于内容完全可控、不可能出现非 BMP 字符的字段,用 substring 没有问题。但对于可能包含 Emoji、生僻字等内容的字段,基于码点的截断才是正确选择。

总结

String.length() 返回的是 char 的数量,不是”字符”的数量。当字符串包含 Emoji、生僻汉字等 BMP 外字符时,length()substring() 都可能产生意料之外的结果。按码点截断才是安全的做法。这不是什么高深的知识点,但恰恰是这种”低级”的陷阱,最容易在 Code Review 中被忽略。


核心 API 速查:

// 获取码点数量
str.codePointCount(0, str.length())

// 按码点偏移获取 char 索引
str.offsetByCodePoints(0, n)

// 安全截断
str.substring(0, str.offsetByCodePoints(0, maxCodePoints))

文章摘自:https://www.cnblogs.com/imadc/p/20049914