发布于 2026-01-06 1 阅读
0

FaunaDB 地理空间查询概述示例场景解决方案第 1 部分:地理哈希解决方案第 2 部分:FaunaDB

FaunaDB 上的地理空间查询

概述

示例场景

解决方案第一部分:地理哈希

解决方案第二部分:FaunaDB

无论你想使用Niemeyer 的原始地理哈希Google 的 S2还是Uber 的 H3FaunaDB灵活的查询语言都能让你轻松引入各种地理空间解决方案。

概述

这或许无需赘言,但我还是要强调:如果你的产品/组织没有利用地理空间数据,你就错失良机。即使是纯粹的互联网公司也会利用地理空间数据来辅助国际化、客户洞察等等。作为开发人员,当我们思考数据库中的地理空间功能时,我们常常会面临一系列类似的关键问题。

我的主数据库可以处理地理空间数据吗?还是我需要向我的堆栈中添加另一个数据库?

该数据库如何存储地理空间数据(如果有的话)?

可以进行哪些类型的地理空间查询?

这个数据库可以与我的主数据库并行扩展吗?

如果你的主数据库具备地理空间功能,即使你运气好,也仍然需要考虑这些功能可能并不适合你的工作;谷歌和优步之所以开发自己的地理空间解决方案,正是有原因的。如果你运气不好,就需要寻找一个“支持地理信息的”辅助数据库。当然,这会带来典型的扩展难题:集群、分布式、备份存储等等。此外,引入 ETL、API 层的另一个数据库驱动程序、与主数据库的容量差异等等,都会显著增加后端复杂性。所有这些潜在的错误都会导致产品和用户体验的后续问题(例如,配送进度数据过时,导致客户误以为订单还在很远的地方)。

在选择地理信息数据库时,我们往往倾向于选择传统数据库,而对其地理空间功能却缺乏深入了解。这是因为我们出于上述数据库痛点的考虑,优先考虑可维护性而非功能,毕竟,一个勉强能满足我们目标的数据库也比一个经常宕机或响应迟钝的数据库要好。因此,本文的主题就在于此:

使用FaunaDB,我们可以获得一个零维护的数据库,并且可以自由选择各种地理空间解决方案😮。

这里的“零维护”指的是我们开发人员无需担心分片、复制、集群、预留读写容量等等问题。我们只需创建一个数据库,所有的扩展和分发工作都会在后台自动完成。接下来,我将带您了解我的公司 Cooksto 如何使用FaunaDBGoogle 的 S2构建一个基于地理位置的自制食品市场。

示例场景

假设我们有 25 道菜在市场上出售,一位顾客只想浏览步行范围内(1000 米或以内)的食物。在这个规模下,计算顾客位置与所有菜品之间的距离并不是什么大问题,但是,为了便于演示,我们将考虑规模效应来构建系统。

替代文字我们的餐点用蓝色图钉和白色圆圈表示,用户/顾客用居中的红色图钉和字母“U”表示。灰色圆圈表示搜索半径(1000米)。我们的目标是返回搜索半径内的所有餐点。

解决方案第一部分:地理哈希

当根据特定属性检索数据库条目时,使用索引通常是一个好主意(例如,通过电子邮件查找用户)。然而,对区域/位置进行索引远比对电子邮件或用户名字段进行索引复杂得多,因为这类数据通常具有多维表示(例如,经纬度、多边形等)。将这些多维表示编码为单一字符串或“哈希值”正是地理哈希技术诞生的目的。

在本文中,我谨慎地使用了“地理哈希”(geohash)一词来指代许多地理空间索引解决方案(例如 S2、H3 等),因为它准确地描述了这些解决方案的功能。然而,“地理哈希”的确切含义属于最早的此类解决方案,即古斯塔夫·尼迈耶 (Gustao Niemeyer) 于 2008 年发明的一个库/系统。此后,谷歌发布了一种更精确的地理哈希算法(考虑到地球的球形结构),称为 S2。本文不会详细介绍 S2,如果您有兴趣了解更多信息,请参阅官方文档。以下两张图片简要概述了 S2 的工作原理。

替代文字来源:https
://s2geometry.io/devguide/img/s2curve-large.gif 简单来说,S2 将一个立方体及其六个面投影到我们美丽的星球——地球上。这六个投影面分别编号为 0 到 5,每个面都包含一个 S2“单元”层级结构。

替代文字来源:https
://s2geometry.io/devguide/img/s2hierarchy.gif 每个 S2 单元格(如上图所示的伪正方形)都以层级结构包含 4 个子单元格(最底层的单元格,即“叶单元格”,没有子单元格)。这种单元格层级结构的最大深度为 30 层,在第 30 层(空间的最小表示层)时,面积约为一平方厘米。

表示 S2 单元格的方法有很多种,但当与FaunaDB一起使用时,“希尔伯特四叉树键”(简称“四叉键”)是最佳表示方法;这是因为它能够很好地表示单元格的层级结构和嵌套关系,并带有前缀(稍后会详细介绍)。我最初是从一个名为s2-geometry的 npm 包中了解到这种表示方法的;简而言之,四叉键基本上是一个四进制数(即大多数数字只能是 0 到 3),只有前两个字符不属于四进制属性;第一个字符保留用于表示面(记住,面是一个介于 0 和 5 之间的数字),第二个字符只是一个正斜杠(即“/”),用于分隔面和单元格编号。以下是一个 S2 单元格的最大长度四叉键示例:

替代文字

由于采用四进制表示法,且 S2 单元格在每一层都分为 4 个子单元格,因此每增加一位数字,就代表 4 个子单元格中的 1 个,并且层级也随之增加。这使得我们可以对四元键进行前缀搜索,从而获得更广泛/更粗略的结果。让我们简要地研究一下第 5 层到第 9 层之间的这种前缀特性:

const { S2 } = require("s2-geometry");

const hawaii = {
  lat: 19.5968,
  lon: -155.5828,
};

console.log(
  "3/30222" === S2.latLngToKey(hawaii.lat, hawaii.lon, 5) &&
  "3/302221" === S2.latLngToKey(hawaii.lat, hawaii.lon, 6) &&
  "3/3022213" === S2.latLngToKey(hawaii.lat, hawaii.lon, 7) &&
  "3/30222133" === S2.latLngToKey(hawaii.lat, hawaii.lon, 8) &&
  "3/302221331" === S2.latLngToKey(hawaii.lat, hawaii.lon, 9)
); // true
Enter fullscreen mode Exit fullscreen mode

替代文字一张 S2 单元格覆盖夏威夷的图像,从第 5 层到第 9 层。夏威夷可以包含在长度为 7 的四元键中,如果我们把四元键扩展到 7 + i,其中i是某个正整数,那么岛屿的特定部分也可以包含在四元键中。

由于采用 4 进制表示法,并且 S2 单元格在每个级别都分为 4 个子单元格,因此每个添加的数字代表 4 个子单元格中的 1 个,并且级别增加 1。

请注意,较低层级实际上是其上方层级的前缀。此特性适用于所有层级。我们将利用这种前缀/嵌套机制来为餐点建立索引。

解决方案第二部分:FaunaDB

现在我们有了索引策略(对 S2 单元四元键/地理哈希进行前缀搜索),接下来让我们启动数据库资源。如果您还没有注册,建议您先在FaunaDB注册并创建一个数据库(例如$ fauna create-database my_db,创建数据库的方法有很多,而且真的非常简单)。

创建数据库之后,我们需要创建集合。请记住,我们提供的是一个自制食品市场,所以我们将集合命名为“meals”(即CreateCollection({ name: “meals”})。FaunaDB以嵌套文档对象的形式存储数据,每个文档对象都属于一个集合(层级结构类似于:数据库 > 集合 > 文档)。

数据

现在是时候填充我们的数据集了,接下来我将使用FaunaDB 的JavaScript 驱动程序。让我们创建 25 份距离示例客户 1500 米范围内的餐食:

const { query: q, Client } = require("faunadb");
const { randomCirclePoint } = require("random-location");
const { S2 } = require("s2-geometry");

const userCoordinates = { lat: 30.274665, lon: -97.74035 };
const searchRadius = 1000; // meters

// creates an array of 25 random points
const randomPoints = Array.from({ length: 25 }).map((num) =>
  randomCirclePoint(
    {
      latitude: userCoordinates.lat,
      longitude: userCoordinates.lon,
    },
    // * 1.5 to demonstrate excluding undesired results
    searchRadius * 1.5
  )
);

// creates an array of 25 meal objects in memory
const meals = randomPoints.map((point, i) => ({
  // usually something like "Marinara Pasta"
  name: `meal-${i}`,
  // ...
  // of course, in the real world, more attributes would go here
  // e.g. initNumServings, pricePerServing, etc.
  // ...
  pickupLocation: {
    // normally the meal's pickup address
    placeName: `placeName-${i}`,
    coordinates: { lat: point.latitude, lon: point.longitude },
    geohash: S2.latLngToKey(
      point.latitude,
      point.longitude,
      30 // max precision level, approximately a square cm
    ),
  },
}));

const client = new Client({
  secret: "<DATABASE_SECRET>",
});

// executes an FQL (Fauna Query Language) query
client
  .query(
    // creates a document in the "meals" collection
    // for each meal in the in-memory meals array
    q.Do(
      meals.map((data) =>
        // creates a document in the "meals" collection
        q.Create(q.Collection("meals"), {
          data,
        })
      )
    )
  )
  .then((data) => console.log(data))
  .catch((err) => console.log(err));

/*
Should console.log something like this:
[ { ref: Ref(Collection("meals"), "264247276731382290"),
    ts: 1588264691537000,
    data: { name: 'meal-0', pickupLocation: [Object] } },
  ...
  { ref: Ref(Collection("meals"), "264247276730322450"),
    ts: 1588264691537000,
    data: { name: 'meal-24', pickupLocation: [Object] } } ]
*/
Enter fullscreen mode Exit fullscreen mode

搞定!我们建立了一个全球分布式数据库,存储了我们所有的餐食信息。让我们再检查一遍,看看这个“餐食”数据库。

client
  .query(
    // maps over a page/array of 25 meal refs
    // e.g. Ref(Collection("meals"), "264069050122895891")
    q.Map(
      q.Paginate(q.Documents(q.Collection("meals"))),
      // given a ref, gets/reads the document from FaunaDB
      q.Lambda((ref) => q.Get(ref))
    )
  )
  .then((data) => console.log(data))
  .catch((err) => console.log(err));

/*
Should console.log something like this:
{ data: [
 { ref: Ref(Collection("meals"), "264069050122895891"),
   ts: 1588094720905000,
   data: [Object] },
 ...,
 { ref: Ref(Collection("meals"), "264145993260360211"),
   ts: 1588168099888000,
   data: [Object] }
] }
*/
Enter fullscreen mode Exit fullscreen mode

指数

显然,直接查询餐点集合会返回一个不想要的列表。请记住,我们的客户只想浏览“步行距离”(1000米)内的餐点。在FaunaDB中,当需要查找特定文档时,可以使用索引。索引允许我们根据文档的字段进行搜索和/或排序。例如,我们可以创建一个索引,将给定的字符串与餐点名称进行匹配,并仅返回匹配的餐点。在本例中,我们需要一个索引,用于返回特定位置内的餐点,更具体地说,是返回给定S2单元格内的餐点。

对于基于现有字段的基本匹配和排序,您可以直接创建一个针对这些字段的索引。但是,如果需要更复杂的匹配/排序,则决定如何何时返回特定结果的逻辑存储在索引绑定中。换句话说,给定一个源集合,索引绑定会在每个文档上提供一个或多个计算字段,供您进行搜索和/或排序。在本例中,我们希望计算一个地理哈希前缀列表以进行搜索。需要说明的是,除了标量值(例如字符串)之外,还可以对数组(我们这里就是这么做的)进行搜索/排序。让我们从索引绑定的“计算”逻辑开始:

const minPrefixLength = 3;

// example max “prefix”: 4/030202112231011311302131301203
// two items to note:
// 1) maxPrefixLength represents the entire geohash
// 2) for simplicity sake, I consider the entire geohash...
// as a “prefix” in this code.
const maxPrefixLength = 32;

// a range of integers from 3 to 32
const allPossiblePrefixLengths = Array.from(
  { length: maxPrefixLength - minPrefixLength + 1 },
  (_, i) => minPrefixLength + i
);

// given a meal object or variable:
// returns an array of geohash prefixes
const computePrefixes = (meal) =>
  q.Let(
    {
      geohash: q.Select(["data", "pickupLocation", "geohash"], meal),
      prefixLengths: q.Take(
        q.Subtract(q.Length(q.Var("geohash")), minPrefixLength - 1),
        allPossiblePrefixLengths
      ),
    },
    q.Map(
      q.Var("prefixLengths"),
      q.Lambda((prefixLength) => q.SubString(q.Var("geohash"), 0, prefixLength))
    )
  );

// testing computePrefixes with a single meal
client
  .query(
    q.Let(
      {
        meal: q.Get(q.Documents(q.Collection("meals"))),
      },
      {
        geohash: q.Select(["data", "pickupLocation", "geohash"], q.Var("meal")),
        prefixes: computePrefixes(q.Var("meal")),
      }
    )
  )
  .then((data) => console.log(data))
  .catch((err) => console.log(err));

/*
Should console.log something like this:
{ geohash: '4/030202112231011311302131301203',
 prefixes:
  [ '4/0',
    '4/03',
    '4/030',
     ...
    '4/0302021122310113113021313012',
    '4/03020211223101131130213130120',
    '4/030202112231011311302131301203' ] }
*/
Enter fullscreen mode Exit fullscreen mode

本质上,该绑定接收一个餐食文档并返回一个前缀数组。请注意,数组中的最后一个前缀可能是整个地理哈希本身(根据定义,它严格来说并非前缀)。现在,我们只需在索引创建器中引用该绑定即可,最终得到:

// ...
// the initial variables from above...
// ...

const createPrefixIndex = q.CreateIndex({
  name: "meals_by_geohash",
  source: {
    collection: q.Collection("meals"),
    fields: {
      prefixes: q.Query(q.Lambda(computePrefixes)),
    },
  },
  // terms are what we search on
  terms: [{ binding: "prefixes" }],
  // values are what get returned
  // i.e. a meal's geohash, ref, latitude, and longitude
  values: [
    {
      field: ["data", "pickupLocation", "geohash"],
    },
    {
      field: ["ref"],
    },
    {
      field: ["data", "pickupLocation", "coordinates", "lat"],
    },
    {
      field: ["data", "pickupLocation", "coordinates", "lon"],
    },
  ],
});

client
  .query(createPrefixIndex)
  .then((data) => console.log(data))
  .catch((err) => console.log(err));

/*
Should console.log something like this:
{ ref: Index("meals_by_geohash"),
  ts: 1588263734990000,
  active: false,
  serialized: true,
  name: 'meals_by_geohash',
  source: ...,
  terms: ...,
  values: ...,
  partitions: 1 }
*/
Enter fullscreen mode Exit fullscreen mode

不错🙂。我们再仔细检查一下工作,快速测试一下索引。

// most of the meals we uploaded earlier...
// share the same geohash up to this prefix...
// so we should see multiple results
const coarseGeohash = "4/030202112231";

// again, multiple meals will likely share this geohash...
// however, less meals will likely be returned
const finerGeohash = coarseGeohash + "1";

const readIndex = {
  coarseGeohash: q.Count(q.Match(q.Index("meals_by_geohash"), coarseGeohash)),
  finerGeohash: q.Count(q.Match(q.Index("meals_by_geohash"), finerGeohash)),
};

client
  .query(readIndex)
  .then((data) => console.log(data))
  .catch((err) => console.log(err));

/*
Should console.log something like this:
{ coarseGeohash: 19, finerGeohash: 6 }
*/
Enter fullscreen mode Exit fullscreen mode

询问

创建索引后,现在可以检索 S2 单元格内的餐点。您可能还记得,我们的客户希望浏览特定搜索半径内的餐点;为了满足这一搜索需求,我们可以选择不同的粒度级别。简单来说,我们的搜索半径是一个圆,我们需要找到一个或多个 S2 单元格来覆盖/填充这个圆。虽然使用更多 S2 单元格(可能大小/级别各异)可以更精确地模拟搜索“圆”的形状,但也会带来略高的成本。如果不需要极高的精度,从技术上讲,单个 S2 单元格即可覆盖我们的搜索半径,从而只需从索引中读取一次。如果中等精度即可,那么 4 到 8 个 S2 单元格应该就足够了,这样只需要(微不足道的)4 到 8 次索引读取或更少。这种趋势一直延续到 S2 单元格数量达到几十个时,此时虽然查询精度会非常高,但性能和成本也需要考虑。

覆盖搜索半径的过程过于复杂,不适合在此赘述。幸运的是,FQL 语言非常适合生成查询,这使得我们可以轻松编写高阶 FQL 函数。为此,我创建了一个名为faunadb-geo的 npm 包供我们使用。该包中的一个抽象 FQL 函数 GeoSearch,在底层使用了一系列匹配、范围和差异运算来生成最优的 FQL 查询。通常,数据库中的每次读取都会映射到单个 S2 单元格,但使用 FQL,我能够节省性能和成本;通过优化大量读取,使其包含多个单元格。我还提供了一些额外的优化/调整选项供您选择,这些选项会影响结果的精度,因此我建议您查阅文档/GitHub代码库以获取更多信息。以下是 GeoSearch 的一个使用示例。

const { Client, query: q } = require("faunadb");
const { GeoSearch } = require("faunadb-geo")(q);

const userCoordinates = { lat: 30.274665, lon: -97.74035 };
const searchRadius = 1000; // meters

client.query(
  q.Paginate(
    GeoSearch("meals_by_geohash", userCoordinates, searchRadius, {
      maxReadOps: 8,
    })
  )
);
Enter fullscreen mode Exit fullscreen mode

FaunaDB使用“读取操作”来计算每次读取的成本,这也是参数中所指的内容maxReadOps。FaunaDB 的定价主要基于按需付费,但也允许用户提前预订读取/写入操作及其他资源。有趣的是:使用基础定价,一个包含 100 次读取操作的完整地理查询(从读取索引到读取每个文档)的价格轻松低于 Algolia 的“专业版”搜索服务👏。只有当读取操作次数达到 300 次时,两者的成本才开始接近;即使如此,随着规模的扩大,FaunaDB 的基础定价仍然可以享受折扣。

我没有记录结果,而是提供了 GeoSearch 在多个实例/覆盖范围内的图像,以展示基于该maxReadOps参数的性能差异。请注意,实际使用的读取操作次数可能少于 maxReadOps 设置的值。

替代文字这张图展示了覆盖客户搜索半径的 S2 单元格和 S2 单元格范围。“范围”指的是一系列相邻/同级单元格,我们可以将它们合并到一次读取操作中。蓝色多边形代表我们希望从索引中读取的单元格/单元格范围,每个多边形对应一次读取操作。此特定覆盖范围的maxReadOps强度设置为 8。

替代文字绿色单元格/单元格范围代表成本效益差异,用于减少读取操作总数。此覆盖范围已maxReadOps设置为 16。

替代文字另一种覆盖方式,但maxReadOps设置为 32。

如您所见,搜索半径的设定可以根据我们期望的读取操作次数以及对结果精确度的要求而变化。在销售和推广自制食品的案例中,对精确度的需求并不一定高于对成本效益的追求。此外,利用faunadb-geoCalculateDistance中的函数,我们可以对搜索半径之外的结果进行 FQL 过滤(示例请参见 faunadb-geo 文档)。

总之,FaunaDB凭借其在部署和性能方面的灵活性,最终为我们找到了一个满足所有需求的解决方案。它能够满足我们对精度的需求,无论是全球范围的查询,还是精确到几平方厘米的区域,都能轻松应对。对于我们这个全球分布式数据库而言,规模大小都不是问题,因为“实例”的概念对开发者来说并不重要。此外,FaunaDB 慷慨的免费套餐和极具竞争力的按需付费模式也轻松满足了我们对成本效益的需求(在很多情况下,我们的地理查询成本甚至低于 Algolia 等其他方案!)。如果您有任何问题或意见,欢迎随时在FaunaDB 社区 Slack上与我联系。希望您喜欢这篇文章,也希望FaunaDB能像我一样让您感到兴奋!干杯!🍻

文章来源:https://dev.to/potato_potaro/geospatial-queries-on-faunadb-135e