通过(差)实施一个来理解数据库索引
#database #性能 #ruby

对数据库索引有很多误解。这些之所以存在,部分原因是人们缺少需要想象数据库如何使用它们所需的上下文。有很多要学会建立这种情况。对于一篇博客文章来说太多了。但是,我们可以尝试引导已经熟悉的事情,以帮助建立更好的理解。

为此,我们将在Ruby中实现一个假数据库索引。这将是 不完整的,但仍然应该足以让您对发生的事情有所了解。

警告

您在这里看到的不是,实际,数据库索引的工作原理。这是一个极为粗略的近似值。我试图召集该近似值是无效的。如果您在实际数据库中遇到任何与您在这里看到的相匹配的任何内容,我鼓励您将其作为潜水和学习更深入的机会。

使事情变得非常简单

我们将构建伪造的索引,以淘汰不起眼的Ruby Hash。那些很熟悉,对吗?通过键和值存储数据,然后您以后可以通过提供密钥来检索值。如果您没有钥匙,那么您基本上只是与Array的更昂贵的变体一起工作。具有讽刺意味的是,在引擎盖下,Ruby Hash使用了许多与数据库指数相同的概念和数据结构。无论如何,这将是我们编写实际数据结构代码的替代品。

我们仅支持唯一索引。我们可能会支持非唯一的人,但可能会凌乱。我只是认为您会在这里教过很多东西。我们 Will 支持复合索引,并将介绍仅使用某些索引列的查询。

可能是将我们的索引与真正的索引分开的最大查询时间是缺乏对范围查询的支持。因此,我们的索引没有WHERE X > 0样式查询。我们之所以忽略这一点,是因为哈希不容易做到有效,而我认为实施它会告诉您直接价值查找不会。真实的数据库索引绝对能够处理许多不同的数据类型。

索引类

我们将从一个课程开始,我们将其命名为Index,这将是我们在这里代码的核心。我们将按照此Index类编写的Ruby代码来实现不同的SQL查询。

我们使用Index.declare在列列表上创建(空)索引。然后,我们可以通过循环浏览数据并调用Index#add来添加数据。

# Allow us to efficiently answer questions about a large amount of data based on
# specific column(s) in it.
class Index
  # The column this index is handling.
  attr_reader :column

  # The columns that come *after* this one in the index.
  #
  # If this list is empty, we're at the "end" of the index column list and
  # should store row ids as our Hash values.
  #
  # If it is *not* empty, we make an `Index` class that deals with those
  # columns and use it as our Hash value.
  attr_reader :subsequent_columns

  # The Hash that represents actual index content. I'm avoiding calling this
  # `data` because it's *not* the actual data we're indexing. Confusing
  # terminology.
  attr_reader :content

  def initialize(column, subsequent_columns = [])
    @column = column
    @subsequent_columns = subsequent_columns
    @content = {}
  end

  # Are we the final column of the index? If so, our answers should be data id
  # values instead of another `Index`
  def leaf?
    @subsequent_columns.empty?
  end

  # "Index" a piece of data. It's assumed that this data is functionally a Hash
  # that contains at least `:id` and whatever value we hvae for `column`.
  def add(data)
    value = data[column]
    if leaf?
      @content[value] = data[:id]
    else
      # If we are *not* the final column, create a new Index to represent the
      # slice of data that all shares the same value for our `column`. This
      # index should use the *next* subsequent column, and needs to know about
      # the *rest* of the subsequent columns in case it too is not the final
      # one.
      @content[value] ||= Index.new(subsequent_columns[0], subsequent_columns.drop(1))
      @content[value].add(data)
    end
  end
end

我们可以了解数据库索引

令人惊讶的是,就在这里,我们就可以对使用索引的重要推论进行一个重要而有用的推论。访问此数据的自然流将沿列规定的路径。我们的索引还可以回答涉及索引的列的问题。

很容易想象按列顺序导航,但是其他订单似乎是一个更大的挑战。数据库充满了巧妙的优化,有时有时可以做出排序的用法,但总的来说,您希望事情要及时发生。

样本数据

为了进行此操作,我们将处理从US Census Bureau City and Town Population Totals获取的样本数据,这是美国〜2万城市的列表,其估计人口。

出于本文的目的,我在CSV中有Cleaned it up,并提取了状态名称。

我们将在这里假设citystate列的组合使记录与众不同。对于此数据而言,这不是严格的 trim ,但再次使使用。

Harnass代码

以下代码足以使我们在IRB会话中开始。它假设上述代码段可作为./index.rb当地可用,并且可以在./city_populations_2022.csv上找到CSV。

require "csv"

load "index.rb"

# Load the CSV, converting integer values as we go
csv = CSV.read("./city_populations_2022.csv", headers: true, converters: [:integer, :all, :all, :all, :integer])

# Store our CSV in an Array where the values are hashes of the row
# data. This will simulate the actual database table.
data = csv.map(&:to_h); nil

# Declare an index on state and city, in that order
index = Index.declare("state", "city")

data.each do |row|
  index.add(row)
end; nil

如果我们要丢弃索引类,而 将事物视为嵌套的哈希,我们的索引看起来像这样:

{
  "California" => {
    "Los Angeles" => 1444 # 1444 is the row id for this city
  }
}

划分州和城市

我们将开始简单:鉴于一个城市和州,请查找行。我们将与加利福尼亚州洛杉矶一起尝试。在SQL中,这将是:SELECT * FROM populations WHERE state = 'California' AND city = 'Los Angeles'

state = index.content["California"]
city = state.content["Los Angeles"]

# Our `id` values don't exactly correspond to Array offsets, so we have to do this.
data[city - 1]

我们可以了解数据库索引

索引排序

请注意,我们如何使用state开始查找?那是因为这是索引的开始。

想象一下,如果我们试图首先从city开始。该代码会是什么样?它必须挖掘 state索引中的每个值才能进入城市,然后向后工作。

通常,您的数据库有效地做到这一点。涉及的数据太多了,仅跟踪您所看的所有内容可能会引起问题。另外,检查整个索引不会是快速操作。如果您没有更好的选择,它可能会采用此策略,但是您确实想给它更好的选择。

行查找

请注意,如何返回数据,我们必须转到data中存储的表?这称为“行”查找。实际数据库几乎可以肯定地分别存储索引数据和行数据,因此行查找具有其他我们需要小心的开销。

通常,优化SQL查询是一个尝试避免比严格必要的更多行查找的过程。

找到一个国家的总人口

好吧,现在让我们尝试另一个可能的任务:找到一个州的总人口。这次我们和爱达荷州一起去。在SQL中,这看起来像SELECT sum(population) FROM populations WHERE state = 'Idaho'

乍一看,它可能看起来并不像我们的索引在这里有所帮助,但仍然如此。这里的代码可以在没有索引的情况下获取此

sum = 0

# Let's keep track of how many times we had to go fetch a row. This is
# important, because row lookups are expensive.
rows_examined = 0

# Notice: we are visiting *every* row in the data. If we had millions or
# billions of rows, this would be really bad.
data.each do |row|
  rows_examined += 1
  next unless row["state"] == "Idaho"

  sum += row["population"]
end; nil

[sum, rows_examined] # => [1302154, 19692]

因此,我们得到了总和,可能在您的计算机上很快(提醒:这是很少的数据),但是我们必须查看数据中的每一行。通常,我们必须查看整个表中的每一行都是您可以看到数据库做的绝对最差的事情之一。

那么我们如何使用索引?我们在爱达荷州每个城市的名字都没有准备好列表,因此,一旦我们到达Idaho索引,我们就可以将其作为钥匙插入。但是,我们 do 有能力通过 value 遍历Hash。因此,我们仍然可以使用我们的索引来帮助我们到达爱达荷州,然后浏览其内容以找到总人口。

sum = 0

# Again, we're tracking rows
rows_examined = 0

state = index.content['Idaho']
state.content.values.each do |row_id|
  city = data[row_id - 1]
  rows_examined += 1
  sum += city["population"]
end; nil

[sum, rows_examined] # => [1302154, 199]

所以现在我们拥有相同的总和,但是我们查看了大约10%的行。那是一个巨大的赢。

我们可以了解数据库索引

数据库不仅在具有每个相关密钥的情况下使用索引。这是他们可以挖掘的数据结构,并且可以大大帮助。

有时他们会通过跳过中间钥匙去进入最后一排,就像我们在这里所做的那样。值得注意的是,这是可能的,因为我们的索引被定义为(state, city)。如果是(city, state),我们将不得不检查每个城市名称,以查看它是否在该州。通常,这仍然比爬每行数据更好,但远远不如我们刚刚体验到的那样。

当您定义复合索引时,S 确实很重要的是考虑最终可能只查询其中一些列的情况。正确获取列订单将最大化您从数据库维护索引工作中获得的值。

一个新的指数,用于更快的人口总数

让我们说这种人口查询非常重要,我们发现以上仅将行的10%访问到 stall 太慢,无法我们的需求。索引可以为我们做什么?

我们使用现有索引做了或多或少的所有事情。如果我们的系统支持非唯一索引,我们可以在state上制作一个索引,这将使我们直接跳入行,但它不会改变我们查看的行数。

让S Build 另一个索引,该索引将我们以前的索引扩展到人口值。因此看起来像(state, city, population)。这是如何:

population_index = Index.declare("state", "city", "population")

data.each do |row|
  population_index.add(row)
end; nil

因为state+city已经是唯一的,所以state+city+population也将是。

这是哈希的草图:

{
  "California" => {
    "Los Angeles" => {
      # NOTE: This "population" Hash will always be a single key (the
      # population) pointing to the row id.
      3898767 => 1444
    }
  }
}

此指数可以为我们提供总数而无需触摸一排!

sum = 0

state = population_index.content["Idaho"]

state.content.values.each do |city|
  sum += city.content.keys.sum
end; nil

sum # => 1302154

请注意,该代码中甚至没有提及data。我们从索引内容中回答 Just

我们可以了解数据库索引

由于我们的索引反映了基本数据,因此我们可以使用索引内容代替 实际数据。数据库使用此技巧很多,这是一个非常有效的优化。

通常可以肯定地假设您在磁盘上的数据不会以使任何特定查找有效的方式组织。在我们阅读199行以获取数据之前,可以肯定地假设这些行都以允许操作系统避免进行199个磁盘读取的方式彼此相邻。

相比,即使该索引序列化为磁盘,所有相关信息的所有相关位置都更加亲密。读取磁盘块很有可能使我们有一个相关的city 遇到了加载和缓存我们需要的其他城市。再加上我们的索引数据比实际行数据要小得多/更密集。因此,即使从磁盘上挖出所有内容都涉及较少的磁盘读取。

试图查找实际的城市记录时,同样的是跳过我们在最后一部分中可以使用的专栏技巧。因此,即使只有statecity,也可以从(state, city, population)到城市记录。该索引可以便利地服务于我们到目前为止看到的每个查询。

找到每个州的总人口

现在,我们将尝试处理此查询:SELECT state, sum(population) FROM populations GROUP BY state

停止!在您进一步阅读之前,我希望您考虑如何解决此问题。您现在有三个选择:

  • 浏览所有数据行
  • 尝试使用原始索引
  • 尝试使用人口指数

决定如何获取数据的行为称为查询计划。这是数据库工作方式的重要组成部分。深入到数据库性能中,您将不得不对数据库的查询计划解释非常熟悉。检查输出是帮助调试慢速查询并弄清楚需要进行哪些更改以使其不慢查询的关键方法。

在这种情况下,我们只有三个选项,并且(可能)相对容易选择哪一个是最好的。但是,让我们思考一下它们的查询计划者如何看待这一点。

如果我们为数据和索引读取分配成本,我们可以通过总成本来权衡我们的选择:

  • 走所有行:20,000个数据读取 + 0索引读取
  • 使用原始索引:20,000个数据读取 + 20,000索引读取
  • 使用总体索引:0数据读取 + 20,000索引读取(提醒:所有必需的数据都在索引中)

通常,假设索引读取比数据读取便宜。因此,我们想称重数据读取更高。真实数据库中的实际过程要复杂得多,但是在这里我们只是假设数据读数为5倍。

这给我们的总费用分别为:100,000 120,000和20,000。这意味着我们应该选择最后一个选择。

旁注:为了使其可访问,此成本计算非常天真。真实的数据库跟踪的信息要比有多少行,并且对数据的特征和存储方式的细节具有更详细的见解。想象一下,您花了多年的时间来完善这个概念来修复您的成本预测错误的情况,并且您更接近数据库的实际工作方式。

那么,我们如何找到每个国家人群?那又是一个直接的:

result = {}

population_index.content.each do |state, cities|
  result[state] = 0

  cities.content.values.each do |population|
    result[state] += population.content.keys[0]
  end
end; nil

result

我们可以了解数据库索引

再次,我们将索引用作信息的,而不仅仅是一种进入的方法。值得重申这一点,因为它在现实世界中经常出现。

,在模拟查询计划中,我们还看到了一种使用索引慢的情况,而不是阅读整个表。实际上,这些情况很少见,但可能会发生。有时,您会查看一个查询计划,并想知道为什么数据库忽略了索引,只是发现您缺少索引使事物降低的细节。在这种情况下,我们要求阅读整个桌子,但这绝对不是唯一的桌子。

我们还在result哈希中瞥见结果缓冲。同样,值得一提的是,在有太多数据以至于将此result存储在内存中的情况下,我们将做什么。

包起来

希望这有助于消除数据库索引。正如我一开始提到的,实际数据结构与我们在这里使用的内容有很大差异,但是我试图保持推理和思维过程一致。

尽管存在不准确,但您可以通过嵌套哈希的观点来漫步,从而对数据库索引的工作方式进行漫步。也许您心理模型的最佳扩展是想象一个哈希,其中还包括在钥匙上进行不平等比较的能力。就像存在Hash#lookup方法一样,将Ruby Range作为一个参数,并且可以有效地为您提供该范围内键的值。

如果所有这些使您对索引的内部的内在的外观感兴趣,则可以从研究B-trees开始。它们可能是为此目的最常用的数据结构。许多数据库都支持基于不同数据结构的替代索引类型,这是您真正开始深入研究每个索引的好处和缺点。

如果您想了解更多有关查询计划的信息,以及数据库如何处理选择算法以查找数据的主题,则不幸的是,这些数据对所涉及的数据库非常有特定。如果您使用MySQLPostgreSQL,我将与其文档的相关部分相关。由于数据库试图在很小一小部分(可能)的大量选择中生成最佳计划,因此查询计划变得有些毛茸茸,详细。

如果这使您对如何有效使用索引的兴趣达到顶峰,则Use the index, Luke是一个绝妙的资源。它甚至包括针对多种数据库类型量身定制的B树的简介和资源。