
大多数 SQL 教程讲到 SELECT、WHERE 和 GROUP BY 就停了。真正做数据分析需要更多:给患者排名、比较相邻就诊记录、关联多个表、把复杂问题拆成几步来解。这篇文章聊聊四个让这些成为可能的工具:
每个例子都基于同一个医院数据库(PostgreSQL 语法),每个展示的结果都是从实际数据跑出来的。
数据库在 morning schema 下,采用星型设计:一个事实表(visits 就诊记录)和多个维度表。
| 表名 | 内容 | 行数 |
|---|---|---|
patients |
patient_id, name, gender, age, county, sub_county | 25 |
doctors |
doctor_id, name, gender, specialty, experience years | 4 |
department |
department_id, department_name | 4 |
wards |
ward_id, ward_name | 4 |
diagnosis |
diagnosis_id, name, category | 18 |
procedures |
procedure_id, name, category | 15 |
visits |
每次就诊一行:date, patient, doctor, department, ward, diagnosis, procedure, admission_type, length_of_stay_days, triage_level, insurance_provider, total_bill_kes, payment_method, follow_up_required, patient_satisfaction_score | 30 |
跑任何查询之前先执行:
set search_path to morning;
数据质量说明。处理数据时发现几个影响分组和关联的问题:诊断名如 Back Pian 和类别 Respiratoey Disease 有拼写错误;Infectious 和 Infectious Disease 是同一类别的两种写法;有个县名前面多了个空格(Kiambu);诊断 513 到 518 原本缺失,后面用建议的名称补上了。实际工作中最好先清理这些问题,因为 GROUP BY 会把每种写法当成不同的组。
GROUP BY 聚合会把多行折叠成一行。窗口函数则是跨一组相关行(这个"窗口")做计算,但保留每条原始行。这样就能在同一个结果行里同时显示一次就诊以及它的排名、上次账单或累计总额。
每个窗口函数后面都跟着 OVER (...),用于定义窗口:
function_name(...) OVER ( PARTITION BY column -- 把行分成若干组(可选) ORDER BY column -- 组内排序(可选) frame_clause -- 包含当前行前后的哪些行(可选)
)
OVER () 里面什么都不写,表示窗口是整个结果集。PARTITION BY 为每个分组重启计算,类似于 GROUP BY 但不会折叠行。按 doctor_id 分区,每个医生会有自己的排名。ORDER BY 在 OVER 内部决定函数看到行的顺序。排名、LAG/LEAD 和累计计算都依赖它。它和查询末尾的 ORDER BY 是分开的——后者只影响最终输出的排序。功能: 给分区内的每行分配一个唯一序号 1、2、3……,按 ORDER BY 排序。即使有并列的行也会得到不同的编号。
用途: 给就诊记录编号、选取每个患者最近一次就诊、去重。
医院场景: 给回来就诊超过一次的五位患者按日期顺序编号。
select visit_id, patient_id, visit_date, total_bill_kes, row_number() over (partition by patient_id order by visit_date) as visit_number
from visits
where patient_id in (1, 2, 5, 7, 10)
order by patient_id, visit_date;
效果: 按患者分组,每组内按日期排序并从 1 开始编号。
| visit_id | patient_id | visit_date | total_bill_kes | visit_number |
|---|---|---|---|---|
| 1001 | 1 | 2026-06-01 | 3500 | 1 |
| 1011 | 1 | 2026-06-06 | 4200 | 2 |
| 1002 | 2 | 2026-06-01 | 2500 | 1 |
| 1021 | 2 | 2026-06-11 | 3800 | 2 |
| 1005 | 5 | 2026-06-03 | 4000 | 1 |
| 1025 | 5 | 2026-06-13 | 7600 | 2 |
| 1007 | 7 | 2026-06-04 | 5500 | 1 |
| 1028 | 7 | 2026-06-14 | 1900 | 2 |
| 1010 | 10 | 2026-06-05 | 76000 | 1 |
| 1030 | 10 | 2026-06-15 | 22000 | 2 |
一个非常常见的模式:每个患者最近一次就诊。 窗口函数不能直接在 WHERE 里过滤,所以要包一层 CTE(第三部分会讲):
with ranked as ( select visit_id, patient_id, visit_date, row_number() over (partition by patient_id order by visit_date desc, visit_id desc) as rn from visits
)
select visit_id, patient_id, visit_date
from ranked
where rn = 1;
这样返回每个患者恰好一行(共 25 行),就是每位患者最近的一次就诊。
这三个函数都能排名,但处理并列的方式不同:
| 函数 | 并列时 | 并列之后 |
|---|---|---|
ROW_NUMBER() |
编号不同(不加决胜条件时是随机的) | 1, 2, 3, 4 |
RANK() |
得到相同排名 | 跳号(1, 2, 2, 4) |
DENSE_RANK() |
得到相同排名 | 不跳号(1, 2, 2, 3) |
医院场景: 按住院天数从长到短给住院患者排名。因为有几位患者住院天数相同,会出现并列。
select visit_id, patient_id, length_of_stay_days as stay, row_number() over (order by length_of_stay_days desc, visit_id) as row_num, rank() over (order by length_of_stay_days desc) as rnk, dense_rank() over (order by length_of_stay_days desc) as dense_rnk
from visits
where admission_type = 'Inpatient'
order by row_num;
| visit_id | patient_id | stay | row_num | rnk | dense_rnk |
|---|---|---|---|---|---|
| 1017 | 16 | 6 | 1 | 1 | 1 |
| 1010 | 10 | 5 | 2 | 2 | 2 |
| 1004 | 4 | 4 | 3 | 3 | 3 |
| 1003 | 3 | 3 | 4 | 4 | 4 |
| 1016 | 15 | 3 | 5 | 4 | 4 |
| 1015 | 14 | 2 | 6 | 6 | 5 |
| 1024 | 22 | 2 | 7 | 6 | 5 |
| 1026 | 23 | 2 | 8 | 6 | 5 |
| 1027 | 24 | 1 | 9 | 9 | 6 |
结果解读: 1003 和 1016 都住了 3 天,所以 RANK 给两者都排第 4,下一组从第 6 开始(因为 4 和 5 被占用了)。DENSE_RANK 给下一组排第 5,没有空号。ROW_NUMBER 就是简单从 1 数到 9。
怎么选: 需要每组恰好一行(去重)用 ROW_NUMBER;比赛式排名、空号有意义用 RANK;"前 N 档"类问题(如"住院天数最长的三个不同档位")用 DENSE_RANK。
功能: 把排好序的行分成大约相等的 n 个桶,告诉每行落在哪个桶里。如果行数不能整除,前面的桶会多一行。
用途: 四分位数、百分位分段、把客户或患者分成等级。
医院场景: 把全部 30 次就诊分成四个账单分位,看看每个分位的账单范围。
with t as ( select visit_id, total_bill_kes, ntile(4) over (order by total_bill_kes) as quartile from visits
)
select quartile, count(*) as visits, min(total_bill_kes) as lowest, max(total_bill_kes) as highest
from t
group by quartile
order by quartile;
| quartile | visits | lowest | highest |
|---|---|---|---|
| 1 | 8 | 1800 | 3500 |
| 2 | 8 | 3800 | 6500 |
| 3 | 7 | 7000 | 24000 |
| 4 | 7 | 28000 | 82000 |
30 行没法平分成四组,所以前两个分位各 8 行,后两个各 7 行。最贵的那个分位(账单 28,000 及以上)聚集了医院大部分收入。
功能: LAG(col) 返回窗口内上一行的 col 值;LEAD(col) 返回下一行的值。分区内第一行没有上一行,所以 LAG 返回 NULL;最后一行 LEAD 返回 NULL。可选的第二个参数设置偏移量,第三个设置默认值(LAG(col, 2, 0))。
用途: 把一条记录和相邻记录做对比,比如对比上次就诊的变化、事件间隔、同比增长,不需要自连接。
医院场景: 对于复诊患者,显示账单相比上次的变化,以及下次就诊日期。
select patient_id, visit_id, visit_date, total_bill_kes, lag(total_bill_kes) over w as previous_bill, total_bill_kes - lag(total_bill_kes) over w as bill_change, lead(visit_date) over w as next_visit
from visits
where patient_id in (1, 2, 5, 7, 10)
window w as (partition by patient_id order by visit_date)
order by patient_id, visit_date;
WINDOW w AS (...) 子句给窗口定义起个名字,这样多个函数可以复用。
| patient_id | visit_id | visit_date | total_bill_kes | previous_bill | bill_change | next_visit |
|---|---|---|---|---|---|---|
| 1 | 1001 | 2026-06-01 | 3500 | NULL | NULL | 2026-06-06 |
| 1 | 1011 | 2026-06-06 | 4200 | 3500 | 700 | NULL |
| 5 | 1005 | 2026-06-03 | 4000 | NULL | NULL | 2026-06-13 |
| 5 | 1025 | 2026-06-13 | 7600 | 4000 | 3600 | NULL |
| 7 | 1007 | 2026-06-04 | 5500 | NULL | NULL | 2026-06-14 |
| 7 | 1028 | 2026-06-14 | 1900 | 5500 | -3600 | NULL |
| 10 | 1010 | 2026-06-05 | 76000 | NULL | NULL | 2026-06-15 |
| 10 | 1030 | 2026-06-15 | 22000 | 76000 | -54000 | NULL |
(患者 2 同理:2500 然后 3800,变化 1300。)患者 10 的账单降了 54,000:第一次是心衰住院 5 天,第二次是胸痛急诊只待了 1 天。每次就诊的账单变化讲出了一个简单的故事,而单纯的 SUM 会把这个信息藏起来。
功能: 返回窗口框架的第一个或最后一个值。这里窗口框架就很重要了,先看下面的警告。
医院场景: 对于 Dr. Faith Chebet(医生 104),对比每次就诊的账单和她第一、最后一次的账单。
select visit_id, visit_date, total_bill_kes, first_value(total_bill_kes) over w as first_bill, last_value(total_bill_kes) over w as last_bill_default, last_value(total_bill_kes) over ( partition by doctor_id order by visit_date, visit_id rows between unbounded preceding and unbounded following ) as last_bill_correct
from visits
where doctor_id = 104
window w as (partition by doctor_id order by visit_date, visit_id)
order by visit_date, visit_id;
| visit_id | visit_date | total_bill_kes | first_bill | last_bill_default | last_bill_correct |
|---|---|---|---|---|---|
| 1004 | 2026-06-02 | 42000 | 42000 | 42000 | 1900 |
| 1007 | 2026-06-04 | 5500 | 42000 | 5500 | 1900 |
| 1014 | 2026-06-07 | 1800 | 42000 | 1800 | 1900 |
| 1019 | 2026-06-10 | 5200 | 42000 | 5200 | 1900 |
| 1024 | 2026-06-12 | 18500 | 42000 | 18500 | 1900 |
| 1028 | 2026-06-14 | 1900 | 42000 | 1900 | 1900 |
坑在这里: 当窗口有 ORDER BY 但没指定显式框架时,默认框架是从分区开头到当前行。所以 LAST_VALUE 用默认框架只会返回当前行自己的值(就是 last_bill_default 列),这几乎永远不是你想要的。把框架扩展到 UNBOUNDED FOLLOWING 才能看到真正的最后一行(last_bill_correct)。FIRST_VALUE 不受影响,因为框架起点始终是分区开头。
熟悉的聚合函数加上 OVER 就变成窗口函数。没有 ORDER BY 时覆盖整个分区,每行都显示同样的值;加了 ORDER BY 就变成累计计算。
医院场景: 显示每个科室的总营收,同时保留每次就诊的明细。用 SELECT DISTINCT 来让每个科室只显示一行。
select distinct department_id, sum(total_bill_kes) over (partition by department_id) as dept_revenue, round(avg(total_bill_kes) over (partition by department_id), 0) as dept_avg_bill, count(*) over (partition by department_id) as visit_count, min(total_bill_kes) over (partition by department_id) as lowest_bill, max(total_bill_kes) over (partition by department_id) as highest_bill
from visits
order by department_id;
| department_id | dept_revenue | dept_avg_bill | visit_count | lowest_bill | highest_bill |
|---|---|---|---|---|---|
| 10 (Cardiology) | 210100 | 23344 | 9 | 3500 | 82000 |
| 20 (General Medicine) | 97800 | 10867 | 9 | 2000 | 36000 |
| 30 (Orthopedics) | 105200 | 17533 | 6 | 6500 | 33000 |
| 40 (Pediatrics) | 74900 | 12483 | 6 | 1800 | 42000 |
不加 DISTINCT 的话,30 行就诊记录每行都会带着自己科室的汇总数据在旁边,很适合用来问"这张账单和科室平均水平比起来怎么样?"这类问题。
累计总量和移动平均 需要 ORDER BY 配合框架。先按天汇总每天的收入,再做累计:
with daily as ( select visit_date, sum(total_bill_kes) as revenue from visits group by visit_date
)
select visit_date, revenue, sum(revenue) over (order by visit_date) as running_total, round(avg(revenue) over (order by visit_date rows between 2 preceding and current row), 0) as moving_avg_3day
from daily
order by visit_date;
15 行结果的前 5 行:
| visit_date | revenue | running_total | moving_avg_3day |
|---|---|---|---|
| 2026-06-01 | 6000 | 6000 | 6000 |
| 2026-06-02 | 70000 | 76000 | 38000 |
| 2026-06-03 | 7000 | 83000 | 27667 |
| 2026-06-04 | 12000 | 95000 | 29667 |
| 2026-06-05 | 91000 | 186000 | 36667 |
第二天移动平均是 (6000 + 70000) / 2,因为目前只有两行;从第三天开始才真正是当前天加前面两天的平均。
框架子句的格式是:
{ ROWS | RANGE } BETWEEN <start> AND <end>
-- start/end 可以是:UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, UNBOUNDED FOLLOWING
ROWS 数的是物理行数。RANGE 按值来处理:所有 ORDER BY 值相同的行(同行)被当成一个整体。ORDER BY 时)是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。医院数据大部分日期有两条就诊记录,这正好能看出两者的区别:
select visit_id, visit_date, total_bill_kes, sum(total_bill_kes) over (order by visit_date) as range_default, sum(total_bill_kes) over (order by visit_date, visit_id rows between unbounded preceding and current row) as rows_running
from visits
where visit_date <= '2026-06-02'
order by visit_date, visit_id;
| visit_id | visit_date | total_bill_kes | range_default | rows_running |
|---|---|---|---|---|
| 1001 | 2026-06-01 | 3500 | 6000 | 3500 |
| 1002 | 2026-06-01 | 2500 | 6000 | 6000 |
| 1003 | 2026-06-02 | 28000 | 76000 | 34000 |
| 1004 | 2026-06-02 | 42000 | 76000 | 76000 |
用 RANGE 时,6 月 1 日的两条就诊是同行,所以每行都包含另一条,显示 6000。用 ROWS 时,累计总量逐行增长。想要真正的逐行累计用 ROWS;想让相同值的行被一起对待用 RANGE。
JOIN 用条件(通常是外键匹配主键)把两个表的行拼在一起。我们的数据库里 visits.patient_id 指向 patients.patient_id,visits.doctor_id 指向 doctors.doctor_id,以此类推。
| JOIN 类型 | 返回结果 |
|---|---|
| INNER JOIN | 只在两边都匹配上的行 |
| LEFT JOIN | 左表所有行,加上右表匹配上的(没有匹配则 NULL) |
| RIGHT JOIN | 右表所有行,加上左表匹配上的(没有匹配则 NULL) |
| FULL OUTER JOIN | 两边所有行,能匹配上的匹配,匹配不上的一侧是 NULL |
| CROSS JOIN | 所有行的笛卡尔积(每种组合) |
| SELF JOIN | 表和自身关联(不是单独的关键字,用别名实现) |
适用场景: 只要两边都存在的记录,比如把就诊和对应的患者、医生详情关联起来。
场景: 列出每次住院就诊,附带患者姓名和负责医生。
select v.visit_id, p.patient_name, d.doctor_name, v.total_bill_kes
from visits v
inner join patients p on v.patient_id = p.patient_id
inner join doctors d on v.doctor_id = d.doctor_id
where v.admission_type = 'Inpatient'
order by v.visit_id;
| visit_id | patient_name | doctor_name | total_bill_kes |
|---|---|---|---|
| 1003 | Grace Naliaka | Dr. Peter Otieno | 28000 |
| 1004 | David Mutua | Dr. Faith Chebet | 42000 |
| 1010 | Peter Ouma | DR.Brian Mwangi | 76000 |
| 1015 | Eunice Moraa | Dr. Peter Otieno | 24000 |
| 1016 | Francis Njuguna | Dr. Sarah Wanjiku | 36000 |
| 1017 | Hellen Jepchirchir | DR.Brian Mwangi | 82000 |
| 1024 | Nadia Hussein | Dr. Faith Chebet | 18500 |
| 1026 | Patrick Muli | Dr. Sarah Wanjiku | 29000 |
| 1027 | Ruth Nekesa | Dr. Peter Otieno | 33000 |
9 次住院,现在每条都有可读的名字而不是一堆 ID。注意 JOIN 可以链式调用:这里两个 JOIN 关联了三张表。
适用场景: 需要主表所有行,即使没有匹配也要保留:"所有患者,以及他们如果有住院的话对应的住院记录。"
这个数据集里每个患者至少有一次就诊,所以从 patients 左连接到 visits 不会看到 NULL。为了真实演示效果,我们把住院条件放在 ON 子句里:
select count(*) as total_rows, count(v.visit_id) as matched
from patients p
left join visits v on p.patient_id = v.patient_id and v.admission_type = 'Inpatient';
| total_rows | matched |
|---|---|
| 25 | 9 |
25 个患者都在;其中 9 人有匹配的住院记录,另外 16 人在就诊列得到 NULL。(COUNT(v.visit_id) 忽略 NULL,所以只统计真正匹配的。)
反连接模式: 加上 WHERE v.visit_id IS NULL 就只保留没有匹配的患者,即从未住院的患者:
select p.patient_id, p.patient_name
from patients p
left join visits v on p.patient_id =