Skip to main content

One post tagged with "arch"

View All Tags

我原本只是想帮小舅“下点视频”。最终却搞得很狼狈,还做了很多无用功。所以事后做了比较详细的复盘。

这篇文章复盘:我怎么一步步把一个小需求做成灾难,又怎么把它收敛成一个最短链路的方案,并把它抽象成一套“变与不变”的决策方法。


前言:我只是想下点视频

场景很简单:

一份 Excel,里面有 300+ 条百度网盘分享文本(URL、提取码、描述混在一起),目标是把这些视频变成“方便查看/播放/分享”的形式。

当时我脑子里默认了两个前提:

  1. Excel 里的字段虽然乱,但“总能下载下来”(最多偶尔失败几条,手工补一下就行)
  2. 先全部下载到本地再说,后续怎么给别人看,到时候再决定

后面你会看到:这两个前提几乎把我推向了所有狼狈的分支。


回头看需求:我到底真正要什么?

回到当时的场景。拿到需求之后,我想,无非是三步

  • 手动下载excel里的sheet为 CSV
  • 用 nushell/python/jq 把 CSV 转 JSON (当时没想着要直接写入sqlite,用DB来做数据收敛),这里涉及到洗数据(因为它提供的excel里的这个字段格式很乱(比如说 有的 Baidu URL 并非 https 开头,有的是URL本身带着 ?pwd 有些则不带,需要自己获取,又或者是没添该字段))
  • BaiduPCS-Go 把这些视频下载到本地

可以看到

我做了两个错误决策:

  • 1、忽视了这种excel本身(大概率)有很多脏数据。
  • 2、我下意识想到的就是直接把视频下载到本地(现在来说,一开始就想茬了),并且也没想明白这些数据到了本地之后要怎么处理

注意以上两个问题分先后

我先是在没意识到

仍然记得那天晚上,一共370个视频,就只差最后10来个下载不到本地。但是因为我没有记录所有record是否已下载、错误状态、具体错误原因 导致还要重新根据已下载视频,找到所有未下载用户,并且手动统计每个用户的具体错误原因。并且写成文档。非常狼狈。

之后,在我决定把视频全部下载到本地之后,就不可避免出现了新问题。100多个GB的数据,是直接在本地做内网穿透,让他人公网访问?还是再传到网盘上?

走到这步,无论如何都是很尴尬的选择。所以最终在没有选择的情况下,被迫只能传到U盘里,闪送给我小舅。

归根到底都是没有想清楚前面两个问题

tip

写在最前面的“需求澄清”,是这次复盘里最关键的一段。

我真正要的不是“下载工具”,而是一个小型系统,满足:

  • 可交付:别人能稳定地在线看/下载(不是我家宽带顶着)
  • 可追踪:每条记录是否成功、失败原因是什么、下一步怎么处理,都能自动汇总
  • 可恢复:中断了能续跑,重复跑不会把事情搞得更乱(幂等)
  • 链路尽量短:每加一跳,就多一类失败场景和维护成本

一句话:这是一个 “内容搬运 + 数据清洗 + 交付分发” 的流水线,而不是“脚本下载”。


第一版:本地下载 + Cloudflare Tunnel 的灾难

我第一版的直觉架构如下:


Excel(导出CSV)

本地脚本:jq解析

BaiduPCS-Go 下载到本地磁盘

Cloudflare Tunnel:把我本地目录穿透出去

别人用 URL 直接访问/在线播放

caution

为啥该方案不可用?

  • (1)带宽与体验不匹配 视频在线播放这件事,对“稳定上行带宽 + 断点/Range + 持续连接质量”要求很高。家宽 + 内网穿透这种组合,本质就是“把你家的上行当 CDN 用”,不现实。
  • (2)我把“失败处理”当成了“偶发情况” 最致命的不是带宽,而是:我没有任何“状态表”

那天的真实场景是:370 个视频就差最后十来个下载不下来。 如果我有一张表记录每条 record:

  • 原始字段(raw)
  • 解析结果(url / pwd / 文件名 hint)
  • 下载状态(pending / success / failed)
  • 失败原因(missing_url / missing_code / invalid_share / rate_limited / …)
  • 重试次数、最后错误信息

那我只需要跑一次汇总查询,就知道“哪十几个为什么失败、怎么补”。

但我没有。于是我只能:

  • 先从本地目录反推“哪些下载过”
  • 再跟 Excel 做 diff
  • 再手工统计失败原因
  • 再写成文档

狼狈的核心原因:系统没有“记忆”


第二版:企业级“VPS 流式传输 + CDN”架构(正确,但过重)

把U盘邮出去之后,复盘了一下这个事

此时我的思考(仍然)是:

tip
  • 我为啥会犯这些错误呢?
  • 怎么才能在做设计时,一开始就考虑到大部分这类问题?
  • 我发现这种需求,很难用上面这个“架构设计框架”来套用,是吗?还是说要做一些调整?

简而言之就是,为什么会这么狼狈?我做错了什么?以后怎么改进?

当时的想法是

Review一下前两天给我小舅写那个【把excel里的百度网盘URL视频,处理为方便查看的形式】。

我来简单说说,我一开始没想明白到底是否要发到CDN上,所以我只处理了下载到本地。之后做了 cloudflare tunnel 的内网穿透,来让别人通过URL直接访问我本地下载的这些视频。但是发现这种视频资源需要高带宽支撑,并不适合用内网穿透实现。

再则,我搞错了一个问题,也就是需要对所有这些视频是否下载到本地,以及具体错误,通过sqlite来标记状态。我以为从视频URL的获得到下载都不会有任何问题。结果耗费大量时间,都是为了处理这部分没有下载到的视频的URL以及相关处理了。

我主要犯了这两个错误。

想错了第一个问题,导致我犯了两个错误,如果早知道最终要存到CDN上,那我肯定就直接在VPS上(而非本地)跑了。如果早知道要到CDN上,那我肯定也会选择流式上传(也就是一边下载到本地,一边上传到CDN),不会等到后来已经全部下载到本地了,想传到CDN上,时间又不够。

所以我把这个需求,套入我的“系统架构设计”的workflow

发现以下问题:

错误 1:一开始没决定“最终落点”

我没有先回答这句:

这些 100+GB 的视频,最终应该落在哪里,才能让别人稳定访问?

如果最终一定要落到“公网可稳定访问”的地方(网盘 / 对象存储 / CDN),那么:

  • 在本地全量下载再搬运,几乎一定会浪费时间
  • 最优做法大概率是 “边转存/边校验” 或直接 “转存到云端落点”

错误 2:我默认“数据是干净的”

Excel 的分享文本字段天然是脏数据温床

  • URL 不一定有 https://
  • 有的 ?pwd= 在 URL 里,有的在文本里,有的缺失
  • 有的链接失效/权限不足/次数限制
  • 有的描述里夹杂标点、空格、全角符号

我把“脏数据处理”当成了边角料,于是它在最后阶段以“不可收拾的人工统计”形式反噬我。


具体优化

我第二版一度走向“企业级正确”:


Excel → 清洗入库 → VPS 下载/转码/切片 → 对象存储 → CDN → 观看

状态与重试

Nushell 视频中转项目设计文档

这套架构有两个价值:

  • 把“交付能力”放到系统层面:VPS 与对象存储/CDN 才是为流量而生
  • 逼我正视“状态机”:必须有任务表、失败分类、重试策略

但它的问题也很明显:链路长、决策多、每一段都要考虑失败与成本

对这次“帮小舅看视频”的场景来说,属于“正确但过度”。


最终版:BaiduPCS-Go transfer + OpenList 的最小可行方案

tip

做完上一版的架构设计之后,我用nushell手搓了一套(只是为了证明我能实现)。

但是又转头想到,上面这种架构是应该在正经项目里用golang来写,用shell来写,多少有点杯水车薪了,并不适用,也毫无意义。所以我又把这套方案删掉了。

正好那两天,在重新搭建 OpenList,就想到是否可以通过 OpenList 来简化操作(此时我的思路仍然是“一定是需要下载到本地的”)

胡渊鸣:Meshy AI,太极,MIT,清华姚班,图形学,物理仿真模拟,开源,商业化,勇气 ,智慧 | WhynotTV Podcast #2_哔哩哔哩_bilibili

[Meshy 奖学金] 当 CEO 重读 PhD:论智慧与勇气 里的 “敢于多花时间找正确的问题” 这部分

之后查了一下,OpenList 只提供 Aria/qBittorrent 之类的离线下载

仍然需要把视频下载到本地,然后再离线上传到。并不支持直接从 百度云“转储”到 Cloudflare R2 之类的CDN

那我为啥不直接把这些视频转存到我的百度云呢?

查了一下 BaiduPCS,果然支持该操作

这样子,链路就很短了,整个需求的核心功能也从“下载”转移到了“维护数据一致性”


链路越长,可能出问题的地方就越多,需要 handle 的失败场景就越多。

这次我最大的收获不是“又学了一个工具”,而是学会了:先砍链路,再谈优化。

最终我落地的最小闭环是:


Excel(.xlsx)

导出 CSV / 或直接读 xlsx

DuckDB 清洗 + 生成“任务表”(含 url/pwd/状态/错误)

BaiduPCS-Go:把分享链接内容 transfer 到“我的网盘目录”

OpenList:挂载我的网盘,提供统一浏览/播放/分享

验证 + 导出失败清单(按原因分组)


tip

最终需要按照最佳实践来实操一下上面说的最终的解决方案

核心流程4步:

- 1、【导入数据】

- 2、【洗数据、在table里补充相关字段】核心流程,需要验证数据是否与excel里的数据源保持一致

- 3、【transfer资源】通过 BaiduPCS-Go 把所有资源 transfer 到我的网盘里 # 需要

- 4、【验证】查看OpenList上mount的网盘,是否已经已经可以看到资源。 # 以及导出所有百度网盘URL有问题的用户,并根据 failed status 分组展示

Excel -> CSV -> DuckDB 清洗 -> Transfer到网盘

下面是我建议的“以后再遇到类似需求就照着做”的流程。

1)导入数据 (csvkit)

毫无疑问两个方案:

  • 方案 A:Excel 手动导出 CSV(最稳)
  • 方案 B:直接用 csvkit 读 xlsx(少一步手工)

goland内置的那个Database服务,不支持直接把excel导入到DB里。只支持导入CSV文件。

先后尝试了 nocodb, airtable 之类的,均不支持该操作

tip

所以最终选择使用 csvkit 导入数据,使用 duckdb 来洗数据

2)洗数据、补字段 (DuckDB, 核心步骤)

你要产出一张“任务表”,至少包含:

- id # 稳定主键(可用行号+hash)
- raw_text # 原始分享文本
- baidu_url / baidu_code
- status:pending/success/failed
- error_type:missing_url / missing_code / invalid_share / …
- error_detail # 最后一次错误详情
- attempts # 重试计数

同时把“可疑项”提前打标出来:缺 URL、缺提取码、URL 里是否自带 pwd= 等。

为什么我最后选 DuckDB?

我试过 GoLand 内置 Database、nocodb 等等,最大的问题不是“能不能导入”,而是:

  • 清洗表达力:字符串拆分、列表爆炸、规则化字段,非常吃 SQL 函数与数据类型
  • 可复现:我希望清洗逻辑能像代码一样被版本化、可回放
  • 可导出/可审计:能导出 DB / CSV / 报告,能验证和 diff

DuckDB 的优势在于:很多在 SQLite 里需要“手搓递归 CTE”的操作,在 DuckDB 里可以用内置函数一两行做完(比如 string_split + generate_series + list/array 操作)。


3)transfer 资源(直接转存,不需要下载)

用 BaiduPCS-Go 的 share transfer,把分享链接内容转存进“我的网盘目录”。

关键点:任务必须幂等

  • 成功的不再重复跑
  • 失败的按策略重试(指数退避/限制次数)
  • 所有结果回写状态表

4)验证,并生成报告

  • 用 OpenList 看 mount 的目录是否齐全、抽样播放
  • 从状态表导出失败记录,并按 error_type 分组输出(这一步是“体面”的来源)

总结:从这次迭代里,我学到的几个架构设计习惯

tip
  • 脏数据
  • 状态机
  • 链路长度

习惯 1:先定“交付形态”,再写“处理脚本”

先回答这三个问题:

  • 谁来用?怎么用?(在线看 vs 下载、单人 vs 多人)
  • 在什么网络环境下用?(家宽上行、跨境、移动端)
  • “成功”的验收标准是什么?(能播、能分享、能追踪)

习惯 2:把脏数据当成默认值

凡是“Excel + 人工录入 + 文本字段”,都默认:

  • 缺字段、错格式、混用标点、大小写、全角半角
  • 需要规则化、需要验证、需要报告

习惯 3:任何批处理都要有“状态机”

只要任务规模到了“几十条以上”,你就需要:

  • 状态表(任务、attempt、最后错误)
  • 可恢复(断点续跑)
  • 可审计(失败原因可汇总)

习惯 4:把链路砍到最短

每增加一跳,你就需要新增一套:

  • 失败分类
  • 监控与告警
  • 成本与权限
  • 数据一致性策略

在个人/小团队的小需求里,短链路几乎永远赢


方法论:这类问题里的「变」与「不变」怎么落地?

这部分聚焦“怎么做、怎么选”。

如果现在让我来设计这个实现,我为什么会把以下这些分别作为“变与不变”。

首先,这个需求本身很简单,从 Excel里收集“视频URL”,最终给用户提供一个可以稳定且流畅观看相应视频的 webpage。并且事后统计,我能知道哪些失败以及为什么。

那么这里就有几个疑问:

  • Excel里是否有脏数据?
  • 对程序来说,数据源是Excel还是CSV?
  • 需要下载的文件是什么格式?
  • 多大数据量?100GB
  • 能保证视频一定能下载成功吗?
  • 整个task是否有时间限制?大概多少?
  • 事后统计需要哪些维度的数据?

所以可以提取到以下关键字:

  • 有脏数据
  • 视频
  • 断点续传及retry
  • 大概半天时间
  • 事后统计

针对这几点,所以产生以下需求:

需求/约束决策/动作原因/说明
有脏数据洗数据,用 DuckDB 作为解析+存储DuckDB 既能做 SQL 清洗也能落地存储,比 jq/python+JSON/CSV 组合更一体
视频落到 CDN/网盘视频是大流量,必须走带宽/吞吐友好的落点,否则无法流畅播放
断点续传/Retry保留断点与重试能力大文件/网络抖动常见,缺少重试会导致任务雪崩
容错预建状态机,幂等写入,失败分类落表同一 id 重跑只更新状态,所有失败必须可审计
半天时间不下载落地,直接 transfer 到网盘/云端100GB 在 10MB/s 需约 2.7h,且 VPS 磁盘不足;链路越短越好,一跳优于两跳
事后统计默认存在失败模型,按类型落表覆盖脏数据、链接失效、频控、登录过期、磁盘/配额不足、网络抖动等维度

tip

其实可以看到以上这些本身就是一些最基本的NFR(非功能需求)

  • 带宽/吞吐(视频就是大流量)
  • 可恢复性(断点续传、幂等)
  • 可观测性(每条记录状态、错误原因、可导出报表)
  • 安全与权限(分享链接、访问控制)
  • 状态机/账本:对“每条视频记录”必须有状态(pending/downloading/success/failed + reason)

把“不变”做成框架,把“变”当配置

通用 Pipeline(骨架)

  1. import: 读 CSV → 表 raw_links
  2. clean: 规则化 → 表 tasks(含状态列)
  3. execute: 按状态驱动 BaiduPCS-Go share transfer
  4. verify: 汇总 success/failed,抽样播放
  5. report: 导出失败清单(按 error_type 分组)

本案的配置项

  • 解析器:DuckDB SQL / jq 解析百度分享文本
  • 执行器:BaiduPCS-Go share transfer <link> <dest>
  • 目标路径:/apps/your-bucket/<date>/(可自定义)
  • 重试策略:failed & attempts < 3 → 指数退避(30s, 2m, 5m)

用决策表收尾

问题不变的约束本case选择若条件变化怎么调
最终交付落点必须公网可稳定访问个人百度网盘+OpenList换成 OSS/CDN 时,只替换执行器与挂载入口
状态追踪必须有幂等任务表DuckDB `tasks`可换 SQLite/Postgres,但表结构不变
链路长度越短越好直接转存,无本地下/传若平台不支持转存,才退回“下+传”
失败与恢复默认存在,需可重试可审计attempts + error_type加报警/速率限制也放在同一状态机上

tip

一句话:

先把“不变”固化成可回放的流水线(表结构 + 状态机 + 幂等脚本),再只替换“变”的部分(解析规则、执行器、落点)。

这样同类需求来一百次,决策动作都收敛在配置层。


那么,最后一个问题

按照这套“变与不变”的决策逻辑,为啥之前的两个方案不好呢?

第一版:本地下载 + Cloudflare Tunnel

  • 违背“不变”的交付形态:公网稳定访问要求高上行和长连接,本地+穿透先天不匹配;链路长(下→穿透→播)却没有状态机。
  • 忽视“输入不可信”:没清洗、没任务表,失败无法追踪/补偿,幂等缺失,导致“反推未下完的十几条”这种狼狈。
  • “变”没被配置化:带宽、失败分类、重试策略都悬空,工具换成再多(jq、PCS)也救不了。

第二版:VPS 流式下载 + CDN(正确但过重)

  • 不符合“链路越短越好”的不变原则:落点/交付已定(公网可访问),却引入下载、转码、切片、对象存储、CDN多跳,增加失败面和成本。
  • 需求规模小、一次性,却套了“企业级”变体,决策与约束不匹配(时间窗口、资源规模、维护成本)。
  • 状态机虽补上,但“变”没有约束到最简实现:核心任务只是“转存+可浏览”,最短链路的转存方案即可满足,不需要整套流式分发栈。

回头看这次折腾,真正让我狼狈的不是 Cloudflare Tunnel,也不是最后那十几个死活下不下来的链接,而是我一开始把“实现”当成了“决策”。

下次再遇到类似需求,我会先把“不变”的东西冻结下来:用户到底要什么、成功标准是什么、链路必须具备哪些NFR(带宽、可恢复、可观测)、以及每条记录的状态机。只要这些不变的骨架立住了,工具怎么换都只是配置:DuckDB 也好、SQLite 也好,AList 也好、CDN 也好,都只是把同一条链路跑通的不同实现。

站在云端思考不变,再回到地面选择变化——这才是我从“小工具”里练到的系统设计。

结语:小工具也是系统设计的练习场

我这次狼狈的根因,并非技术能力,而是没在一开始站在“交付”视角看问题

  • 我以为自己在写脚本
  • 但我实际上在做一个小系统

好消息是:这种“狼狈”很划算。

它让你在一个低成本场景里,把架构设计里最核心的肌肉练出来:

  • 需求澄清
  • 变与不变
  • 状态机与可恢复
  • 缩短链路
  • 以交付反推设计

下次再遇到类似问题,我希望自己能更体面一点:

先把系统的“骨架”(不变)搭好,再用配置去适配具体的“皮肤”(变化)。


附录 A:解析百度分享文本(jq)

caution

补充这部分

解析百度分享文本
# Parse a Baidu share text into structured fields.
# Usage: jq -n --arg raw "<string>" -f parse_baidu_link.jq

def trim: gsub("^\\s+"; "") | gsub("\\s+$"; "");
def collapse_ws:
gsub("\\r"; "")
| gsub("\\n"; " ")
| gsub("\\t"; " ")
| gsub(" +"; " ");

def first_match($text; $pattern):
([ $text | match($pattern) ] | first?);

def capture_named($match; $name):
if $match == null then null
else
($match.captures
| map(select(.name == $name))
| if length > 0 then .[0].string else null end)
end;

def sanitize_url($url):
if ($url // "") == "" then null
else
($url
| trim
| gsub("[,,。;;::、…\\)\\]】》〉>』」\"']+$"; "")
| if startswith("https://") then .
elif startswith("http://") then ("https://" + (ltrimstr("http://")))
elif startswith("http") then .
else "https://" + .
end)
end;

def capture_extension($text):
($text // "") as $body
| ($body | first_match(.; "(?i)(\\.)(?<ext>(mp4|mov|mkv|m4v|avi|wmv|flv|mp3|wav|flac|m4a))")) as $hit
| (capture_named($hit; "ext")) as $ext
| if $ext == null then null else ($ext | ascii_downcase) end;

def parse_share($text):
($text // "") as $raw
| ($raw | collapse_ws | trim) as $normalized
| (first_match($normalized; "(?<url>https?://pan\\.baidu\\.com/[\\w\\-_/\\?=]+)")) as $url_match
| (if $url_match == null then first_match($normalized; "(?<url>pan\\.baidu\\.com/[\\w\\-_/\\?=]+)") else $url_match end) as $url_match2
| (capture_named($url_match2; "url")) as $raw_url
| (sanitize_url($raw_url)) as $clean_url
| (first_match($normalized; "(?i)(提取码|密码)[:: ]*(?<code>[A-Za-z0-9]{4,8})")) as $code_match
| (capture_named($code_match; "code")) as $text_code
| (if $clean_url == null then null else first_match($clean_url; "(?i)[?&]pwd[=::]?(?<code>[A-Za-z0-9]{4,8})") end) as $url_code_match
| (capture_named($url_code_match; "code")) as $url_code
| (if ($text_code // "") != "" then ($text_code // "") else ($url_code // "") end) as $final_code
| (capture_extension($raw)) as $ext_hint
| { raw: $raw,
normalized: $normalized,
has_baidu_url: (($clean_url // "") != ""),
baidu_url: ($clean_url // ""),
baidu_code: ($final_code // ""),
url_contains_pwd: (if $clean_url == null then false else (($clean_url | test("(?i)\\?pwd"))) end),
extension_hint: ($ext_hint // ""),
issues: [
(if ($clean_url // "") == "" then "missing_url" else empty end),
(if ($final_code // "") == "" then "missing_code" else empty end)
] | map(select(. != null))
};

parse_share($ARGS.named.raw)

zzz


def char_code($c): [$c] | implode;

def csvsplit:
(. | explode) as $chars |
reduce range(0; $chars|length) as $idx (
{field:"", fields:[], in_quotes:false, skip:false};
if .skip then
.skip = false
else
($chars[$idx]) as $c |
if .in_quotes then
if $c == 34 then
if ($idx + 1 < ($chars|length)) and ($chars[$idx + 1] == 34) then
.field += char_code(34) | .skip = true
else
.in_quotes = false
end
else
.field += char_code($c)
end
else
if $c == 34 then
.in_quotes = true
elif $c == 44 then
.fields += [.field] | .field = ""
elif $c == 13 then
.
else
.field += char_code($c)
end
end
end
)
| .fields + [.field];

def parse_csv:
(split("\n")
| map(gsub("\r$"; ""))
| map(select(length > 0))) as $rows |
if ($rows|length) == 0 then []
else
($rows[0] | csvsplit) as $headers |
$rows[1:]
| map(
(. | csvsplit) as $cols |
reduce range(0; $headers|length) as $i (
{}; . + { ($headers[$i]): ($cols[$i] // "") }
)
)
end;

def process_csv($data):
$data | parse_csv
| map({
"Contact": .["电话"],
"Group": "独奏组",
"Name": .["姓名"],
"WhetherAdvance": should_advance(.["姓名"])
});

# 从输入读取数据并处理
. as $input | process_csv($input)

附录 B:SQLite vs DuckDB 的“拆名单 + 排序 + 输出”SQL(节选)

caution

补充这部分

比较最终导出Excel的sql




WITH RECURSIVE
awards_sort AS (
SELECT '金奖' AS award, 1 AS sort_order UNION ALL
SELECT '银奖', 2 UNION ALL
SELECT '铜奖', 3 UNION ALL
SELECT '未来之星奖', 4 UNION ALL
SELECT '希望之星', 5 UNION ALL
SELECT '未获奖', 6
),
detail_rows AS (
SELECT
id AS detail_id,
group_name,
CASE
WHEN COALESCE(absent_final, 0) = 1 THEN '希望之星'
WHEN award IN ('金奖','银奖','铜奖','未来之星奖') THEN award
ELSE '未获奖'
END AS award_label,
name,
COALESCE(teacher, '') AS teacher,
num,
CASE WHEN group_name = '弦乐团' THEN name ELSE '' END AS ensemble_name,
member_list,
COALESCE(contact, '') AS contact,
COALESCE(address, '') AS address
FROM v_2025_final
),
group_totals AS (
SELECT group_name, award_label, COUNT(*) AS people_count, SUM(num) AS total_num
FROM detail_rows
GROUP BY group_name, award_label
),
member_split AS (
SELECT
dr.detail_id,
dr.group_name,
dr.award_label,
1 AS member_order,
CASE WHEN instr(dr.member_list,'、') > 0
THEN substr(dr.member_list,1,instr(dr.member_list,'、')-1)
ELSE dr.member_list END AS member_detail,
CASE WHEN instr(dr.member_list,'、') > 0
THEN substr(dr.member_list,instr(dr.member_list,'、')+1)
ELSE NULL END AS remainder
FROM detail_rows dr
WHERE dr.member_list IS NOT NULL AND dr.member_list <> ''
UNION ALL
SELECT
detail_id,
group_name,
award_label,
member_order + 1,
CASE WHEN instr(remainder,'、') > 0
THEN substr(remainder,1,instr(remainder,'、')-1)
ELSE remainder END,
CASE WHEN instr(remainder,'、') > 0
THEN substr(remainder,instr(remainder,'、')+1)
ELSE NULL END
FROM member_split
WHERE remainder IS NOT NULL AND remainder <> ''
),
member_lines AS (
SELECT detail_id, group_name, award_label, member_order, member_detail
FROM member_split
)
SELECT
group_name,
award_label AS award,
row_type,
people_count,
total_num,
name,
teacher,
num,
ensemble_name,
member_detail,
contact,
address
FROM (
-- 小计行
SELECT
g.group_name,
g.award_label,
'小计' AS row_type,
g.people_count,
g.total_num,
'' AS name,
'' AS teacher,
NULL AS num,
'' AS ensemble_name,
NULL AS member_detail,
'' AS contact,
'' AS address,
s.sort_order,
-1 AS detail_seq,
0 AS row_order,
0 AS member_order
FROM group_totals g
JOIN awards_sort s ON s.award = g.award_label

UNION ALL

-- 参赛明细
SELECT
d.group_name,
d.award_label,
'明细' AS row_type,
NULL,
NULL,
d.name,
d.teacher,
d.num,
d.ensemble_name,
NULL,
d.contact,
d.address,
s.sort_order,
d.detail_id,
1 AS row_order,
0 AS member_order
FROM detail_rows d
JOIN awards_sort s ON s.award = d.award_label

UNION ALL

-- 名单
SELECT
m.group_name,
m.award_label,
'名单' AS row_type,
NULL,
NULL,
'' AS name,
'' AS teacher,
NULL AS num,
'' AS ensemble_name,
m.member_detail,
'' AS contact,
'' AS address,
s.sort_order,
m.detail_id,
2 AS row_order,
m.member_order
FROM member_lines m
JOIN awards_sort s ON s.award = m.award_label
)
ORDER BY
group_name,
sort_order,
detail_seq,
row_order,
member_order;

可以看到在这个场景下,sqlite的sql长达159行,而duckdb则只有82行

这也是为啥上面使用 DuckDB 而非 sqlite 的原因之一

SQLite 需要递归 CTE 去“手搓字符串拆分”;

DuckDB 可以用 string_split + generate_series + list/array 操作快速拉平。






• 原因主要是 DuckDB 的内置函数更强,能把你在 SQLite 里“手搓”的步骤用一两行代替:

- 划奖项排序:用 VALUES 一次列出 6 行,不必 UNION ALL 6 次。
- 拆名单:DuckDB 有 string_split() 直接把顿号分隔的名单转成列表,再用 generate_series +
list_element 拉平成多行;SQLite 里没有等价函数,只能写一个递归 CTE member_split 手动
instr/ substr 循环。
- 列转行顺序:generate_series(1, array_length(ss)) 自带递增序号,省掉递归里的 member_order +
1 逻辑。
- 其他小处:少量 COALESCE/CASE 与原样一致,但 DuckDB 允许在同一 CTE 里直接 JOIN LATERAL 取列
表长度和元素,避免额外子查询。

本质上,行数减少是因为 DuckDB 提供了“字符串拆分 + 序号 + 爆炸”的原生操作,取代了 SQLite 需要
的递归拆分模板。



附录 C:DuckDB 导出 sqlite

INSTALL sqlite;
LOAD sqlite;
EXPORT DATABASE 'path_to_your_output_file.sqlite' (FORMAT SQLITE);
archpostmortem25 min read