惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
爱范儿
爱范儿
H
Help Net Security
Last Week in AI
Last Week in AI
The Cloudflare Blog
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
IT之家
IT之家
aimingoo的专栏
aimingoo的专栏
大猫的无限游戏
大猫的无限游戏
Jina AI
Jina AI
Google DeepMind News
Google DeepMind News
B
Blog
C
Check Point Blog
T
Tailwind CSS Blog
云风的 BLOG
云风的 BLOG
D
Docker
Recent Announcements
Recent Announcements
Vercel News
Vercel News
博客园 - 聂微东
阮一峰的网络日志
阮一峰的网络日志
MyScale Blog
MyScale Blog
The GitHub Blog
The GitHub Blog
Stack Overflow Blog
Stack Overflow Blog
雷峰网
雷峰网
人人都是产品经理
人人都是产品经理
月光博客
月光博客
F
Fortinet All Blogs
Blog — PlanetScale
Blog — PlanetScale
B
Blog RSS Feed
The Register - Security
The Register - Security
V
Visual Studio Blog
F
Full Disclosure
Hugging Face - Blog
Hugging Face - Blog
T
Threat Research - Cisco Blogs
Latest news
Latest news
PCI Perspectives
PCI Perspectives
Cisco Talos Blog
Cisco Talos Blog
博客园 - Franky
D
DataBreaches.Net
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
G
Google Developers Blog
P
Palo Alto Networks Blog
Engineering at Meta
Engineering at Meta
Microsoft Azure Blog
Microsoft Azure Blog
T
Tenable Blog
L
LINUX DO - 热门话题
Spread Privacy
Spread Privacy

暗无天日

读:AI Agent 安全日志——从可见性与隐私的两难说起 - 暗无天日 AI写作的语言指纹——如何让文字不那么像机器 - 暗无天日 读:50 条 Claude Code 技巧——一个工程经理的六个月使用心得 读:AI 辅助开发为什么让 E2E 测试更有价值 - 暗无天日 读:在Emacs中使用Claude Code(Spacemacs适配版) - 暗无天日 Claude Code 背后的工程哲学——读 Agent Harness Engineering 读:Agent Harness Engineering——AI 智能体不只是模型,还有套件 - 暗无天日 browser-harness:让 AI 直接接管你的浏览器 - 暗无天日 读:Security-First CI/CD —— DevSecOps 自动化实践指南 TIL: 数字小键盘的小数点陷阱与行内算术求值 - 暗无天日 读:Immutability 不是万能药,它是一种权衡 - 暗无天日 Conducty:给 Claude Code 加上项目记忆和并行执行能力 - 暗无天日 读 — GitHub Trending 里的 Claude Code 技能包 读 — Prompt Caching 省钱指南 TIL: Emacs 中那些跟鼠标配合的冷门快捷键 - 暗无天日 读:Anvil——把 Emacs 变成 AI 的工具服务器 读:Emacs 代码折叠终极指南 - 暗无天日 读:Clojure 搭车客指南 - 暗无天日 git推送失败后恢复仓库损坏的完整记录 - 暗无天日 多智能体系统的两个有效模式——以及对 Claude Code 用户的启示 - 暗无天日 用 Org Babel 写 Literate 博文:扩展执行 + 定制导出 proced:Emacs 内置的进程查看器 - 暗无天日 从 proced 定制中学到的 Elisp 模式 读:让 Emacs proced 在 macOS 上显示 CPU 和内存 异步编程的函数着色税 - 暗无天日 链式调用的代价:JavaScript 和 Clojure 的共同教训 - 暗无天日 hyperfine:命令行基准测试工具 - 暗无天日 管道中的变量去哪了?——子 shell 作用域陷阱 - 暗无天日 开源包装器的信任陷阱:四个危险信号 - 暗无天日 程序员愿意为 AI 写文档,却不愿为同事写 - 暗无天日 mktemp: Shell 脚本中临时文件的安全陷阱与最佳实践 - 暗无天日 WSL9x —— 在 Windows 9x 里跑 Linux 内核 6.19 用 ox.el 做你想做的事 —— org-export 高级编程指南 读:Hot-wiring the Lisp Machine —— 用纯 Elisp 构建零依赖的 Org 静态站点生成器 Elisp 性能优化的六个实战教训 - 暗无天日 fcitx5 下 Emacs 无法切换输入法的排查 - 暗无天日 ERT 测试交互命令的三种方式 - 暗无天日 SEM Assistant: 当 Elisp 守护进程遇上 LLM 用 dmsg 给 Elisp 加上结构化调试日志 用 org-habit 追踪非每日习惯 - 暗无天日 Clojure X-Men:当编程语言特性变成超能力 - 暗无天日 TIL: 用 diff-hl 在 fringe 中显示 git 变更 读:llm-test —— 用 LLM agent 驱动 Emacs 测试 TIL: AI 时代的橡皮鸭调试 - 暗无天日 fcitx 启动后键盘输入卡顿的排查 - 暗无天日 TIL: 早期网页的图片热区导航 - 暗无天日 读 Seeing the Whole System 用 Emacs 自动生成每周链接推荐 - 暗无天日 读:ASCII control characters in my terminal 读 What to learn - 暗无天日 Lisp 的括号之痛——一个愚人节玩笑揭开的老伤疤 - 暗无天日 一本书该"线性读"还是"并行读" - 暗无天日 读 How to Monetize a Blog:一篇伪装成变现指南的讽刺文 Python Mock 第三方依赖的四种策略 - 暗无天日 Emacs Lisp 热重载实用指南 - 暗无天日 Prot 的 Emacs 配置哲学 - 暗无天日 TIL: 从直播对谈中学到的三个 Emacs 技巧 - 暗无天日 TIL: 自动使用项目虚拟环境的 Python - 暗无天日 TIL: 让 Help buffer 自动获得焦点 一条命令让本地开发用上 HTTPS —— slim 工具介绍 用 fsck 检查和修复 Linux 文件系统 排查Linux进程"卡死"实战:从strace到gdb全流程 - 暗无天日 PostgreSQL 索引:从基础到你可能不知道的高级用法 - 暗无天日 用 .pdbrc 自定义 Python 调试器 ANSI 转义码的标准化现状 - 暗无天日 终端程序的潜规则 - 暗无天日 PARA Org-mode 测试配置 - 暗无天日 AI越强越辣鸡?控制论说这是必然的 - 暗无天日 AI 越强越需要你盯着——反馈循环实操指南 - 暗无天日 你的AI代理正在偷你的密钥——四种你没想到的泄露通道 - 暗无天日 LLM 在 DevOps 中的三种角色 - 暗无天日 写作风格的反建议 - 暗无天日 反驳本质复杂性——Dan Luu 论为什么《没有银弹》错了 - 暗无天日 文件充满了危险——Dan Luu 谈文件系统的可靠性陷阱 - 暗无天日 AI 时代的 PARA 方法:用 Org-mode 和 AI 打造个人知识管理系统 Linux 数据去重学习笔记 - 暗无天日 创建跨平台 ZIP 文件的隐藏陷阱:Extra Field - 暗无天日 X11 Forwarding 排障指南 - 暗无天日 IP欺骗端口扫描:当别人冒充你去扫描别人 - 暗无天日 Linux 输入栈全景解析:从硬件按键到屏幕响应 - 暗无天日 Unix 系统中那些被埋没的配置开关——以 FontConfig 为例 - 暗无天日 在Linux上限制儿童使用电脑 - 暗无天日 GIF不仅仅是一种图片格式——用GIF流做些奇怪的事 - 暗无天日 Leiningen 学习笔记:Clojure 项目构建与管理从入门到实战配置 - 暗无天日 Google SRE Book 读书笔记 - 暗无天日 yes 管道 head 发生了什么 - 暗无天日 为什么 nohup 在 crontab 中不起作用 Bash中的Indirection与Nameref - 暗无天日 Linux PAM 简介 - 暗无天日 从Linux ISO文件启动计算机 - 暗无天日 用 Bash 打造一个Screen Locker 用GitHub Actions自动构建EGO博客 - 暗无天日 blocking I/O 的作用 - 暗无天日 mobileog 手机端同步提示Error:2 No such file 的解决方法 回收 WSL2 VHDX 文件占用空间 使用 org-mode columnview 生成任务列表 - 暗无天日 Emacs 作为 MPD 客户端 - 暗无天日 移动文件路径却不破坏org file link的方法 - 暗无天日 如何合理的导出help link 成HTML - 暗无天日 笑话理解之Biology - 暗无天日
读:tetris-sql——用一条SQL查询实现俄罗斯方块 - 暗无天日
lujun9972,Claude Code · 2026-05-24 · via 暗无天日

目录

  • 技巧一:递归 CTE 充当游戏循环
  • 技巧二:RAISE NOTICE 实现实时输出
  • 技巧三:dblink 绕过 MVCC 快照隔离
  • 技巧四:一维数组表示棋盘
  • 性能特征
  • 小结

GitHub 上的 tetris-sql 项目用一条 PostgreSQL 查询实现了完整的俄罗斯方块。听起来像炫技,但拆开看,里面的几个 SQL 技巧在日常工作中真能用上。本文从中提炼四个最值得学的知识点,跳过 Tetris 的游戏逻辑,只讲 SQL。

技巧一:递归 CTE 充当游戏循环

递归公共表表达式(Recursive CTE)在 SQL:1999 标准中引入。它包含两部分:一个非递归项生成初始行,一个递归项反复引用前一次的结果。当递归项不再产生新行时,循环停止。有了递归 CTE,SQL 就成了图灵完备的语言,理论上可以实现任意算法。

一个最简单的例子,用递归 CTE 生成 1 到 5 的数列:

WITH RECURSIVE t(i) AS (
    SELECT 1
    UNION ALL
    SELECT i + 1
    FROM t
    WHERE i < 5
)
SELECT * FROM t;
 i
---
 1
 2
 3
 4
 5
(5 rows)

递归 CTE 的语法拆开看就几个部分: WITH RECURSIVE 名称(列名) AS ( 开头;第一个 SELECT 是种子行(非递归项); UNION ALL 把种子行和后续行拼起来; SELECT ... FROM 名称 WHERE 条件 = 是递归项,其中 =FROM 名称 = 引用上一轮的结果, =WHERE 条件 = 不满足时循环结束;最外层的 =SELECT * FROM 名称 输出所有累积的行。

这个模式天然适合做游戏循环。非递归项初始化游戏状态(棋盘、分数、当前方块),递归项在每次迭代中读取输入、更新状态、渲染画面,WHERE 子句判断游戏是否结束。tetris-sql 的核心结构长这样:

WITH RECURSIVE main AS (
        SELECT ...
    UNION ALL
        SELECT ...,
        pg_sleep(...)      FROM main, ...
    WHERE NOT game_over
)
...

注意 pg_sleep 的作用:没有它,循环会以最快速度执行,玩家根本来不及反应。

技巧二:RAISE NOTICE 实现实时输出

游戏循环有一个问题:PostgreSQL 的递归 CTE 只有在整个循环结束后才返回结果。如果你把终止条件去掉,查询会一直挂着,什么都不输出。这跟 C 语言里的 while(1) { print(...); } 完全不同,后者至少能看到输出。

解决方案是利用 PostgreSQL 的 RAISE NOTICE 命令,它可以在查询执行过程中向 psql 客户端实时输出消息,不需要等查询结束。

问题是, RAISE NOTICE 是 PL/pgSQL(PostgreSQL 的过程语言扩展)的语句,不是 SQL 命令。在 SQL 里写 SELECT RAISE NOTICE 'hello' 会直接报语法错误,CTE 里也一样。唯一的办法是:把 RAISE NOTICE 包在一个 PL/pgSQL 函数里,在 CTE 里调用这个函数。SQL 可以调用函数,函数体用 PL/pgSQL 写,而 PL/pgSQL 认识 RAISE NOTICE ,这就打通了。

CREATE OR REPLACE FUNCTION notify(str varchar) RETURNS void AS $$
BEGIN
    RAISE NOTICE '%', str;
END
$$ LANGUAGE PLPGSQL;

简单解释一下这个函数的语法: CREATE OR REPLACE FUNCTION 创建或替换一个函数(有则替换,没有则创建); notify(str varchar) 是函数名和参数,参数需要声明类型; RETURNS void 表示不返回值; $$ ... $$ 之间是函数体,用 BEGIN ... END 包裹; LANGUAGE PLPGSQL 声明函数体用的是 PL/pgSQL 语言。

然后在递归 CTE 的每一轮迭代中调用这个函数,就能实时输出游戏画面。唯一的副作用是每条消息前面会带 NOTICE: 前缀,用几个换行符就能藏起来。

跑耗时很长的查询时, RAISE NOTICE 是唯一能在执行过程中实时反馈进度的手段。

技巧三:dblink 绕过 MVCC 快照隔离

这是整个项目中最有教育价值的技巧。

游戏需要实时读取玩家的按键输入。自然想到的做法是创建一张输入表,让另一个进程往里写,查询在里面读:

CREATE TABLE Input (cmd char, ts timestamp);

但这里有一个坑。PostgreSQL 使用 MVCC(多版本并发控制,Multi-Version Concurrency Control)来管理并发访问。在默认的 READ COMMITTED 隔离级别下,每个语句能看到语句开始前已提交的数据。关键在于"语句开始前"这几个字。一条递归 CTE 就是一条语句,所以它始终看到的是语句开始时的数据快照。即使另一个进程往 Input 表写了新数据,递归 CTE 也看不到。

下面这个例子能清楚说明问题。初始数据是 'a' ,循环过程中另一个客户端把值改成了 'b' 再改成 'c' ,但循环始终只看到 'a'

INSERT INTO Input VALUES ('a', now());

WITH RECURSIVE main AS (
    SELECT notify('')
    UNION ALL
    SELECT notify(input.cmd)
    FROM main, input
)
SELECT * FROM main;
NOTICE:  a
NOTICE:  a  -- 第一个 UPDATE 之后,仍然看到 a
NOTICE:  a  -- 第二个 UPDATE 之后,仍然看到 a

tetris-sql 的解法是用 PostgreSQL 的 dblink 扩展(使用前需要先执行 CREATE EXTENSION IF NOT EXISTS dblink 安装)。这个扩展本来是查询远程数据库用的,但可以换个用法:每次迭代通过 dblink 建立一个新连接,发一次独立查询,这个查询在全新的事务快照中执行,所以能看到最新的输入数据。

WITH RECURSIVE main AS (
    WITH conn AS (
        SELECT 'conn' as name,
               dblink_connect('conn', 'dbname=' || current_database())
    )
    SELECT 1 as i, notify('')
    UNION ALL
    SELECT i + 1, notify(input.cmd)
    FROM main,
        conn,
        dblink(conn.name, 'SELECT * FROM Input --' || i)
            input(c char, ts timestamp)
)
SELECT * FROM main;
NOTICE:  a
NOTICE:  b  -- 第一个 UPDATE 后,看到了 b
NOTICE:  c  -- 第二个 UPDATE 后,看到了 c

还有一个细节:dblink 查询末尾拼了 -- || i 。这看起来像个注释,实际上是为了防止 PostgreSQL 缓存查询结果。如果不加这个后缀,PostgreSQL 会发现每轮迭代的 SQL 文本完全相同,就只执行一次然后把结果缓存起来。加上递增的 i 后,每轮迭代的 SQL 文本都不同,PostgreSQL 就必须每轮都真正执行一次。

遇到"为什么我的查询看不到最新数据"这种问题时,首先要想到 MVCC 快照隔离。 dblink 提供了一种在单个长查询中读取最新数据的逃生通道。

技巧四:一维数组表示棋盘

棋盘是一个 10 列 20 行的网格。直觉上应该用二维数组,但 tetris-sql 选择了展平成一维数组。

原因是 PostgreSQL 的二维数组操作不太方便。比如用下标取第一个元素:

SELECT (ARRAY[1,2,3])[1];  
 array
-------
     1
(1 row)

一维数组直接返回第一个元素,符合预期。但二维数组就出了意外:

SELECT (ARRAY[[1,2,3], [4,5,6]])[1];  
 array
-------

(1 row)

要取到值得写 (ARRAY[[1,2,3], [4,5,6]])[1][:]

SELECT (ARRAY[[1,2,3], [4,5,6]])[1][:];
   array
-----------
 {{1,2,3}}
(1 row)

返回的是一个二维切片 {{1,2,3}} ,而不是单个值。 unnest 展开二维数组时会直接拍平成一行,丢失行列结构。

所以 tetris-sql 用一维布尔数组表示棋盘: true 表示该格已填, false 表示空格。第 0 个元素对应左上角,最后一个元素对应右下角。

棋盘还有一个巧妙的设计:实际存储 11 列而不是 10 列,第 11 列始终为 true 。这个额外的"墙"简化了边界碰撞检测,方块向右移动时不需要额外判断是否碰到右边界,因为第 11 列的 true 自然会触发碰撞。

玩家看到的棋盘(左)和实际存储的棋盘(右):

|                    |   |                    []|
|                    |   |                    []|
|                    |   |                    []|
|    [][]        [][]|   |    [][]        [][][]|
|  [][][][]    [][][]|   |  [][][][]    [][][][]|
|  [][][][][][][][][]|   |  [][][][][][][][][][]|
+--------------------+   +----------------------+

消行的逻辑也很简洁:把棋盘按行切片,过滤掉全 true 的行(用 WHERE NOT line <@ ARRAY[true] ,其中 <@ 是 PostgreSQL 的"被包含"运算符,整句意思是排除只包含 true 的行),再把剩余行重新拼接成棋盘,顶部补上空行。第 11 列始终为 true 不影响这个算法,因为它在每一行里都存在,不会影响"该行是否全部填满"的判断。

性能特征

在 Ryzen 5 3600 上以 60 FPS 运行时,查询的 CPU 占用约为单线程的 4%。但内存方面有问题:PostgreSQL 无法丢弃递归 CTE 中不再需要的中间结果,所有中间数据都被保留。游戏运行时间越长,内存占用越大。

这是因为递归 CTE 最终只投影了最高分数,理论上 PostgreSQL 可以只保留当前行和最高分来优化内存。但目前的实现没有做这个优化。不过正如作者所说,连原版 NES 俄罗斯方块在长时间运行后也会崩溃,所以这个问题可以当作"feature"。

小结

tetris-sql 的真正价值是把几个平时不太容易遇到的 SQL 问题集中到了一起:

  1. 递归 CTE 不仅能遍历树形结构,还能实现通用的迭代计算
  2. RAISE NOTICE 是长时间查询中唯一能实时输出调试信息的方式
  3. 理解 MVCC 快照隔离对排查"看不到最新数据"的问题至关重要
  4. PostgreSQL 的二维数组有坑,一维数组加索引计算更可靠

这些技巧在数据迁移、ETL 管道、复杂报表查询等场景中都能派上用场。