[{"data":1,"prerenderedAt":8275},["ShallowReactive",2],{"home-latest":3},[4,1441,6347,7285,7965],{"id":5,"title":6,"body":7,"category":1418,"cover":17,"date":1419,"description":1420,"extension":1421,"meta":1422,"navigation":214,"path":1431,"seo":1432,"sitemap":1433,"stem":1439,"__hash__":1440},"articles\u002Farticles\u002F先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录.md","先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录",{"type":8,"value":9,"toc":1402},"minimark",[10,18,21,24,29,40,103,117,125,268,274,277,356,366,370,375,378,544,547,562,569,573,584,736,757,767,771,777,784,787,817,826,829,882,890,897,900,904,908,911,917,920,929,932,935,939,946,971,977,984,987,991,1004,1010,1017,1025,1029,1032,1133,1137,1140,1143,1205,1229,1232,1271,1280,1283,1317,1321,1328,1389,1392,1395,1398],[11,12,13],"p",{},[14,15],"img",{"alt":16,"src":17},"文章配图 1","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F01.png",[11,19,20],{},"做商品搜索时，我首先想到的也是 Elasticsearch。不过，在增加一套搜索服务之前，我想先验证一个问题：如果商品数据已经存放在 PostgreSQL 中，需求主要是中文关键词检索、业务筛选和基础相关性排序，数据库现有的能力能否满足要求？",[11,22,23],{},"这次实验使用 PostgreSQL 18.6 + zhparser 2.4 + pg_trgm 1.6；",[25,26,28],"h2",{"id":27},"_1-先确认各组件负责什么","1. 先确认各组件负责什么",[11,30,31,32,39],{},"常被口头称为“pg_zhparser”的扩展，实际扩展名是 ",[33,34,35],"strong",{},[36,37,38],"code",{},"zhparser","。",[41,42,43,59],"table",{},[44,45,46],"thead",{},[47,48,49,53,56],"tr",{},[50,51,52],"th",{},"组件",[50,54,55],{},"本次用途",[50,57,58],{},"能力边界",[60,61,62,73,92],"tbody",{},[47,63,64,67,70],{},[65,66,38],"td",{},[65,68,69],{},"借助 SCWS 对中文文本分词",[65,71,72],{},"不负责索引维护和相关性排名",[47,74,75,78,89],{},[65,76,77],{},"PostgreSQL 内置全文检索",[65,79,80,81,84,85,88],{},"使用 ",[36,82,83],{},"tsvector"," 表示词项及其位置、权重；通过 GIN 加速匹配，再用 ",[36,86,87],{},"ts_rank_cd"," 排名",[65,90,91],{},"不会自动理解商品意图或完成业务同义词扩展",[47,93,94,97,100],{},[65,95,96],{},"pg_trgm",[65,98,99],{},"基于字符三元组，支持字符串相似度和部分模式匹配查询的索引加速",[65,101,102],{},"不是中文分词器，也不是语义模型",[11,104,105,106,109,110,39,113,116],{},"安装需要区分两件事：",[33,107,108],{},"服务器已经具备扩展文件","，以及",[33,111,112],{},"当前数据库已经启用扩展",[36,114,115],{},"CREATE EXTENSION"," 不会自动下载和编译扩展。",[11,118,119,121,122,124],{},[36,120,96],{}," 通常由 PostgreSQL 的 contrib 模块提供；缺少文件时，需要安装与发行版、PostgreSQL 大版本相匹配的软件包。",[36,123,38],{}," 是第三方扩展，一般需要先安装 SCWS，再进行编译。下面仅展示 Linux 下的构建流程，前提是已经准备好编译工具、PG 18 开发头文件和 SCWS 1.2.3 源码包：",[126,127,132],"pre",{"className":128,"code":129,"language":130,"meta":131,"style":131},"language-bash shiki shiki-themes github-dark","# 从 SCWS 官方站获取 scws-1.2.3.tar.bz2 后执行。\ntar -xjf scws-1.2.3.tar.bz2\n(cd scws-1.2.3 && .\u002Fconfigure --prefix=\u002Fusr\u002Flocal --with-pic \\\n  && make && sudo make install)\n\ngit clone https:\u002F\u002Fgithub.com\u002Famutu\u002Fzhparser.git\ncd zhparser\n# 编译前切换到自己验证过的 tag 或 commit；默认分支不保证对应本文版本。\nmake PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config SCWS_HOME=\u002Fusr\u002Flocal\nsudo make PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config SCWS_HOME=\u002Fusr\u002Flocal install\n","bash","",[36,133,134,143,158,186,209,216,228,236,242,253],{"__ignoreMap":131},[135,136,139],"span",{"class":137,"line":138},"line",1,[135,140,142],{"class":141},"sAwPA","# 从 SCWS 官方站获取 scws-1.2.3.tar.bz2 后执行。\n",[135,144,146,150,154],{"class":137,"line":145},2,[135,147,149],{"class":148},"svObZ","tar",[135,151,153],{"class":152},"sDLfK"," -xjf",[135,155,157],{"class":156},"sU2Wk"," scws-1.2.3.tar.bz2\n",[135,159,161,165,168,171,174,177,180,183],{"class":137,"line":160},3,[135,162,164],{"class":163},"s95oV","(",[135,166,167],{"class":152},"cd",[135,169,170],{"class":156}," scws-1.2.3",[135,172,173],{"class":163}," && ",[135,175,176],{"class":148},".\u002Fconfigure",[135,178,179],{"class":152}," --prefix=\u002Fusr\u002Flocal",[135,181,182],{"class":152}," --with-pic",[135,184,185],{"class":152}," \\\n",[135,187,189,192,195,197,200,203,206],{"class":137,"line":188},4,[135,190,191],{"class":163},"  && ",[135,193,194],{"class":148},"make",[135,196,173],{"class":163},[135,198,199],{"class":148},"sudo",[135,201,202],{"class":156}," make",[135,204,205],{"class":156}," install",[135,207,208],{"class":163},")\n",[135,210,212],{"class":137,"line":211},5,[135,213,215],{"emptyLinePlaceholder":214},true,"\n",[135,217,219,222,225],{"class":137,"line":218},6,[135,220,221],{"class":148},"git",[135,223,224],{"class":156}," clone",[135,226,227],{"class":156}," https:\u002F\u002Fgithub.com\u002Famutu\u002Fzhparser.git\n",[135,229,231,233],{"class":137,"line":230},7,[135,232,167],{"class":152},[135,234,235],{"class":156}," zhparser\n",[135,237,239],{"class":137,"line":238},8,[135,240,241],{"class":141},"# 编译前切换到自己验证过的 tag 或 commit；默认分支不保证对应本文版本。\n",[135,243,245,247,250],{"class":137,"line":244},9,[135,246,194],{"class":148},[135,248,249],{"class":156}," PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config",[135,251,252],{"class":156}," SCWS_HOME=\u002Fusr\u002Flocal\n",[135,254,256,258,260,262,265],{"class":137,"line":255},10,[135,257,199],{"class":148},[135,259,202],{"class":156},[135,261,249],{"class":156},[135,263,264],{"class":156}," SCWS_HOME=\u002Fusr\u002Flocal",[135,266,267],{"class":156}," install\n",[11,269,270,273],{},[36,271,272],{},"pg_config"," 路径需要根据实际环境调整。使用 Docker 时，应将安装步骤固化到镜像中，避免容器重建后丢失扩展。托管数据库需要先确认扩展白名单；Windows 原生部署则需要匹配版本的二进制文件，不能直接照搬上述 Linux 命令。",[11,275,276],{},"我这次使用 Docker 搭建本地环境，并借助 AI 编程工具辅助安装和排查问题。工具可以减少重复操作，但扩展版本、安装日志和最终 SQL 结果仍然需要核对。",[126,278,282],{"className":279,"code":280,"language":281,"meta":131,"style":131},"language-sql shiki shiki-themes github-dark","-- 查看服务器可用的扩展。\n-- installed_version 为 NULL，表示当前数据库尚未启用该扩展。\nSELECT name, default_version, installed_version\nFROM pg_available_extensions\nWHERE name IN ('zhparser', 'pg_trgm');\n\nCREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;\nCREATE EXTENSION IF NOT EXISTS zhparser WITH SCHEMA public;\n\n-- 核对已安装扩展的版本与所在 schema。\nSELECT e.extname, e.extversion, n.nspname AS extension_schema\nFROM pg_extension e\nJOIN pg_namespace n ON n.oid = e.extnamespace\nWHERE e.extname IN ('zhparser', 'pg_trgm');\n","sql",[36,283,284,289,294,299,304,309,313,318,323,327,332,338,344,350],{"__ignoreMap":131},[135,285,286],{"class":137,"line":138},[135,287,288],{},"-- 查看服务器可用的扩展。\n",[135,290,291],{"class":137,"line":145},[135,292,293],{},"-- installed_version 为 NULL，表示当前数据库尚未启用该扩展。\n",[135,295,296],{"class":137,"line":160},[135,297,298],{},"SELECT name, default_version, installed_version\n",[135,300,301],{"class":137,"line":188},[135,302,303],{},"FROM pg_available_extensions\n",[135,305,306],{"class":137,"line":211},[135,307,308],{},"WHERE name IN ('zhparser', 'pg_trgm');\n",[135,310,311],{"class":137,"line":218},[135,312,215],{"emptyLinePlaceholder":214},[135,314,315],{"class":137,"line":230},[135,316,317],{},"CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;\n",[135,319,320],{"class":137,"line":238},[135,321,322],{},"CREATE EXTENSION IF NOT EXISTS zhparser WITH SCHEMA public;\n",[135,324,325],{"class":137,"line":244},[135,326,215],{"emptyLinePlaceholder":214},[135,328,329],{"class":137,"line":255},[135,330,331],{},"-- 核对已安装扩展的版本与所在 schema。\n",[135,333,335],{"class":137,"line":334},11,[135,336,337],{},"SELECT e.extname, e.extversion, n.nspname AS extension_schema\n",[135,339,341],{"class":137,"line":340},12,[135,342,343],{},"FROM pg_extension e\n",[135,345,347],{"class":137,"line":346},13,[135,348,349],{},"JOIN pg_namespace n ON n.oid = e.extnamespace\n",[135,351,353],{"class":137,"line":352},14,[135,354,355],{},"WHERE e.extname IN ('zhparser', 'pg_trgm');\n",[11,357,358,359,39,362,365],{},"下文假设扩展位于 ",[36,360,361],{},"public",[36,363,364],{},"IF NOT EXISTS"," 不会把已安装的扩展移动到指定 schema；如果实际位置不同，需要调整限定名。",[25,367,369],{"id":368},"_2-从单表开始生成并维护搜索向量","2. 从单表开始：生成并维护搜索向量",[371,372,374],"h3",{"id":373},"建表并配置中文检索","① 建表并配置中文检索",[11,376,377],{},"先创建独立的实验 schema 和商品表，再定义中文检索配置：",[126,379,381],{"className":279,"code":380,"language":281,"meta":131,"style":131},"CREATE SCHEMA search_single;\nSET search_path = search_single, public;\n\nCREATE TABLE search_single.products (\n    id BIGSERIAL PRIMARY KEY,\n    title VARCHAR(200) NOT NULL,\n    subtitle VARCHAR(500),\n    description TEXT,\n    brand VARCHAR(100),\n    is_active BOOLEAN DEFAULT TRUE,\n    created_at TIMESTAMPTZ DEFAULT NOW(),\n    search_vector tsvector\n);\n\nCREATE TEXT SEARCH CONFIGURATION search_single.chinese_zh\n    (PARSER = public.zhparser);\n\n-- 将以下词性映射到 simple 字典：\n-- n：名词；v：动词；a：形容词；i：成语；e：叹词；l：习用语；x：未知词性。\nALTER TEXT SEARCH CONFIGURATION search_single.chinese_zh\n    ADDMAPPINGFOR n, v, a, i, e, l, x WITH pg_catalog.simple;\n\n-- 固定本次复现实验的复合分词选项，避免依赖连接原有设置。\nSET zhparser.multi_short = off;\nSET zhparser.multi_duality = off;\nSET zhparser.multi_zmain = off;\nSET zhparser.multi_zall = off;\n\nSELECT *\nFROM ts_debug('search_single.chinese_zh', '苹果 降噪 AirPods');\n",[36,382,383,388,393,397,402,407,412,417,422,427,432,437,442,447,451,457,463,468,474,480,486,492,497,503,509,515,521,527,532,538],{"__ignoreMap":131},[135,384,385],{"class":137,"line":138},[135,386,387],{},"CREATE SCHEMA search_single;\n",[135,389,390],{"class":137,"line":145},[135,391,392],{},"SET search_path = search_single, public;\n",[135,394,395],{"class":137,"line":160},[135,396,215],{"emptyLinePlaceholder":214},[135,398,399],{"class":137,"line":188},[135,400,401],{},"CREATE TABLE search_single.products (\n",[135,403,404],{"class":137,"line":211},[135,405,406],{},"    id BIGSERIAL PRIMARY KEY,\n",[135,408,409],{"class":137,"line":218},[135,410,411],{},"    title VARCHAR(200) NOT NULL,\n",[135,413,414],{"class":137,"line":230},[135,415,416],{},"    subtitle VARCHAR(500),\n",[135,418,419],{"class":137,"line":238},[135,420,421],{},"    description TEXT,\n",[135,423,424],{"class":137,"line":244},[135,425,426],{},"    brand VARCHAR(100),\n",[135,428,429],{"class":137,"line":255},[135,430,431],{},"    is_active BOOLEAN DEFAULT TRUE,\n",[135,433,434],{"class":137,"line":334},[135,435,436],{},"    created_at TIMESTAMPTZ DEFAULT NOW(),\n",[135,438,439],{"class":137,"line":340},[135,440,441],{},"    search_vector tsvector\n",[135,443,444],{"class":137,"line":346},[135,445,446],{},");\n",[135,448,449],{"class":137,"line":352},[135,450,215],{"emptyLinePlaceholder":214},[135,452,454],{"class":137,"line":453},15,[135,455,456],{},"CREATE TEXT SEARCH CONFIGURATION search_single.chinese_zh\n",[135,458,460],{"class":137,"line":459},16,[135,461,462],{},"    (PARSER = public.zhparser);\n",[135,464,466],{"class":137,"line":465},17,[135,467,215],{"emptyLinePlaceholder":214},[135,469,471],{"class":137,"line":470},18,[135,472,473],{},"-- 将以下词性映射到 simple 字典：\n",[135,475,477],{"class":137,"line":476},19,[135,478,479],{},"-- n：名词；v：动词；a：形容词；i：成语；e：叹词；l：习用语；x：未知词性。\n",[135,481,483],{"class":137,"line":482},20,[135,484,485],{},"ALTER TEXT SEARCH CONFIGURATION search_single.chinese_zh\n",[135,487,489],{"class":137,"line":488},21,[135,490,491],{},"    ADDMAPPINGFOR n, v, a, i, e, l, x WITH pg_catalog.simple;\n",[135,493,495],{"class":137,"line":494},22,[135,496,215],{"emptyLinePlaceholder":214},[135,498,500],{"class":137,"line":499},23,[135,501,502],{},"-- 固定本次复现实验的复合分词选项，避免依赖连接原有设置。\n",[135,504,506],{"class":137,"line":505},24,[135,507,508],{},"SET zhparser.multi_short = off;\n",[135,510,512],{"class":137,"line":511},25,[135,513,514],{},"SET zhparser.multi_duality = off;\n",[135,516,518],{"class":137,"line":517},26,[135,519,520],{},"SET zhparser.multi_zmain = off;\n",[135,522,524],{"class":137,"line":523},27,[135,525,526],{},"SET zhparser.multi_zall = off;\n",[135,528,530],{"class":137,"line":529},28,[135,531,215],{"emptyLinePlaceholder":214},[135,533,535],{"class":137,"line":534},29,[135,536,537],{},"SELECT *\n",[135,539,541],{"class":137,"line":540},30,[135,542,543],{},"FROM ts_debug('search_single.chinese_zh', '苹果 降噪 AirPods');\n",[11,545,546],{},"这里的单表包含标题、副标题、描述和品牌。",[11,548,549,550,553,554,557,558,561],{},"分词器负责识别文本中的词，字典负责处理分词结果。",[36,551,552],{},"simple"," 字典会做小写化等处理，不会自动把“苹果”扩展成 ",[36,555,556],{},"apple","。词性映射也不是越多越好：应通过 ",[36,559,560],{},"ts_debug"," 检查目标词的类型及最终词项，避免词已经切出来，却因为没有映射而未进入向量。",[11,563,564,565,568],{},"这些 ",[36,566,567],{},"SET"," 只影响当前连接。生成测试数据、业务写入、查询和后续重算使用的连接，都应采用一致的分词配置；在 Navicat 中另开连接，也不能假设它会继承前一个连接的设置。",[371,570,572],{"id":571},"用触发器维护向量","② 用触发器维护向量",[11,574,575,576,579,580,583],{},"本次给标题设置 A 权重，副标题和品牌设置 B 权重，描述设置 C 权重。",[36,577,578],{},"search_vector"," 保存的是供全文检索使用的词项、位置和权重信息，",[33,581,582],{},"字段本身不是索引","；GIN 索引需要另外创建。",[126,585,587],{"className":279,"code":586,"language":281,"meta":131,"style":131},"CREATE OR REPLACE FUNCTION search_single.update_product_search_vector()\nRETURNS trigger AS $$\nBEGIN\n    -- 只有文本字段均未改变，且向量已经存在时，才跳过重算。\n    -- 保留 NULL 向量的补算入口，便于回填触发器创建前导入的数据。\n    IF TG_OP = 'UPDATE'THEN\n        IF NEW.title       IS NOT DISTINCT FROM OLD.title\n           AND NEW.subtitle    IS NOT DISTINCT FROM OLD.subtitle\n           AND NEW.brand       IS NOT DISTINCT FROM OLD.brand\n           AND NEW.description IS NOT DISTINCT FROM OLD.description\n           AND NEW.search_vector IS NOT NULL\n        THEN\n            RETURN NEW;\n        END IF;\n    END IF;\n\n    NEW.search_vector :=\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.title, '')), 'A') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.subtitle, '')), 'B') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.brand, '')), 'B') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.description, '')), 'C');\n\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER trg_product_search_vector\nBEFORE INSERT OR UPDATE OF title, subtitle, brand, description\nON search_single.products\nFOR EACH ROW EXECUTE FUNCTION search_single.update_product_search_vector();\n",[36,588,589,594,599,604,609,614,619,624,629,634,639,644,649,654,659,664,668,673,678,683,688,693,697,702,707,712,716,721,726,731],{"__ignoreMap":131},[135,590,591],{"class":137,"line":138},[135,592,593],{},"CREATE OR REPLACE FUNCTION search_single.update_product_search_vector()\n",[135,595,596],{"class":137,"line":145},[135,597,598],{},"RETURNS trigger AS $$\n",[135,600,601],{"class":137,"line":160},[135,602,603],{},"BEGIN\n",[135,605,606],{"class":137,"line":188},[135,607,608],{},"    -- 只有文本字段均未改变，且向量已经存在时，才跳过重算。\n",[135,610,611],{"class":137,"line":211},[135,612,613],{},"    -- 保留 NULL 向量的补算入口，便于回填触发器创建前导入的数据。\n",[135,615,616],{"class":137,"line":218},[135,617,618],{},"    IF TG_OP = 'UPDATE'THEN\n",[135,620,621],{"class":137,"line":230},[135,622,623],{},"        IF NEW.title       IS NOT DISTINCT FROM OLD.title\n",[135,625,626],{"class":137,"line":238},[135,627,628],{},"           AND NEW.subtitle    IS NOT DISTINCT FROM OLD.subtitle\n",[135,630,631],{"class":137,"line":244},[135,632,633],{},"           AND NEW.brand       IS NOT DISTINCT FROM OLD.brand\n",[135,635,636],{"class":137,"line":255},[135,637,638],{},"           AND NEW.description IS NOT DISTINCT FROM OLD.description\n",[135,640,641],{"class":137,"line":334},[135,642,643],{},"           AND NEW.search_vector IS NOT NULL\n",[135,645,646],{"class":137,"line":340},[135,647,648],{},"        THEN\n",[135,650,651],{"class":137,"line":346},[135,652,653],{},"            RETURN NEW;\n",[135,655,656],{"class":137,"line":352},[135,657,658],{},"        END IF;\n",[135,660,661],{"class":137,"line":453},[135,662,663],{},"    END IF;\n",[135,665,666],{"class":137,"line":459},[135,667,215],{"emptyLinePlaceholder":214},[135,669,670],{"class":137,"line":465},[135,671,672],{},"    NEW.search_vector :=\n",[135,674,675],{"class":137,"line":470},[135,676,677],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.title, '')), 'A') ||\n",[135,679,680],{"class":137,"line":476},[135,681,682],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.subtitle, '')), 'B') ||\n",[135,684,685],{"class":137,"line":482},[135,686,687],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.brand, '')), 'B') ||\n",[135,689,690],{"class":137,"line":488},[135,691,692],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.description, '')), 'C');\n",[135,694,695],{"class":137,"line":494},[135,696,215],{"emptyLinePlaceholder":214},[135,698,699],{"class":137,"line":499},[135,700,701],{},"    RETURN NEW;\n",[135,703,704],{"class":137,"line":505},[135,705,706],{},"END;\n",[135,708,709],{"class":137,"line":511},[135,710,711],{},"$$ LANGUAGE plpgsql;\n",[135,713,714],{"class":137,"line":517},[135,715,215],{"emptyLinePlaceholder":214},[135,717,718],{"class":137,"line":523},[135,719,720],{},"CREATE TRIGGER trg_product_search_vector\n",[135,722,723],{"class":137,"line":529},[135,724,725],{},"BEFORE INSERT OR UPDATE OF title, subtitle, brand, description\n",[135,727,728],{"class":137,"line":534},[135,729,730],{},"ON search_single.products\n",[135,732,733],{"class":137,"line":540},[135,734,735],{},"FOR EACH ROW EXECUTE FUNCTION search_single.update_product_search_vector();\n",[11,737,738,741,742,745,746,749,750,752,753,756],{},[36,739,740],{},"IS NOT DISTINCT FROM"," 用于正确比较可能为 ",[36,743,744],{},"NULL"," 的字段。还需要区分“进入触发器”和“真正重算向量”：",[36,747,748],{},"UPDATE OF"," 判断的是字段是否出现在 ",[36,751,567],{}," 列表中，即使执行 ",[36,754,755],{},"SET title = title","，也会进入触发器；函数内部再判断是否有必要重算。",[11,758,759,760,763,764,766],{},"实验调试时可以临时加入 ",[36,761,762],{},"RAISE NOTICE '触发器执行了：表=%, 操作=%, 行ID=%', TG_TABLE_NAME, TG_OP, NEW.id;","，观察触发情况，但不应在几百万行导入或回填时逐行输出消息，否则会干扰测量并增加额外开销。应用也不应直接维护 ",[36,765,578],{},"，而应让它由源文本派生。",[371,768,770],{"id":769},"已有数据需要单独回填","③ 已有数据需要单独回填",[11,772,773,774],{},"这里容易遗漏一个步骤：",[33,775,776],{},"创建触发器不会自动处理表中已有的数据。",[11,778,779,780,783],{},"如果先创建触发器，再通过普通 ",[36,781,782],{},"INSERT"," 导入数据，向量会在插入时生成；如果先用 Navicat 生成了数据，随后才创建触发器，就需要补算已有记录。还应确认导入方式没有禁用触发器。",[11,785,786],{},"下面只演示一个主键区间，不是对全表的完整回填：",[126,788,790],{"className":279,"code":789,"language":281,"meta":131,"style":131},"-- 示例批次：按实际主键范围逐批推进，并逐批提交。\nUPDATE search_single.products\nSET title = title\nWHERE id > 0 AND id \u003C= 10000\n  AND search_vector IS NULL;\n",[36,791,792,797,802,807,812],{"__ignoreMap":131},[135,793,794],{"class":137,"line":138},[135,795,796],{},"-- 示例批次：按实际主键范围逐批推进，并逐批提交。\n",[135,798,799],{"class":137,"line":145},[135,800,801],{},"UPDATE search_single.products\n",[135,803,804],{"class":137,"line":160},[135,805,806],{},"SET title = title\n",[135,808,809],{"class":137,"line":188},[135,810,811],{},"WHERE id > 0 AND id \u003C= 10000\n",[135,813,814],{"class":137,"line":211},[135,815,816],{},"  AND search_vector IS NULL;\n",[11,818,819,820,822,823,825],{},"这条语句会进入触发器。因为待处理记录的向量为 ",[36,821,744],{},"，不会被函数中的短路判断跳过。若仅判断“文本字段有没有变化”，",[36,824,755],{}," 就无法完成回填。",[11,827,828],{},"批量处理完成后，再核对实际数据量和向量覆盖情况：",[126,830,832],{"className":279,"code":831,"language":281,"meta":131,"style":131},"-- 大表上的精确计数也有成本，不要在每个批次结束后都全表执行。\nSELECT count(*) AS total_rows,\n       count(*) FILTER (WHERE search_vector IS NULL) AS missing_vectors,\n       count(*) FILTER (WHERE search_vector = ''::tsvector) AS empty_vectors\nFROM search_single.products;\n\nCREATE INDEX idx_products_search_zh\nON search_single.products USING GIN (search_vector);\n\nANALYZE search_single.products;\n",[36,833,834,839,844,849,854,859,863,868,873,877],{"__ignoreMap":131},[135,835,836],{"class":137,"line":138},[135,837,838],{},"-- 大表上的精确计数也有成本，不要在每个批次结束后都全表执行。\n",[135,840,841],{"class":137,"line":145},[135,842,843],{},"SELECT count(*) AS total_rows,\n",[135,845,846],{"class":137,"line":160},[135,847,848],{},"       count(*) FILTER (WHERE search_vector IS NULL) AS missing_vectors,\n",[135,850,851],{"class":137,"line":188},[135,852,853],{},"       count(*) FILTER (WHERE search_vector = ''::tsvector) AS empty_vectors\n",[135,855,856],{"class":137,"line":211},[135,857,858],{},"FROM search_single.products;\n",[135,860,861],{"class":137,"line":218},[135,862,215],{"emptyLinePlaceholder":214},[135,864,865],{"class":137,"line":230},[135,866,867],{},"CREATE INDEX idx_products_search_zh\n",[135,869,870],{"class":137,"line":238},[135,871,872],{},"ON search_single.products USING GIN (search_vector);\n",[135,874,875],{"class":137,"line":244},[135,876,215],{"emptyLinePlaceholder":214},[135,878,879],{"class":137,"line":255},[135,880,881],{},"ANALYZE search_single.products;\n",[11,883,884,886,887,889],{},[36,885,744],{}," 向量与空向量不是同一回事。前者表示字段尚未赋值；后者可能是文本为空，或没有词项通过当前配置，需要结合原始内容和 ",[36,888,560],{}," 排查。",[11,891,892,893,896],{},"这里是在实验表完成导入和回填后建索引。已有业务表如何建索引，应另行评估锁等待、磁盘空间和是否需要 ",[36,894,895],{},"CREATE INDEX CONCURRENTLY","，不能直接套用实验环境的操作顺序。",[11,898,899],{},"对后续普通写入，源文本与向量在同一事务中更新；其他连接能否读到新结果，仍受事务提交和隔离级别影响。批量修改还会带来分词、GIN 维护、WAL 和清理成本，不能把“同步维护”理解成没有写入开销。",[25,901,903],{"id":902},"_3-对照截图命中了什么分数说明什么","3. 对照截图：命中了什么，分数说明什么",[371,905,907],{"id":906},"先检查一条记录的向量","① 先检查一条记录的向量",[11,909,910],{},"下面是单表实验中的一条记录：",[11,912,913],{},[14,914],{"alt":915,"src":916},"文章配图 2","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F02.png",[11,918,919],{},"与本次查询有关的内容是：副标题包含“保健，家庭及婴儿护理”，向量中可以看到：",[126,921,923],{"className":279,"code":922,"language":281,"meta":131,"style":131},"'婴儿':4B '护理':5B\n",[36,924,925],{"__ignoreMap":131},[135,926,927],{"class":137,"line":138},[135,928,922],{},[11,930,931],{},"这表示“婴儿”和“护理”已进入向量，位置分别为 4、5，权重标签都是 B。它们出现在副标题中，因此与触发器里设置的 B 权重一致。",[11,933,934],{},"这张截图可以用于检查该记录的向量内容。",[371,936,938],{"id":937},"区分-and-查询与短语查询","② 区分 AND 查询与短语查询",[11,940,941,942,945],{},"查询前先打印实际生成的 ",[36,943,944],{},"tsquery","，不要只根据输入文字判断查询语义：",[126,947,949],{"className":279,"code":948,"language":281,"meta":131,"style":131},"SELECT\n    websearch_to_tsquery('search_single.chinese_zh', '婴儿 护理') AS and_query,\n    websearch_to_tsquery('search_single.chinese_zh', '\"婴儿 护理\"') AS phrase_query,\n    websearch_to_tsquery('search_single.chinese_zh', '婴儿护理') AS unspaced_query;\n",[36,950,951,956,961,966],{"__ignoreMap":131},[135,952,953],{"class":137,"line":138},[135,954,955],{},"SELECT\n",[135,957,958],{"class":137,"line":145},[135,959,960],{},"    websearch_to_tsquery('search_single.chinese_zh', '婴儿 护理') AS and_query,\n",[135,962,963],{"class":137,"line":160},[135,964,965],{},"    websearch_to_tsquery('search_single.chinese_zh', '\"婴儿 护理\"') AS phrase_query,\n",[135,967,968],{"class":137,"line":188},[135,969,970],{},"    websearch_to_tsquery('search_single.chinese_zh', '婴儿护理') AS unspaced_query;\n",[11,972,973],{},[14,974],{"alt":975,"src":976},"文章配图 3","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F03.png",[11,978,979,980,983],{},"在“婴儿”“护理”都被保留为独立词项的前提下，未加引号的输入生成 AND 查询，要求两个词都出现，但不要求相邻；双引号输入生成短语查询，要求满足分词后的位置关系。本例没有中间停用词时，短语查询会使用 ",[36,981,982],{},"\u003C->","，即相邻位置。",[11,985,986],{},"“婴儿护理”这个无空格输入最终生成什么词项，应以当前分词器、词库和配置的实际输出为准。",[371,988,990],{"id":989},"如何理解截图中的-04","③ 如何理解截图中的 0.4",[11,992,993,995,996,999,1000,1003],{},[36,994,87],{}," 默认的 A\u002FB\u002FC\u002FD 权重分别为 ",[33,997,998],{},"1.0 \u002F 0.4 \u002F 0.2 \u002F 0.1","。如果显式传入权重数组，参数顺序则是 ",[33,1001,1002],{},"D、C、B、A","，不要写反。",[11,1005,1006],{},[14,1007],{"alt":1008,"src":1009},"文章配图 4","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F04.png",[11,1011,1012,1013,1016],{},"截图中，命中的“婴儿”和“护理”位置相邻，且均为 B 权重；显示的排名分数为 ",[36,1014,1015],{},"0.4","。这一结果与该记录的命中情况相符，但不能概括成“只要命中 B 权重，分数就是 0.4”。",[11,1018,1019,1021,1022,1024],{},[36,1020,87],{}," 使用覆盖密度排名，会考虑命中词项的位置、接近程度等信息；出现次数、权重和归一化参数也会影响结果。A 权重更高，并不意味着任何标题命中都一定排在所有其他结果之前。这个分数也不是概率，",[36,1023,1015],{}," 不代表“40% 相关”。",[25,1026,1028],{"id":1027},"_4-用-pg_trgm-补充近似匹配","4. 用 pg_trgm 补充近似匹配",[11,1030,1031],{},"全文检索与字符串匹配处理的是不同问题。对于型号包含关系，以及接近完整标题的拼写错误，可以另外验证 pg_trgm：",[126,1033,1035],{"className":279,"code":1034,"language":281,"meta":131,"style":131},"-- 可选索引：先在实验环境测量体积和收益，再决定是否保留。\nCREATE INDEX idx_products_title_trgm\nON search_single.products USING GIN (title public.gin_trgm_ops);\n\n-- 型号子串匹配；返回记录取决于当前数据是否包含该型号。\nSELECT id, title\nFROM search_single.products\nWHERE title ILIKE'%AirPods%'\nAND is_active = TRUE\nORDER BY id\nLIMIT 20;\n\n-- 故意把 AirPods 写成 AirPdos，观察候选与分数。\nSELECT id, title,\n       public.similarity(title, '苹果 AirPdos Pro 2 蓝牙耳机') AS score\nFROM search_single.products\nWHERE title % '苹果 AirPdos Pro 2 蓝牙耳机'\nAND is_active = TRUE\nORDER BY score DESC, id\nLIMIT10;\n",[36,1036,1037,1042,1047,1052,1056,1061,1066,1071,1076,1081,1086,1091,1095,1100,1105,1110,1114,1119,1123,1128],{"__ignoreMap":131},[135,1038,1039],{"class":137,"line":138},[135,1040,1041],{},"-- 可选索引：先在实验环境测量体积和收益，再决定是否保留。\n",[135,1043,1044],{"class":137,"line":145},[135,1045,1046],{},"CREATE INDEX idx_products_title_trgm\n",[135,1048,1049],{"class":137,"line":160},[135,1050,1051],{},"ON search_single.products USING GIN (title public.gin_trgm_ops);\n",[135,1053,1054],{"class":137,"line":188},[135,1055,215],{"emptyLinePlaceholder":214},[135,1057,1058],{"class":137,"line":211},[135,1059,1060],{},"-- 型号子串匹配；返回记录取决于当前数据是否包含该型号。\n",[135,1062,1063],{"class":137,"line":218},[135,1064,1065],{},"SELECT id, title\n",[135,1067,1068],{"class":137,"line":230},[135,1069,1070],{},"FROM search_single.products\n",[135,1072,1073],{"class":137,"line":238},[135,1074,1075],{},"WHERE title ILIKE'%AirPods%'\n",[135,1077,1078],{"class":137,"line":244},[135,1079,1080],{},"AND is_active = TRUE\n",[135,1082,1083],{"class":137,"line":255},[135,1084,1085],{},"ORDER BY id\n",[135,1087,1088],{"class":137,"line":334},[135,1089,1090],{},"LIMIT 20;\n",[135,1092,1093],{"class":137,"line":340},[135,1094,215],{"emptyLinePlaceholder":214},[135,1096,1097],{"class":137,"line":346},[135,1098,1099],{},"-- 故意把 AirPods 写成 AirPdos，观察候选与分数。\n",[135,1101,1102],{"class":137,"line":352},[135,1103,1104],{},"SELECT id, title,\n",[135,1106,1107],{"class":137,"line":453},[135,1108,1109],{},"       public.similarity(title, '苹果 AirPdos Pro 2 蓝牙耳机') AS score\n",[135,1111,1112],{"class":137,"line":459},[135,1113,1070],{},[135,1115,1116],{"class":137,"line":465},[135,1117,1118],{},"WHERE title % '苹果 AirPdos Pro 2 蓝牙耳机'\n",[135,1120,1121],{"class":137,"line":470},[135,1122,1080],{},[135,1124,1125],{"class":137,"line":476},[135,1126,1127],{},"ORDER BY score DESC, id\n",[135,1129,1130],{"class":137,"line":482},[135,1131,1132],{},"LIMIT10;\n",[25,1134,1136],{"id":1135},"_5-自定义词典","5. 自定义词典",[11,1138,1139],{},"如果业务品牌被拆成了不合适的词项，可以进一步尝试自定义词典。例如先检查虚构品牌“声谷”的分词结果，再决定是否加入业务词。不要仅凭品牌名字推断默认分词一定正确或一定错误。",[11,1141,1142],{},"下面依据 zhparser 2.4 的自定义词表与同步函数整理。",[126,1144,1146],{"className":279,"code":1145,"language":281,"meta":131,"style":131},"INSERT INTO zhparser.zhprs_custom_word(word, tf, attr)\nVALUES ('声谷', 100, '@')\nON CONFLICT (word)\nDO UPDATE SET tf = EXCLUDED.tf, attr = EXCLUDED.attr;\n\n-- 可选：验证停用处理。不要直接套用到生产商品语料。\nINSERT INTO zhparser.zhprs_custom_word(word, attr)\nVALUES ('进行', '!')\nON CONFLICT (word)\nDO UPDATE SETattr = EXCLUDED.attr;\n\nSELECT public.sync_zhprs_custom_word();\n",[36,1147,1148,1153,1158,1163,1168,1172,1177,1182,1187,1191,1196,1200],{"__ignoreMap":131},[135,1149,1150],{"class":137,"line":138},[135,1151,1152],{},"INSERT INTO zhparser.zhprs_custom_word(word, tf, attr)\n",[135,1154,1155],{"class":137,"line":145},[135,1156,1157],{},"VALUES ('声谷', 100, '@')\n",[135,1159,1160],{"class":137,"line":160},[135,1161,1162],{},"ON CONFLICT (word)\n",[135,1164,1165],{"class":137,"line":188},[135,1166,1167],{},"DO UPDATE SET tf = EXCLUDED.tf, attr = EXCLUDED.attr;\n",[135,1169,1170],{"class":137,"line":211},[135,1171,215],{"emptyLinePlaceholder":214},[135,1173,1174],{"class":137,"line":218},[135,1175,1176],{},"-- 可选：验证停用处理。不要直接套用到生产商品语料。\n",[135,1178,1179],{"class":137,"line":230},[135,1180,1181],{},"INSERT INTO zhparser.zhprs_custom_word(word, attr)\n",[135,1183,1184],{"class":137,"line":238},[135,1185,1186],{},"VALUES ('进行', '!')\n",[135,1188,1189],{"class":137,"line":244},[135,1190,1162],{},[135,1192,1193],{"class":137,"line":255},[135,1194,1195],{},"DO UPDATE SETattr = EXCLUDED.attr;\n",[135,1197,1198],{"class":137,"line":334},[135,1199,215],{"emptyLinePlaceholder":214},[135,1201,1202],{"class":137,"line":340},[135,1203,1204],{},"SELECT public.sync_zhprs_custom_word();\n",[11,1206,1207,1210,1211,1214,1215,1217,1218,1221,1222,1225,1226,1228],{},[36,1208,1209],{},"@"," 是该自定义词表的默认属性，",[36,1212,1213],{},"!"," 用于停用处理。最终词性、词项及是否进入向量，应通过 ",[36,1216,560],{}," 和 ",[36,1219,1220],{},"to_tsvector"," 确认。",[36,1223,1224],{},"tf"," 是分词词典参数，不能把它理解为 ",[36,1227,87],{}," 的加分权重，也不能仅凭设置了较高值就保证所有模式下都按整词处理。",[11,1230,1231],{},"提交词表变更并成功同步后，可断开并重建连接再验证，避免旧连接状态干扰；连接池应确认更新的是物理连接。新连接中重新设置与写入、查询一致的分词选项：",[126,1233,1235],{"className":279,"code":1234,"language":281,"meta":131,"style":131},"SET zhparser.multi_short = off;\nSET zhparser.multi_duality = off;\nSET zhparser.multi_zmain = off;\nSET zhparser.multi_zall = off;\n\nSELECT *\nFROM ts_debug('search_single.chinese_zh', '声谷 进行 降噪');\nSELECT to_tsvector('search_single.chinese_zh', '声谷 进行 降噪');\n",[36,1236,1237,1241,1245,1249,1253,1257,1261,1266],{"__ignoreMap":131},[135,1238,1239],{"class":137,"line":138},[135,1240,508],{},[135,1242,1243],{"class":137,"line":145},[135,1244,514],{},[135,1246,1247],{"class":137,"line":160},[135,1248,520],{},[135,1250,1251],{"class":137,"line":188},[135,1252,526],{},[135,1254,1255],{"class":137,"line":211},[135,1256,215],{"emptyLinePlaceholder":214},[135,1258,1259],{"class":137,"line":218},[135,1260,537],{},[135,1262,1263],{"class":137,"line":230},[135,1264,1265],{},"FROM ts_debug('search_single.chinese_zh', '声谷 进行 降噪');\n",[135,1267,1268],{"class":137,"line":238},[135,1269,1270],{},"SELECT to_tsvector('search_single.chinese_zh', '声谷 进行 降噪');\n",[11,1272,1273,1274],{},"只有确认新分词符合预期后，才进入已有数据的向量重算阶段。",[33,1275,1276,1277,1279],{},"修改词库不会自动改写已经存储的 ",[36,1278,83],{},"，仅重建 GIN 索引也不能代替重算向量。",[11,1281,1282],{},"对于正文单表，可以按主键范围分批强制重算。例如：",[126,1284,1286],{"className":279,"code":1285,"language":281,"meta":131,"style":131},"-- 仅示范一个批次。源文本不变，同时把新向量置为 NULL，\n-- 让 BEFORE 触发器在同一条 UPDATE 内重新生成向量。\nUPDATE search_single.products\nSET title = title,\n    search_vector = NULL\nWHERE id > 0 AND id \u003C= 10000;\n",[36,1287,1288,1293,1298,1302,1307,1312],{"__ignoreMap":131},[135,1289,1290],{"class":137,"line":138},[135,1291,1292],{},"-- 仅示范一个批次。源文本不变，同时把新向量置为 NULL，\n",[135,1294,1295],{"class":137,"line":145},[135,1296,1297],{},"-- 让 BEFORE 触发器在同一条 UPDATE 内重新生成向量。\n",[135,1299,1300],{"class":137,"line":160},[135,1301,801],{},[135,1303,1304],{"class":137,"line":188},[135,1305,1306],{},"SET title = title,\n",[135,1308,1309],{"class":137,"line":211},[135,1310,1311],{},"    search_vector = NULL\n",[135,1313,1314],{"class":137,"line":218},[135,1315,1316],{},"WHERE id > 0 AND id \u003C= 10000;\n",[25,1318,1320],{"id":1319},"_7-到这里能得出什么结论","7. 到这里能得出什么结论",[11,1322,1323,1324,1327],{},"我的阶段性判断是：",[33,1325,1326],{},"数据已经在 PG 中，且需求主要是关键词检索和结构化筛选时，可以先做一个可测量的原型；是否继续使用，应由召回质量、性能和维护成本共同决定。"," 如果相关性需求不断增加，或搜索明显影响交易负载，再评估独立搜索服务，而不是仅凭“少一个组件”就确定选型。",[41,1329,1330,1343],{},[44,1331,1332],{},[47,1333,1334,1337,1340],{},[50,1335,1336],{},"维度",[50,1338,1339],{},"本文 PostgreSQL 方案",[50,1341,1342],{},"Elasticsearch",[60,1344,1345,1356,1367,1378],{},[47,1346,1347,1350,1353],{},[65,1348,1349],{},"数据维护",[65,1351,1352],{},"单表向量可在事务内维护；",[65,1354,1355],{},"需要把业务数据同步到独立搜索索引，并处理同步失败、重试和核对",[47,1357,1358,1361,1364],{},[65,1359,1360],{},"相关性",[65,1362,1363],{},"可配置字段权重，并按词项及位置排名；不等于自动获得合适的业务排序",[65,1365,1366],{},"默认采用 BM25，分析器、同义词及查询能力更丰富；中文分词方案仍需单独选择",[47,1368,1369,1372,1375],{},[65,1370,1371],{},"资源开销",[65,1373,1374],{},"少部署一套服务，但分词、GIN 维护和搜索会与业务负载争用数据库资源",[65,1376,1377],{},"增加独立搜索服务及其容量、分片、映射和运维成本",[47,1379,1380,1383,1386],{},[65,1381,1382],{},"适用判断",[65,1384,1385],{},"需要验证业务搜索需求能否由当前能力满足",[65,1387,1388],{},"需要验证新增检索能力是否值得相应的同步与运维投入",[11,1390,1391],{},"这次实验的价值，不在于提前宣布 PG 可以替代 ES，而在于把“能搜到”拆成可以逐项验证的问题：分词是否合适，查询语义是否一致，向量是否及时维护，以及性能是否满足业务要求。每一步有可复现的输入和输出，最终的选型结论才有依据。",[1393,1394],"hr",{},[11,1396,1397],{},"#PG  #PostgreSQL  #分词  #全文检索  #触发器",[1399,1400,1401],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}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);}",{"title":131,"searchDepth":145,"depth":145,"links":1403},[1404,1405,1410,1415,1416,1417],{"id":27,"depth":145,"text":28},{"id":368,"depth":145,"text":369,"children":1406},[1407,1408,1409],{"id":373,"depth":160,"text":374},{"id":571,"depth":160,"text":572},{"id":769,"depth":160,"text":770},{"id":902,"depth":145,"text":903,"children":1411},[1412,1413,1414],{"id":906,"depth":160,"text":907},{"id":937,"depth":160,"text":938},{"id":989,"depth":160,"text":990},{"id":1027,"depth":145,"text":1028},{"id":1135,"depth":145,"text":1136},{"id":1319,"depth":145,"text":1320},"article","2026-10-23","基于 PostgreSQL 18.6、zhparser 2.4 和 pg_trgm 1.6，实测中文商品全文检索的实现流程，涵盖扩展安装、中文分词、搜索向量与触发器维护、历史数据回填、GIN 索引、查询语义、相关性评分、近似匹配和自定义词典，并讨论何时需要评估 Elasticsearch。","md",{"publishedAt":1423,"author":1424,"account":1425,"tags":1426,"source":1429,"downloadedAt":1430},"2026-10-23T21:29:00+08:00","码上智能","一起莱吧",[1427,38,96,1428,1342],"PostgreSQL","全文检索","https:\u002F\u002Fmp.weixin.qq.com\u002Fs\u002Fa-GscWdldVi1_vOvI7rYcA","2026-10-24T22:51:37+08:00","\u002Farticles\u002Fes-postgresql",{"title":6,"description":1420},{"loc":1431,"images":1434},[1435,1436,1437,1438],{"loc":17},{"loc":916},{"loc":976},{"loc":1009},"articles\u002F先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录","b_KVJYap1x8XMDJ0rQIOtwIAlIy129s4pXwedYJ6fME",{"id":1442,"title":1443,"body":1444,"category":1418,"cover":1455,"date":6337,"description":6338,"extension":1421,"meta":6339,"navigation":214,"path":6340,"seo":6341,"sitemap":6342,"stem":6345,"__hash__":6346},"articles\u002Farticles\u002Fspring-boot-dockerfile.md","同样是打镜像，为什么别人 20 秒我 5 分钟？—— Spring Boot 分层构建实测记录",{"type":8,"value":1445,"toc":6304},[1446,1450,1457,1460,1463,1485,1496,1499,1523,1526,1530,1533,1631,1637,1676,1679,1682,1702,1706,1709,1715,1822,1836,1858,1861,1864,1886,1889,1897,1908,1911,1950,1953,1959,1965,1969,1979,2035,2051,2055,2068,2123,2266,2279,2286,2289,2347,2351,2354,2362,2381,2384,2406,2410,2434,2439,2594,2627,2632,2643,2648,2678,2684,2688,2691,2752,2757,2799,2824,2828,2875,2881,2908,2929,2932,2960,2964,2981,2986,3063,3068,3072,3086,3203,3210,3249,3266,3270,3402,3418,3424,3430,3436,3472,3485,3489,3543,3547,3603,3608,3612,3619,3623,3626,3678,3682,3690,3705,3719,3727,3738,3745,3779,3796,3814,3819,4174,4177,4224,4239,4357,4364,4368,4371,4421,4427,4432,4625,4628,4634,4637,4651,4661,4666,4856,4859,4883,4889,4893,4896,4941,4948,4999,5002,5005,5077,5084,5088,5168,5182,5186,5189,5311,5314,5320,5324,5432,5436,5440,5447,5466,5478,5482,5485,5515,5528,5533,5542,5546,5549,5558,5578,5584,5589,5607,5612,5617,5674,5688,5693,5696,5709,5753,5759,5765,5785,5788,5802,5807,5814,5837,5840,5920,5924,5927,5940,5955,5964,5970,5974,5977,6033,6046,6096,6138,6142,6145,6268,6272,6275,6281,6287,6290,6292,6301],[25,1447,1449],{"id":1448},"一事情的起因","一、事情的起因",[11,1451,1452],{},[14,1453],{"alt":1454,"src":1455,"title":1456},"Spring Boot分层构建示意图","\u002Fimg\u002FspringbootDockerfile.png","示意图",[11,1458,1459],{},"项目是个很普通的 Spring Boot 服务，打成 fat jar 大概 60MB，最终镜像 200MB 左右。",[11,1461,1462],{},"一开始的 Dockerfile 是三行：",[126,1464,1468],{"className":1465,"code":1466,"language":1467,"meta":131,"style":131},"language-dockerfile shiki shiki-themes github-dark","FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\nCOPY target\u002Fdemo.jar \u002Fapp.jar\nENTRYPOINT [\"java\", \"-jar\", \"\u002Fapp.jar\"]\n","dockerfile",[36,1469,1470,1475,1480],{"__ignoreMap":131},[135,1471,1472],{"class":137,"line":138},[135,1473,1474],{},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\n",[135,1476,1477],{"class":137,"line":145},[135,1478,1479],{},"COPY target\u002Fdemo.jar \u002Fapp.jar\n",[135,1481,1482],{"class":137,"line":160},[135,1483,1484],{},"ENTRYPOINT [\"java\", \"-jar\", \"\u002Fapp.jar\"]\n",[11,1486,1487,1488,1491,1492,1495],{},"用起来挺爽：IDEA 里 ",[36,1489,1490],{},"mvn package"," 一下，",[36,1493,1494],{},"docker build"," 一秒钟结束。",[11,1497,1498],{},"问题出在两个地方：",[1500,1501,1502,1513],"ol",{},[1503,1504,1505,1508,1509,1512],"li",{},[33,1506,1507],{},"容器启动慢","。同一个 jar，本地 ",[36,1510,1511],{},"java -jar"," 三五秒起来，放进容器要十几秒。滚动发布的时候这个差距会被放大——K8s 探针超时、就绪慢、扩容慢，全跟着遭殃。",[1503,1514,1515,1518,1519,1522],{},[33,1516,1517],{},"换到 CI 上就崩了","。本地构建快是因为 jar 已经在本地了，CI 机器上得先拉代码、装 Maven、下依赖，一次构建五六分钟，其中 95% 的时间在下载那些",[33,1520,1521],{},"根本没变过","的依赖。",[11,1524,1525],{},"于是就有了下面这四轮折腾。",[25,1527,1529],{"id":1528},"二结论先行","二、结论先行",[11,1531,1532],{},"先把实测结果摆出来，后面再逐个解释为什么。",[41,1534,1535,1557],{},[44,1536,1537],{},[47,1538,1539,1542,1545,1548,1551,1554],{},[50,1540,1541],{},"方案",[50,1543,1544],{},"首次构建",[50,1546,1547],{},"改业务代码后构建",[50,1549,1550],{},"镜像大小",[50,1552,1553],{},"容器启动",[50,1555,1556],{},"层缓存利用率",[60,1558,1559,1578,1596,1614],{},[47,1560,1561,1564,1567,1569,1572,1575],{},[65,1562,1563],{},"① IDEA 打包 + COPY（不分层）",[65,1565,1566],{},"1~2 秒",[65,1568,1566],{},[65,1570,1571],{},"~200MB",[65,1573,1574],{},"5~15 秒",[65,1576,1577],{},"❌ 无（也不需要）",[47,1579,1580,1583,1586,1588,1590,1593],{},[65,1581,1582],{},"② IDEA 打包 + COPY 后分层",[65,1584,1585],{},"3~5 秒",[65,1587,1585],{},[65,1589,1571],{},[65,1591,1592],{},"1~3 秒",[65,1594,1595],{},"❌ 每次都要重新解压",[47,1597,1598,1601,1604,1607,1609,1611],{},[65,1599,1600],{},"③ Dockerfile 打包 + 分层（首次）",[65,1602,1603],{},"2~5 分钟",[65,1605,1606],{},"—",[65,1608,1571],{},[65,1610,1592],{},[65,1612,1613],{},"✅ 依赖层永久缓存",[47,1615,1616,1619,1621,1624,1626,1628],{},[65,1617,1618],{},"④ Dockerfile 打包 + 分层（第二次起）",[65,1620,1606],{},[65,1622,1623],{},"10~30 秒",[65,1625,1571],{},[65,1627,1592],{},[65,1629,1630],{},"✅ 只重建 application 层",[11,1632,1633,1636],{},[33,1634,1635],{},"三点口径说明","，不然后面的数字容易被误读：",[1638,1639,1640,1652,1666],"ul",{},[1503,1641,1642,1648,1649,1651],{},[33,1643,1644,1645,1647],{},"构建时间只统计 ",[36,1646,1494],{}," 本身","。方案 ①② 在这之前还得跑一次本地 ",[36,1650,1490],{},"（十几秒），方案 ③④ 不用——所以 ①② 的\"1~2 秒\"是建立在\"jar 已经躺在 target 里\"的前提上的。",[1503,1653,1654,1657,1658,1661,1662,1665],{},[33,1655,1656],{},"容器启动时间","取的是 Spring 日志里 ",[36,1659,1660],{},"process running for x.xxx"," 那个数字，不是 ",[36,1663,1664],{},"Started ... in x.xxx","，两者差着 JVM 启动那一段。",[1503,1667,1668,1671,1672,1675],{},[33,1669,1670],{},"镜像大小跟基础镜像强相关","。本文的底座是完整 JDK + Rocky Linux，光基础镜像就占了大头，应用那 60MB 反而是小头。换成 JRE 变体或更小的 OS 底座，这一列会明显变化——所以别只盯总数，先 ",[36,1673,1674],{},"docker images"," 看一眼底座占多少（第七章有具体做法）。",[11,1677,1678],{},"具体怎么测、脚本长什么样，见第五章。",[11,1680,1681],{},"一句话总结：",[1638,1683,1684,1690,1696],{},[1503,1685,1686,1689],{},[33,1687,1688],{},"只关心构建快"," → 方案 ①，代价是启动慢、镜像不可复现。",[1503,1691,1692,1695],{},[33,1693,1694],{},"只关心启动快"," → 方案 ②，但分层白分了，缓存一次都命中不了。",[1503,1697,1698,1701],{},[33,1699,1700],{},"构建 + 启动 + 可复现都要"," → 方案 ③\u002F④，代价是首次构建慢，需要配好缓存。",[25,1703,1705],{"id":1704},"三准备工作pom-里开启分层","三、准备工作：pom 里开启分层",[11,1707,1708],{},"方案 ②③④ 都依赖 Spring Boot 的\"分层 jar\"能力，所以先得确认 pom 配对。",[11,1710,1711,1714],{},[33,1712,1713],{},"好消息是：Spring Boot 2.3+ 默认就是开启的","，用 Spring Initializr 生成的项目什么都不用改。但建议显式写出来，一是自文档化，二是避免被同事的其他配置覆盖掉。",[126,1716,1720],{"className":1717,"code":1718,"language":1719,"meta":131,"style":131},"language-xml shiki shiki-themes github-dark","\u003Cbuild>\n    \u003Cplugins>\n        \u003Cplugin>\n            \u003CgroupId>org.springframework.boot\u003C\u002FgroupId>\n            \u003CartifactId>spring-boot-maven-plugin\u003C\u002FartifactId>\n            \u003Cconfiguration>\n                \u003Clayers>\n                    \u003Cenabled>true\u003C\u002Fenabled>\n                \u003C\u002Flayers>\n            \u003C\u002Fconfiguration>\n            \u003Cexecutions>\n                \u003Cexecution>\n                    \u003Cgoals>\n                        \u003Cgoal>repackage\u003C\u002Fgoal>\n                    \u003C\u002Fgoals>\n                \u003C\u002Fexecution>\n            \u003C\u002Fexecutions>\n        \u003C\u002Fplugin>\n    \u003C\u002Fplugins>\n\u003C\u002Fbuild>\n","xml",[36,1721,1722,1727,1732,1737,1742,1747,1752,1757,1762,1767,1772,1777,1782,1787,1792,1797,1802,1807,1812,1817],{"__ignoreMap":131},[135,1723,1724],{"class":137,"line":138},[135,1725,1726],{},"\u003Cbuild>\n",[135,1728,1729],{"class":137,"line":145},[135,1730,1731],{},"    \u003Cplugins>\n",[135,1733,1734],{"class":137,"line":160},[135,1735,1736],{},"        \u003Cplugin>\n",[135,1738,1739],{"class":137,"line":188},[135,1740,1741],{},"            \u003CgroupId>org.springframework.boot\u003C\u002FgroupId>\n",[135,1743,1744],{"class":137,"line":211},[135,1745,1746],{},"            \u003CartifactId>spring-boot-maven-plugin\u003C\u002FartifactId>\n",[135,1748,1749],{"class":137,"line":218},[135,1750,1751],{},"            \u003Cconfiguration>\n",[135,1753,1754],{"class":137,"line":230},[135,1755,1756],{},"                \u003Clayers>\n",[135,1758,1759],{"class":137,"line":238},[135,1760,1761],{},"                    \u003Cenabled>true\u003C\u002Fenabled>\n",[135,1763,1764],{"class":137,"line":244},[135,1765,1766],{},"                \u003C\u002Flayers>\n",[135,1768,1769],{"class":137,"line":255},[135,1770,1771],{},"            \u003C\u002Fconfiguration>\n",[135,1773,1774],{"class":137,"line":334},[135,1775,1776],{},"            \u003Cexecutions>\n",[135,1778,1779],{"class":137,"line":340},[135,1780,1781],{},"                \u003Cexecution>\n",[135,1783,1784],{"class":137,"line":346},[135,1785,1786],{},"                    \u003Cgoals>\n",[135,1788,1789],{"class":137,"line":352},[135,1790,1791],{},"                        \u003Cgoal>repackage\u003C\u002Fgoal>\n",[135,1793,1794],{"class":137,"line":453},[135,1795,1796],{},"                    \u003C\u002Fgoals>\n",[135,1798,1799],{"class":137,"line":459},[135,1800,1801],{},"                \u003C\u002Fexecution>\n",[135,1803,1804],{"class":137,"line":465},[135,1805,1806],{},"            \u003C\u002Fexecutions>\n",[135,1808,1809],{"class":137,"line":470},[135,1810,1811],{},"        \u003C\u002Fplugin>\n",[135,1813,1814],{"class":137,"line":476},[135,1815,1816],{},"    \u003C\u002Fplugins>\n",[135,1818,1819],{"class":137,"line":482},[135,1820,1821],{},"\u003C\u002Fbuild>\n",[11,1823,1824,1825,1828,1829,39,1832,1835],{},"关键是 ",[36,1826,1827],{},"\u003Cgoal>repackage\u003C\u002Fgoal>","——",[33,1830,1831],{},"只有被 repackage 过的 fat jar 才有分层结构",[36,1833,1834],{},"mvn jar:jar"," 打出来的原始 jar 不分层，解开就是一坨 class 文件。",[11,1837,1838,1839,1842,1843,1846,1847,1850,1851,1854,1855,1857],{},"如果你的项目继承了 ",[36,1840,1841],{},"spring-boot-starter-parent","，",[36,1844,1845],{},"repackage"," 已经绑在 ",[36,1848,1849],{},"package"," 阶段上了，",[36,1852,1853],{},"\u003Cexecutions>"," 那段可以省略；自己管理 parent 的项目必须显式声明，否则 ",[36,1856,1490],{}," 出来的就是个普通 jar。",[371,1859,1860],{"id":1860},"验证有没有生效",[11,1862,1863],{},"一行命令：",[126,1865,1867],{"className":128,"code":1866,"language":130,"meta":131,"style":131},"java -Djarmode=layertools -jar target\u002Fdemo-0.0.1-SNAPSHOT.jar list\n",[36,1868,1869],{"__ignoreMap":131},[135,1870,1871,1874,1877,1880,1883],{"class":137,"line":138},[135,1872,1873],{"class":148},"java",[135,1875,1876],{"class":152}," -Djarmode=layertools",[135,1878,1879],{"class":152}," -jar",[135,1881,1882],{"class":156}," target\u002Fdemo-0.0.1-SNAPSHOT.jar",[135,1884,1885],{"class":156}," list\n",[11,1887,1888],{},"输出这四层名字就对了：",[126,1890,1895],{"className":1891,"code":1893,"language":1894},[1892],"language-text","dependencies\nspring-boot-loader\nsnapshot-dependencies\napplication\n","text",[36,1896,1893],{"__ignoreMap":131},[11,1898,1899,1900,1903,1904,1907],{},"如果报 ",[36,1901,1902],{},"is not layered","，或者压根不认 ",[36,1905,1906],{},"layertools"," 这个 jarmode，说明 jar 没被 repackage，回去检查 pom。",[11,1909,1910],{},"顺手确认一下每层的体积分布，能直观感受到\"依赖层有多大、值不值得缓存\"：",[126,1912,1914],{"className":128,"code":1913,"language":130,"meta":131,"style":131},"java -Djarmode=layertools -jar target\u002Fdemo.jar extract --destination \u002Ftmp\u002Flayers\ndu -sh \u002Ftmp\u002Flayers\u002F*\n",[36,1915,1916,1936],{"__ignoreMap":131},[135,1917,1918,1920,1922,1924,1927,1930,1933],{"class":137,"line":138},[135,1919,1873],{"class":148},[135,1921,1876],{"class":152},[135,1923,1879],{"class":152},[135,1925,1926],{"class":156}," target\u002Fdemo.jar",[135,1928,1929],{"class":156}," extract",[135,1931,1932],{"class":152}," --destination",[135,1934,1935],{"class":156}," \u002Ftmp\u002Flayers\n",[135,1937,1938,1941,1944,1947],{"class":137,"line":145},[135,1939,1940],{"class":148},"du",[135,1942,1943],{"class":152}," -sh",[135,1945,1946],{"class":156}," \u002Ftmp\u002Flayers\u002F",[135,1948,1949],{"class":152},"*\n",[11,1951,1952],{},"典型输出（80 个依赖左右的项目）：",[126,1954,1957],{"className":1955,"code":1956,"language":1894},[1892],"4.0K    \u002Ftmp\u002Flayers\u002Fapplication        ← 你自己的代码，每次都变\n1.1M    \u002Ftmp\u002Flayers\u002Fspring-boot-loader\n8.0M    \u002Ftmp\u002Flayers\u002Fsnapshot-dependencies\n52M     \u002Ftmp\u002Flayers\u002Fdependencies       ← 这 52M 就是分层要保住的东西\n",[36,1958,1956],{"__ignoreMap":131},[11,1960,1961,1964],{},[36,1962,1963],{},"dependencies"," 占了绝大头，这就是方案 ③\u002F④ 值得折腾的全部理由。",[371,1966,1968],{"id":1967},"spring-boot-33-换了新写法","Spring Boot 3.3+ 换了新写法",[11,1970,1971,1972,1974,1975,1978],{},"老的 ",[36,1973,1906],{}," 被标记废弃，改用 ",[36,1976,1977],{},"tools","：",[126,1980,1982],{"className":128,"code":1981,"language":130,"meta":131,"style":131},"# 查看分层\njava -Djarmode=tools -jar target\u002Fdemo.jar list-layers\n\n# 解压：--layers 分层存放，--launcher 保留启动器\njava -Djarmode=tools -jar target\u002Fdemo.jar extract --layers --launcher --destination extracted\n",[36,1983,1984,1989,2003,2007,2012],{"__ignoreMap":131},[135,1985,1986],{"class":137,"line":138},[135,1987,1988],{"class":141},"# 查看分层\n",[135,1990,1991,1993,1996,1998,2000],{"class":137,"line":145},[135,1992,1873],{"class":148},[135,1994,1995],{"class":152}," -Djarmode=tools",[135,1997,1879],{"class":152},[135,1999,1926],{"class":156},[135,2001,2002],{"class":156}," list-layers\n",[135,2004,2005],{"class":137,"line":160},[135,2006,215],{"emptyLinePlaceholder":214},[135,2008,2009],{"class":137,"line":188},[135,2010,2011],{"class":141},"# 解压：--layers 分层存放，--launcher 保留启动器\n",[135,2013,2014,2016,2018,2020,2022,2024,2027,2030,2032],{"class":137,"line":211},[135,2015,1873],{"class":148},[135,2017,1995],{"class":152},[135,2019,1879],{"class":152},[135,2021,1926],{"class":156},[135,2023,1929],{"class":156},[135,2025,2026],{"class":152}," --layers",[135,2028,2029],{"class":152}," --launcher",[135,2031,1932],{"class":152},[135,2033,2034],{"class":156}," extracted\n",[11,2036,2037,2038,2040,2041,1842,2043,2046,2047,2050],{},"新项目建议直接用 ",[36,2039,1977],{},"，能少看一堆 deprecation warning。本文示例为了兼容 3.0~3.2 写的还是 ",[36,2042,1906],{},[33,2044,2045],{},"两者产出的目录结构完全一致","，Dockerfile 里的 ",[36,2048,2049],{},"COPY"," 不用改。",[371,2052,2054],{"id":2053},"进阶自定义分层规则","进阶：自定义分层规则",[11,2056,2057,2058,2061,2062,2064,2065,1978],{},"默认四层对大多数项目够用。但如果你依赖了公司内部二方库（",[36,2059,2060],{},"com.yourcompany.*","），它们变动频繁却被归进了 ",[36,2063,1963],{}," 层，就会白白拖累缓存命中率。这时候自定义 ",[36,2066,2067],{},"src\u002Flayers.xml",[126,2069,2071],{"className":1717,"code":2070,"language":1719,"meta":131,"style":131},"\u003Cplugin>\n    \u003CgroupId>org.springframework.boot\u003C\u002FgroupId>\n    \u003CartifactId>spring-boot-maven-plugin\u003C\u002FartifactId>\n    \u003Cconfiguration>\n        \u003Clayers>\n            \u003Cenabled>true\u003C\u002Fenabled>\n            \u003Cconfiguration>${project.basedir}\u002Fsrc\u002Flayers.xml\u003C\u002Fconfiguration>\n        \u003C\u002Flayers>\n    \u003C\u002Fconfiguration>\n\u003C\u002Fplugin>\n",[36,2072,2073,2078,2083,2088,2093,2098,2103,2108,2113,2118],{"__ignoreMap":131},[135,2074,2075],{"class":137,"line":138},[135,2076,2077],{},"\u003Cplugin>\n",[135,2079,2080],{"class":137,"line":145},[135,2081,2082],{},"    \u003CgroupId>org.springframework.boot\u003C\u002FgroupId>\n",[135,2084,2085],{"class":137,"line":160},[135,2086,2087],{},"    \u003CartifactId>spring-boot-maven-plugin\u003C\u002FartifactId>\n",[135,2089,2090],{"class":137,"line":188},[135,2091,2092],{},"    \u003Cconfiguration>\n",[135,2094,2095],{"class":137,"line":211},[135,2096,2097],{},"        \u003Clayers>\n",[135,2099,2100],{"class":137,"line":218},[135,2101,2102],{},"            \u003Cenabled>true\u003C\u002Fenabled>\n",[135,2104,2105],{"class":137,"line":230},[135,2106,2107],{},"            \u003Cconfiguration>${project.basedir}\u002Fsrc\u002Flayers.xml\u003C\u002Fconfiguration>\n",[135,2109,2110],{"class":137,"line":238},[135,2111,2112],{},"        \u003C\u002Flayers>\n",[135,2114,2115],{"class":137,"line":244},[135,2116,2117],{},"    \u003C\u002Fconfiguration>\n",[135,2119,2120],{"class":137,"line":255},[135,2121,2122],{},"\u003C\u002Fplugin>\n",[126,2124,2126],{"className":1717,"code":2125,"language":1719,"meta":131,"style":131},"\u003Clayers xmlns=\"http:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\"\n        xmlns:xsi=\"http:\u002F\u002Fwww.w3.org\u002F2001\u002FXMLSchema-instance\"\n        xsi:schemaLocation=\"http:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\n        https:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\u002Flayers-3.2.xsd\">\n    \u003Capplication>\n        \u003Cinto layer=\"spring-boot-loader\">\n            \u003Cinclude>org\u002Fspringframework\u002Fboot\u002Floader\u002F**\u003C\u002Finclude>\n        \u003C\u002Finto>\n        \u003Cinto layer=\"application\"\u002F>\n    \u003C\u002Fapplication>\n    \u003Cdependencies>\n        \u003Cinto layer=\"snapshot-dependencies\">\n            \u003Cinclude>*-SNAPSHOT.jar\u003C\u002Finclude>\n        \u003C\u002Finto>\n        \u003C!-- 公司内部包单独一层，别污染稳定的第三方依赖层 -->\n        \u003Cinto layer=\"company-dependencies\">\n            \u003Cinclude>com\u002Fyourcompany\u002F**\u003C\u002Finclude>\n        \u003C\u002Finto>\n        \u003Cinto layer=\"dependencies\"\u002F>\n    \u003C\u002Fdependencies>\n    \u003ClayerOrder>\n        \u003Clayer>dependencies\u003C\u002Flayer>\n        \u003Clayer>spring-boot-loader\u003C\u002Flayer>\n        \u003Clayer>snapshot-dependencies\u003C\u002Flayer>\n        \u003Clayer>company-dependencies\u003C\u002Flayer>\n        \u003Clayer>application\u003C\u002Flayer>\n    \u003C\u002FlayerOrder>\n\u003C\u002Flayers>\n",[36,2127,2128,2133,2138,2143,2148,2153,2158,2163,2168,2173,2178,2183,2188,2193,2197,2202,2207,2212,2216,2221,2226,2231,2236,2241,2246,2251,2256,2261],{"__ignoreMap":131},[135,2129,2130],{"class":137,"line":138},[135,2131,2132],{},"\u003Clayers xmlns=\"http:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\"\n",[135,2134,2135],{"class":137,"line":145},[135,2136,2137],{},"        xmlns:xsi=\"http:\u002F\u002Fwww.w3.org\u002F2001\u002FXMLSchema-instance\"\n",[135,2139,2140],{"class":137,"line":160},[135,2141,2142],{},"        xsi:schemaLocation=\"http:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\n",[135,2144,2145],{"class":137,"line":188},[135,2146,2147],{},"        https:\u002F\u002Fwww.springframework.org\u002Fschema\u002Fboot\u002Flayers\u002Flayers-3.2.xsd\">\n",[135,2149,2150],{"class":137,"line":211},[135,2151,2152],{},"    \u003Capplication>\n",[135,2154,2155],{"class":137,"line":218},[135,2156,2157],{},"        \u003Cinto layer=\"spring-boot-loader\">\n",[135,2159,2160],{"class":137,"line":230},[135,2161,2162],{},"            \u003Cinclude>org\u002Fspringframework\u002Fboot\u002Floader\u002F**\u003C\u002Finclude>\n",[135,2164,2165],{"class":137,"line":238},[135,2166,2167],{},"        \u003C\u002Finto>\n",[135,2169,2170],{"class":137,"line":244},[135,2171,2172],{},"        \u003Cinto layer=\"application\"\u002F>\n",[135,2174,2175],{"class":137,"line":255},[135,2176,2177],{},"    \u003C\u002Fapplication>\n",[135,2179,2180],{"class":137,"line":334},[135,2181,2182],{},"    \u003Cdependencies>\n",[135,2184,2185],{"class":137,"line":340},[135,2186,2187],{},"        \u003Cinto layer=\"snapshot-dependencies\">\n",[135,2189,2190],{"class":137,"line":346},[135,2191,2192],{},"            \u003Cinclude>*-SNAPSHOT.jar\u003C\u002Finclude>\n",[135,2194,2195],{"class":137,"line":352},[135,2196,2167],{},[135,2198,2199],{"class":137,"line":453},[135,2200,2201],{},"        \u003C!-- 公司内部包单独一层，别污染稳定的第三方依赖层 -->\n",[135,2203,2204],{"class":137,"line":459},[135,2205,2206],{},"        \u003Cinto layer=\"company-dependencies\">\n",[135,2208,2209],{"class":137,"line":465},[135,2210,2211],{},"            \u003Cinclude>com\u002Fyourcompany\u002F**\u003C\u002Finclude>\n",[135,2213,2214],{"class":137,"line":470},[135,2215,2167],{},[135,2217,2218],{"class":137,"line":476},[135,2219,2220],{},"        \u003Cinto layer=\"dependencies\"\u002F>\n",[135,2222,2223],{"class":137,"line":482},[135,2224,2225],{},"    \u003C\u002Fdependencies>\n",[135,2227,2228],{"class":137,"line":488},[135,2229,2230],{},"    \u003ClayerOrder>\n",[135,2232,2233],{"class":137,"line":494},[135,2234,2235],{},"        \u003Clayer>dependencies\u003C\u002Flayer>\n",[135,2237,2238],{"class":137,"line":499},[135,2239,2240],{},"        \u003Clayer>spring-boot-loader\u003C\u002Flayer>\n",[135,2242,2243],{"class":137,"line":505},[135,2244,2245],{},"        \u003Clayer>snapshot-dependencies\u003C\u002Flayer>\n",[135,2247,2248],{"class":137,"line":511},[135,2249,2250],{},"        \u003Clayer>company-dependencies\u003C\u002Flayer>\n",[135,2252,2253],{"class":137,"line":517},[135,2254,2255],{},"        \u003Clayer>application\u003C\u002Flayer>\n",[135,2257,2258],{"class":137,"line":523},[135,2259,2260],{},"    \u003C\u002FlayerOrder>\n",[135,2262,2263],{"class":137,"line":529},[135,2264,2265],{},"\u003C\u002Flayers>\n",[11,2267,2268,2271,2272,2274,2275,2278],{},[36,2269,2270],{},"layerOrder"," 里的顺序，就是 Dockerfile 里 ",[36,2273,2049],{}," 的顺序——",[33,2276,2277],{},"越不容易变的越靠前","。xsd 的版本号要跟你的 Spring Boot 版本对得上，写错了 IDEA 会飘红但不影响构建，容易忽略。",[11,2280,2281,2282,2285],{},"Gradle 项目对应写法是 ",[36,2283,2284],{},"bootJar { layer { ... } }","，思路一样，这里不展开。",[371,2287,2288],{"id":2288},"几个会让分层失效的配置",[1638,2290,2291,2313,2329,2338],{},[1503,2292,2293,2302,2303,2306,2307,2309,2310,39],{},[33,2294,2295,2298,2299],{},[36,2296,2297],{},"\u003Clayout>ZIP\u003C\u002Flayout>"," 或 ",[36,2300,2301],{},"NONE","：换成 ",[36,2304,2305],{},"PropertiesLauncher"," 后目录结构对不上，",[36,2308,1906],{}," 直接罢工。老老实实用默认的 ",[36,2311,2312],{},"JAR",[1503,2314,2315,1978,2322,2325,2326,39],{},[33,2316,2317,2318,2321],{},"配了 ",[36,2319,2320],{},"\u003Cclassifier>"," 产生多个 jar",[36,2323,2324],{},"target\u002F*.jar"," 通配符可能匹配到意料之外的文件，建议写死完整名或用 ",[36,2327,2328],{},"${project.build.finalName}.jar",[1503,2330,2331,2337],{},[33,2332,2333,2334],{},"老教程里的 ",[36,2335,2336],{},"\u003CincludeLayerTools>true\u003C\u002FincludeLayerTools>","：3.x 的插件已经没有这个配置项了，抄过来会报 unknown parameter，删掉即可（layertools 现在是内置的，不需要开关）。",[1503,2339,2340,2346],{},[33,2341,2342,2345],{},[36,2343,2344],{},"\u003Cexcludes>"," 排依赖排得太狠","：排掉 devtools 是好事，但别顺手把 loader 相关的包也排了。",[25,2348,2350],{"id":2349},"四逐个方案拆解","四、逐个方案拆解",[11,2352,2353],{},"开始之前先统一基础镜像。全文四个方案都用同一个，这样测出来的差异才是\"打包方式\"的差异，而不是\"基础镜像\"的差异：",[126,2355,2356],{"className":1465,"code":1474,"language":1467,"meta":131,"style":131},[36,2357,2358],{"__ignoreMap":131},[135,2359,2360],{"class":137,"line":138},[135,2361,1474],{},[11,2363,2364,2365,2372,2373,2376,2377,2380],{},"这是 ",[2366,2367,2371],"a",{"href":2368,"rel":2369},"https:\u002F\u002Fbellsoft.com\u002Fpages\u002Fdownloads\u002F",[2370],"nofollow","BellSoft"," 出的 Liberica JDK 镜像，基于 Rocky Linux，自带 JDK 21。tag 里的 ",[36,2374,2375],{},"-cds"," 后缀表示镜像内",[33,2378,2379],{},"预生成了 CDS（Class Data Sharing）归档","，JVM 启动时能直接复用已经解析好的类元数据，省掉一部分类加载和字节码解析的开销——这对启动时间是实打实的加成，所以下面所有方案的启动数据都是在\"已经有 JDK 层 CDS\"的基础上测的。",[11,2382,2383],{},"两个细节先记住，后面会用到：",[1638,2385,2386,2397],{},[1503,2387,2388,2389,2392,2393,2396],{},"它是",[33,2390,2391],{},"完整 JDK 而不是 JRE","，所以镜像基础体积比 ",[36,2394,2395],{},"*-jre"," 大不少（想瘦身见第七章）。",[1503,2398,2399,2400,2402,2403,2405],{},"它自带 ",[36,2401,1873],{}," 命令，所以在 builder 阶段跑 ",[36,2404,1906],{}," 解压没问题，不需要额外换镜像。",[371,2407,2409],{"id":2408},"方案-idea-打包-copy不分层","方案 ①：IDEA 打包 + COPY，不分层",[126,2411,2413],{"className":1465,"code":2412,"language":1467,"meta":131,"style":131},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\nWORKDIR \u002Fapp\nCOPY target\u002Fdemo.jar app.jar\nENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]\n",[36,2414,2415,2419,2424,2429],{"__ignoreMap":131},[135,2416,2417],{"class":137,"line":138},[135,2418,1474],{},[135,2420,2421],{"class":137,"line":145},[135,2422,2423],{},"WORKDIR \u002Fapp\n",[135,2425,2426],{"class":137,"line":160},[135,2427,2428],{},"COPY target\u002Fdemo.jar app.jar\n",[135,2430,2431],{"class":137,"line":188},[135,2432,2433],{},"ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]\n",[11,2435,2436],{},[33,2437,2438],{},"构建 & 运行",[126,2440,2442],{"className":128,"code":2441,"language":130,"meta":131,"style":131},"# 1) 本地打包。IDEA 里点 Maven → Lifecycle → package 效果一样\nmvn clean package -DskipTests\n\n# 2) 构建镜像。-t 打上方案标识，方便后面对比时区分\ndocker build -f Dockerfile -t demo:copy .\n\n# 3) 运行\ndocker run --rm -p 8080:8080 demo:copy\n\n# 指定环境\ndocker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev demo:copy\n\n# 后台跑 + 看日志\ndocker run -d --name demo -p 8080:8080 demo:copy\ndocker logs -f demo\n",[36,2443,2444,2449,2463,2467,2472,2495,2499,2504,2523,2527,2532,2552,2556,2561,2582],{"__ignoreMap":131},[135,2445,2446],{"class":137,"line":138},[135,2447,2448],{"class":141},"# 1) 本地打包。IDEA 里点 Maven → Lifecycle → package 效果一样\n",[135,2450,2451,2454,2457,2460],{"class":137,"line":145},[135,2452,2453],{"class":148},"mvn",[135,2455,2456],{"class":156}," clean",[135,2458,2459],{"class":156}," package",[135,2461,2462],{"class":152}," -DskipTests\n",[135,2464,2465],{"class":137,"line":160},[135,2466,215],{"emptyLinePlaceholder":214},[135,2468,2469],{"class":137,"line":188},[135,2470,2471],{"class":141},"# 2) 构建镜像。-t 打上方案标识，方便后面对比时区分\n",[135,2473,2474,2477,2480,2483,2486,2489,2492],{"class":137,"line":211},[135,2475,2476],{"class":148},"docker",[135,2478,2479],{"class":156}," build",[135,2481,2482],{"class":152}," -f",[135,2484,2485],{"class":156}," Dockerfile",[135,2487,2488],{"class":152}," -t",[135,2490,2491],{"class":156}," demo:copy",[135,2493,2494],{"class":156}," .\n",[135,2496,2497],{"class":137,"line":218},[135,2498,215],{"emptyLinePlaceholder":214},[135,2500,2501],{"class":137,"line":230},[135,2502,2503],{"class":141},"# 3) 运行\n",[135,2505,2506,2508,2511,2514,2517,2520],{"class":137,"line":238},[135,2507,2476],{"class":148},[135,2509,2510],{"class":156}," run",[135,2512,2513],{"class":152}," --rm",[135,2515,2516],{"class":152}," -p",[135,2518,2519],{"class":156}," 8080:8080",[135,2521,2522],{"class":156}," demo:copy\n",[135,2524,2525],{"class":137,"line":244},[135,2526,215],{"emptyLinePlaceholder":214},[135,2528,2529],{"class":137,"line":255},[135,2530,2531],{"class":141},"# 指定环境\n",[135,2533,2534,2536,2538,2540,2542,2544,2547,2550],{"class":137,"line":334},[135,2535,2476],{"class":148},[135,2537,2510],{"class":156},[135,2539,2513],{"class":152},[135,2541,2516],{"class":152},[135,2543,2519],{"class":156},[135,2545,2546],{"class":152}," -e",[135,2548,2549],{"class":156}," SPRING_PROFILES_ACTIVE=dev",[135,2551,2522],{"class":156},[135,2553,2554],{"class":137,"line":340},[135,2555,215],{"emptyLinePlaceholder":214},[135,2557,2558],{"class":137,"line":346},[135,2559,2560],{"class":141},"# 后台跑 + 看日志\n",[135,2562,2563,2565,2567,2570,2573,2576,2578,2580],{"class":137,"line":352},[135,2564,2476],{"class":148},[135,2566,2510],{"class":156},[135,2568,2569],{"class":152}," -d",[135,2571,2572],{"class":152}," --name",[135,2574,2575],{"class":156}," demo",[135,2577,2516],{"class":152},[135,2579,2519],{"class":156},[135,2581,2522],{"class":156},[135,2583,2584,2586,2589,2591],{"class":137,"line":453},[135,2585,2476],{"class":148},[135,2587,2588],{"class":156}," logs",[135,2590,2482],{"class":152},[135,2592,2593],{"class":156}," demo\n",[2595,2596,2597,2611,2620],"blockquote",{},[11,2598,2599,2606,2607,2610],{},[33,2600,2601,2602,2605],{},"注意：",[36,2603,2604],{},"ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"," 是 exec 形式，不会展开环境变量","，所以 ",[36,2608,2609],{},"-e JAVA_OPTS=-Xmx512m"," 传进去是无效的。要支持外部调 JVM 参数，得改成 shell 形式：",[126,2612,2614],{"className":1465,"code":2613,"language":1467,"meta":131,"style":131},"ENTRYPOINT [\"sh\", \"-c\", \"java $JAVA_OPTS -jar app.jar\"]\n",[36,2615,2616],{"__ignoreMap":131},[135,2617,2618],{"class":137,"line":138},[135,2619,2613],{},[11,2621,2622,2623,2626],{},"后面几个方案的 ENTRYPOINT 同理。这个坑本身跟分层无关，但",[33,2624,2625],{},"做对比测试时如果各方案的 JVM 参数不一致，测出来的启动时间就没有可比性了","——后面第五章会专门讲这个。",[11,2628,2629],{},[33,2630,2631],{},"优点",[1638,2633,2634,2637,2640],{},[1503,2635,2636],{},"构建快到可以忽略，本质就是一次文件拷贝。",[1503,2638,2639],{},"Dockerfile 极简，出问题好排查。",[1503,2641,2642],{},"本地开发迭代体验最好：改代码 → IDEA 打包 → build → run，十几秒闭环。",[11,2644,2645],{},[33,2646,2647],{},"缺点",[1638,2649,2650,2660,2666,2672],{},[1503,2651,2652,2655,2656,2659],{},[33,2653,2654],{},"启动慢","。这是最反直觉的一点：明明 jar 一模一样，为什么容器里就慢？因为 Spring Boot 的 fat jar 是\"jar 里套 jar\"，",[36,2657,2658],{},"BOOT-INF\u002Flib\u002F"," 下面还压着上百个依赖 jar。类加载器要读一个 class，得先在外层 jar 里定位内层 jar 的偏移量，再解出来。这种嵌套读取在磁盘\u002Foverlayfs 上的开销远大于直接读一个解压好的目录。",[1503,2661,2662,2665],{},[33,2663,2664],{},"镜像层无法复用","。业务代码和依赖全糊在一个 jar 里，改一行代码，整个 60MB 的层全部重传。推送到镜像仓库、跨节点拉取的时候，这个代价很实在。",[1503,2667,2668,2671],{},[33,2669,2670],{},"构建不可复现","。镜像内容取决于\"谁在哪台机器上用什么 JDK 和 Maven 版本打的包\"。本地能跑线上不能跑的经典来源。",[1503,2673,2674,2677],{},[33,2675,2676],{},"依赖本地环境","。CI 上还是得老老实实装 Maven，等于只是把慢的部分挪到了 Dockerfile 外面，总时长一点没少。",[11,2679,2680,2683],{},[33,2681,2682],{},"适用","：本地开发自测、Demo、对启动速度不敏感的批处理任务。",[371,2685,2687],{"id":2686},"方案-idea-打包-copy-进去再分层","方案 ②：IDEA 打包 + COPY 进去再分层",[11,2689,2690],{},"想法很自然：既然 fat jar 启动慢是因为嵌套，那我在镜像里把它解开不就行了？",[126,2692,2694],{"className":1465,"code":2693,"language":1467,"meta":131,"style":131},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS builder\nWORKDIR \u002Fbuild\nCOPY target\u002Fdemo.jar app.jar\nRUN java -Djarmode=layertools -jar app.jar extract\n\nFROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\nWORKDIR \u002Fapp\nCOPY --from=builder \u002Fbuild\u002Fdependencies\u002F .\u002F\nCOPY --from=builder \u002Fbuild\u002Fspring-boot-loader\u002F .\u002F\nCOPY --from=builder \u002Fbuild\u002Fsnapshot-dependencies\u002F .\u002F\nCOPY --from=builder \u002Fbuild\u002Fapplication\u002F .\u002F\nENTRYPOINT [\"java\", \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[36,2695,2696,2701,2706,2710,2715,2719,2723,2727,2732,2737,2742,2747],{"__ignoreMap":131},[135,2697,2698],{"class":137,"line":138},[135,2699,2700],{},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS builder\n",[135,2702,2703],{"class":137,"line":145},[135,2704,2705],{},"WORKDIR \u002Fbuild\n",[135,2707,2708],{"class":137,"line":160},[135,2709,2428],{},[135,2711,2712],{"class":137,"line":188},[135,2713,2714],{},"RUN java -Djarmode=layertools -jar app.jar extract\n",[135,2716,2717],{"class":137,"line":211},[135,2718,215],{"emptyLinePlaceholder":214},[135,2720,2721],{"class":137,"line":218},[135,2722,1474],{},[135,2724,2725],{"class":137,"line":230},[135,2726,2423],{},[135,2728,2729],{"class":137,"line":238},[135,2730,2731],{},"COPY --from=builder \u002Fbuild\u002Fdependencies\u002F .\u002F\n",[135,2733,2734],{"class":137,"line":244},[135,2735,2736],{},"COPY --from=builder \u002Fbuild\u002Fspring-boot-loader\u002F .\u002F\n",[135,2738,2739],{"class":137,"line":255},[135,2740,2741],{},"COPY --from=builder \u002Fbuild\u002Fsnapshot-dependencies\u002F .\u002F\n",[135,2743,2744],{"class":137,"line":334},[135,2745,2746],{},"COPY --from=builder \u002Fbuild\u002Fapplication\u002F .\u002F\n",[135,2748,2749],{"class":137,"line":340},[135,2750,2751],{},"ENTRYPOINT [\"java\", \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[11,2753,2754,2756],{},[36,2755,1906],{}," 会把 jar 拆成四层：",[126,2758,2760],{"className":128,"code":2759,"language":130,"meta":131,"style":131},"$ java -Djarmode=layertools -jar demo.jar list\ndependencies\nspring-boot-loader\nsnapshot-dependencies\napplication\n",[36,2761,2762,2779,2784,2789,2794],{"__ignoreMap":131},[135,2763,2764,2767,2770,2772,2774,2777],{"class":137,"line":138},[135,2765,2766],{"class":148},"$",[135,2768,2769],{"class":156}," java",[135,2771,1876],{"class":152},[135,2773,1879],{"class":152},[135,2775,2776],{"class":156}," demo.jar",[135,2778,1885],{"class":156},[135,2780,2781],{"class":137,"line":145},[135,2782,2783],{"class":148},"dependencies\n",[135,2785,2786],{"class":137,"line":160},[135,2787,2788],{"class":148},"spring-boot-loader\n",[135,2790,2791],{"class":137,"line":188},[135,2792,2793],{"class":148},"snapshot-dependencies\n",[135,2795,2796],{"class":137,"line":211},[135,2797,2798],{"class":148},"application\n",[1638,2800,2801,2806,2812,2818],{},[1503,2802,2803,2805],{},[36,2804,1963],{},"：第三方稳定依赖，变动频率最低",[1503,2807,2808,2811],{},[36,2809,2810],{},"spring-boot-loader","：Spring Boot 自己的启动器",[1503,2813,2814,2817],{},[36,2815,2816],{},"snapshot-dependencies","：SNAPSHOT 版本依赖，变动频率中等",[1503,2819,2820,2823],{},[36,2821,2822],{},"application","：你自己的代码和资源，每次都变",[11,2825,2826],{},[33,2827,2438],{},[126,2829,2831],{"className":128,"code":2830,"language":130,"meta":131,"style":131},"mvn clean package -DskipTests\ndocker build -f Dockerfile -t demo:copy-layered .\ndocker run --rm -p 8080:8080 demo:copy-layered\n",[36,2832,2833,2843,2860],{"__ignoreMap":131},[135,2834,2835,2837,2839,2841],{"class":137,"line":138},[135,2836,2453],{"class":148},[135,2838,2456],{"class":156},[135,2840,2459],{"class":156},[135,2842,2462],{"class":152},[135,2844,2845,2847,2849,2851,2853,2855,2858],{"class":137,"line":145},[135,2846,2476],{"class":148},[135,2848,2479],{"class":156},[135,2850,2482],{"class":152},[135,2852,2485],{"class":156},[135,2854,2488],{"class":152},[135,2856,2857],{"class":156}," demo:copy-layered",[135,2859,2494],{"class":156},[135,2861,2862,2864,2866,2868,2870,2872],{"class":137,"line":160},[135,2863,2476],{"class":148},[135,2865,2510],{"class":156},[135,2867,2513],{"class":152},[135,2869,2516],{"class":152},[135,2871,2519],{"class":156},[135,2873,2874],{"class":156}," demo:copy-layered\n",[11,2876,2877,2880],{},[33,2878,2879],{},"验证解压是否真的生效","——这一步建议每次都做，因为多阶段写漏了是不会报错的，只会让镜像悄悄变胖：",[126,2882,2884],{"className":128,"code":2883,"language":130,"meta":131,"style":131},"docker run --rm --entrypoint sh demo:copy-layered -c \"ls -la \u002Fapp && du -sh \u002Fapp\u002F*\"\n",[36,2885,2886],{"__ignoreMap":131},[135,2887,2888,2890,2892,2894,2897,2900,2902,2905],{"class":137,"line":138},[135,2889,2476],{"class":148},[135,2891,2510],{"class":156},[135,2893,2513],{"class":152},[135,2895,2896],{"class":152}," --entrypoint",[135,2898,2899],{"class":156}," sh",[135,2901,2857],{"class":156},[135,2903,2904],{"class":152}," -c",[135,2906,2907],{"class":156}," \"ls -la \u002Fapp && du -sh \u002Fapp\u002F*\"\n",[11,2909,2910,2911,2914,2915,2914,2918,2921,2922,2928],{},"正常应该看到 ",[36,2912,2913],{},"BOOT-INF\u002F","、",[36,2916,2917],{},"META-INF\u002F",[36,2919,2920],{},"org\u002Fspringframework\u002Fboot\u002Floader\u002F"," 这几项，",[33,2923,2924,2925],{},"并且看不到原始的 ",[36,2926,2927],{},"app.jar","。如果 jar 还在，说明多阶段没生效，镜像白胖了 60MB。",[11,2930,2931],{},"再对比一下两个镜像的层结构：",[126,2933,2935],{"className":128,"code":2934,"language":130,"meta":131,"style":131},"docker history demo:copy          # 一层巨大的 COPY\ndocker history demo:copy-layered  # 四条 COPY，体积递减\n",[36,2936,2937,2949],{"__ignoreMap":131},[135,2938,2939,2941,2944,2946],{"class":137,"line":138},[135,2940,2476],{"class":148},[135,2942,2943],{"class":156}," history",[135,2945,2491],{"class":156},[135,2947,2948],{"class":141},"          # 一层巨大的 COPY\n",[135,2950,2951,2953,2955,2957],{"class":137,"line":145},[135,2952,2476],{"class":148},[135,2954,2943],{"class":156},[135,2956,2857],{"class":156},[135,2958,2959],{"class":141},"  # 四条 COPY，体积递减\n",[11,2961,2962],{},[33,2963,2631],{},[1638,2965,2966,2975,2978],{},[1503,2967,2968,2971,2972,2974],{},[33,2969,2970],{},"启动速度确实救回来了","，1~3 秒，和本地 ",[36,2973,1511],{}," 解开跑基本一致。",[1503,2976,2977],{},"构建依然只要 3~5 秒，比方案 ① 只多了一次解压。",[1503,2979,2980],{},"镜像层是按变更频率排布的，推送\u002F拉取时理论上更省流量。",[11,2982,2983],{},[33,2984,2985],{},"缺点（这个坑我踩了挺久）",[1638,2987,2988,3010,3020,3046,3052],{},[1503,2989,2990,2993,2994,2997,2998,3001,3002,3005,3006,3009],{},[33,2991,2992],{},"分层在这里几乎是白分的","。Docker 的层缓存规则是：",[33,2995,2996],{},"某一层变了，它后面所有层全部失效","。而 ",[36,2999,3000],{},"COPY target\u002Fdemo.jar app.jar"," 这一层，每次改完代码 jar 的哈希都不同，缓存必然 miss；后面跟着的 ",[36,3003,3004],{},"RUN extract"," 和四条 ",[36,3007,3008],{},"COPY --from"," 自然也全部重来。也就是说，四层结构是有了，但每次构建都是完整地解压 + 完整地写四层，一点缓存都没吃到。",[1503,3011,3012,3015,3016,3019],{},[33,3013,3014],{},"依赖层没有真正被复用","。你以为 ",[36,3017,3018],{},"dependencies\u002F"," 内容没变就能命中缓存——不会的，因为它是从\"每次都变的那个 jar\"里解出来的，Docker 只看构建指令和上游层，不看解出来的文件内容是否恰好一样。",[1503,3021,3022,3025,3026,3028,3029,3032,3033,3035,3036,2914,3039,3042,3043,39],{},[33,3023,3024],{},"多了一次 JVM 冷启动","。builder 阶段要起一个 JVM 跑 ",[36,3027,1906],{}," 解压 60MB，这 1~2 秒就是方案 ② 比方案 ① 慢的全部来源。顺带一提，解压",[33,3030,3031],{},"只需要 JRE、不需要 JDK","，所以 builder 阶段其实可以换成 Liberica 的 JRE 变体；本文为了跟运行阶段保持一致、少一个变量，统一用了 JDK 镜像。真正的坑是基础镜像里压根没有 ",[36,3034,1873],{}," 命令（比如 ",[36,3037,3038],{},"distroless\u002Fstatic",[36,3040,3041],{},"alpine"," 裸镜像），或者镜像的 Java 版本低于 jar 的编译版本，会直接 ",[36,3044,3045],{},"UnsupportedClassVersionError",[1503,3047,3048,3051],{},[33,3049,3050],{},"依然不可复现","，本地环境问题一个没解决。",[1503,3053,3054,3055,3058,3059,3062],{},"如果忘了写第二个 stage、或者忘了 ",[36,3056,3057],{},"rm app.jar","，原始 jar 和解压后的文件会",[33,3060,3061],{},"同时留在镜像里","，体积直接翻倍。多阶段构建是必须的，不是可选优化。",[11,3064,3065,3067],{},[33,3066,2682],{},"：本地开发但很在意容器启动速度；或者 CI 环境没法改、只能在 Dockerfile 里做文章。",[371,3069,3071],{"id":3070},"方案-把-maven-搬进-dockerfile真正吃满层缓存","方案 ③④：把 Maven 搬进 Dockerfile，真正吃满层缓存",[11,3073,3074,3075,39],{},"这是\"正确\"的做法，也是唯一能让依赖层真正被缓存的方案。核心思路：",[33,3076,3077,3078,3081,3082,3085],{},"先只拷 ",[36,3079,3080],{},"pom.xml"," 去下依赖，再拷 ",[36,3083,3084],{},"src"," 去编译",[126,3087,3089],{"className":1465,"code":3088,"language":1467,"meta":131,"style":131},"# syntax=docker\u002Fdockerfile:1\nFROM maven:3.9-eclipse-temurin-21 AS build\nWORKDIR \u002Fworkspace\n\n# 1) 只拷 pom，这一层只会在依赖变化时失效\nCOPY pom.xml .\nRUN --mount=type=cache,target=\u002Froot\u002F.m2 \\\n    mvn -B -q dependency:go-offline\n\n# 2) 再拷源码，改业务代码只会让这一层之后失效\nCOPY src .\u002Fsrc\nRUN --mount=type=cache,target=\u002Froot\u002F.m2 \\\n    mvn -B -q clean package -DskipTests\n\n# 3) 构建阶段就解开\nRUN java -Djarmode=layertools -jar target\u002F*.jar extract --destination \u002Fextracted\n\nFROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\nWORKDIR \u002Fapp\nCOPY --from=build \u002Fextracted\u002Fdependencies\u002F .\u002F\nCOPY --from=build \u002Fextracted\u002Fspring-boot-loader\u002F .\u002F\nCOPY --from=build \u002Fextracted\u002Fsnapshot-dependencies\u002F .\u002F\nCOPY --from=build \u002Fextracted\u002Fapplication\u002F .\u002F\nENTRYPOINT [\"java\", \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[36,3090,3091,3096,3101,3106,3110,3115,3120,3125,3130,3134,3139,3144,3148,3153,3157,3162,3167,3171,3175,3179,3184,3189,3194,3199],{"__ignoreMap":131},[135,3092,3093],{"class":137,"line":138},[135,3094,3095],{},"# syntax=docker\u002Fdockerfile:1\n",[135,3097,3098],{"class":137,"line":145},[135,3099,3100],{},"FROM maven:3.9-eclipse-temurin-21 AS build\n",[135,3102,3103],{"class":137,"line":160},[135,3104,3105],{},"WORKDIR \u002Fworkspace\n",[135,3107,3108],{"class":137,"line":188},[135,3109,215],{"emptyLinePlaceholder":214},[135,3111,3112],{"class":137,"line":211},[135,3113,3114],{},"# 1) 只拷 pom，这一层只会在依赖变化时失效\n",[135,3116,3117],{"class":137,"line":218},[135,3118,3119],{},"COPY pom.xml .\n",[135,3121,3122],{"class":137,"line":230},[135,3123,3124],{},"RUN --mount=type=cache,target=\u002Froot\u002F.m2 \\\n",[135,3126,3127],{"class":137,"line":238},[135,3128,3129],{},"    mvn -B -q dependency:go-offline\n",[135,3131,3132],{"class":137,"line":244},[135,3133,215],{"emptyLinePlaceholder":214},[135,3135,3136],{"class":137,"line":255},[135,3137,3138],{},"# 2) 再拷源码，改业务代码只会让这一层之后失效\n",[135,3140,3141],{"class":137,"line":334},[135,3142,3143],{},"COPY src .\u002Fsrc\n",[135,3145,3146],{"class":137,"line":340},[135,3147,3124],{},[135,3149,3150],{"class":137,"line":346},[135,3151,3152],{},"    mvn -B -q clean package -DskipTests\n",[135,3154,3155],{"class":137,"line":352},[135,3156,215],{"emptyLinePlaceholder":214},[135,3158,3159],{"class":137,"line":453},[135,3160,3161],{},"# 3) 构建阶段就解开\n",[135,3163,3164],{"class":137,"line":459},[135,3165,3166],{},"RUN java -Djarmode=layertools -jar target\u002F*.jar extract --destination \u002Fextracted\n",[135,3168,3169],{"class":137,"line":465},[135,3170,215],{"emptyLinePlaceholder":214},[135,3172,3173],{"class":137,"line":470},[135,3174,1474],{},[135,3176,3177],{"class":137,"line":476},[135,3178,2423],{},[135,3180,3181],{"class":137,"line":482},[135,3182,3183],{},"COPY --from=build \u002Fextracted\u002Fdependencies\u002F .\u002F\n",[135,3185,3186],{"class":137,"line":488},[135,3187,3188],{},"COPY --from=build \u002Fextracted\u002Fspring-boot-loader\u002F .\u002F\n",[135,3190,3191],{"class":137,"line":494},[135,3192,3193],{},"COPY --from=build \u002Fextracted\u002Fsnapshot-dependencies\u002F .\u002F\n",[135,3195,3196],{"class":137,"line":499},[135,3197,3198],{},"COPY --from=build \u002Fextracted\u002Fapplication\u002F .\u002F\n",[135,3200,3201],{"class":137,"line":505},[135,3202,2751],{},[11,3204,3205,3206,3209],{},"这里有个容易被忽略的细节：",[33,3207,3208],{},"构建阶段用的是 Temurin 21，运行阶段用的是 Liberica 21","。同为 JDK 21，编译产物跨发行版运行没问题，日常也基本碰不到坑。但如果你想要\"编译和运行完全是同一套 JDK\"这种严格一致性（比如踩到过 GC 行为、加密 provider、区域数据差异），可以把 Maven 直接装进 Liberica 镜像：",[126,3211,3213],{"className":1465,"code":3212,"language":1467,"meta":131,"style":131},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS build\nARG MAVEN_VERSION=3.9.9\nRUN curl -fsSL \"https:\u002F\u002Farchive.apache.org\u002Fdist\u002Fmaven\u002Fmaven-3\u002F${MAVEN_VERSION}\u002Fbinaries\u002Fapache-maven-${MAVEN_VERSION}-bin.tar.gz\" \\\n      | tar -xz -C \u002Fopt \\\n && ln -s \"\u002Fopt\u002Fapache-maven-${MAVEN_VERSION}\u002Fbin\u002Fmvn\" \u002Fusr\u002Flocal\u002Fbin\u002Fmvn\nWORKDIR \u002Fworkspace\n# ……后面的 COPY \u002F RUN 步骤跟上面完全一样\n",[36,3214,3215,3220,3225,3230,3235,3240,3244],{"__ignoreMap":131},[135,3216,3217],{"class":137,"line":138},[135,3218,3219],{},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS build\n",[135,3221,3222],{"class":137,"line":145},[135,3223,3224],{},"ARG MAVEN_VERSION=3.9.9\n",[135,3226,3227],{"class":137,"line":160},[135,3228,3229],{},"RUN curl -fsSL \"https:\u002F\u002Farchive.apache.org\u002Fdist\u002Fmaven\u002Fmaven-3\u002F${MAVEN_VERSION}\u002Fbinaries\u002Fapache-maven-${MAVEN_VERSION}-bin.tar.gz\" \\\n",[135,3231,3232],{"class":137,"line":188},[135,3233,3234],{},"      | tar -xz -C \u002Fopt \\\n",[135,3236,3237],{"class":137,"line":211},[135,3238,3239],{}," && ln -s \"\u002Fopt\u002Fapache-maven-${MAVEN_VERSION}\u002Fbin\u002Fmvn\" \u002Fusr\u002Flocal\u002Fbin\u002Fmvn\n",[135,3241,3242],{"class":137,"line":218},[135,3243,3105],{},[135,3245,3246],{"class":137,"line":230},[135,3247,3248],{},"# ……后面的 COPY \u002F RUN 步骤跟上面完全一样\n",[11,3250,3251,3252,1217,3255,3257,3258,3261,3262,3265],{},"Rocky 基础镜像里 ",[36,3253,3254],{},"curl",[36,3256,149],{}," 通常是自带的，缺的话先 ",[36,3259,3260],{},"dnf install -y curl tar","。代价是构建阶段镜像变大、首次拉取变慢，收益是彻底消除 JDK 发行版这一个变量。",[33,3263,3264],{},"大多数项目用官方 Maven 镜像就够了","，别为了理论上的纯洁性拖慢构建。",[11,3267,3268],{},[33,3269,2438],{},[126,3271,3273],{"className":128,"code":3272,"language":130,"meta":131,"style":131},"# 首次构建（冷缓存，要下全部依赖，2~5 分钟）\nDOCKER_BUILDKIT=1 docker build -f Dockerfile -t demo:maven-layered .\n\n# 看每一步的缓存命中情况：CACHED 就是命中了\nDOCKER_BUILDKIT=1 docker build --progress=plain -t demo:maven-layered . 2>&1 | tee build.log\n\n# 改完业务代码再构建，应该只有 COPY src 之后的步骤重跑\nDOCKER_BUILDKIT=1 docker build -t demo:maven-layered .\n\n# 运行\ndocker run --rm -p 8080:8080 demo:maven-layered\n",[36,3274,3275,3280,3308,3312,3317,3351,3355,3360,3378,3382,3387],{"__ignoreMap":131},[135,3276,3277],{"class":137,"line":138},[135,3278,3279],{"class":141},"# 首次构建（冷缓存，要下全部依赖，2~5 分钟）\n",[135,3281,3282,3285,3289,3292,3295,3297,3299,3301,3303,3306],{"class":137,"line":145},[135,3283,3284],{"class":163},"DOCKER_BUILDKIT",[135,3286,3288],{"class":3287},"snl16","=",[135,3290,3291],{"class":156},"1",[135,3293,3294],{"class":148}," docker",[135,3296,2479],{"class":156},[135,3298,2482],{"class":152},[135,3300,2485],{"class":156},[135,3302,2488],{"class":152},[135,3304,3305],{"class":156}," demo:maven-layered",[135,3307,2494],{"class":156},[135,3309,3310],{"class":137,"line":160},[135,3311,215],{"emptyLinePlaceholder":214},[135,3313,3314],{"class":137,"line":188},[135,3315,3316],{"class":141},"# 看每一步的缓存命中情况：CACHED 就是命中了\n",[135,3318,3319,3321,3323,3325,3327,3329,3332,3334,3336,3339,3342,3345,3348],{"class":137,"line":211},[135,3320,3284],{"class":163},[135,3322,3288],{"class":3287},[135,3324,3291],{"class":156},[135,3326,3294],{"class":148},[135,3328,2479],{"class":156},[135,3330,3331],{"class":152}," --progress=plain",[135,3333,2488],{"class":152},[135,3335,3305],{"class":156},[135,3337,3338],{"class":156}," .",[135,3340,3341],{"class":3287}," 2>&1",[135,3343,3344],{"class":3287}," |",[135,3346,3347],{"class":148}," tee",[135,3349,3350],{"class":156}," build.log\n",[135,3352,3353],{"class":137,"line":218},[135,3354,215],{"emptyLinePlaceholder":214},[135,3356,3357],{"class":137,"line":230},[135,3358,3359],{"class":141},"# 改完业务代码再构建，应该只有 COPY src 之后的步骤重跑\n",[135,3361,3362,3364,3366,3368,3370,3372,3374,3376],{"class":137,"line":238},[135,3363,3284],{"class":163},[135,3365,3288],{"class":3287},[135,3367,3291],{"class":156},[135,3369,3294],{"class":148},[135,3371,2479],{"class":156},[135,3373,2488],{"class":152},[135,3375,3305],{"class":156},[135,3377,2494],{"class":156},[135,3379,3380],{"class":137,"line":244},[135,3381,215],{"emptyLinePlaceholder":214},[135,3383,3384],{"class":137,"line":255},[135,3385,3386],{"class":141},"# 运行\n",[135,3388,3389,3391,3393,3395,3397,3399],{"class":137,"line":334},[135,3390,2476],{"class":148},[135,3392,2510],{"class":156},[135,3394,2513],{"class":152},[135,3396,2516],{"class":152},[135,3398,2519],{"class":156},[135,3400,3401],{"class":156}," demo:maven-layered\n",[11,3403,3404,3405,3411,3412,1842,3415,3417],{},"注意这里",[33,3406,3407,3408,3410],{},"没有 ",[36,3409,1490],{}," 这一步","。方案 ③\u002F④ 的爽点就在于：本地不需要装 Maven、不需要配 settings.xml、不需要预热 ",[36,3413,3414],{},".m2",[36,3416,1494],{}," 一条命令从源码直达镜像。这也正是它\"可复现\"的来源。",[11,3419,3420,3423],{},[36,3421,3422],{},"--progress=plain"," 的输出长这样，能一眼看出缓存命中在哪：",[126,3425,3428],{"className":3426,"code":3427,"language":1894},[1892],"#6 [build 2\u002F6] COPY pom.xml .\n#6 CACHED                                    ← 依赖层命中，Maven 完全没跑\n\n#8 [build 4\u002F6] COPY src .\u002Fsrc\n#8 DONE 0.0s                                 ← 只有这里往后重跑\n\n#9 [build 5\u002F6] RUN mvn -B -q clean package -DskipTests\n#9 DONE 18.4s\n",[36,3429,3427],{"__ignoreMap":131},[11,3431,3432,3435],{},[33,3433,3434],{},"模拟 CI 冷启动","（清掉所有本地缓存）：",[126,3437,3439],{"className":128,"code":3438,"language":130,"meta":131,"style":131},"docker builder prune -af        # 清 BuildKit 缓存，多数版本下会连带清掉 --mount=type=cache 的 .m2 卷\ndocker build --no-cache -t demo:maven-layered .\n",[36,3440,3441,3457],{"__ignoreMap":131},[135,3442,3443,3445,3448,3451,3454],{"class":137,"line":138},[135,3444,2476],{"class":148},[135,3446,3447],{"class":156}," builder",[135,3449,3450],{"class":156}," prune",[135,3452,3453],{"class":152}," -af",[135,3455,3456],{"class":141},"        # 清 BuildKit 缓存，多数版本下会连带清掉 --mount=type=cache 的 .m2 卷\n",[135,3458,3459,3461,3463,3466,3468,3470],{"class":137,"line":145},[135,3460,2476],{"class":148},[135,3462,2479],{"class":156},[135,3464,3465],{"class":152}," --no-cache",[135,3467,2488],{"class":152},[135,3469,3305],{"class":156},[135,3471,2494],{"class":156},[11,3473,3474,3477,3478,3481,3482,39],{},[36,3475,3476],{},"docker builder prune -af"," 之后再测出来的就是纯冷启动时间。",[33,3479,3480],{},"平时别乱执行","，删完下一次构建又是五分钟起步。想只清缓存卷不动层缓存，用 ",[36,3483,3484],{},"docker buildx prune --filter type=exec.cachemount",[11,3486,3487],{},[33,3488,2631],{},[1638,3490,3491,3504,3513,3519,3525],{},[1503,3492,3493,3496,3497,3499,3500,3503],{},[33,3494,3495],{},"依赖层永久缓存","。只要 ",[36,3498,3080],{}," 没动，第一层就直接命中，Maven 完全不执行。改一行业务代码，重建的只有 ",[36,3501,3502],{},"COPY src"," 之后的部分——编译 + 打包 + 解压，实测 10~30 秒。",[1503,3505,3506,3509,3510,3512],{},[33,3507,3508],{},"构建可复现","。JDK、Maven、依赖版本全部锁在镜像和 ",[36,3511,3080],{}," 里，谁在哪台机器上 build 结果都一样。CI 和本地产物一致，\"我这儿是好的\"这句话彻底失效。",[1503,3514,3515,3518],{},[33,3516,3517],{},"启动快","，和方案 ② 一样是解压后的目录结构。",[1503,3520,3521,3524],{},[33,3522,3523],{},"CI 环境零准备","。构建机只需要有 Docker，不用装 JDK、不用配 Maven 私服凭据（凭据通过 build secret 注入更安全）。",[1503,3526,3527,3528,3531,3532,3535,3536,3542],{},"配合 ",[36,3529,3530],{},"--mount=type=cache"," 把 ",[36,3533,3534],{},"~\u002F.m2"," 挂成 BuildKit 缓存卷，",[33,3537,3538,3539,3541],{},"即使 ",[36,3540,3080],{}," 变了","，新增依赖也只下增量，不用从头拉一遍。这是层缓存之外的第二重保险。",[11,3544,3545],{},[33,3546,2647],{},[1638,3548,3549,3555,3571,3581,3587],{},[1503,3550,3551,3554],{},[33,3552,3553],{},"首次构建 2~5 分钟","。冷启动要下全部依赖，这个躲不掉。新人第一次 clone 项目、CI 换了新 runner、清了 Docker 缓存，都要重演一次。",[1503,3556,3557,39,3560,3562,3563,3566,3567,3570],{},[33,3558,3559],{},"构建缓存是机器本地的",[36,3561,3530],{}," 的缓存卷不会跟着镜像走，CI 上每次都是干净容器，如果不做缓存导出（",[36,3564,3565],{},"--cache-to","\u002F",[36,3568,3569],{},"--cache-from","）或者不挂持久化 volume，等于每次都在冷启动。这一点极其容易被忽略，很多人抱怨\"用了分层怎么还是慢\"，问题就在这。",[1503,3572,3573,3576,3577,3580],{},[33,3574,3575],{},"占用构建机资源","。Maven 编译跑在容器里，内存和 CPU 开销比直接 ",[36,3578,3579],{},"docker cp"," 大得多。",[1503,3582,3583,3586],{},[33,3584,3585],{},"改依赖的时候体验差","。加一个 starter，就得等一次完整下载。",[1503,3588,3589,3595,3596,3599,3600,3602],{},[33,3590,3591,3594],{},[36,3592,3593],{},"dependency:go-offline"," 并不彻底","。它不会下载所有插件依赖，实际编译时可能还是要联网补一部分。追求极致可以用 ",[36,3597,3598],{},"go-offline-maven-plugin","，或者干脆放弃这一步、只依赖 ",[36,3601,3414],{}," 缓存卷。",[11,3604,3605,3607],{},[33,3606,2682],{},"：CI\u002FCD 流水线、需要产物一致性的团队协作、生产环境镜像。",[25,3609,3611],{"id":3610},"五这些数字是怎么测出来的","五、这些数字是怎么测出来的",[11,3613,3614,3615,3618],{},"上面表格里的数字，机器不同、依赖数量不同、离镜像仓库远近不同，结果都会差很远。所以比起结论，",[33,3616,3617],{},"测量方法","可能更值得抄走——你完全可以在自己项目上重跑一遍，得到属于你自己的那张表。",[371,3620,3622],{"id":3621},"_51-先控制变量","5.1 先控制变量",[11,3624,3625],{},"对比之前把这些固定下来，否则测了也白测：",[1638,3627,3628,3634,3640,3653,3666,3672],{},[1503,3629,3630,3633],{},[33,3631,3632],{},"同一台机器、同一个 Docker 版本","。Docker Desktop（WSL2）的磁盘 IO 跟原生 Linux 差好几倍，跨机器的数据没有可比性。",[1503,3635,3636,3639],{},[33,3637,3638],{},"同一份代码","。测\"改业务代码后构建\"时，只改一个无关紧要的类（加个空行就行），别顺手改 pom——改了 pom 就是在测冷构建了。",[1503,3641,3642,39,3645,3648,3649,3652],{},[33,3643,3644],{},"同一套 JVM 参数",[36,3646,3647],{},"-Xmx"," 给太大，JVM 预分配内存反而拖慢启动。各方案参数不一致的话，启动时间的对比直接失真（就是方案 ① 里 ",[36,3650,3651],{},"JAVA_OPTS"," 那个提醒）。",[1503,3654,3655,3658,3659,3661,3662,3665],{},[33,3656,3657],{},"同一个基础镜像","。别一个方案用 JRE 一个用 JDK，也别一个带 ",[36,3660,2375],{}," 一个不带，那测的就不是分层了。本文四个方案统一用 ",[36,3663,3664],{},"bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds","，包括 builder 阶段。",[1503,3667,3668,3671],{},[33,3669,3670],{},"测前清场","。关掉 IDEA 正在跑的编译、其他容器，避免抢 CPU。",[1503,3673,3674,3677],{},[33,3675,3676],{},"每个数据至少测 3~5 次取中位数","。第一次往往偏慢（宿主机 page cache 是冷的），拿第一次的数据会系统性高估。",[371,3679,3681],{"id":3680},"_52-测构建时间","5.2 测构建时间",[11,3683,3684],{},[33,3685,3686,3687],{},"最简单：Git Bash \u002F Linux 用 ",[36,3688,3689],{},"time",[126,3691,3693],{"className":128,"code":3692,"language":130,"meta":131,"style":131},"time docker build -t demo:x .\n",[36,3694,3695],{"__ignoreMap":131},[135,3696,3697,3699,3702],{"class":137,"line":138},[135,3698,3689],{"class":3287},[135,3700,3701],{"class":163}," docker build -t demo:x ",[135,3703,3704],{"class":152},".\n",[11,3706,3707,3708,3711,3712,3566,3715,3718],{},"看 ",[36,3709,3710],{},"real"," 那一行，",[36,3713,3714],{},"user",[36,3716,3717],{},"sys"," 是客户端进程的 CPU 时间，跟构建总耗时关系不大。",[11,3720,3721],{},[33,3722,3723,3724],{},"Windows PowerShell 用 ",[36,3725,3726],{},"Measure-Command",[126,3728,3732],{"className":3729,"code":3730,"language":3731,"meta":131,"style":131},"language-powershell shiki shiki-themes github-dark","Measure-Command { docker build -t demo:x . } | Select-Object -ExpandProperty TotalSeconds\n","powershell",[36,3733,3734],{"__ignoreMap":131},[135,3735,3736],{"class":137,"line":138},[135,3737,3730],{},[11,3739,3740],{},[33,3741,3742,3743],{},"想知道慢在哪一步：",[36,3744,3422],{},[126,3746,3748],{"className":128,"code":3747,"language":130,"meta":131,"style":131},"DOCKER_BUILDKIT=1 docker build --progress=plain -t demo:x . 2>&1 | tee build.log\n",[36,3749,3750],{"__ignoreMap":131},[135,3751,3752,3754,3756,3758,3760,3762,3764,3766,3769,3771,3773,3775,3777],{"class":137,"line":138},[135,3753,3284],{"class":163},[135,3755,3288],{"class":3287},[135,3757,3291],{"class":156},[135,3759,3294],{"class":148},[135,3761,2479],{"class":156},[135,3763,3331],{"class":152},[135,3765,2488],{"class":152},[135,3767,3768],{"class":156}," demo:x",[135,3770,3338],{"class":156},[135,3772,3341],{"class":3287},[135,3774,3344],{"class":3287},[135,3776,3347],{"class":148},[135,3778,3350],{"class":156},[11,3780,3781,3782,3785,3786,3788,3789,3791,3792,3795],{},"BuildKit 会在每个 step 后标 ",[36,3783,3784],{},"#9 DONE 18.4s"," 这样的耗时，能直接定位到是 ",[36,3787,3593],{}," 慢、",[36,3790,1490],{}," 慢、还是 context 传输慢。",[36,3793,3794],{},"CACHED"," 标记的就是命中缓存的步骤。想统计总耗时：",[126,3797,3799],{"className":128,"code":3798,"language":130,"meta":131,"style":131},"grep -E \"^#[0-9]+ DONE\" build.log\n",[36,3800,3801],{"__ignoreMap":131},[135,3802,3803,3806,3809,3812],{"class":137,"line":138},[135,3804,3805],{"class":148},"grep",[135,3807,3808],{"class":152}," -E",[135,3810,3811],{"class":156}," \"^#[0-9]+ DONE\"",[135,3813,3350],{"class":156},[11,3815,3816],{},[33,3817,3818],{},"批量测冷\u002F热缓存的脚本",[126,3820,3822],{"className":128,"code":3821,"language":130,"meta":131,"style":131},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# bench-build.sh \u003Cdockerfile> \u003C轮次>\nDOCKERFILE=${1:-Dockerfile}\nTAG=bench:$(basename \"$DOCKERFILE\")\nROUNDS=${2:-3}\nSRC=src\u002Fmain\u002Fjava\u002Fcom\u002Fexample\u002Fdemo\u002FDemoApplication.java\n\nmeasure() {\n  local start end\n  start=$(date +%s.%N)\n  docker build -f \"$DOCKERFILE\" \"$@\" -t \"$TAG\" . >\u002Fdev\u002Fnull 2>&1\n  end=$(date +%s.%N)\n  awk -v s=\"$start\" -v e=\"$end\" 'BEGIN{printf \"%.2f\", e-s}'\n}\n\necho \"=== 冷缓存（--no-cache，对应表格里的『首次构建』）===\"\nfor i in $(seq 1 \"$ROUNDS\"); do echo \"第 $i 次: $(measure --no-cache) s\"; done\n\necho \"=== 热缓存（只改业务代码，对应『改业务代码后构建』）===\"\nfor i in $(seq 1 \"$ROUNDS\"); do\n  touch \"$SRC\"                 # 制造一次业务代码变更\n  echo \"第 $i 次: $(measure) s\"\ndone\n",[36,3823,3824,3829,3834,3854,3881,3896,3906,3910,3918,3926,3943,3985,4000,4029,4033,4037,4045,4103,4107,4114,4139,4154,4170],{"__ignoreMap":131},[135,3825,3826],{"class":137,"line":138},[135,3827,3828],{"class":141},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[135,3830,3831],{"class":137,"line":145},[135,3832,3833],{"class":141},"# bench-build.sh \u003Cdockerfile> \u003C轮次>\n",[135,3835,3836,3839,3841,3845,3848,3851],{"class":137,"line":160},[135,3837,3838],{"class":163},"DOCKERFILE",[135,3840,3288],{"class":3287},[135,3842,3844],{"class":3843},"s9osk","${1",[135,3846,3847],{"class":3287},":-",[135,3849,3850],{"class":163},"Dockerfile",[135,3852,3853],{"class":3843},"}\n",[135,3855,3856,3859,3861,3864,3867,3870,3873,3876,3879],{"class":137,"line":188},[135,3857,3858],{"class":163},"TAG",[135,3860,3288],{"class":3287},[135,3862,3863],{"class":156},"bench:",[135,3865,3866],{"class":163},"$(",[135,3868,3869],{"class":148},"basename",[135,3871,3872],{"class":156}," \"",[135,3874,3875],{"class":163},"$DOCKERFILE",[135,3877,3878],{"class":156},"\"",[135,3880,208],{"class":163},[135,3882,3883,3886,3888,3891,3893],{"class":137,"line":211},[135,3884,3885],{"class":163},"ROUNDS",[135,3887,3288],{"class":3287},[135,3889,3890],{"class":3843},"${2",[135,3892,3847],{"class":3287},[135,3894,3895],{"class":3843},"3}\n",[135,3897,3898,3901,3903],{"class":137,"line":218},[135,3899,3900],{"class":163},"SRC",[135,3902,3288],{"class":3287},[135,3904,3905],{"class":156},"src\u002Fmain\u002Fjava\u002Fcom\u002Fexample\u002Fdemo\u002FDemoApplication.java\n",[135,3907,3908],{"class":137,"line":230},[135,3909,215],{"emptyLinePlaceholder":214},[135,3911,3912,3915],{"class":137,"line":238},[135,3913,3914],{"class":148},"measure",[135,3916,3917],{"class":163},"() {\n",[135,3919,3920,3923],{"class":137,"line":244},[135,3921,3922],{"class":3287},"  local",[135,3924,3925],{"class":163}," start end\n",[135,3927,3928,3931,3933,3935,3938,3941],{"class":137,"line":255},[135,3929,3930],{"class":163},"  start",[135,3932,3288],{"class":3287},[135,3934,3866],{"class":163},[135,3936,3937],{"class":148},"date",[135,3939,3940],{"class":156}," +%s.%N",[135,3942,208],{"class":163},[135,3944,3945,3948,3950,3952,3954,3956,3958,3960,3963,3965,3967,3969,3972,3974,3976,3979,3982],{"class":137,"line":334},[135,3946,3947],{"class":148},"  docker",[135,3949,2479],{"class":156},[135,3951,2482],{"class":152},[135,3953,3872],{"class":156},[135,3955,3875],{"class":163},[135,3957,3878],{"class":156},[135,3959,3872],{"class":156},[135,3961,3962],{"class":152},"$@",[135,3964,3878],{"class":156},[135,3966,2488],{"class":152},[135,3968,3872],{"class":156},[135,3970,3971],{"class":163},"$TAG",[135,3973,3878],{"class":156},[135,3975,3338],{"class":156},[135,3977,3978],{"class":3287}," >",[135,3980,3981],{"class":156},"\u002Fdev\u002Fnull",[135,3983,3984],{"class":3287}," 2>&1\n",[135,3986,3987,3990,3992,3994,3996,3998],{"class":137,"line":340},[135,3988,3989],{"class":163},"  end",[135,3991,3288],{"class":3287},[135,3993,3866],{"class":163},[135,3995,3937],{"class":148},[135,3997,3940],{"class":156},[135,3999,208],{"class":163},[135,4001,4002,4005,4008,4011,4014,4016,4018,4021,4024,4026],{"class":137,"line":346},[135,4003,4004],{"class":148},"  awk",[135,4006,4007],{"class":152}," -v",[135,4009,4010],{"class":156}," s=\"",[135,4012,4013],{"class":163},"$start",[135,4015,3878],{"class":156},[135,4017,4007],{"class":152},[135,4019,4020],{"class":156}," e=\"",[135,4022,4023],{"class":163},"$end",[135,4025,3878],{"class":156},[135,4027,4028],{"class":156}," 'BEGIN{printf \"%.2f\", e-s}'\n",[135,4030,4031],{"class":137,"line":352},[135,4032,3853],{"class":163},[135,4034,4035],{"class":137,"line":453},[135,4036,215],{"emptyLinePlaceholder":214},[135,4038,4039,4042],{"class":137,"line":459},[135,4040,4041],{"class":152},"echo",[135,4043,4044],{"class":156}," \"=== 冷缓存（--no-cache，对应表格里的『首次构建』）===\"\n",[135,4046,4047,4050,4053,4056,4059,4062,4065,4067,4070,4072,4075,4078,4081,4084,4087,4090,4092,4094,4097,4100],{"class":137,"line":465},[135,4048,4049],{"class":3287},"for",[135,4051,4052],{"class":163}," i ",[135,4054,4055],{"class":3287},"in",[135,4057,4058],{"class":163}," $(",[135,4060,4061],{"class":148},"seq",[135,4063,4064],{"class":152}," 1",[135,4066,3872],{"class":156},[135,4068,4069],{"class":163},"$ROUNDS",[135,4071,3878],{"class":156},[135,4073,4074],{"class":163},"); ",[135,4076,4077],{"class":3287},"do",[135,4079,4080],{"class":152}," echo",[135,4082,4083],{"class":156}," \"第 ",[135,4085,4086],{"class":163},"$i",[135,4088,4089],{"class":156}," 次: $(",[135,4091,3914],{"class":148},[135,4093,3465],{"class":152},[135,4095,4096],{"class":156},") s\"",[135,4098,4099],{"class":163},"; ",[135,4101,4102],{"class":3287},"done\n",[135,4104,4105],{"class":137,"line":470},[135,4106,215],{"emptyLinePlaceholder":214},[135,4108,4109,4111],{"class":137,"line":476},[135,4110,4041],{"class":152},[135,4112,4113],{"class":156}," \"=== 热缓存（只改业务代码，对应『改业务代码后构建』）===\"\n",[135,4115,4116,4118,4120,4122,4124,4126,4128,4130,4132,4134,4136],{"class":137,"line":482},[135,4117,4049],{"class":3287},[135,4119,4052],{"class":163},[135,4121,4055],{"class":3287},[135,4123,4058],{"class":163},[135,4125,4061],{"class":148},[135,4127,4064],{"class":152},[135,4129,3872],{"class":156},[135,4131,4069],{"class":163},[135,4133,3878],{"class":156},[135,4135,4074],{"class":163},[135,4137,4138],{"class":3287},"do\n",[135,4140,4141,4144,4146,4149,4151],{"class":137,"line":488},[135,4142,4143],{"class":148},"  touch",[135,4145,3872],{"class":156},[135,4147,4148],{"class":163},"$SRC",[135,4150,3878],{"class":156},[135,4152,4153],{"class":141},"                 # 制造一次业务代码变更\n",[135,4155,4156,4159,4161,4163,4165,4167],{"class":137,"line":494},[135,4157,4158],{"class":152},"  echo",[135,4160,4083],{"class":156},[135,4162,4086],{"class":163},[135,4164,4089],{"class":156},[135,4166,3914],{"class":148},[135,4168,4169],{"class":156},") s\"\n",[135,4171,4172],{"class":137,"line":499},[135,4173,4102],{"class":3287},[11,4175,4176],{},"跑法：",[126,4178,4180],{"className":128,"code":4179,"language":130,"meta":131,"style":131},"chmod +x bench-build.sh\n.\u002Fbench-build.sh Dockerfile.copy         3\n.\u002Fbench-build.sh Dockerfile.copy-layered 3\n.\u002Fbench-build.sh Dockerfile.maven        3\n",[36,4181,4182,4193,4204,4214],{"__ignoreMap":131},[135,4183,4184,4187,4190],{"class":137,"line":138},[135,4185,4186],{"class":148},"chmod",[135,4188,4189],{"class":156}," +x",[135,4191,4192],{"class":156}," bench-build.sh\n",[135,4194,4195,4198,4201],{"class":137,"line":145},[135,4196,4197],{"class":148},".\u002Fbench-build.sh",[135,4199,4200],{"class":156}," Dockerfile.copy",[135,4202,4203],{"class":152},"         3\n",[135,4205,4206,4208,4211],{"class":137,"line":160},[135,4207,4197],{"class":148},[135,4209,4210],{"class":156}," Dockerfile.copy-layered",[135,4212,4213],{"class":152}," 3\n",[135,4215,4216,4218,4221],{"class":137,"line":188},[135,4217,4197],{"class":148},[135,4219,4220],{"class":156}," Dockerfile.maven",[135,4222,4223],{"class":152},"        3\n",[11,4225,4226,4229,4230,4232,4233,4235,4236,4238],{},[33,4227,4228],{},"一个必须说清楚的口径问题","：方案 ①② 在 ",[36,4231,1494],{}," 之前还得先 ",[36,4234,1490],{},"，上面的脚本没算这段时间——表格里的\"构建时间\"只统计 ",[36,4237,1494],{}," 本身。如果你想算总账，把打包也包进去：",[126,4240,4242],{"className":128,"code":4241,"language":130,"meta":131,"style":131},"measure_full() {\n  local start end\n  start=$(date +%s.%N)\n  mvn clean package -DskipTests -q && docker build -f \"$1\" -t \"$TAG\" . >\u002Fdev\u002Fnull 2>&1\n  end=$(date +%s.%N)\n  awk -v s=\"$start\" -v e=\"$end\" 'BEGIN{printf \"%.2f\", e-s}'\n}\n",[36,4243,4244,4251,4257,4271,4317,4331,4353],{"__ignoreMap":131},[135,4245,4246,4249],{"class":137,"line":138},[135,4247,4248],{"class":148},"measure_full",[135,4250,3917],{"class":163},[135,4252,4253,4255],{"class":137,"line":145},[135,4254,3922],{"class":3287},[135,4256,3925],{"class":163},[135,4258,4259,4261,4263,4265,4267,4269],{"class":137,"line":160},[135,4260,3930],{"class":163},[135,4262,3288],{"class":3287},[135,4264,3866],{"class":163},[135,4266,3937],{"class":148},[135,4268,3940],{"class":156},[135,4270,208],{"class":163},[135,4272,4273,4276,4278,4280,4283,4286,4288,4290,4292,4294,4296,4299,4301,4303,4305,4307,4309,4311,4313,4315],{"class":137,"line":188},[135,4274,4275],{"class":148},"  mvn",[135,4277,2456],{"class":156},[135,4279,2459],{"class":156},[135,4281,4282],{"class":152}," -DskipTests",[135,4284,4285],{"class":152}," -q",[135,4287,173],{"class":163},[135,4289,2476],{"class":148},[135,4291,2479],{"class":156},[135,4293,2482],{"class":152},[135,4295,3872],{"class":156},[135,4297,4298],{"class":152},"$1",[135,4300,3878],{"class":156},[135,4302,2488],{"class":152},[135,4304,3872],{"class":156},[135,4306,3971],{"class":163},[135,4308,3878],{"class":156},[135,4310,3338],{"class":156},[135,4312,3978],{"class":3287},[135,4314,3981],{"class":156},[135,4316,3984],{"class":3287},[135,4318,4319,4321,4323,4325,4327,4329],{"class":137,"line":211},[135,4320,3989],{"class":163},[135,4322,3288],{"class":3287},[135,4324,3866],{"class":163},[135,4326,3937],{"class":148},[135,4328,3940],{"class":156},[135,4330,208],{"class":163},[135,4332,4333,4335,4337,4339,4341,4343,4345,4347,4349,4351],{"class":137,"line":218},[135,4334,4004],{"class":148},[135,4336,4007],{"class":152},[135,4338,4010],{"class":156},[135,4340,4013],{"class":163},[135,4342,3878],{"class":156},[135,4344,4007],{"class":152},[135,4346,4020],{"class":156},[135,4348,4023],{"class":163},[135,4350,3878],{"class":156},[135,4352,4028],{"class":156},[135,4354,4355],{"class":137,"line":230},[135,4356,3853],{"class":163},[11,4358,4359,4360,4363],{},"按总账算，方案 ① 的\"1~2 秒\"会变成\"打包 15 秒 + 构建 1 秒\"。",[33,4361,4362],{},"文章里如果不点明这个口径，读者会以为方案①真的 1 秒就能出镜像","，那就成了误导。方案 ③\u002F④ 不存在这个问题，它没有本地打包环节。",[371,4365,4367],{"id":4366},"_53-测启动时间","5.3 测启动时间",[11,4369,4370],{},"启动时间有两个口径，别混着用：",[41,4372,4373,4386],{},[44,4374,4375],{},[47,4376,4377,4380,4383],{},[50,4378,4379],{},"口径",[50,4381,4382],{},"含义",[50,4384,4385],{},"怎么测",[60,4387,4388,4404],{},[47,4389,4390,4395,4398],{},[65,4391,4392],{},[33,4393,4394],{},"应用就绪时间",[65,4396,4397],{},"Spring 容器初始化完成",[65,4399,4400,4401],{},"抓日志里的 ",[36,4402,4403],{},"Started DemoApplication in x.xxx seconds",[47,4405,4406,4411,4418],{},[65,4407,4408],{},[33,4409,4410],{},"端到端可用时间",[65,4412,4413,4414,4417],{},"从 ",[36,4415,4416],{},"docker run"," 到端口能响应",[65,4419,4420],{},"轮询健康检查接口",[11,4422,4423,4424],{},"前者排除了 Docker 创建容器、挂载镜像层的开销，适合对比 jar 结构差异；后者才是 K8s 探针真正感受到的时间，也是用户感知的\"启动快不快\"。",[33,4425,4426],{},"建议两个都记。",[11,4428,4429],{},[33,4430,4431],{},"口径一：抓 Spring Boot 日志",[126,4433,4435],{"className":128,"code":4434,"language":130,"meta":131,"style":131},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# bench-start.sh \u003Cimage> \u003C轮次>\nIMAGE=${1:-demo:bench}\nROUNDS=${2:-5}\n\nfor i in $(seq 1 \"$ROUNDS\"); do\n  cid=$(docker run -d \"$IMAGE\")\n  # --line-buffered 保证 grep 实时匹配，拿到第一行就退出循环\n  result=$(docker logs -f \"$cid\" 2>&1 | grep -m1 --line-buffered \"Started .* in .* seconds\")\n  docker rm -f \"$cid\" >\u002Fdev\u002Fnull\n  echo \"第 $i 次: ${result##*Started }\"\ndone\n",[36,4436,4437,4441,4446,4468,4481,4485,4509,4533,4538,4578,4598,4621],{"__ignoreMap":131},[135,4438,4439],{"class":137,"line":138},[135,4440,3828],{"class":141},[135,4442,4443],{"class":137,"line":145},[135,4444,4445],{"class":141},"# bench-start.sh \u003Cimage> \u003C轮次>\n",[135,4447,4448,4451,4453,4455,4457,4460,4463,4466],{"class":137,"line":160},[135,4449,4450],{"class":163},"IMAGE",[135,4452,3288],{"class":3287},[135,4454,3844],{"class":3843},[135,4456,3847],{"class":3287},[135,4458,4459],{"class":163},"demo",[135,4461,4462],{"class":3287},":",[135,4464,4465],{"class":163},"bench",[135,4467,3853],{"class":3843},[135,4469,4470,4472,4474,4476,4478],{"class":137,"line":188},[135,4471,3885],{"class":163},[135,4473,3288],{"class":3287},[135,4475,3890],{"class":3843},[135,4477,3847],{"class":3287},[135,4479,4480],{"class":3843},"5}\n",[135,4482,4483],{"class":137,"line":211},[135,4484,215],{"emptyLinePlaceholder":214},[135,4486,4487,4489,4491,4493,4495,4497,4499,4501,4503,4505,4507],{"class":137,"line":218},[135,4488,4049],{"class":3287},[135,4490,4052],{"class":163},[135,4492,4055],{"class":3287},[135,4494,4058],{"class":163},[135,4496,4061],{"class":148},[135,4498,4064],{"class":152},[135,4500,3872],{"class":156},[135,4502,4069],{"class":163},[135,4504,3878],{"class":156},[135,4506,4074],{"class":163},[135,4508,4138],{"class":3287},[135,4510,4511,4514,4516,4518,4520,4522,4524,4526,4529,4531],{"class":137,"line":230},[135,4512,4513],{"class":163},"  cid",[135,4515,3288],{"class":3287},[135,4517,3866],{"class":163},[135,4519,2476],{"class":148},[135,4521,2510],{"class":156},[135,4523,2569],{"class":152},[135,4525,3872],{"class":156},[135,4527,4528],{"class":163},"$IMAGE",[135,4530,3878],{"class":156},[135,4532,208],{"class":163},[135,4534,4535],{"class":137,"line":238},[135,4536,4537],{"class":141},"  # --line-buffered 保证 grep 实时匹配，拿到第一行就退出循环\n",[135,4539,4540,4543,4545,4547,4549,4551,4553,4555,4558,4560,4562,4564,4567,4570,4573,4576],{"class":137,"line":244},[135,4541,4542],{"class":163},"  result",[135,4544,3288],{"class":3287},[135,4546,3866],{"class":163},[135,4548,2476],{"class":148},[135,4550,2588],{"class":156},[135,4552,2482],{"class":152},[135,4554,3872],{"class":156},[135,4556,4557],{"class":163},"$cid",[135,4559,3878],{"class":156},[135,4561,3341],{"class":3287},[135,4563,3344],{"class":3287},[135,4565,4566],{"class":148}," grep",[135,4568,4569],{"class":152}," -m1",[135,4571,4572],{"class":152}," --line-buffered",[135,4574,4575],{"class":156}," \"Started .* in .* seconds\"",[135,4577,208],{"class":163},[135,4579,4580,4582,4585,4587,4589,4591,4593,4595],{"class":137,"line":255},[135,4581,3947],{"class":148},[135,4583,4584],{"class":156}," rm",[135,4586,2482],{"class":152},[135,4588,3872],{"class":156},[135,4590,4557],{"class":163},[135,4592,3878],{"class":156},[135,4594,3978],{"class":3287},[135,4596,4597],{"class":156},"\u002Fdev\u002Fnull\n",[135,4599,4600,4602,4604,4606,4609,4612,4615,4618],{"class":137,"line":334},[135,4601,4158],{"class":152},[135,4603,4083],{"class":156},[135,4605,4086],{"class":163},[135,4607,4608],{"class":156}," 次: ${",[135,4610,4611],{"class":163},"result",[135,4613,4614],{"class":3287},"##*",[135,4616,4617],{"class":163},"Started",[135,4619,4620],{"class":156}," }\"\n",[135,4622,4623],{"class":137,"line":340},[135,4624,4102],{"class":3287},[11,4626,4627],{},"输出形如：",[126,4629,4632],{"className":4630,"code":4631,"language":1894},[1892],"第 1 次: DemoApplication in 2.318 seconds (process running for 2.741)\n第 2 次: DemoApplication in 2.204 seconds (process running for 2.613)\n",[36,4633,4631],{"__ignoreMap":131},[11,4635,4636],{},"这里有两个数字，含义不同：",[1638,4638,4639,4645],{},[1503,4640,4641,4644],{},[36,4642,4643],{},"in 2.318 seconds","：Spring 上下文刷新耗时",[1503,4646,4647,4650],{},[36,4648,4649],{},"process running for 2.741","：从 JVM 进程启动算起的总耗时",[11,4652,4653,4656,4657,4660],{},[33,4654,4655],{},"对比分层效果时看后者更准","。因为解压带来的收益有一部分体现在 JVM 类加载阶段，早于 Spring 计时开始——只看 ",[36,4658,4659],{},"in x seconds"," 会低估方案 ②③④ 的优势。",[11,4662,4663],{},[33,4664,4665],{},"口径二：轮询健康检查（需要 actuator）",[126,4667,4669],{"className":128,"code":4668,"language":130,"meta":131,"style":131},"#!\u002Fusr\u002Fbin\u002Fenv bash\nIMAGE=${1:-demo:bench}\nPORT=${2:-8080}\n\ncid=$(docker run -d -p $PORT:8080 \"$IMAGE\")\nstart=$(date +%s.%N)\nuntil curl -sf \"http:\u002F\u002Flocalhost:$PORT\u002Factuator\u002Fhealth\" >\u002Fdev\u002Fnull 2>&1; do\n  sleep 0.05\ndone\nend=$(date +%s.%N)\ndocker rm -f \"$cid\" >\u002Fdev\u002Fnull\nawk -v s=\"$start\" -v e=\"$end\" 'BEGIN{printf \"端到端可用耗时: %.2f s\\n\", e-s}'\n",[36,4670,4671,4675,4693,4707,4711,4742,4757,4787,4795,4799,4814,4832],{"__ignoreMap":131},[135,4672,4673],{"class":137,"line":138},[135,4674,3828],{"class":141},[135,4676,4677,4679,4681,4683,4685,4687,4689,4691],{"class":137,"line":145},[135,4678,4450],{"class":163},[135,4680,3288],{"class":3287},[135,4682,3844],{"class":3843},[135,4684,3847],{"class":3287},[135,4686,4459],{"class":163},[135,4688,4462],{"class":3287},[135,4690,4465],{"class":163},[135,4692,3853],{"class":3843},[135,4694,4695,4698,4700,4702,4704],{"class":137,"line":160},[135,4696,4697],{"class":163},"PORT",[135,4699,3288],{"class":3287},[135,4701,3890],{"class":3843},[135,4703,3847],{"class":3287},[135,4705,4706],{"class":3843},"8080}\n",[135,4708,4709],{"class":137,"line":188},[135,4710,215],{"emptyLinePlaceholder":214},[135,4712,4713,4716,4718,4720,4722,4724,4726,4728,4731,4734,4736,4738,4740],{"class":137,"line":211},[135,4714,4715],{"class":163},"cid",[135,4717,3288],{"class":3287},[135,4719,3866],{"class":163},[135,4721,2476],{"class":148},[135,4723,2510],{"class":156},[135,4725,2569],{"class":152},[135,4727,2516],{"class":152},[135,4729,4730],{"class":163}," $PORT",[135,4732,4733],{"class":156},":8080",[135,4735,3872],{"class":156},[135,4737,4528],{"class":163},[135,4739,3878],{"class":156},[135,4741,208],{"class":163},[135,4743,4744,4747,4749,4751,4753,4755],{"class":137,"line":218},[135,4745,4746],{"class":163},"start",[135,4748,3288],{"class":3287},[135,4750,3866],{"class":163},[135,4752,3937],{"class":148},[135,4754,3940],{"class":156},[135,4756,208],{"class":163},[135,4758,4759,4762,4765,4768,4771,4774,4777,4779,4781,4783,4785],{"class":137,"line":230},[135,4760,4761],{"class":3287},"until",[135,4763,4764],{"class":148}," curl",[135,4766,4767],{"class":152}," -sf",[135,4769,4770],{"class":156}," \"http:\u002F\u002Flocalhost:",[135,4772,4773],{"class":163},"$PORT",[135,4775,4776],{"class":156},"\u002Factuator\u002Fhealth\"",[135,4778,3978],{"class":3287},[135,4780,3981],{"class":156},[135,4782,3341],{"class":3287},[135,4784,4099],{"class":163},[135,4786,4138],{"class":3287},[135,4788,4789,4792],{"class":137,"line":238},[135,4790,4791],{"class":148},"  sleep",[135,4793,4794],{"class":152}," 0.05\n",[135,4796,4797],{"class":137,"line":244},[135,4798,4102],{"class":3287},[135,4800,4801,4804,4806,4808,4810,4812],{"class":137,"line":255},[135,4802,4803],{"class":163},"end",[135,4805,3288],{"class":3287},[135,4807,3866],{"class":163},[135,4809,3937],{"class":148},[135,4811,3940],{"class":156},[135,4813,208],{"class":163},[135,4815,4816,4818,4820,4822,4824,4826,4828,4830],{"class":137,"line":334},[135,4817,2476],{"class":148},[135,4819,4584],{"class":156},[135,4821,2482],{"class":152},[135,4823,3872],{"class":156},[135,4825,4557],{"class":163},[135,4827,3878],{"class":156},[135,4829,3978],{"class":3287},[135,4831,4597],{"class":156},[135,4833,4834,4837,4839,4841,4843,4845,4847,4849,4851,4853],{"class":137,"line":340},[135,4835,4836],{"class":148},"awk",[135,4838,4007],{"class":152},[135,4840,4010],{"class":156},[135,4842,4013],{"class":163},[135,4844,3878],{"class":156},[135,4846,4007],{"class":152},[135,4848,4020],{"class":156},[135,4850,4023],{"class":163},[135,4852,3878],{"class":156},[135,4854,4855],{"class":156}," 'BEGIN{printf \"端到端可用耗时: %.2f s\\n\", e-s}'\n",[11,4857,4858],{},"pom 里得有 actuator：",[126,4860,4862],{"className":1717,"code":4861,"language":1719,"meta":131,"style":131},"\u003Cdependency>\n    \u003CgroupId>org.springframework.boot\u003C\u002FgroupId>\n    \u003CartifactId>spring-boot-starter-actuator\u003C\u002FartifactId>\n\u003C\u002Fdependency>\n",[36,4863,4864,4869,4873,4878],{"__ignoreMap":131},[135,4865,4866],{"class":137,"line":138},[135,4867,4868],{},"\u003Cdependency>\n",[135,4870,4871],{"class":137,"line":145},[135,4872,2082],{},[135,4874,4875],{"class":137,"line":160},[135,4876,4877],{},"    \u003CartifactId>spring-boot-starter-actuator\u003C\u002FartifactId>\n",[135,4879,4880],{"class":137,"line":188},[135,4881,4882],{},"\u003C\u002Fdependency>\n",[11,4884,4885,4886,39],{},"没有 actuator 的话，换成任何一个能返回 200 的业务接口也行，关键是",[33,4887,4888],{},"四个方案用同一个接口",[371,4890,4892],{"id":4891},"_54-启动慢到底慢在哪","5.4 启动慢，到底慢在哪",[11,4894,4895],{},"如果两个方案的差距比预期大，别猜，直接看数据。Spring Boot 自带启动耗时埋点：",[126,4897,4900],{"className":4898,"code":4899,"language":1873,"meta":131,"style":131},"language-java shiki shiki-themes github-dark","@SpringBootApplication\npublic class DemoApplication {\n    public static void main(String[] args) {\n        SpringApplication app = new SpringApplication(DemoApplication.class);\n        app.setApplicationStartup(new BufferingApplicationStartup(4096));\n        app.run(args);\n    }\n}\n",[36,4901,4902,4907,4912,4917,4922,4927,4932,4937],{"__ignoreMap":131},[135,4903,4904],{"class":137,"line":138},[135,4905,4906],{},"@SpringBootApplication\n",[135,4908,4909],{"class":137,"line":145},[135,4910,4911],{},"public class DemoApplication {\n",[135,4913,4914],{"class":137,"line":160},[135,4915,4916],{},"    public static void main(String[] args) {\n",[135,4918,4919],{"class":137,"line":188},[135,4920,4921],{},"        SpringApplication app = new SpringApplication(DemoApplication.class);\n",[135,4923,4924],{"class":137,"line":211},[135,4925,4926],{},"        app.setApplicationStartup(new BufferingApplicationStartup(4096));\n",[135,4928,4929],{"class":137,"line":218},[135,4930,4931],{},"        app.run(args);\n",[135,4933,4934],{"class":137,"line":230},[135,4935,4936],{},"    }\n",[135,4938,4939],{"class":137,"line":238},[135,4940,3853],{},[11,4942,4943,4944,4947],{},"配上 ",[36,4945,4946],{},"management.endpoints.web.exposure.include=startup","，启动后：",[126,4949,4951],{"className":128,"code":4950,"language":130,"meta":131,"style":131},"curl -s http:\u002F\u002Flocalhost:8080\u002Factuator\u002Fstartup \\\n  | jq -r '.timeline.events[] | \"\\(.duration)  \\(.startupStep.name)\"' \\\n  | sort -hr | head -20\n",[36,4952,4953,4965,4981],{"__ignoreMap":131},[135,4954,4955,4957,4960,4963],{"class":137,"line":138},[135,4956,3254],{"class":148},[135,4958,4959],{"class":152}," -s",[135,4961,4962],{"class":156}," http:\u002F\u002Flocalhost:8080\u002Factuator\u002Fstartup",[135,4964,185],{"class":152},[135,4966,4967,4970,4973,4976,4979],{"class":137,"line":145},[135,4968,4969],{"class":3287},"  |",[135,4971,4972],{"class":148}," jq",[135,4974,4975],{"class":152}," -r",[135,4977,4978],{"class":156}," '.timeline.events[] | \"\\(.duration)  \\(.startupStep.name)\"'",[135,4980,185],{"class":152},[135,4982,4983,4985,4988,4991,4993,4996],{"class":137,"line":160},[135,4984,4969],{"class":3287},[135,4986,4987],{"class":148}," sort",[135,4989,4990],{"class":152}," -hr",[135,4992,3344],{"class":3287},[135,4994,4995],{"class":148}," head",[135,4997,4998],{"class":152}," -20\n",[11,5000,5001],{},"能看到每个 Bean 初始化、每次类加载的耗时。如果方案 ① 在这里显示大量时间花在类加载相关事件上，就印证了下一章要讲的\"嵌套 jar 读取慢\"。",[11,5003,5004],{},"想看更底层的类加载行为：",[126,5006,5008],{"className":128,"code":5007,"language":130,"meta":131,"style":131},"# 统计加载了多少个类、从哪加载的\ndocker run --rm demo:copy sh -c \"java -Xlog:class+load=info -jar app.jar\" 2>&1 | head -50\n\n# 只看从嵌套 jar 里加载的部分\ndocker run --rm demo:copy sh -c \"java -Xlog:class+load=info -jar app.jar\" 2>&1 | grep -c \"nested:\"\n",[36,5009,5010,5015,5041,5045,5050],{"__ignoreMap":131},[135,5011,5012],{"class":137,"line":138},[135,5013,5014],{"class":141},"# 统计加载了多少个类、从哪加载的\n",[135,5016,5017,5019,5021,5023,5025,5027,5029,5032,5034,5036,5038],{"class":137,"line":145},[135,5018,2476],{"class":148},[135,5020,2510],{"class":156},[135,5022,2513],{"class":152},[135,5024,2491],{"class":156},[135,5026,2899],{"class":156},[135,5028,2904],{"class":152},[135,5030,5031],{"class":156}," \"java -Xlog:class+load=info -jar app.jar\"",[135,5033,3341],{"class":3287},[135,5035,3344],{"class":3287},[135,5037,4995],{"class":148},[135,5039,5040],{"class":152}," -50\n",[135,5042,5043],{"class":137,"line":160},[135,5044,215],{"emptyLinePlaceholder":214},[135,5046,5047],{"class":137,"line":188},[135,5048,5049],{"class":141},"# 只看从嵌套 jar 里加载的部分\n",[135,5051,5052,5054,5056,5058,5060,5062,5064,5066,5068,5070,5072,5074],{"class":137,"line":211},[135,5053,2476],{"class":148},[135,5055,2510],{"class":156},[135,5057,2513],{"class":152},[135,5059,2491],{"class":156},[135,5061,2899],{"class":156},[135,5063,2904],{"class":152},[135,5065,5031],{"class":156},[135,5067,3341],{"class":3287},[135,5069,3344],{"class":3287},[135,5071,4566],{"class":148},[135,5073,2904],{"class":152},[135,5075,5076],{"class":156}," \"nested:\"\n",[11,5078,5079,5080,5083],{},"方案 ① 会看到一堆 ",[36,5081,5082],{},"jar:nested:\u002Fapp.jar\u002F!BOOT-INF\u002Flib\u002F...","，方案 ②③④ 则是干净的文件路径——这就是启动时间差距的直接证据，比任何原理解释都有说服力，值得截图放进文章。",[371,5085,5087],{"id":5086},"_55-顺手把镜像和层也比一下","5.5 顺手把镜像和层也比一下",[126,5089,5091],{"className":128,"code":5090,"language":130,"meta":131,"style":131},"# 各方案镜像体积\ndocker images \"demo:*\"\n\n# 每层的大小和来源指令，验证分层是否真的生效\ndocker history --no-trunc demo:maven-layered\n\n# 精确到字节\ndocker inspect demo:maven-layered --format '{{.Size}}'\n\n# 逐层可视化，看有没有\"白胖\"的层（比如原始 jar 没删干净）\ndive demo:copy-layered\n",[36,5092,5093,5098,5108,5112,5117,5128,5132,5137,5152,5156,5161],{"__ignoreMap":131},[135,5094,5095],{"class":137,"line":138},[135,5096,5097],{"class":141},"# 各方案镜像体积\n",[135,5099,5100,5102,5105],{"class":137,"line":145},[135,5101,2476],{"class":148},[135,5103,5104],{"class":156}," images",[135,5106,5107],{"class":156}," \"demo:*\"\n",[135,5109,5110],{"class":137,"line":160},[135,5111,215],{"emptyLinePlaceholder":214},[135,5113,5114],{"class":137,"line":188},[135,5115,5116],{"class":141},"# 每层的大小和来源指令，验证分层是否真的生效\n",[135,5118,5119,5121,5123,5126],{"class":137,"line":211},[135,5120,2476],{"class":148},[135,5122,2943],{"class":156},[135,5124,5125],{"class":152}," --no-trunc",[135,5127,3401],{"class":156},[135,5129,5130],{"class":137,"line":218},[135,5131,215],{"emptyLinePlaceholder":214},[135,5133,5134],{"class":137,"line":230},[135,5135,5136],{"class":141},"# 精确到字节\n",[135,5138,5139,5141,5144,5146,5149],{"class":137,"line":238},[135,5140,2476],{"class":148},[135,5142,5143],{"class":156}," inspect",[135,5145,3305],{"class":156},[135,5147,5148],{"class":152}," --format",[135,5150,5151],{"class":156}," '{{.Size}}'\n",[135,5153,5154],{"class":137,"line":244},[135,5155,215],{"emptyLinePlaceholder":214},[135,5157,5158],{"class":137,"line":255},[135,5159,5160],{"class":141},"# 逐层可视化，看有没有\"白胖\"的层（比如原始 jar 没删干净）\n",[135,5162,5163,5166],{"class":137,"line":334},[135,5164,5165],{"class":148},"dive",[135,5167,2874],{"class":156},[11,5169,5170,5173,5174,5176,5177,39],{},[36,5171,5172],{},"docker history"," 是判断分层有没有起作用的最快方法：方案 ② 和 ④ 都能看到四条 ",[36,5175,2049],{}," 层，但",[33,5178,5179,5180],{},"只有方案 ④ 在重复构建时依赖层会显示 ",[36,5181,3794],{},[371,5183,5185],{"id":5184},"_56-记录表模板","5.6 记录表模板",[11,5187,5188],{},"自己测的时候建议填这张表，比只看一个数字有用得多：",[41,5190,5191,5231],{},[44,5192,5193],{},[47,5194,5195,5197,5204,5209,5215,5221,5224,5226],{},[50,5196,1541],{},[50,5198,5199,5200,5203],{},"冷构建",[5201,5202],"br",{},"(中位数)",[50,5205,5206,5207,5203],{},"热构建",[5201,5208],{},[50,5210,5211,5212,5214],{},"打包耗时",[5201,5213],{},"(仅①②)",[50,5216,5217,5218,5220],{},"应用就绪",[5201,5219],{},"(process running)",[50,5222,5223],{},"端到端可用",[50,5225,1550],{},[50,5227,5228,5229,3794],{},"依赖层",[5201,5230],{},[60,5232,5233,5252,5272,5291],{},[47,5234,5235,5238,5240,5242,5244,5246,5248,5250],{},[65,5236,5237],{},"① COPY 不分层",[65,5239],{},[65,5241],{},[65,5243],{},[65,5245],{},[65,5247],{},[65,5249],{},[65,5251,1606],{},[47,5253,5254,5257,5259,5261,5263,5265,5267,5269],{},[65,5255,5256],{},"② COPY + 解压",[65,5258],{},[65,5260],{},[65,5262],{},[65,5264],{},[65,5266],{},[65,5268],{},[65,5270,5271],{},"❌",[47,5273,5274,5277,5279,5281,5283,5285,5287,5289],{},[65,5275,5276],{},"③ Maven 多阶段（首次）",[65,5278],{},[65,5280],{},[65,5282,1606],{},[65,5284],{},[65,5286],{},[65,5288],{},[65,5290,1606],{},[47,5292,5293,5296,5298,5300,5302,5304,5306,5308],{},[65,5294,5295],{},"④ Maven 多阶段（第二次）",[65,5297],{},[65,5299],{},[65,5301,1606],{},[65,5303],{},[65,5305],{},[65,5307],{},[65,5309,5310],{},"✅",[11,5312,5313],{},"再单独记一段环境信息，缺了这些，过三个月回来看这张表就不知道数字还可不可信：",[126,5315,5318],{"className":5316,"code":5317,"language":1894},[1892],"CPU \u002F 内存：\nDocker 版本 + BuildKit 是否开启：\n操作系统（原生 Linux \u002F WSL2 \u002F macOS）：\nSpring Boot 版本 \u002F JDK 版本 \u002F Maven 版本：\n依赖数量 + fat jar 体积：\nJVM 参数：\n镜像仓库（本地 \u002F 远程，测 push 时才需要）：\n测量日期：\n",[36,5319,5317],{"__ignoreMap":131},[371,5321,5323],{"id":5322},"_57-测量时容易踩的坑","5.7 测量时容易踩的坑",[1500,5325,5326,5348,5365,5371,5381,5391,5404,5419],{},[1503,5327,5328,5336,5337,5339,5340,5343,5344,5347],{},[33,5329,5330,5332,5333],{},[36,5331,4416],{}," 直接管道给 ",[36,5334,5335],{},"grep -m1","：grep 拿到第一行就退出，SIGPIPE 可能把容器一起带走；如果日志里有多个 ",[36,5338,4617],{},"（集成测试会重启上下文），抓到的还可能是错的那一行。用 ",[36,5341,5342],{},"docker run -d"," + ",[36,5345,5346],{},"docker logs -f"," 更稳。",[1503,5349,5350,1978,5357,5360,5361,5364],{},[33,5351,5352,5353,5356],{},"轮询 ",[36,5354,5355],{},"sleep"," 间隔太大",[36,5358,5359],{},"sleep 1"," 会让端到端时间凭空多出平均 0.5 秒。用 ",[36,5362,5363],{},"0.05"," 甚至更小。",[1503,5366,5367,5370],{},[33,5368,5369],{},"只测一次","：容器启动时间的抖动比想象中大，尤其 Windows + WSL2 环境，单次数据基本没有参考价值。",[1503,5372,5373,5376,5377,5380],{},[33,5374,5375],{},"测热缓存前没清干净上一轮的中间层","：上一轮 ",[36,5378,5379],{},"--no-cache"," 留下的产物会污染这一轮，数据偏乐观还是偏悲观说不清。",[1503,5382,5383,5390],{},[33,5384,5385,5386,5389],{},"忘了 ",[36,5387,5388],{},"touch"," 源码就测热构建","：什么都没改的话 Docker 会全部命中缓存，测出个 0.3 秒，然后得出\"方案②也挺快\"的错误结论。",[1503,5392,5393,5399,5400,5403],{},[33,5394,5395,5396],{},"Windows 下 ",[36,5397,5398],{},"date +%s.%N","：Git Bash 里支持，cmd 的 ",[36,5401,5402],{},"%time%"," 只有毫秒且格式难解析。统一在 Git Bash 或 WSL 里跑脚本。",[1503,5405,5406,5412,5413,5415,5416,5418],{},[33,5407,5408,5409],{},"没装 ",[36,5410,5411],{},"bc","：脚本里用 ",[36,5414,4836],{}," 做浮点减法就是为了绕开这个，别再改成 ",[36,5417,5411],{}," 了。",[1503,5420,5421,5427,5428,5431],{},[33,5422,5423,5424],{},"测启动时忘了 ",[36,5425,5426],{},"--rm","：几十个 Exited 容器堆在那里占磁盘，还会让 ",[36,5429,5430],{},"docker ps -a"," 变得没法看。",[25,5433,5435],{"id":5434},"六原理再往下挖一层","六、原理再往下挖一层",[371,5437,5439],{"id":5438},"为什么-fat-jar-启动就是慢","为什么 fat jar 启动就是慢",[11,5441,5442,5443,5446],{},"Spring Boot 的 fat jar 用了一种叫 \"nested jar\" 的格式：内层 jar 以 ",[33,5444,5445],{},"STORED（不压缩）"," 方式存放，这样可以直接 mmap 到偏移量读取，不需要解压到临时目录。听起来挺高效，但代价是：",[1638,5448,5449,5460,5463],{},[1503,5450,5451,5452,5455,5456,5459],{},"类加载器（",[36,5453,5454],{},"LaunchedClassLoader","）每次加载都要走一套自定义的 URL 协议 ",[36,5457,5458],{},"jar:nested:\u002Fapp.jar\u002F!BOOT-INF\u002Flib\u002Fxxx.jar\u002F!com\u002F...","，多一层解析。",[1503,5461,5462],{},"上百个内层 jar 各自的中央目录（central directory）都要被读一遍来建索引。",[1503,5464,5465],{},"在 overlayfs 上，这些随机小 IO 的性能明显不如直接读解压后的目录树。",[11,5467,5468,5469,5477],{},"解压成目录之后，就是最普通的 classpath 扫描，JVM 干这事最熟。",[33,5470,5471,5472,5476],{},"启动时间从 5",[5473,5474,5475],"del",{},"15 秒降到 1","3 秒，主要就是省在这里","，跟分层本身没关系——分层只是顺便把解压这件事规范化了。",[371,5479,5481],{"id":5480},"为什么方案-的缓存注定命中不了","为什么方案 ② 的缓存注定命中不了",[11,5483,5484],{},"Docker 判断缓存是否命中，看的是两件事：",[1500,5486,5487,5499],{},[1503,5488,5489,5492,5493,2914,5495,5498],{},[33,5490,5491],{},"指令字符串","是否相同（",[36,5494,2049],{},[36,5496,5497],{},"RUN"," 这些）；",[1503,5500,5501,5502,5504,5505,5508,5509,5504,5511,5514],{},"对 ",[36,5503,2049],{}," 来说，",[33,5506,5507],{},"被拷贝文件的内容校验和","是否相同；对 ",[36,5510,5497],{},[33,5512,5513],{},"父层 ID"," 是否相同。",[11,5516,5517,5518,5521,5522,5524,5525,5527],{},"方案 ② 的第一条指令是 ",[36,5519,5520],{},"COPY target\u002Fdemo.jar","。jar 里包含编译后的 class 和 MANIFEST 时间戳，改任何一行代码校验和都变 → 这一层重建 → 它的子层（",[36,5523,3004],{},"）父层 ID 变了 → 重建 → 后面四条 ",[36,5526,3008],{}," 的来源层变了 → 全部重建。",[11,5529,5530],{},[33,5531,5532],{},"只要\"每次都变的文件\"出现在 Dockerfile 靠前的位置，它后面的所有分层优化都是自我安慰。",[11,5534,5535,5536,5538,5539,5541],{},"方案 ③ 的关键就是把顺序倒过来：不变的（",[36,5537,3080],{},"、依赖）放前面，天天变的（",[36,5540,3084],{},"）放后面。这是 Dockerfile 优化的第一性原理，对所有语言都成立，不只是 Java。",[25,5543,5545],{"id":5544},"七还能更快吗","七、还能更快吗",[11,5547,5548],{},"按性价比排序，都是实测有效的：",[11,5550,5551],{},[33,5552,5553,5554,5557],{},"1. ",[36,5555,5556],{},".dockerignore","（成本最低，收益常常最大）",[11,5559,5560,5561,5564,5565,2914,5568,2914,5571,2914,5574,5577],{},"很多人构建慢，慢的不是编译，是",[33,5562,5563],{},"把几 GB 的 context 传给 Docker daemon","。项目根目录下的 ",[36,5566,5567],{},"target\u002F",[36,5569,5570],{},".git\u002F",[36,5572,5573],{},"node_modules\u002F",[36,5575,5576],{},".idea\u002F"," 全都会被打包发送。",[126,5579,5582],{"className":5580,"code":5581,"language":1894},[1892],".git\n.idea\n*.iml\ntarget\u002F*\n!target\u002F*.jar\nlogs\n",[36,5583,5581],{"__ignoreMap":131},[11,5585,5586],{},[33,5587,5588],{},"2. BuildKit 一定要开",[126,5590,5592],{"className":128,"code":5591,"language":130,"meta":131,"style":131},"export DOCKER_BUILDKIT=1\n",[36,5593,5594],{"__ignoreMap":131},[135,5595,5596,5599,5602,5604],{"class":137,"line":138},[135,5597,5598],{"class":3287},"export",[135,5600,5601],{"class":163}," DOCKER_BUILDKIT",[135,5603,3288],{"class":3287},[135,5605,5606],{"class":152},"1\n",[11,5608,5609,5611],{},[36,5610,3530],{},"、并行构建多阶段、更好的缓存输出，都依赖它。Docker 23+ 默认已开启，老版本记得手动打开。",[11,5613,5614],{},[33,5615,5616],{},"3. CI 上做缓存导出",[126,5618,5620],{"className":128,"code":5619,"language":130,"meta":131,"style":131},"docker buildx build \\\n  --cache-from type=registry,ref=registry.example.com\u002Fdemo:buildcache \\\n  --cache-to   type=registry,ref=registry.example.com\u002Fdemo:buildcache,mode=max \\\n  -t registry.example.com\u002Fdemo:${GIT_SHA} \\\n  --push .\n",[36,5621,5622,5633,5643,5653,5667],{"__ignoreMap":131},[135,5623,5624,5626,5629,5631],{"class":137,"line":138},[135,5625,2476],{"class":148},[135,5627,5628],{"class":156}," buildx",[135,5630,2479],{"class":156},[135,5632,185],{"class":152},[135,5634,5635,5638,5641],{"class":137,"line":145},[135,5636,5637],{"class":152},"  --cache-from",[135,5639,5640],{"class":156}," type=registry,ref=registry.example.com\u002Fdemo:buildcache",[135,5642,185],{"class":152},[135,5644,5645,5648,5651],{"class":137,"line":160},[135,5646,5647],{"class":152},"  --cache-to",[135,5649,5650],{"class":156},"   type=registry,ref=registry.example.com\u002Fdemo:buildcache,mode=max",[135,5652,185],{"class":152},[135,5654,5655,5658,5661,5664],{"class":137,"line":188},[135,5656,5657],{"class":152},"  -t",[135,5659,5660],{"class":156}," registry.example.com\u002Fdemo:",[135,5662,5663],{"class":163},"${GIT_SHA} ",[135,5665,5666],{"class":152},"\\\n",[135,5668,5669,5672],{"class":137,"line":211},[135,5670,5671],{"class":152},"  --push",[135,5673,2494],{"class":156},[11,5675,5676,5679,5680,5683,5684,5687],{},[36,5677,5678],{},"mode=max"," 会把中间层也导出，这样换一台 runner 也能命中依赖层缓存。没有镜像仓库缓存的话，用 GitHub Actions 的 ",[36,5681,5682],{},"actions\u002Fcache"," 挂 ",[36,5685,5686],{},"\u002Froot\u002F.m2"," 也是同一思路。",[11,5689,5690],{},[33,5691,5692],{},"4. JVM 侧的启动优化",[11,5694,5695],{},"镜像结构搞定了，还能再往下压。",[11,5697,5698,5699,5701,5702,5705,5706,1978],{},"我们用的 ",[36,5700,2375],{}," 基础镜像已经把 ",[33,5703,5704],{},"JDK 层","的 CDS 归档准备好了，但这只覆盖 JDK 自己的类，你的业务代码和 Spring 那一堆 jar 还是每次启动重新加载。下一步是",[33,5707,5708],{},"应用级 AppCDS",[126,5710,5712],{"className":1465,"code":5711,"language":1467,"meta":131,"style":131},"# 构建阶段训练一份归档\nRUN java -XX:ArchiveClassesAtExit=\u002Fapp\u002Fapp.jsa \\\n         -Dspring.context.exit=onRefresh \\\n         org.springframework.boot.loader.launch.JarLauncher\n\n# 运行时直接复用\nENTRYPOINT [\"java\", \"-XX:SharedArchiveFile=\u002Fapp\u002Fapp.jsa\", \\\n            \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[36,5713,5714,5719,5724,5729,5734,5738,5743,5748],{"__ignoreMap":131},[135,5715,5716],{"class":137,"line":138},[135,5717,5718],{},"# 构建阶段训练一份归档\n",[135,5720,5721],{"class":137,"line":145},[135,5722,5723],{},"RUN java -XX:ArchiveClassesAtExit=\u002Fapp\u002Fapp.jsa \\\n",[135,5725,5726],{"class":137,"line":160},[135,5727,5728],{},"         -Dspring.context.exit=onRefresh \\\n",[135,5730,5731],{"class":137,"line":188},[135,5732,5733],{},"         org.springframework.boot.loader.launch.JarLauncher\n",[135,5735,5736],{"class":137,"line":211},[135,5737,215],{"emptyLinePlaceholder":214},[135,5739,5740],{"class":137,"line":218},[135,5741,5742],{},"# 运行时直接复用\n",[135,5744,5745],{"class":137,"line":230},[135,5746,5747],{},"ENTRYPOINT [\"java\", \"-XX:SharedArchiveFile=\u002Fapp\u002Fapp.jsa\", \\\n",[135,5749,5750],{"class":137,"line":238},[135,5751,5752],{},"            \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[11,5754,5755,5758],{},[36,5756,5757],{},"-Dspring.context.exit=onRefresh"," 是 Spring Boot 提供的开关，上下文刷新完就退出，专门给生成 CDS 归档用的——不然构建阶段会一直挂着。",[11,5760,5761,5764],{},[33,5762,5763],{},"但这个做法有个现实障碍","：训练归档需要应用能真正启动起来，也就是说构建阶段得连得上数据库、配置中心、注册中心。多数项目做不到。两个折中方案：",[1638,5766,5767,5782],{},[1503,5768,5769,5770,5773,5774,5777,5778,5781],{},"用 JDK 21 的 ",[36,5771,5772],{},"-XX:+AutoCreateSharedArchive","，让容器",[33,5775,5776],{},"第一次启动时自动生成","归档，把 ",[36,5779,5780],{},"app.jsa"," 落在挂载卷里，后续重启复用。代价是第一次启动没收益。",[1503,5783,5784],{},"在 CI 上单独跑一个带完整依赖的\"训练环境\"生成归档，再塞进镜像。复杂度高，只有在启动时间是硬指标（比如 Serverless 冷启动 SLA）时才值得。",[11,5786,5787],{},"其他两条路：",[1638,5789,5790,5796],{},[1503,5791,5792,5795],{},[33,5793,5794],{},"Spring AOT \u002F GraalVM Native Image","：启动能到几十毫秒，代价是构建时间长、反射需要额外配置、部分动态特性受限。",[1503,5797,5798,5801],{},[33,5799,5800],{},"CRaC","：快照恢复，启动亚秒级。BellSoft 的 Liberica 有支持 CRaC 的发行版本，走这条路不用换 JDK 厂商，具体 tag 去下载页确认。",[11,5803,5804],{},[33,5805,5806],{},"5. 镜像瘦身",[11,5808,5809,5810,5813],{},"先搞清楚一件事：",[33,5811,5812],{},"分层不减体积","，它只优化缓存命中和推拉流量。四种方案的镜像大小基本一样，大头全在基础镜像上。所以瘦身的第一步是先量一下底座：",[126,5815,5817],{"className":128,"code":5816,"language":130,"meta":131,"style":131},"docker pull bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\ndocker images bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\n",[36,5818,5819,5829],{"__ignoreMap":131},[135,5820,5821,5823,5826],{"class":137,"line":138},[135,5822,2476],{"class":148},[135,5824,5825],{"class":156}," pull",[135,5827,5828],{"class":156}," bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds\n",[135,5830,5831,5833,5835],{"class":137,"line":145},[135,5832,2476],{"class":148},[135,5834,5104],{"class":156},[135,5836,5828],{"class":156},[11,5838,5839],{},"如果发现底座就占了 80%，那再怎么优化 Dockerfile 都是白费力气，得从基础镜像下手：",[1638,5841,5842,5865,5878,5907],{},[1503,5843,5844,5847,5848,2914,5851,2914,5854,5857,5858,5861,5862,5864],{},[33,5845,5846],{},"换成 JRE 变体","。运行阶段不需要 ",[36,5849,5850],{},"javac",[36,5852,5853],{},"jmap",[36,5855,5856],{},"jcmd"," 这些工具，BellSoft 对应的镜像是 ",[36,5859,5860],{},"bellsoft\u002Fliberica-openjre-*","（注意 builder 阶段如果也要解压，同样用 JRE 就够，见方案 ②）。是否有 rocky + ",[36,5863,2375],{}," 的组合 tag，去下载页确认一下再写进 Dockerfile。",[1503,5866,5867,5870,5871,2914,5874,5877],{},[33,5868,5869],{},"换更小的 OS 底座","。Rocky Linux 属于偏重的选择，同一家还有 ",[36,5872,5873],{},"liberica-openjdk-alpine-musl",[36,5875,5876],{},"liberica-openjdk-debian"," 等变体，musl 版本体积最小。代价是 musl 和 glibc 是两套 C 标准库，行为上有差异，涉及本地库加载、字体渲染、DNS 解析、时区数据时要多测一轮。",[1503,5879,5880,5886,5887,5889,5890,5893,5894,2914,5897,2914,5900,2914,5903,5906],{},[33,5881,5882,5885],{},[36,5883,5884],{},"jlink"," 裁剪运行时","。Liberica JDK 自带 ",[36,5888,5884],{},"，可以只保留实际用到的模块，压出几十 MB 的定制 runtime。用 ",[36,5891,5892],{},"jdeps"," 先分析依赖模块，Spring Boot 应用一般少不了 ",[36,5895,5896],{},"java.sql",[36,5898,5899],{},"java.naming",[36,5901,5902],{},"java.instrument",[36,5904,5905],{},"jdk.unsupported"," 这些。",[1503,5908,5909,5912,5913,5915,5916,5919],{},[33,5910,5911],{},"检查依赖层","。是不是把 devtools、测试库、代码生成器打进了 ",[36,5914,1963],{}," 层。",[36,5917,5918],{},"du -sh \u002Ftmp\u002Flayers\u002Fdependencies\u002F*"," 排个序，前几名往往就有惊喜。",[25,5921,5923],{"id":5922},"八还有第三条路不用-dockerfile","八、还有第三条路：不用 Dockerfile",[11,5925,5926],{},"写到这里其实还有个绕不开的选项，篇幅关系只简单提一下，后面可以单独写一篇。",[11,5928,5929,5932,5933,5936,5937,5939],{},[33,5930,5931],{},"Google Jib","：不需要 Docker daemon，直接从 Maven 依赖产物",[33,5934,5935],{},"在 JVM 里拼出分层的 OCI 镜像","。它的分层粒度比 ",[36,5938,1906],{}," 更细（dependencies \u002F snapshot-dependencies \u002F resources \u002F classes），改一行代码只重传 classes 那一层，几 KB。构建速度和方案 ② 接近，缓存效果接近方案 ④。",[126,5941,5943],{"className":128,"code":5942,"language":130,"meta":131,"style":131},"mvn compile jib:dockerBuild\n",[36,5944,5945],{"__ignoreMap":131},[135,5946,5947,5949,5952],{"class":137,"line":138},[135,5948,2453],{"class":148},[135,5950,5951],{"class":156}," compile",[135,5953,5954],{"class":156}," jib:dockerBuild\n",[11,5956,5957,1978,5960,5963],{},[33,5958,5959],{},"Spring Boot Buildpacks",[36,5961,5962],{},"mvn spring-boot:build-image","，走 CNB 标准，产物分层合理，但首次构建同样要拉 builder 镜像，比较重。",[11,5965,5966,5967,39],{},"如果你的诉求就是\"CI 上又快又可复现\"，",[33,5968,5969],{},"Jib 大概率比手写 Dockerfile 更省心",[25,5971,5973],{"id":5972},"九我的最终选型","九、我的最终选型",[11,5975,5976],{},"折腾一圈，落到了一个组合拳，不是单选：",[41,5978,5979,5991],{},[44,5980,5981],{},[47,5982,5983,5986,5988],{},[50,5984,5985],{},"场景",[50,5987,1541],{},[50,5989,5990],{},"理由",[60,5992,5993,6007,6022],{},[47,5994,5995,5998,6001],{},[65,5996,5997],{},"本地日常开发",[65,5999,6000],{},"方案 ①（COPY 不分层）",[65,6002,6003,6004,6006],{},"追求最快闭环，启动慢几秒无所谓，本地一般直接 ",[36,6005,1511],{}," 不进容器",[47,6008,6009,6012,6015],{},[65,6010,6011],{},"本地需要跑容器联调",[65,6013,6014],{},"方案 ②（COPY + 多阶段解压）",[65,6016,6017,6018,6021],{},"3",[5473,6019,6020],{},"5 秒构建 + 1","3 秒启动，性价比最高",[47,6023,6024,6027,6030],{},[65,6025,6026],{},"CI \u002F 生产镜像",[65,6028,6029],{},"方案 ③（Maven 多阶段） + BuildKit 缓存 + registry cache",[65,6031,6032],{},"可复现、依赖层永久缓存、构建机零依赖",[11,6034,6035,6036,1978,6039,6041,6042,6045],{},"具体做法是",[33,6037,6038],{},"两个 Dockerfile 分开维护",[36,6040,3850],{},"（给 CI 用）和 ",[36,6043,6044],{},"Dockerfile.dev","（给本地用），或者用 build target 区分：",[126,6047,6049],{"className":1465,"code":6048,"language":1467,"meta":131,"style":131},"FROM maven:3.9-eclipse-temurin-21 AS build\n# ... 完整构建\n\nFROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS dev\nCOPY target\u002Fdemo.jar app.jar          # 本地：直接拷已打好的 jar\nENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]\n\nFROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS prod\nCOPY --from=build \u002Fextracted\u002F... .\u002F\nENTRYPOINT [\"java\", \"org.springframework.boot.loader.launch.JarLauncher\"]\n",[36,6050,6051,6055,6060,6064,6069,6074,6078,6082,6087,6092],{"__ignoreMap":131},[135,6052,6053],{"class":137,"line":138},[135,6054,3100],{},[135,6056,6057],{"class":137,"line":145},[135,6058,6059],{},"# ... 完整构建\n",[135,6061,6062],{"class":137,"line":160},[135,6063,215],{"emptyLinePlaceholder":214},[135,6065,6066],{"class":137,"line":188},[135,6067,6068],{},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS dev\n",[135,6070,6071],{"class":137,"line":211},[135,6072,6073],{},"COPY target\u002Fdemo.jar app.jar          # 本地：直接拷已打好的 jar\n",[135,6075,6076],{"class":137,"line":218},[135,6077,2433],{},[135,6079,6080],{"class":137,"line":230},[135,6081,215],{"emptyLinePlaceholder":214},[135,6083,6084],{"class":137,"line":238},[135,6085,6086],{},"FROM bellsoft\u002Fliberica-openjdk-rocky:21.0.12-cds AS prod\n",[135,6088,6089],{"class":137,"line":244},[135,6090,6091],{},"COPY --from=build \u002Fextracted\u002F... .\u002F\n",[135,6093,6094],{"class":137,"line":255},[135,6095,2751],{},[126,6097,6099],{"className":128,"code":6098,"language":130,"meta":131,"style":131},"docker build --target dev  -t demo:dev .\ndocker build --target prod -t demo:prod .\n",[36,6100,6101,6120],{"__ignoreMap":131},[135,6102,6103,6105,6107,6110,6113,6115,6118],{"class":137,"line":138},[135,6104,2476],{"class":148},[135,6106,2479],{"class":156},[135,6108,6109],{"class":152}," --target",[135,6111,6112],{"class":156}," dev",[135,6114,5657],{"class":152},[135,6116,6117],{"class":156}," demo:dev",[135,6119,2494],{"class":156},[135,6121,6122,6124,6126,6128,6131,6133,6136],{"class":137,"line":145},[135,6123,2476],{"class":148},[135,6125,2479],{"class":156},[135,6127,6109],{"class":152},[135,6129,6130],{"class":156}," prod",[135,6132,2488],{"class":152},[135,6134,6135],{"class":156}," demo:prod",[135,6137,2494],{"class":156},[25,6139,6141],{"id":6140},"十踩坑清单","十、踩坑清单",[11,6143,6144],{},"按被坑到的概率排序：",[1500,6146,6147,6164,6181,6187,6203,6211,6231,6240,6253],{},[1503,6148,6149,6152,6153,6156,6157,6160,6161,39],{},[33,6150,6151],{},"Spring Boot 3.2 换了启动器包名","。老教程里的 ",[36,6154,6155],{},"org.springframework.boot.loader.JarLauncher"," 在 3.2+ 是 ",[36,6158,6159],{},"org.springframework.boot.loader.launch.JarLauncher","，写错直接 ",[36,6162,6163],{},"ClassNotFoundException",[1503,6165,6166,6171,6172,6174,6175,6177,6178,6180],{},[33,6167,6168,6169],{},"builder 阶段的基础镜像里没有 ",[36,6170,1873],{},"。解压只需要 JRE、不需要 JDK，所以换成 Liberica 的 JRE 变体也完全没问题；但如果用了 ",[36,6173,3038],{},"、裸 ",[36,6176,3041],{},"，或者镜像的 Java 版本低于 jar 的编译版本，这一步就会失败（后者报 ",[36,6179,3045],{},"）。",[1503,6182,6183,6186],{},[33,6184,6185],{},"忘了多阶段，jar 和解压产物同时留在镜像里","，镜像凭空多出一个 jar 的体积（本文的例子是 60MB）。多阶段不是可选优化，是必须项。",[1503,6188,6189,6198,6199,6202],{},[33,6190,6191,6193,6194,6197],{},[36,6192,3502],{}," 写在 ",[36,6195,6196],{},"COPY pom.xml"," 前面","，或者干脆 ",[36,6200,6201],{},"COPY . ."," 一把梭——依赖层缓存彻底报废，这是最常见的错误。",[1503,6204,6205,6210],{},[33,6206,6207,6208],{},"CI 上没有持久化 ",[36,6209,3414],{},"，导致每次都冷启动，然后得出\"分层没用\"的错误结论。",[1503,6212,6213,6219,6220,6223,6224,6227,6228,6230],{},[33,6214,6215,6218],{},[36,6216,6217],{},"\u003Clayers>"," 配置被关掉了","。Spring Boot 2.3+ 默认开启，但如果 ",[36,6221,6222],{},"spring-boot-maven-plugin"," 里显式配了 ",[36,6225,6226],{},"\u003Clayers>\u003Cenabled>false\u003C\u002Fenabled>\u003C\u002Flayers>","，或者用了自定义 layout，",[36,6229,1906],{}," 会直接报 \"not layered\"。",[1503,6232,6233,6236,6237,6239],{},[33,6234,6235],{},"SNAPSHOT 依赖","会让 ",[36,6238,2816],{}," 层频繁失效，生产镜像尽量锁 release 版本。",[1503,6241,6242,6245,6246,6249,6250,6252],{},[33,6243,6244],{},"构建信息里的时间戳","。开了 ",[36,6247,6248],{},"build-info","、或者往 MANIFEST 里塞了构建时间\u002Fgit commit，会让 ",[36,6251,2822],{}," 层每次都变——这层本来每次都变倒无所谓，但注意别把它混进依赖层。",[1503,6254,6255,1978,6261,6264,6265,6267],{},[33,6256,6257,6260],{},[36,6258,6259],{},"-Djarmode"," 在 Spring Boot 3.3+ 有新写法",[36,6262,6263],{},"java -Djarmode=tools -jar app.jar extract","，老的 ",[36,6266,1906],{}," 标记为废弃但仍可用。升级时留意日志里的 warning。",[25,6269,6271],{"id":6270},"十一写在最后","十一、写在最后",[11,6273,6274],{},"这次优化真正的收获不是省下的那几分钟，而是想明白了两件事：",[11,6276,6277,6280],{},[33,6278,6279],{},"第一，Docker 分层缓存的唯一心法是\"按变更频率排序\"","——不变的在前，天天变的在后。这条规则跟语言无关，跟框架无关，Node、Python、Go 全都一样。",[11,6282,6283,6286],{},[33,6284,6285],{},"第二，构建速度、启动速度、可复现性，是个不可能三角","，至少在没有缓存基础设施的前提下是。本地开发放弃可复现性换构建速度，是合理的；生产镜像放弃可复现性，就是在给未来的自己埋雷。",[11,6288,6289],{},"分清场景，别指望一个 Dockerfile 通吃。",[1393,6291],{},[11,6293,6294],{},[6295,6296,6297,6298,6300],"em",{},"以上数据基于 Spring Boot 3.2 + JDK 21（BellSoft Liberica 21.0.12，Rocky Linux 底座，",[36,6299,2375],{}," 变体）+ Maven 3.9，一个约 80 个依赖的中等规模服务实测。基础镜像换一换，\"镜像大小\"和\"容器启动\"这两列都会跟着变，别直接照搬数字。你的项目依赖越多，方案 ③ 的优势越明显；依赖很少的话，可能方案 ② 就够了。",[1399,6302,6303],{},"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":131,"searchDepth":145,"depth":145,"links":6305},[6306,6307,6308,6314,6319,6328,6332,6333,6334,6335,6336],{"id":1448,"depth":145,"text":1449},{"id":1528,"depth":145,"text":1529},{"id":1704,"depth":145,"text":1705,"children":6309},[6310,6311,6312,6313],{"id":1860,"depth":160,"text":1860},{"id":1967,"depth":160,"text":1968},{"id":2053,"depth":160,"text":2054},{"id":2288,"depth":160,"text":2288},{"id":2349,"depth":145,"text":2350,"children":6315},[6316,6317,6318],{"id":2408,"depth":160,"text":2409},{"id":2686,"depth":160,"text":2687},{"id":3070,"depth":160,"text":3071},{"id":3610,"depth":145,"text":3611,"children":6320},[6321,6322,6323,6324,6325,6326,6327],{"id":3621,"depth":160,"text":3622},{"id":3680,"depth":160,"text":3681},{"id":4366,"depth":160,"text":4367},{"id":4891,"depth":160,"text":4892},{"id":5086,"depth":160,"text":5087},{"id":5184,"depth":160,"text":5185},{"id":5322,"depth":160,"text":5323},{"id":5434,"depth":145,"text":5435,"children":6329},[6330,6331],{"id":5438,"depth":160,"text":5439},{"id":5480,"depth":160,"text":5481},{"id":5544,"depth":145,"text":5545},{"id":5922,"depth":145,"text":5923},{"id":5972,"depth":145,"text":5973},{"id":6140,"depth":145,"text":6141},{"id":6270,"depth":145,"text":6271},"2026-09-20","一次为了少等几分钟而做的四种 Dockerfile 方案横向对比，附原理拆解和选型建议。",{},"\u002Farticles\u002Fspring-boot-dockerfile",{"title":1443,"description":6338},{"loc":6340,"images":6343},[6344],{"loc":1455},"articles\u002Fspring-boot-dockerfile","Pah4jgKmPI0GrZrpflSW3S6Zhr3THdyeL-Pr_VLlo1w",{"id":6348,"title":6349,"body":6350,"category":1418,"cover":7276,"date":7277,"description":7278,"extension":1421,"meta":7279,"navigation":214,"path":7280,"seo":7281,"sitemap":7282,"stem":7283,"__hash__":7284},"articles\u002Farticles\u002F库存扣减超时，别急着上Redis.md","老系统库存扣减超时，不一定需要马上引入 Redis",{"type":8,"value":6351,"toc":7241},[6352,6355,6359,6362,6368,6374,6377,6391,6397,6399,6403,6407,6413,6418,6429,6434,6448,6457,6459,6463,6466,6470,6478,6482,6499,6507,6509,6513,6516,6521,6535,6539,6550,6554,6568,6576,6578,6582,6585,6588,6591,6594,6683,6686,6689,6692,6694,6698,6702,6713,6717,6720,6724,6727,6735,6739,6742,6744,6748,6754,6757,6771,6773,6777,6781,6784,6787,6798,6802,6805,6819,6822,6826,6832,6834,6848,6850,6854,6857,6860,6866,6871,6873,6877,6880,6886,6889,6892,6903,6905,6909,6915,6918,6924,6926,6930,6934,6940,6945,6962,6968,6970,6974,6979,6983,6997,7002,7004,7008,7013,7017,7031,7039,7041,7045,7048,7051,7057,7060,7074,7077,7079,7083,7085,7173,7175,7179,7182,7189,7192,7209,7212,7214,7218,7224,7227,7238],[11,6353,6354],{},"背景：\n朋友公司订单系统，架构古老，\n技术栈繁多，C\u002FS、B\u002FS混合，主库Oracle，经常遇到部分商品售卖过程中，出现卡顿的情况。经常观察和定位后，发现是库存扣减出现的偶发性超时。人力有限也不想大改系统架构，就一起讨论了一下后续可落地的一些小方案和建议。",[25,6356,6358],{"id":6357},"一问题本质不是锁机制有问题而是热点行被反复争抢","一、问题本质：不是锁机制有问题，而是热点行被反复争抢",[11,6360,6361],{},"很多老系统的库存扣减逻辑大致是这样的：",[126,6363,6366],{"className":6364,"code":6365,"language":1894},[1892],"update t_stock\nset stock = stock - #{num}\nwhere sku_id = #{skuId}\n  and stock >= #{num};\n",[36,6367,6365],{"__ignoreMap":131},[11,6369,6370,6371,39],{},"这条 SQL 本身是原子的，Oracle 行锁也没有问题。\n问题在于：",[33,6372,6373],{},"同一个 SKU 的所有请求都在更新同一行记录",[11,6375,6376],{},"当某个商品在短时间内出现多单同时扣减时：",[1638,6378,6379,6382,6385,6388],{},[1503,6380,6381],{},"第一个请求拿到行锁；",[1503,6383,6384],{},"后续请求必须等待前一个事务提交或回滚；",[1503,6386,6387],{},"等待队列越来越长；",[1503,6389,6390],{},"接口超时、事务堆积、部分订单失败。",[11,6392,6393,6394,39],{},"这就是典型的",[33,6395,6396],{},"热点行锁竞争",[1393,6398],{},[25,6400,6402],{"id":6401},"二为什么常见方案不一定适合存量系统","二、为什么常见方案不一定适合存量系统",[371,6404,6406],{"id":6405},"_1-乐观锁-version","1. 乐观锁 Version",[126,6408,6411],{"className":6409,"code":6410,"language":1894},[1892],"update t_stock\nset stock = stock - #{num}, version = version + 1\nwhere sku_id = #{skuId}\n  and version = #{oldVersion}\n  and stock >= #{num};\n",[36,6412,6410],{"__ignoreMap":131},[11,6414,6415],{},[33,6416,6417],{},"优点：",[1638,6419,6420,6423,6426],{},[1503,6421,6422],{},"改造成本低；",[1503,6424,6425],{},"不需要改表结构太多；",[1503,6427,6428],{},"非热点商品可用。",[11,6430,6431],{},[33,6432,6433],{},"缺点：",[1638,6435,6436,6439,6442,6445],{},[1503,6437,6438],{},"热点 SKU 会出现大量版本冲突；",[1503,6440,6441],{},"大量请求反复重试；",[1503,6443,6444],{},"CPU 压力上升；",[1503,6446,6447],{},"不一定能明显改善超时问题。",[11,6449,6450,6453,6454,39],{},[33,6451,6452],{},"结论：","\n适合并发不高的普通商品，",[33,6455,6456],{},"不建议作为热点库存问题的主方案",[1393,6458],{},[371,6460,6462],{"id":6461},"_2-应用层队列串行化","2. 应用层队列串行化",[11,6464,6465],{},"把同一个 SKU 的扣减请求排队，串行进入数据库。",[11,6467,6468],{},[33,6469,6417],{},[1638,6471,6472,6475],{},[1503,6473,6474],{},"数据库侧逻辑改动较小；",[1503,6476,6477],{},"可以避免并发行锁。",[11,6479,6480],{},[33,6481,6433],{},[1638,6483,6484,6487,6490,6493,6496],{},[1503,6485,6486],{},"多技术栈系统改造复杂；",[1503,6488,6489],{},"C\u002FS、B\u002FS、Java、C#、PB 都可能要适配；",[1503,6491,6492],{},"响应时间变长；",[1503,6494,6495],{},"队列成为新瓶颈；",[1503,6497,6498],{},"多实例部署还需要 SKU 路由。",[11,6500,6501,6503,6504,39],{},[33,6502,6452],{},"\n适合作为辅助削峰手段，",[33,6505,6506],{},"不太适合作为存量系统第一改造方案",[1393,6508],{},[371,6510,6512],{"id":6511},"_3-redis-预扣-mq-异步落库","3. Redis 预扣 + MQ 异步落库",[11,6514,6515],{},"这是大促、秒杀场景常见方案。",[11,6517,6518],{},[33,6519,6520],{},"思路：",[1500,6522,6523,6526,6529,6532],{},[1503,6524,6525],{},"Redis 先做库存预扣；",[1503,6527,6528],{},"扣成功后发送 MQ；",[1503,6530,6531],{},"消费端异步更新 Oracle；",[1503,6533,6534],{},"订单、支付、取消、回补、对账链路重新设计。",[11,6536,6537],{},[33,6538,6417],{},[1638,6540,6541,6544,6547],{},[1503,6542,6543],{},"可以承受更高瞬时流量；",[1503,6545,6546],{},"Redis 承担入口洪峰；",[1503,6548,6549],{},"适合秒杀、大促场景。",[11,6551,6552],{},[33,6553,6433],{},[1638,6555,6556,6559,6562,6565],{},[1503,6557,6558],{},"引入 Redis、MQ、对账、补偿、回补等多个组件；",[1503,6560,6561],{},"一致性从强一致变成最终一致；",[1503,6563,6564],{},"老系统风险高；",[1503,6566,6567],{},"如果 Oracle 还是单行库存，消费端依然可能出现行锁等待。",[11,6569,6570,6572,6573,39],{},[33,6571,6452],{},"\n对存量混合技术栈系统来说，",[33,6574,6575],{},"风险较高，不建议作为第一步",[1393,6577],{},[25,6579,6581],{"id":6580},"三推荐主方案oracle-库存分桶","三、推荐主方案：Oracle 库存分桶",[371,6583,6584],{"id":6584},"核心思路",[11,6586,6587],{},"把一个逻辑 SKU 的库存，拆成多个物理桶。",[11,6589,6590],{},"例如：",[11,6592,6593],{},"表格",[41,6595,6596,6609],{},[44,6597,6598],{},[47,6599,6600,6603,6606],{},[50,6601,6602],{},"sku_id",[50,6604,6605],{},"bucket_no",[50,6607,6608],{},"stock",[60,6610,6611,6621,6630,6638,6647,6656,6665,6674],{},[47,6612,6613,6616,6618],{},[65,6614,6615],{},"1001",[65,6617,3291],{},[65,6619,6620],{},"125",[47,6622,6623,6625,6628],{},[65,6624,6615],{},[65,6626,6627],{},"2",[65,6629,6620],{},[47,6631,6632,6634,6636],{},[65,6633,6615],{},[65,6635,6017],{},[65,6637,6620],{},[47,6639,6640,6642,6645],{},[65,6641,6615],{},[65,6643,6644],{},"4",[65,6646,6620],{},[47,6648,6649,6651,6654],{},[65,6650,6615],{},[65,6652,6653],{},"5",[65,6655,6620],{},[47,6657,6658,6660,6663],{},[65,6659,6615],{},[65,6661,6662],{},"6",[65,6664,6620],{},[47,6666,6667,6669,6672],{},[65,6668,6615],{},[65,6670,6671],{},"7",[65,6673,6620],{},[47,6675,6676,6678,6681],{},[65,6677,6615],{},[65,6679,6680],{},"8",[65,6682,6620],{},[11,6684,6685],{},"总库存仍然是 1000，但同一个 SKU 不再只有一行。",[11,6687,6688],{},"扣减时，数据库随机选择一个有足够库存的桶进行更新。",[11,6690,6691],{},"这样，并发请求会分散到不同行，而不是全部挤在同一行。",[1393,6693],{},[25,6695,6697],{"id":6696},"四分桶方案为什么适合存量系统","四、分桶方案为什么适合存量系统",[371,6699,6701],{"id":6700},"_1-风险相对可控","1. 风险相对可控",[11,6703,6704,6705,6708,6709,6712],{},"主要改造集中在",[33,6706,6707],{},"库存表结构","和",[33,6710,6711],{},"库存扣减 \u002F 归还逻辑","，不一定要一开始就引入 Redis 和 MQ。",[371,6714,6716],{"id":6715},"_2-仍然保留数据库事务边界","2. 仍然保留数据库事务边界",[11,6718,6719],{},"订单和库存可以尽量保持原有事务模型，不像 Redis + MQ 那样需要彻底重构链路。",[371,6721,6723],{"id":6722},"_3-可以按-sku-灰度","3. 可以按 SKU 灰度",[11,6725,6726],{},"不是所有商品都必须分桶。",[1638,6728,6729,6732],{},[1503,6730,6731],{},"热点 SKU：分 4 桶、8 桶或 16 桶；",[1503,6733,6734],{},"普通 SKU：分 1 桶，等价于原来的单行模式。",[371,6736,6738],{"id":6737},"_4-对多技术栈系统更友好","4. 对多技术栈系统更友好",[11,6740,6741],{},"库存分桶本质上是数据库侧的结构调整，只要各端最终调用同一套库存服务或存储过程，就可以逐步收敛。",[1393,6743],{},[25,6745,6747],{"id":6746},"五表结构建议","五、表结构建议",[126,6749,6752],{"className":6750,"code":6751,"language":1894},[1892],"create table t_sku_stock (\n  id number primary key,\n  sku_id number not null,\n  bucket_no number not null,\n  stock number not null default 0,\n  create_time date default sysdate,\n  update_time date default sysdate,\n  constraint uk_sku_bucket unique (sku_id, bucket_no)\n);\n",[36,6753,6751],{"__ignoreMap":131},[11,6755,6756],{},"说明：",[1638,6758,6759,6765,6768],{},[1503,6760,6761,6764],{},[36,6762,6763],{},"sku_id + bucket_no"," 是逻辑唯一键；",[1503,6766,6767],{},"每个 SKU 预先创建 1~N 个桶；",[1503,6769,6770],{},"冷 SKU 可以只保留 1 个桶。",[1393,6772],{},[25,6774,6776],{"id":6775},"六桶是怎么分配的","六、桶是怎么分配的？",[371,6778,6780],{"id":6779},"_1-桶不是哈希出来的","1. 桶不是哈希出来的",[11,6782,6783],{},"不要根据订单号、用户号做哈希取模来固定分配桶。",[11,6785,6786],{},"这种方式容易导致：",[1638,6788,6789,6792,6795],{},[1503,6790,6791],{},"某个桶先被扣光；",[1503,6793,6794],{},"其他桶还有库存；",[1503,6796,6797],{},"业务提前返回库存不足。",[371,6799,6801],{"id":6800},"_2-桶是预先生成的","2. 桶是预先生成的",[11,6803,6804],{},"每个 SKU 在上架或初始化时，预先创建固定编号的桶：",[1638,6806,6807,6810,6813,6816],{},[1503,6808,6809],{},"1 桶",[1503,6811,6812],{},"4 桶",[1503,6814,6815],{},"8 桶",[1503,6817,6818],{},"16 桶",[11,6820,6821],{},"具体数量取决于商品热点程度。",[371,6823,6825],{"id":6824},"_3-扣减时动态选桶","3. 扣减时动态选桶",[126,6827,6830],{"className":6828,"code":6829,"language":1894},[1892],"update t_sku_stock\nset stock = stock - #{num},\n    update_time = sysdate\nwhere sku_id = #{skuId}\n  and bucket_no = (\n        select bucket_no\n        from t_sku_stock\n        where sku_id = #{skuId}\n          and stock >= #{num}\n        order by dbms_random.value\n        fetch first 1 row only\n      )\n  and stock >= #{num};\n",[36,6831,6829],{"__ignoreMap":131},[11,6833,6756],{},[1638,6835,6836,6839,6842,6845],{},[1503,6837,6838],{},"子查询随机找一个有足够库存的桶；",[1503,6840,6841],{},"外层 update 只锁这一行；",[1503,6843,6844],{},"返回行数大于 0，表示扣减成功；",[1503,6846,6847],{},"返回 0，业务层可以有限重试。",[1393,6849],{},[25,6851,6853],{"id":6852},"七归还库存怎么处理","七、归还库存怎么处理？",[11,6855,6856],{},"订单取消、退货时，不需要严格归还到原来扣减的那个桶。",[11,6858,6859],{},"可以随机选择一个桶归还：",[126,6861,6864],{"className":6862,"code":6863,"language":1894},[1892],"update t_sku_stock\nset stock = stock + #{num},\n    update_time = sysdate\nwhere sku_id = #{skuId}\n  and bucket_no = (\n        select bucket_no\n        from t_sku_stock\n        where sku_id = #{skuId}\n        order by dbms_random.value\n        fetch first 1 row only\n      );\n",[36,6865,6863],{"__ignoreMap":131},[11,6867,6868,6870],{},[33,6869,2601],{},"\n归还逻辑必须保证最终总库存正确，避免漏还、重复还。",[1393,6872],{},[25,6874,6876],{"id":6875},"八总库存怎么查询","八、总库存怎么查询？",[11,6878,6879],{},"真实总库存可以通过汇总得到：",[126,6881,6884],{"className":6882,"code":6883,"language":1894},[1892],"select sum(stock)\nfrom t_sku_stock\nwhere sku_id = #{skuId};\n",[36,6885,6883],{"__ignoreMap":131},[11,6887,6888],{},"但不建议把这个 SQL 放在下单主链路中高频执行。",[11,6890,6891],{},"更合适的方式是：",[1638,6893,6894,6897,6900],{},[1503,6895,6896],{},"下单链路只关心扣减是否成功；",[1503,6898,6899],{},"前台展示库存读取缓存；",[1503,6901,6902],{},"后台对账、管理页面再使用 sum 查询。",[1393,6904],{},[25,6906,6908],{"id":6907},"九存储过程初始化示例","九、存储过程初始化示例",[126,6910,6913],{"className":6911,"code":6912,"language":1894},[1892],"create or replace procedure proc_init_sku_bucket(\n  p_sku_id      in  number,\n  p_total_stock in  number,\n  p_bucket_cnt  in  number,\n  p_ret_code    out number,\n  p_ret_msg     out varchar2\n)\nis\n  v_avg number;\n  v_mod number;\n  v_stock number;\n  v_exists number;\nbegin\n  p_ret_code := 0;\n  p_ret_msg  := 'success';\n\n  if p_bucket_cnt \u003C 1 then\n    p_ret_code := -1;\n    p_ret_msg  := 'bucket count must >= 1';\n    return;\n  end if;\n\n  if p_total_stock \u003C 0 then\n    p_ret_code := -1;\n    p_ret_msg  := 'stock can not \u003C 0';\n    return;\n  end if;\n\n  select count(1)\n  into v_exists\n  from t_sku_stock\n  where sku_id = p_sku_id;\n\n  if v_exists > 0 then\n    p_ret_code := -1;\n    p_ret_msg  := 'sku bucket already exists';\n    return;\n  end if;\n\n  v_avg := trunc(p_total_stock \u002F p_bucket_cnt);\n  v_mod := mod(p_total_stock, p_bucket_cnt);\n\n  for i in 1 .. p_bucket_cnt loop\n    if i \u003C= v_mod then\n      v_stock := v_avg + 1;\n    else\n      v_stock := v_avg;\n    end if;\n\n    insert into t_sku_stock(sku_id, bucket_no, stock, create_time, update_time)\n    values(p_sku_id, i, v_stock, sysdate, sysdate);\n  end loop;\n\n  commit;\n\nexception\n  when others then\n    rollback;\n    p_ret_code := -2;\n    p_ret_msg  := 'error: ' || sqlerrm;\nend proc_init_sku_bucket;\n\u002F\n",[36,6914,6912],{"__ignoreMap":131},[11,6916,6917],{},"调用方式：",[126,6919,6922],{"className":6920,"code":6921,"language":1894},[1892],"declare\n  v_code number;\n  v_msg  varchar2(200);\nbegin\n  proc_init_sku_bucket(\n    p_sku_id      => 1001,\n    p_total_stock => 1000,\n    p_bucket_cnt  => 8,\n    p_ret_code    => v_code,\n    p_ret_msg     => v_msg\n  );\n  dbms_output.put_line('code:' || v_code || ', msg:' || v_msg);\nend;\n\u002F\n",[36,6923,6921],{"__ignoreMap":131},[1393,6925],{},[25,6927,6929],{"id":6928},"十存量系统可执行改造步骤","十、存量系统可执行改造步骤",[371,6931,6933],{"id":6932},"阶段一止血先改热点-sku","阶段一：止血，先改热点 SKU",[11,6935,6936,6939],{},[33,6937,6938],{},"目标："," 快速解决热点商品扣减超时问题。",[11,6941,6942],{},[33,6943,6944],{},"动作：",[1500,6946,6947,6950,6953,6956,6959],{},[1503,6948,6949],{},"识别最近发生超时、等待明显的 SKU；",[1503,6951,6952],{},"新建分桶库存表或增加分桶字段；",[1503,6954,6955],{},"对热点 SKU 初始化 4 桶或 8 桶；",[1503,6957,6958],{},"把这些 SKU 的扣减逻辑切到分桶模式；",[1503,6960,6961],{},"观察行锁等待和超时情况。",[11,6963,6964,6967],{},[33,6965,6966],{},"建议：","\n不要一开始就全量改造所有商品。",[1393,6969],{},[371,6971,6973],{"id":6972},"阶段二收敛库存服务","阶段二：收敛库存服务",[11,6975,6976,6978],{},[33,6977,6938],{}," 避免 C\u002FS、B\u002FS、Java、C#、PB 各自维护一套不一致的扣减逻辑。",[11,6980,6981],{},[33,6982,6944],{},[1500,6984,6985,6988,6991,6994],{},[1503,6986,6987],{},"抽象统一的库存扣减接口或存储过程；",[1503,6989,6990],{},"各端通过统一入口操作库存；",[1503,6992,6993],{},"区分 “扣减”“归还”“查询”“对账” 四类操作；",[1503,6995,6996],{},"记录操作流水，便于问题追踪。",[11,6998,6999,7001],{},[33,7000,6966],{},"\n这一步比单纯引入 Redis 更重要。\n存量系统很多问题不是技术单一问题，而是入口太多、逻辑分散。",[1393,7003],{},[371,7005,7007],{"id":7006},"阶段三补充缓存与对账","阶段三：补充缓存与对账",[11,7009,7010,7012],{},[33,7011,6938],{}," 降低数据库压力，同时保证库存数据可信。",[11,7014,7015],{},[33,7016,6944],{},[1500,7018,7019,7022,7025,7028],{},[1503,7020,7021],{},"前台库存展示使用缓存；",[1503,7023,7024],{},"定期以 Oracle 数据为准进行对账；",[1503,7026,7027],{},"对异常差额进行人工或自动修复；",[1503,7029,7030],{},"监控行锁等待、扣减耗时、失败率。",[11,7032,7033,7035,7036,39],{},[33,7034,6966],{},"\n缓存只承担展示和削峰，",[33,7037,7038],{},"不建议一开始就承担强一致库存扣减主逻辑",[1393,7040],{},[25,7042,7044],{"id":7043},"十一是否需要上-redis-分桶","十一、是否需要上 Redis 分桶？",[11,7046,7047],{},"如果业务确实有明显的瞬时洪峰，例如大促、秒杀，可以考虑在数据库分桶之上，再增加 Redis 分桶。",[11,7049,7050],{},"推荐做法：",[126,7052,7055],{"className":7053,"code":7054,"language":1894},[1892],"请求 → Redis 分桶预扣 → MQ → Oracle 分桶更新\n",[36,7056,7054],{"__ignoreMap":131},[11,7058,7059],{},"其中：",[1638,7061,7062,7065,7068,7071],{},[1503,7063,7064],{},"Redis 分桶：挡住入口洪峰；",[1503,7066,7067],{},"Oracle 分桶：避免数据库行锁；",[1503,7069,7070],{},"MQ 消息中携带 Redis 选中的 bucket_no；",[1503,7072,7073],{},"Oracle 直接更新对应桶。",[11,7075,7076],{},"但这一步改造明显更大，适合作为第二阶段优化，而不是存量系统的首选起点。",[1393,7078],{},[25,7080,7082],{"id":7081},"十二风险评估","十二、风险评估",[11,7084,6593],{},[41,7086,7087,7105],{},[44,7088,7089],{},[47,7090,7091,7093,7096,7099,7102],{},[50,7092,1541],{},[50,7094,7095],{},"改造成本",[50,7097,7098],{},"一致性",[50,7100,7101],{},"风险",[50,7103,7104],{},"适合场景",[60,7106,7107,7124,7140,7157],{},[47,7108,7109,7112,7115,7118,7121],{},[65,7110,7111],{},"乐观锁 Version",[65,7113,7114],{},"低",[65,7116,7117],{},"强一致",[65,7119,7120],{},"热点商品冲突明显",[65,7122,7123],{},"普通商品",[47,7125,7126,7129,7132,7134,7137],{},[65,7127,7128],{},"应用队列",[65,7130,7131],{},"中高",[65,7133,7117],{},[65,7135,7136],{},"多技术栈适配复杂",[65,7138,7139],{},"流量可控系统",[47,7141,7142,7145,7148,7151,7154],{},[65,7143,7144],{},"Redis + MQ",[65,7146,7147],{},"高",[65,7149,7150],{},"最终一致",[65,7152,7153],{},"链路复杂，一致性问题多",[65,7155,7156],{},"大促、秒杀",[47,7158,7159,7162,7165,7167,7170],{},[65,7160,7161],{},"Oracle 分桶",[65,7163,7164],{},"中",[65,7166,7117],{},[65,7168,7169],{},"结构调整，灰度得当风险可控",[65,7171,7172],{},"存量系统首选",[1393,7174],{},[25,7176,7178],{"id":7177},"十三落地建议","十三、落地建议",[11,7180,7181],{},"对于古老、多技术栈混合、主库为 Oracle 的存量系统，我的建议是：",[2595,7183,7184],{},[11,7185,7186],{},[33,7187,7188],{},"不要一上来就追求最先进架构，先从数据库分桶开始，把热点行锁问题控制住，再考虑是否引入 Redis。",[11,7190,7191],{},"更稳妥的改造顺序是：",[1500,7193,7194,7197,7200,7203,7206],{},[1503,7195,7196],{},"统一库存入口；",[1503,7198,7199],{},"热点 SKU 分桶；",[1503,7201,7202],{},"增加库存流水；",[1503,7204,7205],{},"补充缓存与对账；",[1503,7207,7208],{},"确有必要时，再叠加 Redis 分桶与 MQ。",[11,7210,7211],{},"这样既能解决当前超时问题，也不至于把老系统一次性拖入复杂的异步架构。",[1393,7213],{},[25,7215,7217],{"id":7216},"十四小结","十四、小结",[11,7219,7220,7221,39],{},"库存高并发扣减超时，表面上是接口慢，本质上常常是",[33,7222,7223],{},"同一个 SKU 热点行被大量事务争抢",[11,7225,7226],{},"对于存量 Oracle 系统，最值得先尝试的方案不是立刻引入 Redis 和 MQ，而是：",[1638,7228,7229,7232,7235],{},[1503,7230,7231],{},"先把单行库存拆成多个桶；",[1503,7233,7234],{},"让并发请求分散到不同行；",[1503,7236,7237],{},"再通过统一库存服务、缓存和对账逐步优化。",[11,7239,7240],{},"这是我目前认为比较适合这类系统的改造思路，不一定最炫，但通常更稳。",{"title":131,"searchDepth":145,"depth":145,"links":7242},[7243,7244,7249,7252,7258,7259,7264,7265,7266,7267,7272,7273,7274,7275],{"id":6357,"depth":145,"text":6358},{"id":6401,"depth":145,"text":6402,"children":7245},[7246,7247,7248],{"id":6405,"depth":160,"text":6406},{"id":6461,"depth":160,"text":6462},{"id":6511,"depth":160,"text":6512},{"id":6580,"depth":145,"text":6581,"children":7250},[7251],{"id":6584,"depth":160,"text":6584},{"id":6696,"depth":145,"text":6697,"children":7253},[7254,7255,7256,7257],{"id":6700,"depth":160,"text":6701},{"id":6715,"depth":160,"text":6716},{"id":6722,"depth":160,"text":6723},{"id":6737,"depth":160,"text":6738},{"id":6746,"depth":145,"text":6747},{"id":6775,"depth":145,"text":6776,"children":7260},[7261,7262,7263],{"id":6779,"depth":160,"text":6780},{"id":6800,"depth":160,"text":6801},{"id":6824,"depth":160,"text":6825},{"id":6852,"depth":145,"text":6853},{"id":6875,"depth":145,"text":6876},{"id":6907,"depth":145,"text":6908},{"id":6928,"depth":145,"text":6929,"children":7268},[7269,7270,7271],{"id":6932,"depth":160,"text":6933},{"id":6972,"depth":160,"text":6973},{"id":7006,"depth":160,"text":7007},{"id":7043,"depth":145,"text":7044},{"id":7081,"depth":145,"text":7082},{"id":7177,"depth":145,"text":7178},{"id":7216,"depth":145,"text":7217},"\u002Fimg\u002Fstock-no-redis.webp","2026-07-20","适用场景：多技术栈混合系统、部分热点商品在短时间内出现大量扣减请求，导致行锁等待、扣减超时。 设计原则：先止血、再优化；先数据库内解决问题，再谨慎引入缓存与消息队列。",{},"\u002Farticles\u002Fredis",{"title":6349,"description":7278},{"loc":7280},"articles\u002F库存扣减超时，别急着上Redis","gHOu9LxtOKKXUHyEzw24BaOSlVo7X0HKZQ6N9J8KMD4",{"id":7286,"title":7287,"body":7288,"category":1418,"cover":7956,"date":7957,"description":7958,"extension":1421,"meta":7959,"navigation":214,"path":7960,"seo":7961,"sitemap":7962,"stem":7963,"__hash__":7964},"articles\u002Farticles\u002F3万条会员被分配了两次-同步延迟背锅案的平反.md","3 万条会员被分配了两次：一次\"同步延迟\"背锅案的平反",{"type":8,"value":7289,"toc":7941},[7290,7294,7297,7323,7326,7332,7335,7339,7342,7353,7360,7363,7369,7373,7376,7393,7399,7405,7411,7414,7418,7421,7511,7515,7522,7525,7538,7544,7547,7554,7558,7578,7581,7592,7607,7613,7617,7620,7623,7641,7648,7652,7655,7662,7666,7669,7672,7676,7683,7686,7692,7726,7733,7747,7753,7763,7766,7770,7773,7860,7863,7884,7888,7891,7896,7907,7914,7917,7920,7928,7930,7935,7938],[25,7291,7293],{"id":7292},"一事情是这样的","一、事情是这样的",[11,7295,7296],{},"前阵子一个朋友找我聊问题。他们公司做 CRM 系统，技术栈大概是：",[1638,7298,7299,7305,7311,7317],{},[1503,7300,7301,7304],{},[33,7302,7303],{},"MySQL 8","：业务主库，订单、会员、分配关系等源数据都在这",[1503,7306,7307,7310],{},[33,7308,7309],{},"ClickHouse","：会员数据的复杂组合条件查询引擎",[1503,7312,7313,7316],{},[33,7314,7315],{},"Redis 7","：缓存",[1503,7318,7319,7322],{},[33,7320,7321],{},"阿里 DTS","：MySQL 增量同步到 ClickHouse，频率大概 2000 条\u002F秒",[11,7324,7325],{},"为什么上 ClickHouse？因为 CRM 的会员圈选查询条件非常复杂——会员属性、订单行为、话务记录、访问明细多表组合，MySQL 扛不住这种任意组合的筛选，所以引入了 CK 做查询加速。",[11,7327,7328,7329,39],{},"架构看起来很标准：",[33,7330,7331],{},"写在 MySQL，读在 ClickHouse，中间一条同步链路",[11,7333,7334],{},"然后出事了。",[25,7336,7338],{"id":7337},"二事故现象任务包里的会员重复了","二、事故现象：任务包里的会员重复了",[11,7340,7341],{},"他们的业务流程大概是：",[1500,7343,7344,7347,7350],{},[1503,7345,7346],{},"操作员用组合条件在 CK 里圈选会员，一捞就是十几万",[1503,7348,7349],{},"从里面挑 3~5 万，分配到不同的任务包（公海 → 租户 → 员工）",[1503,7351,7352],{},"分配动作全部在 MySQL 上做：更新分配关系表、记录日志、写任务包",[11,7354,7355,7356,7359],{},"最近发现：",[33,7357,7358],{},"部分任务包里出现了重复的会员","。同一个会员，被分进了不止一个任务包。",[11,7361,7362],{},"朋友的初步排查结论是：DTS 同步有延迟，MySQL 里已经分配掉的会员，CK 里还是\"公海\"状态，下一轮圈选又把它捞出来了。",[11,7364,7365,7366],{},"这个判断对不对？",[33,7367,7368],{},"对了一半。",[25,7370,7372],{"id":7371},"三根因分析同步延迟只是帮凶不是主犯","三、根因分析：同步延迟只是帮凶，不是主犯",[11,7374,7375],{},"我跟朋友说，如果只盯着同步延迟修，大概率修不干净。重复数据的产生，需要三个条件同时成立：",[11,7377,7378,7381,7382,7385,7386,7388,7389,7392],{},[33,7379,7380],{},"条件 1：读写分离带来的时间差。"," 分配发生在 MySQL，圈选发生在 CK。DTS 2000 条\u002F秒，而且一次分配涉及多张表变更，延迟是必然的——秒级甚至分钟级。CK 里用了 ",[36,7383,7384],{},"FINAL"," 查询也没用，",[36,7387,7384],{}," 解决的是 ReplacingMergeTree 合并前的多行问题，",[33,7390,7391],{},"解决不了\"数据还没同步过来\"的问题","。这两件事经常被混为一谈。",[11,7394,7395,7398],{},[33,7396,7397],{},"条件 2：分配动作没有防重保护。"," 流程是\"CK 查出列表 → 直接批量 UPDATE\"。没有任何一行代码去校验\"这个会员现在到底是不是还能分\"。只要 CK 给了，就照单全收。",[11,7400,7401,7404],{},[33,7402,7403],{},"条件 3：并发操作没有互斥。"," 操作员经常是\"先捞十几万，再慢慢分\"。两个人圈选条件有重叠时，就算同步零延迟，抢同一批数据也会出重复。",[11,7406,7407,7408],{},"所以结论很明确：",[33,7409,7410],{},"就算把 DTS 优化到毫秒级，只要写入不做条件校验、并发不做控制，重复照样发生。",[11,7412,7413],{},"修复的重心，不应该放在同步链路上。",[25,7415,7417],{"id":7416},"四方案讨论先别急着动手把候选方案摆上桌","四、方案讨论：先别急着动手，把候选方案摆上桌",[11,7419,7420],{},"我们列了五个候选方案，逐个过：",[41,7422,7423,7438],{},[44,7424,7425],{},[47,7426,7427,7429,7432,7435],{},[50,7428,1541],{},[50,7430,7431],{},"思路",[50,7433,7434],{},"能否根治重复",[50,7436,7437],{},"成本",[60,7439,7440,7456,7472,7485,7498],{},[47,7441,7442,7445,7451,7454],{},[65,7443,7444],{},"① CK 查询加时间偏移过滤",[65,7446,7447,7450],{},[36,7448,7449],{},"update_time \u003C NOW() - 5min","，制造\"安全窗口\"",[65,7452,7453],{},"❌ 逻辑不成立",[65,7455,7114],{},[47,7457,7458,7461,7467,7470],{},[65,7459,7460],{},"② MySQL 条件写（CAS）",[65,7462,7463,7466],{},[36,7464,7465],{},"UPDATE ... WHERE status='未分配'","，校验 affected_rows",[65,7468,7469],{},"✅ 根治",[65,7471,7114],{},[47,7473,7474,7477,7480,7483],{},[65,7475,7476],{},"③ 预分配 + 黑名单",[65,7478,7479],{},"MySQL 锁定状态 + Redis 过滤\"锁定中\"ID",[65,7481,7482],{},"✅ 辅助防重",[65,7484,7164],{},[47,7486,7487,7490,7493,7496],{},[65,7488,7489],{},"④ 优化同步链路",[65,7491,7492],{},"换 Canal\u002FFlink CDC，压低延迟",[65,7494,7495],{},"❌ 治标",[65,7497,7147],{},[47,7499,7500,7503,7506,7509],{},[65,7501,7502],{},"⑤ 异步任务队列",[65,7504,7505],{},"分配变成任务单，Worker 查 MySQL 主库执行",[65,7507,7508],{},"✅ 长期方向",[65,7510,7147],{},[371,7512,7514],{"id":7513},"方案一个漂亮的错误","方案①：一个漂亮的错误",[11,7516,7517,7518,7521],{},"这是朋友从某个 AI 助手那里拿到的\"最快见效\"方案：查 CK 时加上 ",[36,7519,7520],{},"update_time \u003C NOW() - INTERVAL 5 MINUTE","，把最近 5 分钟变更过的数据排除掉。",[11,7523,7524],{},"乍一听很合理，仔细一想是错的：",[2595,7526,7527],{},[11,7528,7529,7530,7533,7534,7537],{},"会员 X 十分钟前在 MySQL 被分配了，但这次变更因为 DTS 积压",[33,7531,7532],{},"还没同步到 CK","。CK 里 X 的 ",[36,7535,7536],{},"update_time"," 还是上次变更的旧时间——可能是半年前。旧时间通过了\"5 分钟前\"的过滤，X 照样被查出来。",[11,7539,7540,7543],{},[33,7541,7542],{},"这个过滤器过滤掉的，是已经正确同步的数据；漏掉的，恰恰是有问题的数据。"," 方向完全反了。",[11,7545,7546],{},"唯一成立的变体是用\"同步入库时间\"过滤（CK 表里加一个 ETL 打点的 sync_time 字段），但那要改同步链路，就不是\"纯查询修改\"了。",[11,7548,7549,7550,7553],{},"这件事给我一个提醒：",[33,7551,7552],{},"AI 给的方案，每一条都要在脑子里跑一遍真实数据流，才能判断它是不是真的成立。"," 看起来逻辑通顺，不代表经得起推敲。",[371,7555,7557],{"id":7556},"方案真正的主角mysql-条件写","方案②：真正的主角，MySQL 条件写",[126,7559,7561],{"className":279,"code":7560,"language":281,"meta":131,"style":131},"UPDATE 会员分配关系表\nSET 租户ID = ?, 状态 = '已分配', 更新时间 = NOW()\nWHERE member_id IN (...) AND 状态 = '未分配';\n",[36,7562,7563,7568,7573],{"__ignoreMap":131},[135,7564,7565],{"class":137,"line":138},[135,7566,7567],{},"UPDATE 会员分配关系表\n",[135,7569,7570],{"class":137,"line":145},[135,7571,7572],{},"SET 租户ID = ?, 状态 = '已分配', 更新时间 = NOW()\n",[135,7574,7575],{"class":137,"line":160},[135,7576,7577],{},"WHERE member_id IN (...) AND 状态 = '未分配';\n",[11,7579,7580],{},"不管 CK 查出来的列表有多\"脏\"，写入 MySQL 时带上\"仍是未分配\"这个条件，重复就物理上不可能发生——第二个分配请求的 affected_rows 会是 0。",[11,7582,7583,7584,7587,7588,7591],{},"这里有个",[33,7585,7586],{},"他们业务特有的坑","：他们的分配关系表是\"有记录才算归属，没记录就是公海\"。也就是说公海会员在表里根本没有行，分配动作实际是 ",[33,7589,7590],{},"INSERT 而不是 UPDATE","。那么防重就得靠：",[1638,7593,7594,7600],{},[1503,7595,7596,7599],{},[36,7597,7598],{},"member_id"," 唯一约束（数据库兜底，最硬）",[1503,7601,7602,7603,7606],{},"或 ",[36,7604,7605],{},"INSERT ... SELECT ... WHERE NOT EXISTS","（代码兜底）",[11,7608,7609,7610],{},"这个细节，无论是我还是那个 AI 助手，第一版方案里都漏掉了。",[33,7611,7612],{},"脱离具体表结构谈方案，都是纸上谈兵。",[371,7614,7616],{"id":7615},"方案黑名单是体验优化不是正确性保障","方案③：黑名单是体验优化，不是正确性保障",[11,7618,7619],{},"思路是：分配前先在 MySQL 里把状态改成\"锁定中\"，同时把 ID 写进 Redis；别人查 CK 之后，用 Redis 黑名单把\"锁定中\"的过滤掉，就不会看到别人正在抢的数据。",[11,7621,7622],{},"两个细节必须抠：",[1500,7624,7625,7635],{},[1503,7626,7627,7630,7631,7634],{},[33,7628,7629],{},"黑名单过滤要放在应用层做","，CK 查完回内存里比对，不要拼成 ",[36,7632,7633],{},"id NOT IN (几万个ID)"," 塞进 CK 的 SQL——SQL 文本爆炸，解析和执行都慢。",[1503,7636,7637,7640],{},[33,7638,7639],{},"Redis 锁要按 ID 独立设置、各自带 TTL","，别用一个大 Set 统一设过期时间，否则要么锁被意外续命，要么一批锁同时蒸发。",[11,7642,7643,7644,7647],{},"另外要想明白主次关系：",[33,7645,7646],{},"黑名单丢了、Redis 挂了，都不会产生重复数据","——因为方案②的条件写在兜底。黑名单只是减少\"看得见却分不了\"的空转体验。想清楚这一点，就不会在这个方案上过度设计。",[371,7649,7651],{"id":7650},"方案同步优化先量化再决定","方案④：同步优化，先量化再决定",[11,7653,7654],{},"DTS 到底延迟多少？峰值多少？朋友其实没有准确数字，只是\"感觉慢\"。",[11,7656,7657,7658,7661],{},"我的建议是：",[33,7659,7660],{},"先上延迟监控打点，拿到真实数字再决定要不要换链路","。如果延迟只有 3 秒，那方案②③组合就够了，换 Canal\u002FFlink CDC 的投入完全不值。监控是免费的，换链路是昂贵的。",[371,7663,7665],{"id":7664},"方案异步任务化留给未来","方案⑤：异步任务化，留给未来",[11,7667,7668],{},"多人高频大批量分配，终态应该是\"提交任务单 → 后台 Worker 查 MySQL 主库执行 → 任务中心看结果\"。Worker 直查主库，天然没有延迟问题。",[11,7670,7671],{},"但它要改前端交互、引入任务中心，属于架构级改造，不适合在救火期做。",[25,7673,7675],{"id":7674},"五加状态字段的风险别只盯着分配这一条链路","五、加状态字段的风险：别只盯着分配这一条链路",[11,7677,7678,7679,7682],{},"方案③要引入\"锁定中\"这个新状态，朋友当时最担心的就是这个。担心得对——",[33,7680,7681],{},"会员分配关系表是他们系统里变化最频繁的表","，公海转租户、内部归属变更、回收、延迟、黑名单，全都要读写这张表。",[11,7684,7685],{},"加一个状态值，影响面必须一条条过：",[11,7687,7688,7691],{},[33,7689,7690],{},"1. 多次更新带来的中间态暴露。"," \"未分配 → 锁定中 → 已分配\"从一次写变成两次写。第二次写之前进程崩了怎么办？必须配套超时释放的定时任务。这里有两个设计要点：",[1638,7693,7694,7712],{},[1503,7695,7696,7699,7700,7703,7704,7707,7708,7711],{},[33,7697,7698],{},"时间字段建议存\"过期时刻\"而不是\"加锁时刻\"","。与其存 ",[36,7701,7702],{},"lock_time"," 再算 ",[36,7705,7706],{},"lock_time \u003C NOW() - 10min","，不如加锁时直接写入 ",[36,7709,7710],{},"lock_expire_time = NOW() + TTL","。好处有两个：不同批量大小可以给不同 TTL；长任务可以通过续期（更新 expire_time）保活，避免\"任务还在跑，锁被端了\"。",[1503,7713,7714,7717,7718,7721,7722,7725],{},[33,7715,7716],{},"防误伤靠的不是时间字段，而是条件写的原子性","。超时释放和分配完成是两条争抢同一行的 UPDATE，MySQL 行锁会串行化它们，先执行者生效：分配先完成，释放 SQL 的 ",[36,7719,7720],{},"WHERE status='锁定中'"," 匹配 0 行，误伤不了；释放先执行，分配 SQL 匹配 0 行，失败被暴露。所以两条 SQL 都必须带 ",[36,7723,7724],{},"status='锁定中'"," 条件，谁输谁 affected_rows=0，谁也别想悄悄改赢。",[11,7727,7728,7729,7732],{},"还有一个更隐蔽的边界要防：",[33,7730,7731],{},"静默丢失","。任务跑得太慢被定时任务回收，数据被别人合法分走，姗姗来迟的\"完成\"SQL 匹配 0 行——这时如果代码不看 affected_rows，前端还提示\"分配成功\"，数据就对不上了。所以完成侧必须校验 affected_rows，少于预期就反查哪些会员被回收了，明确告知操作员，绝不能退化成按 ID 强更新。",[11,7734,7735,7738,7739,7742,7743,7746],{},[33,7736,7737],{},"2. 存量查询条件的排查成本。"," 所有查这张表的地方，凡是写了 ",[36,7740,7741],{},"status = '未分配'"," 的，都要问一句：",[33,7744,7745],{},"\"锁定中\"的数据该不该被查出来？"," 回收流程查\"可回收\"列表、黑名单流程查\"在册\"会员、报表统计\"公海数量\"——每一处都是一个判断点。漏改一处，就是新的线上问题。",[11,7748,7749,7752],{},[33,7750,7751],{},"3. 对 DTS 同步的额外压力。"," 锁定操作本身也是一次写，会产生 binlog，DTS 要多同步一批数据。高频分配场景下，这等于给本来就有积压的同步链路雪上加霜。好在锁定状态只是过程态，CK 端查询最终依赖 MySQL 兜底，这个压力可以接受，但要心里有数。",[11,7754,7755,7758,7759,7762],{},[33,7756,7757],{},"4. 状态回滚的路径要想全。"," 分配成功：锁定中 → 已分配。分配失败：锁定中 → 未分配，且要清锁信息。操作员中途关浏览器：靠定时任务超时释放。",[33,7760,7761],{},"每一条失败路径都要有对应的状态出口","，否则数据会被永久冻结在\"锁定中\"，业务方会拿着刀来找你。",[11,7764,7765],{},"这就是现实的工程判断：加状态字段不是加一个枚举值的事，是把一颗石子扔进一张蛛网，每一根丝都会动。",[25,7767,7769],{"id":7768},"六改造成本与时间评估","六、改造成本与时间评估",[11,7771,7772],{},"和朋友最后敲定的落地计划，按投入产出排序：",[41,7774,7775,7791],{},[44,7776,7777],{},[47,7778,7779,7782,7785,7788],{},[50,7780,7781],{},"阶段",[50,7783,7784],{},"内容",[50,7786,7787],{},"预估工时",[50,7789,7790],{},"上线时间",[60,7792,7793,7807,7821,7833,7846],{},[47,7794,7795,7798,7801,7804],{},[65,7796,7797],{},"🔴 紧急止血",[65,7799,7800],{},"分配写入改为条件写 + affected_rows 校验 + 确认 INSERT 防重（唯一约束）",[65,7802,7803],{},"1~2 人日",[65,7805,7806],{},"本周内",[47,7808,7809,7812,7815,7818],{},[65,7810,7811],{},"🟡 体验加固",[65,7813,7814],{},"Redis 锁定标记（应用层过滤）+ 超时释放定时任务",[65,7816,7817],{},"3~4 人日",[65,7819,7820],{},"下周",[47,7822,7823,7826,7829,7831],{},[65,7824,7825],{},"🟡 安全网",[65,7827,7828],{},"对账任务：扫描跨任务包重复会员，出报表\u002F修复存量脏数据（可选）",[65,7830,7803],{},[65,7832,7820],{},[47,7834,7835,7838,7841,7844],{},[65,7836,7837],{},"🟢 观测",[65,7839,7840],{},"DTS 同步延迟监控打点 + 告警",[65,7842,7843],{},"1 人日",[65,7845,7820],{},[47,7847,7848,7851,7854,7857],{},[65,7849,7850],{},"⚪ 排期",[65,7852,7853],{},"分配任务异步化（任务单 + Worker）",[65,7855,7856],{},"2~3 周",[65,7858,7859],{},"下个迭代评估",[11,7861,7862],{},"几个关键判断：",[1638,7864,7865,7871,7878],{},[1503,7866,7867,7870],{},[33,7868,7869],{},"止血方案只改分配服务层，不动表结构、不动同步链路、不动 CK","，所以可以当天评审、隔天上线。救火期最重要的就是改动面小。",[1503,7872,7873,7874,7877],{},"对账任务看起来是\"锦上添花\"，其实是",[33,7875,7876],{},"必做项","——存量已经产生的重复数据不会自己消失，而且它还能兜住未来任何漏网的新场景。",[1503,7879,7880,7881],{},"异步化先不做。等业务量真的涨到并发冲突成为日常，再启动也不迟。",[33,7882,7883],{},"过早的架构升级，本身就是一种风险。",[25,7885,7887],{"id":7886},"七写在最后","七、写在最后",[11,7889,7890],{},"这个问题前前后后聊了两天，最大的感受是：",[11,7892,7893],{},[33,7894,7895],{},"这类一致性问题的答案，从来不是某个精妙的技术方案，而是一连串清醒的取舍。",[1638,7897,7898,7901,7904],{},[1503,7899,7900],{},"同步延迟要不要治？先量化，别凭感觉。",[1503,7902,7903],{},"新状态字段要不要加？先把所有读写这张表的流程列一遍。",[1503,7905,7906],{},"架构要不要升级？先把止血做完，让子弹飞一会儿。",[11,7908,7909,7910,7913],{},"写 MySQL 读 CK 的架构没有错，错的是默认\"查出来的就是最新的\"。",[33,7911,7912],{},"任何跨存储的读写分离，都要先回答一个问题：当两边不一致时，谁说了算？"," 在他们的场景里，答案很清晰——分配状态，永远以 MySQL 为准；CK 只是一个筛选引擎。",[11,7915,7916],{},"把这个原则写进团队规范，比修十个 Bug 都值钱。",[11,7918,7919],{},"最后用一句话收尾，也是我跟朋友说的原话：",[2595,7921,7922],{},[11,7923,7924,7927],{},[33,7925,7926],{},"没有永远完美的方案，只有最适合当前的取舍。"," 工程的美学不在\"最优解\"里，而在\"清楚地知道自己放弃了什么\"里。",[1393,7929],{},[11,7931,7932],{},[6295,7933,7934],{},"如果你也遇到过读写分离带来的一致性问题，欢迎留言聊聊你们最后选了哪条路。有更好建议，也希望留言给我，感谢",[11,7936,7937],{},"标签：#Java #ClickHouse #DTS #读写分离 #脏数据",[1399,7939,7940],{},"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);}",{"title":131,"searchDepth":145,"depth":145,"links":7942},[7943,7944,7945,7946,7953,7954,7955],{"id":7292,"depth":145,"text":7293},{"id":7337,"depth":145,"text":7338},{"id":7371,"depth":145,"text":7372},{"id":7416,"depth":145,"text":7417,"children":7947},[7948,7949,7950,7951,7952],{"id":7513,"depth":160,"text":7514},{"id":7556,"depth":160,"text":7557},{"id":7615,"depth":160,"text":7616},{"id":7650,"depth":160,"text":7651},{"id":7664,"depth":160,"text":7665},{"id":7674,"depth":145,"text":7675},{"id":7768,"depth":145,"text":7769},{"id":7886,"depth":145,"text":7887},"\u002Fimg\u002Fmysql-to-clickhouse.png","2026-05-20","写 MySQL、查 ClickHouse，DTS同步延迟只是帮凶。一次生产问题的原因分析和思考。没有永远完美的方案，只有最适合当前的取舍。",{},"\u002Farticles\u002F3",{"title":7287,"description":7958},{"loc":7960},"articles\u002F3万条会员被分配了两次-同步延迟背锅案的平反","JNvTiST5nn1fsZBXKogIquhDQCC4SlYGv36xDm2yRy0",{"id":7966,"title":7967,"body":7968,"category":1418,"cover":8266,"date":8267,"description":8268,"extension":1421,"meta":8269,"navigation":214,"path":8270,"seo":8271,"sitemap":8272,"stem":8273,"__hash__":8274},"articles\u002Farticles\u002F公司一纸安全令，我把十年老电脑「硬刚」升级成了 Win11.md","公司一纸安全令，我把十年老电脑「硬刚」升级成了 Win11！",{"type":8,"value":7969,"toc":8256},[7970,7974,7977,7984,7987,7995,7998,8000,8004,8007,8014,8017,8036,8041,8044,8046,8050,8057,8062,8066,8071,8078,8083,8090,8095,8102,8118,8123,8130,8132,8136,8142,8145,8171,8177,8179,8183,8215,8217,8221,8228,8234,8241,8243,8248,8253],[25,7971,7973],{"id":7972},"一事情是这样的安全令下只给三天","🔥 一、事情是这样的：安全令下，只给三天",[11,7975,7976],{},"上周一早上，IT 部门在群里甩下一句话：",[2595,7978,7979],{},[11,7980,7981],{},[33,7982,7983],{},"「为满足公司信息安全合规要求，所有办公电脑须在本周内升级至 Windows 11，逾期未升级的设备将被禁止接入内网。」",[11,7985,7986],{},"一句话，杀伤力拉满。翻译过来就是：",[1638,7988,7989,7992],{},[1503,7990,7991],{},"✅ 升级 → 正常上班、连内网、收邮件",[1503,7993,7994],{},"❌ 不升级 → 断网、停摆、被点名",[11,7996,7997],{},"对打工人来说，这已经不是「要不要升」的选择题，而是「几点前必须升完」的倒计时题。⏰",[1393,7999],{},[25,8001,8003],{"id":8002},"二拦路虎主板太老不支持芯片加密","🚧 二、拦路虎：主板太老，不支持「芯片加密」",[11,8005,8006],{},"信心满满地点开升级助手，结果当头一棒：",[2595,8008,8009],{},[11,8010,8011],{},[33,8012,8013],{},"「这台电脑当前不满足 Windows 11 的系统要求。」",[11,8015,8016],{},"原因也很典型 —— 我这台服役多年的老机器：",[1638,8018,8019,8026,8033],{},[1503,8020,8021,8022,8025],{},"🧓 ",[33,8023,8024],{},"主板年代久远","，BIOS 里根本找不到 TPM（可信平台模块 \u002F 芯片加密）选项；",[1503,8027,8028,8029,8032],{},"🔒 ",[33,8030,8031],{},"不支持 TPM 2.0 与安全启动","，而这正是 Win11 的硬性门槛；",[1503,8034,8035],{},"💸 换主板、换整机？公司不报销，自己掏钱不值当。",[11,8037,1681,8038],{},[33,8039,8040],{},"微软的门槛，卡的就是我这种「老爷机」。",[11,8042,8043],{},"那这次升级，是不是就死路一条了？—— 当然不是。👇",[1393,8045],{},[25,8047,8049],{"id":8048},"️-三破局一条命令强制绕过硬件检查方法一","🛠️ 三、破局：一条命令，强制绕过硬件检查（方法一）",[11,8051,8052,8053,8056],{},"网上流传的方案不少，改注册表的、做 U 盘的……但",[33,8054,8055],{},"最简单、能保留原有文件和软件","的，是下面这条命令绕过法。",[2595,8058,8059],{},[11,8060,8061],{},"💡 亲测有效，全程约 20 分钟，文件、软件、桌面全部保留，无需重装。",[371,8063,8065],{"id":8064},"操作三步走","📌 操作三步走",[11,8067,8068],{},[33,8069,8070],{},"第 1 步：下载镜像",[11,8072,8073,8074,8077],{},"从微软官网下载与当前系统语言一致的 ",[33,8075,8076],{},"Windows 11 ISO 镜像","（注意语言要对应，中文系统就下中文版）。",[11,8079,8080],{},[33,8081,8082],{},"第 2 步：挂载并运行",[11,8084,8085,8086,8089],{},"右键点击下载好的 ISO 文件，选择 ",[33,8087,8088],{},"「装载」","，系统会把它虚拟成一个光驱盘。",[11,8091,8092],{},[33,8093,8094],{},"第 3 步：执行命令（关键！）",[11,8096,8097,8098,8101],{},"以",[33,8099,8100],{},"管理员身份","打开命令提示符（CMD），进入刚才装载的盘符，输入以下命令并回车：",[126,8103,8105],{"className":128,"code":8104,"language":130,"meta":131,"style":131},"setup.exe \u002Fproduct server\n",[36,8106,8107],{"__ignoreMap":131},[135,8108,8109,8112,8115],{"class":137,"line":138},[135,8110,8111],{"class":148},"setup.exe",[135,8113,8114],{"class":156}," \u002Fproduct",[135,8116,8117],{"class":156}," server\n",[2595,8119,8120],{},[11,8121,8122],{},"🖼️ 建议在此处配图：CMD 窗口输入命令的截图（对应你参考图「方法一」的样式）",[11,8124,8125,8126,8129],{},"之后跟着安装向导一路「下一步」，选择 ",[33,8127,8128],{},"「保留个人文件和应用」","，等进度条跑完，重启，Win11 就装好了。🎉",[1393,8131],{},[25,8133,8135],{"id":8134},"四原理为什么这条命令能骗过检查","🧠 四、原理：为什么这条命令能「骗过」检查？",[11,8137,8138,8139],{},"很多人担心：绕过检查会不会把系统搞坏？先说结论：",[33,8140,8141],{},"不会。",[11,8143,8144],{},"原理很有意思：",[1638,8146,8147,8154,8165],{},[1503,8148,8149,8150,8153],{},"正常升级时，安装程序会严格检查 ",[33,8151,8152],{},"TPM、安全启动、内存"," 等条件；",[1503,8155,8156,8157,8160,8161,8164],{},"而加上 ",[36,8158,8159],{},"\u002Fproduct server"," 参数后，安装程序会",[33,8162,8163],{},"误以为你在安装「服务器版系统」","；",[1503,8166,8167,8168,39],{},"服务器版的硬件检查策略更宽松，于是就",[33,8169,8170],{},"直接跳过了 TPM 2.0、安全启动和内存检查",[11,8172,8173,8174],{},"一句话：",[33,8175,8176],{},"你装的还是标准 Win11，只是让它「以为」自己在装服务器版，从而放行。",[1393,8178],{},[25,8180,8182],{"id":8181},"️-五几点必须提醒很重要","⚠️ 五、几点必须提醒（很重要）",[1500,8184,8185,8191,8197,8203,8209],{},[1503,8186,8187,8190],{},[33,8188,8189],{},"先备份重要文件","：虽然此法保留数据，但任何系统升级前，重要资料都建议先备份，稳妥第一。",[1503,8192,8193,8196],{},[33,8194,8195],{},"镜像认准官网","：只从微软官方渠道下载 ISO，第三方「魔改版」有安全风险，公司合规更不允许。",[1503,8198,8199,8202],{},[33,8200,8201],{},"语言要一致","：镜像语言与当前系统不一致，可能导致升级失败。",[1503,8204,8205,8208],{},[33,8206,8207],{},"老硬件量力而行","：绕过的是「检查」，不是「性能」。太老的机器升级后可能偏卡，建议至少 8G 内存 + 固态硬盘。",[1503,8210,8211,8214],{},[33,8212,8213],{},"企业环境先问 IT","：如果单位有统一的升级策略或域管控，动手前和 IT 确认一句，避免踩坑。",[1393,8216],{},[25,8218,8220],{"id":8219},"六写在最后","✅ 六、写在最后",[11,8222,8223,8224,8227],{},"一条 ",[36,8225,8226],{},"setup.exe \u002Fproduct server","，让我这台被微软「判了死刑」的老电脑，成功迈进了 Win11 时代，也顺利通过了公司的安全合规检查。✌️",[11,8229,8230,8231],{},"其实很多所谓的「硬件不支持」，并不是真的不能用，只是被一道门槛挡在了外面。",[33,8232,8233],{},"方法用对，老设备也能再战几年。",[11,8235,8236,8237,8240],{},"如果这篇文章帮你顺利过了「安全令」这一关，欢迎 ",[33,8238,8239],{},"点赞 + 在看 + 转发"," 给同样被通知刷屏的同事，说不定就帮 TA 省下了一台新电脑的钱。💰",[1393,8242],{},[2595,8244,8245],{},[11,8246,8247],{},"📮 你所在的公司也下过类似的「升级令」吗？你的老电脑成功升级了吗？评论区聊聊 👇",[11,8249,8250],{},[33,8251,8252],{},"#Windows11 #Win11升级 #老电脑升级Win11 #TPM绕过 #电脑安全合规 #IT运维 #办公技巧 #装机教程 #数码干货",[1399,8254,8255],{},"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 .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);}",{"title":131,"searchDepth":145,"depth":145,"links":8257},[8258,8259,8260,8263,8264,8265],{"id":7972,"depth":145,"text":7973},{"id":8002,"depth":145,"text":8003},{"id":8048,"depth":145,"text":8049,"children":8261},[8262],{"id":8064,"depth":160,"text":8065},{"id":8134,"depth":145,"text":8135},{"id":8181,"depth":145,"text":8182},{"id":8219,"depth":145,"text":8220},"\u002Fimg\u002Fwin10-update-win11.webp","2025-03-20","绕过 TPM 实现实现win11升级，支持补丁更新,",{},"\u002Farticles\u002Fwin11",{"title":7967,"description":8268},{"loc":8270},"articles\u002F公司一纸安全令，我把十年老电脑「硬刚」升级成了 Win11","pDgeD1vVB2qESokQU34FlqueS1t4MCJmmqRAT6kiBro",1793263558067]