25分钟
Lemon-Protocal,基于二进制的 RPC 交互协议设计报告
协议载体:
com.huang.buffer.util.ByteTypeUtil
配套产物:com.huang.buffer.core.MessageMetaData
反序列化入口:GenerateMessageDataHandler.channelRead → ByteTypeUtil.handleLineBytes(统一入口)
版本:2026-08-23 修订(2026-08-15 增补嵌套 LIST 支持;2026-01-31 初版实现)
一、协议概述
Lemon-Protocol 是一种基于类型标记 + 长度前缀的紧凑型二进制序列化协议,用于把一组 Map<String, Object> 参数(单条)或一组同构的行记录(多条,形如关系表的列式压缩)编码为字节流,支持跨 TCP 长连接传输,并可在环形缓冲(RingBuffer/Disruptor)内作为事件载荷零拷贝传递。
协议核心设计目标:
- 极致紧凑:头部只保留字段个数、类型表、长度表,键名只在消息尾部出现一次,避免 JSON 式重复键名带来的体积膨胀。
- 批量压缩:多行消息把"类型表/长度表/名称表"去重为一份,数据区按行列矩阵排布,适合同构批量数据。
- 类型自描述:每个字段带 1 字节类型标记,反序列化时无需外部 schema 即可还原 10 种基础类型(含嵌套 LIST 多行结果集)。
- 流式边界:每条消息以
\n结尾,可作为 Socket 流中多条消息的切分点。
二、类型系统(Type 枚举)
协议内置 10 种可传输类型 + 1 种兜底类型。标记字节为类型名首字母的 ASCII 码:
| 类型 | 标记字节 | ASCII 字符 | 值占用字节数 | 二进制编码 |
|---|---|---|---|---|
BYTE |
98 | b |
1 | 原样 1 字节 |
SHORT |
115 | s |
2 | 大端序 2 字节 |
CHAR |
99 | c |
1 | 取低 8 位(仅单字节字符) |
FLOAT |
102 | f |
4 | IEEE 754 大端序 4 字节 |
DOUBLE |
100 | d |
8 | IEEE 754 大端序 8 字节 |
INTEGER |
105 | i |
4 | 大端序 4 字节 |
LONG |
108 | l |
8 | 大端序 8 字节 |
BOOLEAN |
66 | B |
1 | 1 字节,非 0 即 true |
STRING |
83 | S |
变长(UTF-8) | UTF-8 字节序列;单行长度记录于长度表,多行内嵌 2 字节长度前缀 |
LIST |
76 | L |
变长 | 2 字节大端嵌套块长度前缀 + 递归嵌套帧(多行结果集,见 3.3) |
UNSUPPORTED |
0 | — | — | 反序列化时返回 null |
设计细节:boolean 用小写 b 会与 byte(98)冲突,因此用大写 B(66);string 用小写 s 会与 short(115)冲突,因此用大写 S(83);list 用小写 l 会与 long(108)冲突,因此用大写 L(76)。
三、消息格式
协议存在单行消息与多行消息两种变体,二者均共享"类型表 + 长度表 + 值区 + 尾部名称区"的骨架,区别仅在头部。
3.1 单行消息格式(generateBytes;解析端保留但未接入发送链路)
| 域 | 字节偏移 | 长度(字节) | 内容 / 定义 | 计算规则 |
|---|---|---|---|---|
| 字段数 N | [0] |
1 | 列数,范围 0~255;N=0 时直接返回空字节数组 |
类型区 / 长度区 / 值区的规模基准 |
| 类型区 | [1, 1+N) |
N | 每列 1 字节类型标记(见 二、类型系统) | 起点 = 1;长度 = N |
| 长度区 | [1+N, 1+3N) |
2N | 每列 2 字节大端序长度,容量 0~65535(STRING 可超 255) | 起点 = 1+N;长度 = 2N |
| 值区 | [1+3N, 1+3N+Σlen) |
Σlen | 按列顺序拼接各列值:定长类型直接复制;STRING 按长度表写 UTF-8 字节 | 起点 = 1+3N;长度 = Σlen(各列长度之和) |
| 名称区 | 值区之后 | 不定 | key1|key2|…|keyN\n(UTF-8,\n 结尾作消息终止边界) |
紧随值区;长度 = 键名总字节数 + 1 |
单条消息总长度 =
1 + N + 2N + Σlen + 名称区长度。
- 一号域(字段数 N):1 字节,取值范围 0~255,
N=0时直接返回空字节数组。 - 二号域(类型区):N 个字节,按列顺序记录每列的类型标记。
- 三号域(长度区):N×2 字节,按列顺序记录每列值的字节长度(大端序),容量 0~65535,因此字符串长度允许超过 255。
- 四号域(值区):按列顺序拼接各列值,定长类型直接复制,
STRING按记录长度写入 UTF-8 字节。 - 名称区:各列键名以
|连接,末尾追加换行符\n(作为消息终止边界)。
3.2 多行消息格式(generateLineBytes / handleLineBytes)
| 域 | 字节偏移 | 长度(字节) | 内容 / 定义 | 计算规则 |
|---|---|---|---|---|
| 一号域 lineMark | [0] |
1 | 'Y'(89)=多行,'N'(78)=单行 |
行数 >1 写 'Y',否则写 'N' |
| 二号域 lineNumber | [1, 3) |
2 | 行数 M,大端序,上限 65535;单行时被忽略 | M = list.size();解码时 'N' 按 1 行处理 |
| 三号域 列头数 N | [3] |
1 | 列数,范围 0~255 | N = 首行列数 |
| 四号域 类型区 | [4, 4+N) |
N | 每列 1 字节类型标记;仅首行写入,后续行复用 | 起点 = 4;长度 = N |
| 五号域 长度区 | [4+N, 4+3N) |
2N | 定长列写字节长度(大端);STRING/LIST 列写 0(实际长度内嵌于值区);仅首行写入 | 起点 = 4+N;长度 = 2N |
| 六号域 值区 | [4+3N, 4+3N+Σlen) |
Σlen | 行主序:先第 1 行 N 列,再第 2 行 N 列…;定长列按宽度平铺;STRING 列每行 = 2 字节大端长度前缀 + UTF-8(同列行内变长);LIST 列每行 = 2 字节大端嵌套块长度 + 嵌套帧 | 起点 = 4+3N;Σlen = Σ(所有行所有列):定长按类型宽、STRING 按 2 + 字节数、LIST 按 2 + 嵌套块字节数 |
| 名称区 | 值区之后 | 不定 | 首行键名 key1|…|keyN\n(UTF-8,\n 结尾作消息终止边界) |
紧随值区;长度 = 键名总字节数 + 1 |
多行消息总长度 =
4 + N + 2N + Σlen + 名称区长度。
- 一号域(lineMark):1 字节,
'Y'(89)表示多行、'N'(78)表示单行。 - 二号域(lineNumber):2 字节大端序行数,上限
2^16 - 1 = 65535行;单行时该域被忽略,解码时按 1 行处理。 - 三号域(parameterNumber):1 字节列头数,上限 255 列;单行/多行通用。
- 四号域(类型区):N 个字节,仅首行写入,后续行复用同一类型表。
- 五号域(长度区):N×2 字节,仅首行写入,后续行复用。定长列写入其字节长度(大端序);STRING/LIST 列长度表置 0(实际长度内嵌于值区,见下)。
- 六号域(值区):行主序(row-major)——先排第一行的 N 列,再排第二行的 N 列,以此类推。定长列按各自字节宽度平铺;STRING 列每行以 2 字节大端长度前缀开头,后跟 UTF-8 字节,因此同一 String 列在不同行允许长度不同(行内变长),单值上限 65535 字节;LIST 列每行以 2 字节大端嵌套块长度前缀开头,后跟嵌套帧字节(见 3.3)。
- 名称区:取首行键名,
|连接,以\n结尾。
3.3 嵌套块(LIST 字段,2026-08-23 新增)
LIST 列的值是一个嵌套多行结果集,在值区内按如下布局递归编码:
[2 字节大端 nestedLen][嵌套帧字节]
- 嵌套帧:递归调用
generateLineBytes(toRows(list, columnName), depth + 1, false)的完整产物,与 3.2 的多行帧同构(4 字节头 + 类型区 + 长度区 + 值区 + 名称区),但末尾不带\n——若带换行符,会被外层帧分隔符(DelimiterBasedFrameDecoder(65535, "\n"))误切成两条消息。 - 空列表:
nestedLen = 0(嵌套帧为 0 字节),解码端返回Collections.emptyList()。 - 标量列表(如
List<Integer>):自动包装为"单列多行"嵌套帧,列名沿用外层字段名,业务侧可按该列名识别。 - 深度上限:递归层级上限
MAX_NEST_DEPTH = 8(根帧 depth = 0),超限时序列化/反序列化均抛IllegalStateException,防止栈溢出与恶意报文攻击。 - 可递归嵌套:嵌套帧自身可再含 LIST 列,支持任意层级的树状 / 多级主从结果集(受 8 层上限约束)。
3.4 空消息
parameters 为空(单行)或 list 为空(多行)时,序列化直接返回 new byte[0]。
3.5 字节序与字符集
- 所有多字节数值均为大端序(Big-Endian,网络序)。
STRING与名称区一律使用 UTF-8 编码。
四、序列化过程(编码)
4.1 单行:generateBytes(Map<String, Object>)
generateBytes(map)
├─ map 为空 → 返回 new byte[0]
├─ generateParameterLength(map) → 构造头部 + 值区字节
│ ├─ N = map.size(),总长度 = 1 + N + 2N + Σ(各值长度)
│ ├─ bytes[0] = N
│ ├─ 逐列写入:类型标记(Type.xxx.type())
│ ├─ 逐列写入:长度值(2 字节大端序写入,index 每次 +2)
│ ├─ 逐列写入:值本体(Integer/Long/Double/Float/Short 用 Netty Unpooled.copyXxx 大端复制;
│ │ CHAR/BYTE/BOOLEAN 直接写 1 字节;STRING 写 UTF-8 字节)
│ └─ 返回头部+值区字节
├─ generateParameterNameBytes(map) → "key1|key2|…|keyN" + "\n" 的 UTF-8 字节
└─ Unpooled.wrappedBuffer(头部+值区, 名称区) 合并为完整消息
4.2 多行:generateLineBytes(List<Map<String, Object>>)
generateLineBytes(list, depth = 0, appendTerminator = true)
├─ depth > MAX_NEST_DEPTH(8) → 抛 IllegalStateException(深度超限)
├─ list 为空 → 返回 new byte[0]
├─ expandNestedFields(list) → ① 校验行间 schema 一致(fail-fast,见 6.7)
│ ② 逐行逐列预序列化 LIST 列 → nestedCache[r][c] = 嵌套帧字节
│ 嵌套帧 = generateLineBytes(toRows(list, columnName), depth + 1, appendTerminator = false)
│ └─ appendTerminator = false:嵌套帧末尾不带换行符,防被帧分隔符误切
├─ generateLineParameterLength(list, nestedCache) → 构造头部 + 值区字节
│ ├─ lineNumber = list.size(),N = 首行列数
│ ├─ lineMark = lineNumber > 1 ? 'Y' : 'N'
│ ├─ 总长度 = 4 + N + 2N + Σ(遍历所有行所有列):
│ │ 定长列按类型字节宽累加;STRING 列按 2 + UTF-8 字节数累加(含内嵌前缀);
│ │ LIST 列按 2 + 嵌套帧字节数累加(含内嵌前缀)
│ ├─ bytes[0] = lineMark;bytes[1..2] = lineNumber(大端序)
│ ├─ bytes[3] = N
│ ├─ 外层循环行 i,内层循环列 v:
│ │ ├─ i == 0 时写入类型表与长度表(定长列写真实长度、STRING/LIST 置 0;后续行跳过)
│ │ └─ 每行每列都写值本体:定长列同单行规则;
│ │ STRING 先写 2 字节大端长度前缀,再写 UTF-8 字节(行内变长的关键);
│ │ LIST 先写 2 字节大端嵌套帧长度,再写 nestedCache 中的嵌套帧字节
│ └─ 返回头部+值区字节
└─ 名称区取首行:generateParameterNameBytes(list.get(0), appendTerminator),合并返回
4.3 各类型值编码对照
| Java 类型 | 写入方式 | 占用 |
|---|---|---|
Integer |
Unpooled.copyInt 大端 |
4 |
Long |
Unpooled.copyLong 大端 |
8 |
Double |
Unpooled.copyDouble 大端 |
8 |
Float |
Unpooled.copyFloat 大端 |
4 |
Short |
Unpooled.copyShort 大端 |
2 |
Character |
(byte) value 取低 8 位 |
1 |
Byte |
(byte) value |
1 |
Boolean |
Unpooled.copyBoolean |
1 |
String |
UTF-8 字节序列 | 变长(≤65535) |
List<?> |
2 字节大端嵌套块长度 + 递归嵌套帧 | 变长(受帧上限约束) |
五、反序列化过程(解码)
5.1 单行:1 字节头格式解析(历史保留,未接入发送链路)
说明:单行 1 字节头格式(
generateBytes产物)的解析器仅以私有方法保留在GenerateMessageDataHandler中,当前未接入发送链路。统一入口为 5.2 的handleLineBytes——单行消息以generateLineBytes(List.of(map))发送(4 字节头), 服务端收到后按 1 行处理。
handleBytes(bytes) // 1 字节头:N + 类型区 + 长度区 + 值区 + 名称区
├─ N = bytes[0];N == 0 → 返回空列表
├─ typeIndex = 1,index = 1 + N,offset = 1 + 3N
├─ while (index ≤ 3N) 逐列:
│ ├─ length = (bytes[index] << 8) | bytes[index+1] ← 2 字节大端长度
│ ├─ type = getType(bytes[typeIndex])
│ ├─ 从 offset 拷贝 length 字节 → getValue(type, copy) 还原 Object
│ ├─ 构造 MessageMetaData(type, value);offset += length;index += 2;typeIndex += 1
├─ 尾部解析名称:byteBuf.toString(offset, 剩余, UTF-8) → split("\\|") → 逐列 setName
└─ 返回 List<MessageMetaData>(一列一个元素)
5.2 多行:handleLineBytes(byte[])(统一反序列化入口)
handleLineBytes(bytes, depth = 0)
├─ depth > MAX_NEST_DEPTH(8) → 抛 IllegalStateException(疑似恶意报文/格式损坏)
├─ bytes 为空 → 返回空集合(空嵌套块 / 空 LIST 字段)
├─ lineMark = bytes[0]
│ ├─ 'N' → lineNumber = 1(单行)
│ └─ 其他 → lineNumber = (bytes[1] << 8) | bytes[2](多行)
├─ N = bytes[3];lineNumber == 0 → 返回空列表
├─ typeIndex = 4,index = 4 + N,offset = 4 + 3N
├─ while (lineNumber > 0) 逐行:
│ ├─ while (index < 4 + N + 2N) 逐列:
│ │ ├─ type = getType(bytes[typeIndex])
│ │ ├─ STRING:长度表恒为 0 → 忽略;改从 offset 读内嵌 2 字节 strLen,
│ │ │ copy(offset+2, strLen);offset += strLen + 2
│ │ ├─ LIST:从 offset 读内嵌 2 字节 nestedLen;
│ │ │ nestedLen == 0 → 值 = 空列表(Collections.emptyList())
│ │ │ 否则切片嵌套帧字节 → 递归 handleLineBytes(nested, depth + 1)
│ │ │ 得到 List<List<MessageMetaData>> 作为该字段值;offset += nestedLen + 2
│ │ └─ 定长:length = 长度表 2 字节大端;copy(offset, length);offset += length
│ ├─ 一行结束:结果入 result;typeIndex 复位 4、index 复位 4 + N;lineNumber--
├─ 尾部解析名称区 → 每行每列 setName
└─ 返回 List<List<MessageMetaData>>(行 × 列;LIST 字段值为 List<List<MessageMetaData>>)
5.3 类型还原:getValue(Type, ByteBuf)
按类型标记分别调用 Netty ByteBuf 的读取 API:
| 类型 | 还原方式 |
|---|---|
CHAR |
getCharSequence(0, 1, UTF_8).charAt(0) |
SHORT |
getShort(0) |
FLOAT |
getFloat(0) |
DOUBLE |
getDouble(0) |
INTEGER |
getInt(0) |
LONG |
getLong(0) |
BOOLEAN |
getBoolean(0) |
STRING |
读取全部剩余字节转 UTF-8 字符串 |
| 其他 | null |
LIST不经过getValue:其值在handleLineBytes的 LIST 分支中通过递归解析嵌套帧得到(见 5.2)。
5.4 解码产物:MessageMetaData
MessageMetaData {
ByteTypeUtil.Type type; // 字段类型
Object data; // 还原后的值
String name; // 字段名(来自尾部名称区)
}
业务侧按 name 取 data 并自行强转为对应类型即可使用。当 type == LIST 时,data 为 List<List<MessageMetaData>>(子表行 × 列),业务侧可继续按 name 递归取用各子表字段。
六、设计要点与边界限制
6.1 长度字段容量
长度表每个字段占 2 字节大端序,容量 0~65535。字符串(STRING)长度允许超过 255 字节(容量上限 65535 字节):
- 单行格式:STRING 长度按实际 UTF-8 字节数写入长度表;
- 多行格式:STRING/LIST 列在长度表置 0,实际字节长度改为内嵌在值区每行的 2 字节前缀中(见 3.2/3.3),因此多行 STRING 同样支持最长 65535 字节,LIST 嵌套块同样受 2 字节前缀 65535 上限约束。
6.2 多行模式:String 行内变长,定长列天然等长
多行消息只写首行的类型表/长度表,后续行复用。
- 定长列(INTEGER/LONG/DOUBLE/FLOAT/SHORT/CHAR/BYTE/BOOLEAN):字节宽度由类型唯一决定,天然等长;
- STRING 列:实际长度内嵌在值区每行的 2 字节前缀中,同一列在不同行允许长度不同(行内变长),解码端按前缀逐个还原,不存在"同列等长"约束。
早期实现曾按首行长度估算总预算导致越界/空洞,2026-08-15 已改为遍历所有行所有列精确累计(见修订记录)。
6.3 单行格式与 lineMark 的兼容性(已统一入口)
单行 1 字节头格式(generateBytes)的 bytes[0] 是字段数 N,若直接交给 handleLineBytes,会被当作 lineMark 判断,当 N == 78('N') 或 N == 89('Y') 时会误判分支导致解析错乱。
当前已统一入口:发送端(InnerTestController)一律使用 generateLineBytes(List.of(map)) 生成 4 字节头消息,服务端 GenerateMessageDataHandler.channelRead 统一走 ByteTypeUtil.handleLineBytes,二者完全对齐。generateBytes 及 1 字节头解析器仅作历史保留。
6.4 名称区尾随换行
split("\\|") 未去除末尾 \n,最后一个字段名会携带换行符("nameN\n")。当前仅用于 setName,若下游用 name 精确匹配需注意。
6.5 消息边界
名称区固定以 \n 结尾,在流式 TCP 场景中可作为下一条消息的切分点(GenerateMessageDataHandler 收到的 byte[] 即按此切分后的单条完整消息)。
6.6 嵌套 LIST 字段(2026-08-23 新增)
LIST 列以"2 字节大端嵌套块长度 + 嵌套帧"递归编码(见 3.3),使单行字段支持多行子结果集(List<Map>)与标量列表(List<Integer> 等自动包装为单列多行)。要点:
- 深度上限 8:
MAX_NEST_DEPTH = 8,序列化/反序列化双侧校验,超限抛IllegalStateException,防栈溢出与恶意报文攻击。 - 嵌套帧不带
\n:嵌套块末尾不追加换行符,否则会被帧分隔符\n误切成两条消息。 - 空列表:
nestedLen = 0,解码端返回空列表。 - 整帧上限不变:外层仍受
DelimiterBasedFrameDecoder(65535, "\n")约束,嵌套总数据量锁死在约 64 KB;单值(STRING/LIST)仍有 2 字节长度前缀 65535 上限。
6.7 列数/类型约束:层内一致、跨层自由(2026-08-23 新增)
嵌套块的列数与类型由各层自身的首行决定,与外层互不约束:
- 同一层内:所有行的列数、各列类型必须与首行一致(fail-fast,抛
IllegalArgumentException),防止静默写坏报文; - 跨层:外层 N 列、嵌套层 M 列(M ≠ N)完全合法——“外层 3 列 + 子表 2 列"即可正常往返,支持"外层 20 列 + 子表 20 列"等任意组合。
6.8 Map 迭代顺序
头部类型表/长度表/值区与名称区均基于同一 Map 迭代顺序生成(HashMap 顺序不保证),但解码时列顺序由头部决定、名称仅作标记,故列序不影响正确性(键名与值是一一对应的,只要类型/长度/值三表顺序一致即可)。
七、适合的场景
7.1 高度契合的场景
-
高频、低延迟的内网二进制通信
- 交易/撮合/行情等对时延敏感的场景;
ProtocolFastjsonCompareTest内置与 fastjson 的序列化体积与耗时对照基准(实测见 十、与 fastjson 的基准对比)。 - 头部紧凑(N 字段单行仅
3N+1字节头),键名只出现一次,体积远小于 JSON(无引号、冒号、重复键名)。
- 交易/撮合/行情等对时延敏感的场景;
-
同构批量行数据的列式压缩传输
- 多行模式把类型/长度/名称各存一份,数据区按行列矩阵排布,适合批量订单同步、批量状态上报、批量指标回传等"一张表多条记录"的载荷,压缩比高且解码遍历规则简单。
-
环形缓冲(RingBuffer / Disruptor)事件载荷
- 模块名
native-cloud-common-ringbuffer表明协议与环形缓冲配套:事件对象携带byte[]消息,规避对象池中对象引用竞争,序列化产物不可变,天然适配无锁队列的并发模型。
- 模块名
-
长连接 TCP 流式传输
InnerTestController通过SocketChannel直接写入generateLineBytes产物(4 字节头;单条记录用单元素 List);GenerateMessageDataHandler(NettyChannelInboundHandlerAdapter)在服务端经ByteTypeUtil.handleLineBytes统一解码。\n边界适合单工/半双工的命令-回执流。
-
结构固定、类型简单的参数协议
- 字段名/类型/数量在收发双方约定固定(如 RPC 命令参数、配置下发、业务标记回写),无需动态 schema 协商。
-
嵌套多行结果集(2026-08-23 新增)
- 单行字段可携带
List<Map>子表(多级主从 / 树状结果集,如"订单 → 明细 → 商品”),或标量列表;递归长度前缀 +LIST类型标记,深度上限 8 层。
- 单行字段可携带
7.2 不适合的场景
| 场景 | 原因 |
|---|---|
| 任意对象图 / Map 值 / 枚举 / 泛型 | 仅支持 10 种基础类型 + 嵌套 LIST(元素须为 Map 或标量),不支持任意 Java 对象图 |
| 超长文本(>65535 字节字符串) | 长度字段 2 字节容量上限 65535 |
| 动态字段结构频繁变更 | 需收发双端同步类型/长度/名称三表约定 |
| 跨语言生态对接 | 目前绑定 Netty ByteBuf 与 Java 类型体系,需其他语言自行实现编解码 |
八、总结
Lemon-Protocol 是一套面向高性能 Java 内网通信的紧凑二进制协议:以"字段数 + 类型表 + 长度表 + 值区 + 尾部名称区"为骨架,单行变体面向单条参数、多行变体面向批量行数据。多行 STRING 列通过值区内嵌长度前缀支持行内变长,LIST 列通过递归长度前缀支持嵌套多行结果集(深度上限 8 层),发送/接收已统一为 4 字节头格式,天然适配环形缓冲与长连接流式传输。其优势在于体积小、解码规则简单、零对象复用的并发友好;使用时需注意 String 单值 65535 字节上限、整帧 65535 字节上限、名称区尾随换行等边界条件。
九、测试验证与修订记录
9.1 单元测试:ByteTypeUtilCodecTest(JUnit 5)
测试类位于 native-cloud-common-ringbuffer/src/test/java/com/huang/buffer/ByteTypeUtilCodecTest.java,2026-08-15 新增、2026-08-23 扩展,18 个用例全部通过(Tests run: 18, Failures: 0, Errors: 0):
| 测试方法 | 场景 |
|---|---|
testSingleLineAllTypesRoundTrip |
单行(单元素 List)8 种类型字段往返 |
testSingleLineVariableStringLengths |
单行同列多 String,长度各异(含 300 字节 UTF-8) |
testMultiLineAllTypesRoundTrip |
3 行 × 全类型 |
testMultiLineVariableLengthStringPerRow |
核心场景:同一 String 列 3 行长度不同(行内变长) |
testMultiLineStringNear65535Bytes |
60000 字节 String(2 字节前缀上限内) |
testNestedListFieldSingleOuterRow |
单外层行 + LIST 子表多行(嵌套往返) |
testNestedListFieldMultiOuterRows |
多外层行 + LIST 子表(含空子表) |
testNestedListEmpty |
空列表字段(nestedLen = 0) |
testNestedScalarList |
标量列表:List<Integer> / List<String> 自动包装单列多行 |
testNestedListTwoLevels |
两层嵌套(外层 → 子表 → 孙表) |
testNestedListLongStrings |
嵌套块内 600 字节长字符串 |
testNestedListNearFrameLimit |
300 行子表(约 31 KB),接近帧上限 |
testNestedFiveLevelsTwentyColumnsAllTypes |
五层嵌套 × 每层 20 列全类型(根 → L1 → L2 → L3 → L4 → L5,最内层空子表;每层 19 标量 + 1 LIST) |
testTypeInconsistencyAcrossRowsThrows |
行间列数/类型不一致抛 IllegalArgumentException(fail-fast) |
testUnsupportedTypeThrows |
不支持类型(如 Map/BigDecimal)抛 IllegalArgumentException |
testNestedDepthLimitThrows |
9 层嵌套(超过上限 8)抛 IllegalStateException |
testEmptyParameters |
空列表 / 空 Map 返回空字节数组 |
testByteTypeDeserializationStatus |
记录现状:BYTE 可序列化,但反序列化无 BYTE 分支,data 恒为 null |
9.2 2026-08-15 修订记录
| 问题 | 修复 |
|---|---|
| 定长字段长度区"小端写、大端读"不一致 | 序列化端 16 处(单行 + 多行)统一改为完整 2 字节大端写,与解码端一致;此前 INTEGER 长度被读出 1024(实际 4)、LONG/DOUBLE 2048、CHAR/BYTE/BOOLEAN 256,导致 copy 越界或数据错乱 |
长度区游标 +1 错位 |
单行 lengthIndex = 1 + N + 1、多行 lengthIndex = 4 + N + 1 各删掉多余 +1,与解码端 1+N / 4+N 对齐 |
| 多行长度预算只算首行导致越界/空洞 | 改为遍历所有行所有值精确累计(String 按 2 + 字节数) |
| 发送端与接收端格式不统一 | InnerTestController 两处发送由单行 1 字节头 generateBytes 改为 4 字节头 generateLineBytes(List.of(map)),与服务端 handleLineBytes 完全对齐 |
以上缺陷在旧实现下未被端到端验证暴露——新增的
ByteTypeUtilCodecTest首次对 混合类型 + 行内变长 String 做了全链路往返断言。
9.3 2026-08-23 修订记录
| 变更 | 说明 |
|---|---|
新增 LIST 类型(76 = ‘L’) |
类型表扩展为 10 种;list 首字母 l(108) 与 long 冲突,用大写 L(76) |
| 嵌套多行结果集 | 单行字段支持 List<Map> 子表 / 标量列表;递归长度前缀 + 嵌套帧(末尾不带 \n,防被帧分隔符误切) |
| 递归改造 | generateLineBytes / handleLineBytes 增加 depth 参数与 LIST 分支;expandNestedFields 预序列化嵌套块(nestedCache 三层数组) |
| 深度上限 | MAX_NEST_DEPTH = 8,序列化/反序列化双侧校验,防栈溢出与恶意报文 |
| schema fail-fast | 行间列数/类型不一致、不支持类型(Map/BigDecimal 等)直接抛异常,避免静默写坏报文 |
| 标量列表包装 | toRows 将标量列表包装为单列多行(列名沿用外层字段名) |
| 测试扩展 | 7 → 18 个用例,新增 11 个嵌套相关用例(含五层 × 每层 20 列全类型) |
十、与 fastjson 的基准对比
10.1 测试方法与环境
- 基准类:
ProtocolFastjsonCompareTest(native-cloud-common-ringbuffer/src/test/java/com/huang/buffer/),2026-08-15 新增。 - 环境:macOS;OpenJDK 17(GraalVM CE 22.3.2);Maven 3.6.3;fastjson 1.2.78(test scope)。
- 方法:JIT 预热(单数据 5000 次、多数据 200 次)+ 固定迭代(单数据 50000 次、多数据 500 次)取平均单次耗时;volatile sink 消费返回值防止 JIT 死代码消除;两轮采样结果一致(波动 <5%)。
- 数据集:单数据 = 1 行 × 10 列;多数据 = 1000 行 × 10 列。字段含 INTEGER/LONG/DOUBLE/FLOAT/CHAR/BOOLEAN/STRING×4,字符串含中文。
- 口径:Lemon =
generateLineBytes/handleLineBytes;fastjson =JSON.toJSONString/parseObject、parseArray。字节数 = Lemon 协议字节数组长度 vs JSON 字符串 UTF-8 字节数(网络传输口径)。
10.2 单数据场景(1 行 × 10 列)
| 指标 | Lemon-Protocol | fastjson | 差异 |
|---|---|---|---|
| 序列化字节数 | 181 B | 180 B | 基本持平(+0.6%) |
| 序列化耗时 | 2989 ns/次 | 2592 ns/次 | fastjson 快约 15% |
| 反序列化耗时 | 4919 ns/次 | 2307 ns/次 | fastjson 快约 2.1 倍 |
10.3 多数据场景(1000 行 × 10 列)
| 指标 | Lemon-Protocol | fastjson | 差异 |
|---|---|---|---|
| 序列化字节数 | 88877 B(约 87 KB) | 187071 B(约 183 KB) | Lemon 省约 52% |
| 序列化耗时 | 540182 ns/次(约 540 µs) | 832626 ns/次(约 833 µs) | Lemon 快约 35% |
| 反序列化耗时 | 2321653 ns/次(约 2.32 ms) | 874340 ns/次(约 874 µs) | fastjson 快约 2.7 倍 |
10.4 结论与原因分析
- 字节数:多数据场景 Lemon 优势显著——类型表 + 长度表 + 二进制定长编码去除了 JSON 的键名重复与文本格式开销(键名仅出现一次),体积约为 fastjson 的 47%;单数据场景因头部固定开销(
4 + N + 2N + 名称区)占比高,与 JSON 几乎持平(181 vs 180)。 - 序列化:多数据场景 Lemon 快约 35%(键名只写一次、定长数值直接写二进制);单数据场景 fastjson 略快约 15%(JIT 对 Map 序列化路径优化成熟,Lemon 单条仍有头部组装开销)。
- 反序列化:当前实现 Lemon 明显慢于 fastjson(单数据约 2.1 倍、多数据约 2.7 倍)。主要原因:
handleLineBytes逐行逐列创建MessageMetaData对象、经ByteBuf读取与copy拷贝、逐列走类型分支;批量场景下对象分配与拷贝开销被放大。这是后续优化重点(如按类型表批量解码、列式批量拷贝、减少中间对象)。
说明:耗时与本机 JIT 状态 / JDK 版本强相关;单数据场景差异处于纳秒级,工程上可视为同量级。
ProtocolFastjsonCompareTest保留在测试目录,可随时重跑复现。
附录:协议字节布局示意图
A. 单行消息整体布局(N 字段)
各域定义、偏移与计算规则见 3.1 表格,此处给出字节排布速览(纯 ASCII,等宽字体下对齐):
0 1 1+N 1+3N 1+3N+sumLen
+-----------+---------------+-----------------+----------------+------------------+
| N (1B) | type (N x 1B) | len (2N x 2B) | values | names "...\n" |
| 字段数 | 每列1B类型标记 | 每列2B大端长度 | 按列拼接的值 | key1|key2|...|keyN |
+-----------+---------------+-----------------+----------------+------------------+
B. 单行消息逐字节示例(3 字段:id=100(Integer) + name="abc"(String) + flag=true(Boolean))
| 偏移 | hex | 值(hex) | 说明 |
|---|---|---|---|
| 0 | 0x00 | 03 |
字段数 N = 3 |
| 1 | 0x01 | 69 ('i') |
第1列类型标记:INTEGER |
| 2 | 0x02 | 53 ('S') |
第2列类型标记:STRING |
| 3 | 0x03 | 42 ('B') |
第3列类型标记:BOOLEAN |
| 4 | 0x04 | 00 04 |
第1列值长度 = 4(大端) |
| 6 | 0x06 | 00 03 |
第2列值长度 = 3(大端) |
| 8 | 0x08 | 00 01 |
第3列值长度 = 1(大端) |
| 10 | 0x0A | 00 00 00 64 |
第1列值:100(大端 int) |
| 14 | 0x0E | 61 62 63 |
第2列值:“abc”(UTF-8) |
| 17 | 0x11 | 01 |
第3列值:true(1 字节) |
| 18 | 0x12 | 69 64 7C 6E 61 6D 65 7C |
名称区:“id|name|flag”(续下行) |
66 6C 61 67 |
名称区剩余:flag |
||
| 30 | 0x1E | 0A |
边界符:\n |
总长 = 1 + 3 + 6 + 8 + 13 + 1 = 32 字节
C. 多行消息整体布局(M 行 × N 列)
各域定义、偏移与计算规则见 3.2 表格,此处给出字节排布速览(纯 ASCII,等宽字体下对齐):
0 1 3 4 4+N 4+3N 4+3N+sumLen
+-----------+--------+----+--------------+---------------+-----------------+------------------+
| lineMark | line M | N | type (N x 1B)| len (2N x 2B) | values | names "...\n" |
| 'Y'/'N' | (2B) |(1B)| row1 only | row1 only | row-major | row1 keys |
+-----------+--------+----+--------------+---------------+-----------------+------------------+
| 定长列按宽平铺;STRING/LIST 每行 2B 前缀 + 值
D. 多行消息逐字节示例(2 行 × 3 列:id(Integer) + price(Double) + code(String="AB"/"CD"))
| 偏移 | hex | 值(hex) | 说明 |
|---|---|---|---|
| 0 | 0x00 | 59 ('Y') |
lineMark:多行 |
| 1 | 0x01 | 00 02 |
行数 M = 2(大端) |
| 3 | 0x03 | 03 |
列数 N = 3 |
| 4 | 0x04 | 69 ('i') 64 ('d') 53 ('S') |
类型区:INTEGER / DOUBLE / STRING |
| 7 | 0x07 | 00 04 |
第1列(id)长度 = 4(大端) |
| 9 | 0x09 | 00 08 |
第2列(price)长度 = 8(大端) |
| 11 | 0x0B | 00 00 |
第3列(code, STRING)长度表置 0 |
| 13 | 0x0D | 00 00 00 64 |
行1 id = 100(大端 int) |
| 17 | 0x11 | 40 59 40 00 …(8B)… |
行1 price = 100.5(大端 double) |
| 25 | 0x19 | 00 02 41 42 |
行1 code = “AB”(2B 前缀 00 02 + UTF-8) |
| 29 | 0x1D | 00 00 00 65 |
行2 id = 101 |
| 33 | 0x21 | 40 69 20 00 …(8B)… |
行2 price = 200.75(大端 double) |
| 41 | 0x29 | 00 02 43 44 |
行2 code = “CD” |
| 45 | 0x2D | 69 64 7C 70 72 69 63 65 7C |
名称区:“id|price|code”(续下行) |
63 6F 64 65 |
名称区剩余:code |
||
| 58 | 0x3A | 0A |
边界符:\n |
总长 = 4(头) + 3(类型) + 6(长度) + 32(值区) + 14(名称区) = 59 字节 值区 = 行1(4+8+2+2) + 行2(4+8+2+2) = 32 字节(code 每行含 2 字节长度前缀)
E. 解码指针轨迹(多行为例,handleLineBytes)
lineMark→(判单/多行) → lineNumber(M) → N → 指针初始化:
typeIndex = 4(类型区起点) index = 4+N(长度区起点) offset = 4+3N(值区起点)
每列:
STRING:长度表置 0 → 忽略;改读 offset 处内嵌 2B strLen → copy(offset+2, strLen) → offset += strLen+2
LIST:读 offset 处内嵌 2B nestedLen → 切片嵌套帧 → 递归 handleLineBytes(nested, depth+1) → offset += nestedLen+2
定长:读长度表 2B length → copy(offset, length) → offset += length
每行结束:typeIndex 复位 4、index 复位 4+N、offset 连续、M--
名称区:从当前 offset 到末尾 split("\\|") 得键名,按列 set 到每行的 MessageMetaData
F. LIST 嵌套字段字节示例(外层 1 行 × 2 列:id=1(Integer) + orders=LIST)
外层行 2 列,其中 orders 为嵌套子表(2 行 × 1 列 STRING oid="101"/"102")。注意外层 2 列、嵌套层 1 列——印证"层内一致、跨层自由"(见 6.7)。
外层帧:
| 偏移 | hex | 值(hex) | 说明 |
|---|---|---|---|
| 0 | 0x00 | 4E ('N') |
lineMark:单行(统一走 4 字节头) |
| 1 | 0x01 | 00 01 |
行数 = 1(单行时忽略) |
| 3 | 0x03 | 02 |
列数 N = 2 |
| 4 | 0x04 | 69 ('i') 4C ('L') |
类型区:INTEGER / LIST |
| 6 | 0x06 | 00 04 |
第1列(id)长度 = 4 |
| 8 | 0x08 | 00 00 |
第2列(orders, LIST)长度表置 0 |
| 10 | 0x0A | 00 00 00 01 |
第1列值:id = 1 |
| 14 | 0x0E | 00 17 |
第2列值:嵌套块长度 nestedLen = 23 |
| 16 | 0x10 | 59 00 02 01 53 00 00 00 03 31 30 31 00 03 31 30 32 6F 72 64 65 72 73 |
嵌套帧(23 字节,见下) |
| 39 | 0x27 | 69 64 7C 6F 72 64 65 72 73 |
名称区:“id|orders”(无 \n) |
| 48 | 0x30 | 0A |
边界符:\n |
外层总长 = 1(头) + 2(行数) + 1(列数) + 2(类型) + 4(长度) + 4(id) + 25(orders = 2B 前缀 + 23B 嵌套帧) + 9(名称区) + 1(
\n) = 49 字节
嵌套帧(orders 子表,nestedLen = 23,相对嵌套帧偏移):
| 偏移 | hex | 值(hex) | 说明 |
|---|---|---|---|
| 0 | 0x00 | 59 ('Y') |
lineMark:多行 |
| 1 | 0x01 | 00 02 |
行数 = 2 |
| 3 | 0x03 | 01 |
列数 = 1(与外层 2 列不同,合法) |
| 4 | 0x04 | 53 ('S') |
类型区:STRING |
| 5 | 0x05 | 00 00 |
长度表置 0 |
| 7 | 0x07 | 00 03 31 30 31 |
行1 oid = “101” |
| 12 | 0x0C | 00 03 31 30 32 |
行2 oid = “102” |
| 17 | 0x11 | 6F 72 64 65 72 73 |
名称区:“orders”(appendTerminator = false,无 \n) |
嵌套帧总长 = 4(头) + 1(类型) + 2(长度) + 5 + 5(两行值,各含 2B 前缀) + 6(名称区) = 23 字节