RocketMQ ConsumeQueue 索引:20 字节的精确定位术
一句话结论(30s)
ConsumeQueue 是 RocketMQ 用「冗余、可重建的二级索引」换取读性能的点睛设计。因为所有 Topic 混在一个 CommitLog 里顺序写,按 Topic/Queue 快速定位就必须靠索引;固定 20 字节(8B offset + 4B size + 8B tagHash)让任意位置能 O(1) 直接计算,把「全量顺序扫描 CommitLog」变成「精准定位读取」。
核心原理(2min)
消息写入 CommitLog 后,后台 ReputMessageService 每 1ms 检测新消息,解析出 Topic/QueueId/offset/size/tagHash,构造 20 字节条目追加到对应 Topic/Queue 的 ConsumeQueue 文件;消费时按 ConsumerOffset × 20 直接算偏移读条目,先做 Tag 过滤(不匹配跳过、不读 CommitLog 省 IO),匹配再用 offset+size 去 CommitLog 随机读一次。关键机制:ConsumeQueue 是异步构建、可重建的——Broker 启动检查 abort 文件,非正常关闭则从最后一个 ConsumeQueue 位置重扫 CommitLog 重建索引,最极端可删光 ConsumeQueue 全量重建。
底层深入(5-10min)
问题:所有 Topic 混在一个 CommitLog 里,怎么快速找到某条消息?
RocketMQ 的 CommitLog 是所有 Topic 共用的单一 Append-only 文件。如果消费者想拉取 Topic-A 的下一条消息,难道要顺序扫描整个 CommitLog?
答案是不需要。RocketMQ 建立了二级索引——ConsumeQueue。
CommitLog vs ConsumeQueue:为什么需要二级索引?
CommitLog(一级存储):
┌────────────────────────────────────────────────────┐
│ Msg1(TopicA/Q0) │ Msg2(TopicB/Q1) │ Msg3(TopicA/Q0) │ ...
└────────────────────────────────────────────────────┘
↑ 所有消息按到达顺序混在一起,无法按 Topic/Queue 快速定位
ConsumeQueue(二级索引):
TopicA/Queue0: [条目1] [条目2] [条目3] ... ← 只存指向 CommitLog 的指针
TopicA/Queue1: [条目1] [条目2] ...
TopicB/Queue0: [条目1] ...
↑ 每个 Queue 一个文件,按顺序存储该 Queue 所有消息的索引条目
ConsumeQueue 是 CommitLog 的稀疏索引——它不存消息内容,只存”消息在 CommitLog 的哪个位置、多长”。消费者通过 ConsumeQueue 找到 offset,再去 CommitLog 读取完整消息。
思考·内化:为什么一定要有 ConsumeQueue 这层二级索引?关键在「读」这一侧:CommitLog 把写性能拉到极致,代价就是消息按「到达顺序」混排,Topic A 的下一条消息可能夹在 Topic B、C 中间。没有索引,消费者就只能全量顺序扫描——写有多快,读就有多慢。所以必须用一层「轻量目录」把 O(全量扫描) 变成 O(1) 定位,这就是二级索引存在的唯一理由。
ConsumeQueue 的固定 20 字节条目
这是 RocketMQ 设计中最精妙的部分之一:每个索引条目固定 20 字节。
ConsumeQueue 单条目格式(20 bytes):
┌──────────────────────┬──────────────┬──────────────────────┐
│ CommitLog Offset │ Size(字节数) │ Tags Hash Code │
│ (8 bytes) │ (4 bytes) │ (8 bytes) │
└──────────────────────┴──────────────┴──────────────────────┘
0..7 8..11 12..19
总计:8 + 4 + 8 = 20 bytes
上面的 ASCII 图是笔者的画法,RocketMQ 源码里对这 20 字节同样有精炼的定义(store/ConsumeQueue.java):
/**
* ConsumeQueue's store unit. Size: CommitLog Physical Offset(8) + Body Size(4) + Tag HashCode(8) = 20 Bytes
*/
public static final int CQ_STORE_UNIT_SIZE = 20;
public static final int MSG_TAG_OFFSET_INDEX = 12;
CQ_STORE_UNIT_SIZE = 20 是整个 ConsumeQueue 设计的基石常量:因为每条条目固定 20 字节,任意第 N 条消息的读取位置都能用 N × 20 直接算出,无需顺序扫描,这就是 O(1) 定位的由来。MSG_TAG_OFFSET_INDEX = 12 标记了 Tag 哈希在条目内的起始字节(跳过 8 字节偏移 + 4 字节大小),供按 Tag 估算消息量时快速跳过前两个字段。
而”20 字节”到底是怎么写进文件的,看 putMessagePositionInfo:
private boolean putMessagePositionInfo(final long offset, final int size, final long tagsCode,
final long cqOffset) {
if (offset + size <= this.getMaxPhysicOffset()) {
// During the recovery process after broker crashes, this logs will cause the scrolling of valid logs.
if (messageStore.getStateMachine().getCurrentState().isAfter(MessageStoreStateMachine.MessageStoreState.RECOVER_COMMITLOG_OK) ||
messageStore.getMessageStoreConfig().isEnableLogConsumeQueueRepeatedlyBuildWhenRecover()) {
log.warn("Maybe try to build consume queue repeatedly maxPhysicOffset={} phyOffset={}",
this.getMaxPhysicOffset(), offset);
}
return true;
}
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);
final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;
MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(expectLogicOffset);
if (mappedFile != null) {
if (mappedFile.isFirstCreateInQueue() && cqOffset != 0 && mappedFile.getWrotePosition() == 0) {
this.minLogicOffset = expectLogicOffset;
this.mappedFileQueue.setFlushedWhere(expectLogicOffset);
this.mappedFileQueue.setCommittedWhere(expectLogicOffset);
this.fillPreBlank(mappedFile, expectLogicOffset);
log.info("fill pre blank space " + mappedFile.getFileName() + " " + expectLogicOffset + " "
+ mappedFile.getWrotePosition());
}
if (cqOffset != 0) {
long currentLogicOffset = mappedFile.getWrotePosition() + mappedFile.getFileFromOffset();
if (expectLogicOffset < currentLogicOffset) {
log.warn("Build consume queue repeatedly, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
expectLogicOffset, currentLogicOffset, this.topic, this.queueId, expectLogicOffset - currentLogicOffset);
return true;
}
if (expectLogicOffset != currentLogicOffset) {
LOG_ERROR.warn(
"[BUG]logic queue order maybe wrong, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
expectLogicOffset,
currentLogicOffset,
this.topic,
this.queueId,
expectLogicOffset - currentLogicOffset
);
}
}
this.setMaxPhysicOffset(offset + size);
boolean appendResult;
if (messageStore.getMessageStoreConfig().isPutConsumeQueueDataByFileChannel()) {
appendResult = mappedFile.appendMessageUsingFileChannel(this.byteBufferIndex.array());
} else {
appendResult = mappedFile.appendMessage(this.byteBufferIndex.array());
}
return appendResult;
}
return false;
}
putMessagePositionInfo 就是把一条消息”翻译”成 20 字节索引条目的落盘实现:putLong(offset)、putInt(size)、putLong(tagsCode) 依次写出 8+4+8=20 字节。expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE 再次印证固定长度带来的可计算性——消费位点 cqOffset 乘 20 就是它在 ConsumeQueue 文件里的物理落点。方法开头的 offset + size <= maxPhysicOffset 是幂等保护:Broker 崩溃恢复重扫 CommitLog 时,若发现该偏移已构建过就直接返回 true,避免重复写索引条目。
字段详解
1. CommitLog Offset(8 字节)
消息在 CommitLog 文件中的物理起始偏移量。由于 CommitLog 是单一文件(或分段文件),这个 offset 可以唯一定位到一条消息在磁盘上的确切位置。
如果 CommitLog 总大小为 100 GB:
第一条消息:offset = 0
第二条消息:offset = 532(第一条消息 532 字节)
第三条消息:offset = 1204
...
消费者根据 ConsumeQueue 条目中的 offset,直接在 CommitLog 中用 FileChannel.position(offset).read() 定位读取。
2. Size(4 字节)
消息的总字节数。包括消息头(MessageExt)和消息体。消费者读取时,需要知道从 offset 开始读多少字节。
读取 CommitLog 中的一条消息:
byte[] data = new byte[size];
commitLog.read(offset, data); // 读 [offset, offset+size) 范围
对于一个 4KB 的消息,Size = 4096 + 消息头大约 = 4200 字节左右。
3. Tags Hash Code(8 字节)
消息 Tag 的哈希值。用于消费端的 Tag 过滤。
生产者发送:topic=Order, tag=PAID
RocketMQ 计算 String.hashCode("PAID") → 写入 ConsumeQueue 条目的 Tags Hash 字段
消费者订阅:topic=Order, tag=PAID || SHIPPED
拉取时,Broker 先读 ConsumeQueue 条目,用 Tags Hash 匹配:
- 匹配 → 去 CommitLog 读取完整消息,返回给消费者
- 不匹配 → 跳过(不需要读 CommitLog,节省 IO)
为什么用 8 字节存哈希而不是存 Tag 字符串?
- 固定长度(8 字节)vs 变长字符串(需要额外长度字段,破坏固定 20 字节结构)。
- 快速比较(整数比较 vs 字符串比较)。
- 内存高效(20 字节可以直接用结构体 / 位运算解析)。
代价是哈希冲突——不同 Tag 可能算出相同 hashCode,导致消费者收到不该收到的消息。不过这是小概率事件,而且消费者端会做二次过滤(解析消息的完整 Tag,过滤掉不匹配的)。
思考·内化:为什么每条条目要「固定」20 字节,变长不行吗?想一下「定位第 N 条」这件事:固定长度让你用
N × 20一步算出磁盘落点,随机可达;一旦变长,就得从头累加每条长度才能找到第 N 条,O(1) 立刻退化成 O(N) 顺序扫描。所以「固定 20 字节」不是省那几个字节,而是用固定长度换「可计算性」——这也是 Tag 存哈希而非字符串的根本原因。
ConsumeQueue 文件组织
store/
├── commitlog/
│ ├── 00000000000000000000
│ ├── 00000000001073741824 ← 每 1GB 一个文件
│ └── ...
├── consumequeue/
│ ├── TopicA/
│ │ ├── 0/
│ │ │ ├── 00000000000000000000 ← Queue 0 的 ConsumeQueue
│ │ │ └── 00000000000006000000
│ │ ├── 1/
│ │ │ └── 00000000000000000000 ← Queue 1 的 ConsumeQueue
│ │ └── ...
│ └── TopicB/
│ └── ...
└── index/
└── ...
文件命名规则:以该文件中第一条 ConsumeQueue 条目的起始 CommitLog offset 命名。例如 00000000000006000000 表示这个 ConsumeQueue 文件中存的是从 CommitLog offset = 6,000,000 开始的消息索引。
每个文件多大?
一个 ConsumeQueue 文件 = 30 万 条条目 × 20 bytes = 6,000,000 bytes ≈ 5.72 MB
默认每个 ConsumeQueue 文件包含 30 万个条目。为什么是这个数字?
// MessageStoreConfig.java
private int mappedFileSizeConsumeQueue = 300000 * ConsumeQueue.CQ_STORE_UNIT_SIZE;
mappedFileSizeConsumeQueue 的真实默认值定义在 MessageStoreConfig 中:30 万条 × 每条 CQ_STORE_UNIT_SIZE(20) 字节 = 6,000,000 字节 ≈ 5.72 MB。注意它没有硬编码 20,而是引用 ConsumeQueue.CQ_STORE_UNIT_SIZE 常量——一旦索引条目结构变化,文件大小会自动联动,这是 RocketMQ 对”20 字节”这一核心假设的显式约束。
5.72 MB 是一个精心选择的值:
- 足够小,可以完整映射到内存(mmap),读取效率高。
- 足够大,减少文件数量(否则磁盘 inode 太多)。
- 与 CommitLog 1GB 文件大小形成层次关系(一个 CommitLog 文件 → 约 50-100 个 ConsumeQueue 文件)。
ConsumeQueue 的构建时机
ConsumeQueue 不是同步构建的。流程如下:
1. 消息写入 CommitLog(同步/异步刷盘)
2. 后台线程 ReputMessageService 检测 CommitLog 有新消息
3. 解析消息头 → 获取 Topic、QueueId、CommitLog offset、Size、Tag Hash
4. 构造 20 字节条目 → 追加写入对应 Topic/QueueId/ 的 ConsumeQueue 文件
5. ConsumeQueue 异步刷盘
ReputMessageService 是一个常驻后台线程,每 1ms 醒来执行一次 doReput()(store/DefaultMessageStore.java):
@Override
public void run() {
DefaultMessageStore.LOGGER.info(this.getServiceName() + " service started");
while (!this.isStopped()) {
try {
TimeUnit.MILLISECONDS.sleep(1);
this.doReput();
} catch (Throwable e) {
DefaultMessageStore.LOGGER.warn(this.getServiceName() + " service has exception. ", e);
}
}
DefaultMessageStore.LOGGER.info(this.getServiceName() + " service end");
}
它完全独立于生产者写入 CommitLog 的同步路径——生产者落盘 CommitLog 后即可返回,索引构建由它异步追赶,这正是”ConsumeQueue 是异步构建的二级索引”在代码层的体现。
转发逻辑在 doReput():
public void doReput() {
if (this.reputFromOffset < DefaultMessageStore.this.commitLog.getMinOffset()) {
LOGGER.warn("The reputFromOffset={} is smaller than minPyOffset={}, this usually indicate that the dispatch behind too much and the commitlog has expired.",
this.reputFromOffset, DefaultMessageStore.this.commitLog.getMinOffset());
this.reputFromOffset = DefaultMessageStore.this.commitLog.getMinOffset();
}
boolean isCommitLogAvailable = isCommitLogAvailable();
if (!isCommitLogAvailable) {
currentReputTimestamp = System.currentTimeMillis();
}
for (boolean doNext = true; isCommitLogAvailable() && doNext; ) {
SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
if (result == null) {
break;
}
try {
this.reputFromOffset = result.getStartOffset();
for (int readSize = 0; readSize < result.getSize() && reputFromOffset < getReputEndOffset() && doNext; ) {
DispatchRequest dispatchRequest =
DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false, false);
int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
if (reputFromOffset + size > getReputEndOffset()) {
doNext = false;
break;
}
if (dispatchRequest.isSuccess()) {
if (size > 0) {
currentReputTimestamp = dispatchRequest.getStoreTimestamp();
DefaultMessageStore.this.doDispatch(dispatchRequest);
if (isNotifyMessageArriveWhenReput()) {
notifyMessageArriveIfNecessary(dispatchRequest);
}
this.reputFromOffset += size;
readSize += size;
if (!DefaultMessageStore.this.getMessageStoreConfig().isDuplicationEnable() &&
DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole() == BrokerRole.SLAVE) {
DefaultMessageStore.this.storeStatsService
.getSinglePutMessageTopicTimesTotal(dispatchRequest.getTopic()).add(dispatchRequest.getBatchSize());
DefaultMessageStore.this.storeStatsService
.getSinglePutMessageTopicSizeTotal(dispatchRequest.getTopic())
.add(dispatchRequest.getMsgSize());
}
} else if (size == 0) {
this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
readSize = result.getSize();
}
} else {
if (size > 0) {
LOGGER.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
this.reputFromOffset += size;
} else {
doNext = false;
// If user open the dledger pattern or the broker is master node,
// it will not ignore the exception and fix the reputFromOffset variable
if (DefaultMessageStore.this.getMessageStoreConfig().isEnableDLegerCommitLog() ||
DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
LOGGER.error("[BUG]dispatch message to consume queue error, COMMITLOG OFFSET: {}",
this.reputFromOffset);
this.reputFromOffset += result.getSize() - readSize;
}
}
}
}
} catch (RocksDBException e) {
ERROR_LOG.info("dispatch message to cq exception. reputFromOffset: {}", this.reputFromOffset, e);
return;
} finally {
result.release();
}
}
}
doReput() 是异步转发的核心:从 reputFromOffset 起 getData() 读 CommitLog,checkMessageAndReturnSize() 解析出消息的 DispatchRequest,再交给 doDispatch() 分发,最后把 reputFromOffset 前移 size 继续追赶。整个过程只读 CommitLog、只写 ConsumeQueue 指针,消息体不做二次拷贝,所以即便短暂落后也能快速追平。
doDispatch() 会把解析结果路由给各构建器,其中负责写 ConsumeQueue 的就是这个 dispatcher:
public void doDispatch(DispatchRequest req) throws RocksDBException {
for (CommitLogDispatcher dispatcher : this.dispatcherList) {
dispatcher.dispatch(req);
}
}
class CommitLogDispatcherBuildConsumeQueue implements CommitLogDispatcher {
@Override
public void dispatch(DispatchRequest request) throws RocksDBException {
final int tranType = MessageSysFlag.getTransactionValue(request.getSysFlag());
switch (tranType) {
case MessageSysFlag.TRANSACTION_NOT_TYPE:
case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
putMessagePositionInfo(request);
break;
case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
break;
}
}
}
doDispatch() 遍历 dispatcher 列表,CommitLogDispatcherBuildConsumeQueue 最终调用上一节的 putMessagePositionInfo,把 CommitLog 里的消息真正写进 ConsumeQueue——这就补齐了”CommitLog → ConsumeQueue 二级索引”的完整异步链路:run → doReput → doDispatch → putMessagePositionInfo。注意它只对普通消息和已提交事务消息建索引,半消息(PREPARED)和回滚消息(ROLLBACK)不写 ConsumeQueue,保证消费者看不到未提交的事务消息。
重要:ConsumeQueue 是异步构建的。这意味着:
- 消息写入 CommitLog 后,可能需要几个毫秒才能被消费者拉到(ConsumeQueue 还没构建完)。
- 如果 Broker 在 ConsumeQueue 构建过程中宕机,CommitLog 中有消息但 ConsumeQueue 中没有对应条目——这些消息成为”幽灵消息”,消费者感知不到。Broker 重启后会自检:扫描 CommitLog,重建所有 ConsumeQueue 和 Index。
思考·内化:为什么 ConsumeQueue 要异步构建、还允许可重建?这是「冗余换性能」的典型:ConsumeQueue 本质是 CommitLog 的派生数据,CommitLog 才是真源。既然随时能从 CommitLog 重算,就不必同步构建拖慢写路径——生产者落盘即返回,索引由 ReputMessageService 后台追赶;也正因可重建,哪怕删光索引都不慌。想通「谁是源、谁是派生」,很多设计取舍就豁然开朗。
ConsumeQueue 的自检与重建
Broker 启动 → 检查 abort 文件
├── 存在 → 非正常关闭,需要恢复
│ ├── 恢复 ConsumeQueue:从最后一个 ConsumeQueue 位置开始,重新扫描 CommitLog
│ └── 恢复 Index:重建索引
└── 不存在 → 正常关闭,跳过恢复
这是 RocketMQ 的一个优雅设计:ConsumeQueue 是可重建的。最极端情况下,删除所有 ConsumeQueue 文件,Broker 也能从 CommitLog 重新构建。代价是启动时间变长(需要全量扫描 CommitLog)。
思考·内化:为什么 Broker 重启要检查 abort 文件?因为「正常关闭」和「宕机」的区别就藏在这里:正常关闭会删掉 abort 文件,宕机则残留。若宕机,CommitLog 已写入但 ConsumeQueue 可能只建了一半——这些「幽灵消息」消费者感知不到,所以必须从最后一个索引位置重扫 CommitLog 补齐。abort 文件就是 RocketMQ 判断「上次是否干净退出」的哨兵。
消息消费的完整读取链路
消费者请求拉取消息 (Topic=Order, QueueId=0, ConsumerOffset=5)
│
▼
Broker 定位 ConsumeQueue 文件:/consumequeue/Order/0/00000000000000000000
│
▼
计算偏移:第 5 条条目 = 5 × 20 = offset 100
│
▼
读取 20 字节条目:
CommitLog Offset = 123456789 (8 bytes)
Size = 1024 (4 bytes)
Tags Hash = 0x1A2B3C4D (8 bytes)
│
▼
Tag 过滤匹配?
├── 否 → 跳过,offset += 20,继续读下一个条目
└── 是 → 用 CommitLog Offset + Size 去 CommitLog 读取完整消息
│
▼
读取 CommitLog[123456789, 123456789+1024]
│
▼
解析 → 返回给消费者
为什么这样快?
- ConsumeQueue 固定 20 字节:任意位置偏移可以直接计算(offset = index × 20),不需要顺序扫描。
- ConsumeQueue 按 Queue 分离:不同 Queue 互不干扰,消费者只读自己的 Queue 文件。
- Tag 过滤在 ConsumeQueue 层完成:不需要读 CommitLog 就能过滤掉不匹配的消息。
总结
ConsumeQueue 是 RocketMQ 存储设计的点睛之笔:
CommitLog(全量数据,顺序写)→ 保证写性能
ConsumeQueue(稀疏索引,固定20字节)→ 保证读性能
IndexFile(按消息Key查询)→ 保证按需检索
三层结构中,ConsumeQueue 承上启下:它把”全量顺序扫描 CommitLog”变成了”精准定位读取”,用每个 MessageQueue 一份小文件(5.72MB)的代价,换来了 O(1) 的消息定位。
核心心法:ConsumeQueue 是冗余的、可重建的、牺牲少量磁盘换取巨大读性能的二级索引。理解了”固定 20 字节”这个数字,就理解了 ConsumeQueue 的全部。
章末提问
1. ConsumeQueue 为什么每条固定 20 字节?这三个字段分别是干嘛的?
结论先行:8 字节 CommitLog 物理偏移定位消息位置,4 字节 size 确定读取长度,8 字节 tagHash 做消费端 Tag 预过滤。因为固定 20 字节让第 N 条条目的落点能用 N × 20 直接算出来,实现 O(1) 随机定位,避免变长结构导致的顺序扫描。
2. 消费者要拉取某 Queue 的第 N 条消息,完整链路是怎样的?
结论先行:先按 N × 20 算出 ConsumeQueue 偏移,读 20 字节条目,Tag 不匹配则跳过,匹配则用 offset + size 去 CommitLog 随机读一次取完整消息。因为 ConsumeQueue 是稀疏索引,只存指针不存内容,必须先拿到 CommitLog 偏移才能定位真实消息体。
3. ConsumeQueue 是同步构建还是异步构建?为什么这么设计?
结论先行:异步构建,由 ReputMessageService 后台线程每 1ms 追赶 CommitLog。因为生产者落盘 CommitLog 后即可返回,不必等索引写完,写路径不被拖慢;ConsumeQueue 是 CommitLog 的派生数据、可随时重建,短暂落后可接受。
4. 为什么 ConsumeQueue 可重建?Broker 启动怎么判断要不要重建?
结论先行:因为 ConsumeQueue 是冗余的派生索引,源数据全在 CommitLog 里。Broker 启动检查 abort 文件:存在说明非正常关闭,从最后一个 ConsumeQueue 位置重扫 CommitLog 补齐;不存在则跳过。最极端可删光全部 ConsumeQueue 全量重建,代价只是启动变慢。
5. Tag 哈希冲突怎么办?为什么用哈希不用字符串?
结论先行:哈希冲突是小概率事件,靠消费者端二次过滤兜底(解析完整消息 Tag 再过滤一次)。因为固定 8 字节哈希能保住「固定 20 字节」结构和整数 O(1) 比较,若存变长字符串就得加长度字段、破坏可计算性;用冲突的小代价换确定性结构的大收益,划算。