API 商业化落地的这几年,数据库表结构设计几乎决定着整条服务链路的稳定性、扩展性以及后期运维成本。不少团队为了抢上线窗口,将用户、权限、日志、计费等逻辑全部塞入同一张表。一旦调用量上升,问题立刻暴露:计费对账错乱、海量日志查询卡成幻灯片、并发调用偶尔出现超扣、权限管控混乱不堪。此时再想重构,成本和风险都极为高昂。

一、业务编码冗余的无联表查询架构
技术的核心思路其实非常清晰:在高并发场景下,多表 JOIN 是性能与扩展性的头号杀手。传统做法习惯使用主键关联后再联表查询,但在 API 平台这类高频调用的业务中,联表带来的锁竞争和 I/O 开销很快就会成为瓶颈。因此,直接在调用日志、套餐权限等高频率表中冗余 app_key、api_code 等业务唯一标识——用户调用记录查询、接口统计等核心场景,全部变成单表查询。这样做的好处有:物理主键与业务数据解耦,后续数据迁移、分库分表时业务逻辑不受影响;且核心查询性能至少提升 40% 以上。
-- 日志表冗余业务字段,避免关联用户表、接口表
CREATE TABLE `api_call_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`app_key` varchar(64) NOT NULL COMMENT '冗余字段:用户身份标识',
`api_id` bigint NOT NULL,
`api_code` varchar(64) NOT NULL COMMENT '冗余字段:接口业务编码',
`deduct_amount` decimal(10,4) DEFAULT 0.0000,
`call_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_time` (`user_id`,`call_time`),
KEY `idx_app_key_time` (`app_key`,`call_time`) -- 直接通过 app_key 查询调用记录
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 核心查询:单表查询用户近 7 天调用记录,无需关联用户表
SELECT api_code, COUNT(*) AS call_num, SUM(deduct_amount) AS total_cost
FROM api_call_log
WHERE app_key = 'ak_xxxxxx' AND call_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY api_code;
二、双层扣费的事务边界与幂等保障机制
API 调用通常同时涉及账户余额扣减 + 套餐次数扣减 + 调用日志写入三个操作。单靠表的行级锁无法覆盖全链路的一致性——比如余额扣了但日志没写进去,对账就会出问题。设计核心思路是:窄事务边界 + request_id 唯一幂等。将扣费和日志写入放在同一个事务里,同时在日志表的 request_id 上建立唯一索引,这样每个请求天然就是幂等的。即便出现网络重试、超时重发,也不会产生重复扣费的资损风险。
-- 日志表增加 request_id 唯一索引,作为幂等键
ALTER TABLE api_call_log ADD UNIQUE KEY `uk_request_id` (`request_id`);
-- 完整扣费事务:余额扣减 + 套餐扣减 + 日志写入,天然幂等
START TRANSACTION;
-- 1. 扣减账户余额(行锁保证原子性)
UPDATE api_user SET balance = balance - 0.0100, total_calls = total_calls + 1
WHERE id = 1001 AND balance >= 0.0100 AND status = 1;
-- 2. 扣减套餐剩余次数
UPDATE api_user_package SET surplus_num = surplus_num - 1, daily_used = daily_used + 1
WHERE user_id = 1001 AND api_id = 101 AND surplus_num >= 1 AND status = 1;
-- 3. 写入调用日志(唯一索引触发重复键报错,实现幂等)
INSERT IGNORE INTO api_call_log (user_id, app_key, api_id, api_code, request_id, deduct_amount, business_code)
VALUES (1001, 'ak_xxxxxx', 101, 'goods_detail', 'req_202607010001', 0.0100, '0');
COMMIT;
三、日志表梯度索引与分级存储优化
调用日志是 API 平台数据量最大的表,没有之一。如果所有字段都存、所有查询场景都建索引,表体积会急剧膨胀,写入性能也会直线下降。所以这里采用 梯度索引 + 分级存储 的策略:核心查询场景(比如按用户+时间、按接口+时间)建立联合索引;长尾排查场景(比如按 IP、错误码)则不建索引,采用离线分析去查询。存储上也做分级——成功调用只存储响应摘要,失败调用才存储完整报错信息;请求参数则自动脱敏后再落库。这样一来,单表体积能降低 30% 以上,写入 QPS 提升 25%。
-- 梯度索引设计:仅保留 3 个核心查询索引,拒绝无效索引
CREATE TABLE `api_call_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`request_id` varchar(64) NOT NULL,
`request_params` text COMMENT '脱敏后请求参数',
`response_summary` varchar(500) DEFAULT '' COMMENT '成功调用:响应摘要',
`response_full` text COMMENT '失败调用:完整报错信息',
`call_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- 核心索引:用户+时间、接口+时间、幂等键
KEY `idx_user_time` (`user_id`,`call_time`),
KEY `idx_api_time` (`api_id`,`call_time`),
UNIQUE KEY `uk_request_id` (`request_id`)
-- 拒绝为 IP、错误码等长尾查询单独建索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 分级存储插入示例:成功存摘要,失败存全量
-- 成功调用
INSERT INTO api_call_log (response_summary, response_full, business_code)
VALUES ('返回商品数据10条', '', '0');
-- 失败调用
INSERT INTO api_call_log (response_summary, response_full, business_code, error_msg)
VALUES ('参数校验失败', '{"code":400,"msg":"商品ID格式错误","trace":"xxx"}', '400', '商品ID格式错误');
