主键 ID VS 业务 ID
在数据库设计中,除了主键 ID,一般还需要一个具有唯一索引的业务 ID。二者承担的职责不一样,它们共同满足了我们对于 技术实现 和 业务需求 的双重目标
1. 职责分离原则
主键 ID | 业务唯一标识 ID | |
作用 | 保证数据库层面的唯一性 | 保证业务层面的唯一性 |
目标 | 保证数据存储和关联的可靠性 | 满足业务规则和外部交互需求 |
特点 | 无意义、自增/随机、不可变 | 有具体业务含义、可读、可暴露 |
eg:
- 商品表的主键 ID 可能是 1、2、3,但商品编码(业务唯一标识)可能是
00012517271821
。前四位是所处的地区码,中间是随机生成的数字,最后四位是新增这个商品的用户 ID 后四位 - 订单表的主键 ID 可能是 1、2、3,但订单编码可能是时间戳拼上今天订单的序号:
202505220012
2. 使用场景分析
场景一:防止暴露内部信息
- 问题:直接暴露自增主键
ID
,可能泄露业务规模(如用户量、订单量),甚至被恶意遍历数据 - 解决:使用无规律的业务
ID
(如UUID、哈希值)对外暴露,隐藏自增主键
场景二:分库分表需求
- 问题:如果需要分库分表,主键
ID
就无法保证全局唯一性 - 解决:通过业务
ID
实现全局唯一(雪花算法生成的分布式 ID、使用自定义序列生成器生成的 ID)
场景三:业务标识符的灵活性
- 问题:业务唯一标识可能需要动态规则(如订单号包含日期、地区码),而自增主键无法满足
- 解决:业务唯一标识
ID
按业务规则生成,主键ID
保持默认策略
3. 技术实现对比
主键 ID | 业务唯一标识 ID | |
数据类型 | 通常为 | 可能是 |
唯一性范围 | 表内唯一 | 全局唯一(跨表、跨系统) |
生成方式 | 自增/随机 | 程序生成(UUID、雪花算法、业务规则拼接) |
修改性 | 不可变(与数据生命周期绑定) | 可能允许修改(如用户重设唯一用户名) |
建议:
- 主键
ID
始终存在:作为数据库的“技术锚点”,用于外键关联、索引优化 - 业务唯一标识
ID
按需设计
-
- 若无需业务唯一标识,可省略
- 若需暴露或业务规则复杂,必加,并为其添加唯一索引
- 查询优化
-
- 内部关联用
ID
(更快) - 对外接口用业务
ID
(更安全)
- 内部关联用
4. 何时不需要用业务 ID ?
- 纯内部工具表,无暴露需求
- 业务标识符可直接复用主键(如简单的配置表)
为什么需要自定义序列生成器?
前面有说过业务ID
一般是具有具体业务含义的,我们需要支持根据动态规则来生成具有不同业务属性的业务ID
注意:
自定义序列生成器一般用来生成业务 ID ,但也可以用来生成主键 ID。具体实现方式是由多个维度所决定的。例如:公司觉得主键 ID 使用雪花算法生成的 64 位长整型数字比较占用内存,但是又不想新增一个具备实际业务含义的字段,那就可以选择使用自定义序列生成器生成具备业务属性的主键 ID(合二为一)
下面,我将分别实现 单体架构 和 分布式架构 下的序列生成器。它们最大的区别在于分布式架构下的序列生成器可以保证序列在多个不同的数据库之间也不会出现重复的问题,保证全局唯一性
单体架构实现
实现单体架构的序列生成器较为简单,只要想明白两个注意点:
- 由于要支持动态规则,所以需要用一张表来存储不同的业务生成序列的对应规则
- 我们需要保证业务 ID 唯一,所以每次要记录生成的最后一次数值,确保下次生成的值具有顺序且不重复
想明白了以上两点,我们就来尝试实现吧
实现步骤:
- 定义一张表用来配置不同业务的序列生成规则模板
CREATE TABLE `sequence_rule` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增列',
`module_id` varchar(50) NOT NULL COMMENT '模块ID',
`rule` varchar(100) NOT NULL COMMENT '序列规则',
`cuid` int(11) NOT NULL COMMENT '当前流水号',
`pref` varchar(50) NOT NULL COMMENT '规则前缀',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_module_id` (`module_id`)
) COMMENT='序列规则配置';
注意:模块 ID 要单独建立唯一索引,保证唯一性
- 定义一张表用来记录不同序列对应生成的值
CREATE TABLE `sequence_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sequence_key` varchar(64) NOT NULL COMMENT '序列编码',
`sequence_value` bigint(20) DEFAULT NULL COMMENT '序列值',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='序列ID记录表';
- 定义入口方法
VoucherIdManager.generateIds()
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public List<String> generateIds(ModuleEnum moduleEnum, Long length) {
if (moduleEnum == null ||length == null) {
throw new BizException(200, "缺失必传参数");
}
return this.buildIds(moduleEnum, length);
}
- 核心逻辑为
buildIds()
/**
* 构建 ID
*
* @param moduleEnum 模块枚举
* @param length 集合长度
* @return List<String>
*/
private List<String> buildIds(ModuleEnum moduleEnum, Long length) {
List<String> ids = new ArrayList<>();
// 1.获取序列规则
SequenceRule sequenceRule = sequenceRuleService.getByModuleEnum(moduleEnum);
String rule = sequenceRule.getRule().toUpperCase();
// 2.生成 ID 前缀
// ID 规则为: CO[yy][mm][dd][ID000000] 则第二步会生成 CO20230501 这一串前缀
String idPref = this.generateIdPref(rule);
log.info("idPref -> [{}]", idPref);
// 3.生成唯一值
Matcher matcher = SEQUENCE.matcher(rule);
if (matcher.find()) {
// 如果匹配上了,获取 0 的个数 (0 的个数就意味着要生成的随机数的长度)
int zeroLength = matcher.end() - matcher.start() - 4;
for (int i = 0; i < length; i++) {
Long nextSequence = sequenceManager.getNextSequence(idPref);
ids.add(idPref + String.format("%0" + zeroLength + "d", nextSequence));
}
} else {
throw new BizException(200, "序列规则配置错误");
}
return ids;
}
- 定义一个类,将数据库中对应序列的属性保存到内存(此处也可替换成 Redis)
private class SequenceHolder {
private String key;
/**
* 当前序列号,初始化是为 0
*/
private AtomicLong sequenceValue;
/**
* 数据库保存的序列号
*/
private long dbValue;
/**
* 步长,用来判断序列号是否还在给定的步长范围内
*/
private long step;
public SequenceHolder(long step) {
this.step = step;
}
public long nextValue() {
if (sequenceValue == null) {
// 初始化
this.init();
}
long sequence = sequenceValue.incrementAndGet();
if (sequence > step) {
// 意味着分配给它的序列号已经用完,需要重新分配
this.nextRound();
return this.nextValue();
} else {
return dbValue + sequence;
}
}
private synchronized void nextRound() {
if (sequenceValue.get() > step) {
// 重新生成下一个序列号
dbValue = SequenceManager.this.nextValue(key, step) - step;
sequenceValue = new AtomicLong(0);
}
}
private synchronized void init() {
if (sequenceValue != null) {
return;
}
dbValue = SequenceManager.this.nextValue(key, step) - step;
sequenceValue = new AtomicLong(0);
}
}
步长 step 的作用是什么?
步长的意思就是一次返回序列号的长度。例如:step=100,则会修改数据库中对应序列的可用值为当前值 + 100,意味着这段区间已经分配给了当前服务。只要 sequenceValue 没有超过这个步长,则可以安全的使用分配给它的这一段区间。如果超过了,则需要重新获取一个新的区间,此区间长度为 step
- 实现序列生成逻辑
/**
* @Description 序列生成器
* @Author Mr.Zhang
* @Date 2025/5/25 19:04
* @Version 1.0
*/
@Slf4j
@Component
public class SequenceManager {
@Autowired
private SequenceRecordService sequenceRecordService;
private static final Map<String, SequenceHolder> holder = new HashMap<>();
/**
* 获取下一个序列 确保唯一性
*
* @param identity Key
* @return
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public long getNextSequence(String identity) {
SequenceHolder sequenceHolder = holder.get(identity);
if (sequenceHolder == null) {
synchronized (holder) {
sequenceHolder = holder.get(identity);
if (sequenceHolder == null) {
sequenceHolder = new SequenceHolder(1); // 默认为 1
sequenceHolder.setKey(identity);
sequenceHolder.init();
holder.put(identity, sequenceHolder);
}
}
}
return sequenceHolder.nextValue();
}
/**
* 获取下一个序列 确保唯一性
*
* @param sequenceKey Key
* @return
*/
private long nextValue(String sequenceKey, long step) {
for (int i = 0; i < 10; i++) {
SequenceRecord sequenceRecord = sequenceRecordService.querySequence(sequenceKey);
int effectRow = sequenceRecordService.nextValue(sequenceRecord.getSequenceValue() + step, sequenceRecord.getSequenceValue(), sequenceKey);
if (effectRow == 1) {
return sequenceRecord.getSequenceValue() + step; // 返回下一个可用值
}
}
throw new BizException(200, "获取序列失败");
}
}
单体架构的核心代码就是这些。最主要的思路其实是保证序列生成的唯一性。此实现采用步长 + 乐观锁的方式确保不同的服务拿到的是不同的序列值
单体架构实现完整代码已上传到 github 上,感兴趣的朋友可以配合我的讲解看看具体实现代码
GitHub - nowtostudeyday/sequence-generate: 序列生成器。支持单体架构和分布式架构下的序列生成。支持自定义序列前缀,保证全局唯一性
欢迎 star~~