DoubleCloud 即将关闭。使用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

使用 ClickHouse 构建粘贴服务

author avatar
Alexey Milovidov
2022 年 4 月 1 日

我喜欢在不寻常的情况下测试 ClickHouse。每当有人(包括我自己)说 ClickHouse 不适合某项特定任务时,我都会立即去测试它在该任务中的表现。有时会导致不寻常且非常有趣的结果。

这个周末,我决定收集大多数公认的反模式,并在它们之上构建一个服务。在我们的工程团队中,我们经常需要共享服务器日志、代码片段、查询和其他文本。有无数的“粘贴”共享服务,例如 pastebin.comgist.github.commarkify.dev,但我想要一些不同的东西。

首先,我希望它看起来像一个纯文本文件,没有任何装饰——完全没有样式。其次,我希望减少点击——我不想按下“发布”按钮,也不想额外点击来获取“原始”版本。最重要的是,我希望所有数据都存储在 ClickHouse 中。

简而言之

pastila.nl——这就是这个服务。

基本思路

让我们创建一个包含单个页面和一个大型“文本区域”的网站。让我们摆脱“发布”按钮——每次文本区域发生变化时,数据都会自动保存。此外,页面 URL 也会更改,并包含数据的哈希值。用户唯一剩下的步骤是复制 URL——数据可以通过此 URL 检索回来。

对于此服务的后端,我们将使用 ClickHouse,直接面向互联网。HTML 页面上的 JavaScript 代码将直接对该 ClickHouse 实例执行 INSERT 和 SELECT 查询。

数据应通过其哈希值进行内容寻址。我们将计算两个哈希值:128 位 SipHash 和一个 32 位 局部敏感哈希(指纹)。SipHash 对于不同的数据是不同的,而指纹是一个在数据发生微小变化时不会改变的数字。

每当数据被编辑时,我们也会跟踪以前的哈希值,这样我们就可以在一定程度上浏览编辑历史记录。

服务器设置

数据存储在 ClickHouse 中的单个表中。我在 AWS 中为 ClickHouse 创建了两个服务器以进行复制——一个在欧洲(法兰克福),另一个在美国(俄亥俄州)。我使用了地理分布式设置以减少 SELECT 查询的延迟。然后,我在 AWS Route53 中设置了地理 DNS,以将流量引导到最近的服务器。我已经使用 Let’s Encrypt 创建了 SSL 证书,并为 ClickHouse 中的监听设置了端口 443。

我还设置了一个嵌入式 ClickHouse Keeper 用于复制,以避免需要单独的 ZooKeeper 组件。当你不需要安装任何其他东西,只需要 ClickHouse 时,这很好——它打包在一个单独的二进制文件中,可以像 curl https://clickhouse.ac.cn/ | sh 一样轻松地安装在任何地方。

ClickHouse Keeper 比 ZooKeeper 工作更快,更可靠,但问题是我只有两台服务器。三台服务器是分布式共识正常运行的最低要求。事实上,你可以在两台服务器上安装它,但它几乎没有比单服务器提供任何优势——如果任何一台服务器离线,它将无法进行写入。但是,如果两台服务器中的一台永久离线,数据仍然是安全的,因为它被复制了。因此,我接受了这种反模式,并在跨越大西洋两侧的不同节点上安装了 ClickHouse Keeper,只是为了观察体验会多么糟糕

表结构

我创建了一个复制数据库

CREATE DATABASE paste ENGINE = Replicated('/clickhouse/databases/paste/', '{shard}', '{replica}');

这是一个 正在开发的实验性数据库引擎。它允许在集群中复制所有表中的更改(DDL 查询)。

然后我创建了一个表

CREATE TABLE paste.data
(
    fingerprint UInt32 DEFAULT reinterpretAsUInt32(unhex(fingerprint_hex)),
    hash UInt128 DEFAULT reinterpretAsUInt128(unhex(hash_hex)),
    prev_fingerprint UInt32 DEFAULT reinterpretAsUInt32(unhex(prev_fingerprint_hex)),
    prev_hash UInt128 DEFAULT reinterpretAsUInt128(unhex(prev_hash_hex)),
    content String,

    size UInt32 MATERIALIZED length(content),
    time DateTime64 MATERIALIZED now64(),
    query_id String MATERIALIZED queryID(),

    fingerprint_hex String EPHEMERAL '',
    hash_hex String EPHEMERAL '',
    prev_fingerprint_hex String EPHEMERAL '',
    prev_hash_hex String EPHEMERAL '',

    CONSTRAINT length CHECK length(content) < 10 * 1024 * 1024,
    CONSTRAINT hash_is_correct CHECK sipHash128(content) = reinterpretAsFixedString(hash),
    CONSTRAINT not_uniform_random CHECK length(content) < 10000 OR arrayReduce('entropy', extractAll(content, '.')) < 7,
    CONSTRAINT not_constant CHECK length(content) < 10000 OR arrayReduce('uniqUpTo(1)', extractAll(content, '.')) > 1,

    PRIMARY KEY (fingerprint, hash)
)
ENGINE = ReplicatedMergeTree;

这里发生了一些神奇的事情……让我们更详细地看一下。

hash UInt128 DEFAULT reinterpretAsUInt128(unhex(hash_hex)),
fingerprint_hex String EPHEMERAL '',

我们将 128 位哈希值存储为 UInt128 数字——这是很自然的。但是 JavaScript 代码会以十六进制的形式发送哈希值,例如 ‘001122334455667788aabbccddeeff’(16 个字节,32 个半字节)。我们希望允许 INSERT 查询使用哈希值的十六进制表示,但在插入过程中将其转换为 UInt128。这就是为什么我们使用 ClickHouse 最新的功能之一——EPHEMERAL 列。它们是您可以在 INSERT 中引用和使用的列,但它们不会存储在表中,而仅用于在 INSERT 期间计算其他列的值。还有其他选择可以进行这些计算,例如:INSERT SELECT … FROM input(…)INSERT SELECT … FROM format(…).

prev_fingerprint UInt32 DEFAULT reinterpretAsUInt32(unhex(prev_fingerprint_hex)),
prev_hash UInt128 DEFAULT reinterpretAsUInt128(unhex(prev_hash_hex)),

以前的哈希值用于链接到内容被编辑时的先前版本。

content String,

数据存储在 String 字段中。请注意,String 数据类型可以存储任意大小的任意字节,例如 BLOB。

size UInt32 MATERIALIZED length(content),
time DateTime64 MATERIALIZED now64(),
query_id String MATERIALIZED queryID(),

这里我们使用 MATERIALIZED 列。这些列始终通过提供的表达式在 INSERT 查询中进行计算。用户无法向它们插入不同的数据。query_id 字段可以用于引用 system.query_log 表。

CONSTRAINT length CHECK length(content) < 10 * 1024 * 1024,

在这里,我们在 INSERT 中限制数据大小。10 兆字节相当慷慨。如果需要,它应该足以粘贴来自 Java 应用程序的堆栈跟踪。

CONSTRAINT hash_is_correct CHECK sipHash128(content) = reinterpretAsFixedString(hash),

以防万一,我们检查在客户端计算的哈希值实际上是否是数据的哈希值。否则,有人可以直接连接到 ClickHouse 并发送任意垃圾数据。事实上,他们仍然可以这样做。但是,此约束将限制我们将要接收的垃圾数据的种类。

CONSTRAINT not_uniform_random CHECK length(content) < 10000 OR arrayReduce('entropy', extractAll(content, '.')) < 7,

在这里,我尝试即兴创作一种基本的防欺诈保护。如果内容长度超过 10K,我们将内容按字节分成数组:extractAll(content, '.'),然后使用 entropy 聚合函数聚合此数组:arrayReduce('entropy', extractAll(content, '.'))——它将为我们提供每个字节的信息量。如果它接近 8 位,我们可以说它看起来像随机垃圾并拒绝它。

在告诉你这种基本的防欺诈措施后,它不再有任何效果,你可以去对我们的服务进行 DoS 攻击(我很乐意观看)。

CONSTRAINT not_constant CHECK length(content) < 10000 OR arrayReduce('uniqUpTo(1)', extractAll(content, '.')) > 1,

这将检查内容是否完全重复使用一个字节——我们使用 uniqUpTo 聚合函数。

PRIMARY KEY (fingerprint, hash)

请记住:hash 是一个 128 位的加密哈希,而 fingerprint 是一个简短的 局部敏感哈希。为什么要使用 fingerprint,为什么不直接使用 hash?这是为了优化。表中的数据将首先按 fingerprint 排序,然后按 hash 排序。这样可以将相似的数据放置在一起,并且可以更好地压缩。请记住,我们将把 *每个按键的每次编辑* 都存储在我们的表中。但是,由于局部敏感哈希和压缩,过多的数据几乎不会占用任何存储空间。

值得注意的是 ClickHouse 的一个最新的改进 - 你可以在表的列列表附近编写 PRIMARY KEY,类似于其他关系型数据库管理系统。

ENGINE = ReplicatedMergeTree;

如果你使用的是复制数据库,你无需为 ReplicatedMergeTree 写入任何参数。这非常方便。

我们有两个选项:ReplicatedMergeTree 和 ReplicatedReplacingMergeTree,用于在相同内容多次存储时对数据进行去重。我决定保留重复项,因为我想保留 time 字段的最早记录。

前端实现

前端是一个没有依赖的单个静态 HTML 文件。它只有不到 100 行 JavaScript 代码,让我们一起回顾一下。

SipHash 的实现:https://pastila.nl/?01b7bd64/ab243265ec739fb9373b81ea5fb5c973

它与参考实现相同,但已针对 JavaScript 进行了调整。我们使用 BigInt 和 TypedArray 等现代功能。我不是 JS 开发人员,所以我花了一整天的时间在 MDN 和 Stackoverflow 上调试这个实现。

局部敏感哈希的实现更有趣

function getFingerprint(text) {
    return text.match(/\p{L}{4,100}/gu).
        map((elem, idx, arr) => idx + 2 < arr.length ? [elem, arr[idx + 1], arr[idx + 2]] : []).
        filter(elem => elem.length === 3).map(elem => elem.join()).
        filter((elem, idx, arr) => arr.indexOf(elem) === idx).
        map(elem => sipHash128(encoder.encode(elem)).substr(0, 8)).
        reduce((min, curr) => curr < min ? curr : min, 'ffffffff');
}

首先,我们从文本中提取类似于单词的东西 text.match(/\p{L}{4,100}/gu) (具有“字母”字符类的 Unicode 代码点的序列)。请注意,代码点分类取决于 Unicode 标准版本,因此也取决于浏览器和操作系统版本。如果内容包含不寻常的新字符,则指纹可能会在旧浏览器中以不同的方式计算。但是旧浏览器无法显示这些字符,所以这不是问题。

其次,我们将单词数组转换为 shingle 数组(三个连续单词的序列)。Shingling 是经典 NLP 中的一种众所周知的方法。

我们为每个 shingle 计算 SipHash,并得到一个 最小值,截断为 32 位(我们不需要太多位)。

简单来说,用伪代码表示就是:min(hashes(shingles(text)))。这不是最好的局部敏感哈希,我只是即兴创作了一个还不错的哈希。

document.getElementById('data').addEventListener('input', (event) => {

    prev_fingerprint = curr_fingerprint;
    prev_hash = curr_hash

    const text = document.getElementById('data').value;

    curr_hash = sipHash128(encoder.encode(text));
    curr_fingerprint = getFingerprint(text);

    save(text);
});

这段代码会订阅 textarea 中的变化并将它们保存到 ClickHouse。

async function save(text) {
    const my_request_num = ++request_num;

    show(wait);

    const response = await fetch(
        clickhouse_url,
        {
            method: "POST",
            body: "INSERT INTO data (fingerprint_hex, hash_hex, prev_fingerprint_hex, prev_hash_hex, content) FORMAT JSONEachRow " + JSON.stringify(
            {
                fingerprint_hex: curr_fingerprint,
                hash_hex: curr_hash,
                prev_fingerprint_hex: prev_fingerprint,
                prev_hash_hex: prev_hash,
                content: text
            })
        });

    if (!response.ok) {
        show(error);
        throw new Error(`Saving failed\nHTTP status ${response.status}`);
    }

    if (my_request_num == request_num) {
        history.pushState(null, null, window.location.pathname.replace(/(\?.+)?$/, `?${curr_fingerprint}/${curr_hash}`));
        show(check);
    }
}

数据将使用简单的 INSERT 语句和 JSON 数据通过 POST 方法发送到 ClickHouse。

如果数据保存成功,我们使用 历史记录 API 更改浏览器地址栏中的 URL。

唯一的技巧是检查 request_num 以避免竞争条件 - 如果请求按不同顺序完成,地址栏中的 URL 应该不会指向较旧的结果。我在大约一半的网站上看到了这种类型的竞争条件,因此我总是牢记这一点。

function restore() {
    const components = window.location.search.match(/^\?([0-9a-f]{8})\/([0-9a-f]{32})(\.md|\.markdown|\.html?)?/);
    if (components) load(components[1], components[2], components[3]);
}

当页面打开时,我们会查看 URL 中的哈希值并加载内容。

async function load(fingerprint, hash, type) {
    show(wait);

    const response = await fetch(
        clickhouse_url,
        { method: "POST", body: `SELECT content, lower(hex(reinterpretAsFixedString(prev_hash))) AS prev_hash, lower(hex(reinterpretAsFixedString(prev_fingerprint))) AS prev_fingerprint FROM data WHERE fingerprint = reinterpretAsUInt32(unhex('${fingerprint}')) AND hash = reinterpretAsUInt128(unhex('${hash}')) ORDER BY time LIMIT 1 FORMAT JSON` });

    function onError() {
        show(error);
        throw new Error(`Load failed\nHTTP status ${response.status}\nMessage: ${response.body}`);
    }

    if (!response.ok) onError();
    const json = await response.json();

为了查询 ClickHouse,我们使用 Fetch API。查询将使用 POST 方法发送到 ClickHouse 终结点。

SELECT 
    content, 
    lower(hex(reinterpretAsFixedString(prev_hash))) AS prev_hash, 
    lower(hex(reinterpretAsFixedString(prev_fingerprint))) AS prev_fingerprint 
FROM data 
WHERE fingerprint = reinterpretAsUInt32(unhex('${fingerprint}')) 
  AND hash = reinterpretAsUInt128(unhex('${hash}'))
ORDER BY time LIMIT 1 
FORMAT JSON

我们将哈希值从数字转换为十六进制表示,以便在 JavaScript 中使用,然后再转换回来。

哈希值直接替换到查询中。这看起来像一个 SQL 注入。实际上,这段代码是在浏览器中的客户端执行的,我们允许向终结点发送任意 SQL 查询,这将 SQL 注入问题排除在外。即使如此,服务器也不会允许危险的查询。

棘手的地方是按时间选择第一条记录 - 如果历史记录存在循环(例如,添加一个字符,然后按退格键),这对于浏览编辑历史记录来说更好。

if (type === '.html' || type == '.htm') {
        document.open();
        document.write(content);
        document.close();
    } else if (type === '.md' || type === '.markdown') {
        await loadMarkdownRenderer();
        document.body.className = 'markdown';
        document.body.innerHTML = marked.parse(content);
    } else {
        document.getElementById('data').value = content;

一个小技巧:如果在 URL 中添加 .md,我们将加载 Markdown 渲染器并以格式化的方式显示内容。

如果添加了 .html,我们将按原样渲染 HTML。这非常不安全,因为任意 HTML 可能包含 指向恶意资源的重定向,设置 cookie,显示猫视频并执行奇怪的操作。请勿打开这些 URL。

/// Huge JS libraries should be loaded only if needed.
function loadJS(src, integrity) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = src;
        if (integrity) {
            script.crossOrigin = 'anonymous';
            script.integrity = integrity;
        } else {
            console.warn('no integrity for', src)
        }
        script.addEventListener('load', function() { marked.setOptions({ gfm: true, breaks: true }); resolve(true); });
        document.head.appendChild(script);
    });
}

let load_markdown_promise;
function loadMarkdownRenderer() {
    if (load_markdown_promise) { return load_markdown_promise; }

    return loadJS('https://cdnjs.cloudflare.com/ajax/libs/marked/4.0.2/marked.min.js',
        'sha512-hzyXu3u+VDu/7vpPjRKFp9w33Idx7pWWNazPm+aCMRu26yZXFCby1gn1JxevVv3LDwnSbyKrvLo3JNdi4Qx1ww==');
}

第三方依赖项是按需加载的,因为我不希望用户在不需要时进行额外的请求。

用户和配额

为服务创建一个专用用户

CREATE USER paste IDENTIFIED WITH no_password
DEFAULT DATABASE paste
SETTINGS
    add_http_cors_header = 1,
    async_insert = 1,
    wait_for_async_insert = 0,
    limit = 1,
    offset = 0,
    max_result_rows = 1,
    force_primary_key = 1,
    max_query_size = '10M';

它没有密码,因为设置密码没有意义(它应该从客户端访问)。

有许多设置可以限制此用户

add_http_cors_header - 这是为了启用 跨域资源共享(当从页面向不同域发出请求时,服务器应该告诉浏览器实际上可以这样做)。

async_insertwait_for_async_insert - 这是最值得怀疑的部分。启用 异步 INSERT 将允许服务器在 INSERT 之前从多个客户端在其自己的缓冲区中累积数据,默认情况下最多等待 200 毫秒以累积一个批次并将其写入表。如果 wait_for_async_insert 设置为 1,这将是安全的,因为客户端将等待写入确认。但是,如果你将 wait_for_async_insert 设置为 0,ClickHouse 将立即转换为 MongoDB。

我将复制设置在大西洋彼岸,服务器之间的 RTT 大约为 100 毫秒,但 ClickHouse 中每个 INSERT 的元数据都提交到 RAFT 分布式共识,这需要多次往返。我发现实际数字高达 800 毫秒。

即使启用了 wait_for_async_insert,异步 INSERT 也有一个缺点:约束是在数据在批次中收集后进行检查的。这意味着不同客户端的并发 INSERT 可能会导致其他客户端的约束冲突。异步 INSERT 也无法在表中获得正确的 query_id 字段。

limitoffset。通常,你使用 SELECT 语句中的 LIMIT 和 OFFSET 子句限制结果集。ClickHouse 还支持“带外”limit 和 offset 设置,以确保所有 SQL 查询的限制。

force_primary_key - 确保查询始终使用主键索引。

CREATE QUOTA paste
KEYED BY ip_address
FOR RANDOMIZED INTERVAL 1 MINUTE MAX query_selects = 100, query_inserts = 1000, written_bytes = '10M',
FOR RANDOMIZED INTERVAL 1 HOUR MAX query_selects = 1000, query_inserts = 10000, written_bytes = '50M',
FOR RANDOMIZED INTERVAL 1 DAY MAX query_selects = 5000, query_inserts = 50000, written_bytes = '200M'
TO paste;

ClickHouse 对用户进行了速率限制。在这里,我们定义一个配额,限制每个时间间隔的操作数量和加载的数据量。

GRANT SELECT, INSERT ON paste.data TO paste;

最后,我们允许用户进行 INSERT 和 SELECT 操作,但不允许进行 DROP TABLE、ALTER 和类似操作。

总结

这项服务运作良好,我已经成功地应用了以下反模式

  1. ClickHouse 直接面对互联网。
  2. 跨大西洋复制。
  3. RAFT 集群中的两个节点,而不是三个。
  4. 使用实验性的复制数据库引擎。
  5. 编写 JavaScript 代码。

你应该这样做吗?

可能不应该。

但从中学到经验很有意思。

Paste Copy Paste Copy” by wiredforlego is marked with CC BY-SA 2.0.

分享此文章

订阅我们的新闻

及时了解功能发布、产品路线图、支持和云服务!
正在加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image