[{"data":1,"prerenderedAt":9548},["ShallowReactive",2],{"\u002Ftutorials\u002Fpostgresql":3,"tutorials-search\u002Ftutorials\u002Fpostgresql":9212},{"id":4,"title":5,"body":6,"category":9201,"cover":9202,"date":9203,"description":9204,"extension":9205,"meta":9206,"navigation":1755,"path":9207,"seo":9208,"sitemap":9209,"stem":9210,"__hash__":9211},"tutorials\u002Ftutorials\u002Fpostgresql.md","PostgreSQL 教程：从建表查询到全文搜索与向量检索",{"type":7,"value":8,"toc":9099},"minimark",[9,14,23,30,200,204,207,214,218,221,231,252,262,283,287,290,294,297,381,384,417,425,459,463,517,520,571,590,594,613,630,636,640,663,667,683,698,714,728,741,751,764,768,933,937,940,944,954,960,1022,1033,1037,1054,1061,1067,1078,1093,1115,1119,1147,1177,1180,1184,1274,1279,1282,1297,1307,1317,1334,1364,1380,1384,1402,1405,1424,1428,1434,1475,1478,1493,1499,1529,1533,1679,1690,1693,1786,1792,1796,1807,1911,1924,1931,1986,1999,2014,2018,2021,2064,2092,2095,2098,2123,2196,2205,2211,2220,2239,2274,2290,2300,2309,2316,2349,2359,2368,2375,2408,2442,2445,2476,2479,2792,2795,3014,3017,3217,3224,3361,3367,3387,3394,3397,3408,4024,4029,4033,4040,4044,4078,4111,4115,4144,4147,4151,4157,4176,4186,4225,4237,4281,4295,4300,4303,4311,4326,4349,4359,4374,4383,4392,4398,4418,4438,4459,4477,4486,4490,4493,4497,4532,4548,4552,4595,4610,4614,4633,4636,4661,4671,4675,4684,4757,4773,4777,4780,4788,4803,4814,4823,4843,4856,4877,4908,4922,4937,4941,5048,5101,5105,5122,5126,5195,5198,5223,5227,5230,5249,5255,5294,5298,5307,5313,5323,5327,5330,5339,5389,5396,5404,5443,5453,5479,5495,5499,5502,5506,5525,5543,5547,5556,5586,5590,5600,5609,5619,5634,5637,5651,5660,5665,5668,5681,5711,5730,5739,5754,5761,5779,5797,5809,5813,5871,5990,5994,6001,6005,6008,6081,6084,6088,6102,6122,6129,6133,6151,6201,6211,6215,6218,6224,6229,6235,6242,6260,6275,6281,6295,6301,6307,6314,6320,6324,6413,6417,6421,6445,6452,6461,6468,6472,6478,6517,6524,6528,6531,6570,6573,6622,6628,6635,6664,6667,6677,6700,6710,6735,6745,6749,6752,6756,6774,6842,6863,6897,6901,6904,6977,6987,7003,7007,7010,7039,7046,7049,7062,7079,7108,7128,7138,7153,7165,7181,7196,7200,7203,7293,7297,7305,7377,7381,7388,7392,7479,7499,7505,7511,7515,7534,7540,7555,7558,7570,7576,7593,7605,7611,7615,7689,7696,7700,7704,7723,7727,7761,7790,7796,7799,7803,7809,7853,7871,7877,7882,7885,7895,7901,7920,7934,7952,7965,7985,7989,8060,8111,8115,8118,8122,8198,8201,8241,8259,8262,8276,8294,8307,8319,8329,8332,8337,8341,8421,8425,8432,8436,8527,8534,8538,8549,8564,8571,8596,8599,8603,8606,8669,8676,8680,8683,8696,8718,8727,8740,8746,8750,8807,8898,8902,8905,9089,9092,9095],[10,11,13],"h2",{"id":12},"一为什么是-postgresql先看清它和你熟悉的-mysql-差在哪","一、为什么是 PostgreSQL：先看清它和你熟悉的 MySQL 差在哪",[15,16,17,18,22],"p",{},"先还原一个常见场景。你写了多年 Java，库一直是 MySQL，MyBatis 一把梭；新项目立起来了，需求栏里躺着三行字：商品搜索要支持联想与相关度排序、App 要找「3 公里内的门店」、客服知识库要做语义检索。用 MySQL，这三件事意味着引入三个独立组件（Elasticsearch、专门的地理服务、向量数据库）；用 PostgreSQL，答案全是同一句：",[19,20,21],"strong",{},"装个扩展就行","。",[15,24,25,26,29],{},"这也是很多团队切 PG 的真实动机——不是因为它「更强」，而是因为它能",[19,27,28],{},"少养几个系统","。但动手之前，有几处差异值得先钉在墙上：",[31,32,33,52],"table",{},[34,35,36],"thead",{},[37,38,39,43,46,49],"tr",{},[40,41,42],"th",{},"维度",[40,44,45],{},"MySQL",[40,47,48],{},"PostgreSQL",[40,50,51],{},"对你的影响",[53,54,55,70,84,98,112,129,143,160,186],"tbody",{},[37,56,57,61,64,67],{},[58,59,60],"td",{},"SQL 标准与查询能力",[58,62,63],{},"够用，窗口\u002FCTE 补齐较晚",[58,65,66],{},"以「标准超集」著称：递归 CTE、DISTINCT ON、FILTER 聚合、LATERAL 都是原生能力",[58,68,69],{},"复杂报表、树遍历类需求少绕很多弯",[37,71,72,75,78,81],{},[58,73,74],{},"类型系统",[58,76,77],{},"数值 \u002F 字符串 \u002F 时间 \u002F JSON",[58,79,80],{},"多出数组、区间、jsonb、枚举、自定义类型",[58,82,83],{},"「逗号分隔字符串存多值」这类反模式在 PG 没有存在理由",[37,85,86,89,92,95],{},[58,87,88],{},"MVCC 实现",[58,90,91],{},"回滚段 undo log",[58,93,94],{},"每行多版本元组，读不加锁",[58,96,97],{},"第九章整章都在讲它带来的行为差异",[37,99,100,103,106,109],{},[58,101,102],{},"默认隔离级别",[58,104,105],{},"REPEATABLE READ",[58,107,108],{},"READ COMMITTED",[58,110,111],{},"语义不同，迁移时最容易想当然",[37,113,114,117,120,126],{},[58,115,116],{},"外键自动建索引",[58,118,119],{},"InnoDB 会",[58,121,122,125],{},[19,123,124],{},"不会","，要自己建",[58,127,128],{},"第七章的大坑，先记下",[37,130,131,134,137,140],{},[58,132,133],{},"扩展生态",[58,135,136],{},"插件相对少",[58,138,139],{},"全文搜索 \u002F PostGIS \u002F pgvector 都是「一个扩展装完就能用」",[58,141,142],{},"第十三到十五章的三大主角",[37,144,145,148,151,157],{},[58,146,147],{},"连接模型",[58,149,150],{},"每连接一个线程",[58,152,153,154],{},"每连接一个",[19,155,156],{},"进程",[58,158,159],{},"连接更贵，连接池几乎是必选项",[37,161,162,165,172,183],{},[58,163,164],{},"标识符大小写",[58,166,167,168],{},"表名大小写敏感性取决于 ",[169,170,171],"code",{},"lower_case_table_names",[58,173,174,175,178,179,182],{},"不加引号的标识符",[19,176,177],{},"一律转小写","；建表时用了 ",[169,180,181],{},"\"Users\""," 这种带引号的大写，以后每次查询都得引号伺候",[58,184,185],{},"建表 SQL 里对标识符一律不加双引号",[37,187,188,191,194,197],{},[58,189,190],{},"许可协议",[58,192,193],{},"GPL（商业需授权）",[58,195,196],{},"PostgreSQL License（类 MIT）",[58,198,199],{},"商用无负担",[201,202,203],"h3",{"id":203},"这门课的路线图",[15,205,206],{},"第二到十二章是地基：安装连接 → 基本结构 → 建表 → 单表查询 → 函数 → 多表 → 索引 → 事务与锁 → 视图与 CTE → 权限 → 备份恢复。第十三到十五章是三个扩展专题（全文搜索 \u002F 地理位置 \u002F 向量），可以按需跳读，也可以顺着读——它们正好是第一章那张对比表里「扩展生态」的现场证明。第十六章是一份上线核对清单。",[15,208,209,210,213],{},"浏览器里跑不了 SQL，所以示例沿用 Linux \u002F Docker 教程的做法：",[19,211,212],{},"写死真实输出的模拟终端","，点按钮逐条回放命令与 psql 的真实打印；三个扩展章节另配了可交互的小页面——数据是静态样本，但打分、测距、找近邻的计算是真算，不是录像。",[201,215,217],{"id":216},"️-两处最容易想当然的差异","⚠️ 两处最容易想当然的差异",[15,219,220],{},"上面那张表里，有两行值得在动手前先展开说透，它们会贯穿整门课。",[15,222,223,226,227,230],{},[19,224,225],{},"每连接一个进程。"," MySQL 里一个连接是一个线程，PG 里主进程 ",[169,228,229],{},"postmaster"," 会为每个连接 fork 一个 backend 进程，内存几 MB 起步。两个直接推论：",[232,233,234,242],"ol",{},[235,236,237,238,241],"li",{},"连接是贵重资源，",[169,239,240],{},"max_connections"," 别照 MySQL 的习惯开几千——100~300 之间，前面再顶一层 PgBouncer 连接池（对习惯 HikariCP 的 Java 同学来说：应用的池要收小，池外面还有一层池）；",[235,243,244,245,248,249,22],{},"进程模型换来一套独特的崩溃语义：单条 SQL 报错只影响它自己；但如果某个 backend 真的段错误了，postmaster 的策略是",[19,246,247],{},"把全部连接断开、重置共享内存再迎接新连接","——宁可全体重启，也不带病运行。线上看到「一夜之间所有连接同时断开重连」，先去翻 PG 日志里的 ",[169,250,251],{},"server process was terminated by signal",[15,253,254,257,258,261],{},[19,255,256],{},"默认隔离级别不同。"," MySQL InnoDB 默认 REPEATABLE READ，PG 默认 READ COMMITTED。看着是配置差异，实际是行为差异：同一个事务里两次执行相同的 SELECT，MySQL 默认读到同一个结果，PG 默认第二次能看到别人",[19,259,260],{},"已提交","的新数据。如果迁移过来的代码依赖「事务内复读」，第九章会给出三种处理姿势。",[15,263,264,265,268,269,272,273,276,277,280,281,22],{},"还有一条建表当天就会碰到的：",[19,266,267],{},"不加引号的标识符一律转小写","。写成 ",[169,270,271],{},"CREATE TABLE \"Users\" (...)"," 之后，每次查询都得 ",[169,274,275],{},"\"users\""," 带引号伺候，漏一次就报 ",[169,278,279],{},"relation \"users\" does not exist","。规矩很简单——",[19,282,185],{},[10,284,286],{"id":285},"二安装与第一次连接三种姿势与-psql-的肌肉记忆","二、安装与第一次连接：三种姿势与 psql 的肌肉记忆",[15,288,289],{},"本章分三段：先一条命令把库起起来连上（第一步），再看三种安装姿势和 psql 的基本功（第二到四步），章末集中放这一章会撞的坑与命令速查表。",[201,291,293],{"id":292},"第一步一条命令起一个能连的库","第一步：一条命令起一个能连的库",[15,295,296],{},"本地想有个能折腾的 PG，Docker 最省事：",[298,299,304],"pre",{"className":300,"code":301,"language":302,"meta":303,"style":303},"language-bash shiki shiki-themes github-dark","docker run -d --name pg-lab \\\n  -e POSTGRES_PASSWORD=lab123456 \\\n  -e POSTGRES_DB=shop \\\n  -p 5432:5432 \\\n  -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata \\\n  postgres:17\n","bash","",[169,305,306,332,343,353,364,375],{"__ignoreMap":303},[307,308,311,315,319,323,326,329],"span",{"class":309,"line":310},"line",1,[307,312,314],{"class":313},"svObZ","docker",[307,316,318],{"class":317},"sU2Wk"," run",[307,320,322],{"class":321},"sDLfK"," -d",[307,324,325],{"class":321}," --name",[307,327,328],{"class":317}," pg-lab",[307,330,331],{"class":321}," \\\n",[307,333,335,338,341],{"class":309,"line":334},2,[307,336,337],{"class":321},"  -e",[307,339,340],{"class":317}," POSTGRES_PASSWORD=lab123456",[307,342,331],{"class":321},[307,344,346,348,351],{"class":309,"line":345},3,[307,347,337],{"class":321},[307,349,350],{"class":317}," POSTGRES_DB=shop",[307,352,331],{"class":321},[307,354,356,359,362],{"class":309,"line":355},4,[307,357,358],{"class":321},"  -p",[307,360,361],{"class":317}," 5432:5432",[307,363,331],{"class":321},[307,365,367,370,373],{"class":309,"line":366},5,[307,368,369],{"class":321},"  -v",[307,371,372],{"class":317}," pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata",[307,374,331],{"class":321},[307,376,378],{"class":309,"line":377},6,[307,379,380],{"class":317},"  postgres:17\n",[15,382,383],{},"等两三秒，连上去：",[298,385,387],{"className":300,"code":386,"language":302,"meta":303,"style":303},"psql -h 127.0.0.1 -p 5432 -U postgres -d shop\n",[169,388,389],{"__ignoreMap":303},[307,390,391,394,397,400,403,406,409,412,414],{"class":309,"line":310},[307,392,393],{"class":313},"psql",[307,395,396],{"class":321}," -h",[307,398,399],{"class":321}," 127.0.0.1",[307,401,402],{"class":321}," -p",[307,404,405],{"class":321}," 5432",[307,407,408],{"class":321}," -U",[307,410,411],{"class":317}," postgres",[307,413,322],{"class":321},[307,415,416],{"class":317}," shop\n",[298,418,423],{"className":419,"code":421,"language":422,"meta":303},[420],"language-text","psql (17.x)\nType \"help\" for help.\n\nshop=# SELECT version();\n                          version\n-----------------------------------------------------------\n PostgreSQL 17.x on x86_64-pc-linux-gnu, compiled by gcc...\n(1 row)\n","text",[169,424,421],{"__ignoreMap":303},[15,426,427,428,431,432,435,436,22,439,442,443,446,447,450,451,454,455,458],{},"看到提示符变成 ",[169,429,430],{},"shop=#"," 就连上了：",[169,433,434],{},"#"," 表示超级用户，普通用户是 ",[169,437,438],{},"shop=>",[169,440,441],{},"\\q"," 退出。四个参数的含义：",[169,444,445],{},"POSTGRES_PASSWORD"," 必填（缺了容器直接起不来）、",[169,448,449],{},"POSTGRES_DB"," 顺手建好第一个业务库、",[169,452,453],{},"-p"," 把容器内 5432 映射到宿主、",[169,456,457],{},"-v"," 挂的卷是数据本体。",[201,460,462],{"id":461},"第二步三种安装方式选一种就够","第二步：三种安装方式，选一种就够",[31,464,465,478],{},[34,466,467],{},[37,468,469,472,475],{},[40,470,471],{},"方式",[40,473,474],{},"适合谁",[40,476,477],{},"关键点",[53,479,480,495,506],{},[37,481,482,485,488],{},[58,483,484],{},"Docker",[58,486,487],{},"跟着本教程做实验、本机多版本并存",[58,489,490,491,494],{},"数据必须挂数据卷，否则 ",[169,492,493],{},"docker rm"," 之日就是数据消失之时（Docker 教程第九章算过这笔账）",[37,496,497,500,503],{},[58,498,499],{},"系统包管理器（PGDG 源）",[58,501,502],{},"Linux 服务器长期运行",[58,504,505],{},"Debian\u002FUbuntu 官方源版本偏旧，加 PGDG 源拿新版本",[37,507,508,511,514],{},[58,509,510],{},"Windows 安装器",[58,512,513],{},"本机只想有个能连的库",[58,515,516],{},"EDB 安装器一路下一步，装完自带 pgAdmin 图形客户端",[15,518,519],{},"服务器上长期跑，用包管理器：",[298,521,523],{"className":300,"code":522,"language":302,"meta":303,"style":303},"sudo apt install postgresql-17          # 先按官方文档加 PGDG 源\nsudo systemctl status postgresql@17-main\nsudo -u postgres psql                   # 本地 peer 认证，免密直接进\n",[169,524,525,543,556],{"__ignoreMap":303},[307,526,527,530,533,536,539],{"class":309,"line":310},[307,528,529],{"class":313},"sudo",[307,531,532],{"class":317}," apt",[307,534,535],{"class":317}," install",[307,537,538],{"class":317}," postgresql-17",[307,540,542],{"class":541},"sAwPA","          # 先按官方文档加 PGDG 源\n",[307,544,545,547,550,553],{"class":309,"line":334},[307,546,529],{"class":313},[307,548,549],{"class":317}," systemctl",[307,551,552],{"class":317}," status",[307,554,555],{"class":317}," postgresql@17-main\n",[307,557,558,560,563,565,568],{"class":309,"line":345},[307,559,529],{"class":313},[307,561,562],{"class":321}," -u",[307,564,411],{"class":317},[307,566,567],{"class":317}," psql",[307,569,570],{"class":541},"                   # 本地 peer 认证，免密直接进\n",[15,572,573,574,577,578,581,582,585,586,589],{},"Debian 系装完会自动 initdb 一个「数据库簇」，并创建同名 OS 用户 ",[169,575,576],{},"postgres","（数据库管理员同体），",[169,579,580],{},"sudo -u postgres psql"," 相当于 MySQL 里的 ",[169,583,584],{},"mysql -u root"," 免密进。另外容器场景有个偷懒通道：",[169,587,588],{},"docker exec -it pg-lab psql -U postgres -d shop","，走容器内部 socket，不用输密码——只限实验环境。",[201,591,593],{"id":592},"第三步psql-里先练这四个命令","第三步：psql 里先练这四个命令",[15,595,596,597,600,601,604,605,608,609,612],{},"连上之后，日常 90% 的操作就四个：",[169,598,599],{},"\\dt"," 看有哪些表、",[169,602,603],{},"\\d 表名"," 看单表结构、",[169,606,607],{},"\\l"," 看有哪些库、",[169,610,611],{},"\\timing on"," 打印每条 SQL 的耗时。",[15,614,615,618,619,621,622,625,626,629],{},[169,616,617],{},"\\d"," 是用得最多的一个：建完表不用猜索引建没建对，",[169,620,603],{}," 一屏把列、类型、索引、约束全看到。完整命令表（含 ",[169,623,624],{},"\\x","、",[169,627,628],{},"\\copy"," 等）在本章末尾的速查区。",[631,632],"demo-html",{"height":633,"src":634,"title":635},460,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F02-install-psql.html","示例 02：安装与第一次连接——psql 基本功",[201,637,639],{"id":638},"第四步不想每次输密码","第四步：不想每次输密码",[15,641,642,643,646,647,650,651,654,655,658,659,662],{},"不想每次交互输密码，两种姿势：临时用环境变量 ",[169,644,645],{},"PGPASSWORD=... psql ...","；长期用 ",[169,648,649],{},"~\u002F.pgpass","，格式一行一条 ",[169,652,653],{},"主机:端口:数据库:用户:密码","。超级用户第一件事建议 ",[169,656,657],{},"\\password"," 改掉弱密码——谁能连、用什么方式认证，由 ",[169,660,661],{},"pg_hba.conf"," 把关，第十一章展开。",[201,664,666],{"id":665},"️-本章的坑","⚠️ 本章的坑",[15,668,669,675,676,679,680,682],{},[19,670,671,672,674],{},"1. 没挂数据卷 = ",[169,673,493],{}," 之后数据全没。"," ",[169,677,678],{},"-v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata"," 这一行是数据本体，删容器不带 ",[169,681,457],{}," 参数的重建就是清库。",[15,684,685,688,689,625,691,693,694,697],{},[19,686,687],{},"2. 只有卷为空时才会 initdb。"," 所以改了 ",[169,690,445],{},[169,692,449],{}," 这些环境变量，重启老容器不会生效——它们只在第一次初始化时起作用。想重来，得连卷一起删（",[169,695,696],{},"docker volume rm pg-lab-data","）。",[15,699,700,706,707,710,711,713],{},[19,701,702,703,22],{},"3. 镜像 tag 写大版本号，别写 ",[169,704,705],{},"latest"," 跨大版本（16 → 17）不是换个 tag 拉个新镜像就完事，要靠 ",[169,708,709],{},"pg_upgrade"," 迁移；写 ",[169,712,705],{}," 等于把版本交给别人决定。",[15,715,716,723,724,727],{},[19,717,718,719,722],{},"4. SQL 必须以 ",[169,720,721],{},";"," 结尾才执行。"," 多行语句写到一半没分号，提示符会变成 ",[169,725,726],{},"shop-"," 静静等你，看着像「卡死」，其实是它以为你还没写完——补个分号回车就回来了。",[15,729,730,737,738,22],{},[19,731,732,733,736],{},"5. ",[169,734,735],{},".pgpass"," 权限不是 600 会被直接忽略。"," PG 认为别人能读的密码文件不可信，报的还是「密码认证失败」这种不相干的错，很难往这上面想。",[169,739,740],{},"chmod 600 ~\u002F.pgpass",[15,742,743,746,747,750],{},[19,744,745],{},"6. Debian\u002FUbuntu 官方源的版本偏旧。"," 想跟这门课用 17，要按官方文档加 PGDG 源，否则 ",[169,748,749],{},"apt install postgresql"," 装出来的是十几年前语义的老版本。",[15,752,753,760,761,763],{},[19,754,755,756,759],{},"7. ",[169,757,758],{},"docker exec"," 那条免密通道只限实验。"," 它走容器内部 socket，绕过了 ",[169,762,661],{}," 的密码认证；生产环境照抄等于给自己留了个免认证入口。",[201,765,767],{"id":766},"附psql-反斜杠命令速查","附：psql 反斜杠命令速查",[31,769,770,783],{},[34,771,772],{},[37,773,774,777,780],{},[40,775,776],{},"反斜杠命令",[40,778,779],{},"作用",[40,781,782],{},"MySQL 里的对应",[53,784,785,799,814,828,848,861,876,888,903,918],{},[37,786,787,791,794],{},[58,788,789],{},[169,790,607],{},[58,792,793],{},"列出所有数据库",[58,795,796],{},[169,797,798],{},"SHOW DATABASES",[37,800,801,806,809],{},[58,802,803],{},[169,804,805],{},"\\c shop",[58,807,808],{},"切换数据库",[58,810,811],{},[169,812,813],{},"USE shop",[37,815,816,820,823],{},[58,817,818],{},[169,819,599],{},[58,821,822],{},"列出当前 schema 的表",[58,824,825],{},[169,826,827],{},"SHOW TABLES",[37,829,830,835,842],{},[58,831,832],{},[169,833,834],{},"\\d orders",[58,836,837,838,841],{},"看表结构：列、类型、",[19,839,840],{},"索引、约束","一起看",[58,843,844,847],{},[169,845,846],{},"DESC orders"," 的加强版",[37,849,850,855,858],{},[58,851,852],{},[169,853,854],{},"\\dn",[58,856,857],{},"列出所有 schema",[58,859,860],{},"无对应",[37,862,863,868,871],{},[58,864,865],{},[169,866,867],{},"\\du",[58,869,870],{},"列出角色与成员关系",[58,872,873],{},[169,874,875],{},"SELECT user, host FROM mysql.user",[37,877,878,882,885],{},[58,879,880],{},[169,881,611],{},[58,883,884],{},"之后每条 SQL 打印执行耗时",[58,886,887],{},"无对应，调优刚需",[37,889,890,895,898],{},[58,891,892],{},[169,893,894],{},"\\x on",[58,896,897],{},"宽表竖排显示（列多时救命）",[58,899,900],{},[169,901,902],{},"\\G",[37,904,905,910,913],{},[58,906,907],{},[169,908,909],{},"\\i init.sql",[58,911,912],{},"执行脚本文件",[58,914,915],{},[169,916,917],{},"SOURCE",[37,919,920,924,927],{},[58,921,922],{},[169,923,628],{},[58,925,926],{},"导入导出 CSV",[58,928,929,932],{},[169,930,931],{},"LOAD DATA INFILE","（第十二章细讲）",[10,934,936],{"id":935},"三基本结构实例数据库schema-与表多出来的那一层是什么","三、基本结构：实例、数据库、schema 与表——多出来的那一层是什么",[15,938,939],{},"本章分三段：先看清 PG 比 MySQL 多出来的那层是什么（第一步），再动手建一个 schema 把表放进去（第二步），然后把建库时一次定死、事后改不了的几件事放到前面（第三、四步），章末集中收坑。",[201,941,943],{"id":942},"第一步多出来的那一层是-schema","第一步：多出来的那一层是 schema",[15,945,946,947,949,950,953],{},"PG 的层级比 MySQL 多一层。一个实例（",[169,948,229],{}," 进程 + 共享内存 + 一份数据目录，官方术语叫 ",[19,951,952],{},"cluster","，数据库簇——和「分库分表集群」不是一个东西，别被名字吓到）下面挂多个 database，database 里再分 schema，schema 里才是表、序列、函数、视图：",[298,955,958],{"className":956,"code":957,"language":422,"meta":303},[420],"实例（postmaster + 共享内存 + 数据目录，官方叫 cluster）\n ├── 数据库 postgres        —— 管理员的常驻地\n ├── 数据库 shop            —— 业务库\n │    ├── schema public     —— 默认命名空间\n │    └── schema business   —— 自定义命名空间：把「订单域」整组收拢\n │         ├── 表 orders\n │         └── 序列 \u002F 函数 \u002F 视图 \u002F 索引……都挂在 schema 里\n ├── 数据库 template1       —— 建库的默认模板\n └── 数据库 template0       —— 纯净模板\n",[169,959,957],{"__ignoreMap":303},[31,961,962,974],{},[34,963,964],{},[37,965,966,969,972],{},[40,967,968],{},"层级",[40,970,971],{},"是什么",[40,973,782],{},[53,975,976,987,998,1012],{},[37,977,978,981,984],{},[58,979,980],{},"实例（簇）",[58,982,983],{},"postmaster + 共享内存 + 一份数据目录",[58,985,986],{},"mysqld 实例",[37,988,989,992,995],{},[58,990,991],{},"database",[58,993,994],{},"相互完全隔离的数据库，各自有自己的目录",[58,996,997],{},"database（库）",[37,999,1000,1003,1006],{},[58,1001,1002],{},"schema",[58,1004,1005],{},"库内的命名空间 + 授权单元",[58,1007,1008,1011],{},[19,1009,1010],{},"没有","——MySQL 库直接管表",[37,1013,1014,1017,1020],{},[58,1015,1016],{},"表",[58,1018,1019],{},"数据本身",[58,1021,31],{},[15,1023,1024,1025,1028,1029,1032],{},"多出这层 schema 不是官僚主义：一是",[19,1026,1027],{},"跨 database 不能直接 JOIN","（要上 postgres_fdw 这类外部包装器），但跨 schema 只要写前缀 ",[169,1030,1031],{},"business.orders"," 就行——所以「把 MySQL 的多个小库合成 PG 一个库的多个 schema」是很常见的迁移姿势，原本的跨库查询全部变通；二是权限可以按 schema 整组授予，第十一章的最小权限模板会用到。",[201,1034,1036],{"id":1035},"第二步建一个-schema把表放进去","第二步：建一个 schema，把表放进去",[298,1038,1042],{"className":1039,"code":1040,"language":1041,"meta":303,"style":303},"language-sql shiki shiki-themes github-dark","CREATE SCHEMA business;\nCREATE TABLE business.orders (id bigint, note text);\n","sql",[169,1043,1044,1049],{"__ignoreMap":303},[307,1045,1046],{"class":309,"line":310},[307,1047,1048],{},"CREATE SCHEMA business;\n",[307,1050,1051],{"class":309,"line":334},[307,1052,1053],{},"CREATE TABLE business.orders (id bigint, note text);\n",[15,1055,1056,1057,1060],{},"不带前缀的表名去哪找？由 ",[169,1058,1059],{},"search_path"," 决定：",[298,1062,1065],{"className":1063,"code":1064,"language":422,"meta":303},[420],"shop=# SHOW search_path;\n   search_path\n-----------------\n \"$user\", public\n(1 row)\n",[169,1066,1064],{"__ignoreMap":303},[15,1068,1069,1070,1073,1074,1077],{},"默认值是「与我同名的 schema，再 public」，第一个 ",[169,1071,1072],{},"\"$user\""," 99% 的情况下不存在，实际命中的都是 ",[169,1075,1076],{},"public","。把刚建的 schema 排到前面：",[298,1079,1081],{"className":1039,"code":1080,"language":1041,"meta":303,"style":303},"SET search_path = business, public;\nSELECT * FROM orders;              -- 等价于 business.orders\n",[169,1082,1083,1088],{"__ignoreMap":303},[307,1084,1085],{"class":309,"line":310},[307,1086,1087],{},"SET search_path = business, public;\n",[307,1089,1090],{"class":309,"line":334},[307,1091,1092],{},"SELECT * FROM orders;              -- 等价于 business.orders\n",[15,1094,1095,1096,1099,1100,625,1103,1106,1107,1110,1111,1114],{},"系统表所在的 ",[169,1097,1098],{},"pg_catalog"," 永远隐含在最前面，所以 ",[169,1101,1102],{},"pg_class",[169,1104,1105],{},"pg_settings"," 这类系统表名不会和你业务表打架。想临时改一次用上面的 ",[169,1108,1109],{},"SET","，想整个会话\u002F账号长期生效用 ",[169,1112,1113],{},"ALTER ROLE ... SET search_path","（第十一章的授权模板里就有这一句）。",[201,1116,1118],{"id":1117},"第三步建库时把编码和排序规则一次定死","第三步：建库时把编码和排序规则一次定死",[15,1120,1121,1123,1124,625,1126,625,1129,1132,1133,1136,1137,1140,1141,1143,1144,1146],{},[169,1122,607],{}," 里永远能看到三个库：",[169,1125,576],{},[169,1127,1128],{},"template1",[169,1130,1131],{},"template0","。PG 的 ",[169,1134,1135],{},"CREATE DATABASE"," 本质是",[19,1138,1139],{},"复制一份模板库","——默认复制 ",[169,1142,1128],{},"；",[169,1145,1131],{}," 则是出厂纯净版，还能在建库时指定编码和排序规则（这是 template1 做不到的）：",[298,1148,1150],{"className":1039,"code":1149,"language":1041,"meta":303,"style":303},"CREATE DATABASE shop\n  TEMPLATE template0\n  ENCODING 'UTF8'\n  LC_COLLATE 'C.UTF-8'\n  LC_CTYPE 'C.UTF-8';\n",[169,1151,1152,1157,1162,1167,1172],{"__ignoreMap":303},[307,1153,1154],{"class":309,"line":310},[307,1155,1156],{},"CREATE DATABASE shop\n",[307,1158,1159],{"class":309,"line":334},[307,1160,1161],{},"  TEMPLATE template0\n",[307,1163,1164],{"class":309,"line":345},[307,1165,1166],{},"  ENCODING 'UTF8'\n",[307,1168,1169],{"class":309,"line":355},[307,1170,1171],{},"  LC_COLLATE 'C.UTF-8'\n",[307,1173,1174],{"class":309,"line":366},[307,1175,1176],{},"  LC_CTYPE 'C.UTF-8';\n",[15,1178,1179],{},"建库这一步把编码和 collation 定死，是第十六章核对清单的第一行，原因见本章坑 2。",[201,1181,1183],{"id":1182},"第四步必调的四个参数","第四步：必调的四个参数",[31,1185,1186,1202],{},[34,1187,1188],{},[37,1189,1190,1193,1196,1199],{},[40,1191,1192],{},"参数",[40,1194,1195],{},"默认",[40,1197,1198],{},"建议",[40,1200,1201],{},"备注",[53,1203,1204,1220,1239,1254],{},[37,1205,1206,1211,1214,1217],{},[58,1207,1208],{},[169,1209,1210],{},"shared_buffers",[58,1212,1213],{},"128MB",[58,1215,1216],{},"物理内存的 25% 左右",[58,1218,1219],{},"PG 自己的热数据缓存。注意它在 OS 页缓存之外又算一层，别给到 50%+",[37,1221,1222,1227,1230,1233],{},[58,1223,1224],{},[169,1225,1226],{},"work_mem",[58,1228,1229],{},"4MB",[58,1231,1232],{},"16~64MB 起步",[58,1234,1235,1238],{},[19,1236,1237],{},"单个排序 \u002F 哈希操作","的内存预算：一条 SQL 里可以用多个，再乘以并发连接数——盲目调大是内存事故的头号来源",[37,1240,1241,1245,1248,1251],{},[58,1242,1243],{},[169,1244,240],{},[58,1246,1247],{},"100",[58,1249,1250],{},"≤300，前面加连接池",[58,1252,1253],{},"每连接是进程，开 2000 个先想清楚内存",[37,1255,1256,1261,1264,1267],{},[58,1257,1258],{},[169,1259,1260],{},"effective_cache_size",[58,1262,1263],{},"4GB",[58,1265,1266],{},"约等于 OS 可用页缓存",[58,1268,1269,1270,1273],{},"只影响优化器的成本估算，",[19,1271,1272],{},"不实际分配内存","——填小了优化器会不敢用索引",[631,1275],{"height":1276,"src":1277,"title":1278},480,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F03-cluster-structure.html","示例 03：数据库簇的结构观察——\\l \u002F \\dn \u002F search_path \u002F 参数",[201,1280,666],{"id":1281},"️-本章的坑-1",[15,1283,1284,1290,1291,1293,1294,22],{},[19,1285,1286,1287,1289],{},"1. 往 ",[169,1288,1128],{}," 里建的东西，以后每个新库都会带着。"," 它是建库的默认模板，你在里面留了张测试表，之后 ",[169,1292,1135],{}," 出来的每个库都有这张表。要改模板先想清楚这一点，需要干净起点时用 ",[169,1295,1296],{},"TEMPLATE template0",[15,1298,1299,1302,1303,1306],{},[19,1300,1301],{},"2. 排序规则（collation）建库之后改不了","，而字符串索引的顺序完全依赖它——升个 glibc 版本导致 collation 行为变化、索引悄悄损坏的事故是有真实案例的。所以第三步那次「建库就定死」不是形式主义；已经建错了的库只能 ",[169,1304,1305],{},"pg_dump"," 出来重建。",[15,1308,1309,1312,1313,1316],{},[19,1310,1311],{},"3. 跨 database 不能 JOIN。"," 想当然照 MySQL 的 ",[169,1314,1315],{},"db1.t1 JOIN db2.t2"," 写会直接报错。要么用 postgres_fdw 挂外部表，要么按第一步说的：一个库、多个 schema。",[15,1318,1319,1325,1326,1329,1330,1333],{},[19,1320,1321,1322,1324],{},"4. PG 15 起 ",[169,1323,1076],{}," schema 默认收回普通用户的建表权。"," 普通用户 ",[169,1327,1328],{},"CREATE TABLE"," 会报 ",[169,1331,1332],{},"permission denied for schema public","。这不是 bug，是安全默认值——正确姿势是给业务单独建 schema、单独授权，第十一章给出整套模板。",[15,1335,1336,1341,1342,1345,1346,1348,1349,1352,1353,1356,1357,1360,1361,1363],{},[19,1337,732,1338,1340],{},[169,1339,1059],{}," 里排前面的 schema 会遮蔽后面的同名表。"," 如果 ",[169,1343,1344],{},"business"," 和 ",[169,1347,1076],{}," 里都有 ",[169,1350,1351],{},"orders","，",[169,1354,1355],{},"SELECT * FROM orders"," 只会命中排在前面的那个。生产代码里跨 schema 的表",[19,1358,1359],{},"写全前缀","，或把 ",[169,1362,1059],{}," 固定成一组明确的 schema，别让它随会话漂移。",[15,1365,1366,1372,1373,1375,1376,1379],{},[19,1367,1368,1369,1371],{},"6. ",[169,1370,1226],{}," 的账要会算：它不是「每个连接」的预算，而是「每个排序\u002F哈希节点」。"," 100 个连接、每条查询里两个 64MB 的排序节点，理论峰值就是 100 × 128MB = 12.8GB。调它之前先用 ",[169,1374,611],{}," + ",[169,1377,1378],{},"EXPLAIN"," 确认瓶颈真的是排序\u002F哈希（第八章），而不是无脑加。",[10,1381,1383],{"id":1382},"四表结构定义类型选对索引的锅就少背一半","四、表结构定义：类型选对，索引的锅就少背一半",[15,1385,1386,1387,1390,1391,1394,1395,1397,1398,1401],{},"场景定型：这门课的示例业务是一个小电商——",[169,1388,1389],{},"users","（用户）、",[169,1392,1393],{},"products","（商品）、",[169,1396,1351],{},"（订单）、",[169,1399,1400],{},"order_items","（订单明细）。",[15,1403,1404],{},"本章分三段读，第一遍只看第一段就够了：",[232,1406,1407,1413,1418],{},[235,1408,1409,1412],{},[19,1410,1411],{},"入门四步","：建出能跑的表、类型怎么选、四张表怎么连起来、jsonb 与数组怎么查——全是可直接抄的最小示例；",[235,1414,1415,1417],{},[19,1416,666],{},"：8 个真实报错和它们的成因，等表建起来了再回头看；",[235,1419,1420,1423],{},[19,1421,1422],{},"附：类型速查表","：字节长度、Java 类型、MySQL → PG 迁移对照，写 DDL 时查，不用通读。",[201,1425,1427],{"id":1426},"第一步先建出一张能跑的表","第一步：先建出一张能跑的表",[15,1429,1430,1431,1433],{},"最小的 ",[169,1432,1389],{}," 表，一行一个字段：",[298,1435,1437],{"className":1039,"code":1436,"language":1041,"meta":303,"style":303},"CREATE TABLE users (\n  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n  name    text NOT NULL,                                    -- 用户名，必填\n  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n);\n",[169,1438,1439,1444,1449,1454,1459,1464,1469],{"__ignoreMap":303},[307,1440,1441],{"class":309,"line":310},[307,1442,1443],{},"CREATE TABLE users (\n",[307,1445,1446],{"class":309,"line":334},[307,1447,1448],{},"  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n",[307,1450,1451],{"class":309,"line":345},[307,1452,1453],{},"  name    text NOT NULL,                                    -- 用户名，必填\n",[307,1455,1456],{"class":309,"line":355},[307,1457,1458],{},"  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n",[307,1460,1461],{"class":309,"line":366},[307,1462,1463],{},"  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n",[307,1465,1466],{"class":309,"line":377},[307,1467,1468],{},"  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n",[307,1470,1472],{"class":309,"line":1471},7,[307,1473,1474],{},");\n",[15,1476,1477],{},"插一条、查一条，确认它真的活了：",[298,1479,1481],{"className":1039,"code":1480,"language":1041,"meta":303,"style":303},"INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\nSELECT id, name, email, created_at FROM users;\n",[169,1482,1483,1488],{"__ignoreMap":303},[307,1484,1485],{"class":309,"line":310},[307,1486,1487],{},"INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\n",[307,1489,1490],{"class":309,"line":334},[307,1491,1492],{},"SELECT id, name, email, created_at FROM users;\n",[298,1494,1497],{"className":1495,"code":1496,"language":422,"meta":303},[420]," id | name | email                |       created_at\n----+------+----------------------+-------------------------\n  1 | 小明 | xiaoming@example.com | 2026-10-16 11:20:31+08\n",[169,1498,1496],{"__ignoreMap":303},[15,1500,1501,1502,1505,1506,1509,1510,1513,1514,625,1517,625,1520,1352,1523,1525,1526,22],{},"注意 ",[169,1503,1504],{},"INSERT"," 里没有 ",[169,1507,1508],{},"id","——",[169,1511,1512],{},"GENERATED ALWAYS AS IDENTITY"," 就是 PG 里「自增主键」的标准写法，值由数据库生成。Java 实体对应关系是 ",[169,1515,1516],{},"Long id",[169,1518,1519],{},"String name",[169,1521,1522],{},"OffsetDateTime createdAt",[169,1524,422],{}," 一律映射成 ",[169,1527,1528],{},"String",[201,1530,1532],{"id":1531},"第二步类型怎么选先记住这六个","第二步：类型怎么选，先记住这六个",[31,1534,1535,1551],{},[34,1536,1537],{},[37,1538,1539,1542,1545,1548],{},[40,1540,1541],{},"存什么",[40,1543,1544],{},"就用它",[40,1546,1547],{},"Java 类型",[40,1549,1550],{},"一句话理由",[53,1552,1553,1571,1593,1618,1636,1661],{},[37,1554,1555,1558,1563,1568],{},[58,1556,1557],{},"主键、外键、ID",[58,1559,1560],{},[169,1561,1562],{},"bigint",[58,1564,1565],{},[169,1566,1567],{},"Long",[58,1569,1570],{},"64 位整型，雪花 ID 也装得下，不会哪天爆掉",[37,1572,1573,1576,1580,1584],{},[58,1574,1575],{},"任何文本（短用户名 \u002F 长正文）",[58,1577,1578],{},[169,1579,422],{},[58,1581,1582],{},[169,1583,1528],{},[58,1585,1586,1587,1345,1589,1592],{},"PG 里 ",[169,1588,422],{},[169,1590,1591],{},"varchar"," 是同一种实现，不用纠结长度",[37,1594,1595,1598,1603,1608],{},[58,1596,1597],{},"金额",[58,1599,1600],{},[169,1601,1602],{},"numeric(12, 2)",[58,1604,1605],{},[169,1606,1607],{},"BigDecimal",[58,1609,1610,1611,1614,1615],{},"精确十进制，",[169,1612,1613],{},"0.1 + 0.2"," 不会等于 ",[169,1616,1617],{},"0.30000000000000004",[37,1619,1620,1623,1628,1633],{},[58,1621,1622],{},"时间",[58,1624,1625],{},[169,1626,1627],{},"timestamptz",[58,1629,1630],{},[169,1631,1632],{},"OffsetDateTime",[58,1634,1635],{},"带时区的绝对时刻，换服务器也不漂",[37,1637,1638,1641,1646,1651],{},[58,1639,1640],{},"是否、开关",[58,1642,1643],{},[169,1644,1645],{},"boolean",[58,1647,1648],{},[169,1649,1650],{},"Boolean",[58,1652,1653,1654,1657,1658],{},"字面量就是 ",[169,1655,1656],{},"true"," \u002F ",[169,1659,1660],{},"false",[37,1662,1663,1666,1671,1676],{},[58,1664,1665],{},"数量、库存",[58,1667,1668],{},[169,1669,1670],{},"integer",[58,1672,1673],{},[169,1674,1675],{},"Integer",[58,1677,1678],{},"32 位，约 ±21 亿，计数场景足够",[15,1680,1681,1682,1685,1686,1689],{},"再加两个「进阶但很常用」：订单的扩展属性（渠道、活动信息）用 ",[169,1683,1684],{},"jsonb","，一条记录挂多个标签用 ",[169,1687,1688],{},"text[]"," 数组。它们都是 PG 的原生能力，不需要像 MySQL 那样把多个值拼成逗号字符串。",[15,1691,1692],{},"把商品表建出来，六个类型一次用全：",[298,1694,1696],{"className":1039,"code":1695,"language":1041,"meta":303,"style":303},"CREATE TABLE products (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title    text NOT NULL,\n  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n  tags     text[],                                       -- 标签数组，可空\n  meta     jsonb,                                        -- 扩展属性\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO products (title, price, stock, tags, meta)\nVALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n\nSELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\nFROM products;\n",[169,1697,1698,1703,1708,1713,1718,1723,1728,1733,1739,1745,1750,1757,1763,1769,1774,1780],{"__ignoreMap":303},[307,1699,1700],{"class":309,"line":310},[307,1701,1702],{},"CREATE TABLE products (\n",[307,1704,1705],{"class":309,"line":334},[307,1706,1707],{},"  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[307,1709,1710],{"class":309,"line":345},[307,1711,1712],{},"  title    text NOT NULL,\n",[307,1714,1715],{"class":309,"line":355},[307,1716,1717],{},"  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n",[307,1719,1720],{"class":309,"line":366},[307,1721,1722],{},"  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n",[307,1724,1725],{"class":309,"line":377},[307,1726,1727],{},"  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n",[307,1729,1730],{"class":309,"line":1471},[307,1731,1732],{},"  tags     text[],                                       -- 标签数组，可空\n",[307,1734,1736],{"class":309,"line":1735},8,[307,1737,1738],{},"  meta     jsonb,                                        -- 扩展属性\n",[307,1740,1742],{"class":309,"line":1741},9,[307,1743,1744],{},"  created_at  timestamptz NOT NULL DEFAULT now()\n",[307,1746,1748],{"class":309,"line":1747},10,[307,1749,1474],{},[307,1751,1753],{"class":309,"line":1752},11,[307,1754,1756],{"emptyLinePlaceholder":1755},true,"\n",[307,1758,1760],{"class":309,"line":1759},12,[307,1761,1762],{},"INSERT INTO products (title, price, stock, tags, meta)\n",[307,1764,1766],{"class":309,"line":1765},13,[307,1767,1768],{},"VALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n",[307,1770,1772],{"class":309,"line":1771},14,[307,1773,1756],{"emptyLinePlaceholder":1755},[307,1775,1777],{"class":309,"line":1776},15,[307,1778,1779],{},"SELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\n",[307,1781,1783],{"class":309,"line":1782},16,[307,1784,1785],{},"FROM products;\n",[298,1787,1790],{"className":1788,"code":1789,"language":422,"meta":303},[420],"  title   | price  | tags         | channel\n----------+--------+--------------+---------\n 机械键盘 | 399.00 | {数码,外设} | app\n",[169,1791,1789],{"__ignoreMap":303},[201,1793,1795],{"id":1794},"第三步把四张表连起来外键与约束","第三步：把四张表连起来（外键与约束）",[15,1797,1798,1799,1802,1803,1806],{},"订单和明细是一对多，",[169,1800,1801],{},"REFERENCES"," 就是外键，",[169,1804,1805],{},"ON DELETE CASCADE"," 表示「父行删了，子行跟着删」：",[298,1808,1810],{"className":1039,"code":1809,"language":1041,"meta":303,"style":303},"CREATE TABLE orders (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n  status   text NOT NULL DEFAULT 'created',\n  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n  meta     jsonb,\n  tags     text[],\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE order_items (\n  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  product_id bigint NOT NULL REFERENCES products(id),\n  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n  qty        integer NOT NULL CHECK (qty > 0),\n  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n);\n",[169,1811,1812,1817,1821,1826,1831,1836,1841,1846,1851,1856,1860,1864,1868,1873,1878,1883,1888,1894,1900,1906],{"__ignoreMap":303},[307,1813,1814],{"class":309,"line":310},[307,1815,1816],{},"CREATE TABLE orders (\n",[307,1818,1819],{"class":309,"line":334},[307,1820,1707],{},[307,1822,1823],{"class":309,"line":345},[307,1824,1825],{},"  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n",[307,1827,1828],{"class":309,"line":355},[307,1829,1830],{},"  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n",[307,1832,1833],{"class":309,"line":366},[307,1834,1835],{},"  status   text NOT NULL DEFAULT 'created',\n",[307,1837,1838],{"class":309,"line":377},[307,1839,1840],{},"  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n",[307,1842,1843],{"class":309,"line":1471},[307,1844,1845],{},"  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n",[307,1847,1848],{"class":309,"line":1735},[307,1849,1850],{},"  meta     jsonb,\n",[307,1852,1853],{"class":309,"line":1741},[307,1854,1855],{},"  tags     text[],\n",[307,1857,1858],{"class":309,"line":1747},[307,1859,1744],{},[307,1861,1862],{"class":309,"line":1752},[307,1863,1474],{},[307,1865,1866],{"class":309,"line":1759},[307,1867,1756],{"emptyLinePlaceholder":1755},[307,1869,1870],{"class":309,"line":1765},[307,1871,1872],{},"CREATE TABLE order_items (\n",[307,1874,1875],{"class":309,"line":1771},[307,1876,1877],{},"  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[307,1879,1880],{"class":309,"line":1776},[307,1881,1882],{},"  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n",[307,1884,1885],{"class":309,"line":1782},[307,1886,1887],{},"  product_id bigint NOT NULL REFERENCES products(id),\n",[307,1889,1891],{"class":309,"line":1890},17,[307,1892,1893],{},"  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n",[307,1895,1897],{"class":309,"line":1896},18,[307,1898,1899],{},"  qty        integer NOT NULL CHECK (qty > 0),\n",[307,1901,1903],{"class":309,"line":1902},19,[307,1904,1905],{},"  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n",[307,1907,1909],{"class":309,"line":1908},20,[307,1910,1474],{},[15,1912,1913,1916,1917,1920,1921,22],{},[169,1914,1915],{},"total"," 是 PG 12 起的",[19,1918,1919],{},"生成列","：由同表其他列算出、物理存储，查的时候不用每次重算 ",[169,1922,1923],{},"price * qty",[15,1925,1926,1927,1930],{},"外键的删除动作按业务逐个选，别无脑 ",[169,1928,1929],{},"CASCADE","：",[31,1932,1933,1946],{},[34,1934,1935],{},[37,1936,1937,1940,1943],{},[40,1938,1939],{},"动作",[40,1941,1942],{},"父行被删时",[40,1944,1945],{},"适合",[53,1947,1948,1960,1973],{},[37,1949,1950,1954,1957],{},[58,1951,1952],{},[169,1953,1805],{},[58,1955,1956],{},"子行连带删除",[58,1958,1959],{},"强从属：订单没了，明细必须没了",[37,1961,1962,1967,1970],{},[58,1963,1964],{},[169,1965,1966],{},"ON DELETE SET NULL",[58,1968,1969],{},"子行外键置 NULL",[58,1971,1972],{},"弱关联：商品删了，收藏记录留着但指向空",[37,1974,1975,1980,1983],{},[58,1976,1977],{},[169,1978,1979],{},"ON DELETE RESTRICT",[58,1981,1982],{},"有子行就拒绝删除，报错",[58,1984,1985],{},"任何强引用",[15,1987,1988,1989,1992,1993,1995,1996,1998],{},"约束是应用层校验之外的最后防线：ORM 的校验能被绕过，",[169,1990,1991],{},"CHECK"," 不会。四张表建完，",[169,1994,599],{}," 能看到它们，",[169,1997,834],{}," 能看单表结构。最后造一条订单，给下一节的 jsonb \u002F 数组查询当数据：",[298,2000,2002],{"className":1039,"code":2001,"language":1041,"meta":303,"style":303},"INSERT INTO orders (user_id, status, meta, tags)\nVALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);\n",[169,2003,2004,2009],{"__ignoreMap":303},[307,2005,2006],{"class":309,"line":310},[307,2007,2008],{},"INSERT INTO orders (user_id, status, meta, tags)\n",[307,2010,2011],{"class":309,"line":334},[307,2012,2013],{},"VALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);\n",[201,2015,2017],{"id":2016},"第四步jsonb-取值与数组查询的三个写法","第四步：jsonb 取值与数组查询的三个写法",[15,2019,2020],{},"入门只需要三个操作符，先记住形状：",[298,2022,2024],{"className":1039,"code":2023,"language":1041,"meta":303,"style":303},"-- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\nSELECT meta ->> 'channel' AS channel FROM orders;\n\n-- 2) @> 包含查询：meta 里含这个键值对的就命中\nSELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n\n-- 3) && 数组交集：tags 里含有 'vip' 的就命中\nSELECT id FROM orders WHERE tags && ARRAY['vip'];\n",[169,2025,2026,2031,2036,2040,2045,2050,2054,2059],{"__ignoreMap":303},[307,2027,2028],{"class":309,"line":310},[307,2029,2030],{},"-- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\n",[307,2032,2033],{"class":309,"line":334},[307,2034,2035],{},"SELECT meta ->> 'channel' AS channel FROM orders;\n",[307,2037,2038],{"class":309,"line":345},[307,2039,1756],{"emptyLinePlaceholder":1755},[307,2041,2042],{"class":309,"line":355},[307,2043,2044],{},"-- 2) @> 包含查询：meta 里含这个键值对的就命中\n",[307,2046,2047],{"class":309,"line":366},[307,2048,2049],{},"SELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n",[307,2051,2052],{"class":309,"line":377},[307,2053,1756],{"emptyLinePlaceholder":1755},[307,2055,2056],{"class":309,"line":1471},[307,2057,2058],{},"-- 3) && 数组交集：tags 里含有 'vip' 的就命中\n",[307,2060,2061],{"class":309,"line":1735},[307,2062,2063],{},"SELECT id FROM orders WHERE tags && ARRAY['vip'];\n",[15,2065,2066,1345,2069,2072,2073,2075,2076,2078,2079,625,2082,625,2085,625,2088,2091],{},[169,2067,2068],{},"->",[169,2070,2071],{},"->>"," 的区别只在于返回 ",[169,2074,1684],{}," 还是 ",[169,2077,422],{},"；其余操作符（",[169,2080,2081],{},"#>>",[169,2083,2084],{},"?",[169,2086,2087],{},"jsonb_set()",[169,2089,2090],{},"jsonb_array_elements()","）见本章末尾的速查表。",[201,2093,666],{"id":2094},"️-本章的坑-2",[15,2096,2097],{},"表能跑起来之后，再回头看这 8 条——它们都是上面那些「看起来没问题」的写法在真实项目里会长出来的刺。",[15,2099,2100,2107,2108,2111,2112,2114,2115,2118,2119,2122],{},[19,2101,2102,2103,2106],{},"1. ",[169,2104,2105],{},"serial"," 不是类型，是宏，新表一律换 identity。"," 它会建一个序列并把默认值设成 ",[169,2109,2110],{},"nextval()","，三宗罪迟早撞上：不是 SQL 标准，迁移工具对不上；权限要单独授，把表授给应用账号后 ",[169,2113,1504],{}," 仍会报 ",[169,2116,2117],{},"permission denied for sequence users_id_seq","，得再 ",[169,2120,2121],{},"GRANT USAGE ON SEQUENCE"," 一次（第十一章模板里已包含）；序列和表归属松散，删来删去留孤儿。",[31,2124,2125,2141],{},[34,2126,2127],{},[37,2128,2129,2131,2136],{},[40,2130],{},[40,2132,2133,2135],{},[169,2134,2105],{}," 宏",[40,2137,2138],{},[169,2139,2140],{},"GENERATED ... AS IDENTITY",[53,2142,2143,2154,2167,2185],{},[37,2144,2145,2148,2151],{},[58,2146,2147],{},"标准",[58,2149,2150],{},"PG 方言",[58,2152,2153],{},"SQL 标准",[37,2155,2156,2159,2164],{},[58,2157,2158],{},"序列权限",[58,2160,2161,2162],{},"表授权之外要单独 ",[169,2163,2121],{},[58,2165,2166],{},"授权器自动处理，不踩坑",[37,2168,2169,2172,2175],{},[58,2170,2171],{},"手工插 id",[58,2173,2174],{},"默认就允许（副作用源头之一）",[58,2176,2177,2180,2181,2184],{},[169,2178,2179],{},"ALWAYS"," 拒绝 \u002F ",[169,2182,2183],{},"BY DEFAULT"," 允许，白纸黑字",[37,2186,2187,2190,2193],{},[58,2188,2189],{},"删表后序列",[58,2191,2192],{},"可能残留孤儿序列",[58,2194,2195],{},"随表删除",[15,2197,2198,2204],{},[19,2199,2200,2201,2203],{},"2. ",[169,2202,2179],{}," 意味着你手工插 id 会被拒。"," 这是上面那条 DDL 的副作用，报错长这样：",[298,2206,2209],{"className":2207,"code":2208,"language":422,"meta":303},[420],"shop=# INSERT INTO users (id, name) VALUES (99, 'Bob');\nERROR:  cannot insert a non-DEFAULT value into column \"id\"\nDETAIL:  Column \"id\" is an identity column defined as GENERATED ALWAYS.\nHINT:  Use OVERRIDING SYSTEM VALUE to override.\n",[169,2210,2208],{"__ignoreMap":303},[15,2212,2213,2214,2217,2218,22],{},"迁移历史数据时加 ",[169,2215,2216],{},"OVERRIDING SYSTEM VALUE","；想平时也允许手工指定，建表时就用 ",[169,2219,2183],{},[15,2221,2222,675,2229,2231,2232,2235,2236,2238],{},[19,2223,2224,2225,2228],{},"3. 时间列写成 ",[169,2226,2227],{},"timestamp","，换时区就漂。",[169,2230,1627],{}," 存的是",[19,2233,2234],{},"绝对时刻","（内部按 UTC 记账），显示随会话时区变；",[169,2237,2227],{}," 存的只是「墙上读数」。实验两分钟胜过背十遍：",[298,2240,2242],{"className":1039,"code":2241,"language":1041,"meta":303,"style":303},"CREATE TABLE clock_t (ts timestamptz);\nSET TIME ZONE 'Asia\u002FShanghai';\nINSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\nSELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\nSET TIME ZONE 'UTC';\nSELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00\n",[169,2243,2244,2249,2254,2259,2264,2269],{"__ignoreMap":303},[307,2245,2246],{"class":309,"line":310},[307,2247,2248],{},"CREATE TABLE clock_t (ts timestamptz);\n",[307,2250,2251],{"class":309,"line":334},[307,2252,2253],{},"SET TIME ZONE 'Asia\u002FShanghai';\n",[307,2255,2256],{"class":309,"line":345},[307,2257,2258],{},"INSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\n",[307,2260,2261],{"class":309,"line":355},[307,2262,2263],{},"SELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\n",[307,2265,2266],{"class":309,"line":366},[307,2267,2268],{},"SET TIME ZONE 'UTC';\n",[307,2270,2271],{"class":309,"line":377},[307,2272,2273],{},"SELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00\n",[15,2275,2276,2277,2279,2280,2282,2283,2286,2287,22],{},"同一个绝对时刻，两个会话两个读数，都对。如果列建成 ",[169,2278,2227],{},"，这个值就只是一串「9 点半」，谁也不知道是哪个时区的 9 点半。所以：时间列一律 ",[169,2281,1627],{},"，字面量尽量带偏移（",[169,2284,2285],{},"'2026-10-10 09:30:00+08'","），应用连接串里写明 ",[169,2288,2289],{},"TimeZone=Asia\u002FShanghai",[15,2291,2292,2299],{},[19,2293,2294,2295,2298],{},"4. 金额用 ",[169,2296,2297],{},"float8","，对账时对不平。"," 浮点存的不是十进制字面值：",[298,2301,2303],{"className":1039,"code":2302,"language":1041,"meta":303,"style":303},"SELECT 0.1::float8 + 0.2::float8;   -- 0.30000000000000004\n",[169,2304,2305],{"__ignoreMap":303},[307,2306,2307],{"class":309,"line":310},[307,2308,2302],{},[15,2310,2311,2312,2315],{},"金额一律 ",[169,2313,2314],{},"numeric(p, s)","，它按十进制精确存。",[15,2317,2318,675,2328,2331,2332,2335,2336,1143,2339,2342,2343,1375,2345,2348],{},[19,2319,732,2320,2323,2324,2327],{},[169,2321,2322],{},"char(n)"," 会补空格，",[169,2325,2326],{},"="," 比较会骗人。",[169,2329,2330],{},"char(10)"," 存 ",[169,2333,2334],{},"'abc'"," 读出来是 ",[169,2337,2338],{},"'abc       '",[169,2340,2341],{},"varchar(n)"," 的 n 也不带来任何性能差异，只是长度约束。要约束长度，用 ",[169,2344,422],{},[169,2346,2347],{},"CHECK (char_length(x) \u003C= n)"," 更明白。",[15,2350,2351,2354,2355,2358],{},[19,2352,2353],{},"6. PG 不给外键自动建索引——MySQL 老手最容易栽的一条。"," InnoDB 建外键时会自动在子表列上建索引，PG 什么都不做。于是删父表一行时，PG 要",[19,2356,2357],{},"顺序扫整个子表","检查有没有引用，行数一大就是秒级起步的锁等待（第九章的排队事故里有它的戏份）。解法很朴素：",[298,2360,2362],{"className":1039,"code":2361,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_order_items_order_id ON order_items (order_id);\n",[169,2363,2364],{"__ignoreMap":303},[307,2365,2366],{"class":309,"line":310},[307,2367,2361],{},[15,2369,2370,2371,2374],{},"建完之后 ",[169,2372,2373],{},"\\d order_items","，索引区和约束区各看到一条——「外键约束」和「外键列索引」是两码事，谁也不替谁负责。",[15,2376,2377,2382,2383,2385,2386,2389,2390,2393,2394,2397,2398,2401,2402,2405,2406,22],{},[19,2378,755,2379,2381],{},[169,2380,1562],{}," 传到前端会被改掉末几位。"," Java 侧 ",[169,2384,1567],{}," 是 64 位，JS 的 ",[169,2387,2388],{},"Number"," 只有 53 位安全整数，雪花 ID 序列化过去末几位悄悄变了。所以 ID 出参统一转字符串（Jackson 的 ",[169,2391,2392],{},"ToStringSerializer","，或全局配 ",[169,2395,2396],{},"Long → String","），前端全程按 ",[169,2399,2400],{},"string"," 传。RuoYi 这类用雪花算法生成主键的框架，实体里是 ",[169,2403,2404],{},"Long userId","，建表就必须是 ",[169,2407,1562],{},[15,2409,2410,675,2423,2425,2426,2428,2429,2431,2432,2434,2435,2438,2439,2441],{},[19,2411,2412,2413,1345,2416,2418,2419,2422],{},"8. ",[169,2414,2415],{},"json",[169,2417,1684],{}," 别选错，",[169,2420,2421],{},"uuid"," 主键别乱用。",[169,2424,2415],{}," 只是原样存文本，每次读都要重新解析、不能建索引；",[169,2427,1684],{}," 是解析后的二进制，能建 GIN 索引能查询——99% 的场景选 ",[169,2430,1684],{},"。另外 ",[169,2433,2421],{}," 做主键虽然方便（PG 13 起 ",[169,2436,2437],{},"gen_random_uuid()"," 内置，不用装 pgcrypto），但随机值会让 B-tree 索引写入分散、页分裂变多，大表主键仍推荐 ",[169,2440,1562],{},"（第八章有账可算）。",[201,2443,1422],{"id":2444},"附类型速查表",[15,2446,2447,2448,1132,2454,625,2457,2460,2461,2464,2465,2468,2469,625,2471,2473,2474,22],{},"写 DDL 时查这几张表。先说一个命名陷阱：",[19,2449,2450,2453],{},[169,2451,2452],{},"int8"," 里的 8 不是 8 位，是 8 字节（byte）",[169,2455,2456],{},"int2 \u002F int4 \u002F int8",[169,2458,2459],{},"float4 \u002F float8"," 前缀数字一律指",[19,2462,2463],{},"字节数","，跟 MySQL 的 ",[169,2466,2467],{},"int(11)","（那是显示宽度，和取值范围毫无关系）是两套语义。所以 ",[169,2470,2452],{},[169,2472,1562],{}," 是同一个东西的两种写法：64 位长整型，对应 Java 实体里的 ",[169,2475,1567],{},[15,2477,2478],{},"数值类型：",[31,2480,2481,2505],{},[34,2482,2483],{},[37,2484,2485,2488,2491,2494,2497,2500,2502],{},[40,2486,2487],{},"PG 别名",[40,2489,2490],{},"SQL 标准名",[40,2492,2493],{},"字节",[40,2495,2496],{},"位",[40,2498,2499],{},"取值范围",[40,2501,1547],{},[40,2503,2504],{},"生产推荐",[53,2506,2507,2539,2571,2602,2637,2666,2698,2738,2762],{},[37,2508,2509,2514,2519,2522,2525,2528,2533],{},[58,2510,2511],{},[169,2512,2513],{},"int2",[58,2515,2516],{},[169,2517,2518],{},"smallint",[58,2520,2521],{},"2",[58,2523,2524],{},"16",[58,2526,2527],{},"−32768 ~ 32767（约 ±3 万）",[58,2529,2530],{},[169,2531,2532],{},"Short",[58,2534,2535,2536],{},"状态码、字典序号这类小整数；",[19,2537,2538],{},"别拿来存 ID",[37,2540,2541,2551,2555,2558,2561,2564,2568],{},[58,2542,2543,1657,2546,1657,2548],{},[169,2544,2545],{},"int4",[169,2547,1670],{},[169,2549,2550],{},"int",[58,2552,2553],{},[169,2554,1670],{},[58,2556,2557],{},"4",[58,2559,2560],{},"32",[58,2562,2563],{},"约 ±21 亿",[58,2565,2566],{},[169,2567,1675],{},[58,2569,2570],{},"数量、库存、分页 size；日活过千万的表主键别赌它",[37,2572,2573,2579,2583,2586,2589,2592,2596],{},[58,2574,2575,1657,2577],{},[169,2576,2452],{},[169,2578,1562],{},[58,2580,2581],{},[169,2582,1562],{},[58,2584,2585],{},"8",[58,2587,2588],{},"64",[58,2590,2591],{},"约 ±9.2 × 10¹⁸",[58,2593,2594],{},[169,2595,1567],{},[58,2597,2598,2601],{},[19,2599,2600],{},"主键、外键、雪花 ID 一律用它","，多占 4 字节换一辈子的安心",[37,2603,2604,2611,2616,2619,2622,2625,2629],{},[58,2605,2606,1657,2608],{},[169,2607,2314],{},[169,2609,2610],{},"decimal",[58,2612,2613],{},[169,2614,2615],{},"numeric",[58,2617,2618],{},"变长（约 2 字节存 4 位数字 + 头部）",[58,2620,2621],{},"—",[58,2623,2624],{},"任意精度，p 上限 1000 位",[58,2626,2627],{},[169,2628,1607],{},[58,2630,2631,1352,2634,2636],{},[19,2632,2633],{},"金额唯一正确答案",[169,2635,1602],{}," 起步；p 只限数字位数，不占额外空间",[37,2638,2639,2647,2651,2653,2655,2658,2663],{},[58,2640,2641,1657,2644],{},[169,2642,2643],{},"float4",[169,2645,2646],{},"real",[58,2648,2649],{},[169,2650,2646],{},[58,2652,2557],{},[58,2654,2560],{},[58,2656,2657],{},"单精度，6 ~ 7 位有效数字",[58,2659,2660],{},[169,2661,2662],{},"Float",[58,2664,2665],{},"只在打分、统计这类容忍误差的场景用",[37,2667,2668,2675,2679,2681,2683,2686,2691],{},[58,2669,2670,1657,2672],{},[169,2671,2297],{},[169,2673,2674],{},"double precision",[58,2676,2677],{},[169,2678,2674],{},[58,2680,2585],{},[58,2682,2588],{},[58,2684,2685],{},"双精度，15 ~ 17 位有效数字",[58,2687,2688],{},[169,2689,2690],{},"Double",[58,2692,2693,2694,2697],{},"经纬度、相关度分值；",[19,2695,2696],{},"存钱会翻车","（本章坑 4）",[37,2699,2700,2710,2712,2715,2717,2727,2735],{},[58,2701,2702,1657,2705,1657,2707],{},[169,2703,2704],{},"smallserial",[169,2706,2105],{},[169,2708,2709],{},"bigserial",[58,2711,2621],{},[58,2713,2714],{},"2 \u002F 4 \u002F 8",[58,2716,2621],{},[58,2718,2719,2720,1657,2722,1657,2724,2726],{},"同 ",[169,2721,2513],{},[169,2723,2545],{},[169,2725,2452],{},"，各自背后挂一个序列",[58,2728,2729,1657,2731,1657,2733],{},[169,2730,2532],{},[169,2732,1675],{},[169,2734,1567],{},[58,2736,2737],{},"只是「序列 + 默认值」的宏，新表换 identity（本章坑 1）",[37,2739,2740,2745,2747,2749,2751,2754,2759],{},[58,2741,2742],{},[169,2743,2744],{},"oid",[58,2746,2621],{},[58,2748,2557],{},[58,2750,2560],{},[58,2752,2753],{},"0 ~ 4294967295",[58,2755,2756,2758],{},[169,2757,1567],{},"（JDBC 返回）",[58,2760,2761],{},"系统内部对象号，业务键别用它",[37,2763,2764,2769,2771,2773,2775,2782,2786],{},[58,2765,2766],{},[169,2767,2768],{},"money",[58,2770,2621],{},[58,2772,2585],{},[58,2774,2588],{},[58,2776,2777,2778,2781],{},"随 ",[169,2779,2780],{},"lc_monetary"," locale 变化",[58,2783,2784],{},[169,2785,1607],{},[58,2787,2788,2789,2791],{},"不用，跨 locale 会算错，",[169,2790,2615],{}," 完胜",[15,2793,2794],{},"字符串、布尔与二进制：",[31,2796,2797,2811],{},[34,2798,2799],{},[37,2800,2801,2804,2807,2809],{},[40,2802,2803],{},"PG 类型",[40,2805,2806],{},"长度",[40,2808,1547],{},[40,2810,2504],{},[53,2812,2813,2857,2879,2901,2925,2942,2964,2987],{},[37,2814,2815,2822,2825,2829],{},[58,2816,2817,1657,2819],{},[169,2818,1645],{},[169,2820,2821],{},"bool",[58,2823,2824],{},"1 字节",[58,2826,2827],{},[169,2828,1650],{},[58,2830,2831,2832,1657,2834,2836,2837,1657,2840,625,2843,1657,2846,2849,2850,2852,2853,2856],{},"真正的布尔，字面量 ",[169,2833,1656],{},[169,2835,1660],{},"，也认 ",[169,2838,2839],{},"'t'",[169,2841,2842],{},"'f'",[169,2844,2845],{},"'1'",[169,2847,2848],{},"'0'","；MyBatis 直接映射 ",[169,2851,1650],{},"，不用再学 MySQL 的 ",[169,2854,2855],{},"tinyint(1)"," 习惯",[37,2858,2859,2863,2869,2873],{},[58,2860,2861],{},[169,2862,2341],{},[58,2864,2865,2866],{},"变长，n ≤ 10485760 ",[19,2867,2868],{},"个字符",[58,2870,2871],{},[169,2872,1528],{},[58,2874,2875,2876],{},"需要长度约束时用；n 不影响性能，也",[19,2877,2878],{},"不会预分配空间",[37,2880,2881,2885,2888,2892],{},[58,2882,2883],{},[169,2884,422],{},[58,2886,2887],{},"变长，上限 1 GB",[58,2889,2890],{},[169,2891,1528],{},[58,2893,2894,2897,2898,2900],{},[19,2895,2896],{},"默认选择","：短用户名和长正文都用它，和 ",[169,2899,1591],{}," 底层同一种实现，没有任何性能差",[37,2902,2903,2912,2915,2919],{},[58,2904,2905,2907,2908,2911],{},[169,2906,2322],{},"（内部名 ",[169,2909,2910],{},"bpchar","）",[58,2913,2914],{},"定长 n，不足补空格",[58,2916,2917],{},[169,2918,1528],{},[58,2920,2921,2922,2924],{},"避免：读出来带尾部空格，",[169,2923,2326],{}," 比较会因此骗人",[37,2926,2927,2932,2934,2939],{},[58,2928,2929],{},[169,2930,2931],{},"bytea",[58,2933,2887],{},[58,2935,2936],{},[169,2937,2938],{},"byte[]",[58,2940,2941],{},"小二进制块（签名、缩略图）可以；大文件走对象存储，别塞进库里拖垮备份",[37,2943,2944,2948,2951,2958],{},[58,2945,2946],{},[169,2947,2421],{},[58,2949,2950],{},"16 字节",[58,2952,2953,1657,2955],{},[169,2954,1528],{},[169,2956,2957],{},"java.util.UUID",[58,2959,2960,2961,2963],{},"全局 ID，配 ",[169,2962,2437],{},"；做主键要注意 B-tree 索引写入分散（第八章）",[37,2965,2966,2971,2974,2978],{},[58,2967,2968],{},[169,2969,2970],{},"citext",[58,2972,2973],{},"变长（扩展类型）",[58,2975,2976],{},[169,2977,1528],{},[58,2979,2980,2981,2983,2984],{},"大小写不敏感列；更常见的做法还是 ",[169,2982,422],{}," + 表达式索引 ",[169,2985,2986],{},"lower(email)",[37,2988,2989,2995,2998,3003],{},[58,2990,2991,2994],{},[169,2992,2993],{},"enum","（自定义类型）",[58,2996,2997],{},"4 字节",[58,2999,3000,3002],{},[169,3001,1528],{}," \u002F Java 枚举",[58,3004,3005,3006,3009,3010,1375,3012],{},"取值固定且要省空间时用；改取值要 ",[169,3007,3008],{},"ALTER TYPE","，多数团队直接用 ",[169,3011,422],{},[169,3013,1991],{},[15,3015,3016],{},"时间、日期与其他：",[31,3018,3019,3032],{},[34,3020,3021],{},[37,3022,3023,3025,3028,3030],{},[40,3024,2803],{},[40,3026,3027],{},"长度 \u002F 精度",[40,3029,1547],{},[40,3031,2504],{},[53,3033,3034,3061,3078,3096,3114,3141,3160,3182,3203],{},[37,3035,3036,3044,3047,3055],{},[58,3037,3038,3040,3041,2911],{},[169,3039,1627],{},"（",[169,3042,3043],{},"timestamp with time zone",[58,3045,3046],{},"8 字节，微秒",[58,3048,3049,3051,3052,2911],{},[169,3050,1632],{},"（老驱动常落到 ",[169,3053,3054],{},"java.util.Date",[58,3056,3057,3060],{},[19,3058,3059],{},"时间列默认写它","，内部按 UTC 记账，显示随会话时区",[37,3062,3063,3068,3070,3075],{},[58,3064,3065,3067],{},[169,3066,2227],{},"（without time zone）",[58,3069,3046],{},[58,3071,3072],{},[169,3073,3074],{},"LocalDateTime",[58,3076,3077],{},"只在「墙上时间」本身即业务语义时用（营业到 21:00 这类），不带时区",[37,3079,3080,3085,3088,3093],{},[58,3081,3082],{},[169,3083,3084],{},"date",[58,3086,3087],{},"4 字节，天",[58,3089,3090],{},[169,3091,3092],{},"LocalDate",[58,3094,3095],{},"生日、账期、统计日",[37,3097,3098,3103,3106,3111],{},[58,3099,3100],{},[169,3101,3102],{},"time",[58,3104,3105],{},"8 字节",[58,3107,3108],{},[169,3109,3110],{},"LocalTime",[58,3112,3113],{},"时段端点",[37,3115,3116,3121,3124,3132],{},[58,3117,3118],{},[169,3119,3120],{},"interval",[58,3122,3123],{},"16 字节，微秒",[58,3125,3126,1657,3129],{},[169,3127,3128],{},"Duration",[169,3130,3131],{},"Period",[58,3133,3134,3135,625,3138],{},"时长、超时阈值；能写 ",[169,3136,3137],{},"'3 days'",[169,3139,3140],{},"'2 hours 30 mins'",[37,3142,3143,3147,3149,3157],{},[58,3144,3145],{},[169,3146,1684],{},[58,3148,2887],{},[58,3150,3151,3153,3154],{},[169,3152,1528],{}," + TypeHandler \u002F ",[169,3155,3156],{},"Map",[58,3158,3159],{},"半结构化首选，解析后存储、能建 GIN 索引（本章速查表末 + 第八章）",[37,3161,3162,3170,3173,3179],{},[58,3163,3164,1657,3166,3169],{},[169,3165,1688],{},[169,3167,3168],{},"int[]"," 数组",[58,3171,3172],{},"变长",[58,3174,3175,3178],{},[169,3176,3177],{},"List\u003CString>"," \u002F TypeHandler",[58,3180,3181],{},"多值标签；驱动层要 PGobject 或自定义 TypeHandler 接",[37,3183,3184,3189,3192,3196],{},[58,3185,3186],{},[169,3187,3188],{},"inet",[58,3190,3191],{},"4 或 16 字节 + 掩码",[58,3193,3194],{},[169,3195,1528],{},[58,3197,3198,3199,3202],{},"存 IP（含 IPv6），比 ",[169,3200,3201],{},"varchar(45)"," 强在能索引和网段运算",[37,3204,3205,3210,3212,3214],{},[58,3206,3207],{},[169,3208,3209],{},"tsvector",[58,3211,3172],{},[58,3213,2621],{},[58,3215,3216],{},"全文检索列，第十三章的主角，不映射 Java",[15,3218,3219,3220,3223],{},"jsonb 操作符全表（",[169,3221,3222],{},"meta = {\"channel\":\"app\",\"tags\":[\"vip\",\"new\"]}","）：",[31,3225,3226,3239],{},[34,3227,3228],{},[37,3229,3230,3233,3236],{},[40,3231,3232],{},"操作符",[40,3234,3235],{},"输入 → 输出",[40,3237,3238],{},"例子",[53,3240,3241,3261,3280,3297,3317,3333,3347],{},[37,3242,3243,3247,3252],{},[58,3244,3245],{},[169,3246,2068],{},[58,3248,3249,3250],{},"键 → ",[19,3251,1684],{},[58,3253,3254,3257,3258],{},[169,3255,3256],{},"meta->'tags'"," → ",[169,3259,3260],{},"[\"vip\",\"new\"]",[37,3262,3263,3267,3271],{},[58,3264,3265],{},[169,3266,2071],{},[58,3268,3249,3269],{},[19,3270,422],{},[58,3272,3273,3257,3276,3279],{},[169,3274,3275],{},"meta->>'channel'",[169,3277,3278],{},"app","（最常用）",[37,3281,3282,3286,3289],{},[58,3283,3284],{},[169,3285,2081],{},[58,3287,3288],{},"路径 → text",[58,3290,3291,3257,3294],{},[169,3292,3293],{},"meta #>> '{tags,0}'",[169,3295,3296],{},"vip",[37,3298,3299,3304,3310],{},[58,3300,3301],{},[169,3302,3303],{},"@>",[58,3305,3306,3307,2911],{},"包含判断（",[19,3308,3309],{},"能走 GIN 索引",[58,3311,3312,3257,3315],{},[169,3313,3314],{},"meta @> '{\"channel\":\"app\"}'",[169,3316,1656],{},[37,3318,3319,3323,3326],{},[58,3320,3321],{},[169,3322,2084],{},[58,3324,3325],{},"顶层键存在",[58,3327,3328,3257,3331],{},[169,3329,3330],{},"meta ? 'tags'",[169,3332,1656],{},[37,3334,3335,3339,3342],{},[58,3336,3337],{},[169,3338,2087],{},[58,3340,3341],{},"改值",[58,3343,3344],{},[169,3345,3346],{},"jsonb_set(meta, '{vip}', '\"gold\"'::jsonb)",[37,3348,3349,3353,3356],{},[58,3350,3351],{},[169,3352,2090],{},[58,3354,3355],{},"数组炸开成行",[58,3357,3358],{},[169,3359,3360],{},"SELECT jsonb_array_elements(meta->'tags')",[15,3362,3363,3364,3366],{},"实战里最值钱的是 ",[169,3365,3303],{}," 包含查询配合 GIN 索引（第八章会讲两种索引 opclass 的取舍）：",[298,3368,3370],{"className":1039,"code":3369,"language":1041,"meta":303,"style":303},"SELECT id, meta->>'channel' AS channel\nFROM orders\nWHERE meta @> '{\"tags\": [\"vip\"]}';\n",[169,3371,3372,3377,3382],{"__ignoreMap":303},[307,3373,3374],{"class":309,"line":310},[307,3375,3376],{},"SELECT id, meta->>'channel' AS channel\n",[307,3378,3379],{"class":309,"line":334},[307,3380,3381],{},"FROM orders\n",[307,3383,3384],{"class":309,"line":345},[307,3385,3386],{},"WHERE meta @> '{\"tags\": [\"vip\"]}';\n",[15,3388,3389,3390,3393],{},"数组类型的日常是交集查询：",[169,3391,3392],{},"WHERE tags && ARRAY['vip']"," 表示「标签里含 vip」，配 GIN 索引同样走索引。",[15,3395,3396],{},"MySQL → PG 类型迁移对照：",[15,3398,3399,3400,3403,3404,3407],{},"从 MySQL 迁表时别一一对着名字猜，尤其注意 PG ",[19,3401,3402],{},"没有 unsigned","、没有 1 字节整型、",[169,3405,3406],{},"datetime"," 不带时区这三件事：",[31,3409,3410,3428],{},[34,3411,3412],{},[37,3413,3414,3417,3420,3423,3425],{},[40,3415,3416],{},"MySQL 类型",[40,3418,3419],{},"PG 对应",[40,3421,3422],{},"长度变化",[40,3424,1547],{},[40,3426,3427],{},"迁移要点",[53,3429,3430,3461,3484,3513,3536,3567,3595,3621,3643,3670,3693,3722,3754,3783,3807,3833,3855,3880,3914,3941,3966,3985,4007],{},[37,3431,3432,3436,3440,3443,3447],{},[58,3433,3434],{},[169,3435,2855],{},[58,3437,3438],{},[169,3439,1645],{},[58,3441,3442],{},"1 → 1 字节",[58,3444,3445],{},[169,3446,1650],{},[58,3448,3449,3450,1657,3452,1143,3454,3457,3458,3460],{},"0\u002F1 转 ",[169,3451,1660],{},[169,3453,1656],{},[169,3455,3456],{},"tinyint(2)"," 以上不是布尔，按 ",[169,3459,2518],{}," 迁",[37,3462,3463,3468,3474,3477,3481],{},[58,3464,3465],{},[169,3466,3467],{},"tinyint",[58,3469,3470,3040,3472,2911],{},[169,3471,2518],{},[169,3473,2513],{},[58,3475,3476],{},"1 → 2 字节",[58,3478,3479],{},[169,3480,2532],{},[58,3482,3483],{},"PG 没有 1 字节整型，只能升一档",[37,3485,3486,3490,3494,3497,3501],{},[58,3487,3488],{},[169,3489,2518],{},[58,3491,3492],{},[169,3493,2518],{},[58,3495,3496],{},"相同",[58,3498,3499],{},[169,3500,2532],{},[58,3502,3503,3504,3507,3508,3510,3511],{},"MySQL 的 ",[169,3505,3506],{},"unsigned smallint"," 上限 65535 超了 ",[169,3509,2513],{},"，升 ",[169,3512,2545],{},[37,3514,3515,3520,3526,3529,3533],{},[58,3516,3517],{},[169,3518,3519],{},"mediumint",[58,3521,3522,3040,3524,2911],{},[169,3523,1670],{},[169,3525,2545],{},[58,3527,3528],{},"3 → 4 字节",[58,3530,3531],{},[169,3532,1675],{},[58,3534,3535],{},"PG 没有 3 字节整型",[37,3537,3538,3544,3548,3550,3554],{},[58,3539,3540,1657,3542],{},[169,3541,2550],{},[169,3543,1670],{},[58,3545,3546],{},[169,3547,1670],{},[58,3549,3496],{},[58,3551,3552],{},[169,3553,1675],{},[58,3555,3556,3564,3565],{},[19,3557,3558,3561,3562],{},[169,3559,3560],{},"int unsigned","（上限约 42.9 亿）装不进 ",[169,3563,2545],{},"，必须升 ",[169,3566,1562],{},[37,3568,3569,3573,3579,3582,3586],{},[58,3570,3571],{},[169,3572,1562],{},[58,3574,3575,3040,3577,2911],{},[169,3576,1562],{},[169,3578,2452],{},[58,3580,3581],{},"相同 8 字节",[58,3583,3584],{},[169,3585,1567],{},[58,3587,3588,3589,3592,3593],{},"雪花 ID 直接对得上；",[169,3590,3591],{},"bigint unsigned"," 超出 64 位有符号，改用 ",[169,3594,2615],{},[37,3596,3597,3604,3608,3610,3614],{},[58,3598,3599,1657,3601],{},[169,3600,2467],{},[169,3602,3603],{},"int(10) unsigned",[58,3605,3606],{},[169,3607,1670],{},[58,3609,2621],{},[58,3611,3612],{},[169,3613,1675],{},[58,3615,3616,3617,3620],{},"括号里的数字是",[19,3618,3619],{},"显示宽度","，PG 没有这个概念，直接丢掉",[37,3622,3623,3630,3634,3636,3640],{},[58,3624,3625,1657,3628],{},[169,3626,3627],{},"decimal(p, s)",[169,3629,2615],{},[58,3631,3632],{},[169,3633,2314],{},[58,3635,3172],{},[58,3637,3638],{},[169,3639,1607],{},[58,3641,3642],{},"语义一致，p \u002F s 原样搬",[37,3644,3645,3653,3659,3661,3667],{},[58,3646,3647,1657,3650],{},[169,3648,3649],{},"float",[169,3651,3652],{},"double",[58,3654,3655,1657,3657],{},[169,3656,2646],{},[169,3658,2674],{},[58,3660,3496],{},[58,3662,3663,1657,3665],{},[169,3664,2662],{},[169,3666,2690],{},[58,3668,3669],{},"名字变了，精度没变",[37,3671,3672,3676,3682,3684,3688],{},[58,3673,3674],{},[169,3675,2322],{},[58,3677,3678,3040,3680,2911],{},[169,3679,2322],{},[169,3681,2910],{},[58,3683,3496],{},[58,3685,3686],{},[169,3687,1528],{},[58,3689,3690,3691],{},"两边都补空格，行为一致；仍然建议换 ",[169,3692,422],{},[37,3694,3695,3699,3706,3712,3716],{},[58,3696,3697],{},[169,3698,2341],{},[58,3700,3701,3703,3704],{},[169,3702,2341],{}," 或 ",[169,3705,422],{},[58,3707,3708,3709],{},"n 单位是",[19,3710,3711],{},"字符",[58,3713,3714],{},[169,3715,1528],{},[58,3717,3718,3719,3721],{},"两边的 n 都按字符算，迁过去不会截断；差别在 MySQL 单行有 65535 ",[19,3720,2493],{},"上限，utf8mb4 下几个大 varchar 就会撞墙，PG 没有这条约束",[37,3723,3724,3737,3741,3744,3748],{},[58,3725,3726,1657,3728,1657,3731,1657,3734],{},[169,3727,422],{},[169,3729,3730],{},"tinytext",[169,3732,3733],{},"mediumtext",[169,3735,3736],{},"longtext",[58,3738,3739],{},[169,3740,422],{},[58,3742,3743],{},"统一变长，上限 1 GB",[58,3745,3746],{},[169,3747,1528],{},[58,3749,3750,3751,3753],{},"PG 一个 ",[169,3752,422],{}," 通吃，MySQL 那四档长度差异全部消失",[37,3755,3756,3761,3771,3773,3777],{},[58,3757,3758],{},[169,3759,3760],{},"enum('a','b')",[58,3762,3763,1375,3765,3767,3768,3770],{},[169,3764,422],{},[169,3766,1991],{}," 或自定义 ",[169,3769,2993],{}," 类型",[58,3772,2621],{},[58,3774,3775],{},[169,3776,1528],{},[58,3778,3779,3780,3782],{},"推荐 ",[169,3781,1991],{},"：加一个取值只是改约束，不用动类型",[37,3784,3785,3790,3794,3796,3800],{},[58,3786,3787],{},[169,3788,3789],{},"set('a','b')",[58,3791,3792,3169],{},[169,3793,1688],{},[58,3795,2621],{},[58,3797,3798],{},[169,3799,3177],{},[58,3801,3802,3803,3806],{},"数组能整体建 GIN 索引，比 ",[169,3804,3805],{},"FIND_IN_SET"," 干净",[37,3808,3809,3813,3817,3819,3825],{},[58,3810,3811],{},[169,3812,2415],{},[58,3814,3815],{},[169,3816,1684],{},[58,3818,3172],{},[58,3820,3821,1657,3823],{},[169,3822,1528],{},[169,3824,3156],{},[58,3826,3827,3828,3830,3831],{},"99% 的情况选 ",[169,3829,1684],{},"；只有要求「原样保留文本和键序」才用 ",[169,3832,2415],{},[37,3834,3835,3839,3843,3846,3850],{},[58,3836,3837],{},[169,3838,3406],{},[58,3840,3841],{},[169,3842,1627],{},[58,3844,3845],{},"5~8 字节 → 8 字节",[58,3847,3848],{},[169,3849,1632],{},[58,3851,3503,3852,3854],{},[169,3853,3406],{}," 不带时区，迁移动作里必须显式说明原数据是哪个时区，否则整体漂 8 小时",[37,3856,3857,3862,3866,3869,3873],{},[58,3858,3859,3861],{},[169,3860,2227],{},"（MySQL）",[58,3863,3864],{},[169,3865,1627],{},[58,3867,3868],{},"4 → 8 字节",[58,3870,3871],{},[169,3872,1632],{},[58,3874,3503,3875,3877,3878],{},[169,3876,2227],{}," 存 UTC、按会话时区显示，语义正好对应 PG 的 ",[169,3879,1627],{},[37,3881,3882,3891,3899,3901,3909],{},[58,3883,3884,1657,3886,1657,3888],{},[169,3885,3084],{},[169,3887,3102],{},[169,3889,3890],{},"year",[58,3892,3893,1657,3895,1657,3897],{},[169,3894,3084],{},[169,3896,3102],{},[169,3898,2513],{},[58,3900,2621],{},[58,3902,3903,1657,3905,1657,3907],{},[169,3904,3092],{},[169,3906,3110],{},[169,3908,1675],{},[58,3910,3911,3912,3770],{},"PG 没有 ",[169,3913,3890],{},[37,3915,3916,3928,3932,3934,3938],{},[58,3917,3918,1657,3921,1657,3924,3927],{},[169,3919,3920],{},"binary",[169,3922,3923],{},"varbinary(n)",[169,3925,3926],{},"blob"," 家族",[58,3929,3930],{},[169,3931,2931],{},[58,3933,3172],{},[58,3935,3936],{},[169,3937,2938],{},[58,3939,3940],{},"大 BLOB 建议趁机搬去对象存储",[37,3942,3943,3949,3953,3956,3961],{},[58,3944,3945,3948],{},[169,3946,3947],{},"binary(16)","（存 UUID）",[58,3950,3951],{},[169,3952,2421],{},[58,3954,3955],{},"16 → 16 字节",[58,3957,3958],{},[169,3959,3960],{},"UUID",[58,3962,3963,3964],{},"换成原生类型，能直接用 ",[169,3965,2437],{},[37,3967,3968,3973,3977,3979,3983],{},[58,3969,3970],{},[169,3971,3972],{},"bit(1)",[58,3974,3975],{},[169,3976,1645],{},[58,3978,2621],{},[58,3980,3981],{},[169,3982,1650],{},[58,3984,2621],{},[37,3986,3987,3992,3996,3998,4000],{},[58,3988,3989],{},[169,3990,3991],{},"AUTO_INCREMENT",[58,3993,3994],{},[169,3995,2140],{},[58,3997,2621],{},[58,3999,2621],{},[58,4001,4002,4003,4006],{},"见本章坑 1；导数据时要 ",[169,4004,4005],{},"setval()"," 把序列推到当前最大 id 之后",[37,4008,4009,4014,4017,4019,4021],{},[58,4010,4011],{},[169,4012,4013],{},"ON UPDATE CURRENT_TIMESTAMP",[58,4015,4016],{},"触发器或应用侧赋值",[58,4018,2621],{},[58,4020,2621],{},[58,4022,4023],{},"PG 没有这个语法，别指望迁移工具带过来",[631,4025],{"height":4026,"src":4027,"title":4028},560,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F04-types-and-ddl.html","示例 04：类型行为观察——identity \u002F timestamptz \u002F jsonb \u002F char",[10,4030,4032],{"id":4031},"五单表查询where排序分页与几件-pg-独门兵器","五、单表查询：WHERE、排序、分页与几件 PG 独门兵器",[15,4034,4035,4036,4039],{},"单表查询是日常 80% 的 SQL。本章先给最常用的形状（第一步到第三步），把最容易出错的几件事——NULL 比较、NULL 排序、深分页、",[169,4037,4038],{},"count"," 的两种数法——集中放到章末的坑区。",[201,4041,4043],{"id":4042},"第一步五个关键词一条语句","第一步：五个关键词，一条语句",[298,4045,4047],{"className":1039,"code":4046,"language":1041,"meta":303,"style":303},"SELECT status, count(*) AS cnt\nFROM orders\nWHERE created_at >= '2026-10-01'\nGROUP BY status\nORDER BY cnt DESC\nLIMIT 10;\n",[169,4048,4049,4054,4058,4063,4068,4073],{"__ignoreMap":303},[307,4050,4051],{"class":309,"line":310},[307,4052,4053],{},"SELECT status, count(*) AS cnt\n",[307,4055,4056],{"class":309,"line":334},[307,4057,3381],{},[307,4059,4060],{"class":309,"line":345},[307,4061,4062],{},"WHERE created_at >= '2026-10-01'\n",[307,4064,4065],{"class":309,"line":355},[307,4066,4067],{},"GROUP BY status\n",[307,4069,4070],{"class":309,"line":366},[307,4071,4072],{},"ORDER BY cnt DESC\n",[307,4074,4075],{"class":309,"line":377},[307,4076,4077],{},"LIMIT 10;\n",[15,4079,4080,4083,4084,4087,4088,4091,4092,4095,4096,4099,4100,1657,4103,4106,4107,4110],{},[169,4081,4082],{},"WHERE"," 过滤行、",[169,4085,4086],{},"GROUP BY"," 分组、",[169,4089,4090],{},"HAVING"," 过滤组、",[169,4093,4094],{},"ORDER BY"," 排序、",[169,4097,4098],{},"LIMIT"," 取前 N 条——五个关键词按这个顺序写，执行顺序是 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT。判断空值用 ",[169,4101,4102],{},"IS NULL",[169,4104,4105],{},"IS NOT NULL","，不要用 ",[169,4108,4109],{},"= NULL","（原因见坑 1）。",[201,4112,4114],{"id":4113},"第二步分页的两种写法","第二步：分页的两种写法",[298,4116,4118],{"className":1039,"code":4117,"language":1041,"meta":303,"style":303},"-- 写法一：按页码跳页\nSELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n\n-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\nSELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;\n",[169,4119,4120,4125,4130,4134,4139],{"__ignoreMap":303},[307,4121,4122],{"class":309,"line":310},[307,4123,4124],{},"-- 写法一：按页码跳页\n",[307,4126,4127],{"class":309,"line":334},[307,4128,4129],{},"SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n",[307,4131,4132],{"class":309,"line":345},[307,4133,1756],{"emptyLinePlaceholder":1755},[307,4135,4136],{"class":309,"line":355},[307,4137,4138],{},"-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\n",[307,4140,4141],{"class":309,"line":366},[307,4142,4143],{},"SELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;\n",[15,4145,4146],{},"第一种能「跳转到第 N 页」，是给用户翻页用的；第二种代价恒定，适合信息流式的「下一页」。为什么深页要换写法，本章坑 3 有实测数字。",[201,4148,4150],{"id":4149},"第三步三件-pg-顺手兵器","第三步：三件 PG 顺手兵器",[15,4152,4153,4156],{},[19,4154,4155],{},"每组第一条","（「每个用户最近的一笔订单」），MySQL 得上窗口函数，PG 一句话：",[298,4158,4160],{"className":1039,"code":4159,"language":1041,"meta":303,"style":303},"SELECT DISTINCT ON (user_id) user_id, id, created_at\nFROM orders\nORDER BY user_id, created_at DESC;\n",[169,4161,4162,4167,4171],{"__ignoreMap":303},[307,4163,4164],{"class":309,"line":310},[307,4165,4166],{},"SELECT DISTINCT ON (user_id) user_id, id, created_at\n",[307,4168,4169],{"class":309,"line":334},[307,4170,3381],{},[307,4172,4173],{"class":309,"line":345},[307,4174,4175],{},"ORDER BY user_id, created_at DESC;\n",[15,4177,4178,4181,4182,4185],{},[19,4179,4180],{},"条件聚合","，比 ",[169,4183,4184],{},"CASE WHEN"," 干净：",[298,4187,4189],{"className":1039,"code":4188,"language":1041,"meta":303,"style":303},"SELECT user_id,\n       count(*)                                     AS total_cnt,\n       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\nFROM orders\nGROUP BY user_id\nHAVING count(*) FILTER (WHERE status = 'paid') >= 3;\n",[169,4190,4191,4196,4201,4206,4211,4215,4220],{"__ignoreMap":303},[307,4192,4193],{"class":309,"line":310},[307,4194,4195],{},"SELECT user_id,\n",[307,4197,4198],{"class":309,"line":334},[307,4199,4200],{},"       count(*)                                     AS total_cnt,\n",[307,4202,4203],{"class":309,"line":345},[307,4204,4205],{},"       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n",[307,4207,4208],{"class":309,"line":355},[307,4209,4210],{},"       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\n",[307,4212,4213],{"class":309,"line":366},[307,4214,3381],{},[307,4216,4217],{"class":309,"line":377},[307,4218,4219],{},"GROUP BY user_id\n",[307,4221,4222],{"class":309,"line":1471},[307,4223,4224],{},"HAVING count(*) FILTER (WHERE status = 'paid') >= 3;\n",[15,4226,4227,3040,4230,4233,4234,3223],{},[19,4228,4229],{},"改完顺手把结果带回来",[169,4231,4232],{},"RETURNING","），以及幂等写入（",[169,4235,4236],{},"ON CONFLICT",[298,4238,4240],{"className":1039,"code":4239,"language":1041,"meta":303,"style":303},"UPDATE orders SET status = 'paid', paid_at = now()\nWHERE id = 42\nRETURNING id, status, paid_at;\n\nINSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\nON CONFLICT (user_id, day)\nDO UPDATE SET pv = stats.pv + 1\nRETURNING pv;\n",[169,4241,4242,4247,4252,4257,4261,4266,4271,4276],{"__ignoreMap":303},[307,4243,4244],{"class":309,"line":310},[307,4245,4246],{},"UPDATE orders SET status = 'paid', paid_at = now()\n",[307,4248,4249],{"class":309,"line":334},[307,4250,4251],{},"WHERE id = 42\n",[307,4253,4254],{"class":309,"line":345},[307,4255,4256],{},"RETURNING id, status, paid_at;\n",[307,4258,4259],{"class":309,"line":355},[307,4260,1756],{"emptyLinePlaceholder":1755},[307,4262,4263],{"class":309,"line":366},[307,4264,4265],{},"INSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\n",[307,4267,4268],{"class":309,"line":377},[307,4269,4270],{},"ON CONFLICT (user_id, day)\n",[307,4272,4273],{"class":309,"line":1471},[307,4274,4275],{},"DO UPDATE SET pv = stats.pv + 1\n",[307,4277,4278],{"class":309,"line":1735},[307,4279,4280],{},"RETURNING pv;\n",[15,4282,4283,4286,4287,4290,4291,4294],{},[169,4284,4285],{},"DO UPDATE"," 换成 ",[169,4288,4289],{},"DO NOTHING"," 就是「有就跳过」。统计累加这类需求，一条 ",[169,4292,4293],{},"ON CONFLICT ... DO UPDATE"," 抵掉应用层整套「先查后插」的代码。",[631,4296],{"height":4297,"src":4298,"title":4299},520,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F05-single-table.html","示例 05：单表查询——NULL \u002F 深分页 \u002F DISTINCT ON \u002F FILTER \u002F RETURNING",[201,4301,666],{"id":4302},"️-本章的坑-3",[15,4304,4305,4310],{},[19,4306,2102,4307,4309],{},[169,4308,4109],{}," 永远选不出东西。"," SQL 里 NULL 不是值，是「未知」，任何比较碰上它结果都是「未知」（不成立）：",[298,4312,4314],{"className":1039,"code":4313,"language":1041,"meta":303,"style":303},"SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\nSELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法\n",[169,4315,4316,4321],{"__ignoreMap":303},[307,4317,4318],{"class":309,"line":310},[307,4319,4320],{},"SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\n",[307,4322,4323],{"class":309,"line":334},[307,4324,4325],{},"SELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法\n",[15,4327,4328,4329,4332,4333,4336,4337,4340,4341,4344,4345,4348],{},"由此派生两件常用兵器：",[169,4330,4331],{},"IS DISTINCT FROM"," 把 NULL 当成一个具体值来比较（",[169,4334,4335],{},"a IS DISTINCT FROM b"," 两侧都是 NULL 时为 false，正好补上 ",[169,4338,4339],{},"\u003C>"," 的洞）；",[169,4342,4343],{},"coalesce(x, 0)"," 给 NULL 兜底。除零保护留到第六章和 ",[169,4346,4347],{},"nullif"," 一起讲。",[15,4350,4351,4354,4355,4358],{},[19,4352,4353],{},"2. 排序时 NULL 的位置和你想的相反。"," PG 的默认规则是：",[19,4356,4357],{},"升序时 NULL 沉底，降序时 NULL 置顶","。于是「按支付时间倒序取最近 5 单」，第一行居然是个没支付的空时间：",[298,4360,4362],{"className":1039,"code":4361,"language":1041,"meta":303,"style":303},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\nSELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底\n",[169,4363,4364,4369],{"__ignoreMap":303},[307,4365,4366],{"class":309,"line":310},[307,4367,4368],{},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\n",[307,4370,4371],{"class":309,"line":334},[307,4372,4373],{},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底\n",[15,4375,4376],{},[19,4377,4378,4379,4382],{},"3. ",[169,4380,4381],{},"OFFSET"," 是给用户翻页用的，不是给深页用的。",[298,4384,4386],{"className":1039,"code":4385,"language":1041,"meta":303,"style":303},"SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 500000;  -- 要先算过前 50 万行再扔掉\n",[169,4387,4388],{"__ignoreMap":303},[307,4389,4390],{"class":309,"line":310},[307,4391,4385],{},[15,4393,4394,4395,4397],{},"它是 O(offset)：翻到越深的页越慢，第 25000 页就是全表扫的价钱。换成第二步的键集分页，前提是排序列有索引且唯一，代价恒定；代价是不支持「跳转到第 N 页」。示例 05 里 ",[169,4396,611],{}," 的两个数字对比，一眼就能看到量级差异。",[15,4399,4400,4410,4411,4414,4415,4417],{},[19,4401,4402,4403,4406,4407,4409],{},"4. ",[169,4404,4405],{},"DISTINCT ON"," 只取一条，且 ",[169,4408,4094],{}," 最左边必须同列。"," 规则说人话：",[169,4412,4413],{},"DISTINCT ON (列)"," 指定「每组」，",[169,4416,4094],{}," 的最左边必须是同样的列，然后每组按剩余排序条件取第一条。要每组前三条就得回窗口函数（第六章）。它是 PG 对 SQL 标准的扩展，别的库没有。",[15,4419,4420,4429,4430,4433,4434,4437],{},[19,4421,732,4422,1345,4425,4428],{},[169,4423,4424],{},"count(*)",[169,4426,4427],{},"count(列)"," 数的不是一回事。"," 前者数所有行，后者只数该列非 NULL 的行——「转化率」类指标两个 count 相除时务必想清楚用哪个。同理，",[169,4431,4432],{},"FILTER"," 虽然是 PG 扩展语法（等价于 ",[169,4435,4436],{},"count(CASE WHEN status='paid' THEN 1 END)","，但少敲一半字符），换库时要记得它不通用。",[15,4439,4440,675,4447,4449,4450,4452,4453,4455,4456,22],{},[19,4441,1368,4442,1345,4444,4446],{},[169,4443,4090],{},[169,4445,4082],{}," 的先后位置别放反。",[169,4448,4082],{}," 过滤聚合前的行，",[169,4451,4090],{}," 过滤聚合后的组。把聚合条件写进 ",[169,4454,4082],{}," 会直接报 ",[169,4457,4458],{},"aggregate functions are not allowed in WHERE",[15,4460,4461,675,4466,4469,4470,4473,4474,22],{},[19,4462,755,4463,4465],{},[169,4464,4236],{}," 的推断列必须有唯一约束。",[169,4467,4468],{},"ON CONFLICT (user_id, day)"," 成立的前提是 ",[169,4471,4472],{},"(user_id, day)"," 上真有唯一索引，否则报 ",[169,4475,4476],{},"there is no unique or exclusion constraint matching the ON CONFLICT specification",[15,4478,4479,4482,4483,4485],{},[19,4480,4481],{},"8. 「先查后插」在并发下会丢更新。"," 应用层写「查一下有没有，没有就插」，两个线程同时查到「没有」就出事了。",[169,4484,4293],{}," 把这件事交给数据库原子完成；同一类问题在事务层面还有第三种解法，第九章细算这笔账。",[10,4487,4489],{"id":4488},"六常用函数字符串时间jsonb-与窗口","六、常用函数：字符串、时间、JSONB 与窗口",[15,4491,4492],{},"函数不背清单。本章按「改字符串、算时间、掏 JSON、开窗」四组各给一个最小用法，速查表和几个容易翻车的细节放到章末。",[201,4494,4496],{"id":4495},"第一步字符串拼接兜底切分","第一步：字符串——拼接、兜底、切分",[298,4498,4500],{"className":1039,"code":4499,"language":1041,"meta":303,"style":303},"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\nSELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\nSELECT split_part(email, '@', 2);           -- 取域名\nSELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\nSELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\nSELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用\n",[169,4501,4502,4507,4512,4517,4522,4527],{"__ignoreMap":303},[307,4503,4504],{"class":309,"line":310},[307,4505,4506],{},"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\n",[307,4508,4509],{"class":309,"line":334},[307,4510,4511],{},"SELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\n",[307,4513,4514],{"class":309,"line":345},[307,4515,4516],{},"SELECT split_part(email, '@', 2);           -- 取域名\n",[307,4518,4519],{"class":309,"line":355},[307,4520,4521],{},"SELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\n",[307,4523,4524],{"class":309,"line":366},[307,4525,4526],{},"SELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\n",[307,4528,4529],{"class":309,"line":377},[307,4530,4531],{},"SELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用\n",[15,4533,4534,4535,1657,4538,4541,4542,1509,4545,4547],{},"批量拼文案优先用 ",[169,4536,4537],{},"concat",[169,4539,4540],{},"format","，别用 ",[169,4543,4544],{},"||",[169,4546,4544],{}," 遇到 NULL 会把整个结果传染成 NULL（详见坑 1）。",[201,4549,4551],{"id":4550},"第二步时间截断加减取部位","第二步：时间——截断、加减、取部位",[298,4553,4555],{"className":1039,"code":4554,"language":1041,"meta":303,"style":303},"SELECT date_trunc('month', created_at) AS mon, count(*)\nFROM orders\nGROUP BY mon;                              -- 月报分组键\n\nSELECT created_at + interval '30 days' AS deadline,   -- 加减\n       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\nFROM orders;\n",[169,4556,4557,4562,4566,4571,4575,4580,4585,4590],{"__ignoreMap":303},[307,4558,4559],{"class":309,"line":310},[307,4560,4561],{},"SELECT date_trunc('month', created_at) AS mon, count(*)\n",[307,4563,4564],{"class":309,"line":334},[307,4565,3381],{},[307,4567,4568],{"class":309,"line":345},[307,4569,4570],{},"GROUP BY mon;                              -- 月报分组键\n",[307,4572,4573],{"class":309,"line":355},[307,4574,1756],{"emptyLinePlaceholder":1755},[307,4576,4577],{"class":309,"line":366},[307,4578,4579],{},"SELECT created_at + interval '30 days' AS deadline,   -- 加减\n",[307,4581,4582],{"class":309,"line":377},[307,4583,4584],{},"       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n",[307,4586,4587],{"class":309,"line":1471},[307,4588,4589],{},"       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\n",[307,4591,4592],{"class":309,"line":1735},[307,4593,4594],{},"FROM orders;\n",[15,4596,4597,4598,4601,4602,4605,4606,4609],{},"更多写法（",[169,4599,4600],{},"to_char"," 格式化、",[169,4603,4604],{},"AT TIME ZONE"," 换时区、",[169,4607,4608],{},"now()"," 三兄弟的区别）见章末速查表。",[201,4611,4613],{"id":4612},"第三步从-jsonb-里掏数据","第三步：从 jsonb 里掏数据",[298,4615,4617],{"className":1039,"code":4616,"language":1041,"meta":303,"style":303},"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\nFROM orders\nGROUP BY channel;\n",[169,4618,4619,4624,4628],{"__ignoreMap":303},[307,4620,4621],{"class":309,"line":310},[307,4622,4623],{},"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\n",[307,4625,4626],{"class":309,"line":334},[307,4627,3381],{},[307,4629,4630],{"class":309,"line":345},[307,4631,4632],{},"GROUP BY channel;\n",[15,4634,4635],{},"要展开数组逐行处理（比如统计订单里每个标签的出现次数）：",[298,4637,4639],{"className":1039,"code":4638,"language":1041,"meta":303,"style":303},"SELECT tag, count(*)\nFROM orders,\n     jsonb_array_elements(meta->'tags') AS tag\nGROUP BY tag;\n",[169,4640,4641,4646,4651,4656],{"__ignoreMap":303},[307,4642,4643],{"class":309,"line":310},[307,4644,4645],{},"SELECT tag, count(*)\n",[307,4647,4648],{"class":309,"line":334},[307,4649,4650],{},"FROM orders,\n",[307,4652,4653],{"class":309,"line":345},[307,4654,4655],{},"     jsonb_array_elements(meta->'tags') AS tag\n",[307,4657,4658],{"class":309,"line":355},[307,4659,4660],{},"GROUP BY tag;\n",[15,4662,4663,4666,4667,4670],{},[169,4664,4665],{},"FROM 表, jsonb_array_elements(...) AS 别名"," 这个写法等价于 ",[169,4668,4669],{},"LATERAL","（第七章），把每个 jsonb 数组元素炸成一行。",[201,4672,4674],{"id":4673},"第四步窗口函数不动行数地看邻居","第四步：窗口函数——不动行数地「看邻居」",[15,4676,4677,4679,4680,4683],{},[169,4678,4086],{}," 会把 N 行折叠成 1 行，窗口函数是",[19,4681,4682],{},"每一行都保留、额外加一列聚合视野","。两个最高频场景：",[298,4685,4687],{"className":1039,"code":4686,"language":1041,"meta":303,"style":303},"-- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\nSELECT * FROM (\n  SELECT o.*,\n         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n  FROM orders o\n) t\nWHERE rn \u003C= 3;\n\n-- 环比：拿上一行的值；累计：组起点到当前行\nSELECT user_id, created_at, amount,\n       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\nFROM orders;\n",[169,4688,4689,4694,4699,4704,4709,4714,4719,4724,4728,4733,4738,4743,4748,4753],{"__ignoreMap":303},[307,4690,4691],{"class":309,"line":310},[307,4692,4693],{},"-- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\n",[307,4695,4696],{"class":309,"line":334},[307,4697,4698],{},"SELECT * FROM (\n",[307,4700,4701],{"class":309,"line":345},[307,4702,4703],{},"  SELECT o.*,\n",[307,4705,4706],{"class":309,"line":355},[307,4707,4708],{},"         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n",[307,4710,4711],{"class":309,"line":366},[307,4712,4713],{},"  FROM orders o\n",[307,4715,4716],{"class":309,"line":377},[307,4717,4718],{},") t\n",[307,4720,4721],{"class":309,"line":1471},[307,4722,4723],{},"WHERE rn \u003C= 3;\n",[307,4725,4726],{"class":309,"line":1735},[307,4727,1756],{"emptyLinePlaceholder":1755},[307,4729,4730],{"class":309,"line":1741},[307,4731,4732],{},"-- 环比：拿上一行的值；累计：组起点到当前行\n",[307,4734,4735],{"class":309,"line":1747},[307,4736,4737],{},"SELECT user_id, created_at, amount,\n",[307,4739,4740],{"class":309,"line":1752},[307,4741,4742],{},"       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n",[307,4744,4745],{"class":309,"line":1759},[307,4746,4747],{},"       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n",[307,4749,4750],{"class":309,"line":1765},[307,4751,4752],{},"                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\n",[307,4754,4755],{"class":309,"line":1771},[307,4756,4594],{},[15,4758,4759,4762,4763,1657,4766,1657,4769,4772],{},[169,4760,4761],{},"ROWS BETWEEN ..."," 子句叫窗口帧，不写默认是「组起点到当前行」，累计值正是靠它。",[169,4764,4765],{},"row_number()",[169,4767,4768],{},"rank()",[169,4770,4771],{},"dense_rank()"," 三个序号函数的区别见章末速查表。",[631,4774],{"height":4297,"src":4775,"title":4776},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F06-functions-window.html","示例 06：函数与窗口——NULL 传染 \u002F 除零保护 \u002F jsonb \u002F 环比",[201,4778,666],{"id":4779},"️-本章的坑-4",[15,4781,4782,4787],{},[19,4783,2102,4784,4786],{},[169,4785,4544],{}," 遇到 NULL，整个结果变 NULL。"," 「无中生有」是 SQL 的规矩：",[298,4789,4791],{"className":1039,"code":4790,"language":1041,"meta":303,"style":303},"SELECT 'a' || NULL;            -- NULL\nSELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串\n",[169,4792,4793,4798],{"__ignoreMap":303},[307,4794,4795],{"class":309,"line":310},[307,4796,4797],{},"SELECT 'a' || NULL;            -- NULL\n",[307,4799,4800],{"class":309,"line":334},[307,4801,4802],{},"SELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串\n",[15,4804,4805,4806,4809,4810,1657,4812,22],{},"批量拼展示文案时，要么先 ",[169,4807,4808],{},"coalesce","，要么直接用 ",[169,4811,4537],{},[169,4813,4540],{},[15,4815,4816,675,4819,4822],{},[19,4817,4818],{},"2. 除法不保护就是报错。",[169,4820,4821],{},"nullif(a, b)"," 在 a=b 时返回 NULL，否则返回 a，它最大的用场是除零保护——第四章埋的账在这里还：",[298,4824,4826],{"className":1039,"code":4825,"language":1041,"meta":303,"style":303},"SELECT 100 \u002F 0;                          -- ERROR: division by zero\nSELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\nSELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算\n",[169,4827,4828,4833,4838],{"__ignoreMap":303},[307,4829,4830],{"class":309,"line":310},[307,4831,4832],{},"SELECT 100 \u002F 0;                          -- ERROR: division by zero\n",[307,4834,4835],{"class":309,"line":334},[307,4836,4837],{},"SELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\n",[307,4839,4840],{"class":309,"line":345},[307,4841,4842],{},"SELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算\n",[15,4844,4845,675,4848,4851,4852,4855],{},[19,4846,4847],{},"3. 三个长度函数对中文的结果不一样。",[169,4849,4850],{},"length('中文')"," 是 2（字符），",[169,4853,4854],{},"octet_length('中文')"," 是 6（UTF-8 字节）——限长、算存储时想清楚自己要的是哪个。",[15,4857,4858,4864,4865,4868,4869,4872,4873,4876],{},[19,4859,4860,4861,4863],{},"4. 窗口函数不能出现在 ",[169,4862,4082],{}," 里。"," 必须包一层子查询再过滤，直接写 ",[169,4866,4867],{},"WHERE rn = 1"," 会收到 ",[169,4870,4871],{},"ERROR: window functions are not allowed in WHERE","——PG 没有 DuckDB\u002FSnowflake 那种 ",[169,4874,4875],{},"QUALIFY"," 短路，子查询包装就是标准答案。",[15,4878,4879,4884,4885,4887,4888,3040,4891,4894,4895,4898,4899,4902,4903,4905,4906,22],{},[19,4880,732,4881,4883],{},[169,4882,4608],{}," 不等于「此刻」。"," 函数三兄弟：",[169,4886,4608],{}," = ",[169,4889,4890],{},"transaction_timestamp()",[19,4892,4893],{},"事务内恒定","）、",[169,4896,4897],{},"statement_timestamp()","（每条语句刷新）、",[169,4900,4901],{},"clock_timestamp()","（真实钟，同一行里两次调用都不同）。做「同一事务内 created 和 updated 相同」要 ",[169,4904,4608],{},"，做监控计时才用 ",[169,4907,4901],{},[15,4909,4910,675,4915,4917,4918,4921],{},[19,4911,1368,4912,4914],{},[169,4913,2087],{}," 的第三个参数必须是 jsonb 字面量。",[169,4916,3346],{}," 里的引号不是装饰——少写 ",[169,4919,4920],{},"::jsonb"," 或写成裸字符串，会得到一个带多余引号的值或直接报类型错。",[15,4923,4924,4930,4931,4933,4934,4936],{},[19,4925,4926,4927,4929],{},"7. 包含查询 ",[169,4928,3303],{}," 不建索引就是全表扫。"," 第四章提过，",[169,4932,1684],{}," 的 ",[169,4935,3303],{}," 要走 GIN 索引才划算（第八章讲两种 opclass 的取舍），否则每次包含查询都是顺序扫整列 jsonb。",[201,4938,4940],{"id":4939},"附时间与序号函数速查","附：时间与序号函数速查",[31,4942,4943,4956],{},[34,4944,4945],{},[37,4946,4947,4950,4953],{},[40,4948,4949],{},"需求",[40,4951,4952],{},"写法",[40,4954,4955],{},"结果（以 2026-10-10 09:30:15+08 为例）",[53,4957,4958,4974,4990,5003,5019,5035],{},[37,4959,4960,4963,4968],{},[58,4961,4962],{},"按月截断",[58,4964,4965],{},[169,4966,4967],{},"date_trunc('month', ts)",[58,4969,4970,4973],{},[169,4971,4972],{},"2026-10-01 00:00:00+08","——月报、周报的分组键",[37,4975,4976,4979,4987],{},[58,4977,4978],{},"加减",[58,4980,4981,625,4984],{},[169,4982,4983],{},"ts + interval '30 days'",[169,4985,4986],{},"ts - interval '2 hours'",[58,4988,4989],{},"interval 直接参与运算",[37,4991,4992,4995,5000],{},[58,4993,4994],{},"取部位",[58,4996,4997],{},[169,4998,4999],{},"extract(epoch FROM ts)",[58,5001,5002],{},"Unix 秒，跟前端\u002F缓存对时间的通用语言",[37,5004,5005,5008,5013],{},[58,5006,5007],{},"两个时刻差",[58,5009,5010],{},[169,5011,5012],{},"age(ts2, ts1)",[58,5014,5015,5018],{},[169,5016,5017],{},"1 year 2 mons 3 days"," 这种人话",[37,5020,5021,5024,5029],{},[58,5022,5023],{},"格式化",[58,5025,5026],{},[169,5027,5028],{},"to_char(ts, 'YYYY-MM-DD HH24:MI')",[58,5030,5031,5032],{},"输出展示用；入参用 ",[169,5033,5034],{},"to_timestamp(s, 'YYYY-MM-DD')",[37,5036,5037,5040,5045],{},[58,5038,5039],{},"换时区",[58,5041,5042],{},[169,5043,5044],{},"ts AT TIME ZONE 'UTC'",[58,5046,5047],{},"timestamptz 与 timestamp 互转",[31,5049,5050,5063],{},[34,5051,5052],{},[37,5053,5054,5057,5060],{},[40,5055,5056],{},"序号函数",[40,5058,5059],{},"并列时",[40,5061,5062],{},"结果",[53,5064,5065,5077,5089],{},[37,5066,5067,5071,5074],{},[58,5068,5069],{},[169,5070,4765],{},[58,5072,5073],{},"仍递增",[58,5075,5076],{},"1, 2, 3, 4——「严格名次 + 去重取一」",[37,5078,5079,5083,5086],{},[58,5080,5081],{},[169,5082,4768],{},[58,5084,5085],{},"并列同名次，跳号",[58,5087,5088],{},"1, 2, 2, 4",[37,5090,5091,5095,5098],{},[58,5092,5093],{},[169,5094,4771],{},[58,5096,5097],{},"并列同名次，不跳号",[58,5099,5100],{},"1, 2, 2, 3",[10,5102,5104],{"id":5103},"七多表关系join-家族exists-与外键的连锁行为","七、多表关系：JOIN 家族、EXISTS 与外键的连锁行为",[15,5106,5107,5108,5110,5111,5110,5113,5115,5116,5118,5119,22],{},"模型定型：",[169,5109,1389],{}," 1:N ",[169,5112,1351],{},[169,5114,1400],{}," N:1 ",[169,5117,1393],{},"；商品与标签多对多走中间表 ",[169,5120,5121],{},"product_tags",[201,5123,5125],{"id":5124},"第一步join-家族","第一步：JOIN 家族",[31,5127,5128,5140],{},[34,5129,5130],{},[37,5131,5132,5134,5137],{},[40,5133,4952],{},[40,5135,5136],{},"语义",[40,5138,5139],{},"记忆点",[53,5141,5142,5156,5169,5182],{},[37,5143,5144,5150,5153],{},[58,5145,5146,5149],{},[169,5147,5148],{},"INNER JOIN","（默认 JOIN）",[58,5151,5152],{},"只留两边都能配上对的",[58,5154,5155],{},"配不上对的行整体消失",[37,5157,5158,5163,5166],{},[58,5159,5160],{},[169,5161,5162],{},"LEFT [OUTER] JOIN",[58,5164,5165],{},"左表全保留，右边配不上补 NULL",[58,5167,5168],{},"「主表 + 带不带都行」",[37,5170,5171,5176,5179],{},[58,5172,5173],{},[169,5174,5175],{},"RIGHT \u002F FULL JOIN",[58,5177,5178],{},"对称地保留右表 \u002F 两边都保留",[58,5180,5181],{},"FULL 在补数据、对账场景常见",[37,5183,5184,5189,5192],{},[58,5185,5186],{},[169,5187,5188],{},"CROSS JOIN",[58,5190,5191],{},"笛卡尔积",[58,5193,5194],{},"几乎只出现在生成日历、排列组合的场景",[15,5196,5197],{},"最常用的就是前两种，一条语句记住形状：",[298,5199,5201],{"className":1039,"code":5200,"language":1041,"meta":303,"style":303},"SELECT u.name, count(o.id) AS order_cnt\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nGROUP BY u.name;\n",[169,5202,5203,5208,5213,5218],{"__ignoreMap":303},[307,5204,5205],{"class":309,"line":310},[307,5206,5207],{},"SELECT u.name, count(o.id) AS order_cnt\n",[307,5209,5210],{"class":309,"line":334},[307,5211,5212],{},"FROM users u\n",[307,5214,5215],{"class":309,"line":345},[307,5216,5217],{},"LEFT JOIN orders o ON o.user_id = u.id\n",[307,5219,5220],{"class":309,"line":355},[307,5221,5222],{},"GROUP BY u.name;\n",[201,5224,5226],{"id":5225},"第二步exists-与-lateral","第二步：EXISTS 与 LATERAL",[15,5228,5229],{},"「买了东西的用户」两种写法，优化器基本会改写成同一个计划，选哪个看可读性：",[298,5231,5233],{"className":1039,"code":5232,"language":1041,"meta":303,"style":303},"SELECT name FROM users u WHERE EXISTS (\n  SELECT 1 FROM orders o WHERE o.user_id = u.id\n);\n",[169,5234,5235,5240,5245],{"__ignoreMap":303},[307,5236,5237],{"class":309,"line":310},[307,5238,5239],{},"SELECT name FROM users u WHERE EXISTS (\n",[307,5241,5242],{"class":309,"line":334},[307,5243,5244],{},"  SELECT 1 FROM orders o WHERE o.user_id = u.id\n",[307,5246,5247],{"class":309,"line":345},[307,5248,1474],{},[15,5250,5251,5252,5254],{},"「每组取 N 条」还有个比窗口函数更直接的 ",[169,5253,4669],{},"：右边的子查询可以引用左边表的列，每行参数化执行一次：",[298,5256,5258],{"className":1039,"code":5257,"language":1041,"meta":303,"style":303},"SELECT u.name, o.id, o.amount\nFROM users u\nLEFT JOIN LATERAL (\n  SELECT * FROM orders o\n  WHERE o.user_id = u.id\n  ORDER BY created_at DESC LIMIT 3\n) o ON true;\n",[169,5259,5260,5265,5269,5274,5279,5284,5289],{"__ignoreMap":303},[307,5261,5262],{"class":309,"line":310},[307,5263,5264],{},"SELECT u.name, o.id, o.amount\n",[307,5266,5267],{"class":309,"line":334},[307,5268,5212],{},[307,5270,5271],{"class":309,"line":345},[307,5272,5273],{},"LEFT JOIN LATERAL (\n",[307,5275,5276],{"class":309,"line":355},[307,5277,5278],{},"  SELECT * FROM orders o\n",[307,5280,5281],{"class":309,"line":366},[307,5282,5283],{},"  WHERE o.user_id = u.id\n",[307,5285,5286],{"class":309,"line":377},[307,5287,5288],{},"  ORDER BY created_at DESC LIMIT 3\n",[307,5290,5291],{"class":309,"line":1471},[307,5292,5293],{},") o ON true;\n",[201,5295,5297],{"id":5296},"第三步外键动作的现场","第三步：外键动作的现场",[15,5299,5300,5301,5304,5305,1930],{},"第四章给过 CASCADE \u002F SET NULL \u002F RESTRICT 的选型表，这里看现场。给 ",[169,5302,5303],{},"orders.user_id"," 配 ",[169,5306,1805],{},[298,5308,5311],{"className":5309,"code":5310,"language":422,"meta":303},[420],"shop=# DELETE FROM users WHERE id = 7;\nDELETE 1\nshop=# SELECT count(*) FROM orders WHERE user_id = 7;\n count\n-------\n     0\n(1 row)      -- 订单整整齐齐跟着没了，删库跑门槛都没有\n",[169,5312,5310],{"__ignoreMap":303},[15,5314,5315,5316,5319,5320,5322],{},"如果当初配的是 ",[169,5317,5318],{},"SET NULL","，同一组操作后 ",[169,5321,1351],{}," 里 user_id=7 的行还在、外键变成 NULL——「关联断了但数据还在」。两个动作都对，取决于业务上订单是「用户的附属品」还是「独立凭证」。",[631,5324],{"height":4026,"src":5325,"title":5326},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F07-joins-fk.html","示例 07：JOIN 与外键——过滤位置坑 \u002F NOT IN 翻车 \u002F 级联删除与触发器耗时",[201,5328,666],{"id":5329},"️-本章的坑-5",[15,5331,5332,5338],{},[19,5333,2102,5334,5337],{},[169,5335,5336],{},"LEFT JOIN"," 的过滤条件放错地方，语义直接退化。"," 两个查询只差一个从句：",[298,5340,5342],{"className":1039,"code":5341,"language":1041,"meta":303,"style":303},"-- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n\n-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid';\n",[169,5343,5344,5349,5354,5358,5363,5367,5372,5376,5380,5384],{"__ignoreMap":303},[307,5345,5346],{"class":309,"line":310},[307,5347,5348],{},"-- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\n",[307,5350,5351],{"class":309,"line":334},[307,5352,5353],{},"SELECT u.name, o.id\n",[307,5355,5356],{"class":309,"line":345},[307,5357,5212],{},[307,5359,5360],{"class":309,"line":355},[307,5361,5362],{},"LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n",[307,5364,5365],{"class":309,"line":366},[307,5366,1756],{"emptyLinePlaceholder":1755},[307,5368,5369],{"class":309,"line":377},[307,5370,5371],{},"-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\n",[307,5373,5374],{"class":309,"line":1471},[307,5375,5353],{},[307,5377,5378],{"class":309,"line":1735},[307,5379,5212],{},[307,5381,5382],{"class":309,"line":1741},[307,5383,5217],{},[307,5385,5386],{"class":309,"line":1747},[307,5387,5388],{},"WHERE o.status = 'paid';\n",[15,5390,5391,5392,5395],{},"规则一句话：",[19,5393,5394],{},"对右表（被 JOIN 表）的过滤放 ON，对左表主数据的过滤放 WHERE","。示例 07 里有这两个查询并排的输出对比。",[15,5397,5398],{},[19,5399,2200,5400,5403],{},[169,5401,5402],{},"NOT IN"," 遇上子查询里的 NULL 会全军覆没。",[298,5405,5407],{"className":1039,"code":5406,"language":1041,"meta":303,"style":303},"-- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\nSELECT name FROM products\nWHERE id NOT IN (SELECT product_id FROM product_tags);\n\n-- NOT EXISTS 不受影响，语义正确\nSELECT name FROM products p\nWHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id);\n",[169,5408,5409,5414,5419,5424,5428,5433,5438],{"__ignoreMap":303},[307,5410,5411],{"class":309,"line":310},[307,5412,5413],{},"-- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\n",[307,5415,5416],{"class":309,"line":334},[307,5417,5418],{},"SELECT name FROM products\n",[307,5420,5421],{"class":309,"line":345},[307,5422,5423],{},"WHERE id NOT IN (SELECT product_id FROM product_tags);\n",[307,5425,5426],{"class":309,"line":355},[307,5427,1756],{"emptyLinePlaceholder":1755},[307,5429,5430],{"class":309,"line":366},[307,5431,5432],{},"-- NOT EXISTS 不受影响，语义正确\n",[307,5434,5435],{"class":309,"line":377},[307,5436,5437],{},"SELECT name FROM products p\n",[307,5439,5440],{"class":309,"line":1471},[307,5441,5442],{},"WHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id);\n",[15,5444,5445,5446,5448,5449,5452],{},"原因是 ",[169,5447,5402],{}," 展开成「与所有元素都不等」，而任何值与 NULL 比较都是「未知」，整个条件永远不成立。",[19,5450,5451],{},"否定语义一律写 NOT EXISTS","，这条规则能免掉一类极难排查的线上问题（数据一变 NULL，查询悄悄变空）。",[15,5454,5455,5458,5459,5462,5463,5466,5467,5470,5471,5474,5475,5478],{},[19,5456,5457],{},"3. PG 不给外键自动建索引，删除时会现场爆发。"," 第四章埋的雷在这里显形：",[169,5460,5461],{},"EXPLAIN ANALYZE DELETE FROM users WHERE id = 7"," 的输出里有一行 ",[169,5464,5465],{},"Trigger for constraint orders_user_id_fkey: time=143.2 ms","——父表按主键索引秒删，但 PG 检查引用约束时要",[19,5468,5469],{},"顺序扫一遍 orders 全表","。给 ",[169,5472,5473],{},"orders(user_id)"," 建上索引再删，同一个 DELETE 的触发器耗时掉到 0.1ms 量级。",[19,5476,5477],{},"建外键的下一行就建索引","，让这两行在迁移脚本里永远是邻居。",[15,5480,5481,5490,5491,5494],{},[19,5482,4402,5483,5485,5486,5489],{},[169,5484,4669],{}," 子查询后面必须跟 ",[169,5487,5488],{},"ON"," 条件","，没有条件就写 ",[169,5492,5493],{},"ON true","，直接省掉会报语法错误。它比窗口函数直观，但每行都要执行一次子查询，驱动集很大时先看执行计划（第八章）。",[10,5496,5498],{"id":5497},"八索引b-tree-走得快也要养得起","八、索引：B-tree 走得快，也要养得起",[15,5500,5501],{},"本章先给「建一个索引并确认它生效」这条最小路径（第一步到第三步），把读执行计划的方法、六种索引类型和索引的成本账放到章末——第一遍只需要会前三步。",[201,5503,5505],{"id":5504},"第一步建一个索引确认它生效","第一步：建一个索引，确认它生效",[298,5507,5509],{"className":1039,"code":5508,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_orders_order_no ON orders (order_no);\n\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123';\n",[169,5510,5511,5516,5520],{"__ignoreMap":303},[307,5512,5513],{"class":309,"line":310},[307,5514,5515],{},"CREATE INDEX idx_orders_order_no ON orders (order_no);\n",[307,5517,5518],{"class":309,"line":334},[307,5519,1756],{"emptyLinePlaceholder":1755},[307,5521,5522],{"class":309,"line":345},[307,5523,5524],{},"EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123';\n",[15,5526,5527,5528,5531,5532,5535,5536,5538,5539,5542],{},"计划里出现 ",[169,5529,5530],{},"Index Scan using idx_orders_order_no"," 就说明走上了；还是 ",[169,5533,5534],{},"Seq Scan"," 就是没走上（列上有隐式转换、条件写成函数包裹、或者命中行太多优化器主动放弃）。",[169,5537,1378],{}," 必须加 ",[169,5540,5541],{},"ANALYZE"," 才会真的执行并给出 actual 数字，详见坑 1。",[201,5544,5546],{"id":5545},"第二步复合索引的列序","第二步：复合索引的列序",[298,5548,5550],{"className":1039,"code":5549,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);\n",[169,5551,5552],{"__ignoreMap":303},[307,5553,5554],{"class":309,"line":310},[307,5555,5549],{},[15,5557,5558,5559,5562,5563,5566,5567,5570,5571,5574,5575,5578,5579,5581,5582,5585],{},"最左前缀规则决定它能服务谁：",[169,5560,5561],{},"WHERE user_id = 42"," 能用（最左列独立命中）；",[169,5564,5565],{},"WHERE user_id = 42 AND created_at > now() - interval '7 days'"," 能用得很好；",[169,5568,5569],{},"WHERE created_at > ..."," 单独用",[19,5572,5573],{},"不能","——最左列缺席，索引整体作废。通用配方：",[19,5576,5577],{},"等值条件列在前，范围条件列在后","；再把 ",[169,5580,4094],{}," 的列对齐进去，",[169,5583,5584],{},"WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5"," 可以完全在索引里倒着走五步就停，计划里连 Sort 节点都没有（示例 08 有这个计划的对照）。",[201,5587,5589],{"id":5588},"第三步三种救场索引","第三步：三种「救场」索引",[15,5591,5592,5595,5596,5599],{},[19,5593,5594],{},"覆盖索引（INCLUDE）","——查询需要的列全在索引里，就不用回表。PG 11 起 ",[169,5597,5598],{},"CREATE INDEX ... INCLUDE (extra_col)"," 把额外列挂进索引叶子页（不参与排序）：",[298,5601,5603],{"className":1039,"code":5602,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_orders_user_cover ON orders (user_id) INCLUDE (status, amount);\n",[169,5604,5605],{"__ignoreMap":303},[307,5606,5607],{"class":309,"line":310},[307,5608,5602],{},[15,5610,5611,5614,5615,5618],{},[19,5612,5613],{},"部分索引","——只索引满足条件的行，待办列表场景极爽，百万订单里 ",[169,5616,5617],{},"pending"," 状态的就几千行：",[298,5620,5622],{"className":1039,"code":5621,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_orders_pending ON orders (created_at)\nWHERE status = 'pending';\n",[169,5623,5624,5629],{"__ignoreMap":303},[307,5625,5626],{"class":309,"line":310},[307,5627,5628],{},"CREATE INDEX idx_orders_pending ON orders (created_at)\n",[307,5630,5631],{"class":309,"line":334},[307,5632,5633],{},"WHERE status = 'pending';\n",[15,5635,5636],{},"索引体积是全量索引的零头，写入时多数行根本不进索引，代价几乎为零。",[15,5638,5639,5642,5643,5646,5647,5650],{},[19,5640,5641],{},"表达式索引","——第四章埋过的雷在这里拆。",[169,5644,5645],{},"WHERE lower(email) = 'ada@example.com'"," 用普通 btree 索引是",[19,5648,5649],{},"命中不了的","，建表达式索引：",[298,5652,5654],{"className":1039,"code":5653,"language":1041,"meta":303,"style":303},"CREATE INDEX idx_users_email_lc ON users (lower(email));\n",[169,5655,5656],{"__ignoreMap":303},[307,5657,5658],{"class":309,"line":310},[307,5659,5653],{},[631,5661],{"height":5662,"src":5663,"title":5664},600,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F08-index-explain.html","示例 08：索引与 EXPLAIN——Seq \u002F Index \u002F Bitmap 前后对照、复合列序、表达式索引",[201,5666,666],{"id":5667},"️-本章的坑-6",[15,5669,5670,5676,5677,5680],{},[19,5671,5672,5673,5675],{},"1. 不加 ",[169,5674,5541],{}," 的 EXPLAIN 是「打算」，不是「结果」。"," 不加只显示优化器",[19,5678,5679],{},"计划","怎么执行，加上才真的执行并显示 actual 行数与耗时。拿计划里的估算行数去汇报，等于没跑。",[15,5682,5683,5686,5687,5690,5691,5694,5695,5698,5699,5702,5703,5706,5707,5710],{},[19,5684,5685],{},"2. 估算行数和实际行数差一个数量级，先怀疑统计信息。"," 读输出抓三样：",[169,5688,5689],{},"rows=","（估算行数）与 actual 行数差多少——差一个数量级说明统计信息过期，跑一把 ",[169,5692,5693],{},"ANALYZE orders"," 让 autovacuum 重新采样；",[169,5696,5697],{},"Buffers: shared read=","（从磁盘读了多少页）——",[169,5700,5701],{},"hit"," 是缓存命中，",[169,5704,5705],{},"read"," 是真实 IO；",[169,5708,5709],{},"Execution Time","——唯一能写进汇报的数字。",[15,5712,5713,5716,5717,5719,5720,5722,5723,5726,5727,22],{},[19,5714,5715],{},"3. 表达式索引是「文本级一致」才匹配。"," 建索引用 ",[169,5718,2986],{},"，查询就必须写一模一样的 ",[169,5721,2986],{},"，写成 ",[169,5724,5725],{},"lower(email, 'C')"," 都会对不上——这也是全文搜索第十三章要用「同一表达式」的原因。另一种解法更省事：应用侧保证存入时已小写，查询写 ",[169,5728,5729],{},"WHERE email = lower('Ada@Example.com')",[15,5731,5732,5738],{},[19,5733,4402,5734,5737],{},[169,5735,5736],{},"%关键词%"," 这种中缀 LIKE 用不上 btree。"," 解法是 pg_trgm 扩展 + GIN 索引：",[298,5740,5742],{"className":1039,"code":5741,"language":1041,"meta":303,"style":303},"CREATE EXTENSION pg_trgm;\nCREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);\n",[169,5743,5744,5749],{"__ignoreMap":303},[307,5745,5746],{"class":309,"line":310},[307,5747,5748],{},"CREATE EXTENSION pg_trgm;\n",[307,5750,5751],{"class":309,"line":334},[307,5752,5753],{},"CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);\n",[15,5755,5756,5757,5760],{},"它把字符串拆成三元组做倒排，",[169,5758,5759],{},"ILIKE '%键盘%'"," 也能走索引——很多「搜索框」需求到这一步就够了，撑不到需要第十三章全文搜索的程度。",[15,5762,5763,5766,5767,5770,5771,5774,5775,5778],{},[19,5764,5765],{},"5. 索引不是免费的：写入要付利息，膨胀要还本金。"," 每多一个索引，每次 INSERT\u002FUPDATE 都多一次维护写入；",[169,5768,5769],{},"UPDATE"," 只要碰了",[19,5772,5773],{},"任何一个被索引的列","，新版本就要在所有相关索引里各插一份（旧版本变死元组，第九章细说）。所以「先建 20 个索引再倒数据」不如反过来：",[19,5776,5777],{},"大批量导入前删掉非约束索引，导完 ANALYZE 再重建","，快一个数量级很正常。",[15,5780,5781,5784,5785,5788,5789,5792,5793,5796],{},[19,5782,5783],{},"6. 表和索引都会膨胀，要会看数字。"," PG 里有 ",[169,5786,5787],{},"pgstattuple"," 扩展可以看表和索引的死元组比例（膨胀率），阈值到 20%~30% 就值得处理：常规 ",[169,5790,5791],{},"VACUUM"," 只回收不还磁盘，",[169,5794,5795],{},"VACUUM FULL"," 还磁盘但拿全表排他锁，线上用 pg_repack 平替。这一账记进第十六章核对清单。",[15,5798,5799,5805,5806,5808],{},[19,5800,755,5801,5804],{},[169,5802,5803],{},"Index Only Scan"," 吃不吃得到，取决于 vacuum。"," 覆盖索引配合「visibility map 足够干净」才会出现 ",[169,5807,5803],{},"，连堆表都不碰；长期不 vacuum 的表会退化成带 heap fetch 的普通扫描（第九章 MVCC 讲为什么）。",[201,5810,5812],{"id":5811},"附explain-节点与索引类型速查","附：EXPLAIN 节点与索引类型速查",[31,5814,5815,5828],{},[34,5816,5817],{},[37,5818,5819,5822,5825],{},[40,5820,5821],{},"节点",[40,5823,5824],{},"意思",[40,5826,5827],{},"什么时候出现",[53,5829,5830,5842,5855],{},[37,5831,5832,5836,5839],{},[58,5833,5834],{},[169,5835,5534],{},[58,5837,5838],{},"全表顺序扫",[58,5840,5841],{},"无索引可用，或优化器算出全扫更便宜（命中行太多）",[37,5843,5844,5849,5852],{},[58,5845,5846],{},[169,5847,5848],{},"Index Scan",[58,5850,5851],{},"沿索引走，边走边回表",[58,5853,5854],{},"命中行少",[37,5856,5857,5865,5868],{},[58,5858,5859,1375,5862],{},[169,5860,5861],{},"Bitmap Heap Scan",[169,5863,5864],{},"Bitmap Index Scan",[58,5866,5867],{},"先在索引里收集一批行的位图，再成批回表",[58,5869,5870],{},"命中行中等量级（几百到几万）",[31,5872,5873,5886],{},[34,5874,5875],{},[37,5876,5877,5880,5883],{},[40,5878,5879],{},"类型",[40,5881,5882],{},"擅长",[40,5884,5885],{},"典型场景",[53,5887,5888,5916,5931,5951,5964,5977],{},[37,5889,5890,5896,5913],{},[58,5891,5892,5895],{},[169,5893,5894],{},"btree","（默认）",[58,5897,5898,625,5900,625,5903,625,5906,5909,5910],{},[169,5899,2326],{},[169,5901,5902],{},"\u003C",[169,5904,5905],{},">",[169,5907,5908],{},"BETWEEN","、前缀 ",[169,5911,5912],{},"LIKE 'abc%'",[58,5914,5915],{},"99% 的索引",[37,5917,5918,5923,5928],{},[58,5919,5920],{},[169,5921,5922],{},"hash",[58,5924,5925,5926],{},"只有 ",[169,5927,2326],{},[58,5929,5930],{},"PG 10 起才可靠，等值超长键可以考虑",[37,5932,5933,5938,5941],{},[58,5934,5935],{},[169,5936,5937],{},"gin",[58,5939,5940],{},"倒排索引，「包含」类查询",[58,5942,5943,5944,5946,5947,5950],{},"jsonb ",[169,5945,3303],{},"、数组 ",[169,5948,5949],{},"&&","、全文搜索（第十三章）",[37,5952,5953,5958,5961],{},[58,5954,5955],{},[169,5956,5957],{},"gist",[58,5959,5960],{},"通用搜索树，「相交\u002F相邻」类",[58,5962,5963],{},"地理（第十四章）、范围类型、排他约束（防止区间重叠）",[37,5965,5966,5971,5974],{},[58,5967,5968],{},[169,5969,5970],{},"spgist",[58,5972,5973],{},"空间分区树",[58,5975,5976],{},"特殊数据分布，如 IP 前缀",[37,5978,5979,5984,5987],{},[58,5980,5981],{},[169,5982,5983],{},"brin",[58,5985,5986],{},"只存每个块区间的 min\u002Fmax",[58,5988,5989],{},"超大且按写入顺序查询的表（日志、流水），体积极小",[10,5991,5993],{"id":5992},"九事务与锁mvcc隔离级别与排队事故","九、事务与锁：MVCC、隔离级别与排队事故",[15,5995,5996,5997,6000],{},"PG 的并发控制全建立在 MVCC（多版本并发控制）上：",[19,5998,5999],{},"写不阻塞读，读不阻塞写","。第一遍只需要掌握第一步那三种写法（不丢更新的套路），隔离级别的差异、锁排队和死锁都收在章末。",[201,6002,6004],{"id":6003},"第一步事务的三种安全写法","第一步：事务的三种安全写法",[15,6006,6007],{},"「SELECT 检查库存 → 应用判断 → UPDATE 扣减」这套流程在并发下必然丢更新：两个事务同时读到库存 1、都判断通过、都写成功，库存变 -1。三种修法按保守到乐观排：",[298,6009,6011],{"className":1039,"code":6010,"language":1041,"meta":303,"style":303},"-- ① 悲观：先把行锁住再查（行内排队）\nBEGIN;\nSELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\nUPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\nCOMMIT;\n\n-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\nUPDATE products SET stock = stock - 1\nWHERE id = 1 AND stock >= 1;\n-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n\n-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\nINSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\nON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1;\n",[169,6012,6013,6018,6023,6028,6033,6038,6042,6047,6052,6057,6062,6066,6071,6076],{"__ignoreMap":303},[307,6014,6015],{"class":309,"line":310},[307,6016,6017],{},"-- ① 悲观：先把行锁住再查（行内排队）\n",[307,6019,6020],{"class":309,"line":334},[307,6021,6022],{},"BEGIN;\n",[307,6024,6025],{"class":309,"line":345},[307,6026,6027],{},"SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\n",[307,6029,6030],{"class":309,"line":355},[307,6031,6032],{},"UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\n",[307,6034,6035],{"class":309,"line":366},[307,6036,6037],{},"COMMIT;\n",[307,6039,6040],{"class":309,"line":377},[307,6041,1756],{"emptyLinePlaceholder":1755},[307,6043,6044],{"class":309,"line":1471},[307,6045,6046],{},"-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\n",[307,6048,6049],{"class":309,"line":1735},[307,6050,6051],{},"UPDATE products SET stock = stock - 1\n",[307,6053,6054],{"class":309,"line":1741},[307,6055,6056],{},"WHERE id = 1 AND stock >= 1;\n",[307,6058,6059],{"class":309,"line":1747},[307,6060,6061],{},"-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n",[307,6063,6064],{"class":309,"line":1752},[307,6065,1756],{"emptyLinePlaceholder":1755},[307,6067,6068],{"class":309,"line":1759},[307,6069,6070],{},"-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\n",[307,6072,6073],{"class":309,"line":1765},[307,6074,6075],{},"INSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\n",[307,6077,6078],{"class":309,"line":1771},[307,6079,6080],{},"ON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1;\n",[15,6082,6083],{},"日常优先 ②——它不持锁等待、天然并发安全，业务分支也清晰；热点行抢购场景才需要 ① 或引入队列削峰。",[201,6085,6087],{"id":6086},"第二步知道读不阻塞写就够了","第二步：知道「读不阻塞写」就够了",[15,6089,6090,6091,6094,6095,6098,6099,1930],{},"每行都有几个系统隐藏列：",[169,6092,6093],{},"xmin","（创建它的事务号）、",[169,6096,6097],{},"xmax","（删除\u002F失效它的事务号）。UPDATE 也不存在原地修改——",[19,6100,6101],{},"它是「插一行新版本 + 给旧版本打墓碑」",[298,6103,6105],{"className":1039,"code":6104,"language":1041,"meta":303,"style":303},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\nUPDATE orders SET status = 'paid' WHERE id = 42;\nSELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid\n",[169,6106,6107,6112,6117],{"__ignoreMap":303},[307,6108,6109],{"class":309,"line":310},[307,6110,6111],{},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\n",[307,6113,6114],{"class":309,"line":334},[307,6115,6116],{},"UPDATE orders SET status = 'paid' WHERE id = 42;\n",[307,6118,6119],{"class":309,"line":345},[307,6120,6121],{},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid\n",[15,6123,6124,6125,6128],{},"第二次查询看到的 xmin 已经是新事务号——那是一个新元组。旧版本（xmin=258312）被标上 ",[169,6126,6127],{},"xmax=258421","，成为死元组；但只要还有别的事务的快照能看到它，它就必须留着。这一条推出本章所有的怪现象，也推出第九章坑 1 那条雪崩。",[201,6130,6132],{"id":6131},"第三步多-worker-抢任务用-skip-locked","第三步：多 worker 抢任务，用 SKIP LOCKED",[15,6134,6135,6138,6139,6142,6143,6146,6147,6150],{},[169,6136,6137],{},"SELECT ... FOR UPDATE"," 拿行级排他锁，两个修饰词改变冲突时的行为：",[169,6140,6141],{},"NOWAIT"," 拿不到立即报错；",[169,6144,6145],{},"SKIP LOCKED"," 拿不到就",[19,6148,6149],{},"跳过去看下一行","。后者是「多 worker 抢任务」的标准解：",[298,6152,6154],{"className":1039,"code":6153,"language":1041,"meta":303,"style":303},"UPDATE jobs SET status = 'running', worker = 'w1'\nWHERE id = (\n  SELECT id FROM jobs\n  WHERE status = 'pending'\n  ORDER BY id\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nRETURNING id, payload;\n",[169,6155,6156,6161,6166,6171,6176,6181,6186,6191,6196],{"__ignoreMap":303},[307,6157,6158],{"class":309,"line":310},[307,6159,6160],{},"UPDATE jobs SET status = 'running', worker = 'w1'\n",[307,6162,6163],{"class":309,"line":334},[307,6164,6165],{},"WHERE id = (\n",[307,6167,6168],{"class":309,"line":345},[307,6169,6170],{},"  SELECT id FROM jobs\n",[307,6172,6173],{"class":309,"line":355},[307,6174,6175],{},"  WHERE status = 'pending'\n",[307,6177,6178],{"class":309,"line":366},[307,6179,6180],{},"  ORDER BY id\n",[307,6182,6183],{"class":309,"line":377},[307,6184,6185],{},"  LIMIT 1\n",[307,6187,6188],{"class":309,"line":1471},[307,6189,6190],{},"  FOR UPDATE SKIP LOCKED\n",[307,6192,6193],{"class":309,"line":1735},[307,6194,6195],{},")\n",[307,6197,6198],{"class":309,"line":1741},[307,6199,6200],{},"RETURNING id, payload;\n",[15,6202,6203,6204,6207,6208,6210],{},"两个 worker 同时执行：w1 锁住 101 号，w2 在子查询里发现 101 被锁、自动跳到 102——",[19,6205,6206],{},"互不等待、永不撞车","。拿消息队列（Kafka\u002FRedis）之前，很多轻量任务场景到 ",[169,6209,6145],{}," 就够用了，又少养一个组件。",[631,6212],{"height":5662,"src":6213,"title":6214},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F09-mvcc-locks.html","示例 09：MVCC 与锁——xmin\u002Fxmax、复读现象、SKIP LOCKED、死锁与 lock_timeout",[201,6216,666],{"id":6217},"️-本章的坑-7",[15,6219,6220,6223],{},[19,6221,6222],{},"1. 一个「idle in transaction」能让全库膨胀。"," 承第二步的推论：只要还有事务的快照能看到旧版本，死元组就不能回收。所以一个挂着不提交的长事务，会让 vacuum 无法回收全库的死元组，表和索引一路膨胀，全库变慢——这是 PG 独有的雪崩形态，第十六章清单里「监控 idle in transaction」专门冲它去的。",[15,6225,6226],{},[19,6227,6228],{},"2. 默认隔离级别 READ COMMITTED 下，事务内两次 SELECT 结果可以不同。",[298,6230,6233],{"className":6231,"code":6232,"language":422,"meta":303},[420],"-- 会话 A                                    -- 会话 B\nBEGIN;\nSELECT count(*) FROM orders;  -- 1000\n                                             BEGIN;\n                                             UPDATE orders SET status='paid' WHERE id\u003C5;\n                                             COMMIT;   -- B 已提交\nSELECT count(*) FROM orders;  -- 还是 1000？不，这次是 1005\nCOMMIT;\n",[169,6234,6232],{"__ignoreMap":303},[15,6236,6237,6238,6241],{},"A 的两条 SELECT 之间没有任何写入，但第二次看到了 B 已提交的数据——因为 RC 下",[19,6239,6240],{},"每条语句","都换新快照。从 MySQL（默认 RR）迁过来、依赖「事务内复读」的代码要改写。",[15,6243,6244,6247,6248,6251,6252,6255,6256,6259],{},[19,6245,6246],{},"3. 升到 RR \u002F SERIALIZABLE，就要写重试循环。"," InnoDB 的 RR 靠间隙锁挡幻读，PG 的 RR 靠快照——",[19,6249,6250],{},"读不加锁，写冲突靠报错重试","：并发改同一行时冲突方会报 ",[169,6253,6254],{},"could not serialize access due to concurrent update","，SERIALIZABLE 还会预测危险结构并报 serial failure。应用必须捕获 ",[169,6257,6258],{},"40001"," SQLSTATE 重来一次，否则就是偶发 500。",[15,6261,6262,6268,6269,6274],{},[19,6263,4402,6264,6267],{},[169,6265,6266],{},"ALTER TABLE"," 能把全站堵死。"," 行锁之上还有表级锁，八级里只需要记住两端：普通 SELECT\u002FUPDATE 拿的都是弱锁，互不冲突；而 ",[19,6270,6271,6273],{},[169,6272,6266],{}," 要拿 ACCESS EXCLUSIVE——与一切读写互斥","。事故链通常是这么发生的：",[298,6276,6279],{"className":6277,"code":6278,"language":422,"meta":303},[420],"14:00:03  会话 A：一个长查询\u002F未提交事务还挂着（拿住弱锁）\n14:00:05  DBA：ALTER TABLE orders ADD COLUMN memo text;  → 排队等 A\n14:00:06  之后的所有 SELECT ... FROM orders → 全部排在 DDL 后面（FIFO 队列）\n14:00:30  全站接口超时，报警炸了\n",[169,6280,6278],{"__ignoreMap":303},[15,6282,6283,6286,6287,6290,6291,6294],{},[19,6284,6285],{},"锁队列是先进先出的","，DDL 一旦排进去，后面所有请求不管读写都得等它。防御两件套：会话级先 ",[169,6288,6289],{},"SET lock_timeout = '3s';"," 再执行 DDL，拿不到锁立刻退出（而不是堵门）；大表建索引一律 ",[169,6292,6293],{},"CREATE INDEX CONCURRENTLY","——不挡写入（代价：慢一些，失败会留下 INVALID 索引需要 drop 重来）。",[15,6296,6297,6300],{},[19,6298,6299],{},"5. 死锁不靠调参解决，靠统一加锁顺序。"," A 锁 42 再要 43，B 锁 43 再要 42，互相等到天荒地老——PG 的死锁检测器（默认 deadlock_timeout 1 秒）会杀掉其一：",[298,6302,6305],{"className":6303,"code":6304,"language":422,"meta":303},[420],"ERROR:  deadlock detected\nDETAIL:  Process 2847 waits for ShareLock on transaction 258330;\n         blocked by transaction 258325.\n         Process 2831 waits for ShareLock on transaction 258325;\n         blocked by transaction 258330.\nHINT:  ... server log 有两个进程正在执行的 SQL\n",[169,6306,6304],{"__ignoreMap":303},[15,6308,6309,6310,6313],{},"解法不是调参，是",[19,6311,6312],{},"改业务加锁顺序","：所有代码路径都按固定顺序（比如按主键升序）加锁，环路就消失了。",[15,6315,6316,6319],{},[19,6317,6318],{},"6. 外键列没索引，删除会放大成锁等待。"," 第七章那条「PG 不给外键自动建索引」的坑，在事务里更致命：删父表一行要顺序扫子表检查引用，扫描期间两边都持锁，直接变成排队事故。",[201,6321,6323],{"id":6322},"附隔离级别速查","附：隔离级别速查",[31,6325,6326,6345],{},[34,6327,6328],{},[37,6329,6330,6333,6336,6339,6342],{},[40,6331,6332],{},"级别",[40,6334,6335],{},"脏读",[40,6337,6338],{},"不可重复读",[40,6340,6341],{},"幻读",[40,6343,6344],{},"PG 的实现与脾气",[53,6346,6347,6363,6380,6399],{},[37,6348,6349,6352,6355,6358,6360],{},[58,6350,6351],{},"READ UNCOMMITTED",[58,6353,6354],{},"不存在（PG 直接当 RC 处理）",[58,6356,6357],{},"有",[58,6359,6357],{},[58,6361,6362],{},"PG 不允许脏读，设了也白设",[37,6364,6365,6368,6371,6375,6377],{},[58,6366,6367],{},"READ COMMITTED（默认）",[58,6369,6370],{},"无",[58,6372,6373],{},[19,6374,6357],{},[58,6376,6357],{},[58,6378,6379],{},"每条语句拿一个新快照，事务里两次 SELECT 结果可以不同",[37,6381,6382,6384,6386,6388,6393],{},[58,6383,105],{},[58,6385,6370],{},[58,6387,6370],{},[58,6389,6390,6392],{},[19,6391,6370],{},"（快照隔离）",[58,6394,6395,6396,6398],{},"事务开始定快照；并发改同一行时冲突方报 ",[169,6397,6254],{},"，需重试",[37,6400,6401,6404,6406,6408,6410],{},[58,6402,6403],{},"SERIALIZABLE",[58,6405,6370],{},[58,6407,6370],{},[58,6409,6370],{},[58,6411,6412],{},"SSI 算法预测危险结构，冲突报 serial failure，应用必须带重试",[10,6414,6416],{"id":6415},"十视图与-cte把长查询变成复用单元","十、视图与 CTE：把长查询变成复用单元",[201,6418,6420],{"id":6419},"第一步视图给长查询起个名字","第一步：视图——给长查询起个名字",[298,6422,6424],{"className":1039,"code":6423,"language":1041,"meta":303,"style":303},"CREATE VIEW v_paid_orders AS\nSELECT id, user_id, amount, paid_at\nFROM orders\nWHERE status = 'paid';\n",[169,6425,6426,6431,6436,6440],{"__ignoreMap":303},[307,6427,6428],{"class":309,"line":310},[307,6429,6430],{},"CREATE VIEW v_paid_orders AS\n",[307,6432,6433],{"class":309,"line":334},[307,6434,6435],{},"SELECT id, user_id, amount, paid_at\n",[307,6437,6438],{"class":309,"line":345},[307,6439,3381],{},[307,6441,6442],{"class":309,"line":355},[307,6443,6444],{},"WHERE status = 'paid';\n",[15,6446,6447,6448,6451],{},"视图的两个常规用途：给前端\u002FBI 一个稳定的「表样子」，和给低权限账号一个只露部分列的口子（配合 GRANT，第十一章）。PG 的特色是",[19,6449,6450],{},"简单视图自动可更新","——只要视图是单表直出（没有 GROUP BY\u002F聚合\u002FDISTINCT\u002F窗口\u002F集合操作），对它的 INSERT\u002FUPDATE\u002FDELETE 会自动改到原表上：",[298,6453,6455],{"className":1039,"code":6454,"language":1041,"meta":303,"style":303},"UPDATE v_paid_orders SET amount = 99.00 WHERE id = 42;   -- 直接生效\n",[169,6456,6457],{"__ignoreMap":303},[307,6458,6459],{"class":309,"line":310},[307,6460,6454],{},[15,6462,6463,6464,6467],{},"再加 ",[169,6465,6466],{},"WITH CHECK OPTION","，连「改完就掉出视图可见范围」的行都会被拒绝，防止通过视图越权改数据。",[201,6469,6471],{"id":6470},"第二步物化视图昂贵报表落盘","第二步：物化视图——昂贵报表落盘",[15,6473,6474,6475,1930],{},"普通视图每次查询都现算；物化视图把结果",[19,6476,6477],{},"落盘",[298,6479,6481],{"className":1039,"code":6480,"language":1041,"meta":303,"style":303},"CREATE MATERIALIZED VIEW mv_daily_sales AS\nSELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\nFROM orders WHERE status = 'paid' GROUP BY 1;\n\nREFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\nCREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\nREFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读\n",[169,6482,6483,6488,6493,6498,6502,6507,6512],{"__ignoreMap":303},[307,6484,6485],{"class":309,"line":310},[307,6486,6487],{},"CREATE MATERIALIZED VIEW mv_daily_sales AS\n",[307,6489,6490],{"class":309,"line":334},[307,6491,6492],{},"SELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\n",[307,6494,6495],{"class":309,"line":345},[307,6496,6497],{},"FROM orders WHERE status = 'paid' GROUP BY 1;\n",[307,6499,6500],{"class":309,"line":355},[307,6501,1756],{"emptyLinePlaceholder":1755},[307,6503,6504],{"class":309,"line":366},[307,6505,6506],{},"REFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\n",[307,6508,6509],{"class":309,"line":377},[307,6510,6511],{},"CREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\n",[307,6513,6514],{"class":309,"line":1471},[307,6515,6516],{},"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读\n",[15,6518,6519,6520,6523],{},"定个任务凌晨全量刷，白天报表直接查物化视图，一笔流水一笔账。",[169,6521,6522],{},"CONCURRENTLY"," 的两个前提见坑 2。",[201,6525,6527],{"id":6526},"第三步cte拆步骤与遍历树","第三步：CTE——拆步骤与遍历树",[15,6529,6530],{},"「起名字、拆步骤」，多个 CTE 依次引用：",[298,6532,6534],{"className":1039,"code":6533,"language":1041,"meta":303,"style":303},"WITH recent AS (\n  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n),\nbig AS (\n  SELECT * FROM recent WHERE amount > 1000\n)\nSELECT count(*) FROM big;\n",[169,6535,6536,6541,6546,6551,6556,6561,6565],{"__ignoreMap":303},[307,6537,6538],{"class":309,"line":310},[307,6539,6540],{},"WITH recent AS (\n",[307,6542,6543],{"class":309,"line":334},[307,6544,6545],{},"  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n",[307,6547,6548],{"class":309,"line":345},[307,6549,6550],{},"),\n",[307,6552,6553],{"class":309,"line":355},[307,6554,6555],{},"big AS (\n",[307,6557,6558],{"class":309,"line":366},[307,6559,6560],{},"  SELECT * FROM recent WHERE amount > 1000\n",[307,6562,6563],{"class":309,"line":377},[307,6564,6195],{},[307,6566,6567],{"class":309,"line":1471},[307,6568,6569],{},"SELECT count(*) FROM big;\n",[15,6571,6572],{},"树形结构（部门、评论楼层、物料清单）的遍历神器是递归 CTE：",[298,6574,6576],{"className":1039,"code":6575,"language":1041,"meta":303,"style":303},"WITH RECURSIVE sub AS (\n  -- 种子：起点\n  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n  UNION ALL\n  -- 递归：从上一轮结果往下走一层\n  SELECT d.id, d.name, d.parent_id, s.depth + 1\n  FROM depts d JOIN sub s ON d.parent_id = s.id\n)\nSELECT * FROM sub ORDER BY depth;\n",[169,6577,6578,6583,6588,6593,6598,6603,6608,6613,6617],{"__ignoreMap":303},[307,6579,6580],{"class":309,"line":310},[307,6581,6582],{},"WITH RECURSIVE sub AS (\n",[307,6584,6585],{"class":309,"line":334},[307,6586,6587],{},"  -- 种子：起点\n",[307,6589,6590],{"class":309,"line":345},[307,6591,6592],{},"  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n",[307,6594,6595],{"class":309,"line":355},[307,6596,6597],{},"  UNION ALL\n",[307,6599,6600],{"class":309,"line":366},[307,6601,6602],{},"  -- 递归：从上一轮结果往下走一层\n",[307,6604,6605],{"class":309,"line":377},[307,6606,6607],{},"  SELECT d.id, d.name, d.parent_id, s.depth + 1\n",[307,6609,6610],{"class":309,"line":1471},[307,6611,6612],{},"  FROM depts d JOIN sub s ON d.parent_id = s.id\n",[307,6614,6615],{"class":309,"line":1735},[307,6616,6195],{},[307,6618,6619],{"class":309,"line":1741},[307,6620,6621],{},"SELECT * FROM sub ORDER BY depth;\n",[298,6623,6626],{"className":6624,"code":6625,"language":422,"meta":303},[420]," id |  name   | parent_id | depth\n----+---------+-----------+-------\n  2 | 技术部  |         1 |     1\n  5 | 后端组  |         2 |     2\n  6 | 前端组  |         2 |     2\n  9 | 平台小队 |         5 |     3\n(4 rows)\n",[169,6627,6625],{"__ignoreMap":303},[15,6629,6630,6631,6634],{},"还有一个冷门但极顺手的用法：",[19,6632,6633],{},"数据修改 CTE","，一条语句完成「删 A 表搬进 B 表」，还天然原子：",[298,6636,6638],{"className":1039,"code":6637,"language":1041,"meta":303,"style":303},"WITH moved AS (\n  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n  RETURNING *\n)\nINSERT INTO jobs_archive SELECT * FROM moved;\n",[169,6639,6640,6645,6650,6655,6659],{"__ignoreMap":303},[307,6641,6642],{"class":309,"line":310},[307,6643,6644],{},"WITH moved AS (\n",[307,6646,6647],{"class":309,"line":334},[307,6648,6649],{},"  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n",[307,6651,6652],{"class":309,"line":345},[307,6653,6654],{},"  RETURNING *\n",[307,6656,6657],{"class":309,"line":355},[307,6658,6195],{},[307,6660,6661],{"class":309,"line":366},[307,6662,6663],{},"INSERT INTO jobs_archive SELECT * FROM moved;\n",[201,6665,666],{"id":6666},"️-本章的坑-8",[15,6668,6669,6672,6673,6676],{},[19,6670,6671],{},"1. 视图不物化，就是每次都现算。"," 普通视图只是一段被起名的 SQL，",[169,6674,6675],{},"SELECT * FROM v_paid_orders"," 展开后该扫多少行还是多少行；报表慢的时候别怪视图，要么加索引，要么按第二步物化。",[15,6678,6679,6685,6686,6688,6689,6692,6693,6696,6697,6699],{},[19,6680,2200,6681,6684],{},[169,6682,6683],{},"REFRESH MATERIALIZED VIEW"," 全量刷期间查询被阻塞","，想不阻塞读必须用 ",[169,6687,6522],{},"，而它的前提是物化视图上有一个",[19,6690,6691],{},"唯一索引","（上面示例里那行 ",[169,6694,6695],{},"CREATE UNIQUE INDEX"," 就是前置条件）。另外 ",[169,6698,6522],{}," 只更新变化的行，代价是更依赖 diff 键，唯一索引选错列会刷得很慢。",[15,6701,6702,6705,6706,6709],{},[19,6703,6704],{},"3. 「CTE 会慢」是老版本的结论。"," PG 12 之前 CTE 是优化围栏（外面怎么查都不下推进去），PG 12 起默认内联优化，想强制物化要写 ",[169,6707,6708],{},"AS MATERIALIZED","。所以看资料要看版本：新集群里把子查询改写成 CTE 通常只是可读性收益，不背性能锅。",[15,6711,6712,675,6715,6718,6719,6722,6723,6726,6727,6730,6731,6734],{},[19,6713,6714],{},"4. 递归 CTE 忘写终止条件会无限循环。",[169,6716,6717],{},"UNION"," 会对结果去重、还能兜住意外死循环；",[169,6720,6721],{},"UNION ALL"," 不去重更快，但",[19,6724,6725],{},"忘写递归终止条件（JOIN 不再产新行）就会无限循环","——调试期先加 ",[169,6728,6729],{},"LIMIT 100"," 再跑，是保命习惯。数据里存在环（A 的父是 B、B 的父是 A）时同样会转不出来，PG 14 起可用 ",[169,6732,6733],{},"CYCLE ... SET"," 子句标记环路行。",[15,6736,6737,6740,6741,6744],{},[19,6738,6739],{},"5. 数据修改 CTE 的「原子」是语句级的。"," 上面那段「删 + 搬」在同一条语句里，但 CTE 内的可见性遵循所在事务的隔离级别：READ COMMITTED 下，",[169,6742,6743],{},"moved"," 里看不到并发刚提交的其它行变更，别把它当成「读己之写」的万能方案。",[10,6746,6748],{"id":6747},"十一用户和角色权限落到位","十一、用户和角色：权限落到位",[15,6750,6751],{},"本章第一步讲权限的标准落法——授给组、人挂组；第二步把它用在应用账号上；第三步是多租户的 RLS。认证方式表和权限相关的坑放在章末。",[201,6753,6755],{"id":6754},"第一步权限给组人挂组","第一步：权限给组，人挂组",[15,6757,6758,6759,6762,6763,6766,6767,6770,6771,1930],{},"PG 里",[19,6760,6761],{},"只有一种对象叫 role","：「用户」就是带 ",[169,6764,6765],{},"LOGIN"," 属性能连库的 role，「用户组」就是不带 LOGIN 的 role。官方文档的说法是「一个 role 可以当用户、也可以当用户组，取决于你怎么配它」，而",[19,6768,6769],{},"主流做法就是把权限授给组，再把需要的人加进组","——而不是逐个用户去 ",[169,6772,6773],{},"GRANT",[298,6775,6777],{"className":1039,"code":6776,"language":1041,"meta":303,"style":303},"-- 1) 建一个「只读组」：它自己连不了库，只是权限容器\nCREATE ROLE read_only NOLOGIN;\nGRANT USAGE ON SCHEMA business TO read_only;\nGRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\nALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n\n-- 2) 建用户并挂进组\nCREATE ROLE zhangsan LOGIN PASSWORD '...';\nGRANT read_only TO zhangsan;\n\n-- 3) 以后新同事也只看数据？一行就够\nCREATE ROLE lisi LOGIN PASSWORD '...';\nGRANT read_only TO lisi;\n",[169,6778,6779,6784,6789,6794,6799,6804,6808,6813,6818,6823,6827,6832,6837],{"__ignoreMap":303},[307,6780,6781],{"class":309,"line":310},[307,6782,6783],{},"-- 1) 建一个「只读组」：它自己连不了库，只是权限容器\n",[307,6785,6786],{"class":309,"line":334},[307,6787,6788],{},"CREATE ROLE read_only NOLOGIN;\n",[307,6790,6791],{"class":309,"line":345},[307,6792,6793],{},"GRANT USAGE ON SCHEMA business TO read_only;\n",[307,6795,6796],{"class":309,"line":355},[307,6797,6798],{},"GRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\n",[307,6800,6801],{"class":309,"line":366},[307,6802,6803],{},"ALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n",[307,6805,6806],{"class":309,"line":377},[307,6807,1756],{"emptyLinePlaceholder":1755},[307,6809,6810],{"class":309,"line":1471},[307,6811,6812],{},"-- 2) 建用户并挂进组\n",[307,6814,6815],{"class":309,"line":1735},[307,6816,6817],{},"CREATE ROLE zhangsan LOGIN PASSWORD '...';\n",[307,6819,6820],{"class":309,"line":1741},[307,6821,6822],{},"GRANT read_only TO zhangsan;\n",[307,6824,6825],{"class":309,"line":1747},[307,6826,1756],{"emptyLinePlaceholder":1755},[307,6828,6829],{"class":309,"line":1752},[307,6830,6831],{},"-- 3) 以后新同事也只看数据？一行就够\n",[307,6833,6834],{"class":309,"line":1759},[307,6835,6836],{},"CREATE ROLE lisi LOGIN PASSWORD '...';\n",[307,6838,6839],{"class":309,"line":1765},[307,6840,6841],{},"GRANT read_only TO lisi;\n",[15,6843,6844,6845,6848,6849,3703,6851,6854,6855,6858,6859,6862],{},"这样管的好处很实在：权限的定义只有一份（在组上），进人出人只是成员关系的变化；哪天要收权，",[169,6846,6847],{},"REVOKE ... FROM read_only"," 一次生效，不用去回忆「当初到底给哪几个人单独授过」；审计时 ",[169,6850,867],{},[169,6852,6853],{},"information_schema.role_table_grants"," 一眼能看清「谁能看什么」。",[169,6856,6857],{},"CREATE USER"," 只是 ",[169,6860,6861],{},"CREATE ROLE ... LOGIN"," 的别名，见到别慌。",[15,6864,6865,6868,6869,6872,6873,6876,6877,6880,6881,6884,6885,6888,6889,6892,6893,6896],{},[19,6866,6867],{},"成员默认会自动继承组的权限","（role 属性 ",[169,6870,6871],{},"INHERIT"," 是默认值），所以 ",[169,6874,6875],{},"zhangsan"," 挂进 ",[169,6878,6879],{},"read_only"," 之后直接 ",[169,6882,6883],{},"SELECT"," 就能用，不需要 ",[169,6886,6887],{},"SET ROLE","。反过来说，如果建用户时写了 ",[169,6890,6891],{},"NOINHERIT","，成员关系还在、权限却拿不到，得显式 ",[169,6894,6895],{},"SET ROLE read_only;"," 才生效——见本章坑 3。",[201,6898,6900],{"id":6899},"第二步应用账号的最小权限模板","第二步：应用账号的最小权限模板",[15,6902,6903],{},"应用账号不该是 owner、更不该是超级用户，照抄这套（它正是第一步那套思路用在服务账号上：应用账号只拿增删改查，建表归迁移账号）：",[298,6905,6907],{"className":1039,"code":6906,"language":1041,"meta":303,"style":303},"-- 1. 收口：public schema 不再放业务表\nCREATE SCHEMA business AUTHORIZATION migrator;\nREVOKE CREATE ON SCHEMA public FROM PUBLIC;\n\n-- 2. 应用账号：只用不建\nCREATE ROLE app LOGIN PASSWORD '...';\nALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\nGRANT USAGE ON SCHEMA business TO app;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT USAGE ON SEQUENCES TO app;\n",[169,6908,6909,6914,6919,6924,6928,6933,6938,6943,6948,6953,6958,6963,6968,6972],{"__ignoreMap":303},[307,6910,6911],{"class":309,"line":310},[307,6912,6913],{},"-- 1. 收口：public schema 不再放业务表\n",[307,6915,6916],{"class":309,"line":334},[307,6917,6918],{},"CREATE SCHEMA business AUTHORIZATION migrator;\n",[307,6920,6921],{"class":309,"line":345},[307,6922,6923],{},"REVOKE CREATE ON SCHEMA public FROM PUBLIC;\n",[307,6925,6926],{"class":309,"line":355},[307,6927,1756],{"emptyLinePlaceholder":1755},[307,6929,6930],{"class":309,"line":366},[307,6931,6932],{},"-- 2. 应用账号：只用不建\n",[307,6934,6935],{"class":309,"line":377},[307,6936,6937],{},"CREATE ROLE app LOGIN PASSWORD '...';\n",[307,6939,6940],{"class":309,"line":1471},[307,6941,6942],{},"ALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\n",[307,6944,6945],{"class":309,"line":1735},[307,6946,6947],{},"GRANT USAGE ON SCHEMA business TO app;\n",[307,6949,6950],{"class":309,"line":1741},[307,6951,6952],{},"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\n",[307,6954,6955],{"class":309,"line":1747},[307,6956,6957],{},"GRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\n",[307,6959,6960],{"class":309,"line":1752},[307,6961,6962],{},"ALTER DEFAULT PRIVILEGES IN SCHEMA business\n",[307,6964,6965],{"class":309,"line":1759},[307,6966,6967],{},"  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\n",[307,6969,6970],{"class":309,"line":1765},[307,6971,6962],{},[307,6973,6974],{"class":309,"line":1771},[307,6975,6976],{},"  GRANT USAGE ON SEQUENCES TO app;\n",[15,6978,6979,6982,6983,6986],{},[169,6980,6981],{},"ALTER DEFAULT PRIVILEGES"," 解决的是「今天授了 ALL TABLES，明天新建的表又没权限」的补漏问题——它登记的是「",[19,6984,6985],{},"以后","谁建的表自动授给谁」。",[15,6988,6989,6990,6993,6994,6996,6997,6999,7000,7002],{},"服务账号要不要也套一层组？两种都算主流：讲究一点的做法是再建一个 ",[169,6991,6992],{},"rw_app"," 组拿权限、",[169,6995,3278],{}," 只做 ",[169,6998,6765],{}," 并挂进组，好处是将来换连接账号（改密码、换应用）不用重授一遍权；小团队直接授给 ",[169,7001,3278],{}," 也很常见。选定一种写进规范，别一半走组、一半直接授。",[201,7004,7006],{"id":7005},"第三步多租户再加一道-rls","第三步：多租户再加一道 RLS",[15,7008,7009],{},"「每个租户只能看见自己的行」如果只靠应用层 WHERE，一个忘写就跨租户泄漏。PG 的 RLS 把这条规则焊在表上：",[298,7011,7013],{"className":1039,"code":7012,"language":1041,"meta":303,"style":303},"ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation ON orders\n  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);\n",[169,7014,7015,7020,7024,7029,7034],{"__ignoreMap":303},[307,7016,7017],{"class":309,"line":310},[307,7018,7019],{},"ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n",[307,7021,7022],{"class":309,"line":334},[307,7023,1756],{"emptyLinePlaceholder":1755},[307,7025,7026],{"class":309,"line":345},[307,7027,7028],{},"CREATE POLICY tenant_isolation ON orders\n",[307,7030,7031],{"class":309,"line":355},[307,7032,7033],{},"  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n",[307,7035,7036],{"class":309,"line":366},[307,7037,7038],{},"  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);\n",[15,7040,7041,7042,7045],{},"应用连接后执行 ",[169,7043,7044],{},"SET app.tenant_id = '42';","，之后这张表上所有查询自动带上租户过滤（USING），所有写入自动校验租户归属（WITH CHECK）。",[201,7047,666],{"id":7048},"️-本章的坑-9",[15,7050,7051,7054,7055,7058,7059,22],{},[19,7052,7053],{},"1. 表 owner 和超级用户默认绕过 RLS。"," 开了 ",[169,7056,7057],{},"ENABLE ROW LEVEL SECURITY"," 却用 owner 账号连，策略一条都不生效——测试时「明明配了策略怎么还能看见别人的数据」多半是这个。想连自己也拦，要 ",[169,7060,7061],{},"ALTER TABLE orders FORCE ROW LEVEL SECURITY",[15,7063,7064,675,7067,7070,7071,7074,7075,7078],{},[19,7065,7066],{},"2. 连接池 + RLS = 串数据风险。",[169,7068,7069],{},"SET app.tenant_id"," 的作用域是会话级，连接归还进池子时值还在。下个租户复用这条连接，就看到上一个租户的数据。必须归还前 ",[169,7072,7073],{},"RESET app.tenant_id","，或者用事务级的 ",[169,7076,7077],{},"SET LOCAL","（事务结束自动失效）。",[15,7080,7081,7084,7085,7088,7089,7091,7092,7094,7095,7097,7098,7100,7101,7104,7105,7107],{},[19,7082,7083],{},"3. 组权限「给了却没生效」，先看成员是不是 NOINHERIT。"," 权限授给了组、人也挂进了组，查询仍报 ",[169,7086,7087],{},"permission denied for table orders","——先用 ",[169,7090,867],{}," 看那个用户的属性列。成员角色默认带 ",[169,7093,6871],{},"，会自动继承组权限；但如果建号时写了 ",[169,7096,6891],{},"，成员关系只等于「有资格切换」，必须显式 ",[169,7099,6895],{}," 才拿得到权限。新版本还能在单次授予上单独控制：",[169,7102,7103],{},"GRANT read_only TO zhangsan WITH INHERIT FALSE;","。团队里规范只选一个：要么一路用默认的 INHERIT，要么写明「必须 SET ROLE」，两种混用最难排查。顺带一条：组角色是 NOLOGIN 的，拿 ",[169,7106,6879],{}," 直接连库必然失败，那不是权限没配好。",[15,7109,7110,7116,7117,7119,7120,7123,7124,7127],{},[19,7111,4402,7112,7115],{},[169,7113,7114],{},"GRANT ... ON ALL TABLES"," 只管当下。"," 它不会覆盖以后新建的表，忘了 ",[169,7118,6981],{}," 就会出现「昨天还好好的，今天上线新表应用就报 ",[169,7121,7122],{},"permission denied for table ...","」。另外它登记的是「",[19,7125,7126],{},"谁","建的表」，用 A 账号建的表、给 B 账号登记过默认权限，也不会自动生效。",[15,7129,7130,7133,7134,7137],{},[19,7131,7132],{},"5. 表 owner 天生全权，GRANT 收不走。"," owner 随时能 ",[169,7135,7136],{},"DROP"," 自己的表，任何授权都限制不了它。所以生产环境里「迁移专用账号」当 owner、应用账号只拿增删改查，两层分开。",[15,7139,7140,7146,7147,7149,7150,22],{},[19,7141,1368,7142,7145],{},[169,7143,7144],{},"trust"," 出现在公网段等于开源。"," 认证方式一行写错，等于没密码。",[169,7148,7144],{}," 只允许出现在本机测试；对外一律 ",[169,7151,7152],{},"scram-sha-256",[15,7154,7155,675,7161,7164],{},[19,7156,7157,7158,7160],{},"7. 改完 ",[169,7159,661],{}," 不重启也要 reload。",[169,7162,7163],{},"SELECT pg_reload_conf();"," 就够（连接数、内存类参数才要重启）。忘了 reload 会白排查半小时。",[15,7166,7167,7170,7171,7173,7174,3703,7177,7180],{},[19,7168,7169],{},"8. 老 md5 密码要重设一次才升级。"," PG 14 起默认加密方式是 SCRAM，但库里存量的 md5 密码不会自动变，得用 ",[169,7172,657],{}," 重设一次。两端不一致时报的是 ",[169,7175,7176],{},"password authentication failed",[169,7178,7179],{},"server does not support SCRAM","，看不出根因。",[15,7182,7183,7188,7189,7192,7193,7195],{},[19,7184,7185,7186,22],{},"9. 排查「怎么连都失败」先看 ",[169,7187,661],{}," 规则",[19,7190,7191],{},"从上往下第一条命中即生效","，尤其 ",[169,7194,758],{}," 走的本机 socket 默认是 trust，而 TCP 进来要密码，两条路行为不同属正常（第二章坑 7 的另一面）。",[201,7197,7199],{"id":7198},"附角色与权限排查速查","附：角色与权限排查速查",[15,7201,7202],{},"「到底谁有什么权限」这类问题，靠这几条命令一次看清：",[31,7204,7205,7215],{},[34,7206,7207],{},[37,7208,7209,7212],{},[40,7210,7211],{},"想知道什么",[40,7213,7214],{},"用什么",[53,7216,7217,7227,7237,7247,7260,7270,7280],{},[37,7218,7219,7222],{},[58,7220,7221],{},"有哪些角色、能不能登录、属于哪个组",[58,7223,7224,7226],{},[169,7225,867],{},"（看 Attributes 与 Member of 两列）",[37,7228,7229,7232],{},[58,7230,7231],{},"角色属性的机器可读版",[58,7233,7234],{},[169,7235,7236],{},"SELECT rolname, rolcanlogin, rolinherit, rolcreaterole FROM pg_roles;",[37,7238,7239,7242],{},[58,7240,7241],{},"成员关系明细（谁挂进了哪个组）",[58,7243,7244],{},[169,7245,7246],{},"SELECT r.rolname AS member, g.rolname AS grp FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member JOIN pg_roles g ON g.oid=m.roleid;",[37,7248,7249,7252],{},[58,7250,7251],{},"某张表授给了谁",[58,7253,7254,7256,7257],{},[169,7255,834],{}," 的 Access privileges，或 ",[169,7258,7259],{},"SELECT * FROM information_schema.role_table_grants WHERE table_name='orders';",[37,7261,7262,7265],{},[58,7263,7264],{},"某人是不是在某组里",[58,7266,7267],{},[169,7268,7269],{},"SELECT pg_has_role('zhangsan', 'read_only', 'member');",[37,7271,7272,7275],{},[58,7273,7274],{},"某人能不能查这张表",[58,7276,7277],{},[169,7278,7279],{},"SELECT has_table_privilege('zhangsan', 'business.orders', 'SELECT');",[37,7281,7282,7285],{},[58,7283,7284],{},"临时以组的身份执行",[58,7286,7287,3040,7290,7292],{},[169,7288,7289],{},"SET ROLE read_only; … RESET ROLE;",[169,7291,6891],{}," 成员必须走这一步）",[201,7294,7296],{"id":7295},"附pg_hba-认证方式速查","附：pg_hba 认证方式速查",[15,7298,7299,7300,7302,7303,1930],{},"连接认证由数据目录里的 ",[169,7301,661],{}," 决定，五行一列（类型 数据库 用户 地址 认证方式），",[19,7304,7191],{},[31,7306,7307,7319],{},[34,7308,7309],{},[37,7310,7311,7313,7316],{},[40,7312,471],{},[40,7314,7315],{},"行为",[40,7317,7318],{},"评价",[53,7320,7321,7333,7351,7364],{},[37,7322,7323,7327,7330],{},[58,7324,7325],{},[169,7326,7144],{},[58,7328,7329],{},"免密直连",[58,7331,7332],{},"只允许出现在本机测试，出现在公网段等于开源",[37,7334,7335,7339,7342],{},[58,7336,7337],{},[169,7338,7152],{},[58,7340,7341],{},"密码走 SCRAM 挑战",[58,7343,7344,7347,7348,7350],{},[19,7345,7346],{},"推荐","。PG 14 起默认加密方式即它；旧 md5 密码用 ",[169,7349,657],{}," 重设一次自动升级",[37,7352,7353,7358,7361],{},[58,7354,7355],{},[169,7356,7357],{},"peer",[58,7359,7360],{},"校验本机 OS 用户同名",[58,7362,7363],{},"本机管理入口的舒适区",[37,7365,7366,7371,7374],{},[58,7367,7368],{},[169,7369,7370],{},"reject",[58,7372,7373],{},"显式拒绝",[58,7375,7376],{},"垫在规则末尾封禁网段",[10,7378,7380],{"id":7379},"十二备份与恢复pg_dump物理备份与-pitr","十二、备份与恢复：pg_dump、物理备份与 PITR",[15,7382,7383,7384,7387],{},"先立总原则：",[19,7385,7386],{},"没恢复过的备份不算备份","。本章的每个命令，都值得在实验容器里跑一遍。第一遍只需要会第一步的逻辑备份与恢复。",[201,7389,7391],{"id":7390},"第一步一次完整的逻辑备份与恢复","第一步：一次完整的逻辑备份与恢复",[298,7393,7395],{"className":300,"code":7394,"language":302,"meta":303,"style":303},"pg_dump -U postgres -Fc -f shop-20261010.dump shop    # -Fc 自定义压缩格式\nls -lh shop-20261010.dump                              # 214M\npg_restore -l shop-20261010.dump | head -12            # 先看目录再动手\npg_restore -j 4 --clean --if-exists -d shop shop-20261010.dump\n",[169,7396,7397,7420,7433,7456],{"__ignoreMap":303},[307,7398,7399,7401,7403,7405,7408,7411,7414,7417],{"class":309,"line":310},[307,7400,1305],{"class":313},[307,7402,408],{"class":321},[307,7404,411],{"class":317},[307,7406,7407],{"class":321}," -Fc",[307,7409,7410],{"class":321}," -f",[307,7412,7413],{"class":317}," shop-20261010.dump",[307,7415,7416],{"class":317}," shop",[307,7418,7419],{"class":541},"    # -Fc 自定义压缩格式\n",[307,7421,7422,7425,7428,7430],{"class":309,"line":334},[307,7423,7424],{"class":313},"ls",[307,7426,7427],{"class":321}," -lh",[307,7429,7413],{"class":317},[307,7431,7432],{"class":541},"                              # 214M\n",[307,7434,7435,7438,7441,7443,7447,7450,7453],{"class":309,"line":345},[307,7436,7437],{"class":313},"pg_restore",[307,7439,7440],{"class":321}," -l",[307,7442,7413],{"class":317},[307,7444,7446],{"class":7445},"snl16"," |",[307,7448,7449],{"class":313}," head",[307,7451,7452],{"class":321}," -12",[307,7454,7455],{"class":541},"            # 先看目录再动手\n",[307,7457,7458,7460,7463,7466,7469,7472,7474,7476],{"class":309,"line":355},[307,7459,7437],{"class":313},[307,7461,7462],{"class":321}," -j",[307,7464,7465],{"class":321}," 4",[307,7467,7468],{"class":321}," --clean",[307,7470,7471],{"class":321}," --if-exists",[307,7473,322],{"class":321},[307,7475,7416],{"class":317},[307,7477,7478],{"class":317}," shop-20261010.dump\n",[15,7480,7481,7484,7485,3040,7488,7491,7492,7495,7496,7498],{},[169,7482,7483],{},"-Fc"," 自定义格式支持",[19,7486,7487],{},"并行恢复",[169,7489,7490],{},"-j 4","）、选择性恢复（",[169,7493,7494],{},"--table=orders"," 只捞一张表）、压缩，比裸 SQL 灵活得多。数据搬家用 ",[169,7497,628],{},"（客户端侧 CSV，权限跟随当前用户）：",[298,7500,7503],{"className":7501,"code":7502,"language":422,"meta":303},[420],"\\copy products TO 'products.csv' WITH (FORMAT csv, HEADER true)\n\\copy products FROM 'products_bak.csv' WITH (FORMAT csv, HEADER true)\n",[169,7504,7502],{"__ignoreMap":303},[15,7506,7507,7508,7510],{},"大批量导入记得第八章的账：先删非约束索引、导完 ",[169,7509,5541],{}," 再重建。",[201,7512,7514],{"id":7513},"第二步wal-归档与-pitr-的最小配置","第二步：WAL 归档与 PITR 的最小配置",[15,7516,7517,7518,7521,7522,7525,7526,7529,7530,7533],{},"WAL（预写日志）是 PG 的 ",[169,7519,7520],{},"redo log","+",[169,7523,7524],{},"binlog"," 合体：每个数据页修改前先记日志，崩溃后靠它回放。",[19,7527,7528],{},"归档 WAL"," 就等于把「所有变更流水」存下来，配上一次完整的物理备份（basebackup），理论上可以恢复到",[19,7531,7532],{},"任意时间点","（PITR，即 Point-In-Time Recovery）：",[298,7535,7538],{"className":7536,"code":7537,"language":422,"meta":303},[420],"archive_mode = on\narchive_command = 'cp %p \u002Fvar\u002Flib\u002Fpostgresql\u002Farchive\u002F%f'   # 每个 WAL 段写完拷走\n\npg_basebackup -D \u002Fbackup\u002Fbase-20261010 -Fp -Xs -R\n# -Xs 流式同时拉 WAL，-R 生成恢复配置，顺带也是搭流复制的第一步\n",[169,7539,7537],{"__ignoreMap":303},[15,7541,7542,7543,7546,7547,7550,7551,7554],{},"误操作恢复（比如 14:32 有人 ",[169,7544,7545],{},"DROP TABLE","）的标准动作：起一台恢复机 → 拷入最近的 basebackup → 配置 ",[169,7548,7549],{},"restore_command"," 指向归档目录 + ",[169,7552,7553],{},"recovery_target_time = '14:31:00'","（事故前一分钟）→ 起库回放到目标点 → 从影子里把数据捞出来。",[201,7556,666],{"id":7557},"️-本章的坑-10",[15,7559,7560,7565,7566,7569],{},[19,7561,2102,7562,7564],{},[169,7563,1305],{}," 不导角色、密码、表空间。"," 这些是全局对象，要用 ",[169,7567,7568],{},"pg_dumpall --globals-only"," 单独备份。漏了它的典型症状：恢复出来的库「没人连得上」，或者应用账号权限全丢。",[15,7571,7572,7575],{},[19,7573,7574],{},"2. pg_dump 的一致性来自单一事务快照，但它只导「当前逻辑内容」。"," 不是某个时刻的完整物理状态——想回到过去某分钟，只能靠第二步的 WAL 归档 + PITR，别指望一份昨天的 dump。",[15,7577,7578,7581,7582,1930,7585,7588,7589,7592],{},[19,7579,7580],{},"3. PG 没有 MySQL binlog 那种可直接反向执行的回滚日志。"," 逻辑误操作（UPDATE 忘带 WHERE）的恢复就是「PITR 拉影子库捞数」，所以",[19,7583,7584],{},"归档的连续性必须有监控",[169,7586,7587],{},"archive_command"," 失败时未归档的 WAL 会一直堆积，撑满磁盘之后数据库连日志都写不下去，直接停摆。归档是否落后，看 ",[169,7590,7591],{},"pg_stat_archiver"," 里的失败计数和最近成功时间。",[15,7594,7595,675,7598,7601,7602,7604],{},[19,7596,7597],{},"4. PITR 期间实例不可用，且恢复目标点要保守。",[169,7599,7600],{},"recovery_target_time"," 设在事故前一分钟，还得确认那分钟的 WAL 段真的归档成功了（",[169,7603,7591],{}," 或归档目录里的文件名连续性）。恢复完的库是影子库，捞数出来再回写，别直接把它当生产库上线。",[15,7606,7607,7610],{},[19,7608,7609],{},"5. 恢复演练要真的演练。"," 本章末尾那张策略表里每一行都得有对应的演练记录，否则「有备份」只是心理安慰——这也是第十六章清单里「演练」一栏存在的原因。",[201,7612,7614],{"id":7613},"附备份策略组合表","附：备份策略组合表",[31,7616,7617,7630],{},[34,7618,7619],{},[37,7620,7621,7624,7627],{},[40,7622,7623],{},"灾难场景",[40,7625,7626],{},"靠什么救",[40,7628,7629],{},"恢复粒度",[53,7631,7632,7643,7654,7665,7678],{},[37,7633,7634,7637,7640],{},[58,7635,7636],{},"误删某张表",[58,7638,7639],{},"逻辑备份里的该表 \u002F PITR",[58,7641,7642],{},"分钟级（PITR 要起影子库）",[37,7644,7645,7648,7651],{},[58,7646,7647],{},"误删整个库 \u002F 逻辑坏数据",[58,7649,7650],{},"PITR",[58,7652,7653],{},"分钟级时间点",[37,7655,7656,7659,7662],{},[58,7657,7658],{},"机器磁盘全毁",[58,7660,7661],{},"异地归档 + basebackup",[58,7663,7664],{},"小时级",[37,7666,7667,7670,7675],{},[58,7668,7669],{},"大版本升级 \u002F 换云厂商",[58,7671,7672,7674],{},[169,7673,1305],{}," 逻辑迁移",[58,7676,7677],{},"与版本解耦，最通用",[37,7679,7680,7683,7686],{},[58,7681,7682],{},"应用层误 UPDATE（无备份演练）",[58,7684,7685],{},"PITR 影子库捞回",[58,7687,7688],{},"这也是为什么清单里必须有「演练」一栏",[15,7690,7691,7692,7695],{},"一个够用的小规模组合：",[19,7693,7694],{},"每日一次 pg_dump（逻辑，防呆）+ 每日一次 pg_basebackup + 持续 WAL 归档（防灾难）","，归档目录本身再异地同步一份。再往上规模就该看 pgBackRest \u002F WAL-G 这类带增量与去重的工具了。",[631,7697],{"height":4026,"src":7698,"title":7699},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F12-backup-restore.html","示例 12：备份与恢复——pg_dump \u002F pg_restore 目录 \u002F pg_basebackup \u002F PITR 日志",[10,7701,7703],{"id":7702},"十三扩展-全文搜索tsvectorgin-与相关度","十三、扩展 - 全文搜索：tsvector、GIN 与相关度",[15,7705,7706,7707,7710,7711,7714,7715,7718,7719,7722],{},"「搜索框里输个词，把商品\u002F文章按相关度排出来」——第一反应写 ",[169,7708,7709],{},"LIKE '%词%'"," 的话，三个问题全撞上：",[169,7712,7713],{},"%前缀%"," 用不了索引必然全表扫；",[169,7716,7717],{},"keyboard"," 搜不到 ",[169,7720,7721],{},"Keyboards","（没有词干归一）；结果没有相关度可言，只能按时间凑合。PG 的内置全文搜索把这三件事一次解决，而且不需要装任何第三方扩展——英文场景开箱即用。",[201,7724,7726],{"id":7725},"第一步一条-sql-做出按相关度排序","第一步：一条 SQL 做出「按相关度排序」",[298,7728,7730],{"className":1039,"code":7729,"language":1041,"meta":303,"style":303},"SELECT id, title\nFROM articles\nWHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\nORDER BY ts_rank(to_tsvector('english', body),\n                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\nLIMIT 10;\n",[169,7731,7732,7737,7742,7747,7752,7757],{"__ignoreMap":303},[307,7733,7734],{"class":309,"line":310},[307,7735,7736],{},"SELECT id, title\n",[307,7738,7739],{"class":309,"line":334},[307,7740,7741],{},"FROM articles\n",[307,7743,7744],{"class":309,"line":345},[307,7745,7746],{},"WHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\n",[307,7748,7749],{"class":309,"line":355},[307,7750,7751],{},"ORDER BY ts_rank(to_tsvector('english', body),\n",[307,7753,7754],{"class":309,"line":366},[307,7755,7756],{},"                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\n",[307,7758,7759],{"class":309,"line":377},[307,7760,4077],{},[15,7762,7763,7764,7767,7768,7771,7772,625,7775,625,7778,7781,7782,7785,7786,7789],{},"三个零件：",[169,7765,7766],{},"to_tsvector"," 把文本切成「词 + 位置」，",[169,7769,7770],{},"websearch_to_tsquery"," 把用户在搜索框里打的那句话翻译成查询（",[169,7773,7774],{},"\"引号短语\"",[169,7776,7777],{},"-排除",[169,7779,7780],{},"OR"," 都认识），",[169,7783,7784],{},"@@"," 判断是否命中，",[169,7787,7788],{},"ts_rank"," 给相关度打分。先看一眼 tsvector 长什么样，理解会快很多：",[298,7791,7794],{"className":7792,"code":7793,"language":422,"meta":303},[420],"shop=# SELECT to_tsvector('english', 'Learning PostgreSQL is fun');\n            to_tsvector\n------------------------------------\n 'fun':4 'learn':1 'postgresql':2\n(1 row)\n",[169,7795,7793],{"__ignoreMap":303},[15,7797,7798],{},"一步发生了什么：按空白和标点切词、去掉停用词（is\u002Fa\u002Fthe 这类）、词干归一（learning → learn）。tsvector 就是「这个词 + 出现在第几个位置」的有序表，体积小、可比较、可索引。",[201,7800,7802],{"id":7801},"第二步让它走索引","第二步：让它走索引",[15,7804,7805,7806,7808],{},"上面的写法每行都要现算 ",[169,7807,7766],{},"，全表扫。两种索引化姿势，推荐后者：",[298,7810,7812],{"className":1039,"code":7811,"language":1041,"meta":303,"style":303},"-- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\nCREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n\n-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\nALTER TABLE articles\n  ADD COLUMN tsv tsvector\n  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\nCREATE INDEX idx_articles_tsv ON articles USING gin (tsv);\n",[169,7813,7814,7819,7824,7828,7833,7838,7843,7848],{"__ignoreMap":303},[307,7815,7816],{"class":309,"line":310},[307,7817,7818],{},"-- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\n",[307,7820,7821],{"class":309,"line":334},[307,7822,7823],{},"CREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n",[307,7825,7826],{"class":309,"line":345},[307,7827,1756],{"emptyLinePlaceholder":1755},[307,7829,7830],{"class":309,"line":355},[307,7831,7832],{},"-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\n",[307,7834,7835],{"class":309,"line":366},[307,7836,7837],{},"ALTER TABLE articles\n",[307,7839,7840],{"class":309,"line":377},[307,7841,7842],{},"  ADD COLUMN tsv tsvector\n",[307,7844,7845],{"class":309,"line":1471},[307,7846,7847],{},"  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\n",[307,7849,7850],{"class":309,"line":1735},[307,7851,7852],{},"CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);\n",[15,7854,7855,7856,7859,7860,7862,7863,7866,7867,7870],{},"高亮展示用 ",[169,7857,7858],{},"ts_headline('english', body, query, 'StartSel=\u003Cb>, StopSel=\u003C\u002Fb>')"," 直接产出带标签的摘要片段；打分函数 ",[169,7861,7788],{}," 越大越相关（还有一个考虑词距的 ",[169,7864,7865],{},"ts_rank_cd","）。标题命中权重高于正文，是靠姿势二里把 title 拼在前面加 ",[169,7868,7869],{},"setweight"," 实现的，思路都是「打分素材进 tsvector，排序交给查询」。",[15,7872,7873,7874,7876],{},"示例 13 是个可交互的小页面：左边按钮回放 SQL 侧的真实输出，下面是一个真的能输入的搜索框——在静态语料上按「去停用词 + 词干归一 + 词频打分」现场计算并高亮（打分是简化版，真实 ",[169,7875,7788],{}," 还考虑词距）。",[631,7878],{"height":7879,"src":7880,"title":7881},620,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F13-fulltext-search.html","示例 13：全文搜索——tsvector 现场 + 可交互的相关度排序",[201,7883,666],{"id":7884},"️-本章的坑-11",[15,7886,7887,7890,7891,7894],{},[19,7888,7889],{},"1. 中文场景内置分词器不够用。"," 默认解析器",[19,7892,7893],{},"不认识中文词","——连续的汉字会被当成一整个 token：",[298,7896,7899],{"className":7897,"code":7898,"language":422,"meta":303},[420],"shop=# SELECT to_tsvector('simple', 'PostgreSQL 数据库实战');\n        to_tsvector\n---------------------------\n 'postgresql':1 '数据库实战':2\n",[169,7900,7898],{"__ignoreMap":303},[15,7902,7903,7904,7907,7908,7911,7912,7915,7916,7919],{},"于是搜「数据库」匹配不到「数据库实战」——整串是一个词，没有子串匹配。解法是装分词器扩展 ",[169,7905,7906],{},"zhparser","（基于 SCWS）或 ",[169,7909,7910],{},"pg_jieba","，装完注册一个配置，",[169,7913,7914],{},"to_tsvector('zh', ...)"," 就能按真实词切（",[169,7917,7918],{},"北京欢迎你"," → 北京 | 欢迎 | 你）。",[15,7921,7922,7925,7926,7929,7930,7933],{},[19,7923,7924],{},"2. 表达式索引要「文本级一字不差」才命中。"," 索引建的是 ",[169,7927,7928],{},"to_tsvector('english', body)","，查询写成 ",[169,7931,7932],{},"to_tsvector('simple', body)"," 或换了列顺序都对不上（第八章那条规则在这里最容易踩）。姿势二用生成列就是为了避开这个匹配问题。",[15,7935,7936,7939,7940,7943,7944,7947,7948,7951],{},[19,7937,7938],{},"3. 配置（regconfig）要全链路统一。"," 写入用 ",[169,7941,7942],{},"'english'","、查询用 ",[169,7945,7946],{},"'simple'","，结果就是「数据明明在库里，搜索永远为空」——因为两边的词表归一规则不同。把配置写进生成列表达式或 ",[169,7949,7950],{},"default_text_search_config","，别让每个调用点各填各的。",[15,7953,7954,7960,7961,7964],{},[19,7955,7956,7957,7959],{},"4. 别用 ",[169,7958,7709],{}," 硬扛搜索框。"," 中缀 LIKE 用不上 btree（第八章），数据量上去就是全表扫。搜索需求很轻时可以先用 ",[169,7962,7963],{},"pg_trgm"," + GIN 顶住，别一上来就建全文索引。",[15,7966,7967,675,7970,625,7973,7976,7977,7980,7981,7984],{},[19,7968,7969],{},"5. 停用词和词干归一会「吃掉」你的精确匹配。",[169,7971,7972],{},"is",[169,7974,7975],{},"the"," 这类词根本不进索引，搜 ",[169,7978,7979],{},"MySQL 8.0"," 里的 ",[169,7982,7983],{},"8.0"," 也可能被按数字\u002F词切分处理掉。要精确命中型号、专名，走关键词列（普通 btree 或 trgm）而不是全文列——这也是第十五章混合检索要两路并行的原因。",[201,7986,7988],{"id":7987},"附查询翻译函数与中文方案速查","附：查询翻译函数与中文方案速查",[31,7990,7991,8007],{},[34,7992,7993],{},[37,7994,7995,7998,8001],{},[40,7996,7997],{},"函数",[40,7999,8000],{},"输入风格",[40,8002,8003,8006],{},[169,8004,8005],{},"'learning postgres'"," 翻译成",[53,8008,8009,8025,8038],{},[37,8010,8011,8016,8022],{},[58,8012,8013],{},[169,8014,8015],{},"to_tsquery('english', ?)",[58,8017,8018,8019],{},"手写语法 ",[169,8020,8021],{},"learn & postgres",[58,8023,8024],{},"逻辑符直译，给开发者用",[37,8026,8027,8032,8035],{},[58,8028,8029],{},[169,8030,8031],{},"plainto_tsquery('english', ?)",[58,8033,8034],{},"一句话",[58,8036,8037],{},"全部 AND 起来",[37,8039,8040,8045,8048],{},[58,8041,8042],{},[169,8043,8044],{},"websearch_to_tsquery('english', ?)",[58,8046,8047],{},"搜索框习惯",[58,8049,8050,625,8052,625,8054,8056,8057],{},[169,8051,7774],{},[169,8053,7777],{},[169,8055,7780],{}," 都认识，",[19,8058,8059],{},"给用户输入用的就是它",[31,8061,8062,8071],{},[34,8063,8064],{},[37,8065,8066,8068],{},[40,8067,4949],{},[40,8069,8070],{},"方案",[53,8072,8073,8083,8091,8103],{},[37,8074,8075,8078],{},[58,8076,8077],{},"子串模糊搜、拼写纠错",[58,8079,8080,8082],{},[169,8081,7963],{}," + GIN（第八章讲过），撑住搜索框的「凑合版」",[37,8084,8085,8088],{},[58,8086,8087],{},"英文为主、要词干与相关度",[58,8089,8090],{},"内置 FTS，零依赖",[37,8092,8093,8096],{},[58,8094,8095],{},"中文、不想多养组件",[58,8097,8098,8099,1657,8101],{},"PG + ",[169,8100,7906],{},[169,8102,7910],{},[37,8104,8105,8108],{},[58,8106,8107],{},"中文 + 高亮\u002F聚合\u002F复杂运营位",[58,8109,8110],{},"上 Elasticsearch \u002F Meilisearch，PG 存底",[10,8112,8114],{"id":8113},"十四扩展-地理位置postgis-的两种几何观","十四、扩展 - 地理位置：PostGIS 的两种几何观",[15,8116,8117],{},"「找 3 公里内的门店」「这个点在不在配送圈内」——这类需求 PG 的答案是 PostGIS，地理信息事实上的开源标准，也是第一章「少养几个系统」论点的最强证据之一：装上它，门店查询就是一个普通 SQL。",[201,8119,8121],{"id":8120},"第一步装扩展建一张能查附近的表","第一步：装扩展，建一张能查「附近」的表",[298,8123,8125],{"className":1039,"code":8124,"language":1041,"meta":303,"style":303},"CREATE EXTENSION postgis;\nSELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n\nCREATE TABLE stores (\n  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name text NOT NULL,\n  geo  geography(Point, 4326) NOT NULL\n);\n\nINSERT INTO stores (name, geo)\nVALUES ('中关村店',\n        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n-- 注意参数顺序：经度在前，纬度在后\n\nCREATE INDEX idx_stores_geo ON stores USING gist (geo);\n",[169,8126,8127,8132,8137,8141,8146,8151,8156,8161,8165,8169,8174,8179,8184,8189,8193],{"__ignoreMap":303},[307,8128,8129],{"class":309,"line":310},[307,8130,8131],{},"CREATE EXTENSION postgis;\n",[307,8133,8134],{"class":309,"line":334},[307,8135,8136],{},"SELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n",[307,8138,8139],{"class":309,"line":345},[307,8140,1756],{"emptyLinePlaceholder":1755},[307,8142,8143],{"class":309,"line":355},[307,8144,8145],{},"CREATE TABLE stores (\n",[307,8147,8148],{"class":309,"line":366},[307,8149,8150],{},"  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[307,8152,8153],{"class":309,"line":377},[307,8154,8155],{},"  name text NOT NULL,\n",[307,8157,8158],{"class":309,"line":1471},[307,8159,8160],{},"  geo  geography(Point, 4326) NOT NULL\n",[307,8162,8163],{"class":309,"line":1735},[307,8164,1474],{},[307,8166,8167],{"class":309,"line":1741},[307,8168,1756],{"emptyLinePlaceholder":1755},[307,8170,8171],{"class":309,"line":1747},[307,8172,8173],{},"INSERT INTO stores (name, geo)\n",[307,8175,8176],{"class":309,"line":1752},[307,8177,8178],{},"VALUES ('中关村店',\n",[307,8180,8181],{"class":309,"line":1759},[307,8182,8183],{},"        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n",[307,8185,8186],{"class":309,"line":1765},[307,8187,8188],{},"-- 注意参数顺序：经度在前，纬度在后\n",[307,8190,8191],{"class":309,"line":1771},[307,8192,1756],{"emptyLinePlaceholder":1755},[307,8194,8195],{"class":309,"line":1776},[307,8196,8197],{},"CREATE INDEX idx_stores_geo ON stores USING gist (geo);\n",[15,8199,8200],{},"「3 公里内的门店」一条 SQL：",[298,8202,8204],{"className":1039,"code":8203,"language":1041,"meta":303,"style":303},"-- 用户坐标\nWITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\nSELECT s.id, s.name,\n       round(ST_Distance(s.geo, p.pt)) AS dist_m\nFROM stores s, p\nWHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\nORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引\n",[169,8205,8206,8211,8216,8221,8226,8231,8236],{"__ignoreMap":303},[307,8207,8208],{"class":309,"line":310},[307,8209,8210],{},"-- 用户坐标\n",[307,8212,8213],{"class":309,"line":334},[307,8214,8215],{},"WITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\n",[307,8217,8218],{"class":309,"line":345},[307,8219,8220],{},"SELECT s.id, s.name,\n",[307,8222,8223],{"class":309,"line":355},[307,8224,8225],{},"       round(ST_Distance(s.geo, p.pt)) AS dist_m\n",[307,8227,8228],{"class":309,"line":366},[307,8229,8230],{},"FROM stores s, p\n",[307,8232,8233],{"class":309,"line":377},[307,8234,8235],{},"WHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\n",[307,8237,8238],{"class":309,"line":1471},[307,8239,8240],{},"ORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引\n",[15,8242,8243,8246,8247,8250,8251,8254,8255,8258],{},[169,8244,8245],{},"ST_DWithin"," 是唯一正确答案，因为它",[19,8248,8249],{},"能走 GiST 索引","（先粗筛再精算）。",[169,8252,8253],{},"\u003C->"," 是「距离近者先出」的 KNN 排序算子，配 GiST 索引可以流式吐出「最近的 N 家」——外卖、打车场景的标配写法。点在多边形内的判定用 ",[169,8256,8257],{},"ST_Contains(zone.geom, s.geo)","，配送圈、行政区都在这一挂上。",[201,8260,666],{"id":8261},"️-本章的坑-12",[15,8263,8264,8267,8268,8271,8272,8275],{},[19,8265,8266],{},"1. 经纬度顺序。"," PostGIS 全家统一「X 在前 = 经度在前」，GeoJSON 也是 ",[169,8269,8270],{},"[lng, lat]","——但绝大多数业务接口、高德\u002F百度地图 SDK 的习惯是「纬度在前」。写反了",[19,8273,8274],{},"不报错","，只是查不到、距离离谱，属于最阴险的一类 bug，值得在插入入口统一转换一次。",[15,8277,8278,8281,8282,8285,8286,8289,8290,8293],{},[19,8279,8280],{},"2. SRID 混用。"," 插数据忘了 ",[169,8283,8284],{},"ST_SetSRID(..., 4326)","（默认 0），与已有数据做运算立刻报 ",[169,8287,8288],{},"Operation on mixed SRID geometries (0, 4326)","——看到它就回去补 SRID。给列直接声明 ",[169,8291,8292],{},"geometry(Point, 4326)"," 可以在插入时就拦住。",[15,8295,8296,675,8299,8302,8303,8306],{},[19,8297,8298],{},"3. 在 4326 的 geometry 上算距离。",[169,8300,8301],{},"ST_Distance"," 对 geometry 返回的是",[19,8304,8305],{},"度","，不是米——差了大约 11 万倍。要米就 cast 成 geography，或者换本地投影。",[15,8308,8309,8315,8316,8318],{},[19,8310,4402,8311,8314],{},[169,8312,8313],{},"WHERE ST_Distance(geo, p) \u003C 3000"," 不走索引。"," 它每行都要真算一遍大圆距离，行数大就是灾难。换成 ",[169,8317,8245],{},"，让 GiST 索引先粗筛。",[15,8320,8321,8324,8325,8328],{},[19,8322,8323],{},"5. geometry 还是 geography，别选错。"," 经验法则一句话：",[19,8326,8327],{},"业务地图就是「一个城市的几张公里级网」用 geometry + 本地投影；「全国门店、跨国航线」用 geography","。拿不准就用 geography，慢的那点通常无所谓，至少单位不会错。两类的详细差异见下面这张速查表。",[15,8330,8331],{},"示例 14 的交互页面里，可以在静态地图上点选中心、拖半径，距离是拿经纬度真算的（haversine 公式，与 geography 同思路），多边形判定也是真的射线法——SQL 样板同步展示，所见即所查。",[631,8333],{"height":8334,"src":8335,"title":8336},640,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F14-postgis-geo.html","示例 14：地理位置——可交互的 ST_DWithin 与就近排序",[201,8338,8340],{"id":8339},"附geometry-与-geography-对照","附：geometry 与 geography 对照",[31,8342,8343,8361],{},[34,8344,8345],{},[37,8346,8347,8349,8355],{},[40,8348],{},[40,8350,8351,8354],{},[169,8352,8353],{},"geometry","（平面）",[40,8356,8357,8360],{},[169,8358,8359],{},"geography","（球面）",[53,8362,8363,8374,8385,8399,8410],{},[37,8364,8365,8368,8371],{},[58,8366,8367],{},"把地球当",[58,8369,8370],{},"展平的地图",[58,8372,8373],{},"真实的球",[37,8375,8376,8379,8382],{},[58,8377,8378],{},"坐标单位",[58,8380,8381],{},"依投影而定",[58,8383,8384],{},"永远是经纬度（SRID 4326）",[37,8386,8387,8390,8393],{},[58,8388,8389],{},"算出来的距离",[58,8391,8392],{},"投影单位（度或米）",[58,8394,8395,8398],{},[19,8396,8397],{},"米","（沿球面大圆算）",[37,8400,8401,8404,8407],{},[58,8402,8403],{},"索引与计算",[58,8405,8406],{},"更快、函数全",[58,8408,8409],{},"慢一些、函数子集",[37,8411,8412,8415,8418],{},[58,8413,8414],{},"什么时候选",[58,8416,8417],{},"同城\u002F小范围，或需要投影运算",[58,8419,8420],{},"跨大范围、直接要米",[10,8422,8424],{"id":8423},"十五扩展-向量数据库pgvector-与近似检索","十五、扩展 - 向量数据库：pgvector 与近似检索",[15,8426,8427,8428,8431],{},"最后一个扩展是当下最热的一个。语义搜索 \u002F RAG 的底层是：把文本喂给 embedding 模型，得到一串浮点（比如 1536 维），",[19,8429,8430],{},"语义相近的文本，向量距离也近","。查询时把问题也变成向量，找库里「最近的几条」。pgvector 让 PG 直接干这件事——又一个「少养一个组件」。",[201,8433,8435],{"id":8434},"第一步存向量取最相关的-5-条","第一步：存向量，取最相关的 5 条",[298,8437,8439],{"className":1039,"code":8438,"language":1041,"meta":303,"style":303},"CREATE EXTENSION vector;\n\nCREATE TABLE kb_chunks (\n  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  doc_id    bigint NOT NULL,\n  content   text NOT NULL,\n  embedding vector(1536)                  -- 维度 = 模型输出维度\n);\n\n-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\nINSERT INTO kb_chunks (doc_id, content, embedding)\nVALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n\n-- 余弦相似检索：距离算子 \u003C=>，越小越相似\nSELECT content, embedding \u003C=> $1 AS dist\nFROM kb_chunks\nORDER BY embedding \u003C=> $1\nLIMIT 5;\n",[169,8440,8441,8446,8450,8455,8460,8465,8470,8475,8479,8483,8488,8493,8498,8502,8507,8512,8517,8522],{"__ignoreMap":303},[307,8442,8443],{"class":309,"line":310},[307,8444,8445],{},"CREATE EXTENSION vector;\n",[307,8447,8448],{"class":309,"line":334},[307,8449,1756],{"emptyLinePlaceholder":1755},[307,8451,8452],{"class":309,"line":345},[307,8453,8454],{},"CREATE TABLE kb_chunks (\n",[307,8456,8457],{"class":309,"line":355},[307,8458,8459],{},"  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[307,8461,8462],{"class":309,"line":366},[307,8463,8464],{},"  doc_id    bigint NOT NULL,\n",[307,8466,8467],{"class":309,"line":377},[307,8468,8469],{},"  content   text NOT NULL,\n",[307,8471,8472],{"class":309,"line":1471},[307,8473,8474],{},"  embedding vector(1536)                  -- 维度 = 模型输出维度\n",[307,8476,8477],{"class":309,"line":1735},[307,8478,1474],{},[307,8480,8481],{"class":309,"line":1741},[307,8482,1756],{"emptyLinePlaceholder":1755},[307,8484,8485],{"class":309,"line":1747},[307,8486,8487],{},"-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\n",[307,8489,8490],{"class":309,"line":1752},[307,8491,8492],{},"INSERT INTO kb_chunks (doc_id, content, embedding)\n",[307,8494,8495],{"class":309,"line":1759},[307,8496,8497],{},"VALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n",[307,8499,8500],{"class":309,"line":1765},[307,8501,1756],{"emptyLinePlaceholder":1755},[307,8503,8504],{"class":309,"line":1771},[307,8505,8506],{},"-- 余弦相似检索：距离算子 \u003C=>，越小越相似\n",[307,8508,8509],{"class":309,"line":1776},[307,8510,8511],{},"SELECT content, embedding \u003C=> $1 AS dist\n",[307,8513,8514],{"class":309,"line":1782},[307,8515,8516],{},"FROM kb_chunks\n",[307,8518,8519],{"class":309,"line":1890},[307,8520,8521],{},"ORDER BY embedding \u003C=> $1\n",[307,8523,8524],{"class":309,"line":1896},[307,8525,8526],{},"LIMIT 5;\n",[15,8528,8529,8530,8533],{},"三个算子分别对应三种距离，语义搜索默认用 ",[169,8531,8532],{},"\u003C=>","（余弦），细节见章末速查表。",[201,8535,8537],{"id":8536},"第二步数据多了加一个-hnsw-索引","第二步：数据多了，加一个 hnsw 索引",[15,8539,8540,8541,8544,8545,8548],{},"不带索引时 ",[169,8542,8543],{},"ORDER BY embedding \u003C=> q"," 是",[19,8546,8547],{},"精确检索","（全库逐个算），几百行无所谓，百万行就是秒级。要快就得近似最近邻（ANN）索引：",[298,8550,8552],{"className":1039,"code":8551,"language":1041,"meta":303,"style":303},"CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\nSET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢\n",[169,8553,8554,8559],{"__ignoreMap":303},[307,8555,8556],{"class":309,"line":310},[307,8557,8558],{},"CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\n",[307,8560,8561],{"class":309,"line":334},[307,8562,8563],{},"SET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢\n",[15,8565,8566,8567,8570],{},"近似的意思是",[19,8568,8569],{},"可能漏掉真正的最近邻","。召回率要自己量（这也是把它接进生产前必做的实验）：",[298,8572,8574],{"className":1039,"code":8573,"language":1041,"meta":303,"style":303},"-- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\nSET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\nSET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\nSET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms\n",[169,8575,8576,8581,8586,8591],{"__ignoreMap":303},[307,8577,8578],{"class":309,"line":310},[307,8579,8580],{},"-- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\n",[307,8582,8583],{"class":309,"line":334},[307,8584,8585],{},"SET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\n",[307,8587,8588],{"class":309,"line":345},[307,8589,8590],{},"SET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\n",[307,8592,8593],{"class":309,"line":355},[307,8594,8595],{},"SET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms\n",[15,8597,8598],{},"按业务能接受的漏检率（语义搜索通常 0.9+ 够用）反调 probes\u002Fef_search，这就是 ANN 索引的全部调优——没有玄学，只有测量。两种索引的取舍见章末对照表。",[201,8600,8602],{"id":8601},"第三步和全文搜索配合做混合检索","第三步：和全文搜索配合，做混合检索",[15,8604,8605],{},"关键词管「精确命中」（型号、专名），向量管「语义相近」（换个说法也能搜到）。生产常用 RRF（倒数排名融合）把两路结果合起来，CTE（第十章）正好用上：",[298,8607,8609],{"className":1039,"code":8608,"language":1041,"meta":303,"style":303},"WITH kw AS (      -- 关键词路：第十三章\n  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n  WHERE tsv @@ q LIMIT 50\n),\nvec AS (          -- 向量路\n  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n)\nSELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\nFROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\nGROUP BY id ORDER BY rrf_score DESC;\n",[169,8610,8611,8616,8621,8626,8631,8635,8640,8645,8650,8654,8659,8664],{"__ignoreMap":303},[307,8612,8613],{"class":309,"line":310},[307,8614,8615],{},"WITH kw AS (      -- 关键词路：第十三章\n",[307,8617,8618],{"class":309,"line":334},[307,8619,8620],{},"  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n",[307,8622,8623],{"class":309,"line":345},[307,8624,8625],{},"  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n",[307,8627,8628],{"class":309,"line":355},[307,8629,8630],{},"  WHERE tsv @@ q LIMIT 50\n",[307,8632,8633],{"class":309,"line":366},[307,8634,6550],{},[307,8636,8637],{"class":309,"line":377},[307,8638,8639],{},"vec AS (          -- 向量路\n",[307,8641,8642],{"class":309,"line":1471},[307,8643,8644],{},"  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n",[307,8646,8647],{"class":309,"line":1735},[307,8648,8649],{},"  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n",[307,8651,8652],{"class":309,"line":1741},[307,8653,6195],{},[307,8655,8656],{"class":309,"line":1747},[307,8657,8658],{},"SELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\n",[307,8660,8661],{"class":309,"line":1752},[307,8662,8663],{},"FROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\n",[307,8665,8666],{"class":309,"line":1759},[307,8667,8668],{},"GROUP BY id ORDER BY rrf_score DESC;\n",[15,8670,8671,8672,8675],{},"示例 15 的交互页面用",[19,8673,8674],{},"二维平面","直观化了「精确 vs 近似」的差异：勾上近似检索开关，亲眼看到它只探最近的簇、漏掉真近邻——这正是 ivfflat probes=1 的现场。",[631,8677],{"height":8334,"src":8678,"title":8679},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F15-pgvector-ann.html","示例 15：向量检索——可交互的精确 vs 近似（召回现场）",[201,8681,666],{"id":8682},"️-本章的坑-13",[15,8684,8685,8691,8692,8695],{},[19,8686,2102,8687,8690],{},[169,8688,8689],{},"ivfflat"," 必须先灌数据再建索引。"," 空表建索引质心全挤一起，召回灾难。",[169,8693,8694],{},"hnsw"," 没这个前提，可随时建、边插边查——所以默认推荐 hnsw。",[15,8697,8698,8704,8705,8707,8708,8711,8712,8715,8716,22],{},[19,8699,8700,8701,8703],{},"2. 距离算子、索引 opclass、",[169,8702,4094],{}," 三者必须一致。"," 用 ",[169,8706,8532],{},"（余弦）查询，索引却要建 ",[169,8709,8710],{},"vector_l2_ops","，结果是不走索引、退化成全库精确扫。建 ",[169,8713,8714],{},"vector_cosine_ops"," 就一路用 ",[169,8717,8532],{},[15,8719,8720,675,8723,8726],{},[19,8721,8722],{},"3. 维度定死在列类型里。",[169,8724,8725],{},"vector(1536)"," 换 embedding 模型（比如 3072 维）不是改配置，是要重建列重算全量向量。选型时把「模型可能换版」算进成本。",[15,8728,8729,8732,8733,1657,8736,8739],{},[19,8730,8731],{},"4. 近似检索会漏，且漏得悄无声息。"," 不报错、结果看着也正常，只是少了真正的最近邻。上线前用同一批真实查询量一次 recall@K（第二步那段），并把 ",[169,8734,8735],{},"ef_search",[169,8737,8738],{},"probes"," 记进配置。",[15,8741,8742,8745],{},[19,8743,8744],{},"5. 什么时候不用 pgvector。"," 数据到几千万行、要求毫秒级 p99、或要做复杂的向量过滤\u002F分片——再考虑专用向量库。在绝大多数「知识库 + 万级到百万级 chunk」的规模里，pgvector 加上一张熟悉的 PG，就是最省心的答案。",[201,8747,8749],{"id":8748},"附距离算子与-ann-索引速查","附：距离算子与 ANN 索引速查",[31,8751,8752,8765],{},[34,8753,8754],{},[37,8755,8756,8759,8762],{},[40,8757,8758],{},"算子",[40,8760,8761],{},"距离",[40,8763,8764],{},"场景",[53,8766,8767,8779,8792],{},[37,8768,8769,8773,8776],{},[58,8770,8771],{},[169,8772,8253],{},[58,8774,8775],{},"欧氏距离（L2）",[58,8777,8778],{},"绝对距离有意义时",[37,8780,8781,8786,8789],{},[58,8782,8783],{},[169,8784,8785],{},"\u003C#>",[58,8787,8788],{},"负内积",[58,8790,8791],{},"未归一化向量、关注量级",[37,8793,8794,8798,8801],{},[58,8795,8796],{},[169,8797,8532],{},[58,8799,8800],{},"余弦距离（1 - cos）",[58,8802,8803,8806],{},[19,8804,8805],{},"语义搜索最常用","；很多模型输出已归一化，此时与 L2 排序等价",[31,8808,8809,8821],{},[34,8810,8811],{},[37,8812,8813,8815,8818],{},[40,8814],{},[40,8816,8817],{},"ivfflat（聚类倒排）",[40,8819,8820],{},"hnsw（分层图导航）",[53,8822,8823,8834,8848,8874,8885],{},[37,8824,8825,8828,8831],{},[58,8826,8827],{},"原理",[58,8829,8830],{},"K-means 聚成 lists 个簇，查询只探 probes 个簇",[58,8832,8833],{},"多层近邻图，贪心跳转",[37,8835,8836,8839,8845],{},[58,8837,8838],{},"建索引前提",[58,8840,8841,8844],{},[19,8842,8843],{},"必须先灌数据再建","——空表建索引质心全挤一起，召回灾难",[58,8846,8847],{},"可随时建，边插边查",[37,8849,8850,8853,8862],{},[58,8851,8852],{},"关键参数",[58,8854,8855,8858,8859],{},[169,8856,8857],{},"lists","（建议 行数\u002F1000）、查询期 ",[169,8860,8861],{},"ivfflat.probes",[58,8863,8864,625,8867,8870,8871],{},[169,8865,8866],{},"m=16",[169,8868,8869],{},"ef_construction=64","、查询期 ",[169,8872,8873],{},"hnsw.ef_search",[37,8875,8876,8879,8882],{},[58,8877,8878],{},"召回\u002F内存",[58,8880,8881],{},"召回一般、内存小",[58,8883,8884],{},"召回高、内存大",[37,8886,8887,8890,8893],{},[58,8888,8889],{},"选择",[58,8891,8892],{},"内存紧、能接受漏检",[58,8894,8895],{},[19,8896,8897],{},"默认推荐",[10,8899,8901],{"id":8900},"十六收尾上线前核对清单与学习路线","十六、收尾：上线前核对清单与学习路线",[15,8903,8904],{},"一门课收尾，把散在十五章「⚠️ 本章的坑」里的东西收进一张可打勾的清单。新库上线、老库交接，按行过一遍：",[31,8906,8907,8923],{},[34,8908,8909],{},[37,8910,8911,8914,8917,8920],{},[40,8912,8913],{},"类别",[40,8915,8916],{},"检查项",[40,8918,8919],{},"不做的后果",[40,8921,8922],{},"详见",[53,8924,8925,8943,8964,8987,9001,9020,9044,9058,9075],{},[37,8926,8927,8930,8937,8940],{},[58,8928,8929],{},"建库",[58,8931,8932,8933,8936],{},"编码 UTF8、collation 定死；",[169,8934,8935],{},"data_checksums"," 尽量开",[58,8938,8939],{},"collation 变更带坏字符串索引；磁盘静默损坏无感知",[58,8941,8942],{},"三、十二章",[37,8944,8945,8947,8958,8961],{},[58,8946,1192],{},[58,8948,8949,8951,8952,8954,8955,8957],{},[169,8950,1210],{},"≈25% 内存；",[169,8953,1226],{}," 按并发算账；",[169,8956,240],{},"≤300 + PgBouncer",[58,8959,8960],{},"内存事故、连接风暴",[58,8962,8963],{},"三、一章",[37,8965,8966,8969,8981,8984],{},[58,8967,8968],{},"模式",[58,8970,8971,8972,8974,8975,8977,8978,8980],{},"时间一律 ",[169,8973,1627],{},"；金额 ",[169,8976,2615],{},"；文本 ",[169,8979,422],{},"；主键 identity",[58,8982,8983],{},"时区漂移、分币丢失、序列权限报错",[58,8985,8986],{},"四章",[37,8988,8989,8992,8995,8998],{},[58,8990,8991],{},"索引",[58,8993,8994],{},"外键列手动建索引；模糊搜 pg_trgm；FTS 表达式与查询一字不差",[58,8996,8997],{},"删父行秒级锁等待、搜索全表扫",[58,8999,9000],{},"七、八、十三章",[37,9002,9003,9006,9014,9017],{},[58,9004,9005],{},"慢查询",[58,9007,9008,9011,9012],{},[169,9009,9010],{},"log_min_duration_statement = 500ms"," 收慢日志；EXPLAIN 差值大先 ",[169,9013,5541],{},[58,9015,9016],{},"慢而无据，调优靠猜",[58,9018,9019],{},"八章",[37,9021,9022,9025,9038,9041],{},[58,9023,9024],{},"事务",[58,9026,9027,9028,9031,9032,9035,9036],{},"监控 ",[169,9029,9030],{},"idle in transaction","；DDL 前 ",[169,9033,9034],{},"SET lock_timeout","；建索引用 ",[169,9037,6522],{},[58,9039,9040],{},"表膨胀拖垮全库；ALTER 排队全站超时",[58,9042,9043],{},"九章",[37,9045,9046,9049,9052,9055],{},[58,9047,9048],{},"权限",[58,9050,9051],{},"应用账号最小权限且非 owner；pg_hba 无公网 trust；密码 scram",[58,9053,9054],{},"一条 SQL 误删全库；弱口令裸奔",[58,9056,9057],{},"十一章",[37,9059,9060,9063,9069,9072],{},[58,9061,9062],{},"备份",[58,9064,9065,9066],{},"每日 dump + basebackup + WAL 归档；",[19,9067,9068],{},"恢复演练真的做过一次",[58,9070,9071],{},"「有备份」和「能恢复」是两回事",[58,9073,9074],{},"十二章",[37,9076,9077,9080,9083,9086],{},[58,9078,9079],{},"运维",[58,9081,9082],{},"autovacuum 跟上（膨胀超 30% 处理）；死锁日志定期看",[58,9084,9085],{},"性能慢性病、业务加锁环路没人改",[58,9087,9088],{},"八、九章",[15,9090,9091],{},"学完这门课，继续往下走的路线：官方文档的 Part II（SQL 语言）与 Part IV（客户端认证）是两块最适合精读的；练习题首推 pgexercises.com（免费、全 SQL 覆盖）；索引原理补 use-the-index-luke.com；三个扩展的深水区分别在 PG 官方文档的 Text Search \u002F PostGIS 手册 \u002F pgvector 的 GitHub README。",[15,9093,9094],{},"最后回到第一章的场景：全文搜索、附近门店、语义检索——三行需求，一套 PG 加三个扩展全接住。组件少一个，事故面就小一圈。祝你的下一张表，类型全对、索引都绿。",[9096,9097,9098],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":303,"searchDepth":334,"depth":334,"links":9100},[9101,9105,9113,9120,9128,9134,9142,9148,9155,9162,9168,9176,9182,9188,9193,9200],{"id":12,"depth":334,"text":13,"children":9102},[9103,9104],{"id":203,"depth":345,"text":203},{"id":216,"depth":345,"text":217},{"id":285,"depth":334,"text":286,"children":9106},[9107,9108,9109,9110,9111,9112],{"id":292,"depth":345,"text":293},{"id":461,"depth":345,"text":462},{"id":592,"depth":345,"text":593},{"id":638,"depth":345,"text":639},{"id":665,"depth":345,"text":666},{"id":766,"depth":345,"text":767},{"id":935,"depth":334,"text":936,"children":9114},[9115,9116,9117,9118,9119],{"id":942,"depth":345,"text":943},{"id":1035,"depth":345,"text":1036},{"id":1117,"depth":345,"text":1118},{"id":1182,"depth":345,"text":1183},{"id":1281,"depth":345,"text":666},{"id":1382,"depth":334,"text":1383,"children":9121},[9122,9123,9124,9125,9126,9127],{"id":1426,"depth":345,"text":1427},{"id":1531,"depth":345,"text":1532},{"id":1794,"depth":345,"text":1795},{"id":2016,"depth":345,"text":2017},{"id":2094,"depth":345,"text":666},{"id":2444,"depth":345,"text":1422},{"id":4031,"depth":334,"text":4032,"children":9129},[9130,9131,9132,9133],{"id":4042,"depth":345,"text":4043},{"id":4113,"depth":345,"text":4114},{"id":4149,"depth":345,"text":4150},{"id":4302,"depth":345,"text":666},{"id":4488,"depth":334,"text":4489,"children":9135},[9136,9137,9138,9139,9140,9141],{"id":4495,"depth":345,"text":4496},{"id":4550,"depth":345,"text":4551},{"id":4612,"depth":345,"text":4613},{"id":4673,"depth":345,"text":4674},{"id":4779,"depth":345,"text":666},{"id":4939,"depth":345,"text":4940},{"id":5103,"depth":334,"text":5104,"children":9143},[9144,9145,9146,9147],{"id":5124,"depth":345,"text":5125},{"id":5225,"depth":345,"text":5226},{"id":5296,"depth":345,"text":5297},{"id":5329,"depth":345,"text":666},{"id":5497,"depth":334,"text":5498,"children":9149},[9150,9151,9152,9153,9154],{"id":5504,"depth":345,"text":5505},{"id":5545,"depth":345,"text":5546},{"id":5588,"depth":345,"text":5589},{"id":5667,"depth":345,"text":666},{"id":5811,"depth":345,"text":5812},{"id":5992,"depth":334,"text":5993,"children":9156},[9157,9158,9159,9160,9161],{"id":6003,"depth":345,"text":6004},{"id":6086,"depth":345,"text":6087},{"id":6131,"depth":345,"text":6132},{"id":6217,"depth":345,"text":666},{"id":6322,"depth":345,"text":6323},{"id":6415,"depth":334,"text":6416,"children":9163},[9164,9165,9166,9167],{"id":6419,"depth":345,"text":6420},{"id":6470,"depth":345,"text":6471},{"id":6526,"depth":345,"text":6527},{"id":6666,"depth":345,"text":666},{"id":6747,"depth":334,"text":6748,"children":9169},[9170,9171,9172,9173,9174,9175],{"id":6754,"depth":345,"text":6755},{"id":6899,"depth":345,"text":6900},{"id":7005,"depth":345,"text":7006},{"id":7048,"depth":345,"text":666},{"id":7198,"depth":345,"text":7199},{"id":7295,"depth":345,"text":7296},{"id":7379,"depth":334,"text":7380,"children":9177},[9178,9179,9180,9181],{"id":7390,"depth":345,"text":7391},{"id":7513,"depth":345,"text":7514},{"id":7557,"depth":345,"text":666},{"id":7613,"depth":345,"text":7614},{"id":7702,"depth":334,"text":7703,"children":9183},[9184,9185,9186,9187],{"id":7725,"depth":345,"text":7726},{"id":7801,"depth":345,"text":7802},{"id":7884,"depth":345,"text":666},{"id":7987,"depth":345,"text":7988},{"id":8113,"depth":334,"text":8114,"children":9189},[9190,9191,9192],{"id":8120,"depth":345,"text":8121},{"id":8261,"depth":345,"text":666},{"id":8339,"depth":345,"text":8340},{"id":8423,"depth":334,"text":8424,"children":9194},[9195,9196,9197,9198,9199],{"id":8434,"depth":345,"text":8435},{"id":8536,"depth":345,"text":8537},{"id":8601,"depth":345,"text":8602},{"id":8682,"depth":345,"text":666},{"id":8748,"depth":345,"text":8749},{"id":8900,"depth":334,"text":8901},"postgresql","\u002Fimg\u002Fpg-cover.svg","2026-10-10","安装连接、类型选型、单表查询与窗口函数、多表与索引、事务与 MVCC、角色权限、备份与 PITR，再到全文搜索、PostGIS 地理与 pgvector 向量三大扩展，16 章循序渐进，配模拟终端与可交互示例。","md",{},"\u002Ftutorials\u002Fpostgresql",{"title":5,"description":9204},{"loc":9207},"tutorials\u002Fpostgresql","RydWQslwB0RSkpk3hrK8ILdqI_xuQodSCcrHS_aaxdQ",[9213,9215,9219,9223,9227,9230,9234,9238,9242,9246,9250,9254,9257,9261,9265,9269,9273,9277,9281,9285,9289,9293,9297,9301,9305,9309,9313,9317,9321,9325,9328,9332,9336,9340,9344,9348,9352,9356,9360,9364,9368,9372,9375,9379,9383,9387,9391,9395,9399,9403,9407,9411,9415,9419,9422,9426,9430,9434,9438,9441,9445,9449,9453,9457,9461,9465,9469,9473,9477,9481,9485,9489,9493,9497,9501,9505,9508,9512,9516,9520,9524,9528,9532,9536,9540,9544],{"id":9207,"title":5,"titles":9214,"content":9204,"level":310},[],{"id":9216,"title":13,"titles":9217,"content":9218,"level":334},"\u002Ftutorials\u002Fpostgresql#一为什么是-postgresql先看清它和你熟悉的-mysql-差在哪",[5],"先还原一个常见场景。你写了多年 Java，库一直是 MySQL，MyBatis 一把梭；新项目立起来了，需求栏里躺着三行字：商品搜索要支持联想与相关度排序、App 要找「3 公里内的门店」、客服知识库要做语义检索。用 MySQL，这三件事意味着引入三个独立组件（Elasticsearch、专门的地理服务、向量数据库）；用 PostgreSQL，答案全是同一句：装个扩展就行。 这也是很多团队切 PG 的真实动机——不是因为它「更强」，而是因为它能少养几个系统。但动手之前，有几处差异值得先钉在墙上： 维度 MySQL PostgreSQL 对你的影响 SQL 标准与查询能力 够用，窗口\u002FCTE 补齐较晚 以「标准超集」著称：递归 CTE、DISTINCT ON、FILTER 聚合、LATERAL 都是原生能力 复杂报表、树遍历类需求少绕很多弯 类型系统 数值 \u002F 字符串 \u002F 时间 \u002F JSON 多出数组、区间、jsonb、枚举、自定义类型 「逗号分隔字符串存多值」这类反模式在 PG 没有存在理由 MVCC 实现 回滚段 undo log 每行多版本元组，读不加锁 第九章整章都在讲它带来的行为差异 默认隔离级别 REPEATABLE READ READ COMMITTED 语义不同，迁移时最容易想当然 外键自动建索引 InnoDB 会 不会，要自己建 第七章的大坑，先记下 扩展生态 插件相对少 全文搜索 \u002F PostGIS \u002F pgvector 都是「一个扩展装完就能用」 第十三到十五章的三大主角 连接模型 每连接一个线程 每连接一个进程 连接更贵，连接池几乎是必选项 标识符大小写 表名大小写敏感性取决于 lower_case_table_names 不加引号的标识符一律转小写；建表时用了 \"Users\" 这种带引号的大写，以后每次查询都得引号伺候 建表 SQL 里对标识符一律不加双引号 许可协议 GPL（商业需授权） PostgreSQL License（类 MIT） 商用无负担",{"id":9220,"title":203,"titles":9221,"content":9222,"level":345},"\u002Ftutorials\u002Fpostgresql#这门课的路线图",[5,13],"第二到十二章是地基：安装连接 → 基本结构 → 建表 → 单表查询 → 函数 → 多表 → 索引 → 事务与锁 → 视图与 CTE → 权限 → 备份恢复。第十三到十五章是三个扩展专题（全文搜索 \u002F 地理位置 \u002F 向量），可以按需跳读，也可以顺着读——它们正好是第一章那张对比表里「扩展生态」的现场证明。第十六章是一份上线核对清单。 浏览器里跑不了 SQL，所以示例沿用 Linux \u002F Docker 教程的做法：写死真实输出的模拟终端，点按钮逐条回放命令与 psql 的真实打印；三个扩展章节另配了可交互的小页面——数据是静态样本，但打分、测距、找近邻的计算是真算，不是录像。",{"id":9224,"title":217,"titles":9225,"content":9226,"level":345},"\u002Ftutorials\u002Fpostgresql#️-两处最容易想当然的差异",[5,13],"上面那张表里，有两行值得在动手前先展开说透，它们会贯穿整门课。 每连接一个进程。 MySQL 里一个连接是一个线程，PG 里主进程 postmaster 会为每个连接 fork 一个 backend 进程，内存几 MB 起步。两个直接推论： 连接是贵重资源，max_connections 别照 MySQL 的习惯开几千——100~300 之间，前面再顶一层 PgBouncer 连接池（对习惯 HikariCP 的 Java 同学来说：应用的池要收小，池外面还有一层池）； 进程模型换来一套独特的崩溃语义：单条 SQL 报错只影响它自己；但如果某个 backend 真的段错误了，postmaster 的策略是把全部连接断开、重置共享内存再迎接新连接——宁可全体重启，也不带病运行。线上看到「一夜之间所有连接同时断开重连」，先去翻 PG 日志里的 server process was terminated by signal。 默认隔离级别不同。 MySQL InnoDB 默认 REPEATABLE READ，PG 默认 READ COMMITTED。看着是配置差异，实际是行为差异：同一个事务里两次执行相同的 SELECT，MySQL 默认读到同一个结果，PG 默认第二次能看到别人已提交的新数据。如果迁移过来的代码依赖「事务内复读」，第九章会给出三种处理姿势。 还有一条建表当天就会碰到的：不加引号的标识符一律转小写。写成 CREATE TABLE \"Users\" (...) 之后，每次查询都得 \"users\" 带引号伺候，漏一次就报 relation \"users\" does not exist。规矩很简单——建表 SQL 里对标识符一律不加双引号。",{"id":9228,"title":286,"titles":9229,"content":289,"level":334},"\u002Ftutorials\u002Fpostgresql#二安装与第一次连接三种姿势与-psql-的肌肉记忆",[5],{"id":9231,"title":293,"titles":9232,"content":9233,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步一条命令起一个能连的库",[5,286],"本地想有个能折腾的 PG，Docker 最省事： docker run -d --name pg-lab \\\n  -e POSTGRES_PASSWORD=lab123456 \\\n  -e POSTGRES_DB=shop \\\n  -p 5432:5432 \\\n  -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata \\\n  postgres:17 等两三秒，连上去： psql -h 127.0.0.1 -p 5432 -U postgres -d shop psql (17.x)\nType \"help\" for help.\n\nshop=# SELECT version();\n                          version\n-----------------------------------------------------------\n PostgreSQL 17.x on x86_64-pc-linux-gnu, compiled by gcc...\n(1 row) 看到提示符变成 shop=# 就连上了：# 表示超级用户，普通用户是 shop=>。\\q 退出。四个参数的含义：POSTGRES_PASSWORD 必填（缺了容器直接起不来）、POSTGRES_DB 顺手建好第一个业务库、-p 把容器内 5432 映射到宿主、-v 挂的卷是数据本体。",{"id":9235,"title":462,"titles":9236,"content":9237,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步三种安装方式选一种就够",[5,286],"方式 适合谁 关键点 Docker 跟着本教程做实验、本机多版本并存 数据必须挂数据卷，否则 docker rm 之日就是数据消失之时（Docker 教程第九章算过这笔账） 系统包管理器（PGDG 源） Linux 服务器长期运行 Debian\u002FUbuntu 官方源版本偏旧，加 PGDG 源拿新版本 Windows 安装器 本机只想有个能连的库 EDB 安装器一路下一步，装完自带 pgAdmin 图形客户端 服务器上长期跑，用包管理器： sudo apt install postgresql-17          # 先按官方文档加 PGDG 源\nsudo systemctl status postgresql@17-main\nsudo -u postgres psql                   # 本地 peer 认证，免密直接进 Debian 系装完会自动 initdb 一个「数据库簇」，并创建同名 OS 用户 postgres（数据库管理员同体），sudo -u postgres psql 相当于 MySQL 里的 mysql -u root 免密进。另外容器场景有个偷懒通道：docker exec -it pg-lab psql -U postgres -d shop，走容器内部 socket，不用输密码——只限实验环境。",{"id":9239,"title":593,"titles":9240,"content":9241,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步psql-里先练这四个命令",[5,286],"连上之后，日常 90% 的操作就四个：\\dt 看有哪些表、\\d 表名 看单表结构、\\l 看有哪些库、\\timing on 打印每条 SQL 的耗时。 \\d 是用得最多的一个：建完表不用猜索引建没建对，\\d 表名 一屏把列、类型、索引、约束全看到。完整命令表（含 \\x、\\copy 等）在本章末尾的速查区。",{"id":9243,"title":639,"titles":9244,"content":9245,"level":345},"\u002Ftutorials\u002Fpostgresql#第四步不想每次输密码",[5,286],"不想每次交互输密码，两种姿势：临时用环境变量 PGPASSWORD=... psql ...；长期用 ~\u002F.pgpass，格式一行一条 主机:端口:数据库:用户:密码。超级用户第一件事建议 \\password 改掉弱密码——谁能连、用什么方式认证，由 pg_hba.conf 把关，第十一章展开。",{"id":9247,"title":666,"titles":9248,"content":9249,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑",[5,286],"1. 没挂数据卷 = docker rm 之后数据全没。 -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata 这一行是数据本体，删容器不带 -v 参数的重建就是清库。 2. 只有卷为空时才会 initdb。 所以改了 POSTGRES_PASSWORD、POSTGRES_DB 这些环境变量，重启老容器不会生效——它们只在第一次初始化时起作用。想重来，得连卷一起删（docker volume rm pg-lab-data）。 3. 镜像 tag 写大版本号，别写 latest。 跨大版本（16 → 17）不是换个 tag 拉个新镜像就完事，要靠 pg_upgrade 迁移；写 latest 等于把版本交给别人决定。 4. SQL 必须以 ; 结尾才执行。 多行语句写到一半没分号，提示符会变成 shop- 静静等你，看着像「卡死」，其实是它以为你还没写完——补个分号回车就回来了。 5. .pgpass 权限不是 600 会被直接忽略。 PG 认为别人能读的密码文件不可信，报的还是「密码认证失败」这种不相干的错，很难往这上面想。chmod 600 ~\u002F.pgpass。 6. Debian\u002FUbuntu 官方源的版本偏旧。 想跟这门课用 17，要按官方文档加 PGDG 源，否则 apt install postgresql 装出来的是十几年前语义的老版本。 7. docker exec 那条免密通道只限实验。 它走容器内部 socket，绕过了 pg_hba.conf 的密码认证；生产环境照抄等于给自己留了个免认证入口。",{"id":9251,"title":767,"titles":9252,"content":9253,"level":345},"\u002Ftutorials\u002Fpostgresql#附psql-反斜杠命令速查",[5,286],"反斜杠命令 作用 MySQL 里的对应 \\l 列出所有数据库 SHOW DATABASES \\c shop 切换数据库 USE shop \\dt 列出当前 schema 的表 SHOW TABLES \\d orders 看表结构：列、类型、索引、约束一起看 DESC orders 的加强版 \\dn 列出所有 schema 无对应 \\du 列出角色与成员关系 SELECT user, host FROM mysql.user \\timing on 之后每条 SQL 打印执行耗时 无对应，调优刚需 \\x on 宽表竖排显示（列多时救命） \\G \\i init.sql 执行脚本文件 SOURCE \\copy 导入导出 CSV LOAD DATA INFILE（第十二章细讲）",{"id":9255,"title":936,"titles":9256,"content":939,"level":334},"\u002Ftutorials\u002Fpostgresql#三基本结构实例数据库schema-与表多出来的那一层是什么",[5],{"id":9258,"title":943,"titles":9259,"content":9260,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步多出来的那一层是-schema",[5,936],"PG 的层级比 MySQL 多一层。一个实例（postmaster 进程 + 共享内存 + 一份数据目录，官方术语叫 cluster，数据库簇——和「分库分表集群」不是一个东西，别被名字吓到）下面挂多个 database，database 里再分 schema，schema 里才是表、序列、函数、视图： 实例（postmaster + 共享内存 + 数据目录，官方叫 cluster）\n ├── 数据库 postgres        —— 管理员的常驻地\n ├── 数据库 shop            —— 业务库\n │    ├── schema public     —— 默认命名空间\n │    └── schema business   —— 自定义命名空间：把「订单域」整组收拢\n │         ├── 表 orders\n │         └── 序列 \u002F 函数 \u002F 视图 \u002F 索引……都挂在 schema 里\n ├── 数据库 template1       —— 建库的默认模板\n └── 数据库 template0       —— 纯净模板 层级 是什么 MySQL 里的对应 实例（簇） postmaster + 共享内存 + 一份数据目录 mysqld 实例 database 相互完全隔离的数据库，各自有自己的目录 database（库） schema 库内的命名空间 + 授权单元 没有——MySQL 库直接管表 表 数据本身 table 多出这层 schema 不是官僚主义：一是跨 database 不能直接 JOIN（要上 postgres_fdw 这类外部包装器），但跨 schema 只要写前缀 business.orders 就行——所以「把 MySQL 的多个小库合成 PG 一个库的多个 schema」是很常见的迁移姿势，原本的跨库查询全部变通；二是权限可以按 schema 整组授予，第十一章的最小权限模板会用到。",{"id":9262,"title":1036,"titles":9263,"content":9264,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步建一个-schema把表放进去",[5,936],"CREATE SCHEMA business;\nCREATE TABLE business.orders (id bigint, note text); 不带前缀的表名去哪找？由 search_path 决定： shop=# SHOW search_path;\n   search_path\n-----------------\n \"$user\", public\n(1 row) 默认值是「与我同名的 schema，再 public」，第一个 \"$user\" 99% 的情况下不存在，实际命中的都是 public。把刚建的 schema 排到前面： SET search_path = business, public;\nSELECT * FROM orders;              -- 等价于 business.orders 系统表所在的 pg_catalog 永远隐含在最前面，所以 pg_class、pg_settings 这类系统表名不会和你业务表打架。想临时改一次用上面的 SET，想整个会话\u002F账号长期生效用 ALTER ROLE ... SET search_path（第十一章的授权模板里就有这一句）。",{"id":9266,"title":1118,"titles":9267,"content":9268,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步建库时把编码和排序规则一次定死",[5,936],"\\l 里永远能看到三个库：postgres、template1、template0。PG 的 CREATE DATABASE 本质是复制一份模板库——默认复制 template1；template0 则是出厂纯净版，还能在建库时指定编码和排序规则（这是 template1 做不到的）： CREATE DATABASE shop\n  TEMPLATE template0\n  ENCODING 'UTF8'\n  LC_COLLATE 'C.UTF-8'\n  LC_CTYPE 'C.UTF-8'; 建库这一步把编码和 collation 定死，是第十六章核对清单的第一行，原因见本章坑 2。",{"id":9270,"title":1183,"titles":9271,"content":9272,"level":345},"\u002Ftutorials\u002Fpostgresql#第四步必调的四个参数",[5,936],"参数 默认 建议 备注 shared_buffers 128MB 物理内存的 25% 左右 PG 自己的热数据缓存。注意它在 OS 页缓存之外又算一层，别给到 50%+ work_mem 4MB 16~64MB 起步 单个排序 \u002F 哈希操作的内存预算：一条 SQL 里可以用多个，再乘以并发连接数——盲目调大是内存事故的头号来源 max_connections 100 ≤300，前面加连接池 每连接是进程，开 2000 个先想清楚内存 effective_cache_size 4GB 约等于 OS 可用页缓存 只影响优化器的成本估算，不实际分配内存——填小了优化器会不敢用索引",{"id":9274,"title":666,"titles":9275,"content":9276,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-1",[5,936],"1. 往 template1 里建的东西，以后每个新库都会带着。 它是建库的默认模板，你在里面留了张测试表，之后 CREATE DATABASE 出来的每个库都有这张表。要改模板先想清楚这一点，需要干净起点时用 TEMPLATE template0。 2. 排序规则（collation）建库之后改不了，而字符串索引的顺序完全依赖它——升个 glibc 版本导致 collation 行为变化、索引悄悄损坏的事故是有真实案例的。所以第三步那次「建库就定死」不是形式主义；已经建错了的库只能 pg_dump 出来重建。 3. 跨 database 不能 JOIN。 想当然照 MySQL 的 db1.t1 JOIN db2.t2 写会直接报错。要么用 postgres_fdw 挂外部表，要么按第一步说的：一个库、多个 schema。 4. PG 15 起 public schema 默认收回普通用户的建表权。 普通用户 CREATE TABLE 会报 permission denied for schema public。这不是 bug，是安全默认值——正确姿势是给业务单独建 schema、单独授权，第十一章给出整套模板。 5. search_path 里排前面的 schema 会遮蔽后面的同名表。 如果 business 和 public 里都有 orders，SELECT * FROM orders 只会命中排在前面的那个。生产代码里跨 schema 的表写全前缀，或把 search_path 固定成一组明确的 schema，别让它随会话漂移。 6. work_mem 的账要会算：它不是「每个连接」的预算，而是「每个排序\u002F哈希节点」。 100 个连接、每条查询里两个 64MB 的排序节点，理论峰值就是 100 × 128MB = 12.8GB。调它之前先用 \\timing on + EXPLAIN 确认瓶颈真的是排序\u002F哈希（第八章），而不是无脑加。",{"id":9278,"title":1383,"titles":9279,"content":9280,"level":334},"\u002Ftutorials\u002Fpostgresql#四表结构定义类型选对索引的锅就少背一半",[5],"场景定型：这门课的示例业务是一个小电商——users（用户）、products（商品）、orders（订单）、order_items（订单明细）。 本章分三段读，第一遍只看第一段就够了： 入门四步：建出能跑的表、类型怎么选、四张表怎么连起来、jsonb 与数组怎么查——全是可直接抄的最小示例； ⚠️ 本章的坑：8 个真实报错和它们的成因，等表建起来了再回头看； 附：类型速查表：字节长度、Java 类型、MySQL → PG 迁移对照，写 DDL 时查，不用通读。",{"id":9282,"title":1427,"titles":9283,"content":9284,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步先建出一张能跑的表",[5,1383],"最小的 users 表，一行一个字段： CREATE TABLE users (\n  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n  name    text NOT NULL,                                    -- 用户名，必填\n  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n); 插一条、查一条，确认它真的活了： INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\nSELECT id, name, email, created_at FROM users; id | name | email                |       created_at\n----+------+----------------------+-------------------------\n  1 | 小明 | xiaoming@example.com | 2026-10-16 11:20:31+08 注意 INSERT 里没有 id——GENERATED ALWAYS AS IDENTITY 就是 PG 里「自增主键」的标准写法，值由数据库生成。Java 实体对应关系是 Long id、String name、OffsetDateTime createdAt，text 一律映射成 String。",{"id":9286,"title":1532,"titles":9287,"content":9288,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步类型怎么选先记住这六个",[5,1383],"存什么 就用它 Java 类型 一句话理由 主键、外键、ID bigint Long 64 位整型，雪花 ID 也装得下，不会哪天爆掉 任何文本（短用户名 \u002F 长正文） text String PG 里 text 和 varchar 是同一种实现，不用纠结长度 金额 numeric(12, 2) BigDecimal 精确十进制，0.1 + 0.2 不会等于 0.30000000000000004 时间 timestamptz OffsetDateTime 带时区的绝对时刻，换服务器也不漂 是否、开关 boolean Boolean 字面量就是 true \u002F false 数量、库存 integer Integer 32 位，约 ±21 亿，计数场景足够 再加两个「进阶但很常用」：订单的扩展属性（渠道、活动信息）用 jsonb，一条记录挂多个标签用 text[] 数组。它们都是 PG 的原生能力，不需要像 MySQL 那样把多个值拼成逗号字符串。 把商品表建出来，六个类型一次用全： CREATE TABLE products (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title    text NOT NULL,\n  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n  tags     text[],                                       -- 标签数组，可空\n  meta     jsonb,                                        -- 扩展属性\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO products (title, price, stock, tags, meta)\nVALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n\nSELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\nFROM products; title   | price  | tags         | channel\n----------+--------+--------------+---------\n 机械键盘 | 399.00 | {数码,外设} | app",{"id":9290,"title":1795,"titles":9291,"content":9292,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步把四张表连起来外键与约束",[5,1383],"订单和明细是一对多，REFERENCES 就是外键，ON DELETE CASCADE 表示「父行删了，子行跟着删」： CREATE TABLE orders (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n  status   text NOT NULL DEFAULT 'created',\n  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n  meta     jsonb,\n  tags     text[],\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE order_items (\n  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  product_id bigint NOT NULL REFERENCES products(id),\n  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n  qty        integer NOT NULL CHECK (qty > 0),\n  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n); total 是 PG 12 起的生成列：由同表其他列算出、物理存储，查的时候不用每次重算 price * qty。 外键的删除动作按业务逐个选，别无脑 CASCADE： 动作 父行被删时 适合 ON DELETE CASCADE 子行连带删除 强从属：订单没了，明细必须没了 ON DELETE SET NULL 子行外键置 NULL 弱关联：商品删了，收藏记录留着但指向空 ON DELETE RESTRICT 有子行就拒绝删除，报错 任何强引用 约束是应用层校验之外的最后防线：ORM 的校验能被绕过，CHECK 不会。四张表建完，\\dt 能看到它们，\\d orders 能看单表结构。最后造一条订单，给下一节的 jsonb \u002F 数组查询当数据： INSERT INTO orders (user_id, status, meta, tags)\nVALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);",{"id":9294,"title":2017,"titles":9295,"content":9296,"level":345},"\u002Ftutorials\u002Fpostgresql#第四步jsonb-取值与数组查询的三个写法",[5,1383],"入门只需要三个操作符，先记住形状： -- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\nSELECT meta ->> 'channel' AS channel FROM orders;\n\n-- 2) @> 包含查询：meta 里含这个键值对的就命中\nSELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n\n-- 3) && 数组交集：tags 里含有 'vip' 的就命中\nSELECT id FROM orders WHERE tags && ARRAY['vip']; -> 和 ->> 的区别只在于返回 jsonb 还是 text；其余操作符（#>>、?、jsonb_set()、jsonb_array_elements()）见本章末尾的速查表。",{"id":9298,"title":666,"titles":9299,"content":9300,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-2",[5,1383],"表能跑起来之后，再回头看这 8 条——它们都是上面那些「看起来没问题」的写法在真实项目里会长出来的刺。 1. serial 不是类型，是宏，新表一律换 identity。 它会建一个序列并把默认值设成 nextval()，三宗罪迟早撞上：不是 SQL 标准，迁移工具对不上；权限要单独授，把表授给应用账号后 INSERT 仍会报 permission denied for sequence users_id_seq，得再 GRANT USAGE ON SEQUENCE 一次（第十一章模板里已包含）；序列和表归属松散，删来删去留孤儿。 serial 宏 GENERATED ... AS IDENTITY 标准 PG 方言 SQL 标准 序列权限 表授权之外要单独 GRANT USAGE ON SEQUENCE 授权器自动处理，不踩坑 手工插 id 默认就允许（副作用源头之一） ALWAYS 拒绝 \u002F BY DEFAULT 允许，白纸黑字 删表后序列 可能残留孤儿序列 随表删除 2. ALWAYS 意味着你手工插 id 会被拒。 这是上面那条 DDL 的副作用，报错长这样： shop=# INSERT INTO users (id, name) VALUES (99, 'Bob');\nERROR:  cannot insert a non-DEFAULT value into column \"id\"\nDETAIL:  Column \"id\" is an identity column defined as GENERATED ALWAYS.\nHINT:  Use OVERRIDING SYSTEM VALUE to override. 迁移历史数据时加 OVERRIDING SYSTEM VALUE；想平时也允许手工指定，建表时就用 BY DEFAULT。 3. 时间列写成 timestamp，换时区就漂。 timestamptz 存的是绝对时刻（内部按 UTC 记账），显示随会话时区变；timestamp 存的只是「墙上读数」。实验两分钟胜过背十遍： CREATE TABLE clock_t (ts timestamptz);\nSET TIME ZONE 'Asia\u002FShanghai';\nINSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\nSELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\nSET TIME ZONE 'UTC';\nSELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00 同一个绝对时刻，两个会话两个读数，都对。如果列建成 timestamp，这个值就只是一串「9 点半」，谁也不知道是哪个时区的 9 点半。所以：时间列一律 timestamptz，字面量尽量带偏移（'2026-10-10 09:30:00+08'），应用连接串里写明 TimeZone=Asia\u002FShanghai。 4. 金额用 float8，对账时对不平。 浮点存的不是十进制字面值： SELECT 0.1::float8 + 0.2::float8;   -- 0.30000000000000004 金额一律 numeric(p, s)，它按十进制精确存。 5. char(n) 会补空格，= 比较会骗人。 char(10) 存 'abc' 读出来是 'abc       '；varchar(n) 的 n 也不带来任何性能差异，只是长度约束。要约束长度，用 text + CHECK (char_length(x) \u003C= n) 更明白。 6. PG 不给外键自动建索引——MySQL 老手最容易栽的一条。 InnoDB 建外键时会自动在子表列上建索引，PG 什么都不做。于是删父表一行时，PG 要顺序扫整个子表检查有没有引用，行数一大就是秒级起步的锁等待（第九章的排队事故里有它的戏份）。解法很朴素： CREATE INDEX idx_order_items_order_id ON order_items (order_id); 建完之后 \\d order_items，索引区和约束区各看到一条——「外键约束」和「外键列索引」是两码事，谁也不替谁负责。 7. bigint 传到前端会被改掉末几位。 Java 侧 Long 是 64 位，JS 的 Number 只有 53 位安全整数，雪花 ID 序列化过去末几位悄悄变了。所以 ID 出参统一转字符串（Jackson 的 ToStringSerializer，或全局配 Long → String），前端全程按 string 传。RuoYi 这类用雪花算法生成主键的框架，实体里是 Long userId，建表就必须是 bigint。 8. json 和 jsonb 别选错，uuid 主键别乱用。 json 只是原样存文本，每次读都要重新解析、不能建索引；jsonb 是解析后的二进制，能建 GIN 索引能查询——99% 的场景选 jsonb。另外 uuid 做主键虽然方便（PG 13 起 gen_random_uuid() 内置，不用装 pgcrypto），但随机值会让 B-tree 索引写入分散、页分裂变多，大表主键仍推荐 bigint（第八章有账可算）。",{"id":9302,"title":1422,"titles":9303,"content":9304,"level":345},"\u002Ftutorials\u002Fpostgresql#附类型速查表",[5,1383],"写 DDL 时查这几张表。先说一个命名陷阱：int8 里的 8 不是 8 位，是 8 字节（byte）。PG 的 int2 \u002F int4 \u002F int8、float4 \u002F float8 前缀数字一律指字节数，跟 MySQL 的 int(11)（那是显示宽度，和取值范围毫无关系）是两套语义。所以 int8、bigint 是同一个东西的两种写法：64 位长整型，对应 Java 实体里的 Long。 数值类型： PG 别名 SQL 标准名 字节 位 取值范围 Java 类型 生产推荐 int2 smallint 2 16 −32768 ~ 32767（约 ±3 万） Short 状态码、字典序号这类小整数；别拿来存 ID int4 \u002F integer \u002F int integer 4 32 约 ±21 亿 Integer 数量、库存、分页 size；日活过千万的表主键别赌它 int8 \u002F bigint bigint 8 64 约 ±9.2 × 10¹⁸ Long 主键、外键、雪花 ID 一律用它，多占 4 字节换一辈子的安心 numeric(p, s) \u002F decimal numeric 变长（约 2 字节存 4 位数字 + 头部） — 任意精度，p 上限 1000 位 BigDecimal 金额唯一正确答案，numeric(12, 2) 起步；p 只限数字位数，不占额外空间 float4 \u002F real real 4 32 单精度，6 ~ 7 位有效数字 Float 只在打分、统计这类容忍误差的场景用 float8 \u002F double precision double precision 8 64 双精度，15 ~ 17 位有效数字 Double 经纬度、相关度分值；存钱会翻车（本章坑 4） smallserial \u002F serial \u002F bigserial — 2 \u002F 4 \u002F 8 — 同 int2 \u002F int4 \u002F int8，各自背后挂一个序列 Short \u002F Integer \u002F Long 只是「序列 + 默认值」的宏，新表换 identity（本章坑 1） oid — 4 32 0 ~ 4294967295 Long（JDBC 返回） 系统内部对象号，业务键别用它 money — 8 64 随 lc_monetary locale 变化 BigDecimal 不用，跨 locale 会算错，numeric 完胜 字符串、布尔与二进制： PG 类型 长度 Java 类型 生产推荐 boolean \u002F bool 1 字节 Boolean 真正的布尔，字面量 true \u002F false，也认 't' \u002F 'f'、'1' \u002F '0'；MyBatis 直接映射 Boolean，不用再学 MySQL 的 tinyint(1) 习惯 varchar(n) 变长，n ≤ 10485760 个字符 String 需要长度约束时用；n 不影响性能，也不会预分配空间 text 变长，上限 1 GB String 默认选择：短用户名和长正文都用它，和 varchar 底层同一种实现，没有任何性能差 char(n)（内部名 bpchar） 定长 n，不足补空格 String 避免：读出来带尾部空格，= 比较会因此骗人 bytea 变长，上限 1 GB byte[] 小二进制块（签名、缩略图）可以；大文件走对象存储，别塞进库里拖垮备份 uuid 16 字节 String \u002F java.util.UUID 全局 ID，配 gen_random_uuid()；做主键要注意 B-tree 索引写入分散（第八章） citext 变长（扩展类型） String 大小写不敏感列；更常见的做法还是 text + 表达式索引 lower(email) enum（自定义类型） 4 字节 String \u002F Java 枚举 取值固定且要省空间时用；改取值要 ALTER TYPE，多数团队直接用 text + CHECK 时间、日期与其他： PG 类型 长度 \u002F 精度 Java 类型 生产推荐 timestamptz（timestamp with time zone） 8 字节，微秒 OffsetDateTime（老驱动常落到 java.util.Date） 时间列默认写它，内部按 UTC 记账，显示随会话时区 timestamp（without time zone） 8 字节，微秒 LocalDateTime 只在「墙上时间」本身即业务语义时用（营业到 21:00 这类），不带时区 date 4 字节，天 LocalDate 生日、账期、统计日 time 8 字节 LocalTime 时段端点 interval 16 字节，微秒 Duration \u002F Period 时长、超时阈值；能写 '3 days'、'2 hours 30 mins' jsonb 变长，上限 1 GB String + TypeHandler \u002F Map 半结构化首选，解析后存储、能建 GIN 索引（本章速查表末 + 第八章） text[] \u002F int[] 数组 变长 List\u003CString> \u002F TypeHandler 多值标签；驱动层要 PGobject 或自定义 TypeHandler 接 inet 4 或 16 字节 + 掩码 String 存 IP（含 IPv6），比 varchar(45) 强在能索引和网段运算 tsvector 变长 — 全文检索列，第十三章的主角，不映射 Java jsonb 操作符全表（meta = {\"channel\":\"app\",\"tags\":[\"vip\",\"new\"]}）： 操作符 输入 → 输出 例子 -> 键 → jsonb meta->'tags' → [\"vip\",\"new\"] ->> 键 → text meta->>'channel' → app（最常用） #>> 路径 → text meta #>> '{tags,0}' → vip @> 包含判断（能走 GIN 索引） meta @> '{\"channel\":\"app\"}' → true ? 顶层键存在 meta ? 'tags' → true jsonb_set() 改值 jsonb_set(meta, '{vip}', '\"gold\"'::jsonb) jsonb_array_elements() 数组炸开成行 SELECT jsonb_array_elements(meta->'tags') 实战里最值钱的是 @> 包含查询配合 GIN 索引（第八章会讲两种索引 opclass 的取舍）： SELECT id, meta->>'channel' AS channel\nFROM orders\nWHERE meta @> '{\"tags\": [\"vip\"]}'; 数组类型的日常是交集查询：WHERE tags && ARRAY['vip'] 表示「标签里含 vip」，配 GIN 索引同样走索引。 MySQL → PG 类型迁移对照： 从 MySQL 迁表时别一一对着名字猜，尤其注意 PG 没有 unsigned、没有 1 字节整型、datetime 不带时区这三件事： MySQL 类型 PG 对应 长度变化 Java 类型 迁移要点 tinyint(1) boolean 1 → 1 字节 Boolean 0\u002F1 转 false \u002F true；tinyint(2) 以上不是布尔，按 smallint 迁 tinyint smallint（int2） 1 → 2 字节 Short PG 没有 1 字节整型，只能升一档 smallint smallint 相同 Short MySQL 的 unsigned smallint 上限 65535 超了 int2，升 int4 mediumint integer（int4） 3 → 4 字节 Integer PG 没有 3 字节整型 int \u002F integer integer 相同 Integer int unsigned（上限约 42.9 亿）装不进 int4，必须升 bigint bigint bigint（int8） 相同 8 字节 Long 雪花 ID 直接对得上；bigint unsigned 超出 64 位有符号，改用 numeric int(11) \u002F int(10) unsigned integer — Integer 括号里的数字是显示宽度，PG 没有这个概念，直接丢掉 decimal(p, s) \u002F numeric numeric(p, s) 变长 BigDecimal 语义一致，p \u002F s 原样搬 float \u002F double real \u002F double precision 相同 Float \u002F Double 名字变了，精度没变 char(n) char(n)（bpchar） 相同 String 两边都补空格，行为一致；仍然建议换 text varchar(n) varchar(n) 或 text n 单位是字符 String 两边的 n 都按字符算，迁过去不会截断；差别在 MySQL 单行有 65535 字节上限，utf8mb4 下几个大 varchar 就会撞墙，PG 没有这条约束 text \u002F tinytext \u002F mediumtext \u002F longtext text 统一变长，上限 1 GB String PG 一个 text 通吃，MySQL 那四档长度差异全部消失 enum('a','b') text + CHECK 或自定义 enum 类型 — String 推荐 CHECK：加一个取值只是改约束，不用动类型 set('a','b') text[] 数组 — List\u003CString> 数组能整体建 GIN 索引，比 FIND_IN_SET 干净 json jsonb 变长 String \u002F Map 99% 的情况选 jsonb；只有要求「原样保留文本和键序」才用 json datetime timestamptz 5~8 字节 → 8 字节 OffsetDateTime MySQL 的 datetime 不带时区，迁移动作里必须显式说明原数据是哪个时区，否则整体漂 8 小时 timestamp（MySQL） timestamptz 4 → 8 字节 OffsetDateTime MySQL 的 timestamp 存 UTC、按会话时区显示，语义正好对应 PG 的 timestamptz date \u002F time \u002F year date \u002F time \u002F int2 — LocalDate \u002F LocalTime \u002F Integer PG 没有 year 类型 binary \u002F varbinary(n) \u002F blob 家族 bytea 变长 byte[] 大 BLOB 建议趁机搬去对象存储 binary(16)（存 UUID） uuid 16 → 16 字节 UUID 换成原生类型，能直接用 gen_random_uuid() bit(1) boolean — Boolean — AUTO_INCREMENT GENERATED ... AS IDENTITY — — 见本章坑 1；导数据时要 setval() 把序列推到当前最大 id 之后 ON UPDATE CURRENT_TIMESTAMP 触发器或应用侧赋值 — — PG 没有这个语法，别指望迁移工具带过来",{"id":9306,"title":4032,"titles":9307,"content":9308,"level":334},"\u002Ftutorials\u002Fpostgresql#五单表查询where排序分页与几件-pg-独门兵器",[5],"单表查询是日常 80% 的 SQL。本章先给最常用的形状（第一步到第三步），把最容易出错的几件事——NULL 比较、NULL 排序、深分页、count 的两种数法——集中放到章末的坑区。",{"id":9310,"title":4043,"titles":9311,"content":9312,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步五个关键词一条语句",[5,4032],"SELECT status, count(*) AS cnt\nFROM orders\nWHERE created_at >= '2026-10-01'\nGROUP BY status\nORDER BY cnt DESC\nLIMIT 10; WHERE 过滤行、GROUP BY 分组、HAVING 过滤组、ORDER BY 排序、LIMIT 取前 N 条——五个关键词按这个顺序写，执行顺序是 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT。判断空值用 IS NULL \u002F IS NOT NULL，不要用 = NULL（原因见坑 1）。",{"id":9314,"title":4114,"titles":9315,"content":9316,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步分页的两种写法",[5,4032],"-- 写法一：按页码跳页\nSELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n\n-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\nSELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20; 第一种能「跳转到第 N 页」，是给用户翻页用的；第二种代价恒定，适合信息流式的「下一页」。为什么深页要换写法，本章坑 3 有实测数字。",{"id":9318,"title":4150,"titles":9319,"content":9320,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步三件-pg-顺手兵器",[5,4032],"每组第一条（「每个用户最近的一笔订单」），MySQL 得上窗口函数，PG 一句话： SELECT DISTINCT ON (user_id) user_id, id, created_at\nFROM orders\nORDER BY user_id, created_at DESC; 条件聚合，比 CASE WHEN 干净： SELECT user_id,\n       count(*)                                     AS total_cnt,\n       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\nFROM orders\nGROUP BY user_id\nHAVING count(*) FILTER (WHERE status = 'paid') >= 3; 改完顺手把结果带回来（RETURNING），以及幂等写入（ON CONFLICT）： UPDATE orders SET status = 'paid', paid_at = now()\nWHERE id = 42\nRETURNING id, status, paid_at;\n\nINSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\nON CONFLICT (user_id, day)\nDO UPDATE SET pv = stats.pv + 1\nRETURNING pv; DO UPDATE 换成 DO NOTHING 就是「有就跳过」。统计累加这类需求，一条 ON CONFLICT ... DO UPDATE 抵掉应用层整套「先查后插」的代码。",{"id":9322,"title":666,"titles":9323,"content":9324,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-3",[5,4032],"1. = NULL 永远选不出东西。 SQL 里 NULL 不是值，是「未知」，任何比较碰上它结果都是「未知」（不成立）： SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\nSELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法 由此派生两件常用兵器：IS DISTINCT FROM 把 NULL 当成一个具体值来比较（a IS DISTINCT FROM b 两侧都是 NULL 时为 false，正好补上 \u003C> 的洞）；coalesce(x, 0) 给 NULL 兜底。除零保护留到第六章和 nullif 一起讲。 2. 排序时 NULL 的位置和你想的相反。 PG 的默认规则是：升序时 NULL 沉底，降序时 NULL 置顶。于是「按支付时间倒序取最近 5 单」，第一行居然是个没支付的空时间： SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\nSELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底 3. OFFSET 是给用户翻页用的，不是给深页用的。 SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 500000;  -- 要先算过前 50 万行再扔掉 它是 O(offset)：翻到越深的页越慢，第 25000 页就是全表扫的价钱。换成第二步的键集分页，前提是排序列有索引且唯一，代价恒定；代价是不支持「跳转到第 N 页」。示例 05 里 \\timing on 的两个数字对比，一眼就能看到量级差异。 4. DISTINCT ON 只取一条，且 ORDER BY 最左边必须同列。 规则说人话：DISTINCT ON (列) 指定「每组」，ORDER BY 的最左边必须是同样的列，然后每组按剩余排序条件取第一条。要每组前三条就得回窗口函数（第六章）。它是 PG 对 SQL 标准的扩展，别的库没有。 5. count(*) 和 count(列) 数的不是一回事。 前者数所有行，后者只数该列非 NULL 的行——「转化率」类指标两个 count 相除时务必想清楚用哪个。同理，FILTER 虽然是 PG 扩展语法（等价于 count(CASE WHEN status='paid' THEN 1 END)，但少敲一半字符），换库时要记得它不通用。 6. HAVING 和 WHERE 的先后位置别放反。 WHERE 过滤聚合前的行，HAVING 过滤聚合后的组。把聚合条件写进 WHERE 会直接报 aggregate functions are not allowed in WHERE。 7. ON CONFLICT 的推断列必须有唯一约束。 ON CONFLICT (user_id, day) 成立的前提是 (user_id, day) 上真有唯一索引，否则报 there is no unique or exclusion constraint matching the ON CONFLICT specification。 8. 「先查后插」在并发下会丢更新。 应用层写「查一下有没有，没有就插」，两个线程同时查到「没有」就出事了。ON CONFLICT ... DO UPDATE 把这件事交给数据库原子完成；同一类问题在事务层面还有第三种解法，第九章细算这笔账。",{"id":9326,"title":4489,"titles":9327,"content":4492,"level":334},"\u002Ftutorials\u002Fpostgresql#六常用函数字符串时间jsonb-与窗口",[5],{"id":9329,"title":4496,"titles":9330,"content":9331,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步字符串拼接兜底切分",[5,4489],"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\nSELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\nSELECT split_part(email, '@', 2);           -- 取域名\nSELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\nSELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\nSELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用 批量拼文案优先用 concat \u002F format，别用 ||——|| 遇到 NULL 会把整个结果传染成 NULL（详见坑 1）。",{"id":9333,"title":4551,"titles":9334,"content":9335,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步时间截断加减取部位",[5,4489],"SELECT date_trunc('month', created_at) AS mon, count(*)\nFROM orders\nGROUP BY mon;                              -- 月报分组键\n\nSELECT created_at + interval '30 days' AS deadline,   -- 加减\n       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\nFROM orders; 更多写法（to_char 格式化、AT TIME ZONE 换时区、now() 三兄弟的区别）见章末速查表。",{"id":9337,"title":4613,"titles":9338,"content":9339,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步从-jsonb-里掏数据",[5,4489],"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\nFROM orders\nGROUP BY channel; 要展开数组逐行处理（比如统计订单里每个标签的出现次数）： SELECT tag, count(*)\nFROM orders,\n     jsonb_array_elements(meta->'tags') AS tag\nGROUP BY tag; FROM 表, jsonb_array_elements(...) AS 别名 这个写法等价于 LATERAL（第七章），把每个 jsonb 数组元素炸成一行。",{"id":9341,"title":4674,"titles":9342,"content":9343,"level":345},"\u002Ftutorials\u002Fpostgresql#第四步窗口函数不动行数地看邻居",[5,4489],"GROUP BY 会把 N 行折叠成 1 行，窗口函数是每一行都保留、额外加一列聚合视野。两个最高频场景： -- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\nSELECT * FROM (\n  SELECT o.*,\n         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n  FROM orders o\n) t\nWHERE rn \u003C= 3;\n\n-- 环比：拿上一行的值；累计：组起点到当前行\nSELECT user_id, created_at, amount,\n       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\nFROM orders; ROWS BETWEEN ... 子句叫窗口帧，不写默认是「组起点到当前行」，累计值正是靠它。row_number() \u002F rank() \u002F dense_rank() 三个序号函数的区别见章末速查表。",{"id":9345,"title":666,"titles":9346,"content":9347,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-4",[5,4489],"1. || 遇到 NULL，整个结果变 NULL。 「无中生有」是 SQL 的规矩： SELECT 'a' || NULL;            -- NULL\nSELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串 批量拼展示文案时，要么先 coalesce，要么直接用 concat \u002F format。 2. 除法不保护就是报错。 nullif(a, b) 在 a=b 时返回 NULL，否则返回 a，它最大的用场是除零保护——第四章埋的账在这里还： SELECT 100 \u002F 0;                          -- ERROR: division by zero\nSELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\nSELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算 3. 三个长度函数对中文的结果不一样。 length('中文') 是 2（字符），octet_length('中文') 是 6（UTF-8 字节）——限长、算存储时想清楚自己要的是哪个。 4. 窗口函数不能出现在 WHERE 里。 必须包一层子查询再过滤，直接写 WHERE rn = 1 会收到 ERROR: window functions are not allowed in WHERE——PG 没有 DuckDB\u002FSnowflake 那种 QUALIFY 短路，子查询包装就是标准答案。 5. now() 不等于「此刻」。 函数三兄弟：now() = transaction_timestamp()（事务内恒定）、statement_timestamp()（每条语句刷新）、clock_timestamp()（真实钟，同一行里两次调用都不同）。做「同一事务内 created 和 updated 相同」要 now()，做监控计时才用 clock_timestamp()。 6. jsonb_set() 的第三个参数必须是 jsonb 字面量。 jsonb_set(meta, '{vip}', '\"gold\"'::jsonb) 里的引号不是装饰——少写 ::jsonb 或写成裸字符串，会得到一个带多余引号的值或直接报类型错。 7. 包含查询 @> 不建索引就是全表扫。 第四章提过，jsonb 的 @> 要走 GIN 索引才划算（第八章讲两种 opclass 的取舍），否则每次包含查询都是顺序扫整列 jsonb。",{"id":9349,"title":4940,"titles":9350,"content":9351,"level":345},"\u002Ftutorials\u002Fpostgresql#附时间与序号函数速查",[5,4489],"需求 写法 结果（以 2026-10-10 09:30:15+08 为例） 按月截断 date_trunc('month', ts) 2026-10-01 00:00:00+08——月报、周报的分组键 加减 ts + interval '30 days'、ts - interval '2 hours' interval 直接参与运算 取部位 extract(epoch FROM ts) Unix 秒，跟前端\u002F缓存对时间的通用语言 两个时刻差 age(ts2, ts1) 1 year 2 mons 3 days 这种人话 格式化 to_char(ts, 'YYYY-MM-DD HH24:MI') 输出展示用；入参用 to_timestamp(s, 'YYYY-MM-DD') 换时区 ts AT TIME ZONE 'UTC' timestamptz 与 timestamp 互转 序号函数 并列时 结果 row_number() 仍递增 1, 2, 3, 4——「严格名次 + 去重取一」 rank() 并列同名次，跳号 1, 2, 2, 4 dense_rank() 并列同名次，不跳号 1, 2, 2, 3",{"id":9353,"title":5104,"titles":9354,"content":9355,"level":334},"\u002Ftutorials\u002Fpostgresql#七多表关系join-家族exists-与外键的连锁行为",[5],"模型定型：users 1:N orders 1:N order_items N:1 products；商品与标签多对多走中间表 product_tags。",{"id":9357,"title":5125,"titles":9358,"content":9359,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步join-家族",[5,5104],"写法 语义 记忆点 INNER JOIN（默认 JOIN） 只留两边都能配上对的 配不上对的行整体消失 LEFT [OUTER] JOIN 左表全保留，右边配不上补 NULL 「主表 + 带不带都行」 RIGHT \u002F FULL JOIN 对称地保留右表 \u002F 两边都保留 FULL 在补数据、对账场景常见 CROSS JOIN 笛卡尔积 几乎只出现在生成日历、排列组合的场景 最常用的就是前两种，一条语句记住形状： SELECT u.name, count(o.id) AS order_cnt\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nGROUP BY u.name;",{"id":9361,"title":5226,"titles":9362,"content":9363,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步exists-与-lateral",[5,5104],"「买了东西的用户」两种写法，优化器基本会改写成同一个计划，选哪个看可读性： SELECT name FROM users u WHERE EXISTS (\n  SELECT 1 FROM orders o WHERE o.user_id = u.id\n); 「每组取 N 条」还有个比窗口函数更直接的 LATERAL：右边的子查询可以引用左边表的列，每行参数化执行一次： SELECT u.name, o.id, o.amount\nFROM users u\nLEFT JOIN LATERAL (\n  SELECT * FROM orders o\n  WHERE o.user_id = u.id\n  ORDER BY created_at DESC LIMIT 3\n) o ON true;",{"id":9365,"title":5297,"titles":9366,"content":9367,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步外键动作的现场",[5,5104],"第四章给过 CASCADE \u002F SET NULL \u002F RESTRICT 的选型表，这里看现场。给 orders.user_id 配 ON DELETE CASCADE： shop=# DELETE FROM users WHERE id = 7;\nDELETE 1\nshop=# SELECT count(*) FROM orders WHERE user_id = 7;\n count\n-------\n     0\n(1 row)      -- 订单整整齐齐跟着没了，删库跑门槛都没有 如果当初配的是 SET NULL，同一组操作后 orders 里 user_id=7 的行还在、外键变成 NULL——「关联断了但数据还在」。两个动作都对，取决于业务上订单是「用户的附属品」还是「独立凭证」。",{"id":9369,"title":666,"titles":9370,"content":9371,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-5",[5,5104],"1. LEFT JOIN 的过滤条件放错地方，语义直接退化。 两个查询只差一个从句： -- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n\n-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid'; 规则一句话：对右表（被 JOIN 表）的过滤放 ON，对左表主数据的过滤放 WHERE。示例 07 里有这两个查询并排的输出对比。 2. NOT IN 遇上子查询里的 NULL 会全军覆没。 -- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\nSELECT name FROM products\nWHERE id NOT IN (SELECT product_id FROM product_tags);\n\n-- NOT EXISTS 不受影响，语义正确\nSELECT name FROM products p\nWHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id); 原因是 NOT IN 展开成「与所有元素都不等」，而任何值与 NULL 比较都是「未知」，整个条件永远不成立。否定语义一律写 NOT EXISTS，这条规则能免掉一类极难排查的线上问题（数据一变 NULL，查询悄悄变空）。 3. PG 不给外键自动建索引，删除时会现场爆发。 第四章埋的雷在这里显形：EXPLAIN ANALYZE DELETE FROM users WHERE id = 7 的输出里有一行 Trigger for constraint orders_user_id_fkey: time=143.2 ms——父表按主键索引秒删，但 PG 检查引用约束时要顺序扫一遍 orders 全表。给 orders(user_id) 建上索引再删，同一个 DELETE 的触发器耗时掉到 0.1ms 量级。建外键的下一行就建索引，让这两行在迁移脚本里永远是邻居。 4. LATERAL 子查询后面必须跟 ON 条件，没有条件就写 ON true，直接省掉会报语法错误。它比窗口函数直观，但每行都要执行一次子查询，驱动集很大时先看执行计划（第八章）。",{"id":9373,"title":5498,"titles":9374,"content":5501,"level":334},"\u002Ftutorials\u002Fpostgresql#八索引b-tree-走得快也要养得起",[5],{"id":9376,"title":5505,"titles":9377,"content":9378,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步建一个索引确认它生效",[5,5498],"CREATE INDEX idx_orders_order_no ON orders (order_no);\n\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123'; 计划里出现 Index Scan using idx_orders_order_no 就说明走上了；还是 Seq Scan 就是没走上（列上有隐式转换、条件写成函数包裹、或者命中行太多优化器主动放弃）。EXPLAIN 必须加 ANALYZE 才会真的执行并给出 actual 数字，详见坑 1。",{"id":9380,"title":5546,"titles":9381,"content":9382,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步复合索引的列序",[5,5498],"CREATE INDEX idx_orders_user_created ON orders (user_id, created_at); 最左前缀规则决定它能服务谁：WHERE user_id = 42 能用（最左列独立命中）；WHERE user_id = 42 AND created_at > now() - interval '7 days' 能用得很好；WHERE created_at > ... 单独用不能——最左列缺席，索引整体作废。通用配方：等值条件列在前，范围条件列在后；再把 ORDER BY 的列对齐进去，WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5 可以完全在索引里倒着走五步就停，计划里连 Sort 节点都没有（示例 08 有这个计划的对照）。",{"id":9384,"title":5589,"titles":9385,"content":9386,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步三种救场索引",[5,5498],"覆盖索引（INCLUDE）——查询需要的列全在索引里，就不用回表。PG 11 起 CREATE INDEX ... INCLUDE (extra_col) 把额外列挂进索引叶子页（不参与排序）： CREATE INDEX idx_orders_user_cover ON orders (user_id) INCLUDE (status, amount); 部分索引——只索引满足条件的行，待办列表场景极爽，百万订单里 pending 状态的就几千行： CREATE INDEX idx_orders_pending ON orders (created_at)\nWHERE status = 'pending'; 索引体积是全量索引的零头，写入时多数行根本不进索引，代价几乎为零。 表达式索引——第四章埋过的雷在这里拆。WHERE lower(email) = 'ada@example.com' 用普通 btree 索引是命中不了的，建表达式索引： CREATE INDEX idx_users_email_lc ON users (lower(email));",{"id":9388,"title":666,"titles":9389,"content":9390,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-6",[5,5498],"1. 不加 ANALYZE 的 EXPLAIN 是「打算」，不是「结果」。 不加只显示优化器计划怎么执行，加上才真的执行并显示 actual 行数与耗时。拿计划里的估算行数去汇报，等于没跑。 2. 估算行数和实际行数差一个数量级，先怀疑统计信息。 读输出抓三样：rows=（估算行数）与 actual 行数差多少——差一个数量级说明统计信息过期，跑一把 ANALYZE orders 让 autovacuum 重新采样；Buffers: shared read=（从磁盘读了多少页）——hit 是缓存命中，read 是真实 IO；Execution Time——唯一能写进汇报的数字。 3. 表达式索引是「文本级一致」才匹配。 建索引用 lower(email)，查询就必须写一模一样的 lower(email)，写成 lower(email, 'C') 都会对不上——这也是全文搜索第十三章要用「同一表达式」的原因。另一种解法更省事：应用侧保证存入时已小写，查询写 WHERE email = lower('Ada@Example.com')。 4. %关键词% 这种中缀 LIKE 用不上 btree。 解法是 pg_trgm 扩展 + GIN 索引： CREATE EXTENSION pg_trgm;\nCREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops); 它把字符串拆成三元组做倒排，ILIKE '%键盘%' 也能走索引——很多「搜索框」需求到这一步就够了，撑不到需要第十三章全文搜索的程度。 5. 索引不是免费的：写入要付利息，膨胀要还本金。 每多一个索引，每次 INSERT\u002FUPDATE 都多一次维护写入；UPDATE 只要碰了任何一个被索引的列，新版本就要在所有相关索引里各插一份（旧版本变死元组，第九章细说）。所以「先建 20 个索引再倒数据」不如反过来：大批量导入前删掉非约束索引，导完 ANALYZE 再重建，快一个数量级很正常。 6. 表和索引都会膨胀，要会看数字。 PG 里有 pgstattuple 扩展可以看表和索引的死元组比例（膨胀率），阈值到 20%~30% 就值得处理：常规 VACUUM 只回收不还磁盘，VACUUM FULL 还磁盘但拿全表排他锁，线上用 pg_repack 平替。这一账记进第十六章核对清单。 7. Index Only Scan 吃不吃得到，取决于 vacuum。 覆盖索引配合「visibility map 足够干净」才会出现 Index Only Scan，连堆表都不碰；长期不 vacuum 的表会退化成带 heap fetch 的普通扫描（第九章 MVCC 讲为什么）。",{"id":9392,"title":5812,"titles":9393,"content":9394,"level":345},"\u002Ftutorials\u002Fpostgresql#附explain-节点与索引类型速查",[5,5498],"节点 意思 什么时候出现 Seq Scan 全表顺序扫 无索引可用，或优化器算出全扫更便宜（命中行太多） Index Scan 沿索引走，边走边回表 命中行少 Bitmap Heap Scan + Bitmap Index Scan 先在索引里收集一批行的位图，再成批回表 命中行中等量级（几百到几万） 类型 擅长 典型场景 btree（默认） =、\u003C、>、BETWEEN、前缀 LIKE 'abc%' 99% 的索引 hash 只有 = PG 10 起才可靠，等值超长键可以考虑 gin 倒排索引，「包含」类查询 jsonb @>、数组 &&、全文搜索（第十三章） gist 通用搜索树，「相交\u002F相邻」类 地理（第十四章）、范围类型、排他约束（防止区间重叠） spgist 空间分区树 特殊数据分布，如 IP 前缀 brin 只存每个块区间的 min\u002Fmax 超大且按写入顺序查询的表（日志、流水），体积极小",{"id":9396,"title":5993,"titles":9397,"content":9398,"level":334},"\u002Ftutorials\u002Fpostgresql#九事务与锁mvcc隔离级别与排队事故",[5],"PG 的并发控制全建立在 MVCC（多版本并发控制）上：写不阻塞读，读不阻塞写。第一遍只需要掌握第一步那三种写法（不丢更新的套路），隔离级别的差异、锁排队和死锁都收在章末。",{"id":9400,"title":6004,"titles":9401,"content":9402,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步事务的三种安全写法",[5,5993],"「SELECT 检查库存 → 应用判断 → UPDATE 扣减」这套流程在并发下必然丢更新：两个事务同时读到库存 1、都判断通过、都写成功，库存变 -1。三种修法按保守到乐观排： -- ① 悲观：先把行锁住再查（行内排队）\nBEGIN;\nSELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\nUPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\nCOMMIT;\n\n-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\nUPDATE products SET stock = stock - 1\nWHERE id = 1 AND stock >= 1;\n-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n\n-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\nINSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\nON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1; 日常优先 ②——它不持锁等待、天然并发安全，业务分支也清晰；热点行抢购场景才需要 ① 或引入队列削峰。",{"id":9404,"title":6087,"titles":9405,"content":9406,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步知道读不阻塞写就够了",[5,5993],"每行都有几个系统隐藏列：xmin（创建它的事务号）、xmax（删除\u002F失效它的事务号）。UPDATE 也不存在原地修改——它是「插一行新版本 + 给旧版本打墓碑」： SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\nUPDATE orders SET status = 'paid' WHERE id = 42;\nSELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid 第二次查询看到的 xmin 已经是新事务号——那是一个新元组。旧版本（xmin=258312）被标上 xmax=258421，成为死元组；但只要还有别的事务的快照能看到它，它就必须留着。这一条推出本章所有的怪现象，也推出第九章坑 1 那条雪崩。",{"id":9408,"title":6132,"titles":9409,"content":9410,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步多-worker-抢任务用-skip-locked",[5,5993],"SELECT ... FOR UPDATE 拿行级排他锁，两个修饰词改变冲突时的行为：NOWAIT 拿不到立即报错；SKIP LOCKED 拿不到就跳过去看下一行。后者是「多 worker 抢任务」的标准解： UPDATE jobs SET status = 'running', worker = 'w1'\nWHERE id = (\n  SELECT id FROM jobs\n  WHERE status = 'pending'\n  ORDER BY id\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nRETURNING id, payload; 两个 worker 同时执行：w1 锁住 101 号，w2 在子查询里发现 101 被锁、自动跳到 102——互不等待、永不撞车。拿消息队列（Kafka\u002FRedis）之前，很多轻量任务场景到 SKIP LOCKED 就够用了，又少养一个组件。",{"id":9412,"title":666,"titles":9413,"content":9414,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-7",[5,5993],"1. 一个「idle in transaction」能让全库膨胀。 承第二步的推论：只要还有事务的快照能看到旧版本，死元组就不能回收。所以一个挂着不提交的长事务，会让 vacuum 无法回收全库的死元组，表和索引一路膨胀，全库变慢——这是 PG 独有的雪崩形态，第十六章清单里「监控 idle in transaction」专门冲它去的。 2. 默认隔离级别 READ COMMITTED 下，事务内两次 SELECT 结果可以不同。 -- 会话 A                                    -- 会话 B\nBEGIN;\nSELECT count(*) FROM orders;  -- 1000\n                                             BEGIN;\n                                             UPDATE orders SET status='paid' WHERE id\u003C5;\n                                             COMMIT;   -- B 已提交\nSELECT count(*) FROM orders;  -- 还是 1000？不，这次是 1005\nCOMMIT; A 的两条 SELECT 之间没有任何写入，但第二次看到了 B 已提交的数据——因为 RC 下每条语句都换新快照。从 MySQL（默认 RR）迁过来、依赖「事务内复读」的代码要改写。 3. 升到 RR \u002F SERIALIZABLE，就要写重试循环。 InnoDB 的 RR 靠间隙锁挡幻读，PG 的 RR 靠快照——读不加锁，写冲突靠报错重试：并发改同一行时冲突方会报 could not serialize access due to concurrent update，SERIALIZABLE 还会预测危险结构并报 serial failure。应用必须捕获 40001 SQLSTATE 重来一次，否则就是偶发 500。 4. ALTER TABLE 能把全站堵死。 行锁之上还有表级锁，八级里只需要记住两端：普通 SELECT\u002FUPDATE 拿的都是弱锁，互不冲突；而 ALTER TABLE 要拿 ACCESS EXCLUSIVE——与一切读写互斥。事故链通常是这么发生的： 14:00:03  会话 A：一个长查询\u002F未提交事务还挂着（拿住弱锁）\n14:00:05  DBA：ALTER TABLE orders ADD COLUMN memo text;  → 排队等 A\n14:00:06  之后的所有 SELECT ... FROM orders → 全部排在 DDL 后面（FIFO 队列）\n14:00:30  全站接口超时，报警炸了 锁队列是先进先出的，DDL 一旦排进去，后面所有请求不管读写都得等它。防御两件套：会话级先 SET lock_timeout = '3s'; 再执行 DDL，拿不到锁立刻退出（而不是堵门）；大表建索引一律 CREATE INDEX CONCURRENTLY——不挡写入（代价：慢一些，失败会留下 INVALID 索引需要 drop 重来）。 5. 死锁不靠调参解决，靠统一加锁顺序。 A 锁 42 再要 43，B 锁 43 再要 42，互相等到天荒地老——PG 的死锁检测器（默认 deadlock_timeout 1 秒）会杀掉其一： ERROR:  deadlock detected\nDETAIL:  Process 2847 waits for ShareLock on transaction 258330;\n         blocked by transaction 258325.\n         Process 2831 waits for ShareLock on transaction 258325;\n         blocked by transaction 258330.\nHINT:  ... server log 有两个进程正在执行的 SQL 解法不是调参，是改业务加锁顺序：所有代码路径都按固定顺序（比如按主键升序）加锁，环路就消失了。 6. 外键列没索引，删除会放大成锁等待。 第七章那条「PG 不给外键自动建索引」的坑，在事务里更致命：删父表一行要顺序扫子表检查引用，扫描期间两边都持锁，直接变成排队事故。",{"id":9416,"title":6323,"titles":9417,"content":9418,"level":345},"\u002Ftutorials\u002Fpostgresql#附隔离级别速查",[5,5993],"级别 脏读 不可重复读 幻读 PG 的实现与脾气 READ UNCOMMITTED 不存在（PG 直接当 RC 处理） 有 有 PG 不允许脏读，设了也白设 READ COMMITTED（默认） 无 有 有 每条语句拿一个新快照，事务里两次 SELECT 结果可以不同 REPEATABLE READ 无 无 无（快照隔离） 事务开始定快照；并发改同一行时冲突方报 could not serialize access due to concurrent update，需重试 SERIALIZABLE 无 无 无 SSI 算法预测危险结构，冲突报 serial failure，应用必须带重试",{"id":9420,"title":6416,"titles":9421,"content":303,"level":334},"\u002Ftutorials\u002Fpostgresql#十视图与-cte把长查询变成复用单元",[5],{"id":9423,"title":6420,"titles":9424,"content":9425,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步视图给长查询起个名字",[5,6416],"CREATE VIEW v_paid_orders AS\nSELECT id, user_id, amount, paid_at\nFROM orders\nWHERE status = 'paid'; 视图的两个常规用途：给前端\u002FBI 一个稳定的「表样子」，和给低权限账号一个只露部分列的口子（配合 GRANT，第十一章）。PG 的特色是简单视图自动可更新——只要视图是单表直出（没有 GROUP BY\u002F聚合\u002FDISTINCT\u002F窗口\u002F集合操作），对它的 INSERT\u002FUPDATE\u002FDELETE 会自动改到原表上： UPDATE v_paid_orders SET amount = 99.00 WHERE id = 42;   -- 直接生效 再加 WITH CHECK OPTION，连「改完就掉出视图可见范围」的行都会被拒绝，防止通过视图越权改数据。",{"id":9427,"title":6471,"titles":9428,"content":9429,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步物化视图昂贵报表落盘",[5,6416],"普通视图每次查询都现算；物化视图把结果落盘： CREATE MATERIALIZED VIEW mv_daily_sales AS\nSELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\nFROM orders WHERE status = 'paid' GROUP BY 1;\n\nREFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\nCREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\nREFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读 定个任务凌晨全量刷，白天报表直接查物化视图，一笔流水一笔账。CONCURRENTLY 的两个前提见坑 2。",{"id":9431,"title":6527,"titles":9432,"content":9433,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步cte拆步骤与遍历树",[5,6416],"「起名字、拆步骤」，多个 CTE 依次引用： WITH recent AS (\n  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n),\nbig AS (\n  SELECT * FROM recent WHERE amount > 1000\n)\nSELECT count(*) FROM big; 树形结构（部门、评论楼层、物料清单）的遍历神器是递归 CTE： WITH RECURSIVE sub AS (\n  -- 种子：起点\n  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n  UNION ALL\n  -- 递归：从上一轮结果往下走一层\n  SELECT d.id, d.name, d.parent_id, s.depth + 1\n  FROM depts d JOIN sub s ON d.parent_id = s.id\n)\nSELECT * FROM sub ORDER BY depth; id |  name   | parent_id | depth\n----+---------+-----------+-------\n  2 | 技术部  |         1 |     1\n  5 | 后端组  |         2 |     2\n  6 | 前端组  |         2 |     2\n  9 | 平台小队 |         5 |     3\n(4 rows) 还有一个冷门但极顺手的用法：数据修改 CTE，一条语句完成「删 A 表搬进 B 表」，还天然原子： WITH moved AS (\n  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n  RETURNING *\n)\nINSERT INTO jobs_archive SELECT * FROM moved;",{"id":9435,"title":666,"titles":9436,"content":9437,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-8",[5,6416],"1. 视图不物化，就是每次都现算。 普通视图只是一段被起名的 SQL，SELECT * FROM v_paid_orders 展开后该扫多少行还是多少行；报表慢的时候别怪视图，要么加索引，要么按第二步物化。 2. REFRESH MATERIALIZED VIEW 全量刷期间查询被阻塞，想不阻塞读必须用 CONCURRENTLY，而它的前提是物化视图上有一个唯一索引（上面示例里那行 CREATE UNIQUE INDEX 就是前置条件）。另外 CONCURRENTLY 只更新变化的行，代价是更依赖 diff 键，唯一索引选错列会刷得很慢。 3. 「CTE 会慢」是老版本的结论。 PG 12 之前 CTE 是优化围栏（外面怎么查都不下推进去），PG 12 起默认内联优化，想强制物化要写 AS MATERIALIZED。所以看资料要看版本：新集群里把子查询改写成 CTE 通常只是可读性收益，不背性能锅。 4. 递归 CTE 忘写终止条件会无限循环。 UNION 会对结果去重、还能兜住意外死循环；UNION ALL 不去重更快，但忘写递归终止条件（JOIN 不再产新行）就会无限循环——调试期先加 LIMIT 100 再跑，是保命习惯。数据里存在环（A 的父是 B、B 的父是 A）时同样会转不出来，PG 14 起可用 CYCLE ... SET 子句标记环路行。 5. 数据修改 CTE 的「原子」是语句级的。 上面那段「删 + 搬」在同一条语句里，但 CTE 内的可见性遵循所在事务的隔离级别：READ COMMITTED 下，moved 里看不到并发刚提交的其它行变更，别把它当成「读己之写」的万能方案。",{"id":9439,"title":6748,"titles":9440,"content":6751,"level":334},"\u002Ftutorials\u002Fpostgresql#十一用户和角色权限落到位",[5],{"id":9442,"title":6755,"titles":9443,"content":9444,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步权限给组人挂组",[5,6748],"PG 里只有一种对象叫 role：「用户」就是带 LOGIN 属性能连库的 role，「用户组」就是不带 LOGIN 的 role。官方文档的说法是「一个 role 可以当用户、也可以当用户组，取决于你怎么配它」，而主流做法就是把权限授给组，再把需要的人加进组——而不是逐个用户去 GRANT： -- 1) 建一个「只读组」：它自己连不了库，只是权限容器\nCREATE ROLE read_only NOLOGIN;\nGRANT USAGE ON SCHEMA business TO read_only;\nGRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\nALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n\n-- 2) 建用户并挂进组\nCREATE ROLE zhangsan LOGIN PASSWORD '...';\nGRANT read_only TO zhangsan;\n\n-- 3) 以后新同事也只看数据？一行就够\nCREATE ROLE lisi LOGIN PASSWORD '...';\nGRANT read_only TO lisi; 这样管的好处很实在：权限的定义只有一份（在组上），进人出人只是成员关系的变化；哪天要收权，REVOKE ... FROM read_only 一次生效，不用去回忆「当初到底给哪几个人单独授过」；审计时 \\du 或 information_schema.role_table_grants 一眼能看清「谁能看什么」。CREATE USER 只是 CREATE ROLE ... LOGIN 的别名，见到别慌。 成员默认会自动继承组的权限（role 属性 INHERIT 是默认值），所以 zhangsan 挂进 read_only 之后直接 SELECT 就能用，不需要 SET ROLE。反过来说，如果建用户时写了 NOINHERIT，成员关系还在、权限却拿不到，得显式 SET ROLE read_only; 才生效——见本章坑 3。",{"id":9446,"title":6900,"titles":9447,"content":9448,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步应用账号的最小权限模板",[5,6748],"应用账号不该是 owner、更不该是超级用户，照抄这套（它正是第一步那套思路用在服务账号上：应用账号只拿增删改查，建表归迁移账号）： -- 1. 收口：public schema 不再放业务表\nCREATE SCHEMA business AUTHORIZATION migrator;\nREVOKE CREATE ON SCHEMA public FROM PUBLIC;\n\n-- 2. 应用账号：只用不建\nCREATE ROLE app LOGIN PASSWORD '...';\nALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\nGRANT USAGE ON SCHEMA business TO app;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT USAGE ON SEQUENCES TO app; ALTER DEFAULT PRIVILEGES 解决的是「今天授了 ALL TABLES，明天新建的表又没权限」的补漏问题——它登记的是「以后谁建的表自动授给谁」。 服务账号要不要也套一层组？两种都算主流：讲究一点的做法是再建一个 rw_app 组拿权限、app 只做 LOGIN 并挂进组，好处是将来换连接账号（改密码、换应用）不用重授一遍权；小团队直接授给 app 也很常见。选定一种写进规范，别一半走组、一半直接授。",{"id":9450,"title":7006,"titles":9451,"content":9452,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步多租户再加一道-rls",[5,6748],"「每个租户只能看见自己的行」如果只靠应用层 WHERE，一个忘写就跨租户泄漏。PG 的 RLS 把这条规则焊在表上： ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation ON orders\n  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint); 应用连接后执行 SET app.tenant_id = '42';，之后这张表上所有查询自动带上租户过滤（USING），所有写入自动校验租户归属（WITH CHECK）。",{"id":9454,"title":666,"titles":9455,"content":9456,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-9",[5,6748],"1. 表 owner 和超级用户默认绕过 RLS。 开了 ENABLE ROW LEVEL SECURITY 却用 owner 账号连，策略一条都不生效——测试时「明明配了策略怎么还能看见别人的数据」多半是这个。想连自己也拦，要 ALTER TABLE orders FORCE ROW LEVEL SECURITY。 2. 连接池 + RLS = 串数据风险。 SET app.tenant_id 的作用域是会话级，连接归还进池子时值还在。下个租户复用这条连接，就看到上一个租户的数据。必须归还前 RESET app.tenant_id，或者用事务级的 SET LOCAL（事务结束自动失效）。 3. 组权限「给了却没生效」，先看成员是不是 NOINHERIT。 权限授给了组、人也挂进了组，查询仍报 permission denied for table orders——先用 \\du 看那个用户的属性列。成员角色默认带 INHERIT，会自动继承组权限；但如果建号时写了 NOINHERIT，成员关系只等于「有资格切换」，必须显式 SET ROLE read_only; 才拿得到权限。新版本还能在单次授予上单独控制：GRANT read_only TO zhangsan WITH INHERIT FALSE;。团队里规范只选一个：要么一路用默认的 INHERIT，要么写明「必须 SET ROLE」，两种混用最难排查。顺带一条：组角色是 NOLOGIN 的，拿 read_only 直接连库必然失败，那不是权限没配好。 4. GRANT ... ON ALL TABLES 只管当下。 它不会覆盖以后新建的表，忘了 ALTER DEFAULT PRIVILEGES 就会出现「昨天还好好的，今天上线新表应用就报 permission denied for table ...」。另外它登记的是「谁建的表」，用 A 账号建的表、给 B 账号登记过默认权限，也不会自动生效。 5. 表 owner 天生全权，GRANT 收不走。 owner 随时能 DROP 自己的表，任何授权都限制不了它。所以生产环境里「迁移专用账号」当 owner、应用账号只拿增删改查，两层分开。 6. trust 出现在公网段等于开源。 认证方式一行写错，等于没密码。trust 只允许出现在本机测试；对外一律 scram-sha-256。 7. 改完 pg_hba.conf 不重启也要 reload。 SELECT pg_reload_conf(); 就够（连接数、内存类参数才要重启）。忘了 reload 会白排查半小时。 8. 老 md5 密码要重设一次才升级。 PG 14 起默认加密方式是 SCRAM，但库里存量的 md5 密码不会自动变，得用 \\password 重设一次。两端不一致时报的是 password authentication failed 或 server does not support SCRAM，看不出根因。 9. 排查「怎么连都失败」先看 pg_hba.conf。 规则从上往下第一条命中即生效，尤其 docker exec 走的本机 socket 默认是 trust，而 TCP 进来要密码，两条路行为不同属正常（第二章坑 7 的另一面）。",{"id":9458,"title":7199,"titles":9459,"content":9460,"level":345},"\u002Ftutorials\u002Fpostgresql#附角色与权限排查速查",[5,6748],"「到底谁有什么权限」这类问题，靠这几条命令一次看清： 想知道什么 用什么 有哪些角色、能不能登录、属于哪个组 \\du（看 Attributes 与 Member of 两列） 角色属性的机器可读版 SELECT rolname, rolcanlogin, rolinherit, rolcreaterole FROM pg_roles; 成员关系明细（谁挂进了哪个组） SELECT r.rolname AS member, g.rolname AS grp FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member JOIN pg_roles g ON g.oid=m.roleid; 某张表授给了谁 \\d orders 的 Access privileges，或 SELECT * FROM information_schema.role_table_grants WHERE table_name='orders'; 某人是不是在某组里 SELECT pg_has_role('zhangsan', 'read_only', 'member'); 某人能不能查这张表 SELECT has_table_privilege('zhangsan', 'business.orders', 'SELECT'); 临时以组的身份执行 SET ROLE read_only; … RESET ROLE;（NOINHERIT 成员必须走这一步）",{"id":9462,"title":7296,"titles":9463,"content":9464,"level":345},"\u002Ftutorials\u002Fpostgresql#附pg_hba-认证方式速查",[5,6748],"连接认证由数据目录里的 pg_hba.conf 决定，五行一列（类型 数据库 用户 地址 认证方式），从上往下第一条命中即生效： 方式 行为 评价 trust 免密直连 只允许出现在本机测试，出现在公网段等于开源 scram-sha-256 密码走 SCRAM 挑战 推荐。PG 14 起默认加密方式即它；旧 md5 密码用 \\password 重设一次自动升级 peer 校验本机 OS 用户同名 本机管理入口的舒适区 reject 显式拒绝 垫在规则末尾封禁网段",{"id":9466,"title":7380,"titles":9467,"content":9468,"level":334},"\u002Ftutorials\u002Fpostgresql#十二备份与恢复pg_dump物理备份与-pitr",[5],"先立总原则：没恢复过的备份不算备份。本章的每个命令，都值得在实验容器里跑一遍。第一遍只需要会第一步的逻辑备份与恢复。",{"id":9470,"title":7391,"titles":9471,"content":9472,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步一次完整的逻辑备份与恢复",[5,7380],"pg_dump -U postgres -Fc -f shop-20261010.dump shop    # -Fc 自定义压缩格式\nls -lh shop-20261010.dump                              # 214M\npg_restore -l shop-20261010.dump | head -12            # 先看目录再动手\npg_restore -j 4 --clean --if-exists -d shop shop-20261010.dump -Fc 自定义格式支持并行恢复（-j 4）、选择性恢复（--table=orders 只捞一张表）、压缩，比裸 SQL 灵活得多。数据搬家用 \\copy（客户端侧 CSV，权限跟随当前用户）： \\copy products TO 'products.csv' WITH (FORMAT csv, HEADER true)\n\\copy products FROM 'products_bak.csv' WITH (FORMAT csv, HEADER true) 大批量导入记得第八章的账：先删非约束索引、导完 ANALYZE 再重建。",{"id":9474,"title":7514,"titles":9475,"content":9476,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步wal-归档与-pitr-的最小配置",[5,7380],"WAL（预写日志）是 PG 的 redo log+binlog 合体：每个数据页修改前先记日志，崩溃后靠它回放。归档 WAL 就等于把「所有变更流水」存下来，配上一次完整的物理备份（basebackup），理论上可以恢复到任意时间点（PITR，即 Point-In-Time Recovery）： archive_mode = on\narchive_command = 'cp %p \u002Fvar\u002Flib\u002Fpostgresql\u002Farchive\u002F%f'   # 每个 WAL 段写完拷走\n\npg_basebackup -D \u002Fbackup\u002Fbase-20261010 -Fp -Xs -R\n# -Xs 流式同时拉 WAL，-R 生成恢复配置，顺带也是搭流复制的第一步 误操作恢复（比如 14:32 有人 DROP TABLE）的标准动作：起一台恢复机 → 拷入最近的 basebackup → 配置 restore_command 指向归档目录 + recovery_target_time = '14:31:00'（事故前一分钟）→ 起库回放到目标点 → 从影子里把数据捞出来。",{"id":9478,"title":666,"titles":9479,"content":9480,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-10",[5,7380],"1. pg_dump 不导角色、密码、表空间。 这些是全局对象，要用 pg_dumpall --globals-only 单独备份。漏了它的典型症状：恢复出来的库「没人连得上」，或者应用账号权限全丢。 2. pg_dump 的一致性来自单一事务快照，但它只导「当前逻辑内容」。 不是某个时刻的完整物理状态——想回到过去某分钟，只能靠第二步的 WAL 归档 + PITR，别指望一份昨天的 dump。 3. PG 没有 MySQL binlog 那种可直接反向执行的回滚日志。 逻辑误操作（UPDATE 忘带 WHERE）的恢复就是「PITR 拉影子库捞数」，所以归档的连续性必须有监控：archive_command 失败时未归档的 WAL 会一直堆积，撑满磁盘之后数据库连日志都写不下去，直接停摆。归档是否落后，看 pg_stat_archiver 里的失败计数和最近成功时间。 4. PITR 期间实例不可用，且恢复目标点要保守。 recovery_target_time 设在事故前一分钟，还得确认那分钟的 WAL 段真的归档成功了（pg_stat_archiver 或归档目录里的文件名连续性）。恢复完的库是影子库，捞数出来再回写，别直接把它当生产库上线。 5. 恢复演练要真的演练。 本章末尾那张策略表里每一行都得有对应的演练记录，否则「有备份」只是心理安慰——这也是第十六章清单里「演练」一栏存在的原因。",{"id":9482,"title":7614,"titles":9483,"content":9484,"level":345},"\u002Ftutorials\u002Fpostgresql#附备份策略组合表",[5,7380],"灾难场景 靠什么救 恢复粒度 误删某张表 逻辑备份里的该表 \u002F PITR 分钟级（PITR 要起影子库） 误删整个库 \u002F 逻辑坏数据 PITR 分钟级时间点 机器磁盘全毁 异地归档 + basebackup 小时级 大版本升级 \u002F 换云厂商 pg_dump 逻辑迁移 与版本解耦，最通用 应用层误 UPDATE（无备份演练） PITR 影子库捞回 这也是为什么清单里必须有「演练」一栏 一个够用的小规模组合：每日一次 pg_dump（逻辑，防呆）+ 每日一次 pg_basebackup + 持续 WAL 归档（防灾难），归档目录本身再异地同步一份。再往上规模就该看 pgBackRest \u002F WAL-G 这类带增量与去重的工具了。",{"id":9486,"title":7703,"titles":9487,"content":9488,"level":334},"\u002Ftutorials\u002Fpostgresql#十三扩展-全文搜索tsvectorgin-与相关度",[5],"「搜索框里输个词，把商品\u002F文章按相关度排出来」——第一反应写 LIKE '%词%' 的话，三个问题全撞上：%前缀% 用不了索引必然全表扫；keyboard 搜不到 Keyboards（没有词干归一）；结果没有相关度可言，只能按时间凑合。PG 的内置全文搜索把这三件事一次解决，而且不需要装任何第三方扩展——英文场景开箱即用。",{"id":9490,"title":7726,"titles":9491,"content":9492,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步一条-sql-做出按相关度排序",[5,7703],"SELECT id, title\nFROM articles\nWHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\nORDER BY ts_rank(to_tsvector('english', body),\n                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\nLIMIT 10; 三个零件：to_tsvector 把文本切成「词 + 位置」，websearch_to_tsquery 把用户在搜索框里打的那句话翻译成查询（\"引号短语\"、-排除、OR 都认识），@@ 判断是否命中，ts_rank 给相关度打分。先看一眼 tsvector 长什么样，理解会快很多： shop=# SELECT to_tsvector('english', 'Learning PostgreSQL is fun');\n            to_tsvector\n------------------------------------\n 'fun':4 'learn':1 'postgresql':2\n(1 row) 一步发生了什么：按空白和标点切词、去掉停用词（is\u002Fa\u002Fthe 这类）、词干归一（learning → learn）。tsvector 就是「这个词 + 出现在第几个位置」的有序表，体积小、可比较、可索引。",{"id":9494,"title":7802,"titles":9495,"content":9496,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步让它走索引",[5,7703],"上面的写法每行都要现算 to_tsvector，全表扫。两种索引化姿势，推荐后者： -- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\nCREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n\n-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\nALTER TABLE articles\n  ADD COLUMN tsv tsvector\n  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\nCREATE INDEX idx_articles_tsv ON articles USING gin (tsv); 高亮展示用 ts_headline('english', body, query, 'StartSel=\u003Cb>, StopSel=\u003C\u002Fb>') 直接产出带标签的摘要片段；打分函数 ts_rank 越大越相关（还有一个考虑词距的 ts_rank_cd）。标题命中权重高于正文，是靠姿势二里把 title 拼在前面加 setweight 实现的，思路都是「打分素材进 tsvector，排序交给查询」。 示例 13 是个可交互的小页面：左边按钮回放 SQL 侧的真实输出，下面是一个真的能输入的搜索框——在静态语料上按「去停用词 + 词干归一 + 词频打分」现场计算并高亮（打分是简化版，真实 ts_rank 还考虑词距）。",{"id":9498,"title":666,"titles":9499,"content":9500,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-11",[5,7703],"1. 中文场景内置分词器不够用。 默认解析器不认识中文词——连续的汉字会被当成一整个 token： shop=# SELECT to_tsvector('simple', 'PostgreSQL 数据库实战');\n        to_tsvector\n---------------------------\n 'postgresql':1 '数据库实战':2 于是搜「数据库」匹配不到「数据库实战」——整串是一个词，没有子串匹配。解法是装分词器扩展 zhparser（基于 SCWS）或 pg_jieba，装完注册一个配置，to_tsvector('zh', ...) 就能按真实词切（北京欢迎你 → 北京 | 欢迎 | 你）。 2. 表达式索引要「文本级一字不差」才命中。 索引建的是 to_tsvector('english', body)，查询写成 to_tsvector('simple', body) 或换了列顺序都对不上（第八章那条规则在这里最容易踩）。姿势二用生成列就是为了避开这个匹配问题。 3. 配置（regconfig）要全链路统一。 写入用 'english'、查询用 'simple'，结果就是「数据明明在库里，搜索永远为空」——因为两边的词表归一规则不同。把配置写进生成列表达式或 default_text_search_config，别让每个调用点各填各的。 4. 别用 LIKE '%词%' 硬扛搜索框。 中缀 LIKE 用不上 btree（第八章），数据量上去就是全表扫。搜索需求很轻时可以先用 pg_trgm + GIN 顶住，别一上来就建全文索引。 5. 停用词和词干归一会「吃掉」你的精确匹配。 is、the 这类词根本不进索引，搜 MySQL 8.0 里的 8.0 也可能被按数字\u002F词切分处理掉。要精确命中型号、专名，走关键词列（普通 btree 或 trgm）而不是全文列——这也是第十五章混合检索要两路并行的原因。",{"id":9502,"title":7988,"titles":9503,"content":9504,"level":345},"\u002Ftutorials\u002Fpostgresql#附查询翻译函数与中文方案速查",[5,7703],"函数 输入风格 'learning postgres' 翻译成 to_tsquery('english', ?) 手写语法 learn & postgres 逻辑符直译，给开发者用 plainto_tsquery('english', ?) 一句话 全部 AND 起来 websearch_to_tsquery('english', ?) 搜索框习惯 \"引号短语\"、-排除、OR 都认识，给用户输入用的就是它 需求 方案 子串模糊搜、拼写纠错 pg_trgm + GIN（第八章讲过），撑住搜索框的「凑合版」 英文为主、要词干与相关度 内置 FTS，零依赖 中文、不想多养组件 PG + zhparser \u002F pg_jieba 中文 + 高亮\u002F聚合\u002F复杂运营位 上 Elasticsearch \u002F Meilisearch，PG 存底",{"id":9506,"title":8114,"titles":9507,"content":8117,"level":334},"\u002Ftutorials\u002Fpostgresql#十四扩展-地理位置postgis-的两种几何观",[5],{"id":9509,"title":8121,"titles":9510,"content":9511,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步装扩展建一张能查附近的表",[5,8114],"CREATE EXTENSION postgis;\nSELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n\nCREATE TABLE stores (\n  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name text NOT NULL,\n  geo  geography(Point, 4326) NOT NULL\n);\n\nINSERT INTO stores (name, geo)\nVALUES ('中关村店',\n        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n-- 注意参数顺序：经度在前，纬度在后\n\nCREATE INDEX idx_stores_geo ON stores USING gist (geo); 「3 公里内的门店」一条 SQL： -- 用户坐标\nWITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\nSELECT s.id, s.name,\n       round(ST_Distance(s.geo, p.pt)) AS dist_m\nFROM stores s, p\nWHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\nORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引 ST_DWithin 是唯一正确答案，因为它能走 GiST 索引（先粗筛再精算）。\u003C-> 是「距离近者先出」的 KNN 排序算子，配 GiST 索引可以流式吐出「最近的 N 家」——外卖、打车场景的标配写法。点在多边形内的判定用 ST_Contains(zone.geom, s.geo)，配送圈、行政区都在这一挂上。",{"id":9513,"title":666,"titles":9514,"content":9515,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-12",[5,8114],"1. 经纬度顺序。 PostGIS 全家统一「X 在前 = 经度在前」，GeoJSON 也是 [lng, lat]——但绝大多数业务接口、高德\u002F百度地图 SDK 的习惯是「纬度在前」。写反了不报错，只是查不到、距离离谱，属于最阴险的一类 bug，值得在插入入口统一转换一次。 2. SRID 混用。 插数据忘了 ST_SetSRID(..., 4326)（默认 0），与已有数据做运算立刻报 Operation on mixed SRID geometries (0, 4326)——看到它就回去补 SRID。给列直接声明 geometry(Point, 4326) 可以在插入时就拦住。 3. 在 4326 的 geometry 上算距离。 ST_Distance 对 geometry 返回的是度，不是米——差了大约 11 万倍。要米就 cast 成 geography，或者换本地投影。 4. WHERE ST_Distance(geo, p) \u003C 3000 不走索引。 它每行都要真算一遍大圆距离，行数大就是灾难。换成 ST_DWithin，让 GiST 索引先粗筛。 5. geometry 还是 geography，别选错。 经验法则一句话：业务地图就是「一个城市的几张公里级网」用 geometry + 本地投影；「全国门店、跨国航线」用 geography。拿不准就用 geography，慢的那点通常无所谓，至少单位不会错。两类的详细差异见下面这张速查表。 示例 14 的交互页面里，可以在静态地图上点选中心、拖半径，距离是拿经纬度真算的（haversine 公式，与 geography 同思路），多边形判定也是真的射线法——SQL 样板同步展示，所见即所查。",{"id":9517,"title":8340,"titles":9518,"content":9519,"level":345},"\u002Ftutorials\u002Fpostgresql#附geometry-与-geography-对照",[5,8114],"geometry（平面） geography（球面） 把地球当 展平的地图 真实的球 坐标单位 依投影而定 永远是经纬度（SRID 4326） 算出来的距离 投影单位（度或米） 米（沿球面大圆算） 索引与计算 更快、函数全 慢一些、函数子集 什么时候选 同城\u002F小范围，或需要投影运算 跨大范围、直接要米",{"id":9521,"title":8424,"titles":9522,"content":9523,"level":334},"\u002Ftutorials\u002Fpostgresql#十五扩展-向量数据库pgvector-与近似检索",[5],"最后一个扩展是当下最热的一个。语义搜索 \u002F RAG 的底层是：把文本喂给 embedding 模型，得到一串浮点（比如 1536 维），语义相近的文本，向量距离也近。查询时把问题也变成向量，找库里「最近的几条」。pgvector 让 PG 直接干这件事——又一个「少养一个组件」。",{"id":9525,"title":8435,"titles":9526,"content":9527,"level":345},"\u002Ftutorials\u002Fpostgresql#第一步存向量取最相关的-5-条",[5,8424],"CREATE EXTENSION vector;\n\nCREATE TABLE kb_chunks (\n  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  doc_id    bigint NOT NULL,\n  content   text NOT NULL,\n  embedding vector(1536)                  -- 维度 = 模型输出维度\n);\n\n-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\nINSERT INTO kb_chunks (doc_id, content, embedding)\nVALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n\n-- 余弦相似检索：距离算子 \u003C=>，越小越相似\nSELECT content, embedding \u003C=> $1 AS dist\nFROM kb_chunks\nORDER BY embedding \u003C=> $1\nLIMIT 5; 三个算子分别对应三种距离，语义搜索默认用 \u003C=>（余弦），细节见章末速查表。",{"id":9529,"title":8537,"titles":9530,"content":9531,"level":345},"\u002Ftutorials\u002Fpostgresql#第二步数据多了加一个-hnsw-索引",[5,8424],"不带索引时 ORDER BY embedding \u003C=> q 是精确检索（全库逐个算），几百行无所谓，百万行就是秒级。要快就得近似最近邻（ANN）索引： CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\nSET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢 近似的意思是可能漏掉真正的最近邻。召回率要自己量（这也是把它接进生产前必做的实验）： -- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\nSET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\nSET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\nSET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms 按业务能接受的漏检率（语义搜索通常 0.9+ 够用）反调 probes\u002Fef_search，这就是 ANN 索引的全部调优——没有玄学，只有测量。两种索引的取舍见章末对照表。",{"id":9533,"title":8602,"titles":9534,"content":9535,"level":345},"\u002Ftutorials\u002Fpostgresql#第三步和全文搜索配合做混合检索",[5,8424],"关键词管「精确命中」（型号、专名），向量管「语义相近」（换个说法也能搜到）。生产常用 RRF（倒数排名融合）把两路结果合起来，CTE（第十章）正好用上： WITH kw AS (      -- 关键词路：第十三章\n  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n  WHERE tsv @@ q LIMIT 50\n),\nvec AS (          -- 向量路\n  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n)\nSELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\nFROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\nGROUP BY id ORDER BY rrf_score DESC; 示例 15 的交互页面用二维平面直观化了「精确 vs 近似」的差异：勾上近似检索开关，亲眼看到它只探最近的簇、漏掉真近邻——这正是 ivfflat probes=1 的现场。",{"id":9537,"title":666,"titles":9538,"content":9539,"level":345},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-13",[5,8424],"1. ivfflat 必须先灌数据再建索引。 空表建索引质心全挤一起，召回灾难。hnsw 没这个前提，可随时建、边插边查——所以默认推荐 hnsw。 2. 距离算子、索引 opclass、ORDER BY 三者必须一致。 用 \u003C=>（余弦）查询，索引却要建 vector_l2_ops，结果是不走索引、退化成全库精确扫。建 vector_cosine_ops 就一路用 \u003C=>。 3. 维度定死在列类型里。 vector(1536) 换 embedding 模型（比如 3072 维）不是改配置，是要重建列重算全量向量。选型时把「模型可能换版」算进成本。 4. 近似检索会漏，且漏得悄无声息。 不报错、结果看着也正常，只是少了真正的最近邻。上线前用同一批真实查询量一次 recall@K（第二步那段），并把 ef_search \u002F probes 记进配置。 5. 什么时候不用 pgvector。 数据到几千万行、要求毫秒级 p99、或要做复杂的向量过滤\u002F分片——再考虑专用向量库。在绝大多数「知识库 + 万级到百万级 chunk」的规模里，pgvector 加上一张熟悉的 PG，就是最省心的答案。",{"id":9541,"title":8749,"titles":9542,"content":9543,"level":345},"\u002Ftutorials\u002Fpostgresql#附距离算子与-ann-索引速查",[5,8424],"算子 距离 场景 \u003C-> 欧氏距离（L2） 绝对距离有意义时 \u003C#> 负内积 未归一化向量、关注量级 \u003C=> 余弦距离（1 - cos） 语义搜索最常用；很多模型输出已归一化，此时与 L2 排序等价 ivfflat（聚类倒排） hnsw（分层图导航） 原理 K-means 聚成 lists 个簇，查询只探 probes 个簇 多层近邻图，贪心跳转 建索引前提 必须先灌数据再建——空表建索引质心全挤一起，召回灾难 可随时建，边插边查 关键参数 lists（建议 行数\u002F1000）、查询期 ivfflat.probes m=16、ef_construction=64、查询期 hnsw.ef_search 召回\u002F内存 召回一般、内存小 召回高、内存大 选择 内存紧、能接受漏检 默认推荐",{"id":9545,"title":8901,"titles":9546,"content":9547,"level":334},"\u002Ftutorials\u002Fpostgresql#十六收尾上线前核对清单与学习路线",[5],"一门课收尾，把散在十五章「⚠️ 本章的坑」里的东西收进一张可打勾的清单。新库上线、老库交接，按行过一遍： 类别 检查项 不做的后果 详见 建库 编码 UTF8、collation 定死；data_checksums 尽量开 collation 变更带坏字符串索引；磁盘静默损坏无感知 三、十二章 参数 shared_buffers≈25% 内存；work_mem 按并发算账；max_connections≤300 + PgBouncer 内存事故、连接风暴 三、一章 模式 时间一律 timestamptz；金额 numeric；文本 text；主键 identity 时区漂移、分币丢失、序列权限报错 四章 索引 外键列手动建索引；模糊搜 pg_trgm；FTS 表达式与查询一字不差 删父行秒级锁等待、搜索全表扫 七、八、十三章 慢查询 log_min_duration_statement = 500ms 收慢日志；EXPLAIN 差值大先 ANALYZE 慢而无据，调优靠猜 八章 事务 监控 idle in transaction；DDL 前 SET lock_timeout；建索引用 CONCURRENTLY 表膨胀拖垮全库；ALTER 排队全站超时 九章 权限 应用账号最小权限且非 owner；pg_hba 无公网 trust；密码 scram 一条 SQL 误删全库；弱口令裸奔 十一章 备份 每日 dump + basebackup + WAL 归档；恢复演练真的做过一次 「有备份」和「能恢复」是两回事 十二章 运维 autovacuum 跟上（膨胀超 30% 处理）；死锁日志定期看 性能慢性病、业务加锁环路没人改 八、九章 学完这门课，继续往下走的路线：官方文档的 Part II（SQL 语言）与 Part IV（客户端认证）是两块最适合精读的；练习题首推 pgexercises.com（免费、全 SQL 覆盖）；索引原理补 use-the-index-luke.com；三个扩展的深水区分别在 PG 官方文档的 Text Search \u002F PostGIS 手册 \u002F pgvector 的 GitHub README。 最后回到第一章的场景：全文搜索、附近门店、语义检索——三行需求，一套 PG 加三个扩展全接住。组件少一个，事故面就小一圈。祝你的下一张表，类型全对、索引都绿。 html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",1793263560679]