Seastar 与 ScyllaDB 线程每核架构深度实战:从 shared-nothing 分片、Future 续延到零拷贝网络的工程全解
一、问题的起点:为什么核越多,数据库反而越慢
在 8 核机器上跑得很好的数据库,搬到 64 核机器上吞吐量只涨了 2 倍——这是很多工程师第一次接触 NUMA 多路服务器时的真实体验。瓶颈往往不在磁盘、不在 SQL 解析,而在共享内存数据结构上的锁竞争与缓存一致性流量。
传统数据库(以及大多数 C++ 服务端)采用"线程池 + 共享数据"模型:N 个工作线程争抢同一棵 B+ 树、同一个 Buffer Pool、同一张 LRU 链表。当核数上升时,三件事同时恶化:
- 锁的串行化:一次
mutex争抢在 64 核下可能意味着数十微秒的排队尾延迟。 - 缓存行伪共享与乒乓:
std::atomic<uint64_t> counter被 64 个核同时递增,每一次写都要通过 MESI 协议让其他核的副本失效,L3 互联带宽被打满。 - 上下文切换与调度抖动:OS 调度器并不理解你的数据局部性,一个刚热好 L1/L2 的线程可能被抢占、迁移到另一个 NUMA 节点。
ScyllaDB 给出的答案很激进:干脆不要共享。它把整台机器切成 S 个 shard(S = 物理核数),每个 shard 独占一个核、独占一份内存、独占一个事件循环,数据按 partition key 的 hash 静态划分到 shard,跨 shard 通信只走显式消息队列。这套模型由底层框架 Seastar 提供,ScyllaDB 是它最著名的使用者(此外还有 Redpanda、Ceph 的 Crimson OSD)。
二、Thread-per-core 的第一性原理
2.1 shared-nothing 分片
在 Seastar 里,"锁"几乎是禁词。替代方案是所有权:
- 每个 shard 拥有一份数据结构的私有副本;
- 任何跨 shard 的访问不能"直接读",必须发消息给拥有者,由拥有者在自己的核上执行,再把结果发回来;
- 因此数据访问路径上没有互斥、没有原子计数竞争,只有顺序执行的事件循环。
这意味着吞吐量随核数近似线性扩展——只要你没有热点。
2.2 Reactor 事件循环
每个 shard 是一个 reactor:一个 OS 线程、pthread_setaffinity 绑定到指定核、跑一个永不阻塞的 epoll(或 aio)循环。关键的工程纪律是:在 reactor 线程上绝对不能做阻塞系统调用。一次 10ms 的 open() 会让这个核上排队的数万个请求全部延迟 10ms,这就是著名的 stall。
Seastar 的做法是把所有可能阻塞的东西都异步化,或者丢给专门的 alien thread pool:
// 错误示范:在 reactor 线程上阻塞
seastar::future<> bad_handler() {
std::ifstream f("/etc/config"); // 阻塞 I/O,会 stall 整个核
return seastar::make_ready_future<>();
}
// 正确示范:用 Seastar 的异步文件 I/O
seastar::future<> good_handler() {
return seastar::open_file_dma("/etc/config", seastar::open_flags::ro)
.then([] (seastar::file f) {
return f.dma_read_bulk<char>(0, 4096);
}).then([] (seastar::temporary_buffer<char> buf) {
fmt::print("read {} bytes\n", buf.size());
return seastar::make_ready_future<>();
});
}
// 确实无法异步的第三方库:显式丢到线程池
seastar::future<> legacy_handler() {
return seastar::smp::submit_to(0, [] {
return seastar::async([] { // seastar::thread,有栈协程
legacy_blocking_call(); // 允许阻塞,但不在 reactor 上
});
});
}
三、Future 续延:把异步写成同步
Seastar 提供的是无栈协程(在 C++20 coroutine 普及之前就是 continuation 链)。一个 future<T> 表示"将来会有的 T",.then() 挂接一个续延函数,由 reactor 在事件就绪时调度执行。
seastar::future<> handle_read(seastar::lw_shared_ptr<partition> p, bytes key) {
return p->memtable_lookup(key)
.then([p, key] (std::optional<row> r) {
if (r) {
return seastar::make_ready_future<std::optional<row>>(std::move(r));
}
// memtable 未命中,走 SSTable,可能触发磁盘 I/O
return p->sstable_lookup(key);
})
.then([] (std::optional<row> r) {
if (!r) {
return seastar::make_exception_future<>(not_found{});
}
respond(*r);
return seastar::make_ready_future<>();
})
.handle_exception([] (std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (const not_found&) { respond_empty(); }
return seastar::make_ready_future<>();
});
}
这里有几个值得注意的工程细节:
- 续延里的闭包必须显式捕获并移动(
[p, key]),因为调用栈在.then()返回后就没了。这是无栈协程最容易踩的坑:捕获引用会导致悬垂。 lw_shared_ptr而非shared_ptr:Seastar 的shared_ptr删除了原子引用计数(单 shard 内无竞争),lw_shared_ptr更是去掉了控制块指针,缓存更友好。跨 shard 传递对象必须显式smp::submit_to或序列化。- 异常以
future形式传播,用handle_exception终结,绝不跨续延抛裸异常。
跨核通信的代价
// 请求 shard 3 上的一个分区,结果返回本 shard
return seastar::smp::submit_to(3, [key] {
return shard_local_table().get(key);
}).then([] (std::optional<row> r) {
respond(r);
});
submit_to 的成本是:一次无锁队列入队 + 目标核被唤醒(或轮询发现)+ 出队 + 执行 + 结果回传队列。量级在微秒级,比一次跨核原子操作便宜,但比本地调用贵一到两个数量级。所以设计原则非常明确:让数据来找代码,而不是让代码去找数据。ScyllaDB 的 coordinator 收到请求后,第一件事就是算 token,然后用 smp::submit_to 把读写转发到 owning shard——一次转发,而不是 N 次抢锁。
四、内存与网络:两个被低估的战场
4.1 per-core 分配器
如果所有 shard 共用 malloc,malloc 内部的 arena 锁又会把你拉回原点。Seastar 实现了 per-core allocator:每个 shard 从自己绑定的 NUMA 节点上分配大块内存,切成 per-shard 的 small pool。跨 shard 释放(一个对象在 shard 0 分配、在 shard 5 被销毁)不直接 free,而是塞进一个 跨核释放队列,批量归还给原主。结果是:热路径上零锁、NUMA 本地访问、几乎没有伪共享。
4.2 零拷贝网络
Seastar 自带用户态 TCP/IP 栈(可跑在 DPDK 上,也可走内核的 AF_XDP/普通 socket)。数据包从网卡 DMA 进内存后,一直到应用层解析完,都不发生一次 memcpy:报文由 packet fragment 链表管理,temporary_buffer 持有引用,协议栈只是移动指针。对 ScyllaDB 这种"小请求、高 QPS"的负载,省掉的 memcpy 与 page fault 直接体现在 P99 上。
五、ScyllaDB 如何把 CQL 映射到 shard
ScyllaDB 沿用了 Cassandra 的数据模型与 token ring,但每个 token range 的归属是核而不是节点:
CREATE KEYSPACE iot WITH replication = {
'class': 'NetworkTopologyStrategy', 'dc1': 3
};
CREATE TABLE iot.metrics (
device_id text,
bucket timestamp,
ts timestamp,
temperature double,
PRIMARY KEY ((device_id, bucket), ts)
) WITH CLUSTERING ORDER BY (ts DESC)
AND compaction = { 'class': 'IncrementalCompactionStrategy' };
注意 PRIMARY KEY ((device_id, bucket), ts):(device_id, bucket) 是 partition key,决定这行落在哪个 shard;ts 是 clustering key,决定分区内的物理排序。分桶(bucket)是这套架构下最重要的建模决策——如果你只用 device_id 作 partition key,一个设备跑三年会把 500 万行塞进同一个分区,而那个分区只属于一个核。这个核会变成全集群的热点,其他 63 个核闲着看戏。
定位某个分区在哪个 shard,可以直接问系统表:
SELECT shard, token, partition_key, clustering_key
FROM system.clustering_info
WHERE keyspace_name = 'iot' AND table_name = 'metrics'
LIMIT 5;
生产上更常用的诊断方式是看 per-shard 指标是否偏斜:
# 观察各 shard 的读写请求分布是否均衡
scylla monitor --shards
nodetool tablestats iot.metrics # 关注 SSTable count / Bloom filter 命中率
# 大分区排查
nodetool tablehistograms iot.metrics # 若 99th percentile partition size 达 MB 级即为热点
实战判断标准:任何一个 shard 的 CPU 持续高于均值 20% 以上,就是数据倾斜信号,优先查 partition key 设计,而不是加机器。
六、生产落地的六条经验
- 核数即 shard 数,且不要开超线程。Seastar 默认把 shard 绑到物理核,超线程的兄弟逻辑核共享执行单元,绑上去会得到"看起来 128 核,实际 64 核 + 一倍抖动"。若必须开启,用
--smp显式指定为物理核数。 - 绝不在 reactor 线程上做阻塞 I/O。开发期打开
--abort-on-seastar-bad-alloc与 stall 检测(默认 >100ms 打印Reactor stalled回溯)。日志里出现stalled for N ms就是 P99 抖动的直接来源。 - 警惕跨分区操作。ScyllaDB 的二级索引、物化视图、跨分区 BATCH 都会触发 scatter-gather(一次请求扇出到多个 shard 甚至多个节点),延迟由最慢的那个 shard 决定。能用单分区查询就用单分区。
- 用 workload prioritization 做多租户隔离。Seastar 支持把请求划分为多个 I/O class,ScyllaDB 暴露为 service level:给交互式查询高 shares,给离线 compaction / 备份低 shares,避免后台任务把用户请求的 reactor 时间抢光。
- Compaction 策略按写入形态选。时序类高写入用
IncrementalCompactionStrategy(ScyllaDB 默认,把大 SSTable 切成 1GB 分片并行压缩,避免 Cassandra STCS 的空间放大峰值);读多写少用LeveledCompactionStrategy,用写放大换读放大。 - 小集群不要用。Thread-per-core 的收益来自核数;3 节点的 4 核机器跑 ScyllaDB,既享受不到分片扩展,又要承担 shard 转发开销,性价比不如传统方案。经验门槛是单节点 ≥ 16 核。
七、什么时候不该选这套架构
必须诚实地说,shared-nothing 不是银弹:
- 需要大量跨分区事务或全局一致性快照的场景,跨核协调成本会吃掉分片带来的收益;
- 负载极不均衡且无法通过 key 设计打散(例如全局计数器、热点 ID)的场景,分片等于把热点固定在一个核上;
- 生态依赖 Cassandra 的全部企业级特性时,需要逐个确认兼容性。
反过来说,如果你的负载是高 QPS、key 可散列、单次操作小、延迟敏感——时序指标、用户画像、特征存储、实时推荐缓存——那么 thread-per-core 带来的线性扩展与稳定尾延迟,是任何"调优线程池参数"都换不来的量级差异。
八、结语
Seastar 的价值不在于某个具体的 API,而在于它把一句老话变成了架构强制约束:不要通过共享内存来通信,要通过通信来共享内存。ScyllaDB 只是把这条约束一路贯彻到了存储引擎:分区决定核、核决定内存、内存决定分配器、网络栈决定零拷贝。理解了这条因果链,你就不会再把它当成"又一个 Cassandra 兼容数据库",而会看到一台 64 核机器是如何被真正用满的。

发表评论 取消回复