这门课只做一个项目:为“青岚生活馆”完成一份经营分析。我们会沿用同一套客户、订单、商品和订单明细数据,最后把 SQL 的结果放进 Excel,做成可以复查的经营看板。
先把目标说具体。管理者想知道销售是否增长、哪些品类贡献收入、哪些客户值得维护,以及客户买过一次之后还会不会回来。SQL 负责从明细中算出可信的结果,Excel 负责复核、展示和交付。
一笔订单可能包含多件商品,所以我们不能把所有内容塞进一张表。这个项目使用四张原始表:
“一行代表什么”叫作数据粒度。后面只要结果不对,我们就先回到粒度检查。比如直接在订单明细表里数行数,得到的是明细行数,不是订单数。
课程统一使用 SQLite。它把整个数据库放在一个文件里,不需要额外启动数据库服务,SQL 也足以覆盖本课用到的连接、CTE 和窗口函数。
在一个空目录中准备 setup.sql,然后执行:
sqlite3 greenlife.db进入 SQLite 后打开外键检查,并创建四张表:
PRAGMA foreign_keys = ON;
CREATE TABLE customers_raw (
customer_id INTEGER,
signup_date TEXT,
city TEXT,
channel TEXT,
member_level TEXT
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
product_name TEXT NOT NULL,
category TEXT NOT NULL,
unit_cost REAL NOT NULL,
list_price REAL NOT NULL
);
CREATE TABLE orders_raw (
order_id INTEGER,
customer_id INTEGER,
order_date TEXT,
status TEXT,
payment_method TEXT
);
CREATE TABLE order_items_raw (
order_id INTEGER,
product_id INTEGER,
quantity INTEGER,
unit_price REAL,
discount_rate REAL
);orders_raw 暂时没有主键,这是有意保留的。原始导入层要如实接住源数据,重复订单应该先被发现,再由清洗逻辑处理。如果一开始就加主键,第二条重复记录会导入失败,我们反而看不到源数据的问题。
先插入 12 件商品。成本和标价分开存放,后面才能计算毛利。
INSERT INTO products VALUES
(1, '晴雨伞', '出行', 36.00, 69.00),
(2, '帆布袋', '出行', 18.00, 39.00),
(3, '随行杯', '饮水', 42.00, 89.
下面的递归 CTE 生成 120 位客户。数据里故意保留了前导空格、两种北京写法和少量空渠道,这些问题会在下一节清洗。
WITH RECURSIVE n(x) AS (
SELECT 1
UNION ALL
SELECT x + 1 FROM n WHERE x < 120
)
INSERT INTO customers_raw
SELECT
x,
date('2023-07-01', printf('+%d days', (x * 11) % 365)),
CASE x % 7
WHEN 0 THEN ' 上海'
再生成 540 笔订单,时间范围是 2024 年 1 月到 2025 年 6 月。订单状态包括完成、取消和退货。
WITH RECURSIVE n(x) AS (
SELECT 1
UNION ALL
SELECT x + 1 FROM n WHERE x < 540
)
INSERT INTO orders_raw
SELECT
x,
((x * 37) % 120) + 1,
date('2024-01-01', printf('+%d days', (x * 17) % 547
最后一条语句模拟重复导入,所以表里有 541 行,但只有 540 个不同的订单编号。
不要在建表后立刻开始分析。先用最小查询确认数据是否进入了预期的表。
SELECT 'customers_raw' AS table_name, COUNT(*) AS row_count
FROM customers_raw
UNION ALL
SELECT 'products', COUNT(*) FROM products
UNION ALL
SELECT 'orders_raw', COUNT(*) FROM orders_raw;结果应该是 120 位客户、12 件商品和 541 行订单原始记录。再检查重复订单:
SELECT
COUNT(*) AS rows_in_table,
COUNT(DISTINCT order_id) AS distinct_orders
FROM orders_raw;这两个数字不同不是报错,而是下一节要处理的数据质量问题。
很多初学者会问:既然知道城市有空格、订单有重复,为什么不在导入时直接改掉?因为分析首先要能解释“源数据原来是什么”。
我们把数据分成三层:
如果一开始就在 orders_raw 上删除重复行,后面看到销售额异常时,我们无法确认重复来自源系统还是清洗脚本。保留原始层后,任何清洗规则都可以重新执行,也能比较清洗前后少了多少记录。
原始层并不表示“什么都不管”。字段数量、基本类型和文件是否完整仍要检查,只是不在这里悄悄改变业务内容。
你可以用 1 号订单想象四张表的关系。客户表中只有一行客户,订单表中只有一行 1 号订单,但订单明细里可能有三行:晴雨伞、陶瓷马克杯和便携餐具。
连接订单与明细后,1 号订单会出现三行。这不是重复,而是一对多关系展开后的正常结果。于是下面三个计数回答不同问题:
SELECT
COUNT(*) AS detail_rows,
COUNT(DISTINCT order_id) AS orders,
SUM(quantity) AS units
FROM order_items_raw;COUNT(*) 数明细行。COUNT(DISTINCT order_id) 数订单。SUM(quantity) 数商品件数。后面只要指标不对,先问一句“一行代表什么”。这比先改公式更有效,因为许多销售额翻倍、订单数偏大和客单价偏低,根源都是粒度混了。
products.product_id 是主键,一件商品只能有一个稳定编号。订单明细中的 product_id 指向它,是连接商品名称、品类和成本的外键。
orders_raw 暂时不加主键,是为了让重复记录进入原始层。清洗后我们会让每个 order_id 只保留一条,再把它当作订单的稳定标识。换句话说,原始层可以暴露问题,分析层必须有明确的唯一性。
生成客户和订单时使用了递归 CTE。以客户编号为例:
WITH RECURSIVE n(x) AS (
SELECT 1
UNION ALL
SELECT x + 1 FROM n WHERE x < 120
)
SELECT x FROM n;第一行产生 1,递归部分不断把上一行加 1,直到 120。它相当于临时生成一张 1 到 120 的数字表。
后面的取模表达式负责把编号映射到日期、城市和渠道。例如 x % 4 的结果只会是 0、1、2、3,因此可以稳定分配四种渠道。我们使用确定性规则,而不是随机函数,是为了让每次重建数据库都得到相同结果。教程中的查询结果才能逐行复核。
真实项目不会用递归 CTE 伪造客户,这里只是用它准备一套可反复练习的数据。分析方法仍然与导入真实 CSV 后相同。
建议把建表和插入语句保存为 setup.sql,然后一次执行:
sqlite3 greenlife.db < setup.sql执行后按这个顺序检查:
.tables 能看到四张原始表。PRAGMA table_info(products); 能看到商品字段与类型。日期范围可以这样查:
SELECT
MIN(order_date) AS first_order_date,
MAX(order_date) AS last_order_date
FROM orders_raw;行数对上但日期范围不对,说明数据生成表达式或导入格式仍有问题。不要因为表里“有数据”就直接开始分析。
说明旧数据库还在。练习时可以删除课程生成的数据库文件后重建;正式环境则应该写迁移脚本,不能随意删除业务数据。
先确认 setup.sql 保存为 UTF-8。终端能正确显示中文后再继续。乱码一旦进入表,后面的 Excel 导出也会继续错误。
541 是原始行数,540 才是不同订单编号数。现阶段两个数字都要保留:前者说明导入了多少行,后者暴露重复问题。
先查询重复记录的所有字段,确认它们是否完全相同。若金额、状态或更新时间不同,保留哪一条需要业务规则,不能只按编号随便删除。
接下来我们会补齐订单明细,检查重复、空值和类别写法,再建立干净的数据视图。清洗后的每一个变化都会有清洗前后的数量对照。