4、数据库设计:CA数据库表结构设计、证书序列号生成策略、数据库读写分离、数据库分库分表方案
数据库设计,说白了就是整个发证系统的地基。地基不稳,上面盖再漂亮的房子也得塌。我在做第一个规模化发证项目时,就吃过这个亏——表结构没想清楚,上线三个月后不得不停机重构,那叫一个狼狈。
今天咱们就把这块硬骨头啃下来。我会从表结构设计讲起,再到序列号生成、读写分离,最后聊分库分表。每个点我都会结合实战经验,告诉你哪些坑我踩过,哪些路我走过。
4.1 CA数据库表结构设计
CA系统的核心表,其实没几张。但每张表的设计都藏着门道。我习惯把表分成三类:证书相关、密钥相关、审计相关。
4.1.1 证书主表(certificate)
这张表是核心中的核心。每次签发证书,都会在这里插入一条记录。我见过有人把证书字段塞进一张大宽表,结果查询慢得要命。我的建议是:按访问频率拆分字段。
CREATE TABLE `certificate` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`serial_number` varchar(64) NOT NULL COMMENT '证书序列号(全局唯一)',
`subject_dn` varchar(512) NOT NULL COMMENT '证书主题DN',
`issuer_dn` varchar(512) NOT NULL COMMENT '签发者DN',
`not_before` datetime NOT NULL COMMENT '生效时间',
`not_after` datetime NOT NULL COMMENT '过期时间',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1-有效 2-吊销 3-过期',
`revoked_at` datetime DEFAULT NULL COMMENT '吊销时间',
`revoke_reason` varchar(128) DEFAULT NULL COMMENT '吊销原因',
`cert_pem` mediumtext COMMENT '证书PEM内容(大字段,单独存储)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_serial_number` (`serial_number`),
KEY `idx_subject_dn` (`subject_dn`(128)),
KEY `idx_not_after` (`not_after`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='证书主表';
嗯,这里要注意:cert_pem 我单独拎出来了。为什么?因为证书的 PEM 内容动辄几 KB,甚至几十 KB。如果每次都跟主字段一起查,IO 开销很大。我习惯把大字段放到单独的扩展表,或者干脆存对象存储,表里只留个 URL 指针。
4.1.2 密钥表(key_pair)
密钥表的设计,核心是安全隔离。私钥绝对不能明文存储。我在项目中用过 HSM(硬件安全模块),也用过软加密。不管哪种方式,表结构都差不多:
CREATE TABLE `key_pair` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`cert_id` bigint(20) NOT NULL COMMENT '关联证书ID',
`key_type` varchar(16) NOT NULL COMMENT '密钥类型:RSA/ECC/SM2',
`key_length` int(11) NOT NULL COMMENT '密钥长度',
`public_key` text NOT NULL COMMENT '公钥(PEM格式)',
`encrypted_private_key` mediumtext NOT NULL COMMENT '加密后的私钥',
`encrypt_algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '私钥加密算法',
`hsm_key_id` varchar(128) DEFAULT NULL COMMENT 'HSM中的密钥ID(如果使用HSM)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cert_id` (`cert_id`),
KEY `idx_key_type` (`key_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='密钥对表';
4.1.3 吊销列表表(crl)
CRL 表的设计,要考虑到增量查询和过期清理。我见过有人把 CRL 跟证书表混在一起,每次查吊销列表都要扫全表,性能惨不忍睹。
CREATE TABLE `crl_entry` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`serial_number` varchar(64) NOT NULL COMMENT '吊销的证书序列号',
`revoke_time` datetime NOT NULL COMMENT '吊销时间',
`reason` varchar(64) NOT NULL COMMENT '吊销原因',
`crl_number` bigint(20) NOT NULL COMMENT 'CRL序列号(递增)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_serial_number` (`serial_number`),
KEY `idx_crl_number` (`crl_number`),
KEY `idx_revoke_time` (`revoke_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRL条目表';
4.2 证书序列号生成策略
序列号生成,看着简单,其实门道很深。你想想看,CA 签发的证书可能上亿,序列号必须全局唯一,还得有规律可循。我见过三种主流方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 数据库自增 | 利用 MySQL 自增 ID | 实现简单,天然有序 | 单点瓶颈,分库后冲突 |
| 雪花算法 | 时间戳 + 机器ID + 序列号 | 高性能,分布式友好 | 依赖时钟,回拨会重复 |
| Redis 原子递增 | INCR 命令生成序列 | 高吞吐,低延迟 | Redis 挂了会丢号 |
我个人习惯用雪花算法 + 数据库兜底的组合方案。为什么?因为纯数据库自增在分库分表后很难保证全局唯一,而纯 Redis 又怕丢数据。雪花算法生成 64 位整数,前 41 位是毫秒时间戳,中间 10 位是机器 ID,最后 12 位是序列号。每秒能生成 4096 个不重复的序列号,够用了。
// 简化版雪花算法实现(Java)
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨处理
if (timestamp < lastTimestamp) {
// 等待时钟追上,或抛出异常
timestamp = waitUntil(lastTimestamp);
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - 1288834974657L) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
4.3 数据库读写分离
发证系统的读写比例,我统计过,大概是 1:10。签一张证书,可能要查十次。所以读写分离是性价比最高的优化手段。
我的做法是:主库负责写,从库负责读。但这里有个坑——主从延迟。你刚签发的证书,用户马上查询,结果从库还没同步到,就查不到了。怎么办?
解决方案:读写分离 + 强制读主
- 常规查询走从库,负载均衡
- 刚写入的数据,30 秒内强制读主库
- 关键查询(如吊销验证)始终读主库
我曾在项目中用 ShardingSphere 做读写分离,配置很简单:
# ShardingSphere 读写分离配置
spring.shardingsphere.datasource.names=master,slave1,slave2
spring.shardingsphere.datasource.master.url=jdbc:mysql://192.168.1.1:3306/ca_db
spring.shardingsphere.datasource.slave1.url=jdbc:mysql://192.168.1.2:3306/ca_db
spring.shardingsphere.datasource.slave2.url=jdbc:mysql://192.168.1.3:3306/ca_db
spring.shardingsphere.rules.readwrite-splitting.data-sources.ca_ds.type=Static
spring.shardingsphere.rules.readwrite-splitting.data-sources.ca_ds.props.write-data-source-name=master
spring.shardingsphere.rules.readwrite-splitting.data-sources.ca_ds.props.read-data-source-names=slave1,slave2
嗯,这里要注意:从库的负载均衡策略,我建议用轮询,别用随机。轮询能保证每台从库的请求量相对均匀。另外,监控从库的延迟时间,超过 5 秒就告警,超过 30 秒自动摘除。
4.4 数据库分库分表方案
当证书量突破千万级,单库就扛不住了。分库分表是必经之路。但怎么分?分不好反而更糟。
4.4.1 分片键的选择
分片键选错了,查询就是灾难。我见过有人用自增 ID 做分片键,结果业务查询全走序列号,每次都要广播到所有分片,性能还不如不分。
我的建议:用证书序列号做分片键。因为序列号是业务查询的核心字段,而且雪花算法生成的序列号本身就带有时间信息,天然适合范围查询。
4.4.2 分片策略
我常用的分片策略有两种:
| 策略 | 实现方式 | 适用场景 | 注意事项 |
|---|---|---|---|
| 哈希取模 | hash(serial_number) % 分片数 | 数据均匀分布,无热点 | 扩容时需要迁移数据 |
| 时间范围 | 按月/季度分片 | 证书有明确时间范围 | 可能产生数据倾斜 |
我个人习惯用哈希取模 + 一致性哈希。为什么?因为一致性哈希在扩容时,只需要迁移部分数据,不像普通取模那样要全量迁移。我在项目中用 16 个库,每个库 64 张表,总共 1024 张表。序列号哈希后对 1024 取模,定位到具体的表。
4.4.3 分库分表示例(ShardingSphere)
# 分库分表配置
spring.shardingsphere.rules.sharding.tables.certificate.actual-data-nodes=ca_ds$->{0..15}.certificate_$->{0..63}
# 分片算法:序列号哈希取模
spring.shardingsphere.rules.sharding.tables.certificate.table-strategy.standard.sharding-column=serial_number
spring.shardingsphere.rules.sharding.tables.certificate.table-strategy.standard.sharding-algorithm-name=hash_mod
spring.shardingsphere.rules.sharding.sharding-algorithms.hash_mod.type=HASH_MOD
spring.shardingsphere.rules.sharding.sharding-algorithms.hash_mod.props.sharding-count=1024
# 数据库分片:16个库
spring.shardingsphere.rules.sharding.sharding-algorithms.db_hash.type=HASH_MOD
spring.shardingsphere.rules.sharding.sharding-algorithms.db_hash.props.sharding-count=16
分库分表后,还有一件事不能忘——全局主键。自增 ID 在分片环境下会重复,所以必须用分布式 ID。我前面讲的雪花算法,正好派上用场。
4.5 整体架构图
下面这张图,是我在项目中实际使用的数据库架构。你看一眼,基本就明白整体脉络了。
这张图里,你从上往下看:应用服务先经过读写分离中间件,写请求走主库,读请求走从库。主库和从库各自再做分库分表,用序列号哈希取模均匀分布。最后,索引表兜底,解决跨分片查询的难题。
数据库设计这块,我讲了表结构、序列号生成、读写分离、分库分表。每个点都是我在项目中反复验证过的。你照着这个思路走,至少能避开 80% 的坑。剩下的 20%,嗯,那得靠你亲自踩一踩才能记住。