ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cassandra查询优化:从数据模型到慢查询排查

Cassandra查询优化:从数据模型到慢查询排查 Cassandra这玩意儿用过的朋友都知道写起来一时爽查起来火葬场。尤其是数据量一上来或者查询模式没设计好明明只是按个ID查数据延迟却动不动几百毫秒甚至直接给你抛个TimeoutException让人血压飙升。这背后的核心问题往往不是集群机器不够而是你对Cassandra的分布式数据模型理解得不够透彻。这篇东西我不会讲太多虚的就围绕Cassandra查询优化这条主线结合我实操中踩过的坑把从表结构设计到查询语句写法再到服务端调优和慢查询排查的完整链路掰开揉碎讲清楚。我在生产环境里见过太多因为前期没规划好导致后期查询性能拉胯、不得不凌晨起来迁移数据的案例了。而且很多人习惯用MySQL、Doris那套优化思路去套Cassandra结果南辕北辙。比如最近不少人问的Doris慢查询优化那套统计信息收集、Join重排的路子在Doris里确实好用但在Cassandra里基本不适用甚至会把你带沟里去。所以这篇文章我尽量把关键点讲透你看完至少能明白一个查询慢到底慢在哪个环节以及怎么针对性地干掉这个瓶颈。这篇文章适合还在为Cassandra查询性能头疼的开发者、运维同学以及正在做技术选型、准备把核心业务迁移到Cassandra上的架构师。1. 先搞清楚 Cassandra 查询到底慢在哪1.1 查询优化的本质一张表就是一个分布式哈希表很多优化做不好根子在于没理解Cassandra的底层存储结构。Cassandra本质上是一个分布式的、基于分区Partition的键值存储系统。你要记住一个关键概念一张表Table在物理上是被拆散存储的拆散的依据就是Primary Key中的Partition Key。你在CQL里写一个查询比如SELECT * FROM users WHERE user_id 123Cassandra会先用Partition Key这里是user_id算出一个Token值然后根据这个Token值去集群里定位这个分区到底在哪个节点上。这个定位过程走的是Gossip协议和Partitioner默认是Murmur3Partitioner很快毫秒级。这里的关键是如果你的查询能精确命中一个分区即WHERE条件里包含了完整的Partition Key那这个查询就是单点查询延迟最低。相反如果条件里没有Partition Key或者只有一部分Cassandra就得在所有节点上进行扫描然后把结果合并返回这就是全表扫描的雏形。所以查询优化的第一性原理就是想尽一切办法让你的查询只路由到最少的分区。能走单分区查询绝不走多分区查询能走多分区扫描绝不做全集群扫描。1.2 和 Doris 的慢查询优化思路对比别拿过去的经验套我知道现在不少团队也在用Doris这类OLAP引擎。Doris慢查询优化你打开它的Profile能看到Scan节点、Exchange节点、Join节点的详细耗时然后你会去调并行度、调Bucket Shuffle、改Colocate Join这一套组合拳在Doris里非常有效。但你把这一套思维搬到Cassandra上就会发现寸步难行。原因在于Cassandra没有像MySQL、Doris那样的复杂查询优化器CBO它不做基于统计信息的Join重排也不做代价估算。Cassandra的查询路径是一次性的你写了什么查询它就只能按这个查询所对应的Partition Key去路由。不存在“查询优化器自动帮你选索引”这种说法。Cassandra的二级索引在OLAP场景下就是个灾难因为它本质上是本地索引查询时要发到所有节点俗称“扩散查询”。所以面对Cassandra和Doris你的心态完全不同。Doris是“写完SQL让优化器帮我调”Cassandra是“建表前我就得把查询想好表结构就是查询模式”。1.3 一次查询的完整路径看懂瓶颈在哪个环节为了更精准定位慢查询你得知道一次查询在Cassandra内部是怎么流转的。整个过程是这样的协调节点Coordinator接收查询请求。根据Partition Key计算Token找到数据所在的副本节点。将查询请求发送给副本节点等待响应。副本节点在自己的内存Memtable里查找如果没找到再去SSTable里查这里涉及Bloom Filter、Key Cache、Partition Index等机制后面细说。副本节点返回数据协调节点根据一致性级别合并结果返回给客户端。了解了这个流程遇到慢查询你就知道该往哪看是协调节点阻塞了GC停顿、线程池耗尽还是副本节点读慢了Bloom Filter失效、缓存命中率低、SSTable数量多还是网络往返延迟大了跨数据中心。注意Cassandra的写是内存写读才是真正的“硬功夫”。所以优化核心精力一定放在读路径上。2. 数据模型设计查询优化的第一战场2.1 先别写代码把查询需求列出来做反范式设计Cassandra的设计哲学和传统关系型数据库完全是两码事。关系型数据库讲究“数据单一来源”查询时通过Join把数据拼起来。Cassandra是**查询优先Query-First**的设计哲学你得先想好业务需要什么查询然后专门为这个查询建一张表。这个“反范式”设计我举一个电商订单的例子你就懂了。在MySQL里你可能设计成orders表和order_items表然后通过order_id关联查询。但在Cassandra里你如果想按用户查询他的订单列表就应该这么建表CREATE TABLE orders_by_user ( user_id text, order_id text, order_time timestamp, amount decimal, PRIMARY KEY ((user_id), order_time, order_id) ) WITH CLUSTERING ORDER BY (order_time DESC);这么设计的核心思路就是把用户的所有订单按user_id分区在分区内按时间倒序排序。这样查询某个用户的订单列表就是一次精确的单分区范围查询性能极佳。如果你还需要按“订单状态”去查那通常得另外建一张orders_by_status表用状态作为Partition Key的一部分。虽然带来了写入冗余但换来了查询的极致性能。在Cassandra里冗余不是耻辱是一种性能手段。2.2 分区键设计均匀散列是最核心KPI分区键Partition Key的设计直接决定了你的数据能不能均匀分布到集群各个节点。如果分区键的选择有问题会导致大量数据堆积到少数几个节点上形成热点分区Hot Spot查询的时候这少数几个节点会成为瓶颈。以IoT场景为例如果你用device_id做分区键而最新活动的设备集中在某几个节点上这几个节点会忙死其他节点闲着看戏。解决思路有几种加盐Salting把用户ID拼接一个随机数或分区序号让数据更均匀。代价是查询时要查询多个分区然后合并。使用时间桶Time Bucket如果你的数据有强烈的时间属性比如设备上报数据可以设计为PRIMARY KEY ((device_id, day_bucket), report_time)。这样每个分区只存某设备一天的数据分区大小可控查询时可以按天粒度去查解决了无界增长问题。我自己用下来这个方案是物联网场景最稳的实践。比较常见的误区是上来就用时间戳做分区键比如PRIMARY KEY ((timestamp))。这样每个时刻一个分区你查一个范围内的数据等于要查询成千上万个分区性能直接崩盘。时间戳适合做Clustering Key而不是Partition Key。2.3 聚簇列排序让数据物理上就按你要的顺序排好Clustering Key决定了分区内的数据排序方式。很多人会忽略一个关键点Clustering Key的排序是在写入时就确定的查询只能利用这个顺序不能临时改。比如你的表结构是PRIMARY KEY ((user_id), order_time)意味着同一个用户的所有订单在SSTable里是按照order_time物理排序存放的。你查询WHERE user_idxxx AND order_time 2024-01-01 AND order_time 2024-02-01效率极高因为这是一个顺序读。如果你要查WHERE user_idxxx AND amount 100而amount不是Clustering Key的一部分那它只能在一个分区内做全扫过滤如果这个用户订单量有几十万条这个查询就会变慢。所以在设计Clustering Key时你必须把“查询中会用到的等值和范围条件”列出来让Clustering Key尽量覆盖这些条件。多列Clustering Key的顺序很讲究等值条件在前范围条件、、、在最后。查询时一旦某列用了范围条件它后面的Clustering列就无法参与条件限制了。2.4 二级索引、物化视图想清楚代价再动手Cassandra的二级索引Secondary Index历来是“看起来很美用起来想哭”的功能。原生二级索引在底层是本地索引在查询时协调节点需要把请求广播到所有节点每个节点查自己的本地索引然后返回数据。这样一个简单的查询可能瞬间变成全集群压力测试。二级索引适合的场景极其有限低基数Low Cardinality字段比如性别、状态这种只有几个枚举值的列。但即便这样如果枚举值的分布不均匀比如99%都是正常状态查那个少数状态也很慢。如果用SASI索引能支持更丰富的谓词但内部实现复杂度和维护成本也不低。物化视图Materialized View在写入时帮你维护一份额外的视角同样存在写入放大的问题。在Cassandra 4.x里物化视图依然有已知的已知问题比如无法保证完全一致、写入延迟增加。我的建议是能不用就不用如果真的需要那套“查询视角”直接在应用层双写一张表可控性会强很多。2.5 宽行模型与时间桶设计的实战对照Cassandra单个分区能存很大的数据量官方说法是两个亿的量级实际上是理论值。但分区过大读性能会下降因为读一个分区的内容时如果分区跨很多SSTable且每层SSTable都有数据磁盘IO会增加。所以无界增长的宽行Unbounded Wide Partition是大忌。我带你做一个实际的设计对比。假设要存储用户登录日志表结构ACREATE TABLE login_logs_a ( user_id text, login_time timestamp, ip text, PRIMARY KEY ((user_id), login_time) ) WITH CLUSTERING ORDER BY (login_time DESC);这个设计下一个活跃用户累计登录了10年他的所有登录记录全堆在一个分区里。查询还好单分区查询但分区会越来越大对compaction和读缓存都很不友好。表结构B时间桶设计CREATE TABLE login_logs_b ( user_id text, month_bucket text, login_time timestamp, ip text, PRIMARY KEY ((user_id, month_bucket), login_time) ) WITH CLUSTERING ORDER BY (login_time DESC);应用层写入时把login_time格式化成2024-06作为month_bucket查询一个月的数据时带上这个桶键每次查询只需要扫一个几百KB的小分区。实践证明这种受控的分区大小设计查询延迟的方差非常小比单一大分区稳定得多。3. 查询语句层面的优化寸步不让的CQL写法3.1 ALLOW FILTERING 禁用它但别惧怕它ALLOW FILTERING可能是因为全网都在吐槽大家已经成了惊弓之鸟。它的本质是在查询条件无法完全命中分区或Clustering Key时明确告诉Cassandra“允许你扫描并过滤数据”。不使用它Cassandra会直接拒绝这种低效查询使用了后果自负。先说使用代价。聚合查询不聊单说最简单的SELECT * FROM orders_by_user WHERE order_time 2024-01-01 ALLOW FILTERING;因为查询条件里没有user_idPartition KeyCassandra只能去所有节点、所有分区里扫一遍把所有数据拉出来再过滤。这种查询在生产环境的读路径上跑一下轻则把该节点CPU打满重则拖垮整个集群。我的建议线上高并发请求里宁可去缓存或者用离线分析引擎也别用ALLOW FILTERING。唯一能接受的场景是你已经通过Partition Key把范围缩小到一个非常小的分区比如某个用户的数据然后在这个分区内对Clustering Key之外的列做过滤这种局部过滤可以接受。不过这种场景我也推荐用SASI或者物化视图替代从根上避免问题。3.2 不要轻易SELECT *分页和Token遍历的正确姿势在Cassandra里SELECT *意味着把所有列的数据都读出来。如果这行记录有十几个大字段比如一个长长的payload、JSON字符串而你的业务只需要其中两个字段白白浪费磁盘IO和网络带宽。更值得注意的还有两种场景场景一分页查询。你在Web端做列表分页千万别用LIMIT加OFFSET——Cassandra没有原生OFFSET支持它只是把前面的数据全部查询出来再丢弃。正确做法是使用Paging State在请求里携带上次返回的分页状态。官方Driver支持得很好拿Java Driver为例ResultSet里会携带PagingState下次查询时用.setPagingState(state)即可。场景二全表扫描。有些离线任务比如数据导出、重算数据确实需要全表遍历。暴力做法是SELECT * FROM table WHERE ... ALLOW FILTERING慢且危险。正确做法是用token()函数分段处理-- 查询token范围在(minToken, tokenValue1)之间的数据 SELECT * FROM table WHERE token(partition_key) :minToken AND token(partition_key) :tokenValue1;应用程序维护一个游标当前token值每轮查询取一批数据把返回的最大token值作为下一轮的下界循环往复。这样做的好处是每次查询只扫一小段token范围不至于一次性压垮整个集群。3.3 IN查询、COUNT和聚合操作看起来方便代价很惊人先聊IN。SELECT * FROM users WHERE user_id IN (a, b, c)看起来像单条查询实际是Coordinator分别发起多次查询再合并结果。如果IN列表的分区分布在不同的节点上Coordinator要并发协调多个节点延迟取决于其中最慢的那个节点。所以IN列表数量别太大。一般建议控制在100以内而且得加合理的超时设置比如2秒内。如果是要查一批用户的数据我更推荐在应用层并发发起单个Partition Key的查询灵活性更高一旦某个查询失败也不会拖累其余请求容错性更好。再聊COUNT。SELECT COUNT(*)...在Cassandra里是灾难级别。因为要精确计数必须扫描所有匹配的行这意味着大量数据需要被读出来才能计数。在生产环境里我曾经见过一个COUNT(*)请求把Cassandra节点打到STWStop The WorldGC。后来我们改成让业务每半小时把计数结果写到一张独立的计数表里才彻底解决问题。原则线上绝不用COUNT做实时统计所有计数需求必须预先聚合存起来。3.4 一致性级别与查询超时在性能和正确性之间找平衡Cassandra查询时的Consistency Level一致性级别对性能影响巨大。LOCAL_ONE本机房一个副本确认在绝大多数在线业务场景下表现最好尤其对读多写少的系统。QUORUM级别的读需要等待多个副本返回并做数据比对节点多、跨DC时延迟明显上升。但需要提醒的是LOCAL_ONE读到的是最近节点的数据可能会遇到“读到旧数据”的情况尤其是在Hinted Handoff还没把数据补全之前。对数据一致性要求不高的展示类业务比如用户最近签到时间、设备状态LOCAL_ONE完全可行。关于超时设置默认的cassandra.yaml里read_request_timeout_in_ms默认是5000ms5秒这个值太大。在生产环境一个查询超过1秒就非常不正常了。我建议设置300ms~500ms让问题快速暴露出来不至于积累一堆慢请求把线程池堵死。客户端侧的读超时也要相应设置不要傻傻等待5秒才报错。一致性级别语义适合场景性能表现LOCAL_ONE本地机房一个节点返回即可高并发在线查询、容忍最终一致最好LOCAL_QUORUM本地机房多数节点常规业务避免跨DC延迟良QUORUM跨DC多数节点跨DC强一致需求差ALL所有副本节点基本只在测试环境用最差一般不用于生产4. 运行时调优让服务端配置接住上层的设计4.1 缓存机制Key Cache、Row Cache和Chunk Cache各司其职Cassandra的缓存体系有三个层次很多人搞混。先说Key Cache它缓存的是分区索引Partition Index的映射也就是Token值到具体SSTable文件偏移量的映射作用是把定位一个分区从磁盘IO变成内存查询。Row Cache在4.0以后基本被定位成实验性功能它缓存完整的行数据适合频繁查询同一行且不经常更新的场景。Chunk Cache缓存的是SSTable文件读取的原始压缩块读数据时需要解压缓存块能避免重复IO。那我们应该调谁呢优先调Chunk Cache因为它在3.x和4.x中是提升读性能最关键的一环。默认Chunk Cache大小是256MB或堆内存的16%你可以按“读热数据总量 / 4”粗略估算。如果查询模式属于“少数热点行反复读”可以考虑把Row Cache打开但要注意缓存一致性问题数据更新时Row Cache里的旧数据必须同步失效。说实话现代版本里Key Cache和Row Cache的默认值已经调得比较合理了真正容易出问题的是JVM堆大小和GC策略干运维的同学肯定深有体会。4.2 压缩策略与Compaction策略顺序写、随机读的胜负手Compaction策略决定了SSTable文件的合并策略直接影响查询性能。常见的三种SizeTieredCompactionStrategySTCS默认策略适合写为主的负载。它会把大小相近的SSTable合并但读放大严重查询时需要检查很多层级的SSTable适合OLTP但不适合大量范围查询。LeveledCompactionStrategyLCS把SSTable分成固定的level读性能大幅提升因为同一条数据集中在少量SSTable里。LCS的代价是写放大增加如果写入吞吐非常大LCS可能会导致CPU和磁盘IO飙升。TimeWindowCompactionStrategyTWCS专为时间序列数据设计。按时间窗口比如每天、每小时将数据分组窗口外的数据不参与合并查询和清理都方便物联网和时间序列场景强烈推荐。另外存储节点的压缩建议优先选ZSTD。在Cassandra 4.x里默认压缩是LZ4Compressor算法快但压缩率低一些。ZSTD压缩率通常能提升10%-20%意味着磁盘空间占用少Cache能缓存的块更多读性能也会有好转。代价是CPU使用率略高但从实际压测看收益大于代价。4.3 内存配置堆内存、堆外内存和GC的博弈Cassandra对JVM堆内存的消耗大户是Memtable、堆内Key Cache、Row Cache、Partition Summary等。堆外内存Off-heap主要用于压缩缓存即Chunk Cache和部分Bloom Filter。很多团队的调优误区是把MAX_HEAP_SIZE调得很大比如64GB。堆巨大GC时停顿会更明显尤其是使用CMS收集器时并发标记阶段对CPU的压力不容小觑。Cassandra官方建议是堆内存最好不要超过系统物理内存的1/4一般16GB~24GB是一个比较稳健的选择。剩余内存交给操作系统做Page Cache很多SSTable读会命中Page Cache这部分对性能贡献很大和堆外Chunk Cache。memtable_allocation_type默认是heap_buffers也就是Memtable占的是堆内内存。如果写入压力极强可以改成offheap_buffers把Memtable放在堆外能减轻GC问题但对应的索引结构memtable_index_offheap_allocation也要开成true。还有个参数是memtable_heap_space_in_mb和memtable_offheap_space_in_mb需要根据你的写入吞吐去算调整。对一个32GB堆的实例我通常会把Memtable空间控制在堆的1/4以内避免频繁flush或GC抖动。4.4 Tracing与慢查询日志把慢查询钉死在证据上前面说的都是优化手段但实际运维中第一步永远是定位问题。Cassandra提供了两种很有力的定位手段。手段一请求级Tracing。先用CQL开启TRACING ON; SELECT * FROM orders_by_user WHERE user_id 123;执行完后会返回一个Session ID。然后你可以查system_traces.sessions和system_traces.events看一下整个请求被分解成了哪些活动每个活动耗时多少。你会看到类似Executing single-partition query on users,Sending message to /192.168.x.x,Scanned 30 rows这样的记录。如果看到Scanned 100000 rows说明SSTable层级太多或者分区太大需要回头改数据模型。手段二慢查询日志。设置cassandra.yaml中的slow_query_log_timeout_in_ms比如设成500ms。这样超过500ms的查询都会写入系统表system_slow_log。这个机制简直是生产环境救星SELECT * FROM system_slow_log WHERE start_time 2024-06-01 ALLOW FILTERING;慢查询日志能直接告诉我们慢查询是哪些表、涉及哪些分区、运行时节点是哪个、请求是否超时。把这些信息和节点监控CPU、GC、磁盘IO结合起来通常能找到根因。4.5 可以在意但不要上头的Doris慢查询优化思路提到Doris慢查询优化它的一般流程是打开FE的audit_log_plugin把慢SQL落表分析执行计划找出Scan节点、Join节点的瓶颈然后调整分桶或排序。这个流程在Cassandra里能借鉴的有两点第一你必须有一个统一的入口把慢查询记录下来对应的就是Cassandra的system_slow_log和你的监控系统第二分类处理周期性的慢查询比如固定几分钟出现一次和突发性慢查询临时大查询对应不同的处置策略。整体思维可以借鉴但执行细节就别照搬了引擎差异太大。5. 典型问题排查实录与速查表5.1 常见问题速查表为了方便大家在遇到问题后能快速索引我根据自己的经验整理了一份速查表。这可比你临时翻文档高效多了。现象可能原因优先排查与优化方案单个查询偶尔超时且无规律Compaction与业务高峰期IO竞争调整Compaction策略尤其是改用LCS或TWCS限流Compaction某个特定Partition Key查询极慢单个分区过大改用时间桶/加盐拆分分区全表扫描导致集群CPU飙高使用了ALLOW FILTERING或COUNT尽快找到对应查询改用token分段扫描或用预聚合表查询结果偶尔不一致一致性级别设置过低LOCAL_ONE核对业务容忍度必要时升级LOCAL_QUORUM大量TimeoutException读路径慢、线程池被饿死开启Tracing排查读路径关注GC停顿和磁盘IO数据写入正常读却慢磁盘IO瓶颈或Page Cache命中率低增加Page Cache减少堆内内存分配关注iostat数值新建二级索引后查询延迟变高二级索引查询导致全节点扫描移除二级索引改反范式的多表设计5.2 实战案例一读取路径全部正确依然是“可复现的慢”之前在项目里遇到一个很典型的场景我们的用户画像服务每天凌晨有定时任务批量刷新用户标签。跑了一段时间后一到凌晨就有用户查询超时告警。看面板CPU不高GC也不高但P999延迟飙升。后来用Tracing查了一个慢请求发现Scanned 50000 rows。仔细一查发现因为批量任务写入的是用户维度标签表每天的数据都写进了同一个分区Partition Key是user_id没有时间桶设计。数据积累了一个月后单个分区里已经堆积了百万行数据凌晨批量更新时产生了大量SSTable局部覆盖读的Key Cache不断被刷新导致查询时要扫描的SSTable层数变多。解决方案不复杂把表结构重构为PRIMARY KEY ((user_id, date_bucket), label_name)每天凌晨写入时带上当天的date_bucket查询时如果按date_bucket查就能把扫描范围从数百万行缩小到几千行。另外加了WITH compaction {class: TimeWindowCompactionStrategy, compaction_window_size: 1, compaction_window_unit: DAYS}让旧数据自动被清理合并。调整后P999延迟从600ms降到了80ms。5.3 实战案例二一条多分区查询拖垮了整个集群还有一个印象深刻的教训。当时我们有一个后端服务为了查用户的好友动态写了个SELECT * FROM feeds WHERE user_id IN (list)这个list有几百个用户ID。本来以为并发不高没关系但某天运营做了一次活动流量翻了几十倍这个查询直接把两个节点打满连带影响了其他业务的写入和读取。这就是典型的IN查询滥用。后来我们改写成了应用层并发把user_id拆散成多个单分区查询通过CompletableFuture并发执行每个查询限制在50ms内完成如果某个查询失败或超时就先跳过不阻塞主流程。同时对feeds表开启了slow_query_log_timeout_in_ms300后续观察没有再出现类似的慢查询。这个案例说明Cassandra的某些“方便”功能如IN、COUNT、二级索引在低并发下也许能跑但生产环境千万不能把宝押在这些功能上。应用层多做一点控制数据库能省心很多。6. 结尾谈一点我自己的真实体会最后分享一个我摸索了很久才明白的道理Cassandra查询优化70%的功夫在设计阶段30%才是运行时的调参和监控。代码里写ALLOW FILTERING修改起来两分钟但要去改表结构、迁移数据那就得提心吊胆熬通宵了。我的建议是一个新业务接入Cassandra之前团队里一定要做一次“查询模式评审”。把每一个可能上线的CQL列出来对照数据模型走一遍它命中了几个分区有没有范围扫描会不会随着数据增长出现分区无界扩大会不会引起热点节点这些问题在设计阶段问完比上线后半夜起来看监控要有用得多。如果你已经在生产环境踩了很多坑也别灰心。Cassandra的排查路径相对固定毕竟它不像MySQL那样有各种隐式转换、索引失效的骚套路。你就按这个顺序来先看表结构设计再看查询语句是否规范接着看缓存和Compaction策略最后用Tracing定位具体请求。整套流程跑熟了你会发现它是那么一个“先严后宽”的系统设计时苛刻一点运行时反而轻松一点。这些经验是花钱买不来的希望对你有帮助。
返回列表