一文详解分布式 ID
前言
分布式系统中,我们经常需要对数据、消息等进行唯一标识,这个唯一标识就是分布式 ID,那么我们如何设计它呢?本文将详细讲述分布式 ID 及其生成方案。
一、为什么需要分布式 ID
目前大部分的系统都已是分布式系统,所以在这种场景的业务开发中,经常会需要唯一 ID 对数据进行标识,比如用户身份标识、消息标识等等。
并且在数据量达到一定规模后,大部分的系统也需要进行分库分表,这种场景下单库的自增 ID 已达不到我们的预期。所以我们需要分布式 ID 来对各种场景的数据进行唯一标识。
二、分布式 ID 的特性
主要特性:
- 全局唯一:分布式 ID 最基本要求,必须全局唯一。
- 高可用:高并发下要保证 ID 的生成效率,避免影响系统。
- 易用性:使用简单,可快速接入。
除此之外根据不同场景还有:
- 有序性:数据库场景下的主键 ID,有序性可便于数据写入和排序。
- 安全性:无规则 ID,一般用于避免业务信息泄露场景,如订单量。
二、分布式 ID 常见生成方案
1. UUID
UUID(Universally Unique Identifier),即通用唯一识别码。UUID 的目的是让分布式系统中的所有元素都能有唯一的识别信息。
UUID 是由128位二进制数组成,通常表示为32个十六进制字符,形如:
550e8400-e29b-41d4-a716-446655440000
这个字符串由五个部分组成,以连字符-
分隔开,具体如下:
部分 | 大小 | 说明 |
---|---|---|
时间戳 | 32 bits | UUID的前32位表示当前的时间戳。 |
时钟序列和随机数 | 16 bits | 用于保证在同一时刻生成的UUID的唯一性。 |
变体标识 | 4 bits | 标识 UUID 的变体,通常为固定值,表示是由 RFC 4122 定义。 |
版本号 | 4 bits | 标识UUID的版本,常见版本有1、3、4和5 |
节点 | 48 bits | 在版本 1 中,这部分包含生成 UUID 的计算机的唯一标识。 |
主要的 UUID 版本及其生成规则:
版本 | 场景 | 生成规则 |
---|---|---|
版本 1 | 基于时间和节点 | 由当前的时间戳和节点信息生成。包括时间戳、时钟序列、节点标识。 |
版本 2 | 基于DCE安全标识符 | 类似版本 1,但在时间戳部分包含 POSIX UID/GID 信息。 |
版本 3 | 基于名字和散列值(MD5 版) | 由命名空间和名字的MD5散列生成。 |
版本 4 | 完全随机生成 | 通过随机或伪随机生成128位数字。 |
版本 5 | 基于名字和散列值(SHA-1 版) | 通过随机或伪随机生成128位数字。 |
Java中 UUID 对版本 4 进行了实现:
public static void main (String[] args) {
// 默认版本 4
System.out.println(UUID.randomUUID());
// 版本 3,由命名空间和名字的MD5散列生成,相同命名空间结果相同
// 如下,"fuxing"返回的UUID一直为8b9b6bc3-90c8-37ef-bbef-0ed0c552718f
System.out.println(UUID.nameUUIDFromBytes("fuxing".getBytes()));
}
优点: 本地生成,没有网络消耗,性能非常高。
缺点:
- 占用空间大,32 个字符串,128 位。
- 不安全:版本 1 可能会造成 mac 地址泄露。
- 无序,非自增。
- 机器时间不对,可能造成 UUID 重复。
2. 数据库自增 ID
实现简单,解释通过数据库表中的主键 ID 自增来生成唯一标识。如下,维护一个 MySQL 表来生成 ID。
CREATE TABLE `unique_id` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`value` char(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
当需要生成分布式 ID 时,向表中插入数据并返回主键 ID,这里 value 无含义,只是为了占位,方便插入数据。
优点: 实现简单,基本满足业务需求,且天然有序。
缺点:
- 数据库自身的单点故障和数据一致性问题。
- 不安全,比如可根据自增来判断订单量。
- 高并发场景可能会受限于数据库瓶颈。
那么有没有办法解决数据库自增 ID 的缺点呢?
通过水平拆分的方案,将表设置到不同的数据库中,设置不同的起始值和步长,这样可以有效的生成集群中的唯一 ID,也大大降低 ID 生成数据库操作的负载,示例如下。
unique_id_1
配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
unique_id_2
配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
这个还是需要根据自己的业务需求来,水平扩展的集群数量要符合自己的数据量,因为当设置的集群数量不足以满足高并发时,再次进行扩容集群会很麻烦。多台机器的起始值和步长都需要重新配置。
3. 数据库号段模式
号段模式是当下分布式 ID 生成器的主流实现方式之一,比如美团 Leaf-segment、滴滴 Tinyid、微信序列号等都使用的该方案,下面的大厂中间件中会展开说明。
号段模式也是基于数据库的自增 ID,数据库自增 ID 是一次性获取一个分布式 ID,号段模式可以理解成从数据库批量获取 ID,然后将 ID 缓存在本地,以此来提高业务获取 ID 的效率。
例如,每次从数据库获取 ID 时,获取一个号段,如(1,1000],这个范围表示 1000 个 ID,业务应用在请求获取 ID 时,只需要在本地从 1 开始自增并返回,而不用每次去请求数据库,一直到本地自增到 1000 时,才去数据库重新获取新的号段,后续流程循环往复。
表结构可进行如下设计:
CREATE TABLE `id_generator` (
`id` int(10) NOT NULL,
`max_id` bigint(20) NOT NULL COMMENT '当前最大id',
`step` int(10) NOT NULL COMMENT '号段的步长',
`version` int(20) NOT NULL COMMENT '版本号',
`biz_type` int(20) NOT NULL COMMENT '业务场景',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
其中max_id
和step
用于获取批量的 ID,version
是一个乐观锁,保证并发时数据的正确性。
比如,我们新增一条表数据如下。
id | max_id | step | version | biz_type |
---|---|---|---|---|
1 | 100 | 1000 | 0 | 001 |
然后我们可以使用该号段批量生成的 ID,当max_id = 1000
,则执行 update 操作生成新的号段。新的号段的 SQL。
UPDATE
id_generator
SET
max_id = #{max_id+ step},
version = version + 1
WHERE
version = #{version} AND biz_type = 001;
优点: ID 有序递增、存储消耗空间小。
缺点:
- 数据库自身的单点故障和数据一致性问题。
- 不安全,比如可根据自增来判断订单量。
- 高并发场景可能会受限于数据库瓶颈。
缺点同样可以通过集群的方式进行优化,也可以如Tinyid 采用双缓存进行优化,下面的大厂中间件中会展开说明。
4. 基于Redis
当使用数据库来生成 ID 性能不够的时候,可以尝试使用 Redis 来生成 ID。原理则是利用 Redis 的原子操作 INCR 和 INCRBY 来实现。
命令示例:
命令 | 说明 | 示例 |
---|---|---|
INCR | 让一个整形的key自增1 | redis> SET mykey "10" "OK" redis> INCR mykey (integer) 11 |
INCRBY | 让一个整形的key自增并指定步长 | redis> SET mykey "10" "OK" redis> INCRBY mykey 5 (integer) 15 |
优点: 不依赖于数据库,使用灵活,支持高并发。
缺点:
- 系统须引入 Redis 数据库。
- 不安全,比如可根据自增来判断订单量。
- Redis 出现单点故障问题,可能会丢数据导致 ID 重复。
5. 雪花算法
雪花算法(Snowflake)是 twitter 公司内部分布式项目采用的 ID 生成算法。结果是一个 long 型的 ID。Snowflake 算法将 64bit 划分为多段,分开来标识机器、时间等信息,具体组成结构如下图所示:
结构说明:
结构 | 大小 | 说明 |
---|---|---|
符号位 | 1 bit | 0 表示正数,1 表示负数。 |
时间戳 | 41 bits | 存储的是当前时间戳 - 开始时间戳 ,最长 69 年。 |
机器位 | 10 bits | 前 5位 datacenterId,后 5 位 workerId ,最多表示 1024 台。 |
序列号 | 12 bits | 毫秒内的流水号,意味着每个节点在每毫秒可以产生 4096 个 ID。 |
优点:
稳定性高,不依赖于数据库等第三方系统。
使用灵活方便,可以根据业务需求的特性来调整算法中的 bit 位。
单机上 ID 单调自增,毫秒数在高位,自增序列在低位,整体上按照时间自增排序。
缺点:
- 强依赖机器时钟,如果机器上时钟回拨,可能导致发号重复或者服务处于不可用状态。
- ID 可能不是全局递增,虽然 ID 在单机上是递增的。
- Redis 出现单点故障问题,可能会丢数据导致 ID 重复。
算法实现(Java):
package util;
import java.util.Date;
/**
* @ClassName: SnowFlakeUtil
* @Author: jiaoxian
* @Date: 2022/4/24 16:34
* @Description:
*/
public class SnowFlakeUtil {
private static SnowFlakeUtil snowFlakeUtil;
static {
snowFlakeUtil = new SnowFlakeUtil();
}
// 初始时间戳(纪年),可用雪花算法服务上线时间戳的值
// 1650789964886:2022-04-24 16:45:59
private static final long INIT_EPOCH = 1650789964886L;
// 时间位取&
private static final long TIME_BIT = 0b1111111111111111111111111111111111111111110000000000000000000000L;
// 记录最后使用的毫秒时间戳,主要用于判断是否同一毫秒,以及用于服务器时钟回拨判断
private long lastTimeMillis = -1L;
// dataCenterId占用的位数
private static final long DATA_CENTER_ID_BITS = 5L;
// dataCenterId占用5个比特位,最大值31
// 0000000000000000000000000000000000000000000000000000000000011111
private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS);
// dataCenterId
private long dataCenterId;
// workId占用的位数
private static final long WORKER_ID_BITS = 5L;
// workId占用5个比特位,最大值31
// 0000000000000000000000000000000000000000000000000000000000011111
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
// workId
private long workerId;
// 最后12位,代表每毫秒内可产生最大序列号,即 2^12 - 1 = 4095
private static final long SEQUENCE_BITS = 12L;
// 掩码(最低12位为1,高位都为0),主要用于与自增后的序列号进行位与,如果值为0,则代表自增后的序列号超过了4095
// 0000000000000000000000000000000000000000000000000000111111111111
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
// 同一毫秒内的最新序号,最大值可为 2^12 - 1 = 4095
private long sequence;
// workId位需要左移的位数 12
private static final long WORK_ID_SHIFT = SEQUENCE_BITS;
// dataCenterId位需要左移的位数 12+5
private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
// 时间戳需要左移的位数 12+5+5
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;
/**
* 无参构造
*/
public SnowFlakeUtil() {
this(1, 1);
}
/**
* 有参构造
* @param dataCenterId
* @param workerId
*/
public SnowFlakeUtil(long dataCenterId, long workerId) {
// 检查dataCenterId的合法值
if (dataCenterId < 0 || dataCenterId > MAX_DATA_CENTER_ID) {
throw new IllegalArgumentException(
String.format("dataCenterId 值必须大于 0 并且小于 %d", MAX_DATA_CENTER_ID));
}
// 检查workId的合法值
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException(String.format("workId 值必须大于 0 并且小于 %d", MAX_WORKER_ID));
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
}
/**
* 获取唯一ID
* @return
*/
public static Long getSnowFlakeId() {
return snowFlakeUtil.nextId();
}
/**
* 通过雪花算法生成下一个id,注意这里使用synchronized同步
* @return 唯一id
*/
public synchronized long nextId() {
long currentTimeMillis = System.currentTimeMillis();
System.out.println(currentTimeMillis);
// 当前时间小于上一次生成id使用的时间,可能出现服务器时钟回拨问题
if (currentTimeMillis < lastTimeMillis) {
throw new RuntimeException(
String.format("可能出现服务器时钟回拨问题,请检查服务器时间。当前服务器时间戳:%d,上一次使用时间戳:%d", currentTimeMillis,
lastTimeMillis));
}
if (currentTimeMillis == lastTimeMillis) {
// 还是在同一毫秒内,则将序列号递增1,序列号最大值为4095
// 序列号的最大值是4095,使用掩码(最低12位为1,高位都为0)进行位与运行后如果值为0,则自增后的序列号超过了4095
// 那么就使用新的时间戳
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
currentTimeMillis = getNextMillis(lastTimeMillis);
}
} else { // 不在同一毫秒内,则序列号重新从0开始,序列号最大值为4095
sequence = 0;
}
// 记录最后一次使用的毫秒时间戳
lastTimeMillis = currentTimeMillis;
// 核心算法,将不同部分的数值移动到指定的位置,然后进行或运行
// <<:左移运算符, 1 << 2 即将二进制的 1 扩大 2^2 倍
// |:位或运算符, 是把某两个数中, 只要其中一个的某一位为1, 则结果的该位就为1
// 优先级:<< > |
return
// 时间戳部分
((currentTimeMillis - INIT_EPOCH) << TIMESTAMP_SHIFT)
// 数据中心部分
| (dataCenterId << DATA_CENTER_ID_SHIFT)
// 机器表示部分
| (workerId << WORK_ID_SHIFT)
// 序列号部分
| sequence;
}
/**
* 获取指定时间戳的接下来的时间戳,也可以说是下一毫秒
* @param lastTimeMillis 指定毫秒时间戳
* @return 时间戳
*/
private long getNextMillis(long lastTimeMillis) {
long currentTimeMillis = System.currentTimeMillis();
while (currentTimeMillis <= lastTimeMillis) {
currentTimeMillis = System.currentTimeMillis();
}
return currentTimeMillis;
}
/**
* 获取随机字符串,length=13
* @return
*/
public static String getRandomStr() {
return Long.toString(getSnowFlakeId(), Character.MAX_RADIX);
}
/**
* 从ID中获取时间
* @param id 由此类生成的ID
* @return
*/
public static Date getTimeBySnowFlakeId(long id) {
return new Date(((TIME_BIT & id) >> 22) + INIT_EPOCH);
}
public static void main(String[] args) {
SnowFlakeUtil snowFlakeUtil = new SnowFlakeUtil();
long id = snowFlakeUtil.nextId();
System.out.println(id);
Date date = SnowFlakeUtil.getTimeBySnowFlakeId(id);
System.out.println(date);
long time = date.getTime();
System.out.println(time);
System.out.println(getRandomStr());
}
}
四、大厂中间件
1. 美团 Leaf
Leaf 的官方文档,简介和特性可访问了解,这里我将对 Leaf 的两种方案,Leaf segment 和 Leaf-snowflake 进行。
1.1. Leaf segment
基于数据库号段模式的 ID 生成方案,上面我们介绍到普通的号段模式有一些缺点:
- 数据库自身的单点故障和数据一致性问题。
- 不安全,比如可根据自增来判断订单量。
- 高并发场景可能会受限于数据库瓶颈。
那么 Leaf 是如何做的呢?Leaf 采用了预分发的方式生成ID,也就是在 DB 之上挂 n 个 Leaf Server,每个Server启动时,都会去 DB 拿固定长度的 ID List。
这样就做到了完全基于分布式的架构,同时因为ID是由内存分发,所以也可以做到很高效,处理流程图如下:
其中:
- Leaf Server 1:从 DB 加载号段[1,1000]。
- Leaf Server 2:从 DB 加载号段[1001,2000]。
- Leaf Server 3:从 DB 加载号段[2001,3000]。
用户通过轮询的方式调用 Leaf Server 的各个服务,所以某一个 Client 获取到的ID序列可能是:1,1001,2001,2,1002,2002。当某个 Leaf Server 号段用完之后,下一次请求就会从 DB 中加载新的号段,这样保证了每次加载的号段是递增的。
为了解决在更新DB号段的时出现的耗时和阻塞服务的问题,Leaf 采用了异步更新的策略,同时通过双缓存的方式,保证无论何时DB出现问题,都能有一个 Buffer 的号段可以正常对外提供服务,只要 DB 在一个 Buffer 的下发的周期内恢复,就不会影响整个 Leaf 的可用性。
除此之外,Leaf 还通过动态调整步长,解决由于步长固定导致的缓存中的 ID 被过快消耗问题,以及步长设置过大导致的号段 ID 跨度过大问题,具体公式可去官方文档中了解。
对于数据一致性问题,Leaf 目前是通过中间件 Zebra 加 MHA 做的主从切换。
1.2. Leaf Snowflake
Leaf-snowflake 方案沿用 Snowflake 方案的 bit 位设计。
对于 workerID 的分配:当服务集群较小时,通过配置即可;服务集群较大时,基于 zookeeper 持久顺序节点的特性引入 zookeeper 组件配置 workerID。架构如下图所示:
2. 百度 UidGenerator
UidGenerator 方案是基于 Snowflake 算法的 ID 生成器,其对雪花算法的 bit 位的分配做了微调。
结构说明(参数可在 Spring Bean 配置中进行配置):
结构 | 大小 | 说明 |
---|---|---|
符号位 | 1 bit | 最高位始终是 0。 |
增量秒 | 28 bits | 表示自客户纪元(2016-05-20)以来的增量秒。最大时间为 8.7 年。 |
工作节点 | 22 bits | 表示工作节点 ID,最大值为 4.2 百万个。 |
序列号 | 13 bits | 表示一秒钟内的序列,默认情况下每秒最多 8192 个。 |
UidGenerator 方案包含两种实现方式,DefaultUidGenerator 和 CachedUidGenerator ,性能要求高的情况下推荐 CachedUidGenerator。
3. 滴滴 Tinyid
Tinyid 方案是在 Leaf-segment 的算法基础上升级而来,不仅支持了数据库多主节点模式,还提供了 tinyid-client 客户端的接入方式,使用起来更加方便。
Tinyid 也是采用了异步加双缓存策略,首先可用号段加载到内存中,并在内存中生成 ID,可用号段在首次获取 ID 时加载,号段用到一定程度的时候,就去异步加载下一个号段,保证内存中始终有可用号段,则可避免性能波动。
实现原理如下所示:
五、结语
本文对分布式 ID 以及其场景的生成方案做了介绍,还针对一下大厂的中间件进行简单分析,中间件的接入代码本文并没有做详细介绍,但是官方文档的链接都帖子了每个子标题下,其中都有详细介绍。
文中还针对每个生成方案的优缺点作出了说明,具体的使用可针对优缺点加上业务需求来进行选型。
参考:
一文详解分布式 ID的更多相关文章
- 一文详解Hexo+Github小白建站
作者:玩世不恭的Coder时间:2020-03-08说明:本文为原创文章,未经允许不可转载,转载前请联系作者 一文详解Hexo+Github小白建站 前言 GitHub是一个面向开源及私有软件项目的托 ...
- 一文详解 Linux 系统常用监控工一文详解 Linux 系统常用监控工具(top,htop,iotop,iftop)具(top,htop,iotop,iftop)
一文详解 Linux 系统常用监控工具(top,htop,iotop,iftop) 概 述 本文主要记录一下 Linux 系统上一些常用的系统监控工具,非常好用.正所谓磨刀不误砍柴工,花点时间 ...
- 1.3w字,一文详解死锁!
死锁(Dead Lock)指的是两个或两个以上的运算单元(进程.线程或协程),都在等待对方停止执行,以取得系统资源,但是没有一方提前退出,就称为死锁. 1.死锁演示 死锁的形成分为两个方面,一个是使用 ...
- 一文详解 OpenGL ES 3.x 渲染管线
OpenGL ES 构建的三维空间,其中的三维实体由许多的三角形拼接构成.如下图左侧所示的三维实体圆锥,其由许多三角形按照一定规律拼接构成.而组成圆锥的每一个三角形,其任意一个顶点由三维空间中 x.y ...
- 一文详解 WebSocket 网络协议
WebSocket 协议运行在TCP协议之上,与Http协议同属于应用层网络数据传输协议.WebSocket相比于Http协议最大的特点是:允许服务端主动向客户端推送数据(从而解决Http 1.1协议 ...
- 一文详解如何在基于webpack5的react项目中使用svg
本文主要讨论基于webpack5+TypeScript的React项目(cra.craco底层本质都是使用webpack,所以同理)在2023年的今天是如何在项目中使用svg资源的. 首先,假定您已经 ...
- 一文详解Redis键过期策略
摘要:Redis采用的过期策略:惰性删除+定期删除. 本文分享自华为云社区<Redis键过期策略详解>,作者:JavaEdge. 1 设置带过期时间的 key # 时间复杂度:O(1),最 ...
- 一文详解 Linux Crontab 调度任务
最近接到这样一个任务: 定期(每天.每月)向"特定服务器"传输"软件服务"的运营数据,因此这里涉及到一个定时任务,计划使用Python语言添加Crontab依赖 ...
- 一文详解|Go 分布式链路追踪实现原理
在分布式.微服务架构下,应用一个请求往往贯穿多个分布式服务,这给应用的故障排查.性能优化带来新的挑战.分布式链路追踪作为解决分布式应用可观测问题的重要技术,愈发成为分布式应用不可缺少的基础设施.本文将 ...
- Hadoop MapReduce 一文详解MapReduce及工作机制
@ 目录 前言-MR概述 1.Hadoop MapReduce设计思想及优缺点 设计思想 优点: 缺点: 2. Hadoop MapReduce核心思想 3.MapReduce工作机制 剖析MapRe ...
随机推荐
- NopCommerce支持多种类型的数据库
本文章的内容是根据本人阅读NopCommerce源码的理解,如有不对的地方请指正,谢谢. 阅读目录 1.类结构关系图 2.分析 3.NopCommerce应用 类结构关系图 分析 NopObjectC ...
- Django之路由层、视图层、模板层介绍
一.Django请求生命周期 1.路由层urls.py Django 1.11版本 URLConf官方文档 1.1 urls.py配置基本格式 from django.conf.urls import ...
- selenium操作浏览器模块
selenium模块用途 selenuim原先多用于测试部门测试,由于它可以操作浏览器,有时候也用于爬虫领域 优点:操作浏览器访问网站 缺点:速度较慢 下载模块 # 下载模块 pip3 install ...
- 利用python爬取某壳的房产数据
以无锡的某壳为例进行数据爬取,现在房子的价格起伏很快,买房是人生一个大事,了解本地的房价走势来判断是否应该入手. (建议是近2年不买,本人在21年高位抛了一套房,基本是通过贝壳数据判断房价已经到顶,希 ...
- 【详细教程】手把手教你开通YouTube官方API接口(youtube data api v3)
一.背景调查 1.1 youtube介绍 众所周知,youtube是目前全球最大的视频社交平台,该平台每天产生大量的视频内容,涵盖各种主题和类型,从音乐视频到教育内容,再到娱乐节目和新闻报道等.You ...
- 一些简单的Post方式WebApi接收参数和传递参数的方法及总结
原文地址:https://www.zhaimaojun.top/Note/5475297(我自己的博客网站) 各种Post方式上传参数到服务器,服务器接收各种参数的示例 webapi可以说是很常用了, ...
- Python中强大的通用ORM框架:SQLAlchemy
Python中强大的通用ORM框架:SQLAlchemy https://zhuanlan.zhihu.com/p/444930067
- uniapp中使用极光推送
1.注册极光账号 2.注册几个主流手机厂商的开发者账号(注册手机厂商,可以保证app进程不在的时候走厂商通道推送消息) 3.配置uniapp极光插件 https://ext.dcloud.net.cn ...
- 题解:CF1956A Nene's Game
这道题其实挺有意思,多测里面还套了个多测. 思路就是用向量模拟删除过程,具体请看代码里的注释. #include <bits/stdc++.h> using namespace std; ...
- 在项目中使用UEditor碰到的几个问题
1.文本编辑器的下拉框无法使用.即选择字号字体的下拉选择框无法使用. 通过调试,发现不是编辑器的下拉框没有出来,而是下拉框显示在弹出框的底部,猜测是否和z-index属性有关. 产生这个问题的原因是文 ...