├── .gitignore ├── Clang-Bug-Segment-Fault.md ├── README.md ├── TiDB-OLAP-Optimization.md ├── TiDB-With-Hyperscan.md └── TiKV-DistSQL-Cache-Design.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /Clang-Bug-Segment-Fault.md: -------------------------------------------------------------------------------- 1 | # 一个 Clang Bug 引发的血案 2 | 3 | ## 案发现场 4 | 5 | 最近在做 TiKV 相关的开发和测试,在改了一些 TiKV 的代码之后,在自己的 Mac 笔记本上用 `make release` 编译了一下 TiKV 然后准备挂上自己的数据做一下测试。然后 Segment Fault 发生了。 6 | 7 | ``` 8 | ... 9 | 2017/12/18 10:50:48.657 tikv-server.rs:146: [WARN] environment variable `TZ` is missing, use `/etc/localtime` 10 | 2017/12/18 10:50:48.667 util.rs:426: [INFO] connect to PD leader "http://127.0.0.1:2379" 11 | 2017/12/18 10:50:48.667 util.rs:357: [INFO] All PD endpoints are consistent: ["127.0.0.1:2379"] 12 | 2017/12/18 10:50:48.667 tikv-server.rs:525: [INFO] connect to PD cluster 6498555866359339957 13 | 2017/12/18 10:50:48.716 mod.rs:497: [INFO] storage RaftKv started. 14 | 2017/12/18 10:50:48.720 mod.rs:308: [INFO] starting working thread: store address resolve worker 15 | 2017/12/18 10:50:48.721 server.rs:94: [INFO] listening on 127.0.0.1:20160 16 | 2017/12/18 10:50:48.723 node.rs:329: [INFO] start raft store 1 thread 17 | [1] 86379 segmentation fault (core dumped) ./tikv-server -C tikv.toml 18 | ``` 19 | 20 | 想起了之前 PingCAP 同学说过,TiKV,PD 和 TiDB 版本不同有可能会导致 RPC 协议数据格式的版本不同,然后会导致 Segment Fault。先把 PD 和 TiDB 都升级到最新的代码试试。 21 | 22 | 经过大约3分钟的编译,重新启动。然后 TiKV 仍旧 Segment Fault。好吧,看来是个大新闻。联系到了 PingCAP 的同学并向他们报了障。几分钟之后,PingCAP 那边的同学也反馈遇到了类似的问题,不过 Linux 下 build 的版本并没有遇到任何问题。 23 | 24 | ## 挖地三尺 25 | 26 | 遇到这种 Segment Fault 的问题一般来讲应该是遇到了一个隐藏比较深的 Bug,根据以前的经验,Segment Fault 多半是访问到了不该访问的内存。但鉴于 MacOS 并没有类似 Linux 的 syslog 中记录 Segment Fault 原因的日志,只好自己启动 lldb 来看看堆栈了。 27 | 28 | 经过一番查找,找到了引起 Segment Fault 的线程的案发现场: 29 | 30 | ``` 31 | * thread #19, stop reason = signal SIGSTOP 32 | * frame #0: 0x000000010d5a94d4 tikv-server`rocksdb::Version::AddIteratorsForLevel(rocksdb::ReadOptions const&, rocksdb::EnvOptions const&, rocksdb::MergeIteratorBuilder*, int, rocksdb::RangeDelAggregator*) + 692 33 | frame #1: 0x000000010d5a91f8 tikv-server`rocksdb::Version::AddIterators(rocksdb::ReadOptions const&, rocksdb::EnvOptions const&, rocksdb::MergeIteratorBuilder*, rocksdb::RangeDelAggregator*) + 72 34 | frame #2: 0x000000010d4a9b49 tikv-server`rocksdb::DBImpl::NewInternalIterator(rocksdb::ReadOptions const&, rocksdb::ColumnFamilyData*, rocksdb::SuperVersion*, rocksdb::Arena*, rocksdb::RangeDelAggregator*) + 249 35 | frame #3: 0x000000010d4ad85f tikv-server`rocksdb::DBImpl::NewIterator(rocksdb::ReadOptions const&, rocksdb::ColumnFamilyHandle*) + 495 36 | frame #4: 0x000000010d81de94 tikv-server`::crocksdb_create_iterator_cf(db=0x00007f8d0c674860, options=, column_family=0x00007f8d0c662d90) at c.cc:926 [opt] 37 | frame #5: 0x000000010cec6eb4 tikv-server`tikv::raftstore::store::engine::{{impl}}::new_iterator_cf at rocksdb.rs:213 [opt] 38 | frame #6: 0x000000010cec6e4d tikv-server`tikv::raftstore::store::engine::{{impl}}::new_iterator_cf(self=0x000000010ee98080, cf=, iter_opt=) at engine.rs:357 [opt] 39 | ... 40 | ``` 41 | 42 | 这下子好了,至少找到了引起 Segment Fault 的位置:`rocksdb`。随即上 github 上查看了 TiKV 跟 `rust-rocksdb` 库相关的提交。总共找到了 3 个 PR 跟升级 `rust-rocksdb` 有关。接下来就可以做一些验证,找到引入问题的那个提交。 43 | 44 | ## 山穷水尽 45 | 46 | 找到了 3 个可疑的 PR,那么挨个 Revert 然后 `make release` 最终定位到了最早的那个 PR 就是引入问题的罪魁祸首。 47 | 48 | 在拿到了这个证据之后,随即跟 PingCAP 的同学进行讨论。发现这个 PR 的代码跟 RocksDB 的官方代码有细微的不同,但是他们在改进之后并没有解决问题。然后按照 PingCAP 的同学说用 `make unportable_release` build TiKV 确实没出现 Segment Fault 的问题。 49 | 50 | 看来这个问题真是难缠。难道这两个 build 模式又有什么不同么?抱着这个问题开始翻阅 TiKV 和 rust-rocksdb 两个项目的编译脚本。最终找到了差别: 51 | 52 | * make release 会在 cc 后面加入 `-msse42` 参数 53 | * make unportable_release 会在 cc 后面加入 `-march=native` 同时没有 `-msse42` 参数 54 | 55 | 这下明确了一点,这个 Segment Fault 大概率跟 SSE 指令相关的代码有关。根据这个线索,找到了 RocksDB 中的 `util/crc32c.cc` 文件。行数不多,但使用的跟 SSE 相关的函数经过排查发现是系统提供的。再一次无功而返。 56 | 57 | 这时想起了 lldb 中的反汇编工具,看看程序到底是死在哪里了: 58 | 59 | ``` 60 | ... 61 | 0x10d5a94b0 <+656>: leaq 0x874889(%rip), %rcx ; vtable for rocksdb::(anonymous namespace)::LevelFileIteratorState + 16 62 | 0x10d5a94b7 <+663>: movq %rcx, (%r12) 63 | 0x10d5a94bb <+667>: movq %rax, 0x10(%r12) 64 | 0x10d5a94c0 <+672>: movq 0x2d(%rbx), %rax 65 | 0x10d5a94c4 <+676>: movq %rax, 0x4d(%r12) 66 | 0x10d5a94c9 <+681>: movaps (%rbx), %xmm0 67 | 0x10d5a94cc <+684>: movaps 0x10(%rbx), %xmm1 68 | 0x10d5a94d0 <+688>: movaps 0x20(%rbx), %xmm2 69 | -> 0x10d5a94d4 <+692>: movaps %xmm2, 0x40(%r12) 70 | 0x10d5a94da <+698>: movaps %xmm1, 0x30(%r12) 71 | 0x10d5a94e0 <+704>: movaps %xmm0, 0x20(%r12) 72 | 0x10d5a94e6 <+710>: movq 0x60(%rbx), %rdi 73 | 0x10d5a94ea <+714>: testq %rdi, %rdi 74 | 0x10d5a94ed <+717>: je 0x10d5a9514 ; <+756> 75 | ... 76 | ``` 77 | 78 | 箭头指向了一个 `movaps` 指令,这是什么鬼... 79 | 80 | ## 柳暗花明 81 | 82 | 一路排查到这里,到头来还是什么有价值的东西都没找出来。在仔细整理了思路之后发现整个案件还有一个证据没有找到,Segment Fault 的原因。既然没法从 syslog 中找到对应的日志,那么在 lldb 中运行一下呢? 83 | 84 | ``` 85 | 2017/12/18 11:12:59.553 tikv-server.rs:146: [WARN] environment variable `TZ` is missing, use `/etc/localtime` 86 | 2017/12/18 11:12:59.560 util.rs:426: [INFO] connect to PD leader "http://127.0.0.1:2379" 87 | 2017/12/18 11:12:59.560 util.rs:357: [INFO] All PD endpoints are consistent: ["127.0.0.1:2379"] 88 | 2017/12/18 11:12:59.560 tikv-server.rs:525: [INFO] connect to PD cluster 6498555866359339957 89 | 2017/12/18 11:12:59.624 mod.rs:497: [INFO] storage RaftKv started. 90 | 2017/12/18 11:12:59.629 mod.rs:308: [INFO] starting working thread: store address resolve worker 91 | 2017/12/18 11:12:59.629 server.rs:94: [INFO] listening on 127.0.0.1:20160 92 | 2017/12/18 11:12:59.632 node.rs:329: [INFO] start raft store 1 thread 93 | Process 86787 stopped 94 | * thread #2, name = 'raftstore-1', stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT) 95 | frame #0: 0x0000000100b164d4 tikv-server`rocksdb::Version::AddIteratorsForLevel(rocksdb::ReadOptions const&, rocksdb::EnvOptions const&, rocksdb::MergeIteratorBuilder*, int, rocksdb::RangeDelAggregator*) + 692 96 | tikv-server`rocksdb::Version::AddIteratorsForLevel: 97 | -> 0x100b164d4 <+692>: movaps %xmm2, 0x40(%r12) 98 | 0x100b164da <+698>: movaps %xmm1, 0x30(%r12) 99 | 0x100b164e0 <+704>: movaps %xmm0, 0x20(%r12) 100 | 0x100b164e6 <+710>: movq 0x60(%rbx), %rdi 101 | Target 0: (tikv-server) stopped. 102 | ``` 103 | 104 | 好了,证据收集到了,`reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)`。好了,这些文字还是头一次见过,那么 googl...(好吧,这个网站不存在,我用的是 bing.com!😂) 105 | 106 | 在 Stack Overflow 网站中找到一个帖子,其中有这么一句话: 107 | 108 | >Another likely causes is unaligned access with an SSE register - in other word, reading a 16-byte SSE register from an address that isn't 16-byte aligned. 109 | 110 | 刚好在 lldb 中也看到了 `movaps %xmm2, 0x40(%r12)` 这个指令。那么可以看看是不是 `r12` 这个寄存器里面的值有问题? 111 | 112 | ``` 113 | (lldb) register read 114 | General Purpose Registers: 115 | rax = 0x0000000001000000 116 | rbx = 0x00000001022085c0 117 | rcx = 0x000000010138ad40 tikv-server`vtable for rocksdb::(anonymous namespace)::LevelFileIteratorState + 16 118 | rdx = 0x0000000000000000 119 | rdi = 0x0000000104001030 120 | rsi = 0x0000000102879e38 121 | rbp = 0x000070000b3fdf30 122 | rsp = 0x000070000b3fded0 123 | r8 = 0x000000010287a7e0 124 | r9 = 0x0000000104001478 125 | r10 = 0x00000000fffff800 126 | r11 = 0x00000000000041c0 127 | r12 = 0x0000000104001478 128 | r13 = 0x0000000102873e00 129 | r14 = 0x000070000b3fdf88 130 | r15 = 0x0000000000000001 131 | rip = 0x0000000100b164d4 tikv-server`rocksdb::Version::AddIteratorsForLevel(rocksdb::ReadOptions const&, rocksdb::EnvOptions const&, rocksdb::MergeIteratorBuilder*, int, rocksdb::RangeDelAggregator*) + 692 132 | rflags = 0x0000000000010206 133 | cs = 0x000000000000002b 134 | fs = 0x0000000000000000 135 | gs = 0x0000000000000000 136 | ``` 137 | 138 | `r12 = 0x0000000104001478`,最后一字节是 `0x78`!16 字节对其的话最后一位应该是 `0` 啊!这是什么操作! 139 | 140 | ## 真相只有一个 141 | 142 | 在找到这些线索之后,跟 PingCAP 的同学也交流了一下,最后 PingCAP 的同学也确认了这个问题的锅应该是编译器来背。 143 | 144 | **MacOS 下 Xcode 9.2 携带的 clang 4.9 会有使用 `movaps` 指令但并没做好地址对齐的问题,升级到 clang 5.0 之后指令会被替换成 `movups` 问题就解决了** 145 | 146 | 既然问题的原因找到了,在生产系统上跑的 TiKV 可以放心了。但是使用装了 clang 4.9 的 MacOS 的同学在开发 TiKV 的时候还是尽量使用 `make unportable_release` 来编译并测试。 147 | 148 | 剩下的就是等等新版本的 Xcode 了吧。 149 | 150 | ## 写在最后 151 | 152 | 这个 Bug 跟下来之后,发现对自身的价值观又是一次冲击。上一次对 Linux Kernel 的信赖被遇到的各种 Kernel Panic 打破了。这次又打破了我对编译器的信赖。看来这年头没有什么能 100% 信赖的东西了。 153 | 154 | 当然必须要感谢一下 PingCAP 的同学们。响应非常快,尤其是 张金鹏 同学,为了这个问题我们两个真的是懵逼了好几天,他也是各种 Rust,C++ 的手写程序来抓虫。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This is my public documents repository 2 | 3 | All public documents can be upload to this repo and trace modifies. 4 | 5 | All documents in this repo should not published without author's permited. -------------------------------------------------------------------------------- /TiDB-OLAP-Optimization.md: -------------------------------------------------------------------------------- 1 | # TiDB OLAP 优化之路 2 | 3 | ## 一个请求日志分析系统 4 | 5 | 这次的故事起源于 SpeedyCloud 的一个客户(某移动运营商)的请求日志分析系统需求,需求不复杂,客户把请求日志发到指定的服务器上,然后经过 ETL 阶段把数据加工之后保存到数据库中,然后通过一个 Web 系统配合各种条件和排序查看统计结果。 6 | 7 | 鉴于客户目前的需求最多查询前一天的统计信息,那么这个非实时系统设计起来也就简单多了: 8 | 9 | 1. 通过 ETL 程序分析日志并生成单天的数据 10 | 2. 把单天的数据保存到数据库中 11 | 3. 通过 Web 系统连接数据库并进行查询 12 | 13 | 那么问题来了:应该选用什么数据库? 14 | 15 | * Hadoop + Hive 16 | * ElasticSearch 17 | * MySQL 18 | 19 | 这些是第一时间出现在脑子里的列表。对于 Hadoop + Hive 来说,查询速度是个硬伤;ElasticSearch 的查询速度不错,不过对于一些临时分析需求的支持其实并不友好;MySQL 对于开发和临时的分析支持很棒,但问题是单日数据量很大,保存长时间的数据会导致 MySQL 的查询速度下降。 20 | 21 | 为了能先搭建出来一个原型系统,最终还是选用了 ElasticSearch 来存储 ETL 的结果数据。不过 Web 后台的程序写的也是挺闹心的 @_@ 。 22 | 23 | ## TiDB 24 | 25 | 也算是机缘巧合,同事跟我提到了 TiDB,花了一些时间了解之后,我发现 TiDB 应该是这个日志分析系统最合适的存储引擎,然后花了一些时间改出了一个 TiDB 版本的 Web 后台程序,然后开始了 TiDB 和 ElasticSearch 的 PK 旅程。 26 | 27 | ## 表结构优化 28 | 29 | 日志分析系统的一个常用的功能是查询出某一天的数据,然后按照某个指标排序,然后分页展示。写出来的 SQL 类似: 30 | 31 | ```sql 32 | SELECT * FROM `table` WHERE `date`="20170101" ORDER BY `some_column` LIMIT 0, 20; 33 | ``` 34 | 35 | 但是一天的数据量基本上在百万级,所以 `date` 列加索引的意义不大。根据 TiDB 的架构(扫描完索引,然后在根据索引的 `handle` 扫描表),做全表扫描会比在 `date` 上加索引要快很多。 36 | 37 | 但是不用索引,完全靠全表扫描会有引入外一个问题:随着数据量的增加,全表扫面的时间会随之变长,所以全表扫描虽然会在开始比较快,但最终还是会慢下来。在深入了解了 TiDB 的存储结构之后,发现如果能把日期通过某种编码方式转换成一个数字,然后把这个数字当成主键(Primary Key),那么 TiDB 在查询的时候就会根据主键进行区间扫描,从而把扫描的行数限制在了一个很小的范围,而且这个范围不受到表的总行数影响,因此优化后的查询语句就变成了: 38 | 39 | ```sql 40 | SELECT * FROM `table` WHERE `id` BETWEEN "201701010000000000" AND "201701019999999999" ORDER BY `some_column` LIMIT 0, 20; 41 | ``` 42 | 43 | 主键的编码方式选用了日期拼接上一个自增的ID。 44 | 45 | ## 索引插入优化 46 | 47 | 当 TiDB 插入一行索引的时候,会根据索的引信息编码成 Key-Value Pair 存储在 TiKV 中 ,并按照 Key 的字节序列找到对应的 Region,然后将这个 Key-Value Pair 放在 Region 的对应位置。如果连续插入的索引是自增的话,会导致这些新的索引都落在一个 Region 当中。TiDB 官方推荐在插入索引的时候尽量让索引内容随机化。 48 | 49 | 针对这个日志分析系统,每一天的域名数据是唯一的,同时也有插叙某一天的某个域名的统计信息,因此日期和域名可以创建一个唯一的组合索引: 50 | 51 | ```sql 52 | UNIQUE KEY `idx_domain_date` (`domain`, `date`) 53 | ``` 54 | 55 | 因为数据是按天导入,但每次导入的域名都是不同的,那么根据随机化原则,组合索引的 `domain` 在前,`date` 在后可以大大分散索引插入时命中的 Region 的分散程度,从而提高索引的插入性能。 56 | 57 | ## Region 的大小与查询时间 58 | 59 | 在最初部署的时候,用的是 TiDB 的 RC4 版本,Region 的大小是 256MB,在和 ElasticSearch 版本 PK 的时候发现性能差距很大,ES 的时间基本上在 1s 左右,而 TiDB 的版本在 4~5s。 60 | 61 | 在看了半天监控图,翻了一通 TiDB 的代码,外加各种脑补之后,突然意识到了一些问题: 62 | 63 | * 日志表的数据是保存在 TiKV 的 N 个 Region 中 64 | * N 的大小跟 Region 的大小有关 65 | * TiDB 执行查询的时候会根据要扫描的 N 个 Region 数量生成 N 个 DistSQL 请求发给 TiKV 66 | * TiKV 会从 gRPC 的线程池中选择一个线程执行一个 DistSQL 67 | * TiDB 会根据系统变量控制 DistSQL 的并发数量 68 | 69 | 在想到这些之后,会发现要扫描的 Region 数量 N 越大,并行计算的优势就越能发挥出来。也就是说如果一天的数据最终映射到 4 个 Region 中,那么整个查询也只能使用 TiKV 集群中的 4 个 CPU,同时如果这 4 个 Region 的 Leader 又恰好在同一个服务器上,那么其他的服务器就不会收到任何请求。 70 | 71 | 所以如果有 10 台服务器跑 TiKV,那么最好的方案是一天的数据分散在 10 个 Region 当中,然后这 10 个 Region 的 Leader 分别在这 10 个服务器上,那么这个查询就可以利用 10 台服务器的 CPU 和 IO 资源。 72 | 73 | 基于以上的推断,在修改了 TiKV 的 Region Split 相关的配置,把 Region 的大小配置成 48MB 之后,又重新导入了一遍数据。再次测试查询的时间发现请求时间稳定在 1.5~1.2s。基本上跟 ElasticSearc 打了个平手。 74 | 75 | ## 并行 Region 扫描 76 | 77 | 对于一个 Region 只会生成一个 DistSQL 只有一个 TiKV 线程执行扫描的问题,想到了能不能对一个 Region 发起多个 DistSQL,然后就可以让这个 Region 可以并行扫描以提高查询性能。 78 | 79 | 鉴于上面这个设想,我自己 Hack 了一下 TiDB 的代码,然后在 Staging 环境上做了一个测试,得出的结论如下: 80 | 81 | * 对于小 Region 而言,并行扫描提升不大,毕竟扫描完一个 Region 的时间很短 82 | * 对于大 Region 而言,并行扫描有所提升,但更重要的问题在于 Region 的数据过于集中,导致一个查询只有 1~2 个 TiKV 执行,那么拆分的 N DistSQL 也会因为超过了 gRPC Concurrency 的上限而进行等待。 83 | 84 | ## 小 Region 的问题 85 | 86 | 小 Region 虽然会得到一个很好的数据分布,但也会引入一个问题: 87 | 88 | * 太多的 Region 会有大量的 Raft 心跳,导致 PD 压力大增 89 | 90 | 目前并不清楚 PD 所能管理的 Region 数量的极限,所以小 Region 到底能支撑多大量的数据还不清楚。 91 | 92 | ## 关于 OLAP 得出的一些经验 93 | 94 | * 要了解 TiDB 和 TiKV 是怎么存储数据的,可以针对数据存储和查询方式选择最优的表结构。 95 | * 要了解 TiDB 的查询方式和 TiKV 的存储数据分布程度,选择一个合适的 Region 大小,根据这几天优化的结果,一个好的数据分布可以大大提升 TiDB 在 OLAP 场景下的性能。 96 | 97 | > 友情提示:这些经验并没有在 OLTP 场景下做过测试 98 | 99 | ## ElasticSearch 与 TiDB 的取舍 100 | 101 | 经过一段时间的调优,TiDB 的查询速度已经可以满足这个日志系统的需求了,那么在 ElasticSearch 和 TiDB 之间就要做一个了断了。由于客户还会在查询界面之外提出一些特殊的查询需求,鉴于 TiDB 能很好地支持 SQL 查询,那么利用现有的工具,可以非常方便快捷的满足客户的临时查询需求。另外寻找懂 SQL 的同学还是比寻找懂 ElasticSearch 的同学要方便的多,同时从开发的便捷程度来说,TiDB 还是有更大的优势。所以最终上线的版本还是选择了 TiDB 作为数据存储引擎。(挺 ElasticSearch 的同学请轻拍) 102 | 103 | ## 后记 104 | 105 | 后来和 PingCAP 团队沟通这篇文章的内容,PingCAP 官方认为减小 Region 并不是最终的解决之道,还是要改变单 Region 的扫描并发度,让对 Region 的扫描变为并发的,同时调整调度策略,让相邻的 Region 的 Leader 分散到不同的机器上,这样才能更好的解决这个问题。另外在和 PingCAP 同学的交流中得知 PD 的 Region 处理能力至少在百万级别。 -------------------------------------------------------------------------------- /TiDB-With-Hyperscan.md: -------------------------------------------------------------------------------- 1 | # TiDB + Hyperscan 2 | 3 | ## 缘起 4 | 5 | 之前在研究 Suricata 的时候才知道有多模匹配这么个概念,然后接触到了 Hyperscan 这个库。其实对于多模匹配来说,非常成熟的开源库并不多,而且用起来还是需要动手写一些代码。后来就想到能不能在 TiDB 上面集成 Hyperscan,增加一些内建函数来简化多模匹配的使用。 6 | 7 | 当然,集成 Hyperscan 库的数据库也是有的,比如 Clickhouse。 8 | 9 | ## 设想 10 | 11 | Hyperscan 的用法主要分为2步: 12 | 13 | 1. 创建数据库 14 | 2. 使用数据库进行匹配 15 | 16 | 如果想把这两步放到数据库里面,就需要两类函数,一类用来创建 Hyperscan 数据库,另一类用来使用数据库对每行的指定列进行匹配,如果换成 SQL 的话,下面这样的例子可能更直观一些: 17 | 18 | ```sql 19 | SELECT HS_MATCH(`data`, (SELECT HS_BUILDDB(`id`, `pattern`) FROM `pattern_table`)) as `matched` FROM `data_table`; 20 | ``` 21 | 基于上面这个设想,就需要在 TiDB 中加入一个聚合函数来创建 Hyperscan 的数据库,然后提供一组 match 的标量函数来计算每行是否匹配。 22 | 23 | ## Scalar Function 24 | 25 | 在 TiDB 的 Blog 中已经有一篇文章讲述如何添加一个标量函数了。这里需要做的就是加入一系列 match 相关的函数实现。然后注册到全局变量 `funcs` 这个 map 中即可。 26 | 27 | 另外一个需要注意的是,每个 Scalar Function 都需要给他分配一个 ProtocolBuffer 的 Code。当然,按照正常的流程,需要调整 `tipb` 这个库,为新加入的 Scalar Function 指定一个值。如果不想动 `tipb` 这个库,可以参考 `tipb` 库里面定义的其他函数的 Code 值,然后在 TiDB 这边直接自定义即可。 28 | 29 | ## Aggregation Function 30 | 31 | 相较于 Scalar Function 已经在 Parser 层面做好了工作,如果要添加 Aggregation Function,则需要对 `parser` 库进行修改,从而让 Parser 认为 `hs_builddb` 是个聚合函数而不是一个标量函数。这需要在 `parser.y` 中给 `SumExpr` 加入新的函数描述: 32 | 33 | ```go 34 | | "HS_BUILDDB" '(' ExpressionList ')' 35 | { 36 | $$ = &ast.AggregateFuncExpr{F: ast.AggFuncHSBuildDB, Args: $3.([]ast.ExprNode)} 37 | } 38 | ``` 39 | 40 | 当一个函数是聚合函数时,TiDB 的 Planner 在创建 Plan 的时候会使用 Agg 系列的任务(比如 StreamAgg)。另外,聚合函数的逻辑代码是在 `executor/aggfuncs` 包里,而不是在 `expression` 包里面。 41 | 42 | 在调整好 Parser 之后,就可以在 TiDB 这边实现 `hs_builddb` 函数了。相较于 Scalar Function 来说,Aggregation Function 的代码实现起来会比较分散。对于 `hs_builddb` 函数的逻辑代码需要实现 `AggFunc` 接口即可。这个接口只有 4 个函数,并不复杂,可以参考一些其他的聚合函数和接口函数的注释就可以解决。 43 | 44 | 而说 Aggregation Function 实现起来比较分散主要是在周边的配套设施: 45 | 46 | 1. Aggregation Function Builder (在`executor/aggfuncs/builder.go`中的`Build`函数) 47 | 2. Aggregation Function 的 Protocol Buffer 的互相转换 (在`expression/aggregation/agg_to_pb.go`的`AggFuncToPBExpr`和`PBExprToAggFuncDesc`函数) 48 | 3. Aggregation Function 的执行类型推断(在`expression/aggregation/base_func.go`的`typeInfer`函数) 49 | 4. 针对 Aggregation Function Not Null 的处理(在`expression/aggregation/descriptor.go`的`UpdateNotNullFlag4RetType`函数) 50 | 5. 指定 Aggregation Function 是否支持 TiKV 下推(在`planner/core/rule_aggregation_push_down.go`的`isDecomposableWithJoin`和`isDecomposableWithUnion`函数) 51 | 52 | 在实现完 `AggFunc` 接口之后,只有找到这些配套设施的位置,加以修改才能让聚合函数正常运行。而且以上绝大部分的函数都是用 `swtich` 语句来区分聚合函数的。其实这对于条件编译并不友好。 53 | 54 | ## 条件编译 55 | 56 | 当然,并不是所有人都需要 Hyperscan,或者不是所有编译环境都有 Hyperscan 库,所以条件编译是必须的。好在 Golang 支持 `-tags` 参数来提供条件编译的可能性。那么剩下的是如何设计代码让 TiDB 可以根据环境变量开启或者关闭 Hyperscan 函数的支持。 57 | 58 | 首先,可以在 go 文件中加入以下注释来让这个文件在有对应的 tag 之后才进行编译: 59 | 60 | ``` go 61 | // +build hyperscan 62 | ``` 63 | 64 | 毕竟不像 C 那样可以通过 `#ifdef` 来对某行代码进行条件编译,Go 能控制的粒度只在文件级别。所以配合 `init` 函数向一个全局的 map 变量增加和减少某些 key 实现条件编译是一个比较合适的办法。 65 | 66 | 对于标量函数来说,全局变量 `funcs` map 可以通过 `init` 函数配合条件编译来选择性加入或者不加入 Hyperscan 函数的支持。 67 | 68 | ``` go 69 | // Add hyperscan builtin functions 70 | func init() { 71 | funcs[HSBuildDBJSON] = &hsBuildDbJSONFunctionClass{baseFunctionClass{HSBuildDBJSON, 1, 2}} 72 | funcs[HSMatch] = &hsMatchFunctionClass{baseFunctionClass{HSMatch, 2, 3}} 73 | funcs[HSMatchJSON] = &hsMatchJSONFunctionClass{baseFunctionClass{HSMatchJSON, 2, 2}} 74 | funcs[HSMatchAll] = &hsMatchAllFunctionClass{baseFunctionClass{HSMatchAll, 2, 3}} 75 | funcs[HSMatchAllJSON] = &hsMatchAllJSONFunctionClass{baseFunctionClass{HSMatchAllJSON, 2, 2}} 76 | funcs[HSMatchIds] = &hsMatchIdsFunctionClass{baseFunctionClass{HSMatchIds, 2, 3}} 77 | funcs[HSMatchIdsJSON] = &hsMatchIdsJSONFunctionClass{baseFunctionClass{HSMatchIdsJSON, 2, 2}} 78 | } 79 | ``` 80 | 81 | 但对于聚合函数来说,需要对某些 “**周边的配套设施**” 进行一些修改才好支持条件编译。把 `switch` 语句换成 map 是一种方法,比如针对 Builder 可以在最终返回的时候加入一个针对扩展函数的处理,然后用一个 map 的全局变量来进行注册扩展的聚合函数: 82 | 83 | ``` go 84 | type aggFuncBuilderFunc func(ctx sessionctx.Context, aggFuncDesc *aggregation.AggFuncDesc, ordinal int) AggFunc 85 | 86 | var ( 87 | extensionAggFuncBuilders = map[string]aggFuncBuilderFunc{} 88 | ) 89 | 90 | func buildFromExtensionAggFuncs(ctx sessionctx.Context, aggFuncDesc *aggregation.AggFuncDesc, ordinal int) AggFunc { 91 | buildFunc, have := extensionAggFuncBuilders[aggFuncDesc.Name] 92 | if !have { 93 | return nil 94 | } 95 | return buildFunc(ctx, aggFuncDesc, ordinal) 96 | } 97 | 98 | // Build is used to build a specific AggFunc implementation according to the 99 | // input aggFuncDesc. 100 | func Build(ctx sessionctx.Context, aggFuncDesc *aggregation.AggFuncDesc, ordinal int) AggFunc { 101 | switch aggFuncDesc.Name { 102 | case ast.AggFuncCount: 103 | return buildCount(aggFuncDesc, ordinal) 104 | ... 105 | case ast.AggFuncStddevSamp: 106 | return buildStddevSamp(aggFuncDesc, ordinal) 107 | } 108 | return buildFromExtensionAggFuncs(ctx, aggFuncDesc, ordinal) 109 | } 110 | ``` 111 | 112 | ## Scratch 复用 113 | 114 | Hyperscan 有一个概念叫 Scratch,每个线程可以创建一个 Thread Local 的 Scratch 对象,可以在循环调用 match 族函数的时候复用,以提高匹配的性能。所以在 Scalar Function 针对多行数据做匹配操作的时候,一次性创建一个 Scratch 对象然后在循环中使用这个 Scratch 对象可以大大提高匹配的速度(实测每次匹配时创建 Scratch 然后销毁对性能的影响非常大)。 115 | 116 | 但是 Scratch 的文档中明确说明不能多线程共享,好在 TiDB 并不会用多 goroutine 并行执行 Scalar Function(与 PingCAP 的同学确认的),所以并不存在 Scratch 被多线程共享的问题。所以在 Scalar Function 在第一次调用 evalXXX 函数的时候初始化好 Scratch 对象以供后续调用复用就可以了。 117 | 118 | ## 内存清理 119 | 重复分配 Scratch 的性能问题解决了,但是还有个问题:Scalar Function 对象本身并不知道什么时候 SQL 执行完毕,也就没法做确定性资源清理。由于 TiDB 的其他 Scalar Function 并不涉及到资源回收的问题,也就没有对应的回调函数告知 Scalar Function 对象它所对应的 SQL 语句已经执行完毕。但是 Hyperscan 的 Database 和 Scratch 是要明确调用 `Close()` 和 `Free()` 函数来回收资源的。放任不管肯定是会内存泄漏的。 120 | 121 | 好在 Golang 的 runtime 包提供了 `SetFinalizer` 函数可以在 GC 销毁某个对象时调用一个回调函数。因此对于 Hyperscan 的 Database 和 Scratch 就可以使用下面的方式进行资源的回收: 122 | 123 | ```go 124 | func (b *baseBuiltinHsSig) initScratch() error { 125 | if b.scratch != nil { 126 | return nil 127 | } 128 | scratch, err := hs.NewScratch(b.db) 129 | if err != nil { 130 | return err 131 | } 132 | b.scratch = scratch 133 | runtime.SetFinalizer(scratch, func(hsScratch *hs.Scratch) { 134 | hsScratch.Free() 135 | }) 136 | return nil 137 | } 138 | 139 | func buildBlockDBFromBase64(base64Data string) (hs.BlockDatabase, int, error) { 140 | data, err := base64.StdEncoding.DecodeString(base64Data) 141 | if err != nil { 142 | return nil, 0, err 143 | } 144 | db, err := hs.UnmarshalBlockDatabase(data) 145 | runtime.SetFinalizer(db, func(hsdb hs.BlockDatabase) { 146 | hsdb.Close() 147 | }) 148 | return db, 0, err 149 | } 150 | ``` 151 | 152 | ## 静态链接 153 | 154 | 默认情况下,通过 Homebrew 或者 rpm,apt 安装的 Hyperscan 的开发库一般启用的都是动态链接。但对于 Golang 来说静态链接才叫“讲究”(总不想在分发和部署的时候还得为 hyperscan 的 so 库文件所在位置操心吧)。 155 | 156 | 为了能做静态链接,就需要自己编译一下 Hyperscan 的代码,并在编译时指定使用静态链接。但问题并不会这么简单,在做 TiDB 的 Hyperscan 集成时,使用了 `github.com/flier/gohs` 这个库。这个库对于动态链接来说,一点问题都没有。但是用静态链接模式编译 TiDB 的时候就会在连接时候报错。然后你就会看到连接器打印了一大堆的 C++ 的符号找不到的日志。 157 | 158 | 从报错信息上可以了解到 Go 的连接过程用的是 C 的连接器,而不是 C++,但是找了一圈没有发现有什么命令行参数让 Go 在连接的时候用 g++,直到看到了 gohs 的 [Issue](https://github.com/flier/gohs/issues/27), 然后自己 fork 一个 gohs,加入了一个 dummy.cxx 的空文件,一切正常了。 159 | 160 | 后来翻了一些文档和帖子才知道 Golang 会通过文件扩展名来判断用 C 还是 C++ 的连接器。 161 | 162 | ## 总结 163 | 164 | 最终可以得到一个 TiDB 支持: 165 | 166 | * 条件编译可以选择是否让 TiDB 在运行时支持 Hyperscan 系列函数 167 | * 静态链接可以不用跟随发布 Hyperscan 的动态链接库 168 | * 一组 Scalar Function 可以对字符串进行多模式匹配 169 | * 一个 Aggregation Function 可以方便的构建 Hyperscan 的数据库 170 | 171 | 当然,用起来会是下面的样子: 172 | 173 | ```sql 174 | mysql> select * from patterns; 175 | +------+---------+ 176 | | id | pattern | 177 | +------+---------+ 178 | | 1 | abc | 179 | | 2 | def | 180 | | 3 | test | 181 | +------+---------+ 182 | 3 rows in set (0.00 sec) 183 | 184 | mysql> select line, hs_match_ids(line, (select hs_builddb(id, pattern) from patterns), 'base64') as `matched_ids` from data; 185 | +-----------+-------------+ 186 | | line | matched_ids | 187 | +-----------+-------------+ 188 | | abc def | 1,2 | 189 | | some test | 3 | 190 | | no matche | | 191 | +-----------+-------------+ 192 | 3 rows in set (0.01 sec) 193 | ``` 194 | 195 | 如果想看到更详细的实现,可以通过这个 [PR](https://github.com/pingcap/tidb/pull/23497) 查看。 -------------------------------------------------------------------------------- /TiKV-DistSQL-Cache-Design.md: -------------------------------------------------------------------------------- 1 | # TiKV DistSQL Cache 2 | 3 | ## 起源 4 | 5 | MySQL 有个 Query Cache,那么 TiDB 呢? 6 | 7 | ## DistSQL 8 | 9 | 当我们在 TiDB 执行一个查询语句的时候,TiDB 会根据查询语句计算出需要扫描的 KeyRange 然后根据 KeyRange 分布的 Region 生成 N 个 DistSQL 发送到 TiKV 中执行。 10 | 11 | 由此,DistSQL 里面包含了查询条件和要扫描的 Region 的 KeyRange。那么如果需要为 DistSQL 增加 Cache 的话,区分不同的 DistSQL 只需要把查询条件和要扫描的 KeyRange 编码成一个 Key 就可以了。 12 | 13 | 目前的代码实现为把 KeyRange 和 DistSQL 的 Executors 编码成字符串。 14 | 15 | ## Cache 16 | 17 | LRU Cache 目前来看是个比较简单高效的 Cache 策略,但是 DistSQL 使用的 LRU Cache 并不是个简单的 LRU Cache 就可以的,需要对 LRU 算法进行小调整: 18 | 19 | * Cache Item 的数量需要 LRU 算法进行自动淘汰 20 | * Cache Item 需要记录 DistSQL 对应的 RegionID 21 | * 需要提供针对 RegionID 的 Cache 失效功能 22 | 23 | 在实现了上述功能之后,剩下的就是在 Region 数据变动的地方加入 Hook 函数,调用针对 RegionID 失效相关 Cache Item 的函数。需要 Region 级别失效的时刻为: 24 | 25 | * RocksDB 写入时 26 | * Region Split 时 27 | * Raft Log Apply 时 (目前还不太确认,按说 Raft Log Apply 时应该会有对应的 RocksDB 写入,不过为了安全起见,这个时间点还是加入了针对 RegionID 失效的 Hook) 28 | 29 | 另外 RegionInfo 也会保存到 Cache Item 当中。一旦 Region 的 epoch 和 conf_epoch 改变了,那么当前 Cache Item 应该被认为未命中。 30 | 31 | ## Cache 什么数据 32 | 33 | 对于 TiKV 来说,RocksDB 自身会有 Cache,那么 DistSQL Cache 应该只缓存 RocksDB 的 Cache 无法缓存的内容。根据 coprocessor 所提供的功能,选择 DistSQL 中包含 TopN 和 Aggregation 的 DistSQL 结果来缓存是比较合适的。因为这种 DistSQL 会对 RocksDB 取出来的数据进行大量计算,同时计算出来的结果也会比较小。 34 | 35 | 对于计算结果非常大的 DistSQL 来说,网络传输所占用的时间大概率会成为性能瓶颈(近乎全量导出数据到 TiDB 端),所以目前非 TopN,Aggregation 的 DistSQL 不予缓存。 36 | 37 | ## Cache 的特性 38 | 39 | 在实践过程中发现一个 DistSQL Cache 的特性。由于 DistSQL 是根据 Region 和 KeyRange 进行区分的,那么当 OLAP 的查询语句中 Where 子句中的条件不变,只是扫描的行数增加或者减少,那么之前的 Cache 也会命中。例如: 40 | 41 | 如果表结构为: 42 | 43 | ``` 44 | CREATE TABLE test ( 45 | `id` INT PRIMARY KEY, 46 | `domain_name` VARCHAR(255), 47 | `date` VARCHAR(10), 48 | `count` BIGINT, 49 | ); 50 | ``` 51 | 第一次查询语句为:`SELECT SUM(count) FROM test WHERE id BETWEEN 0 AND 1000 GROUP BY date` 52 | 53 | 第二次查询语句为:`SELECT SUM(count) FROM test WHERE id BETWEEN 0 AND 2000 GROUP BY date` 54 | 55 | 同时 0\~1000 在 Region 1,1001\~2000 在 Region 2。那么第二次查询会命中 Region 1 的 Cache,同时计算 Region 2 的结果。因此,计算时间大约会减半。 56 | 57 | ## Cache 未来的改进 58 | 59 | 以下是针对 LRU Cache 的一些改进想法,还未实现: 60 | 61 | * 针对 Cache 所占用的内存大小进行 Cache Item 的淘汰,以达到固定内存大小的限制 62 | * 使用 TwoQueue Cache 提高缓存命中率 63 | * 针对锁的优化,把锁的粒度细化到 Region 级别,从而提高并发能力 64 | * 针对 DistSQL 结果的大小选择性缓存 65 | 66 | ## 监控项 67 | 68 | 为了能看到 Cache 占用的内存和 Item 数量,需要增加 metrics。Cache Item 占用的内存是在 Cache Item 中记录了 Key 和 Value 外加额外数据结构的字节大小实现的。目前的实现可以获取一个基本正确的缓存字节大小,但并不能非常精确(比如精确到 1 Byte) 69 | 70 | ## 线上测试结果 71 | 72 | DistSQL Cache 可以提高重复查询的响应速度,同时对于聚合和 TopN 类型的查询其结果占用的字节数也不会很大,1000 个 Cache Item 所占用的内存基本上在 50MB 以下。 73 | 74 | 由于线上项目的 Web 服务和数据导入服务是两套程序,所以在 Web 服务中使用 Memcached 进行缓存会引入复杂的缓存失效策略。同时对比 DistSQL Cache 中的失效粒度来看,Memcached 的缓存失效粒度更大,二期对于重复计算没有更好的优化。 75 | 76 | 目前线上项目的用法有两种: 77 | 78 | 1. 查询一天的各个域名的访问情况,并根据某个指标排序,同时提供翻页。其 SQL 类似:`SELECT * FROM some_table WHERE id BETWEEN 201709010000000000 AND 20170901999999999 ORDER BY some_column DESC LIMIT 0, 20` 79 | 2. 按天聚合所有域名的访问情况。其 SQL 类似:`SELECT SUM(column_a), SUM(column_b) ... FROM some_table WHERE id BETWEEN 201709010000000000 AND 201709079999999999 GROUP BY date` 80 | 81 | 在第一种场景下,未命中缓存的情况下,API 响应速度在 1s ~ 2s,命中缓存的情况下,API 响应速度在 100ms ~ 400ms 。 82 | 83 | 在第二种场景下,未命中缓存的情况下,API 响应速度受到日期范围的影响,一个月数据的聚合在冷数据和未命中缓存情况下 API 响应时间会大于 30s,如果一个月的缓存已建立,那么选择半个月时间段聚合的话,API 响应时间会在 ms 级别。如果新的聚合时间区间比已建立的缓存时间段多 1 天的话,API 响应时间会在 1s ~ 3s。 --------------------------------------------------------------------------------