ADB-04 时态与空间数据库
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 复杂;历史一旦写错,修起来要额外的小心。所以是否用时态,取决于"历史是不是业务数据本身"——薪资、价格、余额这些"改动了就是新事实"的数据值得,临时缓存、草稿这类覆盖无所谓的数据不必。这是时态建模前要先想清楚的问题。
💡 记忆口诀:普通表是"记账本 + 橡皮擦",时态数据库是"带时间轴的流水账"——只增不改,随时可回放。
⚠️ 常见错误
- 以为加一列
update_time就算时态表:那只是"最后修改时间",仍然回答不了"3 月 5 日当天工资是多少"——修改前的值已经没了。 - 用
UPDATE维护带历史需求的数据:工资调整这种"一次改动产生一个历史事实"的数据,必须插入新行而非覆盖旧行。 - 把"时间"当普通文本存:
'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_to 和 tx_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 月他工资多少",答案只取决于现实时间,跟哪天录进去没关系——用有效时间即可。
- 回滚数据库:审计场景下问"上周五系统里看到的库存是多少"。这问的是系统当时的状态,不是现实——只靠有效时间答不出来,必须靠事务时间。
- 快照数据库:普通业务系统,没人关心历史,覆盖就覆盖——什么都不存。
- 双时态:既要回答现实、又要回答系统认知,两类问题都要——两对时间列都上。
💡 记忆口诀:有效时间管"现实",事务时间管"数据库";只记现实 = 历史库,只记录入 = 回滚库,都记 = 双时态,都不记 = 快照库。
⚠️ 常见错误
- 把"查询时间"当"事务时间":你 4 月去查一条 3 月录入的记录,查询时间不是事务时间——事务时间是"数据入库那一刻"的系统时间。
- 认为只要建了
valid_from/valid_to就是双时态:那只是有效时间建模(历史数据库)。双时态必须两对时间列并存。 - 有效时间区间口径不统一:区间是"左闭右开"还是"闭闭",全库必须一致,否则 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 都能实现,只是"起止时间列"和"区间过滤"这两件事要自己写对。
⚠️ 常见错误
- 时间点 / 区间概念混用:"3 月 1 日"单看是时间点,但"工资自 3 月 1 日起"表示的是区间
[3月1日, ...),语义完全不同。 - 重叠判断只写一边:判断两区间重叠必须两边都满足
startA <= endB AND startB <= endA,只写一半会把"一个包含另一个"的情况漏判。 valid_to的NULL处理混乱: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 '日期',把数据库"切"到那一天的截面来看。
⚠️ 常见错误
- 以为 TSQL2 是标准 SQLite 可直接跑的语法:TSQL2 是概念标准,本讲示例已标注"概念"二字,实机运行要用等价的手写区间查询。
- 把时态查询当成普通等值过滤:
WHERE valid_from = '2024-03-01'查的是"恰好这天开始生效",不是"这天有效"——后者要求目标日落在区间内。 - 忽略时态表连接时的语义:两张时态表连接时,要同时满足双方的区间条件,只过滤一边会产生重复或遗漏。
4.5 空间数据模型 ⭐
地图上的三种"形状"
打开地图 App:你的位置是一个点,回家的路是一条线,小区占地是一块面。空间数据的基本对象就这三种:
- 点(point):无大小,一个坐标,如餐厅、公交站、你的定位。
- 线(line / linestring):一串有序坐标,如道路、河流、地铁线路。
- 面(polygon):封闭的坐标环,如城市边界、湖泊、店铺服务范围。
这三种对象几乎能表达地图上的任何东西,更复杂的形状(省界这种凹凸不规则的轮廓)由多个简单形状组合而来——所以"点线面"是空间建模的原子积木。日常见到的 GeoJSON、Shapefile 等格式,底层就是把这些坐标序列标准化地写出来(如 POINT(106.5 26.6)、LINESTRING(...)、POLYGON(...)),数据库解析后交给空间函数处理。
这些形状在数据库里存什么?本质是坐标序列:点存一个坐标对,线存一串坐标对,面存一个闭合的坐标环。坐标的载体有两种常见选择:
- 经纬度(地理坐标):适合跨区域、跨国家的数据,如城市、国家边界,需要考虑地球曲率;
- 平面直角坐标(投影坐标):适合小范围、局部地图,如一个城市内的路网,距离计算直接用勾股定理,简单快。
关系表把坐标当字符串或二进制存起来不透明,空间数据库则把几何形状封装成空间数据类型(如 PostGIS 的 geometry、geography),并提供专门的函数来操纵它。类型选择也影响索引和距离算法,这是空间建模的第一个决策点。
空间数据类型与操作
空间数据模型在关系数据库上的落地,是定义空间数据类型和空间操作函数。按 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;
一条语句同时用了"距离判断"和"属性过滤"——这正是空间数据的常态:一半是几何,一半是普通业务属性。空间数据库的价值就是把这两半揉在一起,让开发者不用自己写坐标解析和距离公式。
💡 记忆口诀:空间关系像"两个图形叠纸片"——相交看有没有公共点,包含看谁装下谁,距离看俩中心隔多远。
⚠️ 常见错误
- 用经纬度的
=判断"同一位置":两个坐标差 0.0001 度实际可能差 10 米,位置接近要用距离阈值判断,不要用相等。 - 点 / 线 / 面混存一列:同一空间列必须类型一致,混合存储会导致空间索引失效、函数报错。
- 算距离当直线距离:球面上经纬度距离不是平面直角坐标的欧氏距离,粗算可以,精确计算要用球面距离公式。
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 公里内的餐厅"分两步:
- 粗筛:从根开始,判断查询范围是否和节点的 MBR 相交。不相交的整棵子树直接剪掉(prune)——这一判断只需几个坐标比较,极便宜;
- 精查:落到叶子后,对候选餐厅逐个算真实距离,剔除超范围的。
插入新餐厅时,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 树索引(按位置),查询优化器会根据查询条件选择——"找评分最高的川菜馆"走普通索引,"找我家附近的川菜馆"走空间索引,二者各司其职。空间建模不是"不用普通索引",而是"在需要二维邻近时,额外给位置一个空间索引"。
⚠️ 常见错误
- 把 B 树建在经纬度列上当空间索引用:B 树只能做单维比较,做不了"二维邻近",查询依旧慢。
- 建了索引但查询写法不命中:空间函数(如
ST_DWithin)要写在能利用索引的写法里,把几何函数套在列外面会强制全表扫描。 - 以为 R 树结果一定精确:R 树只保证"候选不漏",粗筛可能带出假阳性,精确距离必须在最后精算,跳过精查会出错。
🧠 记忆口诀
- 时态数据库:普通表是"记账本 + 橡皮擦",时态库是"带时间轴的流水账"——只增不改,随时回放。
- 两种时间:有效时间管"现实",事务时间管"数据库";有效时间问"何时如此",事务时间问"何时知道"。
- 四级分类:只记现实 = 历史库,只记录入 = 回滚库,都记 = 双时态,都不记 = 快照库。
- 区间关系:重叠判断两边都要
startA <= endB AND startB <= endA。 - 时态 SQL:
VALID= 时间切片器——一句VALID '日期'把库切到那一天。 - 空间对象:点、线、面三兄弟;关系看"相交、包含、相邻、距离"。
- 空间索引:B 树管一维、R 树管二维;R 树 = 大盒子粗筛 + 逐个精查。
⭐ 考点清单
- 时态数据库的动机:传统表
UPDATE覆盖历史,业务需要"任意时刻回放"。 - 有效时间 vs 事务时间:现实中成立的时间 vs 数据入库的时间,给出场景要会判断。
- 双时态(bitemporal):两对时间列并存,能回答"历史上系统怎么认为"。
- 时态数据库四分类:快照 / 历史 / 回滚 / 双时态,各自保留哪些时间。
- 时间点 vs 时间区间;Allen 区间代数的常用关系(before、meets、overlaps、during…)。
- 有效时间建模的 SQLite 建表:
valid_from/valid_to+BETWEEN查询某时刻值。 - 区间重叠判断条件:
startA <= endB AND startB <= endA。 - TSQL2 的
VALID子句语义:按有效时间切片的时态查询。 - 空间对象类型:点 / 线 / 面;空间关系与操作(intersects、contains、within、touches、distance、buffer、union…)。
- 空间索引动机:B 树单维排序无法表达二维邻近。
- 网格索引:切格子 + 查周围格子 + 精确距离复核。
- 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 | 盒子套盒子 |