ClickHouse-数据一致性

  在生产环境中,数据一致性的重要性,不论如何强调都不过分。而 ClickHouse 在进行数据变更时,都会产生一个临时分区,而不会更改原始数据文件,对数据文件的修改操作会要等到数据合并时才进行。所以 ClickHouse 只能保证数据的最终一致性,而不能保证强一致性。很可能数据变更后,程序通过 ClickHouse 查到之前的错误数据。因此使用 ClickHouse ,要尽量避免数据的增删改这类数据变更操作。但是实际使用时,又不可避免的要使用数据变更操作。这时就需要有一套策略来全面处理数据一致性问题。
  首先,对于分布式表,最好的办法是尽量避免使用。如果非要使用分布式表,一定要打开internal_replication。每个分片一定要配置多副本机制,使用副本机制来保证副本之间的数据一致性。
  一般来说,分布式表会带来非常多的问题。往分布式表中导入数据时,数据是异步写入到不同的分片当中的,这样数据写入过程中就不可避免的有先有后。在最后一个分片的数据写入完成之前,不可避免的就会产生数据一致性的问题。
  另外,对于分布式表,如果在数据写入时,这个分片的服务宕机了,那么插入的数据就有可能会丢失。ClickHouse 的做法是将这个数据分片转移到 broken 子目录中,并不再使用这个数据分片。也就是说,这时,ClickHouse 这一次的数据写入操作 ius 丢失了。造成的结果就是有可能就是一次 update 操作要更新 1000 条数据,但是最终却只更新了 900 条。
  然后,对于本地的数据库,也一定要注意多副本造成的数据一致性问题。ClickHouse 中,即使是提供了去重功能的 ReplacingMergeTree,它只能保证在数据合并时会去重,只能保证数据的最终一致性,而不能保证强一致性(具体可参考官网说明: https://clickhouse.com/docs/zh/engines/table-engines/mergetree-family/replacingmergetree/)。
我们在使用 ReplacingMergeTree、SummingMergeTree 这类表引擎的时候,会出现短暂数据不一致的情况。
在某些对一致性非常敏感的场景,通常有以下几种解决方案。

1.准备测试表和数据

1.1创建表

CREATE TABLE test_a
(
    `user_id` UInt64,
    `score` String,
    `deleted` UInt8 DEFAULT 0,
    `create_time` DateTime DEFAULT toDateTime(0)
)
ENGINE = ReplacingMergeTree(create_time)
ORDER BY user_id
Query id: 04dd344f-62ce-4434-809c-377d5e224870
Ok.
0 rows in set. Elapsed: 0.039 sec. 
其中:
user_id 是数据去重更新的标识;
create_time 是版本号字段,每组数据中 create_time 最大的一行表示最新的数据;
deleted 是自定的一个标记位,比如 0 代表未删除,1 代表删除数据。

1.2写入1000万测试数据

INSERT INTO test_a (user_id, score) WITH (
        SELECT ['A', 'B', 'C', 'D', 'E', 'F', 'G']
    ) AS dict
SELECT
    number AS user_id,
    dict[(number % 7) + 1]
FROM numbers(10000000)
Query id: 40b4ea66-5856-4ebb-887f-748a1d25c666
Ok.
0 rows in set. Elapsed: 1.184 sec. Processed 10.49 million rows, 83.88 MB (8.86 million rows/s., 70.86 MB/s.)

1.3修改前 50 万 行数据,修改内容包括 name 字段和 create_time 版本号字段

INSERT INTO test_a (user_id, score, create_time) WITH (
        SELECT ['AA', 'BB', 'CC', 'DD', 'EE', 'FF', 'GG']
    ) AS dict
SELECT
    number AS user_id,
    dict[(number % 7) + 1],
    now() AS create_time
FROM numbers(500000)
Query id: e0668a9b-e8dc-43a0-a1d7-4afb2a337269
Ok.
0 rows in set. Elapsed: 0.078 sec. Processed 500.00 thousand rows, 4.00 MB (6.42 million rows/s., 51.37 MB/s.)

1.4统计总数

SELECT COUNT()
FROM test_a
Query id: 64df094e-2c7b-4b69-83da-e16f04627ebc
┌──count()─┐
│ 10500000 │
└──────────┘
1 rows in set. Elapsed: 0.007 sec. 
还未触发分区合并,所以还未去重。

2.手动optimize

在写入数据后,立刻执行 OPTIMIZE 强制触发新写入分区的合并动作。
superset-BI :) OPTIMIZE TABLE test_a FINAL;
OPTIMIZE TABLE test_a FINAL
Query id: 76479daa-1d23-4ac4-9938-6b1cf41c2dbd
Ok.
0 rows in set. Elapsed: 1.197 sec.
superset-BI :) SELECT COUNT() FROM test_a;
SELECT COUNT()
FROM test_a
Query id: b81f2d27-3af6-4fe0-9751-010cc1ba6c71
┌──count()─┐
│ 10000000 │
└──────────┘
1 rows in set. Elapsed: 0.002 sec. 

3.通过Group by 去重

3.1执行去重的查询

SELECT
    user_id,
    argMax(score, create_time) AS score,
    argMax(deleted, create_time) AS deleted,
    max(create_time) AS ctime
FROM test_a
GROUP BY user_id
HAVING deleted = 0
Query id: 3c3e698a-9dd7-48c3-8957-3cacd8357b0e
  Showed first 10000.
10000000 rows in set. Elapsed: 3.014 sec. Processed 10.00 million rows, 230.50 MB (3.32 million rows/s., 76.47 MB/s.)
函数说明:
argMax(field1,field2):按照 field2 的最大值取 field1 的值。
当我们更新数据时,会写入一行新的数据,例如上面语句中,通过查询最大的create_time 得到修改后的 score 字段值。

3.2创建视图,方便测试

CREATE VIEW view_test_a AS
SELECT
    user_id,
    argMax(score, create_time) AS score,
    argMax(deleted, create_time) AS deleted,
    max(create_time) AS ctime
FROM test_a
GROUP BY user_id
HAVING deleted = 0;

3.3插入重复数据,再次查询

#再次插入一条数据
INSERT INTO test_a (user_id, score, create_time) FORMAT Values
Query id: 64630a6a-1339-4a9f-a5d8-18f6c3f964df
Ok.
1 rows in set. Elapsed: 0.010 sec.
 
#再次查询
SELECT *
FROM test_a
WHERE user_id = 0
Query id: 12c40b30-c9dd-4db5-aa90-38f65f5cc213
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AA    │       0 │ 2022-05-09 18:23:09 │
└─────────┴───────┴─────────┴─────────────────────┘
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AAAA  │       0 │ 2022-05-09 21:44:27 │
└─────────┴───────┴─────────┴─────────────────────┘
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AA    │       0 │ 2022-05-09 21:41:34 │
└─────────┴───────┴─────────┴─────────────────────┘
3 rows in set. Elapsed: 0.004 sec. Processed 16.39 thousand rows, 393.24 KB (4.54 million rows/s., 108.98 MB/s.)
从视图查,只能看到最新一条数据
SELECT *
FROM view_test_a
WHERE user_id = 0
Query id: 35d437b9-5d09-4ab3-8c7f-30d1a3a67fd5
┌─user_id─┬─score─┬─deleted─┬───────────────ctime─┐
│       0 │ AAAA  │       0 │ 2022-05-09 21:44:27 │
└─────────┴───────┴─────────┴─────────────────────┘
1 rows in set. Elapsed: 0.018 sec. Processed 16.39 thousand rows, 393.24 KB (894.47 thousand rows/s., 21.47 MB/s.)

3.4删除数据测试

#再次插入一条标记为删除的数据
INSERT INTO test_a (user_id, score, deleted, create_time) FORMAT Values
Query id: 96130c41-b29e-4d3e-b9ea-058d91afe7b9
Ok.
1 rows in set. Elapsed: 0.003 sec. 
 
#再次查询
SELECT *
FROM test_a
WHERE user_id = 0
Query id: e1062fc6-07c1-4ef6-9555-2e051e707c87
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AAAA  │       0 │ 2022-05-09 21:44:27 │
└─────────┴───────┴─────────┴─────────────────────┘
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AAAA  │       1 │ 2022-05-09 21:47:28 │
└─────────┴───────┴─────────┴─────────────────────┘
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AA    │       0 │ 2022-05-09 18:23:09 │
└─────────┴───────┴─────────┴─────────────────────┘
┌─user_id─┬─score─┬─deleted─┬─────────create_time─┐
│       0 │ AA    │       0 │ 2022-05-09 21:41:34 │
└─────────┴───────┴─────────┴─────────────────────┘
4 rows in set. Elapsed: 0.004 sec. Processed 16.39 thousand rows, 393.27 KB (3.82 million rows/s., 91.65 MB/s.)
查视图 刚才那条数据看不到了
SELECT *
FROM view_test_a
WHERE user_id = 0
Query id: 5026ccbd-eae1-45c2-abb2-6ac214ed48d1
Ok.
0 rows in set. Elapsed: 0.006 sec. Processed 16.39 thousand rows, 393.27 KB (2.87 million rows/s., 68.94 MB/s.)
这行数据并没有被真正的删除,而是被过滤掉了。在一些合适的场景下,可以结合 表级别的 TTL 最终将物理数据删除。

4.通过final查询

在查询语句后增加 FINAL 修饰符,这样在查询的过程中将会执行 Merge 的特殊逻辑(例如数据去重,预聚合等)。
但是这种方法在早期版本基本没有人使用,因为在增加 FINAL 之后,我们的查询将会变成一个单线程的执行过程,查询速度非常慢。
在 v20.5.2.7-stable 版本中,FINAL 查询支持多线程执行,并且可以通过 max_final_threads参数控制单个查询的线程数。但是目前读取 part 部分的动作依然是串行的。
FINAL 查询最终的性能和很多因素相关,列字段的大小、分区的数量等等都会影响到最终的查询时间,所以还要结合实际场景取舍。
使用 hits_v1 表进行测试:

4.1普通语句查询

SELECT *
FROM datasets.visits_v1
WHERE StartDate = '2014-03-17'
LIMIT 100
SETTINGS max_threads = 2
 
100 rows in set. Elapsed: 0.073 sec. Processed 13.18 thousand rows, 21.79 MB (181.18 thousand rows/s., 299.57 MB/s.)
查看执行计划
EXPLAIN PIPELINE
SELECT *
FROM datasets.visits_v1
WHERE StartDate = '2014-03-17'
LIMIT 100
SETTINGS max_threads = 2
Query id: 83200393-6f6e-4df9-9c88-8744084b7bd8
┌─explain─────────────────────────┐
│ (Expression)                    │
│ ExpressionTransform × 2         │
│   (SettingQuotaAndLimits)       │
│     (Limit)                     │
│     Limit 2 → 2                 │
│       (ReadFromMergeTree)       │
│       MergeTreeThread × 2 0 → 1 │
└─────────────────────────────────┘
7 rows in set. Elapsed: 0.012 sec. 
明显将由2个线程并行读取part查询

4.2final查询

SELECT *
FROM datasets.visits_v1 final
WHERE StartDate = '2014-03-17'
LIMIT 100
SETTINGS max_threads = 2;
100 rows in set. Elapsed: 0.548 sec. Processed 152.74 thousand rows, 239.19 MB (278.91 thousand rows/s., 436.76 MB/s.)
查询速度没有普通的查询快,但是相比之前已经有了一些提升,查看 FINAL 查询的执行计划:
EXPLAIN PIPELINE
SELECT *
FROM datasets.visits_v1
FINAL
WHERE StartDate = '2014-03-17'
LIMIT 100
SETTINGS max_threads = 2
Query id: ba101258-083a-417e-b77f-ac71af6fdb73
┌─explain──────────────────────────────────┐
│ (Expression)                             │
│ ExpressionTransform × 2                  │
│   (Limit)                                │
│   Limit 2 → 2                            │
│     (Filter)                             │
│     FilterTransform × 2                  │
│       (SettingQuotaAndLimits)            │
│         (ReadFromMergeTree)              │
│         ExpressionTransform × 2          │
│           CollapsingSortedTransform × 2  │
│             Copy 1 → 2                   │
│               AddingSelector             │
│                 ExpressionTransform      │
│                   MergeTreeInOrder 0 → 1 │
└──────────────────────────────────────────┘
14 rows in set. Elapsed: 0.017 sec. 
从 CollapsingSortedTransform 这一步开始已经是多线程执行,但是读取 part 部分的动作还是串行。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值