在分布式系统里,生成全局唯一ID是最基础的需求之一。提到分布式ID,大家首先想到的往往是Snowflake雪花算法或者美团Leaf这类基于号段的方案,而这些方案通常依赖MySQL、Zookeeper或Redis作为底层存储。其实对于一个中小规模的服务来说,用SQLite来实现号段模式的ID生成器是完全可行的,它单文件、零部署、事务能力强,配合多实例隔离策略后可以稳定支撑每秒数十万的发号请求。本文就从零开始,完整实现一个基于SQLite的分布式ID生成服务。

一、方案整体设计与表结构
号段模式的核心思路是:不再每次请求都去数据库取一个ID,而是一次性申请一段连续的ID区间(比如1到1000),缓存在服务内存里,用完之后再申请下一段。这样数据库的读写压力被降低了几个数量级,同时因为SQLite是单文件嵌入式数据库,整个服务可以做到极轻量。
数据库里只需要一张表。这张表以业务标识biz_tag为主键,记录每个业务当前已分配的最大ID号段上限。每次申请号段就是一次UPDATE操作,配合SQLite的事务特性保证原子性。建表语句如下:
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(64) NOT NULL, -- 业务标识,如订单、用户
max_id BIGINT NOT NULL DEFAULT 1, -- 当前已分配的最大ID
step INT NOT NULL DEFAULT 1000, -- 每次申请的号段长度
worker_id INT NOT NULL DEFAULT 0, -- 实例标识,用于多实例隔离
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag, worker_id)
);
这里有个细节需要注意:主键我用了(biz_tag, worker_id)的联合主键。SQLite对联合主键的处理很高效,这样多个实例各自维护自己的号段进度,互不干扰。初始化时插入一条种子数据即可:
INSERT INTO leaf_alloc (biz_tag, max_id, step, worker_id)
VALUES ('order', 0, 2000, 1);
二、号段分配的核心逻辑与事务实现
号段分配本质上是一次“读取当前max_id,加上step,写回数据库”的原子操作。很多人第一反应是用SELECT再UPDATE,但在并发场景下这样容易出现两个请求读到同一个值,导致号段重复分配。SQLite天然适合解决这个问题,因为它的写操作是库级别的排他锁,配合显式事务可以保证分配的原子性。
最优雅的写法是利用UPDATE的返回值。SQLite驱动(如Go的mattn/go-sqlite3)支持返回受影响的行数,配合条件更新可以做成乐观锁;但更简洁的方式是直接用RETURNING子句,一步拿到新的号段上限:
BEGIN IMMEDIATE;
UPDATE leaf_alloc
SET max_id = max_id + step,
update_time = CURRENT_TIMESTAMP
WHERE biz_tag = 'order' AND worker_id = 1
RETURNING max_id, step;
COMMIT;
注意BEGIN IMMEDIATE这个写法,它会在事务开始时就获取写锁,避免事务中途升级锁导致的死锁。RETURNING子句在SQLite 3.35以上版本支持,如果版本较低,可以用两步法替代:先UPDATE,再在同一事务里SELECT出max_id,效果等价。
为什么这里能保证不重号?关键在于UPDATE语句本身的原子性。即使有多个请求并发到达,SQLite的写锁会串行化它们,每次UPDATE都会在上一段的基础上累加,号段区间自然不会重叠。
三、Go语言实现双Buffer异步加载
单Buffer的问题在于:当号段用完后需要同步访问数据库拿下一段,此时发号请求会被阻塞,数据库或磁盘一旦抖动,服务就会出现毛刺。解决办法是双Buffer——当一个Buffer消耗到一定比例(比如20%剩余)时,异步预加载下一个Buffer,两个Buffer无缝切换。
核心数据结构很简单,一个segment保存号段区间和当前指针:
type Segment struct {
BizTag string
MaxId int64 // 号段上限(不含)
Step int64 // 号段长度
Pos int64 // 当前已发放的指针
}
func (s *Segment) Next() (int64, bool) {
if s.Pos >= s.MaxId {
return 0, false // 号段耗尽
}
s.Pos++
return s.Pos, true
}
type SegmentBuffer struct {
mu sync.Mutex
Segments [2]*Segment
Index int
Loading int32 // 是否正在异步加载,防止重复触发
}
func (b *SegmentBuffer) Get() (int64, error) {
b.mu.Lock()
seg := b.Segments[b.Index]
id, ok := seg.Next()
if !ok {
// 当前Buffer耗尽,切换到另一个
next := b.Segments[(b.Index+1)%2]
if next == nil {
b.mu.Unlock()
return 0, errors.New("next segment not ready")
}
b.Index = (b.Index + 1) % 2
id, _ = next.Next()
} else if float64(seg.MaxId-seg.Pos)/float64(seg.Step) < 0.2 {
// 剩余不足20%,异步预加载
if atomic.CompareAndSwapInt32(&b.Loading, 0, 1) {
go b.loadNextSegment()
}
}
b.mu.Unlock()
return id, nil
}
loadNextSegment方法的逻辑就是执行前面那条带RETURNING的SQL,把结果写入另一个Buffer槽位,完成后把Loading标志复位。这里用atomic.CompareAndSwapInt32保证同一时刻只有一个加载协程在跑,避免重复申请号段造成浪费。
还有一个容易忽略的点:服务重启时的状态恢复。因为号段是预分配到内存的,重启后Buffer清空,直接从数据库的max_id之后申请新号段即可,中间未用完的号会自然跳过。号段模式下ID不保证绝对连续,但严格单调递增,这一点和Leaf的语义一致。
四、多实例隔离与脑裂防护
单实例方案再稳也有单点风险,所以生产环境一般部署多个实例。多实例最容易出的问题是脑裂——两个实例拿到重叠的号段,发出重复ID。我们的防护手段就是前面表结构里的worker_id字段:每个实例启动时从环境变量或配置中心领取唯一的worker_id,各自只更新和读取自己worker_id对应的那一行,号段空间天然隔离。
实例数量的上限取决于ID的类型宽度。如果用int64存储ID,可以设计成高16位存worker_id,低48位存号段值,最多支持65536个实例,单实例号段空间约280万亿,完全够用。也可以不加前缀,直接让每个worker独立累加,只要保证表里每个(biz_tag, worker_id)的号段不重叠即可,业务侧对ID的连续性没有要求的话,这种方案更简单。
实例注册与注销建议落在一张单独的表里,配合心跳时间戳。运维上要制定一条铁律:worker_id一旦分配,必须回收确认后才能复用,否则新实例可能读到旧实例残留的max_id,虽然不会重号(因为从max_id继续往后走),但会造成号段浪费,极端情况下会加速int64空间的消耗。
五、性能压测与适用边界
在一台普通SSD服务器上实测,双Buffer方案下单实例发号能力主要取决于内存操作,数据库只在异步加载时被触碰。step设为2000时,每次加载耗时约1毫秒,摊薄到2000次发号上几乎可以忽略。压测结果显示单实例QPS稳定在50万以上,P99延迟在微秒级别。
当然这个方案有明确的适用边界。SQLite的写锁是库级别的,单库写入吞吐有限,所以它只适合号段模式这种低频批量写的场景;如果你的业务有数十个biz_tag且都在高并发分配,或者需要跨机房部署强一致的ID服务,还是应该选MySQL加Leaf,或直接上Snowflake时钟方案。对于单体应用、边缘节点、或者不想引入额外中间件的小团队,这套SQLite方案能以最低的成本换来够用的发号能力,这本身就是嵌入式数据库的价值所在。