dbaplus社群 14小时前
别再用雪花算法生成ID了!试试这个吧
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文探讨了在服务器中生成唯一ID的方案,特别是在需要尽量短的数字ID的背景下。最初考虑雪花算法,但因其生成的64位ID过长而放弃。改进方案包括利用数据库自增ID特性,但遇到并发死锁问题。最终方案参考美团技术团队的文章,采用预申请一批ID的方式,并优化算法以减少ID浪费,兼容登录服扩容与容灾。该方案通过发号表记录号段信息,登录服在停服维护时回写剩余数量,实现了短ID的生成,并经过生产环境测试,运行稳定。

💡 针对账号ID过长的问题,最初考虑雪花算法,但最终因64位ID过长,不便于用户输入和记忆,以及不利于数据库写入性能而放弃。

🛠️ 改进版本尝试利用MySQL数据库的自增ID特性,通过专门的ID生成表和`REPLACE INTO`语句,但高并发下出现死锁问题,导致该方案也被放弃。

🚀 最终方案参考美团技术团队的思路,采用预申请一批ID的方式,每次申请1000个,减少请求量和并发,并通过`tbl_account_freeid`表记录号段信息,登录服在停服时回写剩余数量,减少ID浪费。

✅ 该方案兼容登录服扩容与容灾,新增实例启动时会查找对应实例编号的segment,若找到且left不为0,则继续使用该号段,否则申请新的号段,从而保证ID的唯一性和连续性。

2025-05-26 07:15 广东

雪花算法看起来无懈可击,但唯一的问题是生成的64位ID太长了。

今天聊聊服务器中唯一ID生成。唯一ID生成中雪花算法大家都比较熟,那如果加一个要求呢:

尽量短的数字ID

背景

之前的项目有个需求:为用户账号生成账号ID。最开始用的是UUID(长字符串ID),但是字符串账号相对于数字账号,存储和传输性能都稍逊,也不利于记忆和传播。

因此,生成一套业务内的数字账号,并且尽量简短就是当务之急。

初步版本

我们最开始考虑的是雪花算法方案,使用的是经典的 twitter开源的算法 snowflake。这个算法非常强大,生成的是 64bit 的数字id,天然支持分布式。

有关这个算法的详细分析,可以移步:

雪花算法看起来无懈可击,但是唯一的问题就是生成的64位 ID 太长了。账号ID希望能控制的尽量短,个人理解有以下原因:

    账号id一般显示在个人设置里,会暴露给用户,需要便于输入 + 记忆,这样客服查询起来更方便;

    账号id短并且有序能提高账号库的写入性能;

于是着手改进。

改进版本

一个比较可行的方案是利用数据库的自增 ID 特性。

为了便于理解,我们先来看一下业务里的账号登录流程:

    客户端上传第三方openid及token来登录,登录服拿到openid后需要查询是否已经注册账号

    如果能查到账号ID,表明已经注册,再根据查到的数字账号来做后续登录逻辑

    如果查不到,则需要新注册一个账号到账号表

    新建账号首先需要生成一个数字的账号ID,在目前的机制中,通过一张专门的ID生成表来做的。

OK,先来看我们如何在mysql中存储账号相关信息的:

账号表,accid就是我们说的数字账号。考虑到账号数量级可能到千万甚至上亿,单表的性能肯定不理想,因此我们分了10张表。其表结构为

    CREATE TABLE`tbl_global_user_map_00` (

    `account`varchar(32) NOTNULL,

    `accid`bigint(20) NOTNULL,

    `created_at` datetime DEFAULTNULL,

      PRIMARY KEY (`account`) USING BTREE

      ) ENGINE=InnoDBDEFAULTCHARSET=utf8 

    账号ID生成表,其表结构为

      CREATE TABLE`tbl_accid` (

      `id`bigint(20) NOTNULL AUTO_INCREMENT,

      `stub`char(1) NOTNULLDEFAULT'',

        PRIMARY KEY (`id`) USING BTREE,

        UNIQUEKEY`UQE_tbl_accid_stub` (`stub`) USING BTREE

        ) ENGINE=InnoDBDEFAULTCHARSET=utf8 

      数据为

      整个表只有一行数据,id列为自增列,它的值就是最新生成的账号ID值。这个ID生成的原理是:

        设置id列为自增,这样每插入一列id值就会自动递增

        如果没有其他限制,这张表的数据就会随着insert的次数越来越多,假如账号有几千万,这张表就有几千万行数据

        为此,我们增加了一列 stub,设置其为 unique key,并且每次insert其值都是一样的(例如设置为 'a'),这样就保证整个表只有一行数据,而id会随着每次insert自动递增。

        如果直接用 insert into 语句来做插入,肯定每次都返回错误(除了第1次),因为 stub 为 ‘a’ 的记录已经存在了,每次插入都会失败。

        我们改用 MySQL 扩展的 SQL 语句 replace into 来实现。replace 必须要配合唯一索引来使用。

      于是 SQL 语句就是

        REPLACE INTO tbl_accid(`stub`) VALUES('a');

        它的效果如下:

          如果 stub 为 'a' 的记录不存在,则插入,类似 insert 操作

          如果 stub 为 'a' 的记录已经存在,则先 delete 该条记录,再 insert 新记录。由于删除已有的记录时,表的自增值不会变化,再新增记录时 id 会在老的自增值基础上继续递增

        有同学可能要问了,为什么要搞一个单独的ID生成表来生成自增id?将自增字段直接放到账号表中不行么?

        关键的问题在于业务要分表。假如账号表分了10张,要合并自增id列的话,需要划分好每张表的生成范围。

        例如我们设计每张表可以生成 100w 个id,那 10 张表的起始id 分别是 1, 1000001,2000001, ...

        跨度非常大,和我们当初的设计:简短并尽量连续的要求违背。

        因此,专门的账号ID生成表是必要的。

        问题暴露

        上述方案完成之后,我就去吃火锅唱歌去了。

        然后,就出现了一个比较棘手的问题。某天晚上QA同事反馈压力测试有报错,登录服会间歇性返回db错误,如下:

          ERROR : Deadlock found when trying to get locktry restarting transaction

          登录服收到该返回后打印了错误日志,提示客户端服务器发生错误。很明显,这个方案有死锁问题。

          google了一下 replace 在并发情况下的死锁问题,大致和 replace 被分解成 delete + insert 有关,而 innodb又是行锁机制。详细的原因非常复杂,有关资料为

          很多博客也给出了建议:

          通过几个死锁案例,我们强烈建议在生产环境中尽量避免使用REPLACE INTO和INSERT INTO ON DUPLICATE UPDATE语句,改用普通INSERT操作,并对INSER操作部分代码加入异常加查,当INSERT失败时改为UPDATE操作。

          为了再验证一次死锁的并非语言或者API的bug,我用了 mysql 自带的压测工具 mysqlslap 做了个简单测试:

            mysqlslap -uroot -p --create-schema="db_global_200" --concurrency=2 --iterations=5 --number-of-queries=500 --query="replace_innodb.sql"

            mysqlslap: Cannot run query REPLACE INTO tbl_yptest_innodb(`stub`VALUES('a'); ERROR : Deadlock found when trying to get locktry restarting transaction

            结果显示并发数为 2 时就出现了死锁问题。然后我又尝试将表引擎改为 myisam,再次压测,虽然没有出现死锁问题,但是MYISAM引擎更新数据的效率比较低。因此我们不得不放弃了mysql自增ID的方案,再想其他方案。

            其他方案1

            继续尝试其他方案。其实,我们最新的ID生成方案参考了美团技术团队的一篇文章,有兴趣的可以查阅:

            文中提到了一种Flickr团队的改进方案:

            即:使用 N 个mysqlserver,来提高可用性,降低每个 mysqlserver的压力和并发数。如果 replace into 不支持并发,那就部署尽可能多的 mysqlserver,每次 replace into 时串行。

            然而这种方式部署限制和消耗都太大,而且我们的登录服是多开的,即使在单登录服内控制串行,多个进程也不好控制,因此这个初始的方案只能被pass。

            回到开始的思路,能不能将自增id合并到 账号表_xx 中,从而放弃 replace 呢?

            我们可以将每个 tbl_global_user_map 分表类比成上图中的 mysql-01, mysql-02, ...

            然后自增时,采取 间隔步长N 的方式(默认的自增步长是1,每次自增加1)

            举例:

            tbl_global_user_map_00 表,起始id 20000,每次加10,其生成的 id 每次是 20000, 20010, 20020, 20030...

            tbl_global_user_map_01 表,起始id 20001,也是每次加10,其生成的 id 每次是 20001, 20011, 20021, 20031...

            这个id看起来间隔很小,看起来非常理想。

            需要做的事情就是设置 auto_increment_increment 和 auto_increment_offset 两个mysql中的变量。

            然后很可惜,这两个变量属于 全局 或者 session(连接会话) 级别,没有 table 级别的设置。

            如果我们设置了这两个变量,很容易影响其他表,产生其他错误。

            其他方案2

            再想其他方案。

            仔细整理一下我们的需求,就会发现我们的账号表一般只有新增,没有删除和修改。能不能利用读写分离的思想,在插入新映射关系(同时生成自增账号ID)时,只有一张表可写,自增id可以每次只加1;

            而查询时,属于读,读的数据可以分布在10张表中。我们要做的就是定期将可写表中已有的一些数据迁移到只读的这10张表中(根据账号ID做shard),控制可写表的数量级不能太大。

            账号ID在写表中自增,相当于自动分配账号ID。

            这个机制有点类似于我们的日志滚动,当前正在写的日志文件不停被写入(插入日志),当超过一定大小或者日期切换时会滚动成只读的文件。

            这个方案理论上可行,但是有运维复杂性:需要配合运维来做数据迁移,维护成本比较高,因此组内讨论后我们决定pass掉。

            其他方案3(最终方案)

            我之前所在的成熟项目也用过上述【其他方案1】中类似美团的方案,即预申请一批ID的方式。

            对比来看,我们之前申请ID都是一次自增1,而这种预申请一批的方式,是一次申请N个ID,自增N,可以减少请求量和并发。当请求量明显下降后,之前方案里担忧的问题:ID生成表插入行数过多也就不存在了。

            唯一的问题是:预申请的ID可能会被浪费。如果申请了一段区间的id,但是没有用完,服务器停服再启动后会再申请一段新的,原来未使用的ID就被浪费了。

            因此我们着手优化这种算法,目的很明显:

            减少浪费的ID,去除空洞号段,并自动兼容登录服扩容与容灾的情况。

            如果这个目的能达成,那就完美契合了我们当初的需求。

            短ID方案细节

            设计发号表 tbl_account_freeid

            号段编号,自增

            svr编号

            号段内剩余freeid数量

            segment

            loginsvr

            left

            每个登陆服要申请一批账号ID时,就来表中插入一行,规定每次申请1000个,由于segment自增,相当于申请了 [(segment - 1) * 1000, segment * 1000) 这段区间,申请时候默认 left 是 0

            登录服正常停服维护时将剩余未用完的数量写入 left,防止浪费,下次启动时候还可以再利用。

            以下分析各种case:

            a) 初始 tbl_account_freeid 没有数据,假如 loginsvr 开3个实例,实例编号分别是1,2,3。

            服务器启动时候需要做一次查找,要找对应 实例编号的segment。如果找到了,且 left 不为 0,则说明该号段还可以用;如果找不到,或者left为0,则需要新申请(新插入一行记录)。

            于是第一次启服后数据为

            号段编号,自增

            svr编号

            号段内剩余freeid数量

            segment

            loginsvr

            left

            1

            2

            0

            内存中号段为 1-1000

            2

            1

            0

            内存中号段为1001-2000

            3

            3

            0

            内存中号段为2001-3000

            b) 如果loginsvr发现内存中号段用完了,就不用再查找,直接申请,往数据库插入一行数据,假定实例编号 1 和 3 的 号段用完了,新申请。

            然后各个登录服正常停服,left 回写。可能的数据情况如下:

            号段编号,自增

            svr编号

            号段内剩余freeid数量

            segment

            loginsvr

            left

            1

            2

            200

            2

            1

            0

            (使用完毕,该行可删)

            3

            3

            0

            (使用完毕,该行可删)

            4

            1

            800

            5

            3

            750

            c) 再次起服时,查找到各个编号的实例都有号段可用。无需新插入数据,但是对应的 left 要改为0(相当于申请了 left 个)

            号段编号,自增

            svr编号

            号段内剩余freeid数量

            segment

            loginsvr

            left

            1

            2

            0

            登录服内存中号段为 801-1000(剩余的200个)

            2

            1

            0

            (使用完毕,该行可删除)

            3

            3

            0

            (使用完毕,该行可删除)

            4

            1

            0

            登录服内存中号段为3201-4000(剩余的800个)

            5

            3

            0

            登录服内存中号段为4251-5000 (剩余的750个)

            d) 如果此时 loginsvr 扩容,新增编号 4 - 10 的 svr,和初始情况类似,需要先查找,没有则申请。此时数据可能为

            号段编号,自增

            svr编号

            号段内剩余freeid数量

            segment

            loginsvr

            left

            1

            2

            0

            登录服内存中号段为 801-1000(剩余的200个)

            2

            1

            0

            (使用完毕,该行可删除)

            3

            3

            0

            (使用完毕,该行可删除)

            4

            1

            0

            登录服内存中号段为3201-4000(剩余的800个)

            5

            3

            0

            登录服内存中号段为4251-5000 (剩余的750个)

            6

            4

            0

            登录服内存中号段为5001-6000

            7

            5

            0

            登录服内存中号段为6001-7000

            8

            6

            0

            登录服内存中号段为7001-8000

            9

            7

            0

            登录服内存中号段为8001-9000

            10

            8

            0

            登录服内存中号段为9001-10000

            11

            9

            0

            登录服内存中号段为10001-11000

            12

            10

            0

            登录服内存中号段为11001-12000

            这种方式的特点就是,登录服服务过程中,对应数据库里的 left 为 0,如果停了,数据库里 left 为号段内剩余的可用数量。

            如果登录服宕机,则没有回写 left 的过程,则对应号段内没有用完的(最多1000)会浪费。

            结尾

            以上就是我们在账号短ID生成上的探索,最终方案经历了长时间的生产环境的测试,运转正常。如果小伙伴们有更优秀的经验欢迎一起交流。

            作者丨码财同行

            来源丨网址:juejin.cn/post/7393656776540291087

            dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn

            阅读原文

            跳转微信打开

            Fish AI Reader

            Fish AI Reader

            AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

            FishAI

            FishAI

            鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

            联系邮箱 441953276@qq.com

            相关标签

            唯一ID生成 雪花算法 MySQL自增ID 预申请ID
            相关文章