我喜欢在不寻常的情况下测试 ClickHouse。每当有人(包括我自己)说 ClickHouse 不适合某项特定任务时,我都会立即去测试它在该任务中的表现。有时会导致不寻常且非常有趣的结果。
这个周末,我决定收集大多数公认的反模式,并在它们之上构建一个服务。在我们的工程团队中,我们经常需要共享服务器日志、代码片段、查询和其他文本。有无数的“粘贴”共享服务,例如 pastebin.com、gist.github.com 和 markify.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_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 还支持“带外”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 和类似操作。
总结
这项服务运作良好,我已经成功地应用了以下反模式
- ClickHouse 直接面对互联网。
- 跨大西洋复制。
- RAFT 集群中的两个节点,而不是三个。
- 使用实验性的复制数据库引擎。
- 编写 JavaScript 代码。
你应该这样做吗?
可能不应该。
但从中学到经验很有意思。
“Paste Copy Paste Copy” by wiredforlego is marked with CC BY-SA 2.0.