2024-04-12-结合业务探讨分布式ID技术与实现


[TOC]

标题:结合业务探讨分布式ID技术与实现

引言:

在当今大数据时代,随着业务规模的不断扩大和数据量的不断增长,业务系统对于唯一标识符(ID)的需求越来越迫切。特别是在分布式系统中,生成唯一ID成为了一项挑战。

本文将深入探讨为什么需要分布式ID,业务系统对分布式ID的要求,以及业界几种常见的分布式ID生成方案。结合部门的实际的业务案例,将详细介绍如何根据业务需求选择合适的分布式ID技术,并通过段模式和雪花模式重构部门数据库,实现更高效的数据管理。

一、聊聊传统的主键自增ID

传统的MySQL主键ID模式通常采用自增主键的方式来生成唯一标识符。

在这种模式下,数据库表通常会定义一个名为”id”的列,将其设置为主键,并启用自动递增功能。每当向表中插入一条新记录时,MySQL都会自动为该记录分配一个唯一的ID值,并且这个ID值会自动递增,确保每个记录都具有不同的ID。

比如这张表而言

具体的表设计

CREATE TABLE `book` (
  `bookid` int NOT NULL AUTO_INCREMENT COMMENT '图书ID',
  `bookname` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '图书名称',
  `price` decimal(6,2) NOT NULL COMMENT '价格',
  `author` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作者',
  `publisher` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '出版社',
  `tid` int NOT NULL COMMENT '类别ID',
  `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0-未上架,1-已上架',
  `del` tinyint(1) NOT NULL DEFAULT 0 COMMENT '删除标志:0-未删除,1-已删除',
  `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '评论',
  PRIMARY KEY (`bookid`),
  FOREIGN KEY (`tid`) REFERENCES `category`(`categoryid`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC;

我们可以来分析一下,最后一行 ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC;

  • ENGINE=InnoDB:指定了使用的存储引擎为InnoDB。InnoDB是MySQL的一种常用存储引擎,提供了事务支持和行级锁等特性。
  • AUTO_INCREMENT=9:指定了表的自增主键从值9开始递增。这意味着当向表中插入新记录时,自增主键的初始值为9,并且每次插入新记录时,该主键值会自动递增1。
  • DEFAULT CHARSET=utf8mb3:指定了表的默认字符集为utf8mb3。utf8mb3是UTF-8的一种实现方式,支持最多3个字节表示一个字符,适用于大部分的中文和英文字符。
  • ROW_FORMAT=DYNAMIC:指定了行的格式为动态行格式。动态行格式是InnoDB存储引擎的一种行存储格式。在动态行格式中,每行的列不固定,根据实际数据大小进行灵活存储,可以节省存储空间并提高性能。

AUTO_INCREMENT=9,表示该表自增到9的位置。

1.1 主键ID自增存在的局限

如果是单体系统来说,主键ID可能会常用主键自动的方式进行设置,这种ID生成方法在单体项目是可行的。

但是对于在分布式系统中,可能存在多个数据库实例,每个数据库实例都有自己的自增ID生成器,这样就会造成跨库的ID不唯一问题,需要额外的处理来解决,所以这是不符合业务的。

1.2 业务系统对分布式ID的要求

  1. 全局唯一性:生成的ID必须在全局范围内是唯一的,不同的节点和不同的系统都不能生成相同的ID。
  2. 趋势递增:趋势递增,这对于MySQL等使用聚集索引的数据库来说尤为重要,可提高写入效率。
  3. 单调递增:保证下一个ID大于上一个ID,这种情况可以保证事务版本号,排序等特殊需求实现
  4. 可扩展性:ID生成方案需要具备良好的扩展性,能够适应系统规模的持续增长。无论是增加节点数量还是增加系统负载,ID生成器都能够轻松应对,不会成为系统的瓶颈。
  5. 可预测性:生成的ID应具有一定的可预测性,即在一定范围内,可以预测下一个生成的ID值是多少。(段模式)

二、调研业界常见的分布式ID生成方案

2.1 雪花算法(SnowFlake):

雪花算法是Twitter开源的一种分布式ID生成算法,采用64位的整数表示,其中包含时间戳、机器ID、数据中心ID和序列号等信息,保证了ID的全局唯一性和趋势递增。

  • 优点:

  • 高效性能:雪花算法通过位运算和时间戳生成ID,性能高效,适用于高并发场景。

  • 全局唯一性:雪花算法生成的ID具有全局唯一性,不会产生重复。

  • 缺点:

    • 时钟回拨问题:如果系统时钟发生回拨,可能会导致生成的ID不唯一或不连续。
    • 依赖时间戳:雪花算法的ID生成依赖于时间戳,如果时间戳不稳定,可能会影响ID的唯一性。

2.2 号段模式

号段模式将ID的生成分成两个步骤,首先申请一个区间(号段),然后在该区间内自增生成ID。号段模式适用于高并发场景,可以减少对数据库的访问压力,但需要额外的管理和调度机制。

  • 优点:
    • 分段管理:号段模式可以将ID生成过程分成两个阶段,提高了并发能力和性能。
    • 适用性广泛:号段模式适用于各种分布式系统,并且可以灵活调整号段的大小和生成频率。
  • 缺点:
    • 管理复杂:需要额外的管理和调度机制来管理号段的分配和使用。
    • 可能存在重复:如果号段生成不当,可能会导致ID的重复或碰撞。

2.3 UUID:

全球唯一标识符(UUID)是一种由128位数字表示的标准,通常以32位的十六进制数表示。UUID生成算法基于时间戳和设备唯一标识等信息,保证了全局唯一性。但由于其长度较长,不适合作为数据库的主键。

  • 优点:
    • 全局唯一性:UUID是全球唯一标识符,保证了生成的ID在全球范围内的唯一性。
    • 无序性:UUID是随机生成的,不受顺序限制,适合于分布式系统。
  • 缺点:
    • 长度较长:UUID通常为128位,较长的长度可能会占用较大的存储空间。
    • 不易读性:由于UUID是一串数字和字母的组合,不易于人类识别和记忆。

2.4 数据库自增

在数据库中使用自增主键生成ID,每次插入新记录时,数据库会自动分配一个唯一的ID值。这种方式简单易用,但不适用于分布式环境,可能存在单点故障和性能瓶颈。

  • 优点:
    • 简单易用:使用数据库自增主键生成ID非常简单,不需要额外的代码实现。
    • 递增性:自增主键生成的ID是递增的,有助于提高查询效率。
  • 缺点:
    • 单点故障:在分布式系统中,数据库自增主键可能存在单点故障和性能瓶颈。
    • 不适合分布式:数据库自增主键无法满足分布式系统的需求,不适合于跨数据库实例的应用。

2.5 Redis实现

利用Redis的原子操作和分布式锁机制,可以实现分布式ID的生成。通过维护一个递增的计数器或使用Redis的自增功能,可以生成全局唯一的ID。

  • 优点:
    • 高性能:Redis具有高效的原子操作和分布式锁机制,可以实现高性能的分布式ID生成。
    • 可扩展性:Redis支持集群模式,可以轻松扩展到多个节点,适用于大规模分布式系统。
  • 缺点:
    • 单点故障:Redis作为单点服务可能存在单点故障的风险。
    • 数据丢失:由于Redis是内存数据库,数据可能会丢失或不稳定。

此外,还有其他大厂之间的百度Uidgenerator,美团Leaf,滴滴TinyID等等。

三、方案选择:采取雪花算法+段模式

结合当前的系统业务场景,既要进行分布式id也要进行自增和保持历史数据的现状。采取雪花算法+段模式两种模式去实现分布式id的实现。

3.1 雪花算法(SnowFlake)

保证了生成的ID具有全局唯一性和趋势递增性,每个ID都是递增的,并且不会出现重复的情况。

3.2 段模式

段模式在分段管理的过程中也能够保证ID的唯一性和递增性,通过对号段进行动态管理和分配,可以充分利用号段的使用效率,提高了ID的生成性能和效率。

此外,段模式还可以一眼开出这个id是谁谁谁,清晰明了。

四、分布式ID落地与实现

4.1 golang实现雪花算法

通过一个简单的 SnowFlake 结构体,其中包含了生成唯一ID所需的参数和方法。通过调用 NextID() 方法,可以生成基于雪花算法的唯一ID

package main

import (
	"fmt"
	"sync"
	"time"
)

// SnowFlake 结构体定义
type SnowFlake struct {
	mu           sync.Mutex
	startTime    int64 // 起始时间戳,单位为毫秒
	datacenterID int64 // 数据中心ID
	workerID     int64 // 工作节点ID
	sequence     int64 // 序列号
	lastStamp    int64 // 上次生成ID的时间戳
}

// NewSnowFlake 函数用于创建一个新的 SnowFlake 对象
func NewSnowFlake(datacenterID, workerID int64) *SnowFlake {
	return &SnowFlake{
		startTime:    1609459200000, // 2021-01-01 00:00:00 的毫秒级时间戳
		datacenterID: datacenterID,
		workerID:     workerID,
		sequence:     0,
		lastStamp:    -1,
	}
}

// NextID 方法用于生成下一个唯一ID
func (sf *SnowFlake) NextID() int64 {
	sf.mu.Lock()
	defer sf.mu.Unlock()

	// 获取当前时间戳,单位为毫秒
	now := time.Now().UnixNano() / 1e6

	// 如果当前时间小于上次生成ID的时间戳,则等待
	if now < sf.lastStamp {
		for now <= sf.lastStamp {
			now = time.Now().UnixNano() / 1e6
		}
	}

	// 如果当前时间与上次生成ID的时间戳相同,则递增序列号
	if now == sf.lastStamp {
		sf.sequence = (sf.sequence + 1) & 4095 // 序列号取值范围为 0-4095
		if sf.sequence == 0 {
			now = sf.waitNextMillis(now)
		}
	} else {
		sf.sequence = 0
	}

	// 更新上次生成ID的时间戳
	sf.lastStamp = now

	// 生成ID
	id := ((now - sf.startTime) << 22) | (sf.datacenterID << 17) | (sf.workerID << 12) | sf.sequence
	return id
}

// waitNextMillis 方法用于等待下一个毫秒
func (sf *SnowFlake) waitNextMillis(lastStamp int64) int64 {
	now := time.Now().UnixNano() / 1e6
	for now <= lastStamp {
		now = time.Now().UnixNano() / 1e6
	}
	return now
}

func main() {
	// 创建一个 SnowFlake 对象
	sf := NewSnowFlake(1, 1) // 设置数据中心ID和工作节点ID

	// 生成并打印 10 个唯一ID
	for i := 0; i < 10; i++ {
		fmt.Println("ID:", sf.NextID())
	}
}

4.2 golang实现段模式

结合Segment 结构体,其中包含了生成唯一ID所需的参数和方法。通过调用 NextID() 方法,可以生成基于段模式的唯一ID

package main

import (
	"fmt"
	"sync"
)

// Segment 结构体定义
type Segment struct {
	mu      sync.Mutex
	start   int64 // 起始值
	step    int64 // 步长
	current int64 // 当前值
}

// NewSegment 函数用于创建一个新的 Segment 对象
func NewSegment(start, step int64) *Segment {
	return &Segment{
		start:   start,
		step:    step,
		current: start,
	}
}

// NextID 方法用于生成下一个唯一ID
func (s *Segment) NextID() int64 {
	s.mu.Lock()
	defer s.mu.Unlock()

	id := s.current
	s.current += s.step
	return id
}

func main() {
	// 创建一个 Segment 对象
	segment := NewSegment(1000, 1) // 设置起始值和步长

	// 生成并打印 10 个唯一ID
	for i := 0; i < 10; i++ {
		fmt.Println("ID:", segment.NextID())
	}
}

4.3 实际的业务

在实际的业务上,通过设置一个分布式id的生成服务,每次涉及新增的逻辑,会先调研这个分布式服务生成id,在进行数据库插入等等。

当然在数据库层面也会设置:是否为雪花算法和段模式。

//分布式id改造
protected $distributed = true;
protected $distributedType = 1;
protected $distributedTag = "test:test:book";

protected $table = 'book';
public $timestamps = false;
  1. $distributed:这个变量表示是否启用分布式ID。如果设置为true,则表示启用分布式ID,否则表示不启用。在这段代码中,设置为true,即启用分布式ID。
  2. $distributedType:这个变量表示分布式ID的类型。在这里,设置为1,指定了雪花算法分布式ID生成算法或方案的类型。2是段模式。
  3. $distributedTag:这个变量表示分布式ID的标签或命名空间。在分布式系统中,通常会使用命名空间来区分不同的业务模块或数据表。
  4. $table:这个变量表示数据库表的名称。在这段代码中,设置为’book’,表示该模型对应的数据库表名称是’wx_label_v2’。
  5. $timestamps:这个变量表示是否启用模型的自动维护时间戳。在这段代码中,设置为false,表示不启用模型的自动维护时间戳,即不会自动生成created_at和updated_at字段。

五、总结

当我考虑雪花算法(SnowFlake)和段模式时,我发现它们都是用于生成分布式系统中唯一ID的重要方案。这两种方案各有优劣,下面是我的总结:

雪花算法(SnowFlake)是一种简单且高效的算法。它通过利用时间戳和节点ID生成全局唯一的ID,这确保了ID的唯一性和趋势递增。这使得它在许多场景下都是一种理想的选择,特别是在需要高性能和简单实现的情况下。

另一方面,段模式则更加灵活。它允许每个节点预分配一段ID范围,并自行管理这些ID。这种方式避免了单点故障,并且可以根据需求动态调整ID范围

总的来说,我认为雪花算法(SnowFlake)适用于简单的分布式系统场景,而段模式则更适用于复杂的分布式系统场景。在选择适合自己系统的ID生成方案时,需要权衡它们的优缺点,并根据实际情况做出合适的选择。


如果你对分布式ID生成方案还有其他疑问或需要进一步讨论的地方,请随时在评论区留言哦~


文章作者: 千羽
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 千羽 !
评论
  目录