数据库设计精要:条件逻辑、行列转换与键值映射
本文包含AI辅助创作内容
CASE WHEN、横竖表与 Key-Value
在固定业务里,一张表通常会把字段直接做成列,比如姓名、手机号、金额、状态。这样的结构查起来直观,SQL 也短。但动态表单不太一样,表单字段可能随时新增、删除、改名,如果每次字段变化都去改数据库表结构,维护成本会很高。
所以很多动态表单会把字段值存成 key-value:一条数据只保留一个主记录 ID,具体字段放到另一张字段管理表里,每个字段一行。但查询展示时又遇到另一个问题:页面列表要的是“一条数据一行”,而 key-value 存出来的是“一个字段一行”。
这就是 CASE WHEN 经常出现的地方。在查询时把竖着存的字段值横向展开,让结果看起来像普通横表。
CASE WHEN 怎么把竖表查询成横表。
一、横表:字段就是列
横表是最常见的表结构,一条业务数据对应一行,每个业务字段对应一个列。字段稳定时,这种设计最省心:查询条件、排序、分页都可以直接围绕列来写。
CREATE TABLE form_order (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100),
mobile VARCHAR(30),
amount DECIMAL(10, 2),
status VARCHAR(30),
created_at DATETIME,
updated_at DATETIME
);
SELECT id, name, mobile, amount, status
FROM form_order
WHERE status = 'paid'
ORDER BY amount DESC;
二、竖表:字段变成多行
竖表会把“字段”从列里拿出来,变成数据行。主表只保存这条业务记录是谁、属于哪个表单、什么时候创建;字段明细表保存具体的 key 和 value。
这样做的好处是字段变化不会影响表结构。今天多一个备注字段,明天多一个附件字段,都只是多插入几行数据。
CREATE TABLE form_record (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
form_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE form_record_field (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
record_id BIGINT UNSIGNED NOT NULL,
field_key VARCHAR(100) NOT NULL,
field_value TEXT NULL,
created_at DATETIME,
updated_at DATETIME,
KEY idx_record_key (record_id, field_key),
KEY idx_key_value (field_key, field_value(100))
);
INSERT INTO form_record (form_id, created_at, updated_at)
VALUES (1, NOW(), NOW());
SET @record_id = LAST_INSERT_ID();
INSERT INTO form_record_field (record_id, field_key, field_value, created_at, updated_at)
VALUES
(@record_id, 'name', '张三', NOW(), NOW()),
(@record_id, 'mobile', '13800000001', NOW(), NOW()),
(@record_id, 'amount', '99.00', NOW(), NOW()),
(@record_id, 'status', 'paid', NOW(), NOW());
可以把 form_record 理解成“这条数据本身”,把 form_record_field 理解成“这条数据下面挂了哪些字段”。
三、Key-Value 和竖表是一回事
Key-Value 的重点是:字段名本身也是数据。以前字段名在表结构里,现在字段名放在 field_key 里;以前字段值放在具体列里,现在统一放到 field_value 里。
一条业务记录,拆开后大概就是这样:
record_id = 1
key value
------ -----------
name 张三
mobile 13800000001
amount 99.00
status paid
如果放在程序里看,它更像一个普通对象:
{
"name": "张三",
"mobile": "13800000001",
"amount": "99.00",
"status": "paid"
}
数据库用多行保存,是为了字段灵活;接口返回对象,是为了使用方便。中间这一步“多行变对象”,就需要查询层做一次整理。
四、CASE WHEN:把竖表查成横表
后台列表通常需要横向展示,所以要把多行字段压回一行。核心思路很朴素:每个目标字段写一个 CASE WHEN,命中对应 key 时取 value,不命中就给空值。
SELECT
r.id,
MAX(CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END) AS name,
MAX(CASE f.field_key WHEN 'mobile' THEN f.field_value ELSE '' END) AS mobile,
MAX(CASE f.field_key WHEN 'amount' THEN f.field_value ELSE '' END) AS amount,
MAX(CASE f.field_key WHEN 'status' THEN f.field_value ELSE '' END) AS status
FROM form_record r
LEFT JOIN form_record_field f ON f.record_id = r.id
WHERE r.form_id = 1
GROUP BY r.id;
id name mobile amount status
-- ---- ----------- ------ -------
1 张三 13800000001 99.00 paid
MAX 不是为了比较大小,而是为了配合 GROUP BY,把同一个 record_id 下的字段合并起来。
CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END
-- 执行过程中可以理解成:
record_id name
--------- ----
1 张三
1
1
1
MAX(...)
-- 聚合后变成:
record_id name
--------- ----
1 张三
五、筛选条件怎么放
主表字段在聚合前就存在,所以直接放在 WHERE。通过 CASE WHEN 转出来的字段,是聚合后的结果,一般放在 HAVING。
SELECT
r.id,
r.created_at,
MAX(CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END) AS name,
MAX(CASE f.field_key WHEN 'status' THEN f.field_value ELSE '' END) AS status
FROM form_record r
LEFT JOIN form_record_field f ON f.record_id = r.id
WHERE r.form_id = 1
AND r.created_at >= '2026-01-01 00:00:00'
GROUP BY r.id
HAVING status = 'paid';
模糊查询也是同样的思路:先把 name 转出来,再对转出来的字段做匹配。
SELECT
r.id,
MAX(CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END) AS name,
MAX(CASE f.field_key WHEN 'mobile' THEN f.field_value ELSE '' END) AS mobile
FROM form_record r
LEFT JOIN form_record_field f ON f.record_id = r.id
WHERE r.form_id = 1
GROUP BY r.id
HAVING name LIKE '%张%';
六、排序要注意别名
排序字段如果是主表字段,直接排序就行。如果排序字段来自 key-value 明细表,通常也是先通过 CASE WHEN 转出来,再排序。
有些数据库或 SQL 写法里,聚合别名直接排序会遇到限制。稳一点的写法是外面再包一层,让外层把它当普通列处理。
SELECT *
FROM (
SELECT
r.id,
MAX(CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END) AS name,
MAX(CASE f.field_key WHEN 'amount' THEN f.field_value ELSE '' END) AS amount
FROM form_record r
LEFT JOIN form_record_field f ON f.record_id = r.id
WHERE r.form_id = 1
GROUP BY r.id
) t
ORDER BY CAST(t.amount AS DECIMAL(10, 2)) DESC;
中文排序需要按中文规则排时,也可以放在外层:
SELECT *
FROM (
SELECT
r.id,
MAX(CASE f.field_key WHEN 'name' THEN f.field_value ELSE '' END) AS name
FROM form_record r
LEFT JOIN form_record_field f ON f.record_id = r.id
WHERE r.form_id = 1
GROUP BY r.id
) t
ORDER BY CONVERT(t.name USING gbk) COLLATE gbk_chinese_ci ASC;
七、多选字段也是一个 value
Key-Value 里的 value 不一定只能是普通字符串。多选、附件、关联数据这些复杂值,也可以序列化以后放进去。常见做法是存 JSON,接口返回时再转回数组。
INSERT INTO form_record_field (record_id, field_key, field_value, created_at, updated_at)
VALUES (1, 'tags', '["红色","加急","已确认"]', NOW(), NOW());
SELECT r.id
FROM form_record r
WHERE EXISTS (
SELECT 1
FROM form_record_field f
WHERE f.record_id = r.id
AND f.field_key = 'tags'
AND JSON_CONTAINS(f.field_value, JSON_QUOTE('加急'))
);
这类字段在展示时仍然可以通过 CASE WHEN 转出来,只是业务层拿到值以后要按 JSON 或约定格式解析。
八、这套结构适合什么场景
如果字段经常变,或者每个表单的字段都不同,key-value 结构会更稳。它把“字段定义”和“字段值”从数据库列里解放出来,避免频繁改表。
如果页面、导出、接口又需要横向结构,就用 CASE WHEN 在查询时转一下。它本质上是一个展示层查询技巧:底层仍然竖着存,结果横着返回。
存储时:
record_id field_key field_value
--------- --------- -----------
1 name 张三
1 mobile 13800000001
1 status paid
查询后:
id name mobile status
-- ---- ----------- ------
1 张三 13800000001 paid

请先 登录后发表评论 ~