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='密钥对表';
我曾经踩过的坑: 私钥加密密钥的管理。一开始我把加密密钥写死在配置文件里,结果安全审计没过。后来改用 KMS(密钥管理服务)+ 定期轮换策略,才算过关。记住:加密私钥的密钥,绝不能跟数据库放在同一台机器上。

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 个不重复的序列号,够用了。

我的实战经验: 雪花算法最怕时钟回拨。我在项目中加了一层保护:每次生成前检查当前时间戳是否小于上次记录的时间戳。如果回拨了,就等待时钟追上,或者用备用序列号段。另外,机器 ID 不要硬编码,建议用注册中心动态分配。
// 简化版雪花算法实现(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 取模,定位到具体的表。

我曾经踩过的坑: 分库分表后,跨分片的查询怎么办?比如查某个用户的所有证书。我的做法是:建立索引表。用一张单独的索引表记录「用户ID → 证书序列号」的映射关系。查询时先查索引表拿到序列号,再根据序列号定位到具体分片。虽然多了一次查询,但避免了全分片广播。

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 整体架构图

下面这张图,是我在项目中实际使用的数据库架构。你看一眼,基本就明白整体脉络了。

CA数据库整体架构图 发证应用服务 读写分离中间件(ShardingSphere) 主库(写) 从库1(读) 从库2(读) 分片库组1(库0-3) certificate_0 ~ certificate_63 序列号哈希取模分片 分片库组2(库4-15) certificate_0 ~ certificate_63 序列号哈希取模分片 索引表(用户ID → 证书序列号映射) 说明:主库负责写入,从库负责读取;分片键使用证书序列号,哈希取模后均匀分布到16个库1024张表;索引表解决跨分片查询问题

这张图里,你从上往下看:应用服务先经过读写分离中间件,写请求走主库,读请求走从库。主库和从库各自再做分库分表,用序列号哈希取模均匀分布。最后,索引表兜底,解决跨分片查询的难题。

数据库设计这块,我讲了表结构、序列号生成、读写分离、分库分表。每个点都是我在项目中反复验证过的。你照着这个思路走,至少能避开 80% 的坑。剩下的 20%,嗯,那得靠你亲自踩一踩才能记住。