分布式ID:UUID、数据库号段、Snowflake与生产治理
分库分表和微服务拆分后,单库自增不再天然提供全局唯一标识。分布式 ID 不只是“生成一个不重复的 long”,还要考虑吞吐、趋势递增、索引局部性、可用性、回拨、节点身份、容量寿命、安全泄露、跨地域和故障恢复。
一、学习目标
学完后应能:
- 区分技术主键、业务单号、幂等键和 TraceId。
- 解释数据库自增、Sequence、Redis INCR、ZooKeeper 顺序节点、UUID、号段和 Snowflake 的内部路径。
- 计算 Snowflake 位宽、寿命和单节点理论吞吐。
- 设计 WorkerId 分配、冲突检测和时钟回拨策略。
- 解释随机 UUID 对 B+Tree 索引局部性的影响,以及 UUID v7/ULID 的边界。
- 设计 ID 服务的双 Buffer、降级、监控、迁移与生产 Runbook。
二、先区分四种标识
| 标识 | 主要用途 | 是否可预测 | 示例 |
|---|---|---|---|
| 技术主键 | 数据库行关联和内部引用 | 可以,但不应直接作为授权依据 | order_id bigint |
| 业务单号 | 客服、用户、渠道可识别 | 通常带规则或校验位 | M202607180001 |
| 幂等键 | 判断两次请求是否同一业务意图 | 应由业务操作稳定生成或客户端提供 | 支付请求号 |
| TraceId | 串联一次分布式调用 | 高概率唯一,不表达业务身份 | W3C Trace ID |
不要把技术主键直接当访问凭证。即使 ID 难猜,接口仍必须做租户和资源授权。业务单号也不应承担数据库主键的全部职责:规则可能变化、长度更长、还可能需要补号和人工录入。
三、ID 方案要评估哪些指标
| 指标 | 要问的问题 |
|---|---|
| 唯一性范围 | 单表、单库、单地域还是全球唯一 |
| 吞吐 | 峰值每秒生成多少,突发持续多久 |
| 延迟 | 是否允许每次远程取号 |
| 可用性 | 发号器故障时业务是否必须继续 |
| 顺序性 | 只要趋势递增,还是严格连续 |
| 索引友好 | 主键插入是否集中在 B+Tree 右侧 |
| 信息泄露 | 是否暴露时间、节点、业务量 |
| 寿命 | 时间位、序列位和节点位何时耗尽 |
| 跨地域 | 各地域如何避免号段或 WorkerId 冲突 |
| 可观测 | 能否定位由谁、何时、按哪个配置生成 |
“全局唯一、严格递增、绝不跳号、无中心、高可用、无限吞吐”通常不能同时免费获得。严格连续尤其昂贵:事务回滚、缓存号段和节点故障都会产生空洞。发票等受监管号码应按专门业务规则和审计设计,不要拿普通技术主键冒充。
四、数据库自增与 Sequence
4.1 单库自增
数据库在插入时原子分配值,简单且索引局部性好。问题是:多库各自从 1 开始会冲突;每次生成都依赖数据库;跨地域集中访问延迟高。
可以为不同库设置不同起点和步长,例如奇偶号,但扩容和库数量变化会使配置复杂,并且步长规划错误会直接冲突。
4.2 Sequence
Oracle、PostgreSQL、SQL Server 等提供 Sequence。Sequence 与业务事务通常不是同一个回滚语义:调用 nextval 后事务回滚,号码通常也不会归还;开启 Cache 后实例故障还可能丢失未使用号码。因此 Sequence 保证唯一生成,不保证无空洞。
flowchart TD
A["事务请求nextval"] --> B["Sequence原子增加"]
B --> C["返回新值"]
C --> D{"业务事务是否提交"}
D -- "提交" --> E["ID被业务使用"]
D -- "回滚" --> F["ID通常不会退回,形成空洞"]五、Redis INCR 与 ZooKeeper 顺序节点
5.1 Redis INCR
Redis 在单个主节点执行命令时可原子递增,适合中等规模的集中序号。但必须考虑:
- 主从切换时已确认计数是否可能丢失,取决于持久化和复制确认策略。
- 跨地域访问一个 Redis 的延迟和故障域。
- 热点 key 的单分片吞吐上限。
- 恢复旧备份后计数回退造成冲突。
可以一次 INCRBY 1000 领取本地号段,降低远程频率,但也会接受节点宕机后号段空洞。
5.2 ZooKeeper 顺序节点
ZooKeeper 可创建带单调序号后缀的 Sequential ZNode。它适合选举、排序和协调元数据,不适合作为高吞吐业务 ID 发号器:每次创建经过 Leader 与 Quorum 持久化,节点和 Watch 还会增加控制面负担。
原则是:不要因为组件“能生成顺序号”,就把控制面组件拿来承载交易数据面的高 QPS。
六、UUID、UUID v7 与 ULID
6.1 UUID v4
Java 8 的 UUID.randomUUID() 常见实现是随机 UUID v4。它无需中心协调,碰撞概率极低,但字符串长、随机插入会降低聚簇索引局部性,而且概率唯一不等于数学上绝对不碰撞;数据库仍应保留唯一约束。
import java.util.UUID;
public class UuidDemo {
public static void main(String[] args) {
UUID id = UUID.randomUUID();
System.out.println(id.toString());
}
}数据库存储 UUID 时,二进制 BINARY(16) 通常比带连字符字符串更紧凑,但要统一字节序和转换约定,避免不同语言读写不一致。
6.2 UUID v1
UUID v1 使用时间和节点相关信息,具备一定时间属性,但可能暴露生成时间和节点标识。是否包含真实 MAC 取决于实现,不能把所有 v1 都简单说成必然暴露物理 MAC。
6.3 UUID v7
UUID v7 是 RFC 9562 定义的基于 Unix Epoch 毫秒时间的时间有序 UUID,后续位包含随机/实现定义的单调增强部分。它改善按字典/二进制顺序插入的局部性,但同一毫秒内是否严格单调取决于生成器实现。
Java 8 标准库没有直接提供 UUID v7 生成 API,需要经过验证的库或自研实现;不能把 UUID v7 写成 JDK 8 自带能力。
6.4 ULID
ULID 也是 128 位标识,通常由 48 位毫秒时间和 80 位随机部分组成,并使用可排序 Base32 文本表示。它不是 UUID 版本。普通随机模式下同一毫秒内不保证生成顺序;Monotonic ULID 需要实现额外递增规则,并处理同毫秒随机空间溢出和并发安全。
七、数据库号段模式
号段模式把“每个 ID 都访问数据库”改成“每批 ID 访问一次数据库”。数据库只负责原子分配区间,应用在内存中逐个发放。
号段表:
create table id_segment (
biz_tag varchar(64) primary key,
max_id bigint not null,
step int not null,
version bigint not null,
updated_at timestamp not null
);分配 [oldMax + 1, oldMax + step] 的事务过程:
begin;
select max_id, step, version
from id_segment
where biz_tag = ?
for update;
update id_segment
set max_id = max_id + step,
version = version + 1,
updated_at = current_timestamp
where biz_tag = ?;
commit;flowchart TD
A["本地当前号段接近耗尽"] --> B["事务锁定biz_tag行"]
B --> C["数据库max_id原子增加step"]
C --> D["提交并返回新区间"]
D --> E["应用在内存逐个发放"]
E --> F["达到预取阈值后异步加载下一段"]7.1 双 Buffer
当前 Segment 使用到一定比例时,后台预取 Next Segment。当前段耗尽后原子切换,减少切段时等待数据库的毛刺。
必须处理:
- 多线程发号的原子游标。
- Next Segment 只能有一个加载任务或能够合并重复加载。
- 数据库不可用且两个 Buffer 都耗尽时明确失败,不能重新使用旧号段。
- 节点宕机会浪费剩余区间,但不能回收后再次分配。
- Step 可以按消费速率动态调整,但要设置上下限,避免一次囤积过大。
八、JDK 8 Demo:线程安全本地号段
import java.util.concurrent.atomic.AtomicLong;
public final class LocalSegment {
private final long endInclusive;
private final AtomicLong next;
public LocalSegment(long startInclusive, long endInclusive) {
if (startInclusive > endInclusive) {
throw new IllegalArgumentException("号段起点不能大于终点");
}
if (endInclusive == Long.MAX_VALUE) {
throw new IllegalArgumentException("号段终点必须小于Long.MAX_VALUE");
}
this.next = new AtomicLong(startInclusive);
this.endInclusive = endInclusive;
}
public long nextId() {
for (;;) {
long value = next.get();
if (value > endInclusive) {
throw new IllegalStateException("号段已耗尽");
}
if (next.compareAndSet(value, value + 1L)) {
return value;
}
}
}
public long remaining() {
long value = next.get();
return value > endInclusive ? 0L : endInclusive - value + 1L;
}
public static void main(String[] args) {
LocalSegment segment = new LocalSegment(1001L, 1003L);
System.out.println(segment.nextId());
System.out.println(segment.nextId());
System.out.println(segment.nextId());
System.out.println("remaining=" + segment.remaining());
}
}Demo 只表示本地发放。真正的唯一性来自数据库对区间的原子分配;如果多个实例错误获得同一区间,本地 AtomicLong 无法补救。
8.1 JDK 8 Demo:模拟数据库乐观号段分配
真实商业系统通常不会只靠本地 AtomicLong,还要有一张权威分配表。下面用内存对象模拟数据库行和 version 乐观更新,帮助理解“多个发号服务同时抢号段为什么不会拿到同一段”。
import java.util.concurrent.atomic.AtomicLong;
public class SegmentAllocatorDemo {
static class Segment {
final long start;
final long end;
Segment(long start, long end) {
this.start = start;
this.end = end;
}
public String toString() {
return "[" + start + ", " + end + "]";
}
}
static class FakeDbRow {
private long maxId = 0L;
private long version = 0L;
private final int step = 1000;
synchronized Segment allocate(String bizTag) {
long oldMax = maxId;
long oldVersion = version;
// 对应真实SQL中的 where biz_tag = ? and version = ?
boolean updated = compareAndUpdate(oldVersion, oldMax + step);
if (!updated) {
throw new IllegalStateException("并发更新失败,请重试");
}
return new Segment(oldMax + 1L, maxId);
}
private boolean compareAndUpdate(long expectedVersion, long newMaxId) {
if (version != expectedVersion) {
return false;
}
maxId = newMaxId;
version++;
return true;
}
}
static class IdService {
private final FakeDbRow dbRow;
private volatile LocalSegment current;
IdService(FakeDbRow dbRow) {
this.dbRow = dbRow;
Segment first = dbRow.allocate("order");
this.current = new LocalSegment(first.start, first.end);
}
long nextId() {
for (;;) {
LocalSegment segment = current;
try {
return segment.nextId();
} catch (IllegalStateException exhausted) {
synchronized (this) {
if (current == segment) {
Segment next = dbRow.allocate("order");
current = new LocalSegment(next.start, next.end);
}
}
}
}
}
}
static final class LocalSegment {
private final long endInclusive;
private final AtomicLong next;
LocalSegment(long startInclusive, long endInclusive) {
this.next = new AtomicLong(startInclusive);
this.endInclusive = endInclusive;
}
long nextId() {
for (;;) {
long value = next.get();
if (value > endInclusive) {
throw new IllegalStateException("号段已耗尽");
}
if (next.compareAndSet(value, value + 1L)) {
return value;
}
}
}
}
public static void main(String[] args) {
FakeDbRow db = new FakeDbRow();
IdService serviceA = new IdService(db);
IdService serviceB = new IdService(db);
System.out.println("serviceA id=" + serviceA.nextId());
System.out.println("serviceB id=" + serviceB.nextId());
}
}真实 SQL 通常写成:
update id_segment
set max_id = max_id + step,
version = version + 1,
updated_at = current_timestamp
where biz_tag = ?
and version = ?;如果更新影响行数为 1,说明本实例抢号段成功;如果为 0,说明版本已经被其他实例改掉,应重新查询再重试。不要在应用内自己计算一个区间然后直接使用,必须让数据库原子提交成为唯一事实源。
九、Snowflake 位布局与容量计算
经典 64 位布局常见为:
0 | 41位时间差 | 10位节点 | 12位序列- 最高符号位固定 0,保持正数。
- 41 位毫秒时间差约可使用
2^41毫秒,约 69.7 年。 - 10 位节点最多 1024 个 Worker 身份。
- 12 位序列每毫秒最多 4096 个 ID。
- 单 Worker 理论上约 409.6 万 ID/秒,但真实吞吐受锁、时钟读取、CPU 和调用方式影响。
位宽不是固定标准。可以拆成地域位、机房位、进程位,但每多给节点一位,就少一位给时间或序列。自定义 Epoch 必须文档化且不可随意修改,否则同一部署中的 ID 解释会变化。
十、Snowflake 一次生成过程
flowchart TD
A["读取当前毫秒now"] --> B{"now与lastTimestamp比较"}
B -- "大于" --> C["序列归零"]
B -- "等于" --> D["序列加一并应用掩码"]
D --> E{"序列是否回到零"}
E -- "是" --> F["等待下一毫秒"]
E -- "否" --> G["继续组装ID"]
B -- "小于" --> H["进入时钟回拨策略"]
C --> G
F --> G
H --> I{"回拨是否在允许窗口"}
I -- "短回拨" --> J["有界等待并再次读取"]
I -- "长回拨" --> K["拒绝、告警或安全切换节点空间"]
J --> G
G --> L["时间差、WorkerId和序列按位或"]生成器通常需要同步或 CAS 保护 lastTimestamp 和 sequence。每个请求新建一个生成器会让序列重新从零开始,同一毫秒极易重复;生成器必须是进程内共享且 WorkerId 唯一的长期对象。
十一、WorkerId 怎样分配
| 分配方式 | 优点 | 风险 |
|---|---|---|
| 静态配置 | 简单明确 | 人工复制配置造成冲突 |
| 数据库注册 | 可审计、可分配 | 注册库可用性、租约与回收竞态 |
| ZooKeeper/etcd 临时身份 | 与会话结合 | 控制面故障、旧身份恢复与 Fencing |
| Kubernetes StatefulSet 序号 | 稳定易理解 | 多集群/地域重名、扩缩容生命周期 |
| IP/MAC 哈希 | 无中心 | 截断碰撞、容器地址复用,不可靠 |
WorkerId 回收必须等待旧进程不可能再生成 ID,或使用 Epoch/启动代际隔离。仅看到租约过期就立即把同一 WorkerId 给新进程,旧进程从长 GC 恢复后仍可能与新进程并发生成。
可选防线:
- 注册中心分配
workerId + generation。 - 生成器定期续租,失联主动停止。
- 长回拨或租约丢失后进入不可恢复停止状态,而不是自动继续。
- 中央系统检测同一 WorkerId 多实例心跳并告警。
- 必要时把启动代际编码进节点位或迁移到集中号段方案。
十二、时钟回拨不能只写“等待一下”
回拨来源包括 NTP Step、人工校时、虚拟机恢复和宿主机异常。策略必须区分:
- 短回拨:在请求 Deadline 和阈值内等待到
lastTimestamp。 - 长回拨:拒绝生成并告警,防止线程无限挂起。
- 逻辑时间:使用持久化最大时间,但要评估未来真实时间追赶和重启恢复。
- 备用节点空间:必须证明不会与仍存活实例冲突,不能随机换 WorkerId。
把较小时间强行提升为 lastTimestamp 可以保持唯一性,但会持续消耗同一逻辑毫秒的序列;序列耗尽后仍需等待或失败。任何“自动修复”都必须写清容量和恢复边界。
十三、JDK 8 Demo:最小 Snowflake 生成器
下面 Demo 用 synchronized 保护 lastTimestamp 和 sequence,重点演示位运算、同毫秒序列、序列耗尽等待下一毫秒和时钟回拨处理。它适合学习原理,不等于生产级组件。
public final class SnowflakeIdGenerator {
private static final long EPOCH = 1704067200000L; // 2024-01-01 00:00:00 UTC
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId范围必须是0到" + MAX_WORKER_ID);
}
this.workerId = workerId;
}
public synchronized long nextId() {
long now = currentTimeMillis();
if (now < lastTimestamp) {
long offset = lastTimestamp - now;
if (offset <= 5L) {
sleep(offset);
now = currentTimeMillis();
if (now < lastTimestamp) {
throw new IllegalStateException("时钟回拨仍未恢复,拒绝生成ID");
}
} else {
throw new IllegalStateException("时钟回拨过大,offset=" + offset + "ms");
}
}
if (now == lastTimestamp) {
sequence = (sequence + 1L) & SEQUENCE_MASK;
if (sequence == 0L) {
now = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = now;
return ((now - EPOCH) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
public ParsedId parse(long id) {
long sequenceValue = id & SEQUENCE_MASK;
long workerValue = (id >> WORKER_ID_SHIFT) & MAX_WORKER_ID;
long timestamp = (id >> TIMESTAMP_SHIFT) + EPOCH;
return new ParsedId(timestamp, workerValue, sequenceValue);
}
private long waitNextMillis(long last) {
long now = currentTimeMillis();
while (now <= last) {
now = currentTimeMillis();
}
return now;
}
private long currentTimeMillis() {
return System.currentTimeMillis();
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待时钟恢复被中断", e);
}
}
static class ParsedId {
final long timestamp;
final long workerId;
final long sequence;
ParsedId(long timestamp, long workerId, long sequence) {
this.timestamp = timestamp;
this.workerId = workerId;
this.sequence = sequence;
}
public String toString() {
return "ParsedId{timestamp=" + timestamp
+ ", workerId=" + workerId
+ ", sequence=" + sequence + '}';
}
}
public static void main(String[] args) {
SnowflakeIdGenerator generator = new SnowflakeIdGenerator(17L);
long id = generator.nextId();
System.out.println(id);
System.out.println(generator.parse(id));
}
}这段代码里有几个关键点:
| 代码点 | 原理 |
|---|---|
workerId 构造时校验 | 节点位只有 10 位,超过 1023 会污染时间位 |
synchronized nextId() | 保护同一生成器的 lastTimestamp 和 sequence |
sequence = sequence + 1 & mask | 同一毫秒内递增,超过 4095 后回到 0 |
waitNextMillis | 同毫秒序列耗尽后等待下一毫秒,不能继续复用序列 |
| 短回拨等待 | 小范围 NTP 校正可以有界等待 |
| 长回拨拒绝 | 继续生成可能复用历史时间和序列组合 |
生产级 Snowflake 还需要:
- WorkerId 分配中心和冲突检测。
- 时钟回拨次数、偏移量和拒绝次数监控。
- NTP 策略和禁止人工随意校时。
- 生成器单例化,不能每次请求 new。
- 服务重启、长 GC、容器迁移和多机房演练。
- 对外订单号脱敏,不能把可解码内部 ID 当安全边界。
十四、ID 对数据库 B+Tree 的影响
聚簇主键决定数据页物理组织时,随机 UUID 会把插入分散到树的不同位置,增加随机写、页分裂、缓存失效和索引体积;趋势递增 ID 通常集中写右侧叶子页,局部性更好。
但单调热点也可能在极高并发分布式存储中形成单分片热点。应结合具体数据库:MySQL InnoDB 聚簇索引、PostgreSQL Heap + B-Tree、LSM 系统和分片键的行为不同,不能把一个结论套到所有存储。
二级索引叶子通常还会存主键,主键越宽,全部二级索引越大。因此 UUID 字符串作为主键的代价不只在主键索引。
十五、趋势递增 ID 的安全与隐私边界
连续或可解码 ID 可能泄露:
- 业务量和增长速度。
- 大致创建时间。
- 机房或节点信息。
- 可枚举资源路径。
应对方式不是把 ID 当密码,而是:
- 每个接口做资源级授权和租户过滤。
- 对外可使用独立随机 PublicId,内部保留数值主键。
- 日志和监控避免不必要暴露节点位设计。
- 业务单号可加入随机段或校验位,但这不替代鉴权。
十六、商业场景:订单、支付与采集数据如何选 ID
| 数据 | 推荐思路 | 原因 |
|---|---|---|
| 订单表主键 | Snowflake 或号段 bigint | 高吞吐、索引相对友好 |
| 对外订单号 | 日期/渠道/随机或序列规则单独生成 | 客服可识别,不暴露内部主键 |
| 支付请求号 | 稳定业务幂等键 | 重试必须复用同一业务意图 |
| 采集明细 | 单元位 + 趋势 ID,或按设备分区的号段 | 支持路由和批量写入 |
| TraceId | 128 位高概率唯一随机值 | 不进入业务唯一约束 |
| 对账批次号 | 业务日期 + 渠道 + 批次序号 | 可审计、可重跑 |
一个请求可能同时拥有这几种 ID,不能为了“统一”强迫它们全部相同。
十七、生产级 ID 服务架构
flowchart TD
A["业务SDK请求ID"] --> B["本地批量缓存或生成器"]
B --> C{"本地资源是否充足"}
C -- "是" --> D["返回ID并记录指标"]
C -- "否" --> E["向号段服务预取"]
E --> F["数据库原子分配新区间"]
F --> G["装载Next Buffer"]
G --> D
E --> H{"数据库不可用"}
H -- "当前Buffer仍有余量" --> I["短期继续并告警"]
H -- "全部耗尽" --> J["明确失败,禁止复用旧号段"]应监控:每业务 Tag 发号速率、剩余比例、预取延迟、数据库失败、号段切换、回拨次数、WorkerId 冲突、序列溢出、拒绝次数和生成延迟分位数。
十八、从自增 ID 迁移到全局 ID
- 新增
global_id列和唯一索引,不立即替换旧主键。 - 新写入同时生成全局 ID,旧接口继续兼容旧 ID。
- 按主键范围回填历史数据,记录断点和冲突。
- 下游事件和 API 同时携带新旧 ID。
- 消费者逐个切换,并通过契约与数据对账验证。
- 查询流量切到新 ID 后保留旧 ID 映射回滚窗口。
- 所有消费者退出后再决定是否调整主键,避免在线直接重建超大聚簇索引。
不要在一个发布中同时改主键、分库路由和业务单号规则,否则出现错路由时无法确定问题来自哪一层。
十九、ID 冲突或发号失败 Runbook
- 保存冲突 ID、业务 Tag、生成实例、地域、WorkerId、启动代际和生成时间。
- 解码 Snowflake 的时间、节点和序列位,检查是否同 WorkerId/同毫秒重复。
- 查看时钟回拨、NTP Step、虚拟机恢复和长 GC 记录。
- 检查多个实例是否共享静态 WorkerId,租约回收是否过早。
- 号段模式检查数据库区间分配记录、版本、Buffer 和是否错误回收旧段。
- Redis/Sequence 检查恢复旧备份、主从切换和计数回退。
- 立即隔离冲突生成器,禁止靠删除唯一索引继续写。
- 按业务事实确认冲突行归属,建立 ID 映射或人工修复。
- 修复后做高并发、回拨、重启、双实例同 WorkerId 和数据库故障演练。
二十、常见错误
| 错误 | 后果 | 正确方向 |
|---|---|---|
| 全局唯一就等于严格递增 | 误判方案能力 | 分别定义唯一、趋势、连续要求 |
| 回滚后 Sequence 应归还号码 | 追求无意义连续导致耦合 | 接受技术主键空洞 |
| UUID 绝不碰撞 | 放弃数据库约束 | 仍保留唯一索引和冲突处理 |
| 每次请求新建 Snowflake | 序列状态丢失,同毫秒重复 | 进程内共享生成器 |
| WorkerId 用 IP 后几位 | 容器复用或截断碰撞 | 受控分配和冲突检测 |
| 回拨时直接继续生成 | 可能与历史 ID 冲突 | 有界等待、逻辑时间或拒绝 |
| 号段剩余可以回收 | 宕机旧节点恢复后重复发放 | 已分配区间永不重新使用 |
| 用主键代替鉴权 | ID 可枚举导致越权 | 资源级授权和租户隔离 |
