在分布式系统中生成全局唯一ID一直是个经典问题。传统自增ID在分库分表场景下会面临冲突问题,而UUID虽然唯一但无序且占用空间大。Twitter开源的雪花算法(Snowflake)完美解决了这些痛点,它生成的ID不仅全局唯一,还带有时间顺序信息。
雪花ID的64位结构非常精巧:
最近我在做数据迁移时遇到个典型场景:需要将表A的特定数据补偿到表B,而表B的主键是雪花ID。常规做法是用Java代码生成ID,但这次我希望完全用SQL实现。经过多次验证,最终通过MySQL存储函数成功实现了雪花算法。
雪花算法对时间戳有严格要求:
sql复制DECLARE epoch BIGINT DEFAULT 1288834974657; -- 2010-01-01基准时间
SET timestamp = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - epoch;
这里有几个关键点:
NOW(3)获取毫秒级当前时间UNIX_TIMESTAMP()转为时间戳(秒级)FLOOR()确保取整特别注意:MySQL5.6以下版本不支持毫秒精度,必须使用5.7+版本。我曾因版本问题导致生成的ID重复,排查了半天才发现是这个原因。
在同一毫秒内需要递增序列号:
sql复制IF timestamp = @last_timestamp THEN
SET @sequence = (@sequence + 1) % 4096;
ELSE
SET @sequence = 0;
END IF;
这里用全局变量@last_timestamp记录上次生成时间,@sequence存储当前序列号。当时间戳变化时序列号归零,否则递增到4095后循环。
最终的位运算组合:
sql复制RETURN (timestamp << 22) | (data_center_id << 17) | (machine_id << 12) | @sequence;
各部分左移到对应位置后通过或运算合并:
sql复制DELIMITER //
CREATE FUNCTION generate_snowflake_id() RETURNS BIGINT
READS SQL DATA
BEGIN
DECLARE timestamp BIGINT;
DECLARE machine_id BIGINT DEFAULT 1; -- 实际部署时应从配置读取
DECLARE data_center_id BIGINT DEFAULT 0;
DECLARE epoch BIGINT DEFAULT 1288834974657;
-- 获取当前时间戳(毫秒)
SET timestamp = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - epoch;
-- 处理时钟回拨
IF timestamp < @last_timestamp THEN
SET timestamp = @last_timestamp; -- 简单处理:等待时钟追上
END IF;
-- 序列号管理
IF timestamp = @last_timestamp THEN
SET @sequence = (@sequence + 1) % 4096;
IF @sequence = 0 THEN -- 当前毫秒序列号用完
SET timestamp = wait_next_millis(@last_timestamp);
END IF;
ELSE
SET @sequence = 0;
END IF;
SET @last_timestamp = timestamp;
RETURN (timestamp << 22) | (data_center_id << 17)
| (machine_id << 12) | @sequence;
END //
DELIMITER ;
实际部署时必须考虑服务器时钟回拨问题。我增加了简单的等待策略:
sql复制-- 辅助函数:等待到下一毫秒
DELIMITER //
CREATE FUNCTION wait_next_millis(last_millis BIGINT) RETURNS BIGINT
BEGIN
DECLARE current_millis BIGINT;
SET current_millis = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - 1288834974657;
WHILE current_millis <= last_millis DO
SET current_millis = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - 1288834974657;
END WHILE;
RETURN current_millis;
END //
DELIMITER ;
生产环境建议:对于关键业务系统,应该记录最近生成的ID时间戳到数据库,重启时进行比较。如果发现时钟回拨超过100ms,应该报警人工干预。
sql复制-- 将会话级变量改为全局变量(需注意并发问题)
SET GLOBAL last_snowflake_timestamp = -1;
SET GLOBAL last_snowflake_sequence = 0;
sql复制CREATE PROCEDURE batch_generate_snowflake_ids(IN count INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < count DO
SELECT generate_snowflake_id();
SET i = i + 1;
END WHILE;
END
原始需求是将表A数据迁移到表B:
sql复制INSERT INTO table_b(id, project_code)
SELECT generate_snowflake_id(), project_code
FROM table_a WHERE type = 1;
遇到的典型问题:
批量插入ID重复:因为存储函数中使用会话变量,在长连接中多次调用可能重复
@last_timestamp = -1性能瓶颈:单条生成无法利用批量插入优势
sql复制CREATE TEMPORARY TABLE temp_ids AS
SELECT generate_snowflake_id() AS new_id, project_code
FROM table_a WHERE type = 1;
INSERT INTO table_b(id, project_code)
SELECT new_id, project_code FROM temp_ids;
多MySQL实例部署时需要配置不同的机器ID:
sql复制-- 在每台机器上创建函数时指定不同machine_id
SET @machine_id = 2; -- 第二台机器
CREATE FUNCTION generate_snowflake_id() RETURNS BIGINT
BEGIN
...
DECLARE machine_id BIGINT DEFAULT @machine_id;
...
END
重要提示:机器ID分配建议使用ZooKeeper或数据库序列统一管理,避免人工配置冲突。我曾遇到过因为机器ID重复导致主键冲突的线上事故。
sql复制-- 检查最近1秒内序列号使用情况
SELECT COUNT(*) AS ids_generated_per_second
FROM some_table
WHERE id >> 22 = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - 1288834974657;
bash复制# 在crontab中添加NTP时间同步检查
*/5 * * * * /usr/sbin/ntpdate -q pool.ntp.org | grep 'offset' | awk '{if($6>100) print "Clock skew alert!"}'
sql复制-- MySQL自增主键
CREATE TABLE table_b (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
...
);
-- 分库分表时设置不同步长
SET @@auto_increment_increment=2; -- 步长
SET @@auto_increment_offset=1; -- 起始值
优点:
缺点:
lua复制-- Lua脚本保证原子性
local timestamp = redis.call('TIME')[1]
local sequence = redis.call('INCR', 'snowflake:seq')
return (timestamp << 22) | (ARGV[1] << 12) | (sequence % 4096)
优点:
缺点:
我在金融系统中最终选择了SQL方案,因为它:
对于某些需要短ID的场景,可以调整位数分配:
sql复制-- 38位方案:时间28位 + 机器5位 + 序列5位
RETURN (timestamp << 10) | (machine_id << 5) | @sequence;
调整后:
当雪花ID作为外键大量存在时,建议:
COMPRESSED行格式sql复制ALTER TABLE order_item
ADD INDEX idx_order_id (order_id(8)); -- 前8字节足够区分
在分库分表中间件中使用时,需要配置分布式序列:
yaml复制spring:
shardingsphere:
sharding:
tables:
t_order:
key-generator:
column: order_id
type: SNOWFLAKE
props:
worker.id: 123
当需要将数据迁移到新Snowflake集群时:
sql复制-- 新集群ID第一位设为1
UPDATE table_b SET id = id | (1 << 63) WHERE create_time > '2023-01-01';
这个方案我们在跨国数据迁移时成功应用,实现了零停机切换。关键是要提前在应用层做好双写和路由判断,避免业务逻辑中直接比较ID大小。