以下内容介绍的是设计思想,具体实现可以参考github:SequenceGenerator
现有ID生成业务逻辑,规则为:前缀+年月日+三位数自增序列(JX20260319001),对于自增序列来说需要保证强唯一性、有序性。
基于业务字段直接查询
通过复杂的sql语句查询到最新的序列号,在这个基础上+1作为新的序列号
sql
-- 假设表名为 orders,ID 字段名为 order_id
-- 步骤 1:开启事务 (START TRANSACTION)
START TRANSACTION
-- 步骤 2:锁定并查询当天最大的序列号
-- 使用 FOR UPDATE 确保在当前事务提交前,其他事务不能读取/修改这些行
SELECT MAX(CAST(SUBSTRING(order_id, 11) AS UNSIGNED)) as max_seq
FROM orders
WHERE order_id LIKE 'JX20260319%'
FOR UPDATE;
-- 步骤 3:在代码中处理逻辑
-- 如果 max_seq 为空,则新序列号 = 1
-- 如果 max_seq 不为空,则新序列号 = max_seq + 1
-- 步骤 4:插入新数据
INSERT INTO orders (order_id, ...)
VALUES ('JX20260319001', ...);
-- 步骤 5:提交事务 (COMMIT)
COMMIT
这是基于纯sql的查询方案,在并发环境当中通过FOR UPDATE行锁保证在同一时间只有一个线程能够操作该行数据,同时这样也不可避免的带来性能瓶颈。除此之外在查询最新顺序号的时候对索引行就行了计算,无法通过索引快速定位只能全表查询,必然会产生慢sql。
在这个基础上可以考虑引入一张DB表记录当前使用到的最大顺序号,通过这种方式解决查询慢sql的问题。
引入DB表记录最大顺序号
那么在引入DB表之后,必然也会产生并发问题,解决并发问题的方式有多种,可以通过加互斥锁也可以通过加乐观锁,一下是乐观锁的实现方式。
sql
CREATE TABLE sys_sequence (
seq_key VARCHAR(50) NOT NULL COMMENT '业务标识,如:JX',
curr_date DATE NOT NULL COMMENT '当前日期,如:2026-03-19',
curr_value INT NOT NULL DEFAULT 0 COMMENT '当前已使用的最大序列号',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (seq_key, curr_date) -- 联合主键,确保每天每种业务只有一条记录
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
乐观锁的思想就是我们在拿到当前最大的顺序号时候获取版本号作为期望值,之后对顺序号+1之后尝试写回DB,在这个过程当中再次比较版本号与期望值是否相同,如果相同则提交修改。
基于Redis原子自增的实现方案
但是不论是乐观锁还是互斥锁的实现方式在高并发场景下都会产生所竞争/频繁CAS 自旋,浪费资源。因此在这个基础上我们就可以想到使用redis来解决,通过redis的原子计数功能(redis本身是单线程模型因此不需要考虑线程竞争问题)解决顺序号递增问题。
为了保证每天的 ID 都是从 001 开始,且不产生逻辑混乱,最稳妥的方案是:将日期作为 Redis Key 的一部分。每一天都是一个新的计数器,完全独立,互不干扰。可以通过一下规则构造key:seq:JX:20260319。为了防止 Redis 内存被陈旧的日期 Key 撑爆,我们需要给它设置一个大于 24 小时的生存期(比如 48 小时),确保过了当天后它会被自动清理。
lua
-- KEYS[1]: 序列号Key (例如 seq:JX:20260319)
-- ARGV[1]: 过期时间 (秒,建议设置为 86400 * 2 = 172800,即48小时)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
-- 只有第一次自增时设置过期,确保计数器在完成使命后自动消失
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
在引入redis之后又不得不考虑其他的问题,redis宕机期间服务如何保证,redis宕机恢复之后如何同步id。
首先对于第一个问题在redis宕机期间可以通过降级策略,使用前面提到的DB表方案,这样的话在实现角度就必须在更新Redis的同时更新DB,这是否又绕回上面的DB解决方案了?在这里考虑到优化可以使用多线程异步执行,除此之外还需要保证可靠性可以采用消息中间件解耦,当redis更新完毕之后发送一个消息给对应Topic,异步消费这些消息写入DB。
我们可以定义一个标识位,如果redis发生连接超时异常之后修改标识位,标识位修改之后自动降级为DB处理方案。之后就需要去考虑如何判断redis是否恢复,这里可以通过间隔n次查询DB尝试访问链接redis的方案,如果此时检测到redis恢复则同步顺序号,修改标识位。
对于Redis的序列号的同步来说仍然可能会出现并发安全问题,如此时线程A尝试同步Redis读取到数据库到旧值写会redis(由于间隔N次尝试链接redis恢复数据,A进入之后修改了计数器N,对于这个N来说),线程B进入发现此时为N+1非同步状态且A线程未执行完毕,更新数据库为新值。

为了解决这个问题可以使用一个全局的分布式锁,在降级且尝试更新Redis的时候添加,只有拿到锁的线程才能执行"恢复同步"操作。在同步完成并修改标识位之前,其他线程必须保持等待状态。
NORMAL
FAILOVER
否
是
失败
成功
RECOVERING
业务请求获取ID
当前状态标识?
Redis INCR 获取 ID
间隔N次尝试连接?
DB 降级方案获取 ID
尝试获取全局恢复锁
修改状态为 RECOVERING
从 DB 读取最新值并写入 Redis
修改状态为 NORMAL
释放全局恢复锁
阻塞等待直到状态转为 NORMAL
参考上述流程我们需要维护一个状态机以及全局锁,当触发同步Redis策略时尝试获取全局锁,此时修改状态,其他线程发现状态被修改为RECOVERING时通过lock阻塞,直到redis服务更新完毕,尝试从redis当中取出新顺序号。下面是伪代码实现:
java
public String getSequence() {
// 1. 检查状态(可以缓存在本地内存,由定时任务或异常触发更新)
if (systemStatus == NORMAL) {
try {
return redisIncr();
} catch (Exception e) {
systemStatus = FAILOVER; // 触发降级
}
}
// 2. 降级或恢复阶段
if (systemStatus == FAILOVER || systemStatus == RECOVERING) {
// 使用分布式锁锁定整个"恢复窗口"
RLock recoveryLock = redisson.getLock("id_recovery_lock");
// 如果是 NORMAL 阶段,请求根本不会走到这里,从而保证了平时的性能
if (systemStatus == RECOVERING) {
recoveryLock.lock(); // 阻塞其他业务线程,直到状态切换完成
try {
return redisIncr(); // 恢复后再次尝试
} finally {
recoveryLock.unlock();
}
}
// 3. 尝试恢复逻辑(你提到的间隔 N 次)
if (shouldTryRecover()) {
if (recoveryLock.tryLock()) { // 只有一个线程能抢到恢复权
try {
systemStatus = RECOVERING; // 关键:立即切换状态阻塞后续线程
syncDbToRedis(); // 执行你说的同步逻辑
systemStatus = NORMAL; // 切换回正常
} finally {
recoveryLock.unlock();
}
}
}
return dbGetSequence(); // 没抢到恢复权的,继续走 DB 降级
}
}
基于以上的实现方式可以尽可能的减小锁粒度,非降级阶段不使用锁。
| 状态 | 说明 | 性能 | 锁行为 |
|---|---|---|---|
| NORMAL (0) | 正常使用 Redis | 极高 | 无锁 ,直接 INCR |
| FAILOVER (1) | Redis 宕机,走 DB 降级 | 中等 | 走 DB 事务或乐观锁 |
| RECOVERING (2) | 检测到 Redis 恢复,正在同步 | 低 (暂态) | 全局阻塞锁,阻止所有业务 |
| 在这里通过分布式锁解决了并发同步问题,但是任然还是存在一个问题:当多个线程发现 Redis 恢复并尝试抢锁时,没抢到锁的那些线程,在阻塞结束(锁释放)后如果处理不好,没抢到锁的线程在锁释放后如果不经检查直接去冲撞数据库,依然会导致 ID 冲突或重复同步。 |
因此这里还必须引入双重检查锁:抢锁失败的线程在锁释放并成功获取锁后,第一件事不是去同步 DB,而是再次检查 status。如果此时 status 已经变成了 NORMAL,说明前一个抢到锁的线程已经完成同步了,当前线程应该直接放弃同步,转向 Redis。
![[.../.../assets/Pasted image 20260319155627.png]]