Seastar 与 ScyllaDB 线程每核架构深度实战:从 shared-nothing 分片、Future 续延到零拷贝网络的工程全解

一、问题的起点:为什么核越多,数据库反而越慢

在 8 核机器上跑得很好的数据库,搬到 64 核机器上吞吐量只涨了 2 倍——这是很多工程师第一次接触 NUMA 多路服务器时的真实体验。瓶颈往往不在磁盘、不在 SQL 解析,而在共享内存数据结构上的锁竞争与缓存一致性流量。

传统数据库(以及大多数 C++ 服务端)采用"线程池 + 共享数据"模型:N 个工作线程争抢同一棵 B+ 树、同一个 Buffer Pool、同一张 LRU 链表。当核数上升时,三件事同时恶化:

  1. 锁的串行化:一次 mutex 争抢在 64 核下可能意味着数十微秒的排队尾延迟。
  2. 缓存行伪共享与乒乓:std::atomic<uint64_t> counter 被 64 个核同时递增,每一次写都要通过 MESI 协议让其他核的副本失效,L3 互联带宽被打满。
  3. 上下文切换与调度抖动: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 设计,而不是加机器。

六、生产落地的六条经验

  1. 核数即 shard 数,且不要开超线程。Seastar 默认把 shard 绑到物理核,超线程的兄弟逻辑核共享执行单元,绑上去会得到"看起来 128 核,实际 64 核 + 一倍抖动"。若必须开启,用 --smp 显式指定为物理核数。
  2. 绝不在 reactor 线程上做阻塞 I/O。开发期打开 --abort-on-seastar-bad-alloc 与 stall 检测(默认 >100ms 打印 Reactor stalled 回溯)。日志里出现 stalled for N ms 就是 P99 抖动的直接来源。
  3. 警惕跨分区操作。ScyllaDB 的二级索引、物化视图、跨分区 BATCH 都会触发 scatter-gather(一次请求扇出到多个 shard 甚至多个节点),延迟由最慢的那个 shard 决定。能用单分区查询就用单分区。
  4. 用 workload prioritization 做多租户隔离。Seastar 支持把请求划分为多个 I/O class,ScyllaDB 暴露为 service level:给交互式查询高 shares,给离线 compaction / 备份低 shares,避免后台任务把用户请求的 reactor 时间抢光。
  5. Compaction 策略按写入形态选。时序类高写入用 IncrementalCompactionStrategy(ScyllaDB 默认,把大 SSTable 切成 1GB 分片并行压缩,避免 Cassandra STCS 的空间放大峰值);读多写少用 LeveledCompactionStrategy,用写放大换读放大。
  6. 小集群不要用。Thread-per-core 的收益来自核数;3 节点的 4 核机器跑 ScyllaDB,既享受不到分片扩展,又要承担 shard 转发开销,性价比不如传统方案。经验门槛是单节点 ≥ 16 核。

七、什么时候不该选这套架构

必须诚实地说,shared-nothing 不是银弹:

  • 需要大量跨分区事务或全局一致性快照的场景,跨核协调成本会吃掉分片带来的收益;
  • 负载极不均衡且无法通过 key 设计打散(例如全局计数器、热点 ID)的场景,分片等于把热点固定在一个核上;
  • 生态依赖 Cassandra 的全部企业级特性时,需要逐个确认兼容性。

反过来说,如果你的负载是高 QPS、key 可散列、单次操作小、延迟敏感——时序指标、用户画像、特征存储、实时推荐缓存——那么 thread-per-core 带来的线性扩展与稳定尾延迟,是任何"调优线程池参数"都换不来的量级差异。

八、结语

Seastar 的价值不在于某个具体的 API,而在于它把一句老话变成了架构强制约束:不要通过共享内存来通信,要通过通信来共享内存。ScyllaDB 只是把这条约束一路贯彻到了存储引擎:分区决定核、核决定内存、内存决定分配器、网络栈决定零拷贝。理解了这条因果链,你就不会再把它当成"又一个 Cassandra 兼容数据库",而会看到一台 64 核机器是如何被真正用满的。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部