ADB-04 时态与空间数据库

📅 预计 60 分钟 | ⭐ = 重要知识点 | 📌 中英术语见文末
🛠 建表/查询示例基于 SQLite,可直接运行;纯模型概念配文字推演


4.1 为什么需要时态数据库

一张会被"擦掉"的表

先想一个日常场景:一本纸质记账本,你记下"3 月工资 10000 元"。4 月涨薪到 12000,你习惯性地拿起橡皮把"10000"擦掉,写上"12000"。本子清爽了,但下个月你想回看"3 月到底发多少"——已经查不到了。传统关系表(如 SQL 的 UPDATE)干的正是"擦掉重写"这件事。

员工薪资、商品价格、汇率、库存、余额——这些业务数据几乎都随时间变化,可普通表只保存"当前值",历史被新值覆盖后永久丢失。最典型的例子就是调薪记录:一次涨薪产生一个历史事实,这个事实不能被抹掉,审计、劳动仲裁、工资回溯都要靠它。

CREATE TABLE emp_salary (
  emp_id    INTEGER,
  salary    INTEGER
);

INSERT INTO emp_salary VALUES (1, 10000);
UPDATE emp_salary SET salary = 12000 WHERE emp_id = 1;  -- 覆盖!

SELECT * FROM emp_salary;
-- 输出: 1 | 12000   (3 月的 10000 没了)

时间不是"附带字段",是一等公民

时态数据库(temporal database)的核心思想:把时间作为一等公民存进数据库。每条记录不只回答"现在是多少",还能回答"过去某时刻是多少、将来某时刻是多少"。注意,它解决的问题不是"要不要记日志",而是历史是业务本身

  • 工资审计:回看某员工过去 5 年每月工资,核对涨幅是否合规;
  • 电商定价:促销结束后要能证明"这个商品上周卖什么价";
  • 银行账务:复现某个时间点的账户余额,追溯错误交易。

如果每次变化都覆盖旧值,上面这些问题全部无解。时态数据库的应对方式是只增不改:变化不是修改旧行,而是追加新行,并给每一行标注"它从什么时间开始有效、到什么时间为止"。同样是调薪,正确做法是不更新,插入

CREATE TABLE emp_salary_v2 (
  emp_id    INTEGER,
  salary    INTEGER,
  eff_from  DATE,        -- 生效起始日
  eff_to    DATE         -- 生效结束日(NULL = 至今)
);

INSERT INTO emp_salary_v2 VALUES (1, 10000, '2024-01-01', '2024-02-29');
INSERT INTO emp_salary_v2 VALUES (1, 12000, '2024-03-01', NULL);  -- 新行而非覆盖

SELECT salary FROM emp_salary_v2
WHERE emp_id = 1 AND '2024-02-01' BETWEEN eff_from AND COALESCE(eff_to, '9999-12-31');
-- 输出: 10000   (历史还能查回来)

对比开头的 UPDATE 示例:同样的业务,emp_salary_v2 多了起止时间、加了新行,却同时赢得了"当前值"和"历史值"。这就是时态数据库"用一列时间换回一条完整时间线"的取舍。

当然,时态不是免费的:历史数据只增不减,表会持续膨胀;查询要写区间条件,比普通 WHERE 复杂;历史一旦写错,修起来要额外的小心。所以是否用时态,取决于"历史是不是业务数据本身"——薪资、价格、余额这些"改动了就是新事实"的数据值得,临时缓存、草稿这类覆盖无所谓的数据不必。这是时态建模前要先想清楚的问题。

💡 记忆口诀:普通表是"记账本 + 橡皮擦",时态数据库是"带时间轴的流水账"——只增不改,随时可回放。

⚠️ 常见错误

  1. 以为加一列 update_time 就算时态表:那只是"最后修改时间",仍然回答不了"3 月 5 日当天工资是多少"——修改前的值已经没了。
  2. UPDATE 维护带历史需求的数据:工资调整这种"一次改动产生一个历史事实"的数据,必须插入新行而非覆盖旧行。
  3. 把"时间"当普通文本存'2024-03' 字符串没法做区间比较和加减,时间要存在真正的日期/时间类型里。

4.2 时态数据模型:有效时间 vs 事务时间 vs 双时态 ⭐

一件事有两个时间坐标

想象你收到一张迟到的调薪通知单:"工资自 3 月 1 日起调整为 12000",但财务 3 月 5 日才把这条录进系统。这一条工资记录,其实有两个完全不同的时间:

  • 有效时间(valid time):事实在现实世界中成立的时间段。这里是 3月1日——工资真的从那天起变了,与系统何时记录无关。
  • 事务时间(transaction time):该事实进入数据库的时间。这里是 3月5日——系统何时知道并记下这件事。

有效时间回答"现实中何时如此",事务时间回答"数据库何时知道"。两者可能一致,但经常不一致:迟到录入(上例)、补录历史、事后修正,都会让它们错开。这个区分是时态数据库的第一个分水岭。

CREATE TABLE salary_history (
  emp_id    INTEGER,
  salary    INTEGER,
  valid_from  DATE,   -- 有效时间起点
  valid_to    DATE    -- 有效时间终点(NULL = 至今仍有效)
);

INSERT INTO salary_history VALUES
  (1, 10000, '2024-01-01', '2024-02-29'),
  (1, 12000, '2024-03-01', NULL);

-- 问:3 月 1 日那天,员工 1 的工资是多少?
SELECT salary FROM salary_history
WHERE emp_id = 1
  AND '2024-03-01' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');
-- 输出: 12000

双时态:两套时间叠在一起

双时态(bitemporal) = 有效时间 + 事务时间都保留。一条记录同时带两对时间列(valid_from/valid_totx_from/tx_to)。它最强,能回答一类更刁钻的问题:"在 4 月时,系统认为 3 月的工资是多少?"

展开说:3 月 5 日系统录入"3 月 1 日起工资 12000",4 月 10 日发现录错了——实际是 13000,于是修正。此时"现实"和"系统认知"发生了分裂:

  • 现实(有效时间):3 月 1 日起就是 13000;
  • 系统认知(事务时间):3 月 5 日~4 月 9 日期间,系统以为 3 月的工资是 12000;4 月 10 日后才知道是 13000。

双时态表用两对时间列同时表达这两条线:

CREATE TABLE salary_bi (
  emp_id    INTEGER,
  salary    INTEGER,
  valid_from  DATE,   -- 现实线(有效时间)
  valid_to    DATE,
  tx_from     DATE,   -- 系统线(事务时间)
  tx_to       DATE
);
-- 记录 1:系统 3 月 5 日录入,认为 3/1 起 12000
-- 记录 2:系统 4 月 10 日修正,确认 3/1 起实际 13000

只有双时态能同时追溯这两条线。代价是存储翻倍、查询更复杂,所以现实中除非有"审计系统认知历史"的硬需求(金融、档案、合规),否则很多系统只用有效时间就够了。

时态数据库的四级分类

按"保留哪些时间",时态数据库分四类(Snodgrass 提出的经典框架):

类型 保存有效时间 保存事务时间 能回答的问题
快照数据库(snapshot) 只有当前值
历史数据库(historical) 现实中过去是什么样
回滚数据库(rollback) 数据库过去是什么样
双时态数据库(bitemporal) 两者都能回答

用两个场景把四类区分实一点:

  • 历史数据库:员工薪资系统。问"2024 年 2 月他工资多少",答案只取决于现实时间,跟哪天录进去没关系——用有效时间即可。
  • 回滚数据库:审计场景下问"上周五系统里看到的库存是多少"。这问的是系统当时的状态,不是现实——只靠有效时间答不出来,必须靠事务时间。
  • 快照数据库:普通业务系统,没人关心历史,覆盖就覆盖——什么都不存。
  • 双时态:既要回答现实、又要回答系统认知,两类问题都要——两对时间列都上。

💡 记忆口诀:有效时间管"现实",事务时间管"数据库";只记现实 = 历史库,只记录入 = 回滚库,都记 = 双时态,都不记 = 快照库。

⚠️ 常见错误

  1. 把"查询时间"当"事务时间":你 4 月去查一条 3 月录入的记录,查询时间不是事务时间——事务时间是"数据入库那一刻"的系统时间。
  2. 认为只要建了 valid_from/valid_to 就是双时态:那只是有效时间建模(历史数据库)。双时态必须两对时间列并存。
  3. 有效时间区间口径不统一:区间是"左闭右开"还是"闭闭",全库必须一致,否则 3 月 1 日到底归哪条记录会算错(本讲示例统一用闭区间 + BETWEEN)。

4.3 时间表示与时间演算 ⭐

时间点 vs 时间区间

想象你的日程:一个"今天下午 3 点开会"是时间点(time point),一个"下午 2 点到 4 点"是时间区间(time interval)。数据库里的时间,要么是某个时刻(瞬时),要么是一段持续期(区间)。工资"自 3 月 1 日起生效"其实是区间概念——[3月1日, 至今]

为什么区间这么重要:现实世界的事实几乎都有起止。薪资、职务、订阅、会员,全是区间。而且区间比时间点多一层复杂性——两个区间之间不只是"谁前谁后",还有几十种相对位置,需要用专门的关系代数来描述。

Allen 区间代数:13 种关系的骨架

Allen 区间代数(James Allen 提出)用 13 种关系描述两个区间在时间轴上的相对位置(7 种基本关系 + 6 种逆关系),最常用的 7 种如下:

关系 含义 例子(A vs B)
before / after 完全在前 / 完全在后 A 在 B 之前发生
meets 首尾相接 A 结束时 B 正好开始
overlaps 部分重叠 两段任职时间交叉
during 一个在另一个内部 一次出差在任职期间内
starts / finishes 起点相同 / 终点相同 任期与项目同起点
equal 完全相同

理解这几种关系,就够建模绝大多数业务判断:查"两份兼职是否时间冲突"就是判断两个区间是否 overlaps;查"该员工是否在项目期内在职"就是 during

完整的 Allen 区间代数有 13 种关系:上面 7 种里,除了 equal 之外每种都有一个逆关系(如 before 的逆是 after、meets 的逆是 met-by、overlaps 的逆是 overlapped-by、during 的逆是 contains),equal 与自己互为逆——所以 7 + 6 = 13。考试不需要背全 13 种名字,记住"关系成对出现、方向互换"这个规律即可,真正动手写 SQL 时也只用得上其中少数几种。

-- 判断区间 A=[3月1日,10月31日] 与 B=[5月1日,12月31日] 是否重叠
-- 重叠条件:startA <= endB AND startB <= endA
SELECT '重叠' AS 判断
WHERE '2024-03-01' <= '2024-12-31' AND '2024-05-01' <= '2024-10-31';
-- 输出: 重叠

时间演算还包括对时间本身的运算:时间点可以比较先后(<>)、相减得时长("两个日期差几天")、加减一个量得新时间("三个月后")。SQL 里这些都有现成函数,SQLite 用 julianday() 把日期转成可减的天数:

SELECT julianday('2024-03-31') - julianday('2024-03-01') AS 相差天数;
-- 输出: 30.0

SELECT date('2024-03-01', '+3 months') AS 三个月后;
-- 输出: 2024-06-01

概念上记住一句话:时间是可加减、可排序、可比较的量,和数字一样有算术与大小关系,只是单位特殊(天、月、年,闰年还掺和一脚)。

当前表 + 历史表:最常见的工程实现

教材讲时态建模,工程里最朴素也最稳的模式是一张当前表 + 一张历史表:当前表永远只有最新值(读起来快),历史表追加每次变更(回放全貌)。更通用的做法是"单表 + 起止时间列"(上节 salary_history),用 valid_from/valid_to 表达每条记录的有效区间。

CREATE TABLE emp_cur (          -- 当前表:只存最新薪资
  emp_id INTEGER PRIMARY KEY,
  salary INTEGER
);
CREATE TABLE emp_hist (         -- 历史表:每次变更追加一行
  emp_id    INTEGER,
  salary    INTEGER,
  valid_from DATE,
  valid_to   DATE
);

-- 调薪流程(示意):
-- ① 当前表:UPDATE emp_cur SET salary=12000 WHERE emp_id=1;
-- ② 历史表:INSERT INTO emp_hist VALUES (1,12000,'2024-03-01',NULL);
-- ③ 旧记录收尾:UPDATE emp_hist SET valid_to='2024-02-29'
--    WHERE emp_id=1 AND valid_to IS NULL;

查询"某时刻的薪资"时,在历史表里按"目标时间落在哪个区间"过滤——本质就是区间 overlaps / during 判断:

-- 查询员工 1 在 2024-02-15 的薪资(走历史表)
SELECT salary FROM emp_hist
WHERE emp_id = 1
  AND '2024-02-15' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');
-- 输出: 10000

-- 查询员工 1 的完整调薪轨迹(按时间排序,一目了然)
SELECT valid_from, valid_to, salary FROM emp_hist
WHERE emp_id = 1 ORDER BY valid_from;
-- 输出: 2024-01-01 | 2024-02-29 | 10000
--       2024-03-01 | (NULL)      | 12000

这个"当前表读最新、历史表回放过去"的组合,是真实系统里最不依赖特殊数据库、也最容易维护的时态方案——普通 SQLite、MySQL 都能实现,只是"起止时间列"和"区间过滤"这两件事要自己写对。

⚠️ 常见错误

  1. 时间点 / 区间概念混用:"3 月 1 日"单看是时间点,但"工资自 3 月 1 日起"表示的是区间 [3月1日, ...),语义完全不同。
  2. 重叠判断只写一边:判断两区间重叠必须两边都满足 startA <= endB AND startB <= endA,只写一半会把"一个包含另一个"的情况漏判。
  3. valid_toNULL 处理混乱valid_to = NULL 表示"至今",比较时必须 COALESCE 成一个大日期(如 9999-12-31),直接拿 NULL 做比较永远不为真。

4.4 时态查询语言 TSQL2

在普通 SQL 上"长出"时间语义

TSQL2 是时态数据库的标准化查询语言(源自 Snodgrass 主持的 TSQL2 语言设计)。它的思路不是发明一套全新语言,而是在 SQL 之上扩展时态关键字,让数据库自动管理时间维度。核心语法是 VALID 子句

  • 声明表为时态表(temporal table),加时间粒度;
  • 查询时用 VALID 指定按有效时间回答;
  • VALID '日期' 表示"只看该时刻有效的版本"。
-- 概念示例(TSQL2 语法,非标准 SQLite 语法):
SELECT emp_id, salary
FROM   salary_history
VALID  '2024-03-01';      -- 只看 2024-03-01 当天有效的工资版本

-- 等价于普通 SQL 手写:
SELECT emp_id, salary FROM salary_history
WHERE '2024-03-01' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');

时态语义:时间过滤从业务逻辑挪进语言层

关键理解:时态 SQL 把"时间过滤"从业务逻辑挪进了语言层。用普通 SQL 你得自己写区间比较,每张表、每个查询都要重复;而时态 SQL 写 VALID 子句,数据库自动把有效区间参与判断——像给查询统一装了个"时间切片器"。

为什么 WHERE 日期 = ? 不够:因为一条记录的有效期是区间,目标时刻落在区间内即命中,不是简单的等值比较。时态 SQL 的价值就是把这个区间语义标准化,避免每个项目各自发明一套写法。

从"当前值"到"某时刻快照"

需求"查询 3 月 1 日的工资"翻译成时态语义是:构造 3 月 1 日的快照(snapshot)——对每条工资记录,看它的有效区间是否覆盖 3 月 1 日。这类"任意时刻回放"是时态查询的典型模式,TSQL2 用 VALID 一个词就表达完整。

时态查询还能表达更多:WHEN 子句限定事件发生时刻、时态聚合("按有效时间分组统计")、时态连接(两张时态表连接时同时满足双方区间条件)等。概念上记住一条主线即可:凡是"某时刻"、"某段时期"、"从某时到某时"的查询,都是时态语义的用武之地。

再补充两个 TSQL2 的进阶概念,理解即可:

  • 时态表声明:建表时声明该表"记录有效时间"(AS VALID),数据库就知道哪些列是时间列,自动维护区间,不用应用层每次手写。
  • 时态聚合(temporal aggregation):比如"统计 2024 年每月在职人数",普通 SQL 要自己把区间切成月份再逐段数,时态 SQL 用一个聚合语句表达"按有效时间分桶统计",数据库负责把重叠区间拆开、去重、求和。

这两个概念说明一件事:时态 SQL 的目标是把时间维度"内置化",让开发者写"想查什么",而不是写"怎么处理时间"。需要说明的是,TSQL2 是 90 年代的设计,实际商业产品并未完全采纳(各厂商用各自的扩展,如 SQL Server 的 temporal table、PostgreSQL 的 range 类型),但它确立的"有效时间 / 事务时间 / VALID 语义"这套概念,是所有时态方案共同的理论底座。理解 TSQL2,就拿到了读懂任何时态产品的钥匙。

把同一个需求分别用普通 SQL 和时态 SQL 写一遍,区别一目了然:

-- 需求:3 月 1 日当天,员工 1 的工资
-- 普通 SQL:手动处理区间(每张表、每个查询都要重复这套)
SELECT salary FROM salary_history
WHERE emp_id = 1
  AND '2024-03-01' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');

-- 时态 SQL:数据库自动按有效区间切片
SELECT salary FROM salary_history VALID '2024-03-01' WHERE emp_id = 1;

普通 SQL 的写法没错,但"区间、闭开区间、NULL 收尾"这些细节散落在每个查询里,稍不留神就写错;时态 SQL 把这一整套约定收敛成 VALID 一个词。理解了这个对比,就掌握了 TSQL2 的设计动机。

💡 记忆口诀VALID = 时间切片器——一句 VALID '日期',把数据库"切"到那一天的截面来看。

⚠️ 常见错误

  1. 以为 TSQL2 是标准 SQLite 可直接跑的语法:TSQL2 是概念标准,本讲示例已标注"概念"二字,实机运行要用等价的手写区间查询。
  2. 把时态查询当成普通等值过滤WHERE valid_from = '2024-03-01' 查的是"恰好这天开始生效",不是"这天有效"——后者要求目标日落在区间内。
  3. 忽略时态表连接时的语义:两张时态表连接时,要同时满足双方的区间条件,只过滤一边会产生重复或遗漏。

4.5 空间数据模型 ⭐

地图上的三种"形状"

打开地图 App:你的位置是一个,回家的路是一条线,小区占地是一块。空间数据的基本对象就这三种:

  • 点(point):无大小,一个坐标,如餐厅、公交站、你的定位。
  • 线(line / linestring):一串有序坐标,如道路、河流、地铁线路。
  • 面(polygon):封闭的坐标环,如城市边界、湖泊、店铺服务范围。

这三种对象几乎能表达地图上的任何东西,更复杂的形状(省界这种凹凸不规则的轮廓)由多个简单形状组合而来——所以"点线面"是空间建模的原子积木。日常见到的 GeoJSON、Shapefile 等格式,底层就是把这些坐标序列标准化地写出来(如 POINT(106.5 26.6)LINESTRING(...)POLYGON(...)),数据库解析后交给空间函数处理。

这些形状在数据库里存什么?本质是坐标序列:点存一个坐标对,线存一串坐标对,面存一个闭合的坐标环。坐标的载体有两种常见选择:

  • 经纬度(地理坐标):适合跨区域、跨国家的数据,如城市、国家边界,需要考虑地球曲率;
  • 平面直角坐标(投影坐标):适合小范围、局部地图,如一个城市内的路网,距离计算直接用勾股定理,简单快。

关系表把坐标当字符串或二进制存起来不透明,空间数据库则把几何形状封装成空间数据类型(如 PostGIS 的 geometrygeography),并提供专门的函数来操纵它。类型选择也影响索引和距离算法,这是空间建模的第一个决策点。

空间数据类型与操作

空间数据模型在关系数据库上的落地,是定义空间数据类型和空间操作函数。按 OGC(开放地理空间联盟)/ SQL/MM 标准,常见操作分两类:

① 空间关系(返回真/假)

关系 含义 生活例子
intersects 相交(有公共点) 这条路穿不穿过这个区
contains 包含 小区包含那个餐厅吗
within 在内部(包含的反方向) 餐厅在小区的服务圈内吗
touches / adjacent 相邻(边界相接) 两个省有没有接壤
disjoint 完全分离 两片森林不相交
distance 距离(返回数值) 家和公司隔几公里
-- 概念示例(PostGIS 风格,SQLite 原生无空间类型):
-- SELECT name FROM restaurant
-- WHERE ST_DWithin(geom, ST_MakePoint(106.5, 26.6), 1000);  -- 1 公里内

② 空间运算(返回新的形状或数值)

函数(PostGIS 风格) 作用 返回
ST_Buffer(geom, d) 向外扩张一圈 新面
ST_Union(a, b) 合并两块区域 新面
ST_Area(geom) 算面积 数值
ST_Centroid(geom) 求中心点
ST_Length(geom) 算线的长度 数值

这些函数组合起来能表达很复杂的空间业务,比如"找出距离学校 500 米以内的所有 24 小时便利店",就是一次 within / 距离判断 + 类型过滤:

-- 概念示例(PostGIS 风格):
-- SELECT name FROM convenience_store
-- WHERE ST_DWithin(geom, (SELECT geom FROM school WHERE id=1), 500)
--   AND open_24h = TRUE;

一条语句同时用了"距离判断"和"属性过滤"——这正是空间数据的常态:一半是几何,一半是普通业务属性。空间数据库的价值就是把这两半揉在一起,让开发者不用自己写坐标解析和距离公式。

💡 记忆口诀:空间关系像"两个图形叠纸片"——相交看有没有公共点,包含看谁装下谁,距离看俩中心隔多远。

⚠️ 常见错误

  1. 用经纬度的 = 判断"同一位置":两个坐标差 0.0001 度实际可能差 10 米,位置接近要用距离阈值判断,不要用相等。
  2. 点 / 线 / 面混存一列:同一空间列必须类型一致,混合存储会导致空间索引失效、函数报错。
  3. 算距离当直线距离:球面上经纬度距离不是平面直角坐标的欧氏距离,粗算可以,精确计算要用球面距离公式。

4.6 空间索引:为什么 B 树不香了 ⭐

B 树只懂"一维排队"

普通索引(B 树)的原理是把数据按某列值排成一行,查找像翻字典。这对"找工资 = 12000"这种一维等值/范围查询很合适。但空间查询是二维问题:"找 1 公里内的餐厅"没有自然的"一维顺序"——按经度排,纬度邻近的餐厅可能隔很远;按纬度排也一样。B 树无法表达"二维邻近"。

更本质的问题:B 树只能回答"某列值在某个区间",而空间查询问的是"某个形状在某个区域附近"。于是要么扫描全表对每条记录算距离(几十万条,太慢),要么想专门的空间索引。

思路一:网格索引(grid index)

把地图切成等大的小格子,登记每个格子里有哪些对象。 查询时先定位目标格子和它周围的格子,只在这些格里找,不用遍历全地图。

  ┌───┬───┬───┬───┐
  │ A │ B │   │   │    查询"1 公里内的餐厅":
  ├───┼───┼───┼───┤    1. 算出你所在格子(★)和周围 3×3
  │   │ ★ │ C │   │    2. 只查这 9 个格子里的餐厅
  ├───┼───┼───┼───┤    3. 再逐个算精确距离,剔除超出 1 公里的
  │   │   │   │ D │
  └───┴───┴───┴───┘

网格索引简单直观,但格子大小难调:格子太大,一个格子里对象太多,过滤效果差;格子太小,索引本身存储开销大。它适合分布较均匀的数据,不均匀时会出现"一个格子挤满、周围空空"的情况——市中心餐厅扎堆,郊区一整片空格子,索引空间浪费了,市中心那格还得全查。于是有了更自适应的树形方案:R 树。

思路二:R 树——用"盒子"装数据

**R 树(R-tree)**是空间索引的主流(PostGIS、SpatiaLite 等空间数据库都用它)。核心思想:用最小外接矩形(MBR, minimum bounding rectangle)把邻近的对象"打包",矩形再套矩形,形成树

        ┌─────────────────────────────┐
        │       MBR 根节点(覆盖全图)    │
        └───────────┬─────────────────┘
        ┌───────────┴───────────┐
   ┌────┴─────┐           ┌─────┴─────┐
   │ MBR1     │           │ MBR2      │
   │ (3家餐厅)  │           │ (2家餐厅)   │
   └────┬─────┘           └─────┬─────┘
       A B C  (叶子:具体对象)      D E

查询"找 1 公里内的餐厅"分两步:

  1. 粗筛:从根开始,判断查询范围是否和节点的 MBR 相交。不相交的整棵子树直接剪掉(prune)——这一判断只需几个坐标比较,极便宜;
  2. 精查:落到叶子后,对候选餐厅逐个算真实距离,剔除超范围的。

插入新餐厅时,R 树会把新点放进"矩形扩张最小"的那个分支,必要时触发节点分裂——把过大的矩形一分为二。整棵树自下而上生长,越上层矩形越大、覆盖越广,越下层矩形越小、对象越具体,天然适应数据密度不均:市中心矩形小但密,郊区矩形大但稀。

R 树和 B 树的结构神似(都是平衡树),但节点里存的是"矩形区域"而不是"值的区间",且兄弟节点之间允许空间重叠——这是二维空间的必然代价:B 树的值永不重叠(总能在两值间二分),而二维矩形没法保证不交叠。

💡 记忆口诀:B 树管一维、R 树管二维;R 树 = "先拿大盒子框,框不上的整片不要,框上的再逐个量"。

回到"1 公里内的餐厅"

这个查询正好串起本讲:没有索引要遍历全市几十万条餐厅记录算距离;有了网格 / R 树索引,先一步定位到"你周围一小块",计算量降几个数量级。走一遍 R 树的执行过程:

输入:你在 (106.5, 26.6),半径 1000 米
1. 构造查询矩形:以你为中心、2 公里见方的正方形(粗筛范围)
2. 从根节点开始:根 MBR 覆盖全图 → 一定相交,继续下探
3. 对每个子节点:查询矩形与节点 MBR 不相交?整棵子树跳过
   (比如"城东"节点的 MBR 完全在你西侧,直接不要)
4. 进入候选的叶子:取出其中的餐厅,逐个算真实距离
5. 距离 <= 1000 米的留下,其余剔除

空间索引的本质是"先缩小候选集,再精确验证"——粗筛负责砍掉无关对象,精查负责保证结果正确。没有索引时是"50 万家餐厅全部算一遍距离",有索引后是"先看几十个矩形,再算几个候选"。

网格 vs R 树的取舍:网格实现简单、分布均匀时很高效,但格子大小靠拍脑袋;R 树自适应数据密度、查询稳定,是主流空间数据库的默认选择,代价是实现复杂。做方案时,数据稀疏均匀用网格够用,数据密集成簇、查询并发大就用 R 树。

最后提醒一点:空间索引和普通索引不是二选一。一张餐厅表可以同时有 B 树索引(按评分、按店名)和 R 树索引(按位置),查询优化器会根据查询条件选择——"找评分最高的川菜馆"走普通索引,"找我家附近的川菜馆"走空间索引,二者各司其职。空间建模不是"不用普通索引",而是"在需要二维邻近时,额外给位置一个空间索引"。

⚠️ 常见错误

  1. 把 B 树建在经纬度列上当空间索引用:B 树只能做单维比较,做不了"二维邻近",查询依旧慢。
  2. 建了索引但查询写法不命中:空间函数(如 ST_DWithin)要写在能利用索引的写法里,把几何函数套在列外面会强制全表扫描。
  3. 以为 R 树结果一定精确:R 树只保证"候选不漏",粗筛可能带出假阳性,精确距离必须在最后精算,跳过精查会出错。

🧠 记忆口诀

  1. 时态数据库:普通表是"记账本 + 橡皮擦",时态库是"带时间轴的流水账"——只增不改,随时回放。
  2. 两种时间:有效时间管"现实",事务时间管"数据库";有效时间问"何时如此",事务时间问"何时知道"。
  3. 四级分类:只记现实 = 历史库,只记录入 = 回滚库,都记 = 双时态,都不记 = 快照库。
  4. 区间关系:重叠判断两边都要 startA <= endB AND startB <= endA
  5. 时态 SQLVALID = 时间切片器——一句 VALID '日期' 把库切到那一天。
  6. 空间对象:点、线、面三兄弟;关系看"相交、包含、相邻、距离"。
  7. 空间索引:B 树管一维、R 树管二维;R 树 = 大盒子粗筛 + 逐个精查。

⭐ 考点清单

  1. 时态数据库的动机:传统表 UPDATE 覆盖历史,业务需要"任意时刻回放"。
  2. 有效时间 vs 事务时间:现实中成立的时间 vs 数据入库的时间,给出场景要会判断。
  3. 双时态(bitemporal):两对时间列并存,能回答"历史上系统怎么认为"。
  4. 时态数据库四分类:快照 / 历史 / 回滚 / 双时态,各自保留哪些时间。
  5. 时间点 vs 时间区间;Allen 区间代数的常用关系(before、meets、overlaps、during…)。
  6. 有效时间建模的 SQLite 建表:valid_from/valid_to + BETWEEN 查询某时刻值。
  7. 区间重叠判断条件:startA <= endB AND startB <= endA
  8. TSQL2 的 VALID 子句语义:按有效时间切片的时态查询。
  9. 空间对象类型:点 / 线 / 面;空间关系与操作(intersects、contains、within、touches、distance、buffer、union…)。
  10. 空间索引动机:B 树单维排序无法表达二维邻近。
  11. 网格索引:切格子 + 查周围格子 + 精确距离复核。
  12. R 树:最小外接矩形(MBR)分层打包,粗筛剪枝 + 精查。

📌 中英术语表

中文 English 记忆点
时态数据库 temporal database 带时间维的数据库
有效时间 valid time 现实中成立的时间
事务时间 transaction time 数据入库的时间
双时态 bitemporal 两种时间都保留
快照数据库 snapshot database 只存当前值
历史数据库 historical database 只存有效时间
回滚数据库 rollback database 只存事务时间
时态表 temporal table 声明时间维度的表
时间点 / 时间区间 time point / time interval 时刻 / 持续段
区间代数 interval algebra Allen 13 种关系
时间切片 / 快照 temporal slicing / snapshot VALID 语义
时态查询语言 TSQL2 扩展 SQL 的时态标准
空间数据 spatial data 点线面
点 / 线 / 面 point / linestring / polygon
空间数据类型 spatial data type geometry / geography
空间关系 spatial relation 相交、包含、相邻、距离
相交 / 包含 / 相邻 intersects / contains / touches
空间索引 spatial index 二维的索引
网格索引 grid index 切格子
最小外接矩形 minimum bounding rectangle (MBR) R 树的"盒子"
R 树 R-tree 盒子套盒子