设计写密集型系统
问题
如何设计一个写多读少的高吞吐数据库架构?
答案
一、场景分析
| 场景 | 写入量级 | 特点 |
|---|---|---|
| 日志收集 | 百万 QPS | 只追加,几乎不改 |
| 埋点数据 | 十万 QPS | 高吞吐,可接受延迟 |
| IoT 传感器 | 百万 QPS | 时序数据,定期聚合 |
| 秒杀库存扣减 | 万级 QPS | 热点行,强一致 |
| 消息系统 | 十万 QPS | 顺序写入,持久化 |
二、写优化策略总览
三、批量写入
// ❌ 逐条插入
for (const log of logs) {
await db.execute('INSERT INTO logs (msg, ts) VALUES (?, ?)', [log.msg, log.ts]);
}
// 1000 条 = 1000 次网络往返
// ✅ 批量插入
await db.execute(
'INSERT INTO logs (msg, ts) VALUES ' +
logs.map(() => '(?, ?)').join(', '),
logs.flatMap(log => [log.msg, log.ts])
);
// 1000 条 = 1 次网络往返
-- MySQL 批量插入优化
SET autocommit = 0;
INSERT INTO logs (msg, ts) VALUES
('msg1', NOW()), ('msg2', NOW()), ..., ('msg1000', NOW());
COMMIT;
-- 单事务提交,减少 fsync 次数
四、异步写入(消息队列削峰)
class LogWriter {
private buffer: LogEntry[] = [];
private readonly BATCH_SIZE = 1000;
private readonly FLUSH_INTERVAL = 1000; // ms
async add(entry: LogEntry) {
this.buffer.push(entry);
if (this.buffer.length >= this.BATCH_SIZE) {
await this.flush();
}
}
// 定时刷盘
@Interval(1000)
async flush() {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.BATCH_SIZE);
await this.db.batchInsert('logs', batch);
}
}
五、分库分表
当单库写入成为瓶颈时,进行水平分片:
分片策略:user_id % 4
user_id=1 → db_01.users
user_id=2 → db_02.users
user_id=3 → db_03.users
user_id=4 → db_00.users
function getShardDb(userId: number, totalShards: number): string {
const shardIndex = userId % totalShards;
return `db_${String(shardIndex).padStart(2, '0')}`;
}
分库分表带来的问题
| 问题 | 解决方案 |
|---|---|
| 跨分片查询 | 聚合层查询 / ES 全局索引 |
| 跨分片事务 | 分布式事务(Seata)/ 最终一致 |
| 全局排序 | 各分片排序后归并 |
| 全局 ID | 雪花算法 / 号段模式 |
| 扩容 | 一致性哈希 / 成倍扩容 |
六、写入优化清单
| 优化项 | 说明 | 效果 |
|---|---|---|
| 减少索引 | 写入时需维护所有索引 | 每减一个索引提升 10~20% |
| 批量提交 | 多条合并为一次 COMMIT | 减少 fsync,提升 10x+ |
| 异步写入 | 消息队列削峰 | 平滑写入峰值 |
| 预分配空间 | InnoDB innodb_autoextend_increment | 减少文件扩展 |
| SSD | 固态硬盘替换机械盘 | IOPS 提升 100x |
| WAL 合并 | innodb_flush_log_at_trx_commit=2 | 牺牲少量持久性换吞吐 |
innodb_flush_log_at_trx_commit
= 1(默认):每次事务提交都 fsync,最安全= 2:每秒 fsync,宕机可能丢 1 秒数据= 0:每秒 fsync + 写日志到 OS 缓存- 写密集场景可设为 2,但需要评估数据丢失风险
常见面试问题
Q1: 设计一个日志收集系统的数据库方案
答案:
架构:应用 → Kafka → Flink 聚合 → ClickHouse
1. 应用异步发送日志到 Kafka(毫秒级延迟可接受)
2. Flink 实时消费,按时间窗口聚合
3. 批量写入 ClickHouse(列式存储,写入吞吐高)
4. 原始日志同时归档到 S3/OSS(冷数据)
5. ClickHouse 按日分区,自动过期清理
不用 MySQL 的原因:
- 日志数据量巨大,MySQL 单表亿级写入困难
- 日志主要做分析查询,列式存储更适合
- 不需要事务保证
Q2: 秒杀场景下如何优化库存扣减的写性能?
答案:
1. 预热:将库存加载到 Redis
SET stock:item_001 1000
2. 扣减:Redis Lua 原子扣减
if redis.call('get', key) > 0 then
redis.call('decr', key)
return 1
end
return 0
3. 异步落库:Kafka 消费生成订单 + 更新 MySQL 库存
4. 兜底:MySQL 乐观锁
UPDATE items SET stock = stock - 1
WHERE id = ? AND stock > 0
Q3: 分库分表后如何做全局分页查询?
答案:
方案对比:
| 方案 | 原理 | 适合场景 |
|---|---|---|
| 禁止深分页 | 只允许「上一页/下一页」 | 信息流 |
| 全局 ES 索引 | 所有分片数据同步到 ES | 搜索场景 |
| 归并排序 | 各分片查 N 条后归并 | 数据量可控 |
| 分页游标 | 基于上一页最后一条的排序值 | 通用 |