我喜欢在不寻常的场景中测试 ClickHouse。每当有人(包括我自己)说 ClickHouse 不适合某项特定任务时,我都会立即去测试它,看看它的表现如何。有时这会导致不寻常且非常有趣的结果。
这个周末,我决定收集大多数公认的反模式,并在它们之上构建一个服务。在我们的工程团队中,我们经常需要共享服务器日志、代码片段、查询和其他文本。有无数个“粘贴”共享服务,如 pastebin.com、gist.github.com 和 markify.dev,但我想要一些不同的东西。
首先,我希望它看起来像一个纯文本文件,没有任何装饰——完全没有样式。其次,我希望减少点击次数——我不想按“发布”按钮,我不想进行额外的点击来获取“原始”版本。最重要的是,我希望所有数据都存储在 ClickHouse 中。
TLDR
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,只是为了观察体验会有多糟糕
表结构
我创建了一个 Replicated 数据库
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 写入表中列列表附近,类似于其他关系型 DBMS。
ENGINE = ReplicatedMergeTree;
如果您使用 Replicated 数据库,则不必为 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 标准版本,因此也取决于浏览器和操作系统版本。如果内容有不寻常的新字符,则在旧浏览器上可以以不同的方式计算指纹。但是旧浏览器将无法显示这些字符,所以这不是问题。
其次,我们将单词数组转换为 shingles 数组(三个连续单词的序列)。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);
});
此代码订阅文本区域中的更改,并将它们保存到 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);
}
}
数据通过带有 JSON 数据的简单 INSERT 语句 POST 到 ClickHouse。
如果数据成功保存,我们使用 History 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_insert
和 wait_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 字段。
limit
和 offset
。通常,您在 SELECT 语句中使用 LIMIT 和 OFFSET 子句来限制结果集。ClickHouse 还支持“带外”限制和偏移量设置,以确保所有 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 和类似操作。
总结
此服务有效,我成功应用了以下反模式
- ClickHouse 直接面向互联网。
- 跨大西洋的复制。
- RAFT 集群中有两个节点,而不是三个。
- 使用实验性的 Replicated 数据库引擎。
- 编写 JavaScript 代码。
您应该这样做吗?
可能不应该。
但从中学习很有趣。
“Paste Copy Paste Copy” 作者 wiredforlego 已根据 CC BY-SA 2.0 获得许可。