
系列导读
上一篇的数据库自增ID方案,在中高并发场景(如电商订单每秒10万+请求)会出现瓶颈——频繁访问数据库导致数据库压力过大,甚至卡顿。
今天的“号段模式”完美解决了这个问题:通过“预分配号段+本地缓存”,大幅减少数据库访问次数,QPS可达10万+,同时保留ID有序性,是中高并发场景的首选方案。
本文详解原理、实战代码和避坑点。
一、适用场景
- 中高并发(QPS=1万~10万+);
- 需ID有序(支持分页查询、排序);
- 核心业务(电商订单、支付流水、物流单号);
- 不想引入复杂中间件,希望基于数据库实现。
二、核心原理:预分配号段+本地缓存
号段模式的核心思路是“一次从数据库拿一段ID,本地缓存后按需分配,用完再拿”,类似餐厅“一次领100个号,叫完再领”,减少排队次数:
- 号段定义:一段连续的ID区间(如1000-2000,步长=1000);
- 预分配:服务启动时,从数据库申请一段号段,缓存到本地内存;
- 本地分配:业务需要ID时,直接从本地缓存的号段中递增分配(无需访问数据库);
- 号段耗尽:当本地号段分配到80%(如1800)时,异步申请下一段号段,避免阻塞;
- 数据库存储:数据库只存储“当前最大ID”和“步长”,无需存储每一个ID。
号段模式流程示意图
服务A本地缓存号段:1000-2000
↓
业务请求→从1000开始分配(1000→1001→1002...)
↓
分配到1800(号段80%)→异步申请下一段(2001-3000)
↓
本地号段耗尽(2000)→直接使用新申请的号段(2001开始)
三、实战实现:数据库+Java代码(含预申请优化)
1. 数据库表设计(存储号段信息)
CREATE TABLE `id_generator_segment` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`biz_type` varchar(32) NOT NULL COMMENT '业务类型(order/pay/logistics)',
`current_max_id` bigint NOT NULL COMMENT '当前最大ID(号段结束值)',
`step` int NOT NULL COMMENT '号段步长(一次申请的ID数量)',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-可用,0-禁用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_type` (`biz_type`) COMMENT '按业务类型隔离'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号段模式ID生成表';
-- 初始化订单业务号段(步长=1000)
INSERT INTO `id_generator_segment` (`biz_type`, `current_max_id`, `step`)
VALUES ('order', 1000, 1000);
2. Java核心代码实现(Spring Boot)
2.1 号段实体类
@Data
public class Segment {
private String bizType; // 业务类型
private Long startId; // 号段起始ID
private Long endId; // 号段结束ID
private Long currentId; // 当前分配到的ID
private Integer step; // 步长
private boolean isNeedRefresh; // 是否需要申请下一段
}
2.2 号段生成服务(核心逻辑)
@Service
public class SegmentIdGeneratorService {
@Autowired
private IdGeneratorSegmentMapper segmentMapper;
// 本地缓存号段(key=业务类型,value=号段信息)
private ConcurrentHashMap<String, Segment> segmentCache = new ConcurrentHashMap<>();
// 步长80%时触发预申请
private static final double REFRESH_THRESHOLD = 0.8;
// 获取分布式ID
public Long getDistributedId(String bizType) {
// 1. 本地缓存中获取号段,无则初始化
Segment segment = segmentCache.get(bizType);
if (segment == null) {
segment = initSegment(bizType);
segmentCache.put(bizType, segment);
}
// 2. 本地分配ID(原子递增)
Long id = segment.getCurrentId() + 1;
// 3. 检查是否需要预申请下一段(避免号段耗尽阻塞)
if (id >= segment.getEndId() * REFRESH_THRESHOLD && !segment.isNeedRefresh()) {
segment.setNeedRefresh(true);
// 异步申请下一段号段(不阻塞当前ID分配)
CompletableFuture.runAsync(() -> applyNextSegment(bizType));
}
// 4. 检查当前号段是否耗尽,耗尽则切换到新号段
if (id > segment.getEndId()) {
segment = waitForNewSegment(bizType); // 等待新号段
}
// 5. 更新当前ID
segment.setCurrentId(id);
return id;
}
// 初始化号段(从数据库获取第一段)
private Segment initSegment(String bizType) {
IdGeneratorSegment dbSegment = segmentMapper.selectByBizType(bizType);
Segment segment = new Segment();
segment.setBizType(bizType);
segment.setStartId(dbSegment.getCurrentMaxId() - dbSegment.getStep() + 1); // 1000-1000+1=1
segment.setEndId(dbSegment.getCurrentMaxId()); // 1000
segment.setCurrentId(segment.getStartId() - 1); // 0(初始值)
segment.setStep(dbSegment.getStep());
segment.setNeedRefresh(false);
return segment;
}
// 申请下一段号段(更新数据库)
@Transactional
private void applyNextSegment(String bizType) {
// 乐观锁更新(避免并发更新冲突)
IdGeneratorSegment dbSegment = segmentMapper.selectByBizType(bizType);
Long newMaxId = dbSegment.getCurrentMaxId() + dbSegment.getStep();
int affected = segmentMapper.updateCurrentMaxId(
bizType, newMaxId, dbSegment.getCurrentMaxId() // 乐观锁条件:当前最大ID未被修改
);
if (affected > 0) {
// 新号段生效
Segment newSegment = new Segment();
newSegment.setBizType(bizType);
newSegment.setStartId(dbSegment.getCurrentMaxId() + 1); // 1001
newSegment.setEndId(newMaxId); // 2000
newSegment.setCurrentId(newSegment.getStartId() - 1); // 1000
newSegment.setStep(dbSegment.getStep());
newSegment.setNeedRefresh(false);
segmentCache.put(bizType, newSegment);
} else {
// 更新失败(并发冲突),重试
applyNextSegment(bizType);
}
}
// 等待新号段(号段耗尽时阻塞)
private Segment waitForNewSegment(String bizType) {
int retryCount = 0;
while (retryCount < 30) { // 最多等待3秒
Segment segment = segmentCache.get(bizType);
if (segment.getCurrentId() < segment.getEndId()) {
return segment;
}
try {
Thread.sleep(100);
retryCount++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
throw new RuntimeException("申请新号段超时");
}
}
2.3 Mapper接口(MyBatis)
public interface IdGeneratorSegmentMapper {
// 按业务类型查询号段
IdGeneratorSegment selectByBizType(@Param("bizType") String bizType);
// 更新当前最大ID(乐观锁)
int updateCurrentMaxId(
@Param("bizType") String bizType,
@Param("newMaxId") Long newMaxId,
@Param("oldMaxId") Long oldMaxId
);
}
3. 调用示例
@Controller
@RequestMapping("/order")
public class OrderController {
@Autowired
private SegmentIdGeneratorService idGeneratorService;
@PostMapping("/create")
public ResponseEntity<?> createOrder() {
// 生成订单ID(号段模式)
Long orderId = idGeneratorService.getDistributedId("order");
// 后续订单创建逻辑
return ResponseEntity.ok("订单创建成功,订单ID:" + orderId);
}
}
四、优点&缺点
- 优点:
- 性能高:本地缓存号段,数据库访问频率极低(QPS可达10万+);
- 有序性:同一号段内ID连续递增,支持排序和分页;
- 高可用:数据库主从切换时,本地缓存的号段仍可分配ID,不阻塞业务;
- 扩展性好:步长可动态调整(如大促前增大步长=10000)。
- 缺点:
- 服务宕机可能丢失未分配的号段(如号段1000-2000,只分配到1500,服务宕机后1501-2000丢失,ID会不连续,但不影响唯一性);
- 号段耗尽时可能短暂阻塞(需依赖预申请优化,提前申请下一段)。
五、避坑指南:3个关键问题的解决方案
1. 服务宕机导致的号段丢失
- 问题:服务宕机后,本地缓存的未分配号段会丢失,ID出现断层;
- 解决方案:
- 号段丢失是可接受的(ID只需唯一,无需连续);
- 若业务要求ID连续(如金融流水号),可在数据库中记录已分配的最大ID,服务重启后从数据库获取最新ID,重新申请号段。
2. 号段预申请失败
- 问题:预申请下一段号段时,数据库宕机导致申请失败,当前号段耗尽后阻塞;
- 解决方案:
- 数据库部署主从+哨兵,确保高可用;
- 预申请时重试3次,失败则记录日志,人工介入处理。
3. 并发更新号段冲突
- 问题:多服务实例同时申请下一段号段,导致数据库更新冲突;
- 解决方案:
- 用乐观锁(
update ... where current_max_id = 旧值)避免冲突; - 冲突后重试,确保最终更新成功。
- 用乐观锁(
实战Tips
- 步长设置:根据业务并发量调整(低并发步长=1000,高并发步长=10000),步长越大,数据库访问越少;
- 预申请阈值:建议设置为80%(如号段1000-2000,分配到1800时预申请),预留足够时间避免阻塞;
- 多业务隔离:通过
biz_type字段隔离不同业务(如订单、支付、物流),避免ID冲突; - 监控告警:监控号段剩余量,当剩余量低于20%且预申请失败时,触发告警(如钉钉通知)。
下一篇预告
号段模式适合中高并发,但超高并发场景(如秒杀每秒100万+ID)仍有优化空间——本地缓存分配虽快,但仍有内存计算开销。
下一篇我们拆解“雪花算法(Snowflake)”:纯内存计算,无数据库依赖,QPS可达百万+,是超高并发场景的终极方案!
你在项目中用过号段模式吗?评论区聊聊~
3667

被折叠的 条评论
为什么被折叠?



