主键 ID VS 业务 ID
在数据库设计中,除了主键 ID,一般还需要一个具有唯一索引的业务 ID。二者承担的职责不一样,它们共同满足了我们对于 技术实现 和 业务需求 的双重目标
职责分离原则
主键 ID | 业务唯一标识 ID | |
---|---|---|
作用 | 保证数据库层面的唯一性 | 保证业务层面的唯一性 |
目标 | <font style="color:rgb(79, 79, 79);background-color:rgb(247, 247, 247);">保证数据存储和关联的可靠性</font> | <font style="color:rgb(79, 79, 79);background-color:rgb(247, 247, 247);">满足业务规则和外部交互需求</font> |
特点 | <font style="color:rgb(79, 79, 79);">无意义、自增/随机、不可变</font> | 有具体业务含义、可读、可暴露 |
eg:
- 商品表的主键 ID 可能是 1、2、3,但商品编码(业务唯一标识)可能是
00012517271821
。前四位是所处的地区码,中间是随机生成的数字,最后四位是新增这个商品的用户 ID 后四位- 订单表的主键 ID 可能是 1、2、3,但订单编码可能是时间戳拼上今天订单的序号:
202505220012
使用场景分析
场景一:防止暴露内部信息
- 问题:<font style="color:rgba(0, 0, 0, 0.75);">直接暴露自增主键</font>
<font style="color:rgb(199, 37, 78);">ID</font>
<font style="color:rgba(0, 0, 0, 0.75);">,可能泄露业务规模(如用户量、订单量),甚至被恶意遍历数据</font> - <font style="color:rgba(0, 0, 0, 0.75);">解决</font><font style="color:rgba(0, 0, 0, 0.75);">:使用无规律的业务</font>
<font style="color:rgb(199, 37, 78);">ID</font>
<font style="color:rgba(0, 0, 0, 0.75);">(如UUID、哈希值)对外暴露,隐藏自增主键</font>
场景二:分库分表需求
- 问题:如果需要分库分表,主键
<font style="color:rgb(199, 37, 78);">ID</font>
就无法保证全局唯一性 - 解决:通过业务
<font style="color:rgb(199, 37, 78);">ID</font>
实现全局唯一(雪花算法生成的分布式 ID、使用自定义序列生成器生成的 ID)
场景三:业务标识符的灵活性
- 问题:<font style="color:rgba(0, 0, 0, 0.75);">业务唯一标识可能需要动态规则(如订单号包含日期、地区码),而自增主键无法满足</font>
- 解决:业务唯一标识
<font style="color:rgb(199, 37, 78);">ID</font>
按业务规则生成,主键<font style="color:rgb(199, 37, 78);">ID</font>
保持默认策略
技术实现对比
主键 ID | 业务唯一标识 ID | |
---|---|---|
<font style="color:rgb(79, 79, 79);">数据类型</font> | <font style="color:rgb(79, 79, 79);">通常为 </font><font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">int/bigint</font> <font style="color:rgb(79, 79, 79);">(高效索引)</font> | <font style="color:rgb(79, 79, 79);">可能是 </font><font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">varchar</font> <font style="color:rgb(79, 79, 79);">(兼容复杂规则)</font> |
<font style="color:rgb(79, 79, 79);">唯一性范围</font> | <font style="color:rgb(79, 79, 79);">表内唯一</font> | <font style="color:rgb(79, 79, 79);">全局唯一(跨表、跨系统)</font> |
<font style="color:rgb(79, 79, 79);">生成方式</font> | <font style="color:rgb(79, 79, 79);">自增/随机</font> | <font style="color:rgb(79, 79, 79);">程序生成(UUID、雪花算法、业务规则拼接)</font> |
<font style="color:rgb(79, 79, 79);">修改性</font> | <font style="color:rgb(79, 79, 79);">不可变(与数据生命周期绑定)</font> | <font style="color:rgb(79, 79, 79);">可能允许修改(如用户重设唯一用户名)</font> |
:::color4
<font style="color:#E4495B;">建议:</font>
- 主键
<font style="color:rgb(199, 37, 78);">ID</font>
始终存在:作为数据库的“技术锚点”,用于外键关联、索引优化 业务唯一标识
<font style="color:rgb(199, 37, 78);">ID</font>
按需设计- <font style="color:#2F8EF4;">若无需业务唯一标识,可省略</font><font style="color:rgba(0, 0, 0, 0.75);"></font>
- <font style="color:#2F8EF4;">若需暴露或业务规则复杂,必加,并为其添加唯一索引</font>
查询优化
- 内部关联用
<font style="color:rgb(199, 37, 78);">ID</font>
(更快) - 对外接口用业务
<font style="color:rgb(199, 37, 78);">ID</font>
(更安全)
- 内部关联用
:::
何时不需要用业务 ID ?
- <font style="color:rgba(0, 0, 0, 0.75);">纯内部工具表,无暴露需求</font>
- <font style="color:rgba(0, 0, 0, 0.75);">业务标识符可直接复用主键(如简单的配置表)</font>
为什么需要自定义序列生成器?
前面有说过业务<font style="color:rgb(199, 37, 78);">ID</font>
一般是具有具体业务含义的,我们需要支持根据动态规则来生成具有不同业务属性的业务<font style="color:rgb(199, 37, 78);">ID</font>
:::tips
注意:
自定义序列生成器一般用来生成业务 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);
}
}
<details class="lake-collapse"><summary id="ube6e04aa"><span class="ne-text">步长 step 的作用是什么?</span></summary><p id="ua881deb9" class="ne-p"><span class="ne-text">步长的意思就是一次返回序列号的长度。例如:step=100,则会修改数据库中对应序列的可用值为当前值 + 100,意味着这段区间已经分配给了当前服务。只要 sequenceValue 没有超过这个步长,则可以安全的使用分配给它的这一段区间。如果超过了,则需要重新获取一个新的区间,此区间长度为 step</span></p></details>
- 实现序列生成逻辑
/**
* @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 上,感兴趣的朋友可以配合我的讲解看看具体实现代码
https://github.com/nowtostudeyday/sequence-generate
本文由博客一文多发平台 OpenWrite 发布!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。