在SQLite数据库中存储64位无符号整数的方法

620 阅读5分钟

问题:存储和查询大量的64位无符号整数

在过去的6年中,我们一直在进行基于散列的序列搜索,使用MinHash衍生的方法FracMinHash,这是个相当大的兔子洞。(你可以在这里阅读更多关于FracMinHash的信息,但它本质上是ModHash的一个底层草图版本)。这一切都在sourmash软件中实现,这是一个基于Python和Rust的命令行生物信息学工具包。

其基本思想是,我们采取长的DNA序列,提取固定长度的子序列(例如k=31),对它们进行散列,然后通过只保留低于某个阈值的子序列来勾画它们。然后,我们根据重叠的哈希值的数量来搜索草图之间的匹配。这是重叠的k=31子序列数量的代理,而这又可以转换为各种序列相似度的指标。

我们要解决的问题的规模是相当大的。举个例子,我们有一个数据库(Genbank细菌),有115万个哈希值桶,这些桶里总共有46亿个哈希值(代表大约4.6万亿个原始K-分子)。因此,我们需要做一些适度聪明的事情来存储它们并快速搜索它们。

我们已经有多种格式来存储和查询草图集,包括包含JSON序列化草图的直接压缩文件,一个基于磁盘的自定义序列布鲁姆树,以及一个住在内存中的倒置索引。倒置索引一旦加载就会很快,但序列化却......不是那么好,而且内存消耗很高。这是我想要解决的问题!

对SQLite情有独钟,这个小小的嵌入式数据库引擎快得令人发指,我决定弄清楚如何用SQLite来存储和查询我们的草图。

使用SQLite来存储64位无符号整数:第一次尝试

我面临的挑战是,我们的草图是由64位无符号整数组成的,而SQLite不能原生存储64位无符号整数。但这正是我所需要的!

进入类型转换器!我找到了两个非常好的资源,可以将64位的整数自动转换为SQLite可以处理的数据类型:这个stackoverflow帖子,"Python的int太大,无法转换为SQLite的INTEGER",以及这个来自welllsr.com的伟大教程,为Python调整和转换SQLite数据类型

简而言之,我从 stackoverflow 的答案中抽出代码来做了以下事情。

  • 写一个函数,对于任何大于2**63-1的哈希值,将数字转换为十六进制字符串。
  • 写一个相反的函数,将十六进制字符串转换为数字。
  • 将这些函数注册为SQLite数据类型的适配器,以便为该类型的每一列自动运行。

这样做是因为SQLite有一个非常灵活的内部类型系统,它基本上可以将任何东西存储为字符串,而不管官方的列类型如何。

python代码看起来像这样。

MAX_SQLITE_INT = 2 ** 63 - 1
sqlite3.register_adapter(
    int, lambda x: hex(x) if x > MAX_SQLITE_INT else x)
sqlite3.register_converter(
    'integer', lambda b: int(b, 16 if b[:2] == b'0x' else 10))

当你连接到数据库时,你可以告诉SQLite注意这些适配器,像这样。

conn = sqlite3.connect(dbfile,
    detect_types=sqlite3.PARSE_DECLTYPES)

然后你在SQLite中定义你的表。

CREATE TABLE IF NOT EXISTS hashes
    (hashval INTEGER NOT NULL,
    sketch_id INTEGER NOT NULL,
    FOREIGN KEY (sketch_id) REFERENCES sketches (id))

CREATE TABLE IF NOT EXISTS sketches
  (id INTEGER PRIMARY KEY,
   name TEXT,
   ...)

并且你可以做所有你想做的查询,大的整数将被转换为十六进制字符串,生活很美好。对吗?

这段代码实际上工作得很好!除了一个问题。

它非常慢。让关系型数据库(特别是SQLite)变得快速的一个关键是使用索引,而这些INTEGER列不能再作为INTEGER列被索引,因为它们包含了十六进制字符串这意味着,一旦数据库变大,那么,基本上搜索和检索的速度就太慢了,无法发挥作用。

这段代码功能完善,在一些提交中继续存在,但它的速度不够快,不能用于生产代码。

不幸的是(或幸运的是?),我现在就在其中。我已经在这个问题上投入了足够多的时间,也有足够多的有效代码和测试,所以我决定继续下去。见:沉没成本谬论

在SQLite中有效存储64位无符号整数

我可以把无符号的64位整数转换成有符号的64位数,把它们塞进数据库,然后在查询和哈希区间做等价测试。只要我有条不紊地进行转换,一切都会顺利进行!我最后写了两个适配器函数。

我最后写了两个适配器函数,我在Python代码中调用相关的值(不使用SQLite类型转换器注册表)

MAX_SQLITE_INT = 2 ** 63 - 1
convert_hash_to = lambda x: BitArray(uint=x, length=64).int if x > MAX_SQLITE_INT else x
convert_hash_from = lambda x: BitArray(int=x, length=64).uint if x < 0 else x

注意这里我使用的是可爱的bitstring包,这样我就不用费心思去处理比特的问题了(尽管现在我已经用测试锁定了一切,这也是一种可能的优化)。

我使用的SQL模式看起来像这样。

CREATE TABLE IF NOT EXISTS sketches
  (id INTEGER PRIMARY KEY,
   name TEXT,
   ...)

CREATE TABLE IF NOT EXISTS sourmash_hashes (
   hashval INTEGER NOT NULL,
   sketch_id INTEGER NOT NULL,
   FOREIGN KEY (sketch_id) REFERENCES sourmash_sketches (id)
)

我还建立了三个索引,与我想做的各种查询相对应。

CREATE INDEX IF NOT EXISTS sourmash_hashval_idx ON sourmash_hashes (
   hashval,
   sketch_id
)
CREATE INDEX IF NOT EXISTS sourmash_hashval_idx2 ON sourmash_hashes (
   hashval
)
CREATE INDEX IF NOT EXISTS sourmash_sketch_idx ON sourmash_hashes (
   sketch_id
)

我在这个PR中途做出的一个设计决定是允许在sourmash_hashes ,因为不同的草图可以与其他草图共享哈希值,我们必须这样做,或者有另一个中间表,将唯一的哈希值与潜在的多个sketch_ids联系起来。只是看起来更简单的是让哈希值不唯一,而是为可能的查询建立一个索引。(我以后可能会重新考虑这个问题,现在我可以无所顾忌地重构了;)。

在这一点上,现在插入很容易。

sketch_id = ...

# insert all the hashes
hashes_to_sketch = []
for h in ss.minhash.hashes:
    hh = convert_hash_to(h)
    hashes_to_sketch.append((hh, sketch_id))

c.executemany("INSERT INTO sourmash_hashes (hashval, sketch_id) VALUES (?, ?)",
              hashes_to_sketch)

而检索也同样简单。

sketch_id = ...

c.execute(f"SELECT hashval FROM sourmash_hashes WHERE sourmash_hashes.sketch_id=?", sketch_id)

for hashval, in c:
    hh = convert_hash_from(hashval)
    minhash.add_hash(hh)

因此,这对于在SQLite中存储草图是相当有效的!我可以完美地重建草图。我可以在通过SQLite进行往返后完美地重建草图,这是伟大的第一步。

下一个问题是:我能否以倒置索引的方式快速搜索哈希值?也就是说,我可以根据哈希值的查询来寻找草图,而不是(如上)使用sketch_id 来检索已经确定的草图的哈希值?

在SQLite中对64位无符号整数进行匹配

这最终是非常简单的!

为了查询哈希值的集合,我设置了一个包含查询哈希值的临时表,然后在精确值匹配上做一个连接。方便的是,这并不关心数据库中的值是否是有符号的--它只关心位模式是否相等!

代码,用于一个游标c。

def _get_matching_sketches(self, c, hashes, max_hash):
        """
        For hashvals in 'hashes', retrieve all matching sketches,
        together with the number of overlapping hashes for each sketch.
        """
        c.execute("DROP TABLE IF EXISTS sourmash_hash_query")
        c.execute("CREATE TEMPORARY TABLE sourmash_hash_query (hashval INTEGER PRIMARY KEY)")

        hashvals = [ (convert_hash_to(h),) for h in hashes ]
        c.executemany("INSERT OR IGNORE INTO sourmash_hash_query (hashval) VALUES (?)",
                      hashvals)

        c.execute(f"""
        SELECT DISTINCT sourmash_hashes.sketch_id,COUNT(sourmash_hashes.hashval) as CNT
        FROM sourmash_hashes, sourmash_hash_query
        WHERE sourmash_hashes.hashval=sourmash_hash_query.hashval
        GROUP BY sourmash_hashes.sketch_id ORDER BY CNT DESC
        """, template_values)

        return c

作为一个附带的好处,这个查询按照草图之间的重叠大小对结果进行排序,这导致了一些相当好的、高效的阈值处理代码。

基准测试!?

我只想说,性能绝对是可以接受的--下面的基准测试将sqldb与我们的其他数据库格式进行了比较。我们正在搜索的数据库是一个由48000个草图组成的集合,共有1.61亿个哈希值--GTDB RS202,如果你好奇的话:)。

对于53.9k的查询哈希值,在数据库中发现了19.0k,SQLite的实现是很好的,而且速度很快,尽管有很大的磁盘占用。

db格式数据库大小时间内存
sqldb15 GB28.2s2.6 GB
sbt3.5 GB2m 43s2.9 GB
压缩1.7 GB5m 16s1.9 GB

对于较大的查询,有374.6k的查询哈希值,而我们在数据库中找到189.1k的哈希值,性能稍微平衡了一点。

数据库格式数据库大小时间内存
sqldb15 GB3m 58s9.9 GB
sbt3.5 GB7m 33s2.6 GB
压缩1.7 GB5m 53s2.0 GB

请注意,压缩文件搜索根本不使用任何索引,所以搜索是线性的,预计无论查询什么,时间都会差不多。SBTs并不真正用于这种情况,但它们是我们拥有的其他 "快速搜索 "数据库,所以我还是对它们进行了基准测试。

(我们在这里做的事情有很多细微的差别,我想我主要是理解这些性能数字;我的想法见基准测试问题)。

真正好的是,对于我们的动机用例,即在反向索引中查找哈希值以与其他标签相关联,SQLite的性能比我们目前的JSON-on-disk/in-memory搜索格式好得多

对于53.9k的查询哈希值,我们得到。

LCA数据库格式db大小时间内存
SQL1.6 GB20s380 MB
JSON175 MB1m 21s6.2 GB

坦率地说,这是非常好的--对于8倍的磁盘大小,我们得到了4倍的查询速度和16倍的内存使用量(内存中的性能包括从磁盘加载,这是它如此糟糕的主要原因)。

进一步提高性能?

我在这次编码奥德赛中仍然相当疲惫(>250次提交,最后增加或改变了近3000行代码),所以我把一些工作留给未来。最具体的是,我们想以让多个读者同时从数据库中读取数据为基准,例如Web服务器后端。我希望它能在这方面工作得很好,但我们需要检查。

我确实使用了以下PRAGMAs进行配置,我想知道我是否应该花时间尝试不同的参数;这主要是一个围绕着写一次,读多次的数据库。欢迎提出建议:)。

PRAGMA cache_size=10000000
PRAGMA synchronous = OFF
PRAGMA journal_mode = MEMORY
PRAGMA temp_store = MEMORY

结论性的想法

上面的第二个解决方案是我当前拉动请求中的代码,我希望它最终会被合并到sourmash中并作为sourmash v4.4.0的一部分发布。它完全集成到了sourmash中(使用范围比我上面解释的要广得多;),而且我对它很满意。

总之,这就是我的SQLite探索之旅。欢迎提出意见