From 94f9db0834fc341072e24995d57820e0e8d2b75a Mon Sep 17 00:00:00 2001 From: sanbuphy Date: Tue, 24 Feb 2026 00:18:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E9=99=84=E5=BD=95?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=BB=84=E4=BB=B6=E5=92=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/config.mjs | 1 - .../theme/components/HomeFeatures.vue | 12 +- .../ai-history/AIErasComparisonDemo.vue | 4 +- .../appendix/ai-history/AiEvolutionDemo.vue | 2 +- .../ai-history/AttentionMechanismDemo.vue | 2 +- .../ai-history/BackpropagationDemo.vue | 2 +- .../DiscriminativeVsGenerativeDemo.vue | 2 +- .../appendix/ai-history/GPTEvolutionDemo.vue | 2 +- .../NeuralNetworkVisualizationDemo.vue | 2 +- .../appendix/ai-history/PerceptronDemo.vue | 4 +- .../ai-history/RuleBasedVsLearningDemo.vue | 2 +- .../appendix/api-design/ApiRequestDemo.vue | 4 +- .../api-design/DataFieldDesignDemo.vue | 23 +- .../api-design/ErrorResponseDesignDemo.vue | 10 +- .../api-design/ResponseStructureDemo.vue | 13 +- .../appendix/api-design/RestfulApiFlow.vue | 8 +- .../appendix/api-intro/ApiFunctionVsHttp.vue | 744 ++++++ .../api-intro/DocumentTypesComparison.vue | 652 +++++ .../computer-fundamentals/AdderChainDemo.vue | 4 +- .../computer-fundamentals/AdderDemo.vue | 30 +- .../computer-fundamentals/AlgorithmDemo.vue | 7 +- .../BinaryAdditionRulesDemo.vue | 322 +++ .../computer-fundamentals/CompilerDemo.vue | 10 +- .../CompleteAdderDemo.vue | 20 +- .../DataLifecycleDemo.vue | 2 +- .../DataLinkLayerDemo.vue | 4 +- .../DataStructureDemo.vue | 10 +- .../DataStructureSelectorDemo.vue | 4 +- .../computer-fundamentals/EncodingDemo.vue | 6 +- .../EncodingStorageTransmissionDemo.vue | 2 +- .../computer-fundamentals/FilesystemDemo.vue | 609 +++-- .../computer-fundamentals/FlipFlopDemo.vue | 479 ++++ .../computer-fundamentals/FullAdderDemo.vue | 693 ++--- .../FunctionalUnitDemo.vue | 6 +- .../GreedyThinkingDemo.vue | 2 +- .../computer-fundamentals/HalfAdderDemo.vue | 636 ++--- .../computer-fundamentals/HashTableDemo.vue | 2 +- .../computer-fundamentals/LanguageMapDemo.vue | 30 +- .../computer-fundamentals/MemoryDemo.vue | 582 ++-- .../computer-fundamentals/NetworkLayers.vue | 3 +- .../OSArchitectureDemo.vue | 317 +++ .../OSSystemOverviewDemo.vue | 313 --- .../computer-fundamentals/ProcessDemo.vue | 495 ++-- .../ProcessMemoryFilesystemDemo.vue | 446 ---- .../ProgramLaunchDemo.vue | 301 +++ .../RecursiveThinkingDemo.vue | 3 +- .../computer-fundamentals/RegisterDemo.vue | 3 +- .../SearchAlgorithmDemo.vue | 8 +- .../SortingAlgorithmDemo.vue | 6 +- .../computer-fundamentals/StorageDemo.vue | 3 +- .../SubnetCalculator.vue | 8 +- .../TcpUdpComparison.vue | 3 +- .../TransmissionDemo.vue | 9 +- .../TransportLayerDemo.vue | 4 +- .../computer-fundamentals/TypeSystemDemo.vue | 39 +- .../data-encoding/AudioEncodingDemo.vue | 2 +- .../data-encoding/ImageEncodingDemo.vue | 4 +- .../data-encoding/PhotoUploadJourneyDemo.vue | 936 +++++-- .../data-encoding/StoragePyramidDemo.vue | 2 +- .../appendix/data/ABTestingDemo.vue | 16 +- .../appendix/development-tools/RegexDemo.vue | 18 +- .../development-tools/SSHAuthDemo.vue | 42 +- .../framework-nature/DomOperationCostDemo.vue | 2 +- .../framework-nature/WhatIsDomDemo.vue | 2 +- .../framework-nature/WhyNoAutoSyncDemo.vue | 4 +- .../appendix/git-intro/GitCommitFlow.vue | 6 +- .../ports-localhost/DevServerFlowDemo.vue | 3 +- .../url-to-browser/BrowserRenderingDemo.vue | 1089 ++------ .../appendix/url-to-browser/DnsLookupDemo.vue | 701 +---- .../url-to-browser/HttpExchangeDemo.vue | 647 ++--- .../url-to-browser/TcpHandshakeDemo.vue | 878 +----- .../appendix/url-to-browser/UrlParserDemo.vue | 673 +---- .../url-to-browser/UrlToBrowserQuickStart.vue | 2 +- .../web-basics/BrowserRenderingDemo.vue | 79 +- .../appendix/web-basics/DnsLookupDemo.vue | 14 +- .../appendix/web-basics/HttpExchangeDemo.vue | 51 +- .../appendix/web-basics/TcpHandshakeDemo.vue | 24 +- .../appendix/web-basics/UrlParserDemo.vue | 22 +- docs/.vitepress/theme/index.js | 16 +- .../computer-networks.md | 218 +- .../operating-systems.md | 92 +- .../transistor-to-cpu.md | 25 +- .../4-server-and-backend/api-intro.md | 12 + docs/zh-cn/appendix/5-data/ab-testing.md | 2238 +++++++++++++++- docs/zh-cn/appendix/5-data/data-analysis.md | 1695 +++++++++++- docs/zh-cn/appendix/5-data/data-models.md | 2350 ++++++++++++++++- docs/zh-cn/appendix/5-data/sql.md | 652 ----- eslint.config.js | 6 +- 88 files changed, 11797 insertions(+), 7634 deletions(-) create mode 100644 docs/.vitepress/theme/components/appendix/api-intro/ApiFunctionVsHttp.vue create mode 100644 docs/.vitepress/theme/components/appendix/api-intro/DocumentTypesComparison.vue create mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/BinaryAdditionRulesDemo.vue create mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/FlipFlopDemo.vue create mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/OSArchitectureDemo.vue delete mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/OSSystemOverviewDemo.vue delete mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessMemoryFilesystemDemo.vue create mode 100644 docs/.vitepress/theme/components/appendix/computer-fundamentals/ProgramLaunchDemo.vue delete mode 100644 docs/zh-cn/appendix/5-data/sql.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index daa4551..c715cae 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -797,7 +797,6 @@ export default defineConfig({ text: '五、数据', collapsed: false, items: [ - { text: 'SQL', link: '/zh-cn/appendix/5-data/sql' }, { text: '数据库原理(索引 / 事务 / 查询优化)', link: '/zh-cn/appendix/5-data/database-fundamentals' diff --git a/docs/.vitepress/theme/components/HomeFeatures.vue b/docs/.vitepress/theme/components/HomeFeatures.vue index e91ac8f..9d915cd 100644 --- a/docs/.vitepress/theme/components/HomeFeatures.vue +++ b/docs/.vitepress/theme/components/HomeFeatures.vue @@ -110,13 +110,13 @@ const i18n = { title: '真实项目', headline: '拒绝玩具代码。', desc: '深入理解用户鉴权、数据存储、文件上传等核心业务逻辑。', - link: '/zh-cn/stage-2/backend/2.2-database-supabase/chapter5/chapter5-from-database-to-supabase' + link: '/zh-cn/stage-2/backend/2.2-database-supabase/chapter5/' }, { title: '部署上线', headline: '让世界看到你的作品。', desc: '学习服务器配置、域名解析和自动化部署,打通产品落地的最后一公里。', - link: '/zh-cn/stage-2/backend/2.5-zeabur-deployment/extra6/extra6-zeabur-what-is-it-and-how-to-deploy-web-applications' + link: '/zh-cn/stage-2/backend/2.5-zeabur-deployment/extra6/' } ] }, @@ -133,7 +133,7 @@ const i18n = { { title: 'AI 智能体', desc: '构建具备记忆与规划能力的 Agent,实现自主任务执行。', - link: '/zh-cn/stage-3/ai-advanced/3.a1-rag-introduction/extra5-what-is-rag-and-how-does-it-work-and-future' + link: '/zh-cn/stage-3/ai-advanced/3.a1-rag-introduction/' }, { title: '长效稳定', @@ -1502,7 +1502,7 @@ const stage2Cards = [ desc: '深入理解用户鉴权、数据存储、文件上传等核心业务逻辑。', imageColor: '#8EC5FC', visualType: 'server', - link: '/zh-cn/stage-2/backend/2.2-database-supabase/chapter5/chapter5-from-database-to-supabase' + link: '/zh-cn/stage-2/backend/2.2-database-supabase/chapter5/' }, { title: '部署上线', @@ -1510,7 +1510,7 @@ const stage2Cards = [ desc: '学习服务器配置、域名解析和自动化部署,打通产品落地的最后一公里。', imageColor: '#96E6A1', visualType: 'cloud', - link: '/zh-cn/stage-2/backend/2.5-zeabur-deployment/extra6/extra6-zeabur-what-is-it-and-how-to-deploy-web-applications' + link: '/zh-cn/stage-2/backend/2.5-zeabur-deployment/extra6/' } ] @@ -1528,7 +1528,7 @@ const stage3Cards = [ desc: 'RAG、Agent,探索 LLM 的无限可能。', tag: 'Advanced', visualType: 'ai', - link: '/zh-cn/stage-3/ai-advanced/3.a1-rag-introduction/extra5-what-is-rag-and-how-does-it-work-and-future' + link: '/zh-cn/stage-3/ai-advanced/3.a1-rag-introduction/' }, { title: '复杂业务架构', diff --git a/docs/.vitepress/theme/components/appendix/ai-history/AIErasComparisonDemo.vue b/docs/.vitepress/theme/components/appendix/ai-history/AIErasComparisonDemo.vue index cc903c5..6432ff2 100644 --- a/docs/.vitepress/theme/components/appendix/ai-history/AIErasComparisonDemo.vue +++ b/docs/.vitepress/theme/components/appendix/ai-history/AIErasComparisonDemo.vue @@ -5,7 +5,7 @@ 🌟 AI 发展阶段与核心范式全景对比
-
+
{{ era.icon }}
{{ era.name }}
{{ era.time }}
@@ -25,7 +25,7 @@
典型代表
- {{ tag }} + {{ tag }}
diff --git a/docs/.vitepress/theme/components/appendix/ai-history/AiEvolutionDemo.vue b/docs/.vitepress/theme/components/appendix/ai-history/AiEvolutionDemo.vue index 9e77929..e78a8e7 100644 --- a/docs/.vitepress/theme/components/appendix/ai-history/AiEvolutionDemo.vue +++ b/docs/.vitepress/theme/components/appendix/ai-history/AiEvolutionDemo.vue @@ -1,7 +1,7 @@ diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/BinaryAdditionRulesDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/BinaryAdditionRulesDemo.vue new file mode 100644 index 0000000..d2c1a1c --- /dev/null +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/BinaryAdditionRulesDemo.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompilerDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompilerDemo.vue index c4d6bb4..8a39591 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompilerDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompilerDemo.vue @@ -48,8 +48,7 @@ v-for="(task, j) in currentStage.tasks" :key="j" class="task-chip" - >{{ task }} + >{{ task }}
@@ -88,9 +87,7 @@
{{ step }} - +
@@ -104,8 +101,7 @@
- 核心思想:编译器像翻译官,把人类能读懂的代码逐步翻译成机器能执行的指令。六个阶段各司其职:识别单词 + 核心思想:编译器像翻译官,把人类能读懂的代码逐步翻译成机器能执行的指令。六个阶段各司其职:识别单词 → 理解语法 → 检查语义 → 生成中间码 → 优化 → 生成机器码。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompleteAdderDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompleteAdderDemo.vue index 7a65e78..cb1e634 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompleteAdderDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/CompleteAdderDemo.vue @@ -146,7 +146,7 @@ XOR 异或门
-
A ⊕ B
+
A XOR B
不同为 1 → 本位
@@ -154,7 +154,7 @@ AND 与门
-
A ∧ B
+
A AND B
全 1 为 1 → 进位
@@ -203,13 +203,13 @@
本位: - A ⊕ B = {{ haA ? '1' : '0' }} ⊕ {{ haB ? '1' : '0' }} = + A XOR B = {{ haA ? '1' : '0' }} XOR {{ haB ? '1' : '0' }} = {{ haSum ? '1' : '0' }} ({{ haA !== haB ? '不同' : '相同' }})
进位: - A ∧ B = {{ haA ? '1' : '0' }} ∧ {{ haB ? '1' : '0' }} = + A AND B = {{ haA ? '1' : '0' }} AND {{ haB ? '1' : '0' }} = {{ haCarry ? '1' : '0' }} ({{ haA && haB ? '全为 1' : '不全为 1' }})
@@ -384,15 +384,15 @@
中间: - xor1 = A ⊕ B = {{ faXor1 ? '1' : '0' }} + 中间值 = A XOR B = {{ faXor1 ? '1' : '0' }}
本位: - Sum = xor1 ⊕ Cin = {{ faSum ? '1' : '0' }} + 本位 = 中间值 XOR Cin = {{ faSum ? '1' : '0' }}
进位: - Cout = (A∧B) ∨ (xor1∧Cin) = + 进位 = (A AND B) OR (中间值 AND Cin) = {{ faCarryOut ? '1' : '0' }}
@@ -544,21 +544,21 @@ const gates = [ name: 'AND', cn: '与门', symbol: '&', - formula: 'A ∧ B', + formula: 'A AND B', truth: [0, 0, 0, 1] }, { name: 'OR', cn: '或门', symbol: '≥1', - formula: 'A ∨ B', + formula: 'A OR B', truth: [0, 1, 1, 1] }, { name: 'XOR', cn: '异或门', symbol: '=1', - formula: 'A ⊕ B', + formula: 'A XOR B', truth: [0, 1, 1, 0] }, { name: 'NOT', cn: '非门', symbol: '1', formula: '¬A', truth: [1, 0] } diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLifecycleDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLifecycleDemo.vue index b2548da..c0ca097 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLifecycleDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLifecycleDemo.vue @@ -6,7 +6,7 @@
-
+
{{ index + 1 }} {{ stage.name }} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLinkLayerDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLinkLayerDemo.vue index 1b9ef0f..5276427 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLinkLayerDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataLinkLayerDemo.vue @@ -86,9 +86,7 @@
↓ 广播到局域网
- 我是!我的 MAC 地址是 00:11:22:33:44:66 + 我是!我的 MAC 地址是 00:11:22:33:44:66
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureDemo.vue index 190e189..4a9a39e 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureDemo.vue @@ -40,9 +40,7 @@
{{ item.value }} - +
@@ -88,8 +86,7 @@ v-for="(item, j) in bucket" :key="j" class="bucket-item" - >{{ item }} + >{{ item }}
@@ -178,8 +175,7 @@
- 核心思想:数据结构是数据的"容器",不同的容器有不同的特点。选择合适的数据结构,能让程序效率提升几个数量级。 + 核心思想:数据结构是数据的"容器",不同的容器有不同的特点。选择合适的数据结构,能让程序效率提升几个数量级。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureSelectorDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureSelectorDemo.vue index 21d9770..060ae1f 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureSelectorDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/DataStructureSelectorDemo.vue @@ -24,9 +24,7 @@
- 推荐使用:{{ currentScenario.recommendation }} + 推荐使用:{{ currentScenario.recommendation }}
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingDemo.vue index a2b56c8..bd94cb0 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingDemo.vue @@ -50,11 +50,9 @@ class="char-item" > {{ char }} - U+{{ + U+{{ char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0') - }} + }} {{ char.charCodeAt(0).toString(2).padStart(8, '0') }} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingStorageTransmissionDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingStorageTransmissionDemo.vue index 87c752b..6c02259 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingStorageTransmissionDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/EncodingStorageTransmissionDemo.vue @@ -90,10 +90,10 @@
数据包
{{ layer.name }}: {{ layer.value }} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FilesystemDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FilesystemDemo.vue index 04d27a3..fc365c3 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FilesystemDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FilesystemDemo.vue @@ -1,333 +1,392 @@ diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FlipFlopDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FlipFlopDemo.vue new file mode 100644 index 0000000..7ea62e7 --- /dev/null +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FlipFlopDemo.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FullAdderDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FullAdderDemo.vue index 4b78540..ad38ddc 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FullAdderDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FullAdderDemo.vue @@ -1,212 +1,131 @@ @@ -214,302 +133,168 @@ import { ref, computed } from 'vue' const inputA = ref(true) -const inputB = ref(true) +const inputB = ref(false) const carryIn = ref(false) +// 第一步:半加器 1 const xor1 = computed(() => inputA.value !== inputB.value) const carry1 = computed(() => inputA.value && inputB.value) -const carry2 = computed(() => xor1.value && carryIn.value) + +// 第二步:半加器 2 const sumOut = computed(() => xor1.value !== carryIn.value) +const carry2 = computed(() => xor1.value && carryIn.value) + +// 第三步:OR 合并 const carryOut = computed(() => carry1.value || carry2.value) + +const cases = [ + { a: 0, b: 0, cin: 0, sum: 0, carry: 0, key: '000' }, + { a: 0, b: 0, cin: 1, sum: 1, carry: 0, key: '001' }, + { a: 0, b: 1, cin: 0, sum: 1, carry: 0, key: '010' }, + { a: 0, b: 1, cin: 1, sum: 0, carry: 1, key: '011' }, + { a: 1, b: 0, cin: 0, sum: 1, carry: 0, key: '100' }, + { a: 1, b: 0, cin: 1, sum: 0, carry: 1, key: '101' }, + { a: 1, b: 1, cin: 0, sum: 0, carry: 1, key: '110' }, + { a: 1, b: 1, cin: 1, sum: 1, carry: 1, key: '111' }, +] + +const explainText = computed(() => { + const a = +inputA.value + const b = +inputB.value + const c = +carryIn.value + const total = a + b + c + if (total === 0) return '0 + 0 + 0 = 0。本位写 0,不进位。' + if (total === 1) return `${a} + ${b} + ${c} = 1。本位写 1,不进位。` + if (total === 2) return `${a} + ${b} + ${c} = 2。二进制里 2 就是 "10",所以本位写 0,向左进 1。` + return `${a} + ${b} + ${c} = 3。二进制里 3 就是 "11",所以本位写 1,向左进 1。` +}) diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FunctionalUnitDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FunctionalUnitDemo.vue index afb91cd..77f9194 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/FunctionalUnitDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/FunctionalUnitDemo.vue @@ -20,8 +20,7 @@
- 多路选择器 (MUX):像铁路道岔一样,根据"选择信号"决定让哪一路数据通过。 + 多路选择器 (MUX):像铁路道岔一样,根据"选择信号"决定让哪一路数据通过。
@@ -81,8 +80,7 @@
- 译码器 (Decoder):将二进制输入转换为特定输出线的激活信号(例如 2位输入可以激活 + 译码器 (Decoder):将二进制输入转换为特定输出线的激活信号(例如 2位输入可以激活 4根输出线中的一根)。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/GreedyThinkingDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/GreedyThinkingDemo.vue index 4c38373..675360b 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/GreedyThinkingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/GreedyThinkingDemo.vue @@ -37,9 +37,9 @@
{{ step.coin }}
× {{ step.count }} = {{ step.value }}元
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/HalfAdderDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/HalfAdderDemo.vue index f6c3d57..db388d7 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/HalfAdderDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/HalfAdderDemo.vue @@ -1,167 +1,116 @@ @@ -169,288 +118,277 @@ import { ref, computed } from 'vue' const inputA = ref(false) -const inputB = ref(true) +const inputB = ref(false) const sumOut = computed(() => inputA.value !== inputB.value) const carryOut = computed(() => inputA.value && inputB.value) + +const cases = [ + { a: 0, b: 0, sum: 0, carry: 0 }, + { a: 0, b: 1, sum: 1, carry: 0 }, + { a: 1, b: 0, sum: 1, carry: 0 }, + { a: 1, b: 1, sum: 0, carry: 1 }, +] + +const explainText = computed(() => { + const a = +inputA.value + const b = +inputB.value + if (a === 0 && b === 0) return '0 + 0 = 0。这一列写下 0,不需要进位。' + if (a === 0 && b === 1) return '0 + 1 = 1。这一列写下 1,不需要进位。' + if (a === 1 && b === 0) return '1 + 0 = 1。这一列写下 1,不需要进位。' + return '1 + 1 = 2。但二进制这一列最多写 1,所以写下 0,并且向左边那列"进一个 1"(进位)。就像十进制的 9+1=10,个位写 0、十位进 1。' +}) diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/HashTableDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/HashTableDemo.vue index 5c23903..fe15bf9 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/HashTableDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/HashTableDemo.vue @@ -28,7 +28,7 @@ placeholder="值 (如: 苹果)" class="hash-input" /> - +
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/LanguageMapDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/LanguageMapDemo.vue index 7bc8e39..e176c22 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/LanguageMapDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/LanguageMapDemo.vue @@ -35,8 +35,7 @@ v-for="lang in era.languages" :key="lang" class="lang-dot" - >{{ lang }} + >{{ lang }}
@@ -91,8 +90,7 @@ v-for="lang in selectedParadigm.languages" :key="lang" class="lang-tag" - >{{ lang }} + >{{ lang }}
{{ selectedParadigm.example }}
@@ -102,8 +100,7 @@ v-for="t in selectedParadigm.traits" :key="t" class="trait-chip" - >{{ t }} + >{{ t }}
@@ -161,8 +158,7 @@ v-for="lang in rec.langs" :key="lang" class="choose-lang-tag" - >{{ lang }} + >{{ lang }}
{{ rec.reason }}
@@ -185,19 +181,11 @@
核心思想: - 编程语言从机器语言到现代高级语言,一直在朝着"更接近人类思维"的方向演化。 - 编程范式是思考问题的方式——命令式关注"怎么做",声明式关注"做什么",选择范式比选语言更重要。 - 没有最好的语言,只有最适合场景的语言。类型系统、运行方式、生态都是选择时的关键考量。 - 初学者先学 Python(简单通用),再学 JavaScript(Web - 必备),最后选一门静态语言(TypeScript/Go/Rust)深入。 + 编程语言从机器语言到现代高级语言,一直在朝着"更接近人类思维"的方向演化。 + 编程范式是思考问题的方式——命令式关注"怎么做",声明式关注"做什么",选择范式比选语言更重要。 + 没有最好的语言,只有最适合场景的语言。类型系统、运行方式、生态都是选择时的关键考量。 + 初学者先学 Python(简单通用),再学 JavaScript(Web + 必备),最后选一门静态语言(TypeScript/Go/Rust)深入。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/MemoryDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/MemoryDemo.vue index f7912b0..712d5ec 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/MemoryDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/MemoryDemo.vue @@ -1,373 +1,349 @@ diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/NetworkLayers.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/NetworkLayers.vue index 1a2c36b..6b36a79 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/NetworkLayers.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/NetworkLayers.vue @@ -70,8 +70,7 @@
- 核心思想:分层设计让网络协议模块化,每层只关心自己的职责。数据从应用层向下传递时,每层都会添加自己的"信封"(头部),接收时再逐层拆开。 + 核心思想:分层设计让网络协议模块化,每层只关心自己的职责。数据从应用层向下传递时,每层都会添加自己的"信封"(头部),接收时再逐层拆开。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSArchitectureDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSArchitectureDemo.vue new file mode 100644 index 0000000..6dc3452 --- /dev/null +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSArchitectureDemo.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSSystemOverviewDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSSystemOverviewDemo.vue deleted file mode 100644 index 10a978c..0000000 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/OSSystemOverviewDemo.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - - - diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessDemo.vue index 675ac4f..5550af0 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessDemo.vue @@ -1,376 +1,259 @@ diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessMemoryFilesystemDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessMemoryFilesystemDemo.vue deleted file mode 100644 index 46fc17c..0000000 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProcessMemoryFilesystemDemo.vue +++ /dev/null @@ -1,446 +0,0 @@ - - - - - diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProgramLaunchDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProgramLaunchDemo.vue new file mode 100644 index 0000000..21f703b --- /dev/null +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/ProgramLaunchDemo.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/RecursiveThinkingDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/RecursiveThinkingDemo.vue index 445eb70..12778da 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/RecursiveThinkingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/RecursiveThinkingDemo.vue @@ -110,8 +110,7 @@ function traverse(folder) { traverse(item) // 递归调用! } } -} +} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/RegisterDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/RegisterDemo.vue index 97aec13..811f3cc 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/RegisterDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/RegisterDemo.vue @@ -30,8 +30,7 @@ {{ storedData }} + >{{ storedData }} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SearchAlgorithmDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SearchAlgorithmDemo.vue index f5bd962..f721f6a 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SearchAlgorithmDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SearchAlgorithmDemo.vue @@ -40,10 +40,10 @@
- - +
目标数字:
- - + +
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SortingAlgorithmDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SortingAlgorithmDemo.vue index 6265b5e..c7a3a8b 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SortingAlgorithmDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SortingAlgorithmDemo.vue @@ -22,9 +22,9 @@
- - - + + +
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/StorageDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/StorageDemo.vue index 32103c5..bd97a77 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/StorageDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/StorageDemo.vue @@ -54,8 +54,7 @@
- 核心思想:存储遵循"金字塔"原则:越快的存储越贵、容量越小。CPU + 核心思想:存储遵循"金字塔"原则:越快的存储越贵、容量越小。CPU 需要的数据放在最快的存储(寄存器、缓存),暂时不用的放在慢速大容量存储(磁盘、云端)。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SubnetCalculator.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SubnetCalculator.vue index 31eca84..d85c9a1 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/SubnetCalculator.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/SubnetCalculator.vue @@ -114,12 +114,8 @@
- 网络位 ({{ cidr }}位) - 主机位 ({{ 32 - cidr }}位) + 网络位 ({{ cidr }}位) + 主机位 ({{ 32 - cidr }}位)
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TcpUdpComparison.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TcpUdpComparison.vue index 3a5fa94..0a80756 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TcpUdpComparison.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TcpUdpComparison.vue @@ -62,8 +62,7 @@ v-for="(use, i) in currentProtocol.useCases" :key="i" class="use-tag" - >{{ use }} + >{{ use }} diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransmissionDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransmissionDemo.vue index 0791d92..a6e0c4f 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransmissionDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransmissionDemo.vue @@ -44,8 +44,7 @@ :class="{ sending: sendingBit === i && activeType === 'serial' }" - >{{ bit }} + >{{ bit }}
@@ -57,8 +56,7 @@ :key="i" class="flow-dot" :class="{ active: sendingBit !== null }" - >● + >●
@@ -119,8 +117,7 @@
- 核心思想:现代高速传输多采用串行方式。虽然并行"看起来"更快(一次传多位),但串行可以跑更高频率,抗干扰更强,实际速度反而更快。 + 核心思想:现代高速传输多采用串行方式。虽然并行"看起来"更快(一次传多位),但串行可以跑更高频率,抗干扰更强,实际速度反而更快。
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransportLayerDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransportLayerDemo.vue index c666c38..b5a68bd 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransportLayerDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TransportLayerDemo.vue @@ -64,7 +64,7 @@
TCP
-
+
📦 {{ i }}
@@ -76,7 +76,7 @@
UDP
-
+
⚡ {{ i }}
diff --git a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TypeSystemDemo.vue b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TypeSystemDemo.vue index 9659b2c..af2b679 100644 --- a/docs/.vitepress/theme/components/appendix/computer-fundamentals/TypeSystemDemo.vue +++ b/docs/.vitepress/theme/components/appendix/computer-fundamentals/TypeSystemDemo.vue @@ -52,8 +52,7 @@ v-for="t in selectedQuadrant.traits" :key="t" class="trait-tag" - >{{ t }} + >{{ t }}
@@ -111,18 +110,10 @@
- 弱类型:隐式转换,结果常出人意料 - 强类型:拒绝隐式转换,必须显式指定 - 强类型:字符串拼接是特例,其余严格 - 强类型:类型不匹配就报错,零容忍 + 弱类型:隐式转换,结果常出人意料 + 强类型:拒绝隐式转换,必须显式指定 + 强类型:字符串拼接是特例,其余严格 + 强类型:类型不匹配就报错,零容忍
@@ -146,7 +137,7 @@
- {{ + {{ b }}
@@ -155,19 +146,11 @@
核心思想: - 类型系统在两个维度上做选择——何时检查(静态/动态)和是否允许隐式转换(强/弱)。没有最好的组合,只有最适合的场景。 - 静态类型在编译时就能发现错误,动态类型要到运行时才知道——越早发现 - bug,修复成本越低。 - 弱类型语言会"猜"你的意思做隐式转换(常出错),强类型语言要求你明确表达意图(更安全)。 - 类型推断让你两全其美:代码像动态语言一样简洁,编译器像静态语言一样严格检查。 + 类型系统在两个维度上做选择——何时检查(静态/动态)和是否允许隐式转换(强/弱)。没有最好的组合,只有最适合的场景。 + 静态类型在编译时就能发现错误,动态类型要到运行时才知道——越早发现 + bug,修复成本越低。 + 弱类型语言会"猜"你的意思做隐式转换(常出错),强类型语言要求你明确表达意图(更安全)。 + 类型推断让你两全其美:代码像动态语言一样简洁,编译器像静态语言一样严格检查。
diff --git a/docs/.vitepress/theme/components/appendix/data-encoding/AudioEncodingDemo.vue b/docs/.vitepress/theme/components/appendix/data-encoding/AudioEncodingDemo.vue index a6e75f4..52a916f 100644 --- a/docs/.vitepress/theme/components/appendix/data-encoding/AudioEncodingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/data-encoding/AudioEncodingDemo.vue @@ -9,8 +9,8 @@
-
+
@@ -44,7 +44,7 @@
-
+
将鼠标悬停在左侧画布的方块上
diff --git a/docs/.vitepress/theme/components/appendix/data-encoding/PhotoUploadJourneyDemo.vue b/docs/.vitepress/theme/components/appendix/data-encoding/PhotoUploadJourneyDemo.vue index 2c802b8..2b15ff5 100644 --- a/docs/.vitepress/theme/components/appendix/data-encoding/PhotoUploadJourneyDemo.vue +++ b/docs/.vitepress/theme/components/appendix/data-encoding/PhotoUploadJourneyDemo.vue @@ -1,123 +1,190 @@ diff --git a/docs/.vitepress/theme/components/appendix/data-encoding/StoragePyramidDemo.vue b/docs/.vitepress/theme/components/appendix/data-encoding/StoragePyramidDemo.vue index 9b7dc57..c084fd2 100644 --- a/docs/.vitepress/theme/components/appendix/data-encoding/StoragePyramidDemo.vue +++ b/docs/.vitepress/theme/components/appendix/data-encoding/StoragePyramidDemo.vue @@ -15,7 +15,7 @@
-
+
{{ currentLayer.icon }} {{ currentLayer.name }} diff --git a/docs/.vitepress/theme/components/appendix/data/ABTestingDemo.vue b/docs/.vitepress/theme/components/appendix/data/ABTestingDemo.vue index 2fd1617..54ba30d 100644 --- a/docs/.vitepress/theme/components/appendix/data/ABTestingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/data/ABTestingDemo.vue @@ -38,13 +38,13 @@
- - - +
@@ -64,9 +64,7 @@
💡 - 50/50分配能最快检测出差异,确保两组样本量足够大以获得统计显著性 + 50/50分配能最快检测出差异,确保两组样本量足够大以获得统计显著性
@@ -260,7 +258,7 @@
- @@ -296,9 +294,7 @@
💡 - 提升目标越小,所需样本量越大。5%的提升比20%的提升需要更多样本 + 提升目标越小,所需样本量越大。5%的提升比20%的提升需要更多样本
diff --git a/docs/.vitepress/theme/components/appendix/development-tools/RegexDemo.vue b/docs/.vitepress/theme/components/appendix/development-tools/RegexDemo.vue index dd28fc5..8979070 100644 --- a/docs/.vitepress/theme/components/appendix/development-tools/RegexDemo.vue +++ b/docs/.vitepress/theme/components/appendix/development-tools/RegexDemo.vue @@ -175,19 +175,11 @@
核心思想: - 正则表达式是一种用特殊符号描述文本模式的语言,在搜索、替换、数据验证中无处不在。 - 记住几个核心符号(. * + ? \d \w [] ())就能覆盖 80% - 的使用场景。点击任意符号可直接试验。 - 不需要自己从零写正则——常见场景(邮箱、手机号、URL)都有成熟的模式可以直接复用。 - 正则引擎从左到右逐字符匹配,遇到量词会"贪婪"地尽量多匹配,失败时"回溯"尝试其他路径。 + 正则表达式是一种用特殊符号描述文本模式的语言,在搜索、替换、数据验证中无处不在。 + 记住几个核心符号(. * + ? \d \w [] ())就能覆盖 80% + 的使用场景。点击任意符号可直接试验。 + 不需要自己从零写正则——常见场景(邮箱、手机号、URL)都有成熟的模式可以直接复用。 + 正则引擎从左到右逐字符匹配,遇到量词会"贪婪"地尽量多匹配,失败时"回溯"尝试其他路径。
diff --git a/docs/.vitepress/theme/components/appendix/development-tools/SSHAuthDemo.vue b/docs/.vitepress/theme/components/appendix/development-tools/SSHAuthDemo.vue index de0ed23..6045b8d 100644 --- a/docs/.vitepress/theme/components/appendix/development-tools/SSHAuthDemo.vue +++ b/docs/.vitepress/theme/components/appendix/development-tools/SSHAuthDemo.vue @@ -2,9 +2,7 @@
SSH 密钥认证:你的数字身份证 - 对称加密 vs 非对称加密 · 密钥对生成 · 认证流程 + 对称加密 vs 非对称加密 · 密钥对生成 · 认证流程
@@ -28,7 +26,7 @@
🔑
密码登录
-
+
{{ i + 1 }} {{ step }}
@@ -43,7 +41,7 @@
🔐
密钥登录
-
+
{{ i + 1 }} {{ step }}
@@ -144,18 +142,14 @@
② 发送随机挑战 - "请证明你有私钥:用它签名这段随机数据" + "请证明你有私钥:用它签名这段随机数据"
③ 返回签名 - "用私钥签名后的结果(私钥本身不发送)" + "用私钥签名后的结果(私钥本身不发送)"
④ 用公钥验证 @@ -163,9 +157,7 @@
⑤ 认证成功 - "欢迎登录!从始至终,私钥没离开过你的电脑" + "欢迎登录!从始至终,私钥没离开过你的电脑"
@@ -211,21 +203,13 @@ Host github.com
核心思想: - SSH - 密钥登录比密码更安全,因为私钥从不在网络上传输,无法被中间人窃取。 - 一次 ssh-keygen - 生成一对密钥:私钥自己保管,公钥放到目标服务器或平台。 - 认证过程基于"挑战-响应"机制:服务器出题,你的私钥签名作答,公钥验证答案。全程私钥不离开本机。 - SSH 密钥不仅用于服务器登录,也是 Git (GitHub/GitLab) - 等开发工具的标准身份认证方式。 + SSH + 密钥登录比密码更安全,因为私钥从不在网络上传输,无法被中间人窃取。 + 一次 ssh-keygen + 生成一对密钥:私钥自己保管,公钥放到目标服务器或平台。 + 认证过程基于"挑战-响应"机制:服务器出题,你的私钥签名作答,公钥验证答案。全程私钥不离开本机。 + SSH 密钥不仅用于服务器登录,也是 Git (GitHub/GitLab) + 等开发工具的标准身份认证方式。
diff --git a/docs/.vitepress/theme/components/appendix/framework-nature/DomOperationCostDemo.vue b/docs/.vitepress/theme/components/appendix/framework-nature/DomOperationCostDemo.vue index 344583b..99e4777 100644 --- a/docs/.vitepress/theme/components/appendix/framework-nature/DomOperationCostDemo.vue +++ b/docs/.vitepress/theme/components/appendix/framework-nature/DomOperationCostDemo.vue @@ -48,7 +48,7 @@
-
+
{{ i }} 修改 → 布局 → 绘制
diff --git a/docs/.vitepress/theme/components/appendix/framework-nature/WhatIsDomDemo.vue b/docs/.vitepress/theme/components/appendix/framework-nature/WhatIsDomDemo.vue index 57bc7e9..08445b7 100644 --- a/docs/.vitepress/theme/components/appendix/framework-nature/WhatIsDomDemo.vue +++ b/docs/.vitepress/theme/components/appendix/framework-nature/WhatIsDomDemo.vue @@ -38,7 +38,7 @@ @mouseenter="highlightedTag = node.tag" @mouseleave="highlightedTag = ''" > - └─ + └─ {{ node.label }} "{{ node.text }}"
diff --git a/docs/.vitepress/theme/components/appendix/framework-nature/WhyNoAutoSyncDemo.vue b/docs/.vitepress/theme/components/appendix/framework-nature/WhyNoAutoSyncDemo.vue index a4cffe3..3afe8f9 100644 --- a/docs/.vitepress/theme/components/appendix/framework-nature/WhyNoAutoSyncDemo.vue +++ b/docs/.vitepress/theme/components/appendix/framework-nature/WhyNoAutoSyncDemo.vue @@ -143,11 +143,11 @@
-
+
为什么不自动? JavaScript 的变量是"无感知"的。你执行 count = 4 时,JavaScript 引擎只是把内存中 count 的值从 3 改成 4,仅此而已。它不会通知任何人,不会触发任何回调,不会去检查页面上哪里显示了 count。所以界面不会有任何变化——除非你自己写代码去更新 DOM。
-
+
框架怎么做到的? 框架把你的数据用特殊机制包裹起来。以 Vue 为例,它用 JavaScript 的 Proxy(代理)功能拦截你对变量的赋值操作。当你写 count = 4 时,Proxy 会在赋值的同时自动执行一段"通知"代码,告诉框架"count 变了",框架再去找到所有用到 count 的 DOM 节点并更新它们。整个过程你不需要写任何额外代码。
diff --git a/docs/.vitepress/theme/components/appendix/git-intro/GitCommitFlow.vue b/docs/.vitepress/theme/components/appendix/git-intro/GitCommitFlow.vue index 32ec773..750e27e 100644 --- a/docs/.vitepress/theme/components/appendix/git-intro/GitCommitFlow.vue +++ b/docs/.vitepress/theme/components/appendix/git-intro/GitCommitFlow.vue @@ -40,7 +40,7 @@
📝 工作区 - Working Directory
你正在改的文件
+ Working Directory
你正在改的文件
Changes not staged for commit:
@@ -65,7 +65,7 @@
📦 暂存区 - Staging Area
准备这次提交的文件
+ Staging Area
准备这次提交的文件
Changes to be committed:
@@ -90,7 +90,7 @@
🗄️ 仓库 - Repository (.git)
永久保存的版本
+ Repository (.git)
永久保存的版本
已提交记录 (git log):
diff --git a/docs/.vitepress/theme/components/appendix/ports-localhost/DevServerFlowDemo.vue b/docs/.vitepress/theme/components/appendix/ports-localhost/DevServerFlowDemo.vue index 234b4a7..2dd5303 100644 --- a/docs/.vitepress/theme/components/appendix/ports-localhost/DevServerFlowDemo.vue +++ b/docs/.vitepress/theme/components/appendix/ports-localhost/DevServerFlowDemo.vue @@ -105,7 +105,8 @@ function reset() {
+ }]" +>
浏览器 diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue index 7d5b9e1..cb3f6d0 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue @@ -1,983 +1,216 @@ - - - diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/DnsLookupDemo.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/DnsLookupDemo.vue index 0ba5738..47fdca2 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/DnsLookupDemo.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/DnsLookupDemo.vue @@ -1,700 +1,51 @@ - - - diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/HttpExchangeDemo.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/HttpExchangeDemo.vue index 14b5e00..ba1d487 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/HttpExchangeDemo.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/HttpExchangeDemo.vue @@ -1,531 +1,198 @@ - - - diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/TcpHandshakeDemo.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/TcpHandshakeDemo.vue index c79de10..5f83847 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/TcpHandshakeDemo.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/TcpHandshakeDemo.vue @@ -1,810 +1,172 @@ - - - diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/UrlParserDemo.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/UrlParserDemo.vue index f2ae273..dd9c30e 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/UrlParserDemo.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/UrlParserDemo.vue @@ -1,654 +1,57 @@ - - - \ No newline at end of file +.label.protocol { background: #fee2e2; color: #dc2626; } +.label.host { background: #dbeafe; color: #2563eb; } +.label.path { background: #d1fae5; color: #059669; } +.label.query { background: #ede9fe; color: #7c3aed; } + diff --git a/docs/.vitepress/theme/components/appendix/url-to-browser/UrlToBrowserQuickStart.vue b/docs/.vitepress/theme/components/appendix/url-to-browser/UrlToBrowserQuickStart.vue index 84b4385..c942a32 100644 --- a/docs/.vitepress/theme/components/appendix/url-to-browser/UrlToBrowserQuickStart.vue +++ b/docs/.vitepress/theme/components/appendix/url-to-browser/UrlToBrowserQuickStart.vue @@ -172,7 +172,7 @@ const url = ref('') const isActive = ref(false) const currentStep = ref(0) -const quickUrls = ['baidu.com', 'bilibili.com', 'github.com'] +const quickUrls = ['baidu.com', 'google.com', 'github.com'] const steps = [ { diff --git a/docs/.vitepress/theme/components/appendix/web-basics/BrowserRenderingDemo.vue b/docs/.vitepress/theme/components/appendix/web-basics/BrowserRenderingDemo.vue index 8c527b8..24f7392 100644 --- a/docs/.vitepress/theme/components/appendix/web-basics/BrowserRenderingDemo.vue +++ b/docs/.vitepress/theme/components/appendix/web-basics/BrowserRenderingDemo.vue @@ -72,7 +72,7 @@ @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null" > - <div class="card"> + <div class="player">
- <img class="icon" src="castle.png" /> + <img class="cover" src="cat.jpg" />
- <h2 class="title">乐高城堡</h2> + <h2 class="title">搞笑猫咪合集</h2>
- <button class="btn">购买</button> + <button class="btn">▶️ 播放</button>
- .card { display: flex; padding: 10px; } + .player { margin: auto; padding: 20px; }
- .icon { width: 50px; height: 50px; } + .cover { width: 100%; height: 200px; }
- .title { color: red; } + .title { color: #fb7299; /* B站主题色 */ }
- .btn { background: blue; } + .btn { background: #00aeec; color: white; }
@@ -233,7 +233,7 @@ @mouseenter.stop="hoveredPart = 'card'" @mouseleave="hoveredPart = null" > - div.card + div.player
- img.icon + img.cover 🏰 + >🐈
@@ -267,7 +267,7 @@ 乐高城堡 + >搞笑猫咪合集
@@ -285,7 +285,7 @@ 购买 + >▶️ 播放
@@ -332,26 +332,26 @@ import { ref } from 'vue' const steps = [ { label: 'DOM (搭骨架)', - title: '1. 搭建骨架 (DOM)', - desc: '浏览器工头 (Parser) 解析 HTML 代码,构建出完整的文档树结构。注意:即使代码中省略了 html/body,浏览器也会自动补全。', + title: '1. 搭建骨架 (DOM 解析)', + desc: '浏览器工厂看懂了 HTML 代码,搭建好了页面的“骨架”(比如哪里是包裹盒 div,哪里是按钮 button)。', resultTitle: 'DOM 树结构' }, { - label: 'Style (上色)', - title: '2. 计算样式 (Recalculate Style)', - desc: '装修工 (CSS Parser) 匹配 CSS 规则。比如发现 .title 需要红色,.btn 需要蓝色背景。此时只关心"长什么样",不关心"在哪"。', - resultTitle: '附带样式的节点' + label: 'Style (看图纸)', + title: '2. 匹配样式 (CSS 解析)', + desc: '仔细看了眼配色的说明书。比如发现 .title 字体要是粉色的,.btn 背景要是蓝色的(此时只在脑子里确立样式,但不计算尺寸)。', + resultTitle: '获取了各种配置规则' }, { - label: 'Layout (排版)', - title: '3. 布局排版 (Layout/Reflow)', - desc: '测量员 (Layout) 根据 display:flex 和 padding 等属性,计算每个盒子的精确位置和大小。图片在左,文字在右。', - resultTitle: '几何布局' + label: 'Layout (定尺寸)', + title: '3. 排版规划 (Layout)', + desc: '拿尺子量每个骨架的大小。考虑到用户的屏幕尺寸,精确计算出猫咪的图片要多高、播放按钮要挤到哪个坐标上。', + resultTitle: '排版布局盒子' }, { label: 'Paint (绘制)', - title: '4. 像素绘制 (Paint)', - desc: '画家 (Paint) 按照计算好的位置和样式,真正把像素点画在屏幕上。最终你看到了一个完整的商品卡片。', + title: '4. 像素上色 (Paint)', + desc: '根据前面的几何位置和颜色计划,正式拿起画笔,将一个个像素填到你的屏幕上,一个可以看视频的播放器就诞生了。', resultTitle: '最终画面' } ] @@ -576,34 +576,39 @@ const hoveredPart = ref(null) /* Step 2: Style */ .block-box.title.styled { - color: red; /* Text color applied but not painted yet */ - border: 1px solid red; /* Visual cue for style applied */ - background: #fee2e2; + color: #fb7299; + border: 1px solid #fb7299; + background: #fdf2f8; } .block-box.btn.styled { - background: blue; + background: #00aeec; color: white; - border: 1px solid blue; + border: 1px solid #00aeec; } /* Step 3: Layout */ .block-box.card.layout { display: flex; - flex-direction: row; /* Horizontal layout */ + flex-direction: column; align-items: center; gap: 10px; padding: 15px; background: white; border: 1px solid #ccc; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + border-radius: 8px; } .block-box.img.layout { - width: 50px; - height: 50px; + width: 100%; + height: 120px; background: #eee; border: none; + font-size: 3rem; + display: flex; + align-items: center; + justify-content: center; } .block-box.title.layout { @@ -614,9 +619,11 @@ const hoveredPart = ref(null) } .block-box.btn.layout { - margin-left: auto; /* Push to right */ - padding: 5px 15px; + width: 100%; + padding: 8px; border-radius: 4px; + text-align: center; + cursor: pointer; } /* Content visibility for Paint step */ diff --git a/docs/.vitepress/theme/components/appendix/web-basics/DnsLookupDemo.vue b/docs/.vitepress/theme/components/appendix/web-basics/DnsLookupDemo.vue index 828fa3c..7630fd0 100644 --- a/docs/.vitepress/theme/components/appendix/web-basics/DnsLookupDemo.vue +++ b/docs/.vitepress/theme/components/appendix/web-basics/DnsLookupDemo.vue @@ -5,10 +5,10 @@ 为什么需要 DNS?(查导航)

- 你知道店铺名字叫 "Shop.com",但快递员需要知道具体的经纬度坐标 (IP 地址) + 你知道店铺名字叫 "bilibili.com",但快递员需要知道具体的经纬度坐标 (IP 地址) 才能送达。
- DNS 就像是地图导航,输入店名,它告诉你具体的坐标。 + DNS 就像是地图导航,输入店名,它通过“114查号台”帮你找到坐标。

@@ -16,7 +16,7 @@
店铺名称 (域名)
- shop.com + bilibili.com
@@ -29,10 +29,10 @@ 🧭
- DNS (地图导航) + DNS (查号台)
- 正在查找 shop.com 的位置... + 正在查询 bilibili.com 的 IP...
@@ -41,9 +41,9 @@
- GPS 坐标 (IP 地址) + 精准坐标 (IP 地址)
- 93.184.216.34 + 110.43.12.55
diff --git a/docs/.vitepress/theme/components/appendix/web-basics/HttpExchangeDemo.vue b/docs/.vitepress/theme/components/appendix/web-basics/HttpExchangeDemo.vue index 9756c9b..c1a8f7b 100644 --- a/docs/.vitepress/theme/components/appendix/web-basics/HttpExchangeDemo.vue +++ b/docs/.vitepress/theme/components/appendix/web-basics/HttpExchangeDemo.vue @@ -169,20 +169,20 @@ const props = defineProps({ const t = { send: '提交订单 (发送请求)', - noRequests: '购物车是空的 (无请求)', - placeholder: '点击 "提交订单" 向店员购买玩具', - general: '订单详情 (General)', - requestUrl: '商品地址 (URL)', + noRequests: '还没发请求 (网络空闲)', + placeholder: '点击 "提交订单" 向服务器索要页面', + general: '请求概要 (General)', + requestUrl: '目标地址 (URL)', requestMethod: '操作类型 (Method)', - statusCode: '店员回复 (Status)', - responseHeaders: '包裹标签 (Headers)', + statusCode: '服务器回复状态 (Status)', + responseHeaders: '包裹标签 / 补充说明 (Headers)', tabs: { - headers: '订单信息', - response: '包裹内容', - preview: '玩具预览' + headers: '头部信息(Headers)', + response: '代码内容(Response)', + preview: '大致预览(Preview)' }, cols: { - name: '商品', + name: '请求体', status: '状态', type: '类型', time: '耗时' @@ -190,7 +190,7 @@ const t = { } const method = ref('GET') -const path = ref('/toys/lego-castle') +const path = ref('/video/BV1xx411c7mD') const loading = ref(false) const requestSent = ref(false) const activeTab = ref('headers') @@ -210,20 +210,33 @@ const sendRequest = async () => { loading.value = false if (method.value === 'GET') { - responseStatus.value = '200 OK (有货)' + responseStatus.value = '200 OK (交易成功)' responseHeaders.value = { - 'Content-Type': 'application/json (积木)', - Date: new Date().toLocaleString(), - Store: '乐高官方店' + 'Content-Type': 'text/html; charset=utf-8', + 'Server': 'BWS/1.1 (Bilibili Web Server)', + 'Date': new Date().toUTCString() } - responseBody.value = `{\n "id": 101,\n "name": "Lego Castle",\n "pieces": 500,\n "price": "$99"\n}` + responseBody.value = ` + + + 【B站】超级搞笑的猫咪合集 + + +

超级搞笑的猫咪合集

+
+ 封面 + +
+ +` } else { - responseStatus.value = '201 Created (下单成功)' + responseStatus.value = '201 Created (操作成功)' responseHeaders.value = { 'Content-Type': 'application/json', - Date: new Date().toLocaleString() + 'Server': 'BWS/1.1', + 'Date': new Date().toUTCString() } - responseBody.value = `{\n "success": true,\n "message": "Order placed"\n}` + responseBody.value = `{\n "success": true,\n "message": "点赞成功!"\n}` } } diff --git a/docs/.vitepress/theme/components/appendix/web-basics/TcpHandshakeDemo.vue b/docs/.vitepress/theme/components/appendix/web-basics/TcpHandshakeDemo.vue index 83173e9..7a7df47 100644 --- a/docs/.vitepress/theme/components/appendix/web-basics/TcpHandshakeDemo.vue +++ b/docs/.vitepress/theme/components/appendix/web-basics/TcpHandshakeDemo.vue @@ -139,21 +139,21 @@ const props = defineProps({ // Bilingual text directly const t = { - statusLabel: '通话状态', - connect: '拨打电话', - reset: '挂断重拨', - client: '我 (顾客)', - server: '玩具店', + statusLabel: '连接状态', + connect: '建立连接', + reset: '断开重连', + client: '我 (浏览器)', + server: '对面 (B站服务器)', status: { - closed: '未通话', - handshaking: '正在拨号...', - established: '通话中 (连接已建立)' + closed: '未连接', + handshaking: '正在打招呼确认通道...', + established: 'TCP 通道已建立 (ESTABLISHED)' }, steps: { - 0: '点击 "拨打电话" 开始确认店铺是否营业。', - 1: '步骤 1: 我问 "喂?有人在吗?" (SYN)', - 2: '步骤 2: 店员答 "在的!请问有什么事?" (SYN-ACK)', - 3: '步骤 3: 我说 "太好了,我想买东西!" (ACK)' + 0: '点击 "建立连接" 开始三次握手(电话试音)。', + 1: '第一次握手: "喂,服务器老哥在吗?我能发信息,你能收到吗?" (SYN)', + 2: '第二次握手: "喂喂在的!我收到了!那你现在能听到我说话吗?" (SYN-ACK)', + 3: '第三次握手: "妥了,我也能听到!通道没问题,准备看视频!" (ACK)' } } diff --git a/docs/.vitepress/theme/components/appendix/web-basics/UrlParserDemo.vue b/docs/.vitepress/theme/components/appendix/web-basics/UrlParserDemo.vue index da365d8..98c6153 100644 --- a/docs/.vitepress/theme/components/appendix/web-basics/UrlParserDemo.vue +++ b/docs/.vitepress/theme/components/appendix/web-basics/UrlParserDemo.vue @@ -109,14 +109,14 @@ const props = defineProps({ } }) -const inputUrl = ref('https://shop.com/toys/lego-castle?color=red#summary') +const inputUrl = ref('https://www.bilibili.com/video/BV1xx411c7mD?t=60#comments') const highlightedPart = ref(null) const icons = { protocol: '🚛', host: '🏢', port: '🚪', - pathname: '🧸', + pathname: '📺', search: '📝', hash: '📍' } @@ -125,18 +125,18 @@ const labels = { protocol: '交通方式 (Protocol)', host: '店铺地址 (Host)', port: '大门号 (Port)', - pathname: '商品位置 (Path)', - search: '备注要求 (Query)', - hash: '快速定位 (Hash)' + pathname: '具体货架 (Path)', + search: '特殊要求 (Search/Query)', + hash: '直接跳转 (Hash)' } const descriptions = { - protocol: '怎么去?(例如 https = 坐装甲车去,很安全)', - host: '去哪家店?(域名,例如 shop.com)', - port: '从哪个门进?(默认 443 号门)', - pathname: '商品在哪个货架?(路径)', - search: '给店员的备注 (例如 ?color=red)', - hash: '直接翻到说明书第几页 (锚点)' + protocol: '怎么去?(https = 坐押运车去,比 http 安全)', + host: '去哪家店?(域名:例如 www.bilibili.com)', + port: '走哪个门?(默认隐藏了 443 端口号)', + pathname: '拿什么货?(去 /video 区拿编号为 BV... 的视频)', + search: '给店员的备注说明 (例如 ?t=60 表示要求从 60 秒开始看)', + hash: '拿到货后自己做的事 (例如滚动到评论区 #comments)' } const parsedUrl = computed(() => { diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js index f9d07fb..2b2f2d9 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -44,6 +44,8 @@ import ApiPlayground from './components/appendix/api-intro/ApiPlayground.vue' import RealWorldApiDemo from './components/appendix/api-intro/RealWorldApiDemo.vue' import FunctionApiDemo from './components/appendix/api-intro/FunctionApiDemo.vue' import ApiTypesComparison from './components/appendix/api-intro/ApiTypesComparison.vue' +import ApiFunctionVsHttp from './components/appendix/api-intro/ApiFunctionVsHttp.vue' +import DocumentTypesComparison from './components/appendix/api-intro/DocumentTypesComparison.vue' import HttpMethodsDemo from './components/appendix/api-intro/HttpMethodsDemo.vue' import StatusCodeCategories from './components/appendix/api-intro/StatusCodeCategories.vue' @@ -110,6 +112,7 @@ import NetworkTroubleshooting from './components/appendix/web-basics/NetworkTrou // Computer Fundamentals Components import TransistorDemo from './components/appendix/computer-fundamentals/TransistorDemo.vue' import LogicGateDemo from './components/appendix/computer-fundamentals/LogicGateDemo.vue' +import BinaryAdditionRulesDemo from './components/appendix/computer-fundamentals/BinaryAdditionRulesDemo.vue' import HalfAdderDemo from './components/appendix/computer-fundamentals/HalfAdderDemo.vue' import FullAdderDemo from './components/appendix/computer-fundamentals/FullAdderDemo.vue' import AdderDemo from './components/appendix/computer-fundamentals/AdderDemo.vue' @@ -118,6 +121,7 @@ import CompleteAdderDemo from './components/appendix/computer-fundamentals/Compl import FunctionalUnitDemo from './components/appendix/computer-fundamentals/FunctionalUnitDemo.vue' import CpuArchitectureDemo from './components/appendix/computer-fundamentals/CpuArchitectureDemo.vue' import RegisterDemo from './components/appendix/computer-fundamentals/RegisterDemo.vue' +import FlipFlopDemo from './components/appendix/computer-fundamentals/FlipFlopDemo.vue' // import EvolutionFlowDemo from './components/appendix/computer-fundamentals/EvolutionFlowDemo.vue' import ProcessDemo from './components/appendix/computer-fundamentals/ProcessDemo.vue' import MemoryDemo from './components/appendix/computer-fundamentals/MemoryDemo.vue' @@ -135,8 +139,8 @@ import CFSubnetCalculator from './components/appendix/computer-fundamentals/Subn import CFTcpUdpComparison from './components/appendix/computer-fundamentals/TcpUdpComparison.vue' // Computer Fundamentals Additional Components -import OSSystemOverviewDemo from './components/appendix/computer-fundamentals/OSSystemOverviewDemo.vue' -import ProcessMemoryFilesystemDemo from './components/appendix/computer-fundamentals/ProcessMemoryFilesystemDemo.vue' +import OSArchitectureDemo from './components/appendix/computer-fundamentals/OSArchitectureDemo.vue' +import ProgramLaunchDemo from './components/appendix/computer-fundamentals/ProgramLaunchDemo.vue' import DataLifecycleDemo from './components/appendix/computer-fundamentals/DataLifecycleDemo.vue' import EncodingStorageTransmissionDemo from './components/appendix/computer-fundamentals/EncodingStorageTransmissionDemo.vue' import NetworkOverviewDemo from './components/appendix/computer-fundamentals/NetworkOverviewDemo.vue' @@ -673,6 +677,8 @@ export default { app.component('RealWorldApiDemo', RealWorldApiDemo) app.component('FunctionApiDemo', FunctionApiDemo) app.component('ApiTypesComparison', ApiTypesComparison) + app.component('ApiFunctionVsHttp', ApiFunctionVsHttp) + app.component('DocumentTypesComparison', DocumentTypesComparison) app.component('HttpMethodsDemo', HttpMethodsDemo) app.component('StatusCodeCategories', StatusCodeCategories) @@ -742,6 +748,7 @@ export default { // Computer Fundamentals Components Registration app.component('TransistorDemo', TransistorDemo) app.component('LogicGateDemo', LogicGateDemo) + app.component('BinaryAdditionRulesDemo', BinaryAdditionRulesDemo) app.component('HalfAdderDemo', HalfAdderDemo) app.component('FullAdderDemo', FullAdderDemo) app.component('AdderDemo', AdderDemo) @@ -750,6 +757,7 @@ export default { app.component('FunctionalUnitDemo', FunctionalUnitDemo) app.component('CpuArchitectureDemo', CpuArchitectureDemo) app.component('RegisterDemo', RegisterDemo) + app.component('FlipFlopDemo', FlipFlopDemo) // app.component('EvolutionFlowDemo', EvolutionFlowDemo) app.component('ProcessDemo', ProcessDemo) app.component('MemoryDemo', MemoryDemo) @@ -767,8 +775,8 @@ export default { app.component('CFTcpUdpComparison', CFTcpUdpComparison) // Computer Fundamentals Additional Components Registration - app.component('OSSystemOverviewDemo', OSSystemOverviewDemo) - app.component('ProcessMemoryFilesystemDemo', ProcessMemoryFilesystemDemo) + app.component('OSArchitectureDemo', OSArchitectureDemo) + app.component('ProgramLaunchDemo', ProgramLaunchDemo) app.component('DataLifecycleDemo', DataLifecycleDemo) app.component( 'EncodingStorageTransmissionDemo', diff --git a/docs/zh-cn/appendix/1-computer-fundamentals/computer-networks.md b/docs/zh-cn/appendix/1-computer-fundamentals/computer-networks.md index 66614b5..1022315 100644 --- a/docs/zh-cn/appendix/1-computer-fundamentals/computer-networks.md +++ b/docs/zh-cn/appendix/1-computer-fundamentals/computer-networks.md @@ -1,84 +1,224 @@ # 计算机网络:从输入网址到返回结果的过程 ::: tip 🎯 核心问题 -**当你在浏览器输入 www.google.com 并按下回车,到底发生了什么?** +**当你舒服地靠在沙发上,在手机浏览器里输入 `www.google.com` 并按下回车,为什么几百毫秒后,搜索结果就能准确无误地出现在你的屏幕上?** -这个看似简单的动作,背后隐藏着一个庞大精密的跨国“快递系统”。从填写订单(URL解析)到查询地址簿(DNS解析),从建立运输通道(TCP握手)到快递员送货(HTTP请求与响应),最终在你屏幕上拆开包裹组装(浏览器渲染)。本章带你零基础、完整理解这个神奇的过程。 +在上一章中,我们知道了数据是如何被编码成 0 和 1 并通过海底光缆传输的。但这还不够。互联网上的服务器浩如烟海,你的手机是怎么在茫茫机海中精准找到 Google 的服务器,商量好暗号,并成功把页面要回来的呢? + +这个看似无比简单的"敲回车"动作,背后其实隐藏着一个精密到令人震撼的跨国"快递接力系统"。本章,我们不讲枯燥的八股文概念,而是顺着**"填写购物单 -> 查地址簿 -> 打电话确认 -> 寄包裹 -> 自己拆解组装"**这条主线,带你零基础看清网络世界的全貌。 ::: --- -## 全景演示:网络世界的快递系统 +## 第一步:填写购物单 (URL 解析) -你可以通过下方的交互组件,直观地体验从输入网址到看到网页的 5 个关键步骤。先自己点一点,然后再看底下的详细解释! +**目标**:把人类能看懂的网址,翻译成浏览器能理解的结构化信息。 - +当你在地址栏中输入 `https://www.google.com/search` 时,浏览器第一步必须先把你输入的这段"人类文字",仔细拆解成它能看懂的标准化字段。 + +这就像是你准备去商店买东西,首先要在**购物单**上写清楚:用什么交通工具去、去哪家店、拿什么货。 + + + +**💡 核心原理解析:URL是怎么分工的?** + +- **交通方式(Protocol/协议)**:比如开头写的 `https://`。这代表你要求坐安全级别最高的"运钞车"(加密通信)去。如果是老式的 `http://`,就相当于坐敞篷车,你一路上买什么都会被别人看光。 +- **店铺名(Host/主机名)**:比如 `www.google.com`。这就是你要去哪家店(也就是服务器的域名)。 +- **具体货架(Path/路径)**:比如后面的 `/search`。这代表进了店门之后,你要去哪个房间拿具体的哪份文件。 + +**这一步完成了什么?** 浏览器现在知道了:我要用 HTTPS 协议,去 `www.google.com` 这个域名对应的服务器,获取 `/search` 路径下的内容。 + +**但问题来了**:浏览器知道了域名,但网络世界只认数字 IP 地址。就像你知道"王府井大饭店",但司机需要 GPS 坐标。下一步,我们需要把域名转换成 IP 地址。 --- -## 1. 填写购物单 (URL 解析) +## 第二步:查地址簿 (DNS 解析) -当你在浏览器的地址栏中输入 `https://www.google.com` 这样一段地址并按下回车,这就像是你准备去商店买东西,首先要在**购物单**上写清楚: +**上一步完成了**:浏览器拆解了 URL,知道了目标域名是 `www.google.com`。 -- **交通方式 (Protocol)**:例如 `https://`,代表你想坐安全级别的最高的“运钞车”(加密通信)去。如果是单纯的 `http://`,就相当于坐普通的“大巴”(明文传输),路上可能会被人偷看行李。 -- **店铺地址 (Host)**:例如 `www.google.com`,也就是你要去哪家店(域名)。 -- **商品位置 (Path)**:例如 `/search`,意思是进了商店之后,你要去哪个货架找什么东西(即请求的具体资源路径)。 +**这一步要实现**:把域名转换成 IP 地址,让浏览器知道服务器的精确位置。 -浏览器第一步要做的,就是把这段“人类语言”拆解开,看看你到底想要什么。 +**目的**:网络世界的底层路由器(负责指路的交警)根本不懂英文,它们**只认数字**,也就是所谓的 **IP 地址(如 142.250.80.46)**。 + + + +**💡 核心原理解析:找"114查号台"** + +既然必须用 IP 地址,浏览器就会走一个叫做 **DNS (Domain Name System)** 的打听流程: + +1. **翻自己的备忘录(本地缓存)**:浏览器会先翻翻自己的浏览历史,看看前几天是不是刚去过这家店,记没记过它的数字地址。如果记了,直接用。 +2. **打电话给查号台(递归查询)**:如果实在没见过,它就会向互联网的"总查号台"(通常由你的宽带运营商提供,比如联通、电信的 DNS 服务器)发请求:"你好,请帮我查一下,google.com 对应的数字坐标是几?" +3. **拿到坐标**:查号台通过逐级查询,最终把一个准确的 IP 地址(如 `142.250.80.46`)发回给你的手机。 + +**这一步完成了什么?** 浏览器现在拿到了 Google 服务器的精确 IP 地址 `142.250.80.46`。 + +**但问题来了**:有了 IP 地址就能直接发请求了吗?万一服务器宕机了呢?万一网线断了呢?如果直接发请求,对方没收到,就成了鸡同鸭讲。下一步,我们需要先确认双方能正常通信。 --- -## 2. 查找店铺地址 (DNS 解析) +## 第三步:打电话确认 (TCP 三次握手) -网络世界的“快递员”(路由器设备)是不懂英文的,它们只认数字(也就是 **IP 地址**)。 +**上一步完成了**:浏览器通过 DNS 查询,拿到了服务器的 IP 地址 `142.250.80.46`。 -它们需要知道对方的精确数字坐标!这就像快递员不知道“王府井百货”在哪,他必须先查地图,找到“北京市东城区王府井大街255号”这个确切的门牌号(比如 `142.250.66.4`)。 +**这一步要实现**:建立一条可靠的通信通道,确保双方都能收发数据。 -- **本地缓存**:浏览器会先翻翻自己的备忘录(看之前有没有访问过该网站)。 -- **DNS 系统**:如果在本地找不到,它就会向互联网的“查号台”(DNS 服务器)打电话询问:“请问 google.com 的数字地址是什么?”。一旦获得了对应的 IP 地址,浏览器的快递车就知道该往哪里开了。 +**目的**:在正式传输数据之前,必须先确认"对方在线"且"双方收发通道都正常"。这就像打电话前要先确认"喂,能听到吗?" + + + +**💡 核心原理解析:为什么非得是"三"次?** + +不要被专业名词吓到,它完全可以在现实生活中还原。想象一下你给朋友打电话: --- -## 3. 建立通话 (TCP 握手) +### 第一次握手:SYN(同步请求) -拿到了地址,浏览器不能直接冲过去,万一店今天没开门呢?所以,要先进行一次**“电话确认”**(这叫建立 TCP 连接)。为了确保通话稳定可靠,会有三次非常严谨的“确认打招呼”机制,行业里叫**三次握手 (Three-way Handshake)**: +**浏览器发送 SYN 包** -- **第一次握手 (浏览器)**:“喂,你好,我要来买东西,你在吗?” (SYN) -- **第二次握手 (服务器)**:“我在的,欢迎光临!你也听得到我说话吗?” (SYN-ACK) -- **第三次握手 (浏览器)**:“我也听到了!那我就要过来了!” (ACK) +就像你拨通朋友电话后说的第一句话:"喂,你好,能听到我说话吗?" -经过这三次确认,双方都知道了彼此的听力和表达能力都没问题,一条稳定可靠的通信通道就正式建立了。 +- **SYN** 是 **Synchronize**(同步)的缩写 +- 浏览器生成一个随机数字(比如 `Seq = 100`),告诉服务器:"我要开始建立连接了,我的初始序号是 100" +- 这个序号用来标记后续发送的数据顺序,防止乱序 + +**这一步确认了什么?** 服务器收到了浏览器的消息 → 浏览器的**发送通道**正常。 --- -## 4. 购买商品 (HTTP 请求与响应) +### 第二次握手:SYN-ACK(同步+确认) -通道建好后,业务正式开始。 +**服务器回复 SYN-ACK 包** -- **浏览器(买家)提交订单**:浏览器会打包一份极其规范的订单表格(**HTTP 请求报文**),里面写着:“老板,请给我拿一份你的主页 HTML 文件,我是用 Chrome 浏览器来访问的哦。” -- **服务器(卖家)根据订单发货**:位于地球另一端的 Google 服务器收到请求后,立刻开始在仓库里配货,生成网页的 HTML 代码,然后打包成包裹(**HTTP 响应报文**),发回给你的浏览器。包裹外面还会贴个标签“200 OK”,意思是“交易成功,你要的货全齐了”。 +就像朋友回答:"喂喂,我能听到你!你也能听到我吗?" + +- **SYN-ACK** = **Synchronize + Acknowledge**(同步+确认) +- 服务器做两件事: + 1. **ACK**:确认收到浏览器的消息(`Ack = 101`,表示"我期待收到你序号为 101 的下一个包") + 2. **SYN**:服务器也生成自己的随机序号(比如 `Seq = 200`),告诉浏览器:"我的初始序号是 200" + +**这一步确认了什么?** 浏览器收到了服务器的回复 → 服务器的**发送通道**正常,浏览器的**接收通道**正常。 --- -## 5. 拆盒组装 (浏览器渲染) +### 第三次握手:ACK(确认) -最后一步,货物送到了你的电脑。但发过来的只是一堆代码(HTML、CSS、JavaScript),这就好比你网购买了一箱乐高积木,还需要自己组装: +**浏览器回复 ACK 包** -1. **看说明书 (解析 HTML)**:浏览器先把 HTML 代码解读出来,拼装成网页的骨架(DOM 树)。 -2. **涂抹颜色 (解析 CSS)**:然后检查 CSS 代码,看看字体要多大、按钮是什么颜色,给网页穿上漂亮的外衣(CSSOM 树)。 -3. **计算布局并拼装 (Layout & Paint)**:浏览器计算好每个元素在屏幕上的确切位置,用画笔把它们画在你的显示器上。 -4. **注入灵魂 (执行 JavaScript)**:最后,各种能点击、能滑动的交互效果都通过 JavaScript 激活。 +就像你回答:"能听到!那我们开始聊正事吧!" -**只要短短的几百毫秒,所有的步骤就已全部完成,你也就看到了那个熟悉的页面!** +- **ACK** 是 **Acknowledge**(确认)的缩写 +- 浏览器回复:`Ack = 201`,表示"我期待收到你序号为 201 的下一个包" + +**这一步确认了什么?** 服务器收到了浏览器的确认 → 服务器的**接收通道**也正常。 --- -## 总结:从微观到宏观 +### 为什么必须是三次?两次行不行? -如果我们把目光再拉远一点,整个网络通讯的本质,就是在做**接力跑和翻译**: +**假设只有两次握手:** -- 我们上面看到的这五步,大多是发生在你眼前的**应用程序**层面的事情。 -- 在肉眼看不见的底层,刚才那个充满代码的 HTML 包裹,会被切分成无数块极小的碎片(数据包)。这些碎片顺着你家墙上的网线、海底的万兆光缆,像接力棒一样在各种路由器之间传递。 -- 最终,这一切碎片完好无损地抵达,并在哪怕是几十个毫秒的时间里,化成你屏幕上的绚丽像素。 +1. 浏览器:"喂,能听到吗?" +2. 服务器:"能听到!" -这就是计算机网络的神奇魅力! +这时候服务器以为连接建立了,开始发送数据。但如果服务器的回复在半路丢了,浏览器根本没收到,浏览器就不会认为连接建立成功,也不会处理服务器发来的数据。 + +**结果**:服务器单方面认为连接已建立,疯狂发数据,但浏览器全当垃圾丢弃。服务器资源被白白浪费。 + +**三次握手的精妙之处**: + +第三次握手的 ACK 包,**证明了浏览器确实收到了服务器的回复**。只有浏览器收到了,才会回复 ACK;服务器收到了这个 ACK,才能**100%确定**双方通道都是通的。 + +这就像打电话时的完整确认: +- 你:"喂,能听到吗?"(SYN) +- 朋友:"能听到,你呢?"(SYN-ACK) +- 你:"我也能听到!"(ACK) + +**这一步完成了什么?** 浏览器和服务器都确认了:**我能发给你,我能收到你的,你也能发给我,你也能收到我的**。一条可靠的 TCP 通道正式建立! + +**现在可以开始了吗?** 通道已建立,下一步就是正式发送请求,获取网页内容。 + +--- + +## 第四步:寄包裹 (HTTP 请求与响应) + +**上一步完成了**:通过 TCP 三次握手,建立了可靠的通信通道。 + +**这一步要实现**:正式发送请求,获取网页内容。 + +**目的**:浏览器向服务器"下单",服务器返回"货物"(网页内容)。 + + + +**💡 核心原理解析:HTTP 请求与响应的小纸条** + +浏览器会把你刚才写好的购物单,按照一种极为规范的格式打包(这叫 **HTTP 请求头**),正式塞进刚才建立好的 TCP 通道里,发给服务器。 + +- **买方发纸条(HTTP Request)**: + 浏览器发出的包裹里,写着大写的请求指令。如果是看网页就是 `GET`,如果是提交账号密码登录就是 `POST`。不仅如此,这张纸条里还附带了一些重要情报:"嗨,我是用 Mac 电脑的 Chrome 浏览器访问的哦,另外我只能听懂中文,请把给我的货也转换成中文。"(这些补充说明就被叫做 **请求 Headers**)。 + +- **卖方发纸条(HTTP Response)**: + 位于千里之外的服务器收到这包东西后,看了一眼:"哦,他要 `GET` 这个页面啊"。于是服务器飞速在自己的硬盘里找到相应的 HTML 网页代码打包好,在包裹最外面贴上一个标签:`200 OK`(意思是交易非常成功,你要的货全齐了),然后借由同一个通道,原路寄回给你的电脑。 + +> **小科普**:如果是找不到你要找得页面,服务器就会贴个 `404 Not Found` 的悲伤标签给你退回来。如果是服务器自己代码写错了挂掉了,就会贴个 `500 Server Error` 的崩溃标签。 + +**这一步完成了什么?** 浏览器收到了服务器返回的 HTML、CSS、JavaScript 代码(也就是网页的"原材料")。 + +**但问题来了**:这些代码只是文本,还不是你能看到的网页画面。下一步,浏览器需要把这些代码"翻译"成屏幕上的像素。 + +--- + +## 第五步:拆解组装 (浏览器渲染) + +**上一步完成了**:通过 HTTP 请求,浏览器获取了网页的源代码(HTML、CSS、JavaScript)。 + +**这一步要实现**:把代码转换成屏幕上可见的网页画面。 + +**目的**:将文本代码"翻译"成像素,让用户看到最终的网页。 + + + +**💡 核心原理解析:毫秒级的画家** + +此时你电脑收到的,仅仅是一大串干瘪枯燥的文本代码(HTML 骨架、CSS 色彩图纸、JS 交互动效代码)。这就像你网购了一箱子乐高,它给你的只有几千个塑料零件和一本极度复杂的说明书。 + +浏览器的组装过程堪比惊心动魄的全自动工厂流水线: + +1. **搭骨架 (DOM 解析)**:工人先把 HTML 文件通读一遍,理清楚网页的结构。比如"这里要有一个标题框,那里要有三个图片框"。这个骨架叫做 DOM 树。 +2. **上颜色 (CSS 解析)**:紧接着看 CSS 文件,"哦,老王说标题框必须是红色的,图片框必须有圆角。" +3. **几何计算排版 (Layout)**:结合骨架和颜色后,开始拿尺子计算。因为每个人的屏幕大小不一样,同样是三个图片框,在手机上只能竖着放,在电脑上可以横着放。必须计算出每一个像素块极其精确的摆放坐标。 +4. **上色绘制 (Paint)**:最后拿起了画笔,按照前面算出来的精确设计图,把真真切切的颜色和像素渲染到了你的显示器上! + +**这一步完成了什么?** 浏览器把代码转换成了屏幕上的像素,用户终于看到了完整的网页! + +--- + +## 完整流程回顾 + +让我们把整个过程串起来: + +| 步骤 | 完成了什么 | 下一步需要什么 | +|------|-----------|---------------| +| **1. URL 解析** | 拆解网址,知道要去哪 | 需要把域名转成 IP | +| **2. DNS 解析** | 拿到服务器 IP 地址 | 需要确认服务器在线 | +| **3. TCP 握手** | 建立可靠通信通道 | 需要发送正式请求 | +| **4. HTTP 交换** | 获取网页源代码 | 需要把代码转成画面 | +| **5. 浏览器渲染** | 把代码渲染成像素 | ✅ 用户看到网页! | + +--- + +## 结语:0.5 秒里发生了什么 + +敲下回车,等上半秒,页面就跳出来了——我们早就习惯了这个速度,甚至觉得慢。 + +但仔细想想,就在这眨眼的功夫里: + +- **第一步**:浏览器把你输入的网址拆开看懂 +- **第二步**:跑去问了好多台服务器才要到 IP 地址 +- **第三步**:跟大洋彼岸的服务器来回确认了三次"能听见吗" +- **第四步**:把请求打包发过去,再等着收回来 +- **第五步**:最后还要把成千上万行代码瞬间组装成你能看到的画面 + +这些步骤一环扣一环,**前一步的输出是后一步的输入**,中间哪个环节出问题,页面就打不开。而那些路由器、服务器、光缆,就默默在后台 24 小时运转,保证你每次滑动手机时,内容都能准时出现。 + +下次等网页加载的时候,或许可以想想:这 0.5 秒,其实挺忙的。 diff --git a/docs/zh-cn/appendix/1-computer-fundamentals/operating-systems.md b/docs/zh-cn/appendix/1-computer-fundamentals/operating-systems.md index 225a9f8..739890a 100644 --- a/docs/zh-cn/appendix/1-computer-fundamentals/operating-systems.md +++ b/docs/zh-cn/appendix/1-computer-fundamentals/operating-systems.md @@ -2,98 +2,90 @@ ::: tip 🎯 核心问题 **有了完美的 CPU 和无限的内存,电脑就能直接用了吗?** -在上一章,我们见证了晶体管如何组合成强大的 CPU。但其实,如果直接使用这些冷冰冰的硬件,哪怕只是想在屏幕上打出一个字母,你都需要手写几百行晦涩的机器指令。 +在上一章,我们见证了晶体管如何组合成强大的 CPU。但即使你拥有最顶级的硬件,如果直接让它们工作,连在屏幕上显示一个字母都需要写几百行晦涩的机器指令。不仅麻烦,还极其危险——稍有差池,你的代码就可能把别人的数据覆盖掉。 -为了不让大家在每次用电脑时都被逼疯,前辈们创造了一个夹在“硬件”和“你”之间的超级管家——**操作系统(Operating System, 简称 OS)**。本章我们不谈深奥的理论,只聊聊这个大管家是怎么通过三大“障眼法”,把复杂的硬件调教得服服帖帖的。 +为了解决这些噩梦,**操作系统(Operating System, 简称 OS)**诞生了。它是挡在你和冰冷硬件之间的一层最伟大的"软件"。本章我们将抛开深奥的代码,用通俗的比喻,看看这个"超级管家"是如何把杂乱无章的硬件调教得服服帖帖的。 ::: --- -## 0. 承上启下:如果没有操作系统会怎样? +## 0. 全景图:没有操作系统会怎样? -上一章我们提到,CPU 是一个不知疲倦的无情计算机器,通电后就会一行一行地执行指令。 +想象一下,你开了一家极具潜力的"计算工厂"(你的电脑),厂里有一个全能、不知疲倦的顶级干将(CPU),还有一片巨大的仓库(内存)和无数的集装箱(硬盘)。 -但这带来了几个现实的灾难: -1. **CPU 独占危机**:CPU 一次只能干一件事。如果你正在听歌,想切出去看个网页?抱歉,没有操作系统的调度,你的电脑必须停下音乐,才能去加载网页。 -2. **内存踩踏事故**:微信和游戏都在使用内存。如果没有保安管理,游戏一不小心把数据写到了微信的内存地盘,微信当场崩溃。 -3. **硬盘迷宫**:硬盘本质上只是一张密密麻麻刻满 0 和 1 的巨大光盘。要想找到你昨天存的照片,你必须准确记住它存放在第 12345 圈磁道的第 678 个扇区。 +如果你**不雇佣**一个厂长(操作系统)来管理: +1. **CPU 独占危机**:CPU 一次只能干一件事。如果有人在用它听歌,其他任何人想看网页?抱歉,大家必须排队等听歌的人主动把 CPU 让出来。 +2. **内存踩踏事故**:微信和游戏都在使用仓库(内存)。如果没有保安规划区域,游戏一不小心把装备数据放到了微信的盒子里,微信直接当场崩溃。 +3. **硬盘迷宫**:硬盘硬件只是一张张刻满 0 和 1 的巨大光盘。要想找到昨天存的照片,你必须准确记住它存放在"第 1 盘面、第 56 磁道、第 8 扇区",没人能记住这种反人类的坐标。 -为了解决这些噩梦,操作系统诞生了。它对外提供了一套优雅的“幻觉”,这就是它的三大核心魔法:**进程(管理 CPU)**、**虚拟内存(管理内存)** 和 **文件系统(管理硬盘)**。 + + +为了解决上述的三大噩梦,操作系统祭出了它的三板斧:**进程管理**、**内存管理**和**文件系统**。 --- -## 1. 进程管理:制造“同时运行”的幻觉 +## 1. 进程管理:CPU 的时分复用 你平时用电脑,常常是一边挂着微信,一边听着音乐,还能一边打字。但如果你买的电脑其实只有一个 CPU 核心,它是怎么同时做这三件事的? -答案是:**它并没有同时做**。是操作系统在进行疯狂的“时间管理”。 +答案是:**它并没有同时做。而是操作系统在进行疯狂的"时间管理"。** -::: tip 💡 核心原理解析:时间片轮转(Time Slicing) -操作系统把 CPU 的时间切成了极其微小的片段(比如 10 毫秒)。 -- 第 1-10 毫秒:让 CPU 去执行**微信**的接收消息逻辑。 -- 第 11-20 毫秒:把微信强制暂停,让 CPU 去执行**音乐**的播放逻辑。 -- 第 21-30 毫秒:把音乐暂停,让 CPU 去响应你的**键盘打字**。 +### 1.1 什么是"进程"? +每一个正在运行的程序,就被称为一个**进程**。你可以把它理解为一个"项目组",有自己的代码(做事清单)、自己的内存数据(项目资金),排着队等待 CPU 接见。 -因为切换的速度实在太快了(一秒钟切换成百上千次),在人类迟钝的感知中,就觉得这三个软件是“同时”在运行的。 - -在操作系统的术语里,运行中的程序就被称为**进程(Process)**。操作系统就是这群进程的冷酷无情的排班经理。 -::: +### 1.2 时间片轮转 +为了不让某个流氓软件一直霸占 CPU,操作系统把 CPU 的时间切成极小的片段(约 10 毫秒),轮流分配给各个进程。因为切换速度太快了,你感觉是"同时运行"。 --- -## 2. 内存管理:给每个程序画个“海市蜃楼” +## 2. 内存管理:虚拟地址空间 -解决了 CPU 轮流用的问题,接下来是存放数据的内存。如果所有的进程都挤在同一块物理内存里,很容易发生互相干扰和偷看数据的危险。 - -操作系统的第二大魔法,叫作**虚拟内存(Virtual Memory)**。 +解决了 CPU 轮流用的问题,接下来是内存空间。如果不加管理,所有软件都直接往物理内存条写数据,必然会发生**互相覆盖**的踩踏惨剧。 -::: tip 💡 核心原理解析:内存映射 -操作系统对每一个启动的进程撒了一个弥天大谎:“嘿,你独占了整整 4GB 的纯净内存空间,随便用!”(这就是**虚拟内存**)。 +### 2.1 虚拟内存(Virtual Memory) +操作系统对每一个进程都撒了一个大谎:"嘿,你独占了整台电脑所有的可用内存,随便用!" -但实际上,当进程往这个“虚拟空间”里放东西时,操作系统的底层会拿出一个**映射表(页表)**,偷偷把数据塞进**真实物理内存(Physical Memory)**中各种零碎、不连续的角落里。 +在进程眼里,自己的内存条永远是**连续**且**干净**的。它心安理得地往里面写数据。 -**这么做有两个巨大的好处:** -1. **绝对安全**:微信永远只能看到自己的虚拟空间,它根本不知道音乐的数据在物理内存的哪个角落,自然就不会发生“踩踏”。 -2. **碎片利用**:物理内存就算被用得像狗皮膏药一样稀碎,映射给进程的虚拟空间依然是连续且整齐的。 -::: +### 2.2 页表映射(Page Table) +实际上呢?操作系统偷偷把数据塞进**真实物理内存**中各种零碎的缝隙里。这么做有两个绝顶天才的好处: +1. **绝对安全**:微信永远只能看到自己的空间,没法篡改别人的数据 +2. **碎片利用**:不管物理内存多乱,映射给进程的虚拟空间依然是整齐的 --- -## 3. 文件系统:把“荒地”变成“档案馆” +## 3. 文件系统:持久化存储的组织 -如果你买了一块崭新的硬盘,它里面其实是一片荒芜的存储单元。如果你想存一张照片,硬盘硬件只会问你:“请告诉我你要存在第几个字节地址?”这显然反人类。 - -操作系统的第三大魔法是**文件系统(File System)**,它为你构建了我们最熟悉的:文件夹(目录)和文件的概念。 +如果你买了一块崭新的硬盘,它里面其实是一片荒芜的存储单元。如果你想存一张照片,硬盘只会问你:"请告诉我你要存在第几个字节?" -::: tip 💡 核心原理解析:从地址到路径 -文件系统本质上是一个超级大型的“翻译官”加“账本”: -1. **账本功能**:它悄悄地把硬盘切分成无数个小块(Block),然后用一个账本记录下来“哪几个小块现在是空的可以存数据,哪几个小块已经存了东西”。 -2. **翻译功能**:当你双击一层层文件夹,打开 `D盘/照片/宠物.jpg` 时,并不是硬盘真的长出了树枝一样的结构。而是文件系统在它的账本里疯狂翻阅,最终翻译出:哦,这个路径其实对应的是硬盘上的第 1056、1057 和 998 块小地方,然后把数据取出来交给你。 -::: +### 3.1 文件系统做了什么? +1. **切割硬盘**:把硬盘切成无数个固定大小的**块**(通常是 4KB) +2. **建立账本**:记录哪些块是满的,哪些是空的 +3. **翻译路径**:把 `D盘/照片/宠物.jpg` 翻译成"第 3、7、11 块" + +这就是为什么你重命名文件瞬间就能完成(只改账本上的名字),而复制文件需要好久(要真实读写硬盘数据块)。 --- -## 4. 总结:伟大的幕后英雄 +## 4. 三者协同:程序启动的完整过程 -让我们通过一个你每天都在经历的场景,串联起今天学到的知识。当你**双击鼠标打开一个游戏**时,为了伺候你,大管家做了什么? +我们已经分别了解了操作系统的三大模块,下面看看当你**双击打开一个程序**时,它们是如何协同工作的: -1. **文件系统**:立刻从底层硬盘的杂乱数据块中,拼凑出游戏的执行文件和美术资产。 -2. **内存管理**:为你分配一个巨大的虚拟内存空间,制造出“这台电脑只有这一个游戏”的幻觉,并把刚才找到的文件放进物理内存的空隙里。 -3. **进程管理**:在它的名册上新建一个“游戏进程”,并在下一个瞬间,立刻剥夺其他正在运行软件的 CPU 权利,把 CPU 的计算力全盘移交给你的游戏。 + -我们之所以能那么轻松、优雅地在数字世界里冲浪,全都是因为底层的操作系统在替我们负重前行。 +无论是你点击桌面图标,还是代码中的一句 `print("Hello World")`,都离不开这一套复杂的暗箱操作。我们之所以能那么轻松地在数字世界里冲浪,全都是因为底层的操作系统在替我们负重前行。 --- ## 延伸阅读 -如果你觉得操作系统的各种“管理学”十分有趣,你可以看看这些进阶话题: -- **进程与线程的区别**:除了进程,还有一种叫作“线程”的东西,它们是干什么用的?(为什么 Google Chrome 那么吃内存?) -- **页面置换算法**:当物理内存全都塞满了,但你又打开了一个新软件,操作系统该把谁的数据临时踢到硬盘里?(LRU 算法) -- **操作系统的多态**:Windows 和 macOS 会在底层实现上有什么不同?为什么有些软件只能在特定系统上运行? +如果你觉得操作系统的各种"管理学和骗术"十分有趣,你可以看看这些进阶话题: +- **进程与线程**:如果进程是项目组,那"线程"就是组里干活的员工 +- **并发与锁**:当两个进程同时竞争同一个资源时,如何防止死锁 +- **系统调用**:操作系统给上层应用提供的"服务窗口" diff --git a/docs/zh-cn/appendix/1-computer-fundamentals/transistor-to-cpu.md b/docs/zh-cn/appendix/1-computer-fundamentals/transistor-to-cpu.md index f944d26..d336530 100644 --- a/docs/zh-cn/appendix/1-computer-fundamentals/transistor-to-cpu.md +++ b/docs/zh-cn/appendix/1-computer-fundamentals/transistor-to-cpu.md @@ -112,23 +112,20 @@ 如果刚才介绍的逻辑门只能做简单的条件判断,那计算机到底是如何做数学运算的呢? -我们先回想一下手算加法的方式:对应位相加,如果超出了限制(十进制是满十进一,二进制是满二进一),就向更高位“进位”。 -在二进制中,只有 0 和 1。对于一位数的加法,可能的情况只有四种: -- `0 + 0 = 0` (本位是 0,不进位) -- `0 + 1 = 1` (本位是 1,不进位) -- `1 + 0 = 1` (本位是 1,不进位) -- `1 + 1 = 10` (本位是 0,进位 1) + -仔细观察这四种情况,你会发现: -1. **本位的结果**,只有在两个输入**不同**时才为 1,这正是 **XOR 门(异或门)** 的逻辑。 -2. **进位的结果**,只有在两个输入**都为 1** 时才为 1,这正是 **AND 门(与门)** 的逻辑。 - -因此,只要把一个 XOR 门和一个 AND 门组合起来,我们就得到了能计算一位数加法的电路,这也是最基础的**半加器(Half Adder)**。 +因此,只要把一个 XOR 门(负责算本位)和一个 AND 门(负责算进位)组合起来,我们就得到了能计算一位数加法的电路,这也是最基础的**半加器(Half Adder)**。 -但半加器有个致命缺陷:它无法处理来自低位的进位。在多位加法中,中间的每一位不仅要加 A 和 B,还要加上低位传来的进位。这就需要**全加器(Full Adder)**: +但半加器有个致命缺陷:它在物理结构上**只有两个输入端口(A 和 B)**。 + +想象我们在做十进制竖式加法(比如 `19 + 22`): +- **算个位**:`9 + 2 = 11`。只需两个数相加,写 `1` 进 `1`。这刚好是两个输入,半加器能完美胜任。 +- **算十位**:不仅要算 `1 + 2`,还要**加上刚才个位传过来的“进位 1”**(即 `1 + 2 + 1 = 4`)。这意味着在多位加法中,除了最低位,其他位实际上是在做**三个数字**的相加! + +因为半加器没有接纳“低位传来的进位(Carry-in)”的第三个输入口,所以除了最右边的那一位,它在别的位全都没法用。为了解决这个问题,我们需要能接收三个信号的**全加器(Full Adder)**: @@ -185,6 +182,10 @@ 当我们将 32 个抑或 64 个这种触发器整齐地编排成一列,施加同一种强劲的时钟频率信号(Clock)来号令它们统一行动时,**寄存器(Register)**便应运而生了。它身居 CPU 系统的心脏位置,被当做极速的“工作草稿纸”,默默捍卫着你每一个即时的关键变量。 ::: +请通过下面的互动演示,亲自体验这个打破和恢复闭环的过程: + + + --- ## 4. CPU 架构:从功能单元到处理器 diff --git a/docs/zh-cn/appendix/4-server-and-backend/api-intro.md b/docs/zh-cn/appendix/4-server-and-backend/api-intro.md index 2bc7b69..0cfecfc 100644 --- a/docs/zh-cn/appendix/4-server-and-backend/api-intro.md +++ b/docs/zh-cn/appendix/4-server-and-backend/api-intro.md @@ -66,6 +66,18 @@ result = response.choices[0].message.content +### 1.3 函数 API vs HTTP API 的区别 + +很多初学者会困惑:函数 API 和 HTTP API 到底有什么区别?看文档时该如何区分? + + + +### 1.4 不同类型的 API 文档怎么看 + +面对不同类型的 API 文档,关注重点各不相同: + + + --- ## 2. 一次完整的 API 调用 diff --git a/docs/zh-cn/appendix/5-data/ab-testing.md b/docs/zh-cn/appendix/5-data/ab-testing.md index 5208783..e066cef 100644 --- a/docs/zh-cn/appendix/5-data/ab-testing.md +++ b/docs/zh-cn/appendix/5-data/ab-testing.md @@ -8,6 +8,151 @@ ## 0. 先问一个问题:你有没有经历过这些"伪成功"? +**但在讨论这些问题之前,让我们先理解一些基础概念...** + +### 0.1 什么是"实验"? + +想象你在超市买牙膏,货架上摆着两个品牌: + +- **品牌 A**:你用了 5 年,很熟悉 +- **品牌 B**:新产品,说"美白效果更好" + +你会怎么选? + +**大多数人会这样做**: +- 看价格 +- 看包装 +- 看广告 +- 问朋友推荐 +- 或者...两个都买,自己试试哪个更好 + +**最后一种就是"实验"**——通过实际使用来比较哪个更好。 + +::: tip 💡 实验的本质 +**实验 = 对比 + 观察** + +- **对比**:至少要有两个选项(A 和 B) +- **观察**:在相同条件下,测量它们的表现 +::: + +### 0.2 为什么要做"对比实验"? + +**场景:你的老板说** + +"我看了竞品,他们的按钮是红色的,很醒目!我们也把按钮改成红色吧,这样点击率肯定会提升!" + +**看起来很合理,对吧?** + +但是... + +**问题 1:竞品的产品和你一样吗?** + +- 竞品的用户可能是年轻人(喜欢鲜艳颜色) +- 你的用户可能是商务人士(偏好专业感) +- 竞品的页面风格是暗色(红色更突出) +- 你的页面风格是亮色(红色可能太刺眼) + +**问题 2:你怎么知道"醒目"就一定好?** + +- 也许红色太刺眼,用户反而觉得不安全 +- 也许用户习惯了蓝色,改成红色会困惑 +- 也许点击率提升了,但转化率下降了(点进去的人不买了) + +**问题 3:即使看起来"更好",这是巧合吗?** + +- 也许你改按钮的那一周,刚好赶上促销活动 +- 也许那一周是节假日,用户本身就比较活跃 +- 也许竞争对手刚好那周服务器崩溃,用户都跑到你这里了 + +::: warning ⚠️ 生活中的"伪因果关系" +**"我穿上这件球衣后,球队就赢了!"** + +真的是球衣的功劳吗?也许: +- 球队最近状态本来就很好 +- 这场比赛对手较弱 +- 其他队员发挥超常 + +**单次观察无法确定因果关系**,需要对比实验。 +::: + +### 0.3 为什么不能"简单对比"? + +**错误的对比方式一:时间先后对比** + +``` +第一周:蓝色按钮,点击率 5% +第二周:改成红色按钮,点击率 7% +结论:红色按钮更好! +``` + +**问题**:这两周的其他条件可能完全不同 +- 第一周可能是工作日,第二周是周末 +- 第一周可能下雨,第二周晴天 +- 第一周可能刚开学,第二周是假期 + +**错误的对比方式二:不同用户群对比** + +``` +移动端用户:使用蓝色按钮,点击率 8% +桌面端用户:使用红色按钮,点击率 5% +结论:蓝色按钮更好! +``` + +**问题**:移动端和桌面端用户本身的行为就不同 +- 移动端用户可能更年轻、更爱点击 +- 桌面端用户可能更谨慎、更有目的性 +- 设备屏幕大小不同,本身就影响点击行为 + +::: tip 💡 正确的对比实验 +**同时、随机、相同条件** + +- **同时**:两个版本在相同时间段运行 +- **随机**:用户随机分配到不同版本 +- **相同条件**:除了版本不同,其他所有条件都一样 + +这样,任何差异只能归因于"版本不同"。 +::: + +### 0.4 一个完整实验的基本步骤 + +让我们用一个完整的例子来说明: + +**场景**:你想知道"免费试用 7 天"和"首次购买打 8 折"哪个更能吸引用户 + +**步骤 1:明确问题** +- 比较对象:免费试用 vs 折扣 +- 成功标准:用户付费率 + +**步骤 2:设计对比** +- A 组:看到"免费试用 7 天" +- B 组:看到"首次购买 8 折" +- 其他条件完全相同 + +**步骤 3:随机分配** +- 来到网站的用户,随机进入 A 组或 B 组 +- 每个人 50% 概率 + +**步骤 4:收集数据** +- 记录每组有多少人看到优惠 +- 记录每组有多少人最终付费 + +**步骤 5:分析结果** +- A 组:1000 人看到,80 人付费(8%) +- B 组:1000 人看到,120 人付费(12%) +- B 组比 A 组高 50% + +**步骤 6:得出结论** +- 折扣比免费试用更有效 +- 以后全部使用折扣策略 + +**这就是 A/B 测试的完整思路!** + +--- + +现在,让我们回到开头的问题... + +### 0.5 你有没有经历过这些"伪成功"? + **场景一:被数据骗了** 你改了购物车的结算按钮颜色,从蓝变红。一周后一看数据:点击率提升了 30%! @@ -43,9 +188,115 @@ ## 1. 什么是 A/B 测试? -**A/B 测试**是一种对比实验方法:将用户随机分成两组,分别体验不同版本,比较关键指标(如转化率、点击率)的差异,从而判断哪个版本更优。 +### 1.1 最简单的定义 -### 1.1 用医学试验来类比 +**A/B 测试**就是把用户随机分成两组: +- **A 组**:看到旧版本 +- **B 组**:看到新版本 + +然后比较两组的关键指标(比如点击率、转化率),看看哪个版本更好。 + +**用一句话总结**:让数据告诉我们哪个版本更有效,而不是靠猜测。 + +### 1.2 A/B 测试的核心要素 + +要做一个合格的 A/B 测试,需要满足以下几个条件: + +**条件 1:随机分配** +- 每个用户被分配到 A 组或 B 组的概率是相等的 +- 就像抛硬币,正面朝上进 A 组,反面朝上进 B 组 +- 用户自己不能选择,你也"不能挑选" + +**为什么随机?** +假设你想测试新首页设计: + +❌ **错误做法**:把新首页给"新用户",旧首页给"老用户" +- 问题:新老用户的行为本身就不一样 +- 新用户可能更愿意探索(点击率高) +- 老用户更习惯旧设计(不愿改变) +- 你不知道是"首页设计"的差异,还是"用户类型"的差异 + +✅ **正确做法**:新老用户都随机分配到新旧首页 +- 新用户:一半看新首页,一半看旧首页 +- 老用户:一半看新首页,一半看旧首页 +- 这样才能公平比较 + +**条件 2:同时运行** +- A 组和 B 组在相同时间段运行 +- 不能今天跑 A,明天跑 B + +**为什么同时?** +假设你今天测试蓝色按钮,明天测试红色按钮: + +❌ **问题**: +- 今天可能是周一(工作日,用户少) +- 明天可能是周六(周末,用户多) +- 今天可能下雨,明天可能晴天 +- 这些"时间因素"会影响结果,和按钮颜色无关 + +✅ **正确做法**:蓝色和红色按钮在同一天、同一时间测试 + +**条件 3:只改变一个变量** +- 除了你要测试的东西(比如按钮颜色),其他所有条件都保持相同 +- 页面布局、文案、位置、功能...都一样 + +**为什么只改变一个?** + +假设你同时改了: +- 按钮颜色:蓝 → 红 +- 按钮文案:"立即购买" → "马上抢购" +- 按钮大小:小 → 大 + +如果结果变好了,你不知道是哪个改动起了作用。如果变差了,你也不知道哪个改坏了。 + +✅ **正确做法**:一次只测试一个变化 + +**条件 4:定义清晰的指标** +- 实验前明确要测量什么 +- 不能"到时候看情况" + +**常见的指标类型**: +- **转化率**:完成购买的比例 +- **点击率**:点击按钮的比例 +- **注册率**:完成注册的比例 +- **留存率**:第二天还回来的比例 + +### 1.3 A/B 测试不是万能的 + +A/B 测试适合的场景: +- ✅ 测试页面设计(颜色、布局、文案) +- ✅ 测试功能流程(注册步骤、结算流程) +- ✅ 测试推荐算法(商品推荐、内容推荐) +- ✅ 测试价格策略(折扣、促销) + +A/B 测试不适合的场景: +- ❌ 长期战略调整(品牌定位、市场方向) +- ❌ 重大产品创新(全新功能、全新业务) +- ❌ 需要很长时间才能看到效果的改变(SEO、品牌认知) + +::: tip 💡 判断是否需要 A/B 测试 +**问自己三个问题**: + +1. **能快速看到结果吗?**(几周内,不是几个月) +2. **能随机分配用户吗?**(不会严重影响用户体验) +3. **能量化指标吗?**(有明确的数字可以比较) + +如果三个答案都是"是",那就可以考虑 A/B 测试。 +::: + +### 1.4 A/B 测试 vs 其他"对比方法" + +| 对比方法 | 做法描述 | 问题 | +| :--- | :--- | :--- | +| **直觉判断** | "我觉得红色更好" | 你的直觉不代表用户想法 | +| **竞品模仿** | "竞品这么做,我们也这么做" | 产品不同,用户不同,结果可能不同 | +| **时间先后对比** | "改完后,数据变好了" | 可能是其他因素(促销、季节)导致的 | +| **部分用户对比** | "移动端用新设计,桌面端用旧设计" | 移动端和桌面端用户本身行为就不同 | +| **A/B 测试** | "同时、随机、相同条件对比" | 排除其他因素,结果是可靠的 | + +### 1.5 用医学试验来类比 + +### 1.6 用医学试验来类比 | 医学试验 | A/B 测试 | 说明 | | :--- | :--- | :--- | @@ -56,7 +307,7 @@ **关键原则**:除了版本不同,其他所有条件必须相同(随机分配、同时运行、相同用户群)。 -### 1.2 A/B 测试的完整流程 +### 1.7 A/B 测试的完整流程 ``` 1. 提出假设 @@ -98,6 +349,173 @@ ## 3. 实验设计:对照组与实验组 +### 3.0 从一个完整的例子开始 + +**场景:你的公司要测试一个新的"购物车按钮"** + +**当前情况**: +- 购物车按钮是蓝色的,文案是"立即购买" +- 放置在页面右下角 +- 当前转化率是 5%(100 个访客,5 个购买) + +**你的想法**: +- 如果把按钮改成红色,更醒目,转化率可能会提升 +- 如果把按钮放得更大,更容易点击,转化率可能会提升 + +**问题**:怎么知道这些想法是否有效? + +--- + +**步骤 1:明确你要测试什么** + +你需要先明确: +- **测试变量**:你要改变什么? + - 选项 A:按钮颜色(蓝 → 红) + - 选项 B:按钮大小(小 → 大) + - 选项 C:按钮位置(右下 → 左下) + +**重要**:一次只测试一个变量! + +假设你决定测试**按钮颜色**。 + +--- + +**步骤 2:明确成功指标** + +你怎么知道"红色更好"? + +- **指标 1**:点击率(点击按钮的人数 / 访问人数) +- **指标 2**:转化率(购买人数 / 访问人数) +- **指标 3**:收入(总销售额) + +**建议**:选择最能反映业务目标的指标。 + +对于购物车按钮,**转化率**是最直接的指标。 + +--- + +**步骤 3:设计实验版本** + +**A 组(对照组)**: +- 蓝色按钮 +- 文案"立即购买" +- 位置右下角 +- 大小正常 + +**B 组(实验组)**: +- **红色按钮**(这是唯一的变化!) +- 文案"立即购买"(相同) +- 位置右下角(相同) +- 大小正常(相同) + +**关键原则**:只有按钮颜色不同,其他所有条件都相同。 + +--- + +**步骤 4:确定流量分配** + +**选项 1**:50/50 分配 +- 50% 用户看到 A 组 +- 50% 用户看到 B 组 + +**选项 2**:90/10 分配 +- 90% 用户看到 A 组(安全版本) +- 10% 用户看到 B 组(实验版本) + +**建议**: +- 如果对新版本有信心,用 50/50(最快得到结果) +- 如果担心新版本有风险,用 90/10(更保守) + +假设你选择 **50/50 分配**。 + +--- + +**步骤 5:计算所需样本量** + +你需要回答几个问题: +- **当前转化率**:5% +- **期望检测的提升**:20%(从 5% 提升到 6%) +- **显著性水平**:0.05(95% 置信度) +- **统计功效**:80% + +使用在线计算器,得出: +- **每组需要约 6,000 个样本** +- **两组共需要 12,000 个样本** + +如果你的网站每天有 3,000 个访客,大约需要 **4 天**。 + +--- + +**步骤 6:确定运行时长** + +**最少运行时间**:达到样本量所需时间(4 天) + +**建议运行时间**:至少 2 个完整业务周期(通常 2 周) + +**为什么?** +- 覆盖工作日和周末(用户行为不同) +- 覆盖月初和月末(消费习惯不同) +- 避免"新奇效应"(用户因好奇产生的短期行为) + +假设你决定运行 **2 周**。 + +--- + +**步骤 7:制定决策规则** + +在实验开始前,明确决策规则: + +**如果 B 组显著更好(提升 > 20%,p < 0.05)**: +- 全量上线红色按钮 + +**如果 B 组显著更差(降低 > 10%,p < 0.05)**: +- 放弃红色按钮,保持蓝色按钮 + +**如果结果不显著(p ≥ 0.05)**: +- 转化率差异不大(在 ±10% 以内) +- 保持蓝色按钮,尝试其他优化方向 + +--- + +**步骤 8:准备监控和回滚** + +**监控指标**: +- 每天检查转化率 +- 检查是否有异常(如转化率暴跌) +- 检查用户投诉 + +**回滚计划**: +- 如果发现严重问题,立即停止实验 +- 准备快速切换回蓝色按钮 + +--- + +**步骤 9:运行实验** + +- 开发并部署实验代码 +- 确保随机分配正常工作 +- 开始收集数据 +- 不要中途停止(除非有严重问题) + +--- + +**步骤 10:分析结果** + +2 周后: +- A 组:6,000 人,300 人购买(5.0%) +- B 组:6,000 人,330 人购买(5.5%) +- 相对提升:+10% +- P 值:0.12(不显著) +- 95% CI:[-2%, +22%] + +**决策**:结果不显著,置信区间包含 0。保持蓝色按钮。 + +--- + +**这就是完整的实验设计流程!** + +现在,让我们深入了解每个环节... + ### 3.1 随机分配的重要性 **正确的做法**:每个用户有 50% 概率进入 A 组,50% 概率进入 B 组 @@ -127,12 +545,61 @@ ### 4.1 为什么不能"看着办"? -样本量太小 → 统计功效不足,即使有真实差异也检测不出来(假阴性) -样本量太大 → 浪费资源,运行时间过长 +**问题:样本量太小时会发生什么?** -**正确做法**:实验前计算所需样本量。 +假设你想测试新按钮是否能提升转化率,真实情况是新按钮确实能提升 5%。 -### 4.2 影响样本量的四个因素 +**但你只测试了 100 个用户**: +- A 组:100 人,5 人点击(5%) +- B 组:100 人,6 人点击(6%) +- P 值:0.75(不显著) + +**你会得出错误结论**:"新按钮没有效果" + +**真相**:新按钮确实有效,但因为样本量太小,统计功效不足,无法检测出这个差异。 + +这就是**假阴性**(第二类错误)。 + +--- + +**问题:样本量太大时会有什么问题?** + +如果你测试了 100 万个用户: +- 花费 3 个月时间才收集够数据 +- 占用了大量服务器资源 +- 延迟了其他实验的进度 + +而且,你检测到的是 0.01% 的微小提升,即使真实存在,也没有业务价值。 + +**样本量太小的风险**:浪费机会(错过真实改进) +**样本量太大的风险**:浪费时间(运行太久,资源浪费) + +**正确做法**:实验前计算所需样本量,在"能够检测到有意义的提升"和"合理的实验时间"之间取得平衡。 + +### 4.2 用投票来理解样本量 + +**场景:你想预测选举结果** + +假设有 1000 万选民,你想预测候选人 A 和 B 谁会赢。 + +**问题:你需要调查多少人?** + +- 调查 10 个人?太少,不靠谱 +- 调查 100 个人?还是太少 +- 调查 1,000 个人?可以,误差约 ±3% +- 调查 10,000 个人?更好,误差约 ±1% +- 调查 1,000,000 个人?没必要,误差只比 1,000 人好一点点 + +**关键发现**: +- 样本量从 10 增加到 1,000 → 准确度大幅提升 +- 样本量从 1,000 增加到 1,000,000 → 准确度提升很小 + +**A/B 测试也是一样的道理**: +- 不是样本越多越好 +- 而是"足够"就好 +- "足够"取决于你想检测多小的差异 + +### 4.3 影响样本量的四个因素 | 因素 | 影响 | 典型值 | | :--- | :--- | :--- | @@ -145,7 +612,143 @@ - 你想检测的差异越小(比如只提升 5%),就需要更多样本才能"看清" - 你想要的结果越确定(比如 99% 置信度 vs 95%),就需要更多证据 -### 4.3 样本量计算公式 +#### 因素 1:基准转化率 + +**问题**:为什么基准转化率越高,需要的样本越少? + +想象两个场景: +- **场景 A**:基准转化率 50%(掷硬币,50% 正面) +- **场景 B**:基准转化率 1%(罕见事件,只有 1% 的人会购买) + +**在场景 A**: +- 你掷 100 次硬币,正面会出现 50 次左右 +- 即使有波动,也在 40-60 次之间 +- 你不需要太多样本就能"确定"正面率 + +**在场景 B**: +- 你测试 100 个用户,可能只有 1 个人购买 +- 如果运气好,可能 3 个人购买(3 倍差异!) +- 如果运气不好,可能 0 个人购买 +- 你需要大量样本才能"确定"真实转化率 + +**结论**:转化率越低,随机波动越大,需要更多样本来"降噪"。 + +--- + +#### 因素 2:最小检测提升 + +**这是最重要的因素!** + +**问题**:你想检测多小的差异? + +- **检测 20% 的提升**(从 5% 到 6%):需要约 6,000 样本/组 +- **检测 5% 的提升**(从 5% 到 5.25%):需要约 100,000 样本/组 + +**差距巨大!** + +**为什么?** + +想象你在测量两根头发的长度差异: +- 差异 1 毫米:用普通尺子就能看出来 +- 差异 0.001 毫米:需要显微镜才能看出来 + +统计检验也是一样: +- 大差异(提升 20%):"普通尺子"(小样本)就能看出来 +- 小差异(提升 5%):"显微镜"(大样本)才能看出来 + +::: tip 💡 如何选择"最小检测提升"? +**问自己三个问题**: + +1. **业务上什么提升是有意义的?** + - 提升 1%:太小,没意义 + - 提升 5%:还行,可以接受 + - 提升 20%:很好,值得做 + +2. **你能等待多久?** + - 检测 5% 提升 → 需要 1 个月 + - 检测 20% 提升 → 只需要 1 周 + - 时间紧迫?选择较大的提升目标 + +3. **有多少流量?** + - 日活 1,000 → 无法检测小提升 + - 日活 100,000 → 可以检测小提升 + +**建议**:选择"最小有意义的提升"作为目标,不要贪心求小。 +::: + +--- + +#### 因素 3:显著性水平 (α) + +**什么是显著性水平?** + +显著性水平(α)是你能容忍的"假阳性"概率。 + +- **α = 0.05**:容忍 5% 的假阳性(95% 置信度) +- **α = 0.01**:容忍 1% 的假阳性(99% 置信度) + +**假阳性是什么?** + +假阳性 = "说有效,实际无效" + +例子: +- 新按钮其实没有效果(真实转化率都是 5%) +- 但因为随机波动,B 组恰好表现更好(5% vs 6%) +- 你错误地认为"新按钮有效",并全量上线 +- 上线后,发现转化率并没有提升 + +**这就是假阳性**! + +**为什么 α 越小,样本量越大?** + +想象你在做"无罪推定": +- α = 0.05:需要"比较确凿的证据"才能定罪 +- α = 0.01:需要"非常确凿的证据"才能定罪 + +"非常确凿"需要更多证据(更大样本量)。 + +**建议**: +- 大多数情况用 α = 0.05(行业标准) +- 如果是高风险决策(如价格策略),可以用 α = 0.01 + +--- + +#### 因素 4:统计功效 (1-β) + +**什么是统计功效?** + +统计功效(1-β)是检测到真实效应的概率。 + +- **功效 = 80%**:如果有真实差异,有 80% 的概率能检测出来 +- **功效 = 90%**:如果有真实差异,有 90% 的概率能检测出来 + +**假阴性是什么?** + +假阴性 = "说无效,实际有效" + +例子: +- 新按钮确实有效(真实转化率从 5% 提升到 6%) +- 但因为样本量太小,检验结果显示"不显著"(p = 0.15) +- 你错误地认为"新按钮无效",放弃了它 +- 你错过了一个提升 20% 的机会! + +**这就是假阴性**! + +**为什么功效越大,样本量越大?** + +想象你在安检: +- 功效 80%:能检测出 80% 的危险品(漏掉 20%) +- 功效 90%:能检测出 90% 的危险品(漏掉 10%) + +检测率越高,需要更灵敏的设备(更大样本量)。 + +**建议**: +- 大多数情况用功效 80%(行业标准) +- 如果样本量充足,可以用 90%(更保险) + +--- + +### 4.4 样本量计算公式 对于比例指标(转化率),简化的样本量公式为: @@ -187,33 +790,370 @@ n ≈ 6,932 ## 5. 统计显著性:如何判断结果"可信"? +### 5.0 为什么需要"统计显著性"? + +**场景:你做了一个 A/B 测试** + +``` +A 组(蓝色按钮):1000 个用户,50 个点击 → 点击率 5% +B 组(红色按钮):1000 个用户,60 个点击 → 点击率 6% +``` + +**看起来 B 更好,对吧?** + +但是... + +**问题:这 10 个点击的差异,是真实的,还是巧合?** + +想象一下: +- 也许分配到 B 组的用户,今天心情特别好,更爱点击 +- 也许 B 组恰好多了几个"重度点击用户" +- 也许这只是随机波动,下次再测试 A 组反而更好 + +**这就是"统计显著性"要解决的问题**: +- 如何判断这个差异是"真实的",而不是"随机的"? +- 多大的差异才能相信是真实的? + +::: tip 💡 统计显著性的本质 +**统计显著性 = 差异不太可能是随机产生的** + +就像抛硬币: +- 抛 10 次,出现 7 次正面 → 可能是巧合 +- 抛 100 次,出现 70 次正面 → 这枚硬币可能有问题 + +差异越大、样本越多,越不可能是巧合。 +::: + +--- + ### 5.1 P 值是什么? -**P 值**:如果两个版本真的没有差异(零假设为真),观察到当前数据(或更极端数据)的概率。 +#### 5.1.1 用抛硬币来理解 P 值 -**通俗理解**:P 值越小,说明"纯属巧合"的可能性越小。 +**问题:这枚硬币是公平的吗?** + +假设你有一枚硬币,你想知道它是不是公平的(正面和反面的概率各 50%)。 + +**实验 1:抛 10 次** +- 结果:7 次正面,3 次反面(正面率 70%) + +你会想:"这枚硬币可能偏向正面?" + +但是,即使硬币是公平的,抛 10 次出现 7 次或更多正面的概率大约是 **17%**。 + +**P 值 = 17%** + +意思是:如果硬币是公平的,有 17% 的概率会看到这种结果(或更极端)。 + +**结论**:P 值太大,无法确定硬币不公平。7 次正面可能是随机波动。 + +--- + +**实验 2:抛 100 次** +- 结果:70 次正面,30 次反面(正面率 70%) + +现在的差异还是 70%,但样本量大多了! + +如果硬币是公平的,抛 100 次出现 70 次或更多正面的概率大约是 **0.00004%**。 + +**P 值 < 0.0001%** + +意思是:如果硬币是公平的,几乎不可能看到这种结果。 + +**结论**:P 值非常小,我们可以很有信心地说:这枚硬币确实不公平! + +--- + +#### 5.1.2 P 值的正式定义 + +**P 值**:如果零假设(两组没有差异)为真,观察到当前数据(或更极端数据)的概率。 + +**用通俗语言说**: +- P 值 = "纯属巧合"的概率 +- P 值 = "随机波动"的可能性 +- P 值 = "即使版本没有差异,也会看到这种结果"的概率 + +#### 5.1.3 P 值的判断标准 **常用阈值**: -- `p < 0.05`:统计显著,有 95% 的信心说差异不是随机的 -- `p < 0.01`:高度显著,有 99% 的信心 -- `p ≥ 0.05`:不显著,差异可能是随机波动 -### 5.2 置信区间 +| P 值范围 | 结论 | 解释 | +| :--- | :--- | :--- | +| **p < 0.01** | 高度显著 | 只有 1% 的概率是巧合(99% 信心) | +| **p < 0.05** | 统计显著 | 只有 5% 的概率是巧合(95% 信心) | +| **p ≥ 0.05** | 不显著 | 可能是随机波动,无法确定差异真实 | -**置信区间**:真实差异可能落入的范围。 +**为什么是 0.05?** -示例: -- 相对提升:+15% -- 95% 置信区间:[+5%, +25%] +这是科学界的"传统标准"(可以理解为行业约定俗成): +- 意思是:我们容忍 5% 的"假阳性"风险 +- 也就是说,即使两个版本真的没有差异,我们也有 5% 的概率错误地认为"有差异" -**解读**:我们有 95% 的信心认为,真实提升在 5% 到 25% 之间。 +::: tip 💡 如何记忆 P 值 +**P 值越小,越可信** + +- p = 0.30 → 有 30% 概率是巧合 → 不可信 +- p = 0.10 → 有 10% 概率是巧合 → 不太可信 +- p = 0.05 → 只有 5% 概率是巧合 → 勉强可信 +- p = 0.01 → 只有 1% 概率是巧合 → 很可信 +- p < 0.001 → 几乎不可能是巧合 → 非常可信 +::: + +#### 5.1.4 P 值不是什么? + +::: warning ⚠️ 常见的 P 值误解 + +**误解 1:"P 值越小,差异越大"** + +错误!P 值只告诉你"差异是否真实",不告诉你"差异有多大"。 + +例子: +- 差异 0.1%,样本 100 万 → p < 0.001(显著,但差异很小) +- 差异 50%,样本 10 → p = 0.15(不显著,但差异很大) + +**正确理解**:P 值衡量的是"信心",不是"效果大小"。 + +--- + +**误解 2:"P < 0.05 就是正确的,P ≥ 0.05 就是错误的"** + +错误!0.05 只是一个人为设定的阈值,不是魔法分界线。 + +- p = 0.049 和 p = 0.051 几乎没有区别 +- 但一个是"显著",一个是"不显著" + +**正确理解**:P 值是一个连续的"信心程度",不要过度依赖 0.05 这个分界线。 + +--- + +**误解 3:"P < 0.05 意味着有 95% 的概率 B 版本更好"** + +错误!P 值不是"B 版本更好的概率"。 + +P 值说的是:如果两个版本真的没有差异,看到这种结果的概率。 + +**正确理解**:P 值是对"零假设"的检验,不是直接给出"B 更好的概率"。 + +--- + +**误解 4:"P ≥ 0.05 就证明两个版本没有差异"** + +错误!"不显著"只意味着"无法确定差异存在",不等于"证明差异不存在"。 + +可能的原因: +- 样本量太小(没收集足够数据) +- 差异太小(需要更多样本才能检测到) +- 确实没有差异 + +**正确理解**:"不显著" = "证据不足",不是"证明无效"。 +::: + +#### 5.1.5 P 值是如何计算的? + +**不需要记住公式,但要理解思路** + +假设 A/B 测试的结果: +- A 组:1000 人,50 人点击(5%) +- B 组:1000 人,60 人点击(6%) + +**P 值的计算思路**: + +1. **假设零假设成立**:A 和 B 真的没有差异(真实转化率都是 5%) +2. **计算"标准误"**:在 1000 个样本下,转化率会自然波动多少 +3. **计算 Z 值**:观察到的差异(1%)是标准误的多少倍 +4. **查表得出 P 值**:这个 Z 值对应的概率 + +**简化版计算(不严谨,但帮助理解)**: + +``` +标准误 ≈ √[p×(1-p)/n] = √[0.05×0.95/1000] ≈ 0.7% + +观察差异 = 6% - 5% = 1% + +Z 值 = 观察差异 / 标准误 = 1% / 0.7% ≈ 1.43 + +查表:Z = 1.43 → P ≈ 0.15 +``` + +**结论**:P = 0.15 > 0.05,不显著,这个 1% 的差异可能是随机波动。 + +::: tip 💡 实际应用 +**你不需要手动计算 P 值!** + +使用在线工具: +- [AB TestGuide 计算器](https://abtestguide.com/calc/) +- [Evan Miller 计算器](https://www.evanmiller.org/ab-testing/sample-size.html) + +你需要做的是: +1. 输入 A 组和 B 组的样本量、转化数 +2. 工具自动计算 P 值 +3. 根据 P 值判断是否显著 +::: + +--- + +### 5.2 置信区间:估计真实的提升范围 + +#### 5.2.1 为什么要用"区间"而不是"点估计"? + +**场景:你做了一次 A/B 测试** + +结果: +- B 版本比 A 版本提升了 10% + +**问题**:这个 10% 是"准确的"吗? + +答案是:**不准确** + +为什么?因为你只测试了一部分用户,不是全部用户。如果你: +- 换一批用户再测试一次 → 可能是 8% +- 下个月再测试一次 → 可能是 12% +- 明年再测试一次 → 可能是 5% + +**每次测试的结果都会略有不同**(这就是"抽样波动") + +**所以,我们不应该给出一个精确的数字(点估计),而应该给出一个范围(区间估计)**。 + +::: tip 💡 区间估计 vs 点估计 +**点估计**:"提升 10%"(太精确,不可信) + +**区间估计**:"提升在 5% 到 15% 之间"(更诚实,更符合实际) + +就像你问朋友"从你家到公司要多久?" + +**不精确但诚实**:"大概 30 到 40 分钟吧,看路况" +**精确但不现实**:"准确需要 34 分 27 秒" +::: + +#### 5.2.2 置信区间是什么? + +**置信区间**:真实值可能落入的范围。 + +**示例**: +- 你观察到 B 版本比 A 版本提升了 10% +- 95% 置信区间:[+5%, +15%] + +**意思是**: +- 我们有 95% 的信心,真实提升在 5% 到 15% 之间 +- 真实提升低于 5% 或高于 15% 的概率,只有 5% + +#### 5.2.3 理解"95% 置信" ::: warning ⚠️ 常见误解 -置信区间不是"有 95% 的概率在这个区间内",而是"如果我们重复实验 100 次,95 次的区间会包含真实值"。 +**错误理解**:"真实值有 95% 的概率在区间内" -对于单次实验,真实值要么在区间内,要么不在。但我们不知道是哪种情况。 +**正确理解**:"如果我们重复实验 100 次,95 次的区间会包含真实值" + +对于单次实验: +- 真实值要么在区间内,要么不在 +- 我们不知道是哪种情况 +- 但我们有 95% 的信心,这个方法是可靠的 ::: +**用类比来理解**: + +想象你是一个射击运动员,你射了 100 枪: + +- 95 枪都打在靶子上("置信区间"覆盖了"真实目标") +- 5 枪脱靶了("置信区间"没有覆盖"真实目标") + +每次射击前,你都有 95% 的信心会打中靶子。 + +但你射出一枪后,这一枪要么中,要么不中。你不能说"这一枪有 95% 的概率中靶"。 + +置信区间也是一样:每次实验都有 95% 的可靠性,但对于单次实验,真实值要么在区间内,要么不在。 + +#### 5.2.4 置信区间的可视化 + +**场景 1:结果显著** + +``` +A 组:5% +B 组:6% +相对提升:+20% +95% CI:[+10%, +30%] + 0% ─────┼──────────────────┼──── 40% + ↑ ↑ + 下限(+10%) 上限(+30%) + ✅ 整个区间都 > 0,结果显著 +``` + +**解读**:我们有 95% 的信心,真实提升在 10% 到 30% 之间。即使是最保守估计(10%),也是正提升。 + +--- + +**场景 2:结果不显著** + +``` +A 组:5% +B 组:5.5% +相对提升:+10% +95% CI:[-5%, +25%] + -5% ─┼────┼──────────────────┼──── 30% + ↑ ↑ + 0% 上限(+25%) + ⚠️ 区间包含 0,结果不显著 +``` + +**解读**:我们有 95% 的信心,真实提升在 -5% 到 +25% 之间。 +- 可能是正提升(最高 +25%) +- 可能是负提升(最低 -5%,即 B 更差) +- 无法确定方向,所以"不显著" + +#### 5.2.5 置信区间与 P 值的关系 + +**重要结论**: +- 如果 95% 置信区间**不包含 0** → P < 0.05(显著) +- 如果 95% 置信区间**包含 0** → P ≥ 0.05(不显著) + +**记忆技巧**: +- 区间完全在 0 的右边(都是正数)→ B 更好 +- 区间完全在 0 的左边(都是负数)→ A 更好 +- 区间跨越 0(有正有负)→ 不确定 + +#### 5.2.6 置信区间的宽度:什么决定了宽度? + +**观察**: +- 样本量越大 → 区间越窄(越精确) +- 转化率越高 → 区间越窄(越精确) +- 置信度越高 → 区间越宽(更保守) + +**例子**: + +``` +场景 1:样本小,区间宽 +A 组:100 人,5 人点击(5%) +B 组:100 人,6 人点击(6%) +95% CI:[-8%, +28%](很宽,不确定) + +场景 2:样本大,区间窄 +A 组:10,000 人,500 人点击(5%) +B 组:10,000 人,600 人点击(6%) +95% CI:[+10%, +30%](较窄,较确定) +``` + +::: tip 💡 置信区间的实际应用 +**看到置信区间后,你应该想**: + +1. **区间是否包含 0?** + - 不包含 → 结果显著,可以做决策 + - 包含 → 结果不显著,需要更多数据 + +2. **区间有多宽?** + - 很宽 → 不确定,需要更多样本 + - 很窄 → 很确定,可以信任结果 + +3. **下限是多少?** + - 即使是最保守估计,效果如何? + - 如果下限已经满足业务需求,可以上线 + +4. **上限是多少?** + - 最好的情况下能提升多少? + - 帮助评估"潜在收益" +::: + +--- + ### 5.3 A/B 组结果对比演示 👇 **动手试试看**:调整转化率和样本量,观察统计显著性的变化: @@ -231,107 +1171,969 @@ n ≈ 6,932 ### 6.1 过早停止实验(Peeking 问题) -**问题**:看到结果"显著"就立即停止实验,不再继续观察。 +#### 6.1.1 一个真实的故事 -**真相**:P 值会随着数据积累而波动。你看到的"显著"可能只是暂时的随机波动。 +**场景:你的老板急于看到结果** -**Airbnb 的真实案例**: -- 第 7 天:p = 0.05,结果显著,B 版本领先 -- 第 14 天:p = 0.15,不再显著 -- 第 30 天:p = 0.42,完全中性 +你设计了一个 A/B 测试,测试新的首页设计: -如果第 7 天就停止,就会得出错误的结论。 +**实验计划**: +- 需要运行 2 周 +- 每组需要 10,000 个样本 +- 目标:检测 10% 的提升 -**解决方案**: -- 实验前计算所需样本量,达到后才能分析 -- 使用序贯检验(Sequential Testing),预设"窥探"点和调整后的显著性阈值 +**第 3 天(早上)**: +- A 组:1,000 人,50 人点击(5%) +- B 组:1,000 人,70 人点击(7%) +- P 值:0.04(显著!) -### 6.2 辛普森悖论 +你的老板看到数据,兴奋地说:"B 版本领先了 40%!而且已经显著了!我们不用等 2 周了,今天就全量上线 B 版本吧!" -**问题**:分组看 B 更差,但合并后 B 反而更好(或相反)。 - -**根本原因**:混淆变量(Confounding Variable)分布不均。 - -**示例**: -``` -移动端(占 80% 流量): -- A 组:8% 转化率(分配了 40% 流量) -- B 组:6% 转化率(分配了 40% 流量) - -桌面端(占 20% 流量): -- A 组:4% 转化率(分配了 10% 流量) -- B 组:3% 转化率(分配了 10% 流量) - -合并数据: -- A 组:(8%×0.4 + 4%×0.1) / 0.5 = 7.2% -- B 组:(6%×0.4 + 3%×0.1) / 0.5 = 5.4% - -但如果是这样分配: -移动端:A 组 40% 流量,B 组 40% 流量 -桌面端:A 组 0% 流量,B 组 20% 流量(不平衡!) - -合并数据: -- A 组:(8%×0.4) / 0.4 = 8% -- B 组:(6%×0.4 + 3%×0.2) / 0.6 = 5% -但如果不加权,直接平均:A=5.6%, B=7.3%(B 反而赢了!) -``` - -**解决方案**: -- 确保随机化正确,每个子群体流量分配一致 -- 按关键维度(设备、流量来源、用户类型)分别分析 -- 使用 A/A 测试验证随机化是否有效 - -### 6.3 P-hacking(P 值操纵) - -**问题**:通过尝试不同方法,直到找到"显著"结果。 - -**常见形式**: -- **子群挖掘**:主指标不显著,就按年龄、地区、设备细分,宣称某个子群显著 -- **选择性报告**:同时测了 10 个指标,只报告显著的 1 个 -- **延长实验**:看到 p = 0.06,就再跑几天,"看看能不能降到 0.05 以下" - -**问题**:这些都会大幅增加假阳性率。 - -**解决方案**: -- 预先注册假设和指标,实验过程中不改变 -- 同时测试多个指标时,使用 Bonferroni 校正或 FDR(False Discovery Rate)控制 -- 严格控制"窥探"次数 - -### 6.4 新奇效应 - -**问题**:用户因好奇点击新功能,导致短期数据虚高。 - -**示例**: -- 新按钮上线首周:点击率 +30% -- 第二周:+15% -- 第三周:+5% -- 第四周:0%(甚至 -2%,新鲜感过后,用户发现并不好用) - -**解决方案**: -- 至少运行 2 个完整业务周期(通常 2-4 周) -- 观察趋势是否稳定,而不是只看绝对值 -- 对长期指标(如用户留存)的重视度高于短期指标(如点击率) - -### 6.5 统计功效不足 - -**问题**:样本量太小,即使有真实差异也检测不出来(假阴性)。 - -**示例**:预期提升 5%,但只跑了 1,000 个样本,结果显示"不显著",你就放弃了。 - -实际上,如果检测 5% 的提升,需要约 30,000 样本才能达到 80% 功效。 - -**解决方案**: -- 实验前必须计算样本量 -- 如果资源有限,可以考虑: - - 提高最小检测提升(比如从 5% 改为 10%) - - 降低统计功效(从 80% 降到 70%,但会增加假阴性风险) - - 延长测试时间 +**你犹豫了一下...但还是上线了。** --- -## 7. 实战案例 +**第 7 天**: +- A 组:3,000 人,150 人点击(5%) +- B 组:3,000 人,170 人点击(5.67%) +- P 值:0.35(不显著了) -### 7.1 案例 1:按钮颜色测试 +老板:"没关系,还是 B 更好,只是统计波动。" + +--- + +**第 14 天(原计划结束)**: +- A 组:10,000 人,500 人点击(5%) +- B 组:10,000 人,510 人点击(5.1%) +- P 值:0.80(完全不显著) +- 95% CI:[-6%, +8%] + +**真相揭晓**:两个版本几乎没有任何差异! + +如果你第 3 天就停止实验,你会错误地认为"B 版本提升 40%",并全量上线一个实际上没有效果的版本。 + +--- + +#### 6.1.2 为什么会发生这种情况? + +**P 值的波动性** + +想象你在抛硬币,判断它是否公平: + +- **前 10 次**:7 次正面(p = 0.17,看似不公平) +- **前 50 次**:30 次正面(p = 0.20,看似不公平) +- **前 100 次**:55 次正面(p = 0.37,看起来公平) + +如果你只抛 10 次就下结论,你会错误地认为"硬币不公平"。 + +A/B 测试也是一样: +- 数据少的时候,随机波动大,P 值不稳定 +- 数据多的时候,随机波动小,P 值才稳定 + +**过早停止 = 在随机波动中做决策** + +--- + +#### 6.1.3 正确的做法 + +**方案 1:预先设定样本量,严格达到后才分析** + +``` +步骤: +1. 实验前计算所需样本量(如 10,000 人/组) +2. 设置"检查点":只在第 14 天检查一次 +3. 达到样本量之前,不看数据,不做决策 +4. 第 14 天分析结果,做决策 +``` + +**优点**:简单、可靠、不会出错 +**缺点**:如果版本有严重问题,要等很久才能发现 + +--- + +**方案 2:序贯检验(Sequential Testing)** + +``` +步骤: +1. 预设多个"检查点"(如第 3、7、14 天) +2. 每个检查点使用"更严格"的显著性阈值 +3. 第 3 天:p < 0.01 才停止(更严格) +4. 第 7 天:p < 0.025 才停止 +5. 第 14 天:p < 0.05 才停止(正常) +``` + +**优点**:可以提前发现极端情况(好的或坏的) +**缺点**:计算复杂,需要专门工具 + +::: tip 💡 实践建议 +**如果你是初学者**:使用方案 1(简单可靠) +**如果你有经验**:使用方案 2(更灵活) + +无论用哪种方案,都不要"每天看数据,觉得好就停止"。 +::: + +--- + +### 6.2 辛普森悖论 + +#### 6.2.1 一个令人困惑的故事 + +**场景:你的数据分析结果互相矛盾** + +你的电商网站测试了一个新的"推荐算法": + +**整体数据**: +- A 组(旧算法):转化率 5.6% +- B 组(新算法):转化率 7.3% +- **结论**:B 版本更好!提升 30%! + +你很高兴,准备全量上线新算法。 + +--- + +**但是,你的同事提出了一个问题**:"我们能不能按设备类型分别看看数据?" + +**分开后的数据**: + +**移动端**: +- A 组:转化率 8% +- B 组:转化率 6% +- **结论**:A 版本更好! + +**桌面端**: +- A 组:转化率 4% +- B 组:转化率 3% +- **结论**:A 版本更好! + +**等等,发生了什么?** + +- 移动端:A 更好(8% > 6%) +- 桌面端:A 更好(4% > 3%) +- 合并后:B 更好(7.3% > 5.6%) + +**每个分组的赢家都是 A,但合并后赢家却是 B!** + +这就是**辛普森悖论**。 + +--- + +#### 6.2.2 为什么会发生这种情况? + +**问题出在"样本分布不均"** + +让我们看看真实的样本分布: + +**移动端**(80% 的流量): +- A 组:40% 流量(4,000 人,320 人购买 → 8%) +- B 组:40% 流量(4,000 人,240 人购买 → 6%) + +**桌面端**(20% 的流量): +- A 组:0% 流量(没有人!) +- B 组:20% 流量(1,000 人,30 人购买 → 3%) + +**合并后的数据**(错误计算): +- A 组:320 / 4,000 = 8% +- B 组:(240 + 30) / 5,000 = 5.4% + +但如果你直接平均(错误的做法): +- A 组:只有移动端数据,8% +- B 组:(6% + 3%) / 2 = 4.5%? + +不对,让我们重新计算正确的加权平均: + +**正确的加权平均**: +- A 组:8%(因为只有移动端) +- B 组:6% × 80% + 3% × 20% = 5.4% + +等等,A 更好才对(8% > 5.4%)! + +那为什么你之前看到 B 更好(7.3% > 5.6%)? + +**真相**:流量分配完全混乱了! + +实际上,如果随机分配正确,移动端和桌面端应该各有 50% 流量进入 A/B 组。 + +--- + +#### 6.2.3 正确的随机化应该是这样的 + +**移动端**(假设 8,000 人): +- A 组:4,000 人(50%),320 人购买(8%) +- B 组:4,000 人(50%),240 人购买(6%) + +**桌面端**(假设 2,000 人): +- A 组:1,000 人(50%),40 人购买(4%) +- B 组:1,000 人(50%),30 人购买(3%) + +**合并后的数据**(正确的加权平均): +- A 组:(320 + 40) / 5,000 = 7.2% +- B 组:(240 + 30) / 5,000 = 5.4% + +**结论**:A 更好(7.2% > 5.4%) + +移动端和桌面端的结论一致! + +--- + +#### 6.2.4 如何避免辛普森悖论? + +**方法 1:确保随机化正确** + +- 每个子群体(移动端、桌面端)内部,流量必须是 50/50 +- 不能出现"移动端全是 A 组,桌面端全是 B 组"的情况 + +**方法 2:按关键维度分别分析** + +- 不要只看整体数据 +- 分别看移动端、桌面端、新用户、老用户、不同地区... +- 确保每个分组的结论是一致的 + +**方法 3:使用 A/A 测试验证随机化** + +**A/A 测试**:两个组使用完全相同的版本,看是否有差异 + +**如果 A/A 测试显示"显著差异"** → 说明随机化有问题 +**如果 A/A 测试显示"无显著差异"** → 说明随机化正常 + +::: tip 💡 实践建议 +**每次 A/B 测试前,先问自己**: + +1. 我的关键维度是什么?(移动端/桌面端?新用户/老用户?) +2. 每个维度内部的流量分配是否均匀? +3. 如果按维度分组,结论是否一致? + +如果答案不确定,先做 A/A 测试验证随机化。 +::: + +--- + +### 6.3 P-hacking(P 值操纵) + +#### 6.3.1 一个"只要我想,就能显著"的故事 + +**场景:你想发表一个"成功"的实验结果** + +你的公司鼓励"数据驱动决策",但你的实验结果总是"不显著"。 + +压力之下,你想:"有没有办法让结果变显著?" + +--- + +**尝试 1:主指标不显著,那就换个指标** + +**原始计划**: +- 主指标:购买转化率 +- 结果:A 组 5%,B 组 5.1%,p = 0.60(不显著) + +**你试了其他指标**: +- 点击率:A 组 10%,B 组 11%,p = 0.15(还是不显著) +- 注册率:A 组 8%,B 组 9%,p = 0.20(不显著) +- 页面停留时间:A 组 30 秒,B 组 32 秒,p = 0.08(接近了!) +- 添加购物车率:A 组 15%,B 组 17%,p = 0.03(显著了!) + +**你报告说**:"新版本提升了添加购物车率,我们应该上线!" + +**问题**:你测了 5 个指标,即使全部无效,也有 1 - 0.95^5 = 23% 的概率至少有一个"假阳性"。 + +--- + +**尝试 2:主指标不显著,那就细分用户群** + +**原始数据**(整体): +- A 组:5% +- B 组:5.1% +- p = 0.60(不显著) + +**你开始细分**: +- 新用户:A 组 4%,B 组 4.5%,p = 0.25 +- 老用户:A 组 6%,B 组 6.2%,p = 0.40 +- 移动端:A 组 5%,B 组 5.3%,p = 0.30 +- 桌面端:A 组 5%,B 组 5.1%,p = 0.50 +- 18-25 岁:A 组 4.5%,B 组 5.5%,p = 0.04(显著了!) + +**你报告说**:"虽然整体不显著,但对年轻用户效果显著,我们应该针对这个人群上线!" + +**问题**:你测了 10 个子群体,即使全部无效,也有 40% 的概率至少有一个"假阳性"。 + +--- + +**尝试 3:P 值接近 0.05,那就再跑几天** + +**第 14 天**: +- A 组:5% +- B 组:5.2% +- p = 0.08(接近显著了!) + +你:"再跑几天,看看能不能降到 0.05 以下。" + +**第 16 天**: +- A 组:5% +- B 组:5.25% +- p = 0.06(更接近了!) + +你:"再跑一天!" + +**第 17 天**: +- A 组:5% +- B 组:5.3% +- p = 0.048(终于显著了!) + +你:"成功了!我们可以上线了!" + +**问题**:如果你一直跑下去,P 值迟早会低于 0.05(纯随机波动),但这不代表真实差异。 + +--- + +#### 6.3.2 为什么 P-hacking 是危险的? + +**故事结局**: + +你的"成功"实验上线了。一个月后: +- 转化率没有提升(因为本来就没有真实效果) +- 开发团队浪费了一个月时间 +- 其他更有价值的实验被延迟 + +你失去了团队的信任,以后再也没有人相信你的"数据驱动决策"。 + +--- + +#### 6.3.3 如何避免 P-hacking? + +**原则 1:预先注册假设和指标** + +**实验开始前,写下**: +- 主指标是什么?(只能有 1-2 个) +- 预期结果是什么?(提升多少?) +- 如果不显著,怎么办?(放弃、迭代、还是延长?) + +**实验过程中,不允许改变**! + +--- + +**原则 2:控制多重检验** + +**如果你必须测多个指标或子群体**,使用更严格的阈值: + +- **Bonferroni 校正**:α = 0.05 / 指标数量 + - 测 5 个指标 → α = 0.01 + - 测 10 个指标 → α = 0.005 + - 只有 p < 0.005 才算显著 + +- **FDR 控制**:允许一定比例的假阳性,但总体控制误报率 + +--- + +**原则 3:诚实报告所有结果** + +**不要只报告显著的指标**! + +**诚实报告示例**: +``` +我们测试了新版本对 5 个指标的影响: + +1. 添加购物车率:+13% (p = 0.03) ✅ 显著 +2. 购买转化率:+2% (p = 0.60) ❌ 不显著 +3. 点击率:+10% (p = 0.15) ❌ 不显著 +4. 注册率:+12% (p = 0.20) ❌ 不显著 +5. 页面停留时间:+7% (p = 0.08) ❌ 不显著 + +虽然只有"添加购物车率"显著,但其他关键指标(购买转化率) +没有提升,甚至有下降趋势。建议谨慎上线,或延长观察时间。 +``` + +::: tip 💡 实践建议 +**问自己**: + +"如果所有结果都不显著,我会怎么做?" + +如果答案是"我会放弃",那就不要在结果出来后改变主意,挑选"显著"的指标。 + +**诚实 > 成功** +::: + +--- + +### 6.4 新奇效应 + +#### 6.4.1 一个"昙花一现"的故事 + +**场景:新功能上线,数据暴涨!** + +你的公司发布了一个新功能:"智能推荐"。 + +**第 1 周数据**: +- 点击率:+30% +- 使用率:+50% +- 用户反馈:"太棒了!" + +你高兴极了,准备全量推广并写成功案例。 + +--- + +**第 2 周数据**: +- 点击率:+15% +- 使用率:+20% + +你:"可能是波动,继续观察。" + +--- + +**第 3 周数据**: +- 点击率:+5% +- 使用率:+5% + +你:"怎么突然降了?" + +--- + +**第 4 周数据**: +- 点击率:0% +- 使用率:-2%(比上线前还差!) +- 用户反馈:"其实不太好用..." + +**真相**:前两周的"暴涨"只是新奇效应,用户因为好奇多点了几次。新鲜感过后,真实使用习惯暴露无遗。 + +如果你第 1 周就全量推广并写成功案例,现在会非常尴尬。 + +--- + +#### 6.4.2 为什么会有新奇效应? + +**人类的好奇心** + +想象你走进一家新餐厅: +- **第 1 次**:你点了招牌菜,觉得很好吃(新奇感) +- **第 3 次**:你点了其他菜,发现也就那样(新奇感消退) +- **第 10 次**:你开始觉得"其实家常菜更好吃"(真实偏好) + +A/B 测试也是一样: +- **短期**:用户因好奇点击新功能(虚高) +- **长期**:用户发现新功能并不符合真实需求(回归真实) + +--- + +#### 6.4.3 哪些情况最容易产生新奇效应? + +**高风险场景**: +- ✅ 全新的 UI 设计(用户可能因为"不一样"而点击) +- ✅ 新功能(用户好奇"这是什么") +- ✅ 新的交互方式(用户想"试试看") +- ✅ 显眼的位置变化(用户"被迫注意到") + +**低风险场景**: +- ❌ 后端算法优化(用户感知不到) +- ❌ 性能优化(加载速度变快,用户不会因为"新奇"而点击) +- ❌ 文案微调(影响较小) + +--- + +#### 6.4.4 如何避免新奇效应? + +**方法 1:至少运行 2 个完整业务周期** + +- 如果业务有"周末效应"(周末活跃度高),至少运行 2 周 +- 如果业务有"月初月末效应"(月初消费高),至少运行 1 个月 +- 如果业务有"季节效应"(双 11、春节),避开这些时段 + +**经验法则**:至少 2-4 周,覆盖 2 个完整周期。 + +--- + +**方法 2:观察趋势,而不是绝对值** + +**好的趋势**: +``` +第 1 周:+10% +第 2 周:+12% +第 3 周:+11% +第 4 周:+13% +→ 稳定,可信 +``` + +**不好的趋势**: +``` +第 1 周:+30% +第 2 周:+15% +第 3 周:+5% +第 4 周:0% +→ 新奇效应,不可信 +``` + +--- + +**方法 3:重视长期指标,轻视短期指标** + +| 指标类型 | 示例 | 受新奇效应影响 | +| :--- | :--- | :--- | +| **短期指标** | 点击率、使用率 | ⚠️ 高 | +| **中期指标** | 转化率、留存率 | ⚡ 中 | +| **长期指标** | LTV、NPS | ✅ 低 | + +**决策原则**: +- 如果长期指标(留存率、LTV)没有提升,即使短期指标(点击率)暴涨,也不应该上线。 + +--- + +### 6.5 统计功效不足 + +#### 6.5.1 一个"错过机会"的故事 + +**场景:你做了一个很有前景的改进** + +你的公司测试了一个新的"一键购买"功能: + +**你的计算**(错误): +- "我们每天有 1,000 个访客,跑 1 周应该够了吧?" +- 所需样本:1,000 人/组 +- 运行时间:7 天 + +**实验结果**(1 周后): +- A 组:1,000 人,50 人购买(5%) +- B 组:1,000 人,60 人购买(6%) +- 相对提升:+20% +- P 值:0.30(不显著) + +**你的结论**:"一键购买没有效果,放弃这个功能。" + +--- + +**但是,你的同事提出了一个问题**:"我们的样本量真的够吗?" + +**重新计算**(正确): +- 基准转化率:5% +- 期望检测提升:20%(从 5% 到 6%) +- 显著性水平:α = 0.05 +- 统计功效:80% + +**使用在线计算器**: +- 所需样本:每组约 6,000 人 +- 所需时间:约 6 周(不是 1 周!) + +**真相**:你的实验只跑了 1/6 的样本量,统计功效不足,即使有真实差异也检测不出来(假阴性)。 + +**你错过了一个提升 20% 的机会!** + +--- + +#### 6.5.2 为什么会发生这种情况? + +**统计功效的直观理解** + +想象你在安检: +- **功效 20%**:只能检测出 20% 的危险品(漏掉 80%) +- **功效 50%**:能检测出 50% 的危险品(漏掉 50%) +- **功效 80%**:能检测出 80% 的危险品(漏掉 20%) + +如果你的安检设备只有 20% 功效,你会漏掉 80% 的危险品。 + +A/B 测试也是一样: +- **功效 20%**:即使有真实差异,只有 20% 的概率能检测出来 +- **功效 80%**:如果有真实差异,有 80% 的概率能检测出来 + +**样本量越小 → 功效越低 → 越容易假阴性** + +--- + +#### 6.5.3 如何避免统计功效不足? + +**方法 1:实验前必须计算样本量** + +**不要凭感觉!** 使用在线计算器: +- [Evan Miller 样本量计算器](https://www.evanmiller.org/ab-testing/sample-size.html) +- [Optimizely 样本量计算器](https://www.optimizely.com/sample-size-calculator/) + +**输入**: +- 基准转化率(如 5%) +- 期望检测提升(如 20%) +- 显著性水平(如 0.05) +- 统计功效(如 80%) + +**输出**: +- 所需样本量(如 6,000 人/组) + +--- + +**方法 2:如果资源有限,调整期望** + +**场景**:你只有 1,000 个样本,怎么办? + +**选项 1**:提高最小检测提升 +- 从检测 20% 提升 → 改为检测 50% 提升 +- 所需样本从 6,000 → 降到 1,000 + +**选项 2**:降低统计功效 +- 从 80% 功效 → 降到 50% 功效 +- 所需样本从 6,000 → 降到 2,500 +- 但风险:假阴性概率从 20% → 升到 50% + +**选项 3**:延长测试时间 +- 从 1 周 → 延长到 6 周 +- 样本量从 1,000 → 增加到 6,000 + +::: tip 💡 实践建议 +**优先级**: + +1. **首选**:延长测试时间(最可靠) +2. **次选**:提高最小检测提升(接受"只检测大效果") +3. **下策**:降低统计功效(增加假阴性风险) + +如果三个选项都不可行,说明你的流量不足以做这个实验。考虑: +- 聚合更长时间的数据 +- 放弃这个实验 +- 先做其他实验 +::: + +--- + +## 7. 结果分析:手把手教学 + +### 7.1 从原始数据到结论 + +**场景:你的实验结束了,现在要分析结果** + +**实验设计回顾**: +- 测试内容:购物车按钮颜色(蓝 vs 红) +- 运行时间:2 周 +- 流量分配:50/50 +- 目标指标:购买转化率 +- 期望提升:20%(从 5% 到 6%) + +--- + +#### 步骤 1:收集原始数据 + +**从数据库导出数据**: + +| 日期 | A组访客数 | A组购买数 | B组访客数 | B组购买数 | +| :--- | :--- | :--- | :--- | :--- | +| 第 1 天 | 500 | 25 | 500 | 30 | +| 第 2 天 | 520 | 26 | 510 | 28 | +| ... | ... | ... | ... | ... | +| 第 14 天 | 480 | 22 | 490 | 25 | +| **合计** | **7,000** | **350** | **7,000** | **385** | + +--- + +#### 步骤 2:计算基本指标 + +**A 组(对照组)**: +- 样本量:7,000 +- 购买数:350 +- 转化率:350 / 7,000 = **5.00%** + +**B 组(实验组)**: +- 样本量:7,000 +- 购买数:385 +- 转化率:385 / 7,000 = **5.50%** + +**绝对提升**:5.50% - 5.00% = **+0.5 个百分点** + +**相对提升**:(5.50% - 5.00%) / 5.00% = **+10%** + +--- + +#### 步骤 3:使用在线计算器进行统计检验 + +**访问**:[AB TestGuide 计算器](https://abtestguide.com/calc/) + +**输入数据**: +- A 组:7,000 样本,350 转化 +- B 组:7,000 样本,385 转化 + +**计算器输出**: +- 相对提升:+10% +- P 值:**0.042** +- 95% 置信区间:[+0.3%, +19.7%] + +--- + +#### 步骤 4:解读统计结果 + +**P 值解读**: +- p = 0.042 < 0.05 +- 结论:**统计显著** + +**置信区间解读**: +- 95% CI:[+0.3%, +19.7%] +- 整个区间都在 0 的右边(都是正数) +- 结论:**有 95% 信心真实提升在 0.3% 到 19.7% 之间** + +**关键观察**: +- 下限是 +0.3%,即使是最保守估计,也是正提升 +- 上限是 +19.7%,最好情况下可能接近 20% 提升 +- 观察值 10% 在区间中间 + +--- + +#### 步骤 5:检查数据质量 + +**检查 1:样本量是否足够?** +- 实际样本:7,000/组 +- 计划样本:6,000/组 +- 结论:✅ 样本量充足 + +**检查 2:运行时间是否足够?** +- 实际运行:14 天(2 周) +- 计划运行:14 天 +- 结论:✅ 覆盖 2 个完整业务周期 + +**检查 3:流量分配是否均匀?** +- A 组:7,000(50%) +- B 组:7,000(50%) +- 结论:✅ 分配均匀 + +**检查 4:是否有异常波动?** +(绘制每日数据趋势图,观察是否有异常日) + +- 如果某天数据异常(如服务器故障),需要排除 +- 如果数据平稳增长,说明正常 + +--- + +#### 步骤 6:按子群体分析 + +**移动端**: +- A 组:5,000 人,250 人购买(5.0%) +- B 组:5,000 人,280 人购买(5.6%) +- 相对提升:+12% +- P 值:0.03(显著) + +**桌面端**: +- A 组:2,000 人,100 人购买(5.0%) +- B 组:2,000 人,105 人购买(5.25%) +- 相对提升:+5% +- P 值:0.70(不显著) + +**结论**: +- 移动端效果显著(+12%) +- 桌面端不显著(样本小,差异小) +- 整体显著主要由移动端驱动 + +--- + +#### 步骤 7:检查其他指标 + +**点击率**: +- A 组:10% +- B 组:11% +- 结论:✅ 提升(符合预期) + +**用户满意度**: +- A 组:4.2 分 +- B 组:4.1 分 +- 结论:⚠️ 轻微下降(需要关注) + +**退款率**: +- A 组:2% +- B 组:2.1% +- 结论:✅ 持平(无负面影响) + +**综合评估**: +- 主指标(转化率)提升 ✅ +- 次指标(点击率)提升 ✅ +- 用户满意度轻微下降 ⚠️ +- 退款率持平 ✅ + +--- + +#### 步骤 8:做出决策 + +**情况分析**: +1. 主指标显著提升(+10%) +2. 置信区间合理([+0.3%, +19.7%]) +3. 移动端效果显著,桌面端不显著 +4. 用户满意度轻微下降,但影响不大 +5. 退款率没有上升(说明用户质量没有下降) + +**决策选项**: + +**选项 A:全量上线** +- 优点:获得 +10% 转化率提升 +- 风险:用户满意度可能继续下降 + +**选项 B:仅移动端上线** +- 优点:针对显著有效的群体 +- 风险:桌面端失去优化机会 + +**选项 C:延长观察** +- 优点:收集更多数据,确认趋势 +- 风险:延迟决策时间 + +**最终决定**:**全量上线,但持续监控用户满意度** + +**理由**: +1. 转化率提升显著,业务价值明确 +2. 用户满意度下降很小(4.2 → 4.1),可能是随机波动 +3. 退款率没有上升,说明没有严重质量问题 +4. 上线后持续监控,如果满意度继续下降,再考虑回滚 + +--- + +#### 步骤 9:撰写报告 + +**实验报告示例**: + +``` +【A/B 测试报告】购物车按钮颜色优化 + +实验周期:2024-01-01 至 2024-01-14(14 天) + +实验设计: +- 对照组(A):蓝色按钮 +- 实验组(B):红色按钮 +- 流量分配:50/50 +- 样本量:7,000/组 + +主要结果: +- A 组转化率:5.00%(350/7000) +- B 组转化率:5.50%(385/7000) +- 相对提升:+10% +- P 值:0.042(显著) +- 95% CI:[+0.3%, +19.7%] + +子群体分析: +- 移动端:+12%(p = 0.03,显著) +- 桌面端:+5%(p = 0.70,不显著) + +其他指标: +- 点击率:+10%(A:10% → B:11%) +- 用户满意度:-2.4%(A:4.2 → B:4.1) +- 退款率:持平(A:2% → B:2.1%) + +结论与建议: +✅ 建议全量上线红色按钮 + - 主指标显著提升 +10% + - 置信区间完全为正,下限 +0.3% + - 其他指标无负面影响 + +⚠️ 持续监控: + - 用户满意度轻微下降,需持续观察 + - 建议上线 1 个月后重新评估 + +报告人:XXX +日期:2024-01-15 +``` + +--- + +### 7.2 结果解读决策树 + +**问题 1:P 值是否显著(p < 0.05)?** + +- **否(p ≥ 0.05)** → 转到问题 2 +- **是(p < 0.05)** → 转到问题 3 + +--- + +**问题 2:置信区间是否包含 0?** + +- **包含 0**(如 [-5%, +15%])→ **不显著** + - 决策:保持现状,或延长实验时间 + - 原因:无法确定方向 + +- **不包含 0**(如 [+5%, +15%])→ **显著** + - (这种情况少见,因为 P 值和 CI 通常一致) + +--- + +**问题 3:提升方向是什么?** + +- **B > A**(提升为正)→ 转到问题 4 +- **B < A**(提升为负)→ **放弃 B 版本** + - 决策:保持 A 版本 + - 原因:B 版本显著更差 + +--- + +**问题 4:提升幅度是否有业务价值?** + +- **提升 < 5%** → 转到问题 5 +- **提升 ≥ 5%** → 转到问题 6 + +--- + +**问题 5:小提升(< 5%),但显著,怎么办?** + +**考虑因素**: +- 置信区间下限是多少? + - 如果下限 > 0(如 [+1%, +9%]):可以上线 + - 如果下限接近 0(如 [-0.5%, +8.5%]):谨慎上线 + +- 实施成本是多少? + - 低成本(改文案):可以考虑上线 + - 高成本(重构代码):不建议上线 + +- 是否有副作用? + - 用户满意度下降?保持现状 + - 其他指标恶化?保持现状 + +**决策**: +- 置信区间下限 > 0 + 低成本 + 无副作用 → **上线** +- 其他情况 → **保持现状或延长观察** + +--- + +**问题 6:大提升(≥ 5%),且显著,直接上线?** + +**还需要检查**: +- 用户满意度是否下降? +- 其他关键指标是否恶化? +- 是否有子群体效果相反(辛普森悖论)? +- 是否是新奇效应(短期暴涨,长期回落)? + +**决策**: +- 所有检查通过 → **全量上线** +- 有任何警告 → **谨慎上线或延长观察** + +--- + +### 7.3 实验评估检查清单 + +**在做出决策前,确保回答了以下问题**: + +#### 数据质量检查 +- [ ] 样本量是否达到计划要求? +- [ ] 运行时间是否覆盖 2 个完整业务周期? +- [ ] 流量分配是否均匀(50/50)? +- [ ] 是否有异常数据或技术故障? + +#### 统计显著性检查 +- [ ] P 值是否 < 0.05? +- [ ] 置信区间是否不包含 0? +- [ ] 置信区间宽度是否合理(不过宽)? + +#### 业务价值检查 +- [ ] 提升幅度是否有业务意义(> 5%)? +- [ ] 置信区间下限是否可接受? +- [ ] ROI 是否为正(收益 > 成本)? + +#### 风险检查 +- [ ] 用户满意度是否下降? +- [ ] 退款率/投诉率是否上升? +- [ ] 是否存在辛普森悖论? +- [ ] 是否是新奇效应? + +#### 子群体检查 +- [ ] 移动端和桌面端结论是否一致? +- [ ] 新用户和老用户结论是否一致? +- [ ] 不同地区结论是否一致? + +#### 长期影响检查 +- [ ] 是否观察了足够长的时间(> 2 周)? +- [ ] 趋势是否稳定(不是昙花一现)? +- [ ] 是否有长期负面影响(如用户流失)? + +--- + +**如果所有检查都通过** → **全量上线** + +**如果有任何警告** → **谨慎决策或延长观察** + +**如果有多个失败** → **保持现状或放弃** + +--- + +## 8. 实战案例 **背景**:电商网站购物车结算按钮,当前为蓝色,想测试红色是否能提升转化率。 @@ -428,7 +2230,7 @@ n ≈ 6,932 --- -## 8. 用 AI 辅助 A/B 测试设计 +## 9. 用 AI 辅助 A/B 测试设计 AI 可以帮助你快速设计实验、计算样本量、分析结果。关键在于提供清晰的上下文。 @@ -516,6 +2318,216 @@ AI 可以帮助你快速设计实验、计算样本量、分析结果。关键 --- +## 10. 决策树:什么时候该做什么? + +### 10.1 实验前决策树:是否需要做 A/B 测试? + +**问题 1:改动是否可以快速看到结果?** + +- **否**(需要几个月才能看到效果) + - ❌ 不适合 A/B 测试 + - 原因:测试时间太长,成本太高 + - 替代方案:用户调研、专家评估 + +- **是**(几周内能看到效果) + - ✅ 可以考虑,继续下一个问题 + +--- + +**问题 2:能否随机分配用户?** + +- **否**(会严重影响用户体验) + - ❌ 不适合 A/B 测试 + - 原因:无法保证公平对比 + - 替代方案:渐进式发布、灰度发布 + +- **是**(随机分配不会有大问题) + - ✅ 可以考虑,继续下一个问题 + +--- + +**问题 3:能量化关键指标吗?** + +- **否**(改动是主观的,如品牌形象) + - ❌ 不适合 A/B 测试 + - 原因:无法用数字衡量 + - 替代方案:用户调研、焦点小组 + +- **是**(有明确的数字指标,如转化率) + - ✅ 适合 A/B 测试! + +--- + +**总结**: +- 三个答案都是"是" → **做 A/B 测试** +- 任何一个"否" → **考虑其他方法** + +--- + +### 10.2 实验中决策树:什么时候该停止? + +**问题 1:是否达到预定的样本量?** + +- **否**(还没达到) + - ⚠️ 不要停止,不要分析 + - 原因:P 值还不稳定,容易误判 + - 例外:发现严重问题(如 Bug),立即停止 + +- **是**(已经达到) + - ✅ 可以分析结果了 + +--- + +**问题 2:是否有严重问题?** + +**严重问题的定义**: +- 转化率暴跌 > 50% +- 大量用户投诉 +- 系统错误或 Bug +- 安全问题 + +- **是**(有严重问题) + - 🛑 **立即停止实验,回滚到 A 版本** + - 不要等样本量,安全第一 + +- **否**(没有严重问题) + - ✅ 继续运行到预定时间 + +--- + +**问题 3:是否达到预定时间(如 2 周)?** + +- **否**(还没到) + - ⏳ 继续运行 + - 原因:需要覆盖完整业务周期,避免新奇效应 + +- **是**(已经到了) + - ✅ 可以停止并分析结果 + +--- + +**总结**: +- 发现严重问题 → **立即停止** +- 没达到样本量/时间 → **继续运行** +- 达到样本量 + 时间 → **停止并分析** + +--- + +### 10.3 实验后决策树:如何解读结果? + +**问题 1:P 值是否 < 0.05?** + +- **否**(p ≥ 0.05,不显著) + - 转到问题 2 + +- **是**(p < 0.05,显著) + - 转到问题 3 + +--- + +**问题 2:不显著,怎么办?** + +**检查 1:样本量是否足够?** +- 否(样本太小) + - 延长实验时间,收集更多数据 + +- 是(样本充足) + - 继续检查 2 + +**检查 2:置信区间是否很宽?** +- 是(如 [-20%, +30%]) + - 延长实验,缩小置信区间 + +- 否(如 [-2%, +8%]) + - 继续检查 3 + +**检查 3:是否有业务价值?** +- 置信区间下限 > 5%(如 [+6%, +15%],虽然不显著) + - 考虑上线(如果风险低) + +- 置信区间下限 < 0(如 [-5%, +15%]) + - 保持现状或延长观察 + +--- + +**问题 3:显著,B 版本更好吗?** + +- **否**(B 更差,显著) + - ❌ **放弃 B 版本,保持 A 版本** + - 原因:B 版本显著损害了指标 + +- **是**(B 更好,显著) + - 转到问题 4 + +--- + +**问题 4:B 版本更好,提升多大?** + +- **小提升**(< 5%,如 +2%) + - 转到问题 5 + +- **大提升**(≥ 5%,如 +10%) + - 转到问题 6 + +--- + +**问题 5:小提升但显著,怎么办?** + +**检查 1:置信区间下限是多少?** +- 下限 > 0(如 [+1%, +5%]) + - 可以考虑上线 + +- 下限 ≤ 0(如 [-1%, +5%]) + - 谨慎上线或延长观察 + +**检查 2:实施成本是多少?** +- 低成本(改文案) + - 可以上线 + +- 高成本(重构代码) + - 不建议上线(ROI 太低) + +**检查 3:是否有副作用?** +- 用户满意度下降 +- 其他指标恶化 + - 不上线或延长观察 + +- 无副作用 + - 可以上线 + +--- + +**问题 6:大提升且显著,直接上线?** + +**还需要检查**: +- [ ] 用户满意度是否下降? +- [ ] 退款率/投诉率是否上升? +- [ ] 是否存在辛普森悖论?(子群体结论不一致) +- [ ] 是否是新奇效应?(短期暴涨,长期回落) +- [ ] 其他关键指标是否恶化? + +**结果**: +- 所有检查通过 → **全量上线** +- 有任何警告 → **谨慎上线或延长观察** + +--- + +### 10.4 快速参考:常见决策场景 + +| 场景 | 决策 | 理由 | +| :--- | :--- | :--- | +| **p < 0.01,提升 20%** | ✅ 全量上线 | 高度显著,大提升 | +| **p < 0.05,提升 15%** | ✅ 全量上线 | 显著,中提升 | +| **p < 0.05,提升 3%** | ⚠️ 谨慎上线 | 显著但提升小,检查副作用 | +| **p = 0.08,提升 10%** | ⏳ 延长实验 | 接近显著,需要更多数据 | +| **p = 0.30,提升 2%** | ❌ 保持现状 | 不显著,提升小 | +| **p < 0.05,降低 10%** | 🛑 放弃 B 版本 | 显著恶化 | +| **p < 0.05,移动端 +20%,桌面端 -5%** | ⚠️ 分群体决策 | 存在辛普森悖论 | +| **第 1 周 +30%,第 4 周 0%** | ❌ 保持现状 | 新奇效应,虚假提升 | +| **p = 0.04,但满意度下降** | ⚠️ 延长观察 | 主指标好,副指标差 | + +--- + ## 名词速查表 | 名词 | 英文 | 解释 | diff --git a/docs/zh-cn/appendix/5-data/data-analysis.md b/docs/zh-cn/appendix/5-data/data-analysis.md index 33b9a21..09da3df 100644 --- a/docs/zh-cn/appendix/5-data/data-analysis.md +++ b/docs/zh-cn/appendix/5-data/data-analysis.md @@ -44,11 +44,133 @@ DAU(日活用户):10 万 → 看起来不错! --- -## 1. 数据分析的价值 +## 1. 什么是数据? + +**数据**就是关于任何事物的记录。在你的日常生活中,数据无处不在。 + +### 1.1 生活中的数据例子 + +**你的个人数据**: +- 每天走了多少步(手机会记录) +- 每月花了多少钱(支付宝/微信账单) +- 睡了多少小时(健康 App 记录) +- 看了哪些视频(B站/抖音历史记录) + +**一个咖啡店的数据**: +- 每天卖了多少杯咖啡 +- 每种咖啡卖了多少杯 +- 每笔订单的金额 +- 顾客的等待时间 + +**一个网站的数据**: +- 每天有多少人访问 +- 用户点击了哪些按钮 +- 用户停留了多长时间 +- 用户从哪里来(搜索引擎、社交媒体等) + +::: tip 💡 关键理解 +**数据 = 记录下来的信息** +只要能被记录、被存储、被计算的,都是数据。 +::: + +--- + +## 2. 什么是分析? + +**分析**就是"拆解 + 研究"的意思。就像侦探破案一样,从一堆线索中找到规律。 + +### 2.1 用侦探破案来类比 + +**侦探怎么做**: +1. 收集线索(指纹、脚印、监控录像) +2. 找线索之间的联系 +3. 推理出真相 +4. 抓住坏人 + +**数据分析怎么做**: +1. 收集数据(用户行为、销售记录、日志) +2. 找数据之间的联系 +3. 发现规律和趋势 +4. 做出决策 + +::: tip 💡 关键理解 +**分析 = 从数据中找规律** +不是"看数据",而是"理解数据背后的故事"。 +::: + +### 2.2 生活中的"分析"例子 + +**例子一:你发现咖啡总是卖完** +- **数据**:每天早上 10 点,拿铁咖啡就卖完了 +- **分析**:为什么总是 10 点卖完? + - 查看销售记录 → 发现 8-10 点是高峰期 + - 统计销售量 → 发现拿铁占总销量的 60% + - 分析顾客 → 发现大部分是上班族 +- **结论**:上班族早高峰喜欢喝拿铁 +- **行动**:多准备一些拿铁,或者提前制作 + +**例子二:你发现用户不爱用你的 App** +- **数据**:下载量 1 万,但每天只有 500 人打开 +- **分析**:为什么用户不用? + - 查看用户行为 → 发现 80% 的用户注册后就没再回来 + - 分析注册流程 → 发现需要填写 10 个字段 + - 对比其他 App → 发现其他 App 只需要 2 个字段 +- **结论**:注册流程太复杂,吓跑了用户 +- **行动**:简化注册流程 + +--- + +## 3. 为什么要分析数据? + +### 3.1 一个真实的场景 + +**老板问你**:"我们的用户增长怎么样?" + +**如果不懂数据分析,你可能会说**: +- "挺好的吧,感觉用户变多了" +- "不太清楚,我看看后台" +- "昨天有 100 个新用户" + +**如果懂数据分析,你会这样回答**: +``` +过去 30 天的数据: +- 新增用户:3000 人(日均 100 人) +- 增长趋势:环比增长 15%(上个月是 2600 人) +- 用户质量:次日留存 45%,7 日留存 25% +- 来源分布:搜索引擎 40%,社交媒体 35%,直接访问 25% + +结论: +1. 用户增长健康,且在加速 +2. 社交媒体来源的留存最高(55%),应该加大投放 +3. 搜索引擎来源的留存较低(30%),需要优化落地页 +``` + +**哪个回答更有价值?** 显然是第二个。 + +::: tip 💡 关键理解 +**数据分析的价值 = 让你做出更好的决策** +- 不是"我觉得",而是"数据显示" +- 不是"大概",而是"准确" +- 不是"事后诸葛亮",而是"提前预测" +::: + +### 3.2 数据分析能帮你做什么? + +| 场景 | 问题 | 数据分析能做什么 | +| :--- | :--- | :--- | +| **做生意** | 不知道哪个商品好卖 | 统计销售数据,找出爆款 | +| **做产品** | 不知道用户喜欢什么 | 分析用户行为,优化功能 | +| **做运营** | 不知道广告效果如何 | 对比不同渠道的转化率 | +| **做投资** | 不知道买哪只股票 | 分析历史数据,预测趋势 | +| **个人生活** | 不知道钱花哪了 | 记账分析,找出浪费 | + +--- + +## 4. 数据分析的价值 **数据分析**是从数据中提取有价值信息的过程。它不是简单的"看数字",而是通过统计、聚合、可视化等方法,发现数据背后的规律和趋势。 -### 1.1 用医学检查来类比 +### 4.1 用医学检查来类比 | 医学检查 | 数据分析 | 说明 | | :--- | :--- | :--- | @@ -69,11 +191,252 @@ DAU(日活用户):10 万 → 看起来不错! --- -## 2. 描述性统计:从数据中"提炼信息" +## 5. 描述性统计:从数据中"提炼信息" -描述性统计是数据分析的基础,它用几个关键指标概括大量数据的特征。 +**描述性统计**就是用几个数字来概括大量的数据。 -### 2.1 集中趋势:数据的"中心"在哪里? +想象一下,如果你要向朋友描述"你们班同学的身高",你会怎么说? +- ❌ "张三 170cm,李四 175cm,王五 168cm..."(说 10 分钟都说不完) +- ✅ "我们班平均身高 172cm"(一句话就说清楚了) + +这就是描述性统计的作用:**把复杂的数据变成简单的指标**。 + +### 5.1 均值:数据的"平均值" + +#### 场景:计算平均成绩 + +你有 5 门课的成绩:80, 85, 90, 75, 95 + +**计算步骤**: +``` +步骤 1:把所有成绩加起来 +80 + 85 + 90 + 75 + 95 = 425 + +步骤 2:数一共有几门课 +一共 5 门课 + +步骤 3:用总和除以数量 +425 ÷ 5 = 85 + +所以平均成绩是 85 分。 +``` + +#### 用数学公式表示 + +**均值 = (所有数值的和) ÷ (数值的个数)** + +**符号表示**: +- 均值的符号是 x̄(读作"x bar") +- 数据用 x₁, x₂, x₃... 表示 +- 数据个数用 n 表示 + +**公式**:x̄ = (x₁ + x₂ + x₃ + ... + xₙ) ÷ n + +**用成绩的例子**: +``` +x̄ = (80 + 85 + 90 + 75 + 95) ÷ 5 + = 425 ÷ 5 + = 85 +``` + +#### 什么时候用均值? + +**适合用均值的场景**: +- 数据分布比较均匀 +- 想知道"整体水平" +- 没有极端的异常值 + +**例子**: +- ✅ 计算班级平均成绩(成绩通常分布在 60-100 之间) +- ✅ 计算店铺日均销售额(每天的销售差异不会太大) +- ✅ 计算用户平均年龄(大部分用户年龄相近) + +#### 什么时候不能用均值? + +**问题一:极端值会拉偏均值** + +**场景:工资调查** + +一个公司有 5 个人,工资分别是: +``` +员工 A:3000 元 +员工 B:4000 元 +员工 C:5000 元 +员工 D:6000 元 +老板: 100000 元 +``` + +**计算均值**: +``` +(3000 + 4000 + 5000 + 6000 + 100000) ÷ 5 += 118000 ÷ 5 += 23600 元 +``` + +**问题**:均值显示"平均工资 23600 元",但实际上 4 个员工工资都不到 6000 元。老板的高工资把均值拉高了。 + +**这时候应该用中位数(后面会讲)**。 + +--- + +**问题二:数据分布不均匀** + +**场景:电商订单金额** + +某电商平台今天的订单: +``` +9.9 元 × 1000 单 = 9900 元 +99 元 × 100 单 = 9900 元 +999 元 × 10 单 = 9990 元 +9999 元 × 1 单 = 9999 元 +``` + +**订单数**:1111 单 +**总金额**:39789 元 +**均值**:39789 ÷ 1111 ≈ 35.8 元 + +**问题**:均值显示"平均订单 35.8 元",但实际上大部分订单(1000 单)都是 9.9 元。 + +**这时候应该用众数(后面会讲)**。 + +::: tip 💡 实战建议 +- **看 DAU、GMV 等**:用均值即可(数据量大,极端值影响小) +- **看收入、房价等**:用中位数更准确(避免被极端值 skew) +- **看热销商品等**:用众数(最典型的情况) +::: + +--- + +### 5.2 中位数:排序后"中间"的值 + +#### 什么是中位数? + +**中位数**就是把数据从小到大排序后,位于中间位置的那个值。 + +#### 场景:计算工资中位数 + +**数据:一个公司 5 个人的工资** +``` +3000, 4000, 5000, 6000, 100000 +``` + +**计算步骤**: +``` +步骤 1:排序(从小到大) +3000, 4000, 5000, 6000, 100000 ✓(已经排序) + +步骤 2:找到中间的位置 +一共 5 个数,中间是第 3 个 + +步骤 3:取出中间的值 +中位数 = 5000 元 +``` + +**对比均值**: +- 中位数 = 5000 元(更能代表普通员工的工资) +- 均值 = 23600 元(被老板的高工资拉高了) + +#### 如果数据个数是偶数怎么办? + +**数据:6 个人的工资** +``` +3000, 4000, 5000, 6000, 7000, 100000 +``` + +**计算步骤**: +``` +步骤 1:排序 +3000, 4000, 5000, 6000, 7000, 100000 + +步骤 2:找到中间的位置 +一共 6 个数,中间是第 3 和第 4 个之间 + +步骤 3:计算中间两个数的平均值 +(5000 + 6000) ÷ 2 = 5500 + +中位数 = 5500 元 +``` + +#### 什么时候用中位数? + +**适合用中位数的场景**: +- 数据有极端值(比如工资、房价) +- 想知道"典型情况" +- 数据分布不均匀 + +**例子**: +- ✅ 调查收入(避免被亿万富翁 skew) +- ✅ 统计房价(避免被豪宅 skew) +- ✅ 分析订单金额(避免被大单 skew) + +--- + +### 5.3 众数:出现"最多"的值 + +#### 什么是众数? + +**众数**就是数据中出现次数最多的值。 + +#### 场景:找出最畅销的商品 + +**数据:某咖啡店今天的订单** +``` +拿铁 × 15 杯 +美式 × 8 杯 +卡布奇诺 × 5 杯 +摩卡 × 3 杯 +玛奇朵 × 2 杯 +``` + +**计算步骤**: +``` +步骤 1:统计每种咖啡出现的次数 +拿铁:15 次 +美式:8 次 +卡布奇诺:5 次 +摩卡:3 次 +玛奇朵:2 次 + +步骤 2:找到出现次数最多的 +拿铁出现 15 次,是最多的 + +众数 = 拿铁 +``` + +**结论**:拿铁是最受欢迎的咖啡。 + +#### 特殊情况:可能有多个众数 + +**数据:同学们的鞋码** +``` +37 码 × 2 人 +38 码 × 5 人 +39 码 × 5 人 +40 码 × 3 人 +41 码 × 1 人 +``` + +**众数**:38 码和 39 码(都出现了 5 次) + +**结论**:这个班有两种主流鞋码。 + +#### 什么时候用众数? + +**适合用众数的场景**: +- 数据是分类(不是数字) +- 想知道"最热门"的选项 +- 有多个峰值 + +**例子**: +- ✅ 最畅销的商品(iPhone、奶茶) +- ✅ 最常用的功能(点赞、评论) +- ✅ 最热门的搜索词(用户经常搜什么) + +--- + +### 5.4 集中趋势:数据的"中心"在哪里? + +现在你已经了解了三个指标,让我们总结一下: | 指标 | 定义 | 适用场景 | 示例 | | :--- | :--- | :--- | :--- | @@ -81,57 +444,208 @@ DAU(日活用户):10 万 → 看起来不错! | **中位数** | 排序后位于中间的值 | 有极端值时 | 收入中位数:5000 元(避免被亿万富翁 skew) | | **众数** | 出现次数最多的值 | 分类数据 | 最常买的商品:iPhone | -**为什么需要三个指标?** +#### 为什么需要三个指标? +**场景一:正常分布(三个指标接近)** ```python -# 场景一:正常分布 数据:[1, 2, 3, 4, 5] -均值 = 3, 中位数 = 3, 众数 = 无 -→ 数据分布均匀,三个指标接近 -# 场景二:有极端值 +均值 = (1 + 2 + 3 + 4 + 5) ÷ 5 = 3 +中位数 = 排序后中间的数 = 3 +众数 = 没有重复的数,无众数 + +→ 数据分布均匀,均值和中位数接近 +``` + +**场景二:有极端值(中位数更准确)** +```python 数据:[1, 2, 3, 4, 100] -均值 = 22, 中位数 = 3 -→ 极端值(100)拉高了均值,中位数更准确 -# 场景三:电商订单 +均值 = (1 + 2 + 3 + 4 + 100) ÷ 5 = 22 +中位数 = 排序后中间的数 = 3 +众数 = 没有重复的数,无众数 + +→ 极端值(100)拉高了均值,中位数(3)更准确 +``` + +**场景三:电商订单(众数最典型)** +```python 数据:[9.9, 9.9, 9.9, 999, 9999] -均值 = 2005.72, 众数 = 9.9 -→ 大部分用户买 9.9 元商品,均值被高客单价 skew + +均值 = (9.9 + 9.9 + 9.9 + 999 + 9999) ÷ 5 = 2005.72 +中位数 = 排序后中间的数 = 9.9 +众数 = 9.9(出现 3 次) + +→ 大部分用户买 9.9 元商品,众数(9.9)最典型 +``` + +--- + +### 5.5 离散程度:数据"分散"还是"集中"? + +#### 为什么需要衡量离散程度? + +**场景:两个班的平均成绩都是 80 分** + +**A 班**:[78, 79, 80, 81, 82] +- 均值 = 80 +- 标准差 = 1.41(很小) +- **解读**:成绩很集中,大家水平差不多 + +**B 班**:[50, 65, 80, 95, 100] +- 均值 = 80 +- 标准差 = 18.71(很大) +- **解读**:成绩很分散,有的很好,有的很差 + +**结论**:虽然两个班平均分相同,但 A 班更"稳定",B 班差异"很大"。 + +#### 极差:最简单的衡量方法 + +**极差 = 最大值 - 最小值** + +**例子:考试成绩** +``` +成绩:[60, 75, 80, 85, 95] + +步骤 1:找到最大值 +最大值 = 95 + +步骤 2:找到最小值 +最小值 = 60 + +步骤 3:计算极差 +极差 = 95 - 60 = 35 + +所以成绩的极差是 35 分。 +``` + +**优点**:计算简单 +**缺点**:只看最大和最小,容易被极端值影响 + +--- + +#### 方差:衡量每个数据与均值的偏离 + +**什么是方差?** + +方差衡量"每个数据与均值相差多少",然后取平均值。 + +**计算步骤**: + +**例子:数据 [2, 4, 6, 8, 10]** + +``` +步骤 1:计算均值 +(2 + 4 + 6 + 8 + 10) ÷ 5 = 6 + +步骤 2:计算每个数与均值的差 +2 - 6 = -4 +4 - 6 = -2 +6 - 6 = 0 +8 - 6 = 2 +10 - 6 = 4 + +步骤 3:把差值平方(去掉负号) +(-4)² = 16 +(-2)² = 4 +0² = 0 +2² = 4 +4² = 16 + +步骤 4:计算平方的平均 +(16 + 4 + 0 + 4 + 16) ÷ 5 = 8 + +所以方差 = 8 +``` + +**为什么要平方?** +- 因为差值有正有负(-4, 4),直接加会抵消 +- 平方后都是正数,才能累加 + +**问题**:方差是"平方"后的单位,不好理解。 +- 如果原始数据是"元",方差就是"元²" +- 如果原始数据是"岁",方差就是"岁²" + +**解决方案**:用标准差! + +--- + +#### 标准差:更直观的离散程度 + +**标准差 = 方差的平方根** + +**例子:刚才的方差 = 8** + +``` +标准差 = √8 ≈ 2.83 +``` + +**优点**: +- 单位和原始数据一样(元、岁、分等) +- 更容易理解 + +**如何理解标准差?** + +**经验法则(正态分布)**: +- **68% 的数据**在 [均值 - 1 标准差, 均值 + 1 标准差] 之间 +- **95% 的数据**在 [均值 - 2 标准差, 均值 + 2 标准差] 之间 + +**例子:用户年龄** +``` +均值 = 28 岁 +标准差 = 5 岁 + +解读: +- 68% 的用户年龄在 23-33 岁之间(28 ± 5) +- 95% 的用户年龄在 18-38 岁之间(28 ± 10) +``` + +#### 标准差的应用场景 + +**场景一:判断用户行为是否一致** + +**产品 A**: +- 日均使用时长:30 分钟 +- 标准差:2 分钟(很小) +- **解读**:用户行为一致,产品体验稳定 + +**产品 B**: +- 日均使用时长:30 分钟 +- 标准差:20 分钟(很大) +- **解读**:用户行为差异大,可能需要分群分析 + +**场景二:发现异常值** + +**数据**:用户登录次数 +``` +均值 = 10 次/天 +标准差 = 2 次/天 + +正常范围:[10 - 2×2, 10 + 2×2] = [6, 14] + +某用户登录 50 次/天 → 异常! +(可能是在刷数据,或者是机器人) ``` ::: tip 💡 实战建议 -- **DAU、GMV 等指标**:看均值即可(数据量大,极端值影响小) -- **用户收入、房价等**:看中位数更准确(避免被极端值 skew) -- **热销商品、常用功能**:看众数(最典型的情况) -::: - -### 2.2 离散程度:数据"分散"还是"集中"? - -| 指标 | 定义 | 说明 | -| :--- | :--- | :--- | -| **方差** | 各数据与均值差的平方的平均 | 数值越大,数据越分散 | -| **标准差** | 方差的平方根 | 与原始数据同单位,更直观 | -| **极差** = 最大值 - 最小值 | 最简单的离散度量 | 易受极端值影响 | - -**示例:两个班级的数学成绩** - -``` -A 班:[85, 88, 90, 92, 95] -均值 = 90, 标准差 = 3.16 -→ 成绩集中,水平稳定 - -B 班:[60, 75, 90, 100, 100] -均值 = 85, 标准差 = 16.58 -→ 成绩分散,水平差异大 -``` - -::: tip 💡 标准差的应用 - **标准差小**:用户行为一致,产品体验稳定 - **标准差大**:用户群体差异大,可能需要分群分析 +- **超过 3 个标准差**:通常是异常值,需要检查 ::: -### 2.3 交互式演示 +--- + +### 5.6 离散程度:数据"分散"还是"集中"? + +| 指标 | 定义 | 说明 | 计算复杂度 | +| :--- | :--- | :--- | :--- | +| **极差** | 最大值 - 最小值 | 最简单,但易受极端值影响 | ⭐ | +| **方差** | 各数据与均值差的平方的平均 | 数值越大,数据越分散 | ⭐⭐⭐ | +| **标准差** | 方差的平方根 | 与原始数据同单位,更直观 | ⭐⭐⭐ | + +--- + +### 5.7 交互式演示 👇 **动手试试看**:在下方输入一组数据,实时计算统计指标: @@ -139,40 +653,406 @@ B 班:[60, 75, 90, 100, 100] --- -## 3. 数据聚合:从明细到"洞察" +## 6. 数据聚合:从明细到"洞察" -数据聚合是将明细数据按维度汇总,从"看个体"到"看整体"的过程。 +**数据聚合**就是把"明细数据"(每一行记录)汇总成"统计数据"(总数、平均值等)。 -### 3.1 常用聚合操作 +### 6.1 为什么需要数据聚合? -| 操作 | SQL 函数 | 说明 | 示例 | -| :--- | :--- | :--- | :--- | -| **计数** | COUNT(*) | 统计行数 | 订单总数 | -| **求和** | SUM(amount) | 累加数值 | 总销售额 | -| **均值** | AVG(amount) | 计算平均 | 平均订单额 | -| **最大值** | MAX(amount) | 找最大值 | 最高单笔订单 | -| **最小值** | MIN(amount) | 找最小值 | 最低单笔订单 | +**场景一:从订单明细到总销售额** -### 3.2 分组聚合(GROUP BY) - -**问题**:如何统计每个用户的订单数和总消费? - -```sql -SELECT - user_id, - COUNT(*) as order_count, - SUM(amount) as total_amount -FROM orders -GROUP BY user_id; +**明细数据(每一笔订单)**: +``` +订单 1:用户 A,2024-01-01,100 元 +订单 2:用户 B,2024-01-01,150 元 +订单 3:用户 A,2024-01-02,200 元 +订单 4:用户 C,2024-01-02,180 元 ``` -**结果**: +**聚合后(总销售额)**: +``` +总销售额 = 100 + 150 + 200 + 180 = 630 元 +``` + +**场景二:从用户行为到活跃用户数** + +**明细数据(每一条行为记录)**: +``` +用户 A 在 2024-01-01 点击了 5 次 +用户 B 在 2024-01-01 点击了 3 次 +用户 A 在 2024-01-02 点击了 2 次 +用户 C 在 2024-01-02 点击了 4 次 +``` + +**聚合后(每日活跃用户数)**: +``` +2024-01-01:2 个活跃用户(A 和 B) +2024-01-02:2 个活跃用户(A 和 C) +``` + +::: tip 💡 关键理解 +**聚合 = 从"看个体"到"看整体"** +- **明细数据**:每一行记录(每个订单、每次点击) +- **聚合数据**:统计结果(总销售额、活跃用户数) +::: + +--- + +### 6.2 常用聚合操作 + +#### 计数(COUNT):统计"有多少个" + +**场景:统计订单总数** + +**明细数据**: +``` +订单 1:用户 A,100 元 +订单 2:用户 B,150 元 +订单 3:用户 C,200 元 +``` + +**聚合后**: +``` +订单总数 = 3 +``` + +**SQL 代码**: +```sql +SELECT COUNT(*) as total_orders +FROM orders; + +-- 结果: +-- | total_orders | +-- | :--- | +-- | 3 | +``` + +--- + +#### 求和(SUM):计算"总和" + +**场景:计算总销售额** + +**明细数据**: +``` +订单 1:100 元 +订单 2:150 元 +订单 3:200 元 +``` + +**聚合后**: +``` +总销售额 = 100 + 150 + 200 = 450 元 +``` + +**SQL 代码**: +```sql +SELECT SUM(amount) as total_sales +FROM orders; + +-- 结果: +-- | total_sales | +-- | :--- | +-- | 450 | +``` + +**详细注释**: +```sql +SELECT + SUM(amount) as total_sales -- 把所有订单金额加起来 +FROM orders; -- 从订单表 +``` + +--- + +#### 均值(AVG):计算"平均值" + +**场景:计算平均订单额** + +**明细数据**: +``` +订单 1:100 元 +订单 2:150 元 +订单 3:200 元 +``` + +**聚合后**: +``` +平均订单额 = (100 + 150 + 200) ÷ 3 = 150 元 +``` + +**SQL 代码**: +```sql +SELECT AVG(amount) as avg_order_amount +FROM orders; + +-- 结果: +-- | avg_order_amount | +-- | :--- | +-- | 150 | +``` + +--- + +#### 最大值(MAX):找"最大"的 + +**场景:找出最高单笔订单** + +**明细数据**: +``` +订单 1:100 元 +订单 2:150 元 +订单 3:200 元 +``` + +**聚合后**: +``` +最高订单 = 200 元 +``` + +**SQL 代码**: +```sql +SELECT MAX(amount) as max_order_amount +FROM orders; + +-- 结果: +-- | max_order_amount | +-- | :--- | +-- | 200 | +``` + +--- + +#### 最小值(MIN):找"最小"的 + +**场景:找出最低单笔订单** + +**明细数据**: +``` +订单 1:100 元 +订单 2:150 元 +订单 3:200 元 +``` + +**聚合后**: +``` +最低订单 = 100 元 +``` + +**SQL 代码**: +```sql +SELECT MIN(amount) as min_order_amount +FROM orders; + +-- 结果: +-- | min_order_amount | +-- | :--- | +-- | 100 | +``` + +--- + +### 6.3 聚合操作总结 + +| 操作 | SQL 函数 | 说明 | 示例 | 生活类比 | +| :--- | :--- | :--- | :--- | :--- | +| **计数** | COUNT(*) | 统计行数 | 订单总数 | 数一数有几个苹果 | +| **求和** | SUM(amount) | 累加数值 | 总销售额 | 把所有苹果重量加起来 | +| **均值** | AVG(amount) | 计算平均 | 平均订单额 | 计算苹果的平均重量 | +| **最大值** | MAX(amount) | 找最大值 | 最高单笔订单 | 找出最重的苹果 | +| **最小值** | MIN(amount) | 找最小值 | 最低单笔订单 | 找出最轻的苹果 | + +--- + +### 6.4 分组聚合(GROUP BY):按"类别"统计 + +#### 什么是 GROUP BY? + +**GROUP BY**就是把数据按某个维度"分组",然后对每组进行统计。 + +**生活类比**: +- 你有一堆水果(苹果、香蕉、橘子) +- 你想统计每种水果有多少个 +- 你会先把它们"分组"(苹果一堆、香蕉一堆、橘子一堆) +- 然后数每一堆有多少个 + +这就是 GROUP BY 的思想。 + +#### 场景一:统计每个用户的订单数和总消费 + +**明细数据(orders 表)**: + +| order_id | user_id | amount | +| :--- | :--- | :--- | +| 1 | U001 | 100 | +| 2 | U002 | 150 | +| 3 | U001 | 200 | +| 4 | U003 | 180 | +| 5 | U002 | 120 | + +**问题**:统计每个用户的订单数和总消费? + +**SQL 代码**: +```sql +SELECT + user_id, -- 选择用户 ID + COUNT(*) as order_count, -- 统计订单数 + SUM(amount) as total_amount -- 计算总消费 +FROM orders -- 从订单表 +GROUP BY user_id; -- 按用户 ID 分组 +``` + +**执行过程**: + +``` +步骤 1:按 user_id 分组 + +分组 1(U001): + 订单 1:U001, 100 + 订单 3:U001, 200 + +分组 2(U002): + 订单 2:U002, 150 + 订单 5:U002, 120 + +分组 3(U003): + 订单 4:U003, 180 + +步骤 2:对每组进行聚合 + +分组 1(U001): + order_count = 2(2 笔订单) + total_amount = 100 + 200 = 300 + +分组 2(U002): + order_count = 2(2 笔订单) + total_amount = 150 + 120 = 270 + +分组 3(U003): + order_count = 1(1 笔订单) + total_amount = 180 +``` + +**聚合后(结果)**: | user_id | order_count | total_amount | | :--- | :--- | :--- | -| U001 | 3 | 480 | -| U002 | 2 | 450 | -| U003 | 1 | 250 | +| U001 | 2 | 300 | +| U002 | 2 | 270 | +| U003 | 1 | 180 | + +--- + +#### 场景二:统计每个商品的销售总额 + +**明细数据(order_items 表)**: + +| order_id | product_name | price | quantity | +| :--- | :--- | :--- | :--- | +| 1 | iPhone | 5000 | 1 | +| 1 | 手机壳 | 50 | 2 | +| 2 | iPad | 3000 | 1 | +| 3 | iPhone | 5000 | 2 | +| 3 | AirPods | 1000 | 1 | + +**问题**:统计每个商品的销售总额? + +**SQL 代码**: +```sql +SELECT + product_name, -- 选择商品名称 + SUM(price * quantity) as total_sales -- 计算销售总额 +FROM order_items -- 从订单明细表 +GROUP BY product_name; -- 按商品名称分组 +``` + +**详细注释**: +```sql +SELECT + product_name, -- 商品名称 + SUM(price * quantity) as total_sales -- 总额 = 单价 × 数量,然后求和 +FROM order_items +GROUP BY product_name; -- 按商品分组 +``` + +**聚合后(结果)**: + +| product_name | total_sales | +| :--- | :--- | +| iPhone | 15000(5000×1 + 5000×2) | +| 手机壳 | 100(50×2) | +| iPad | 3000(3000×1) | +| AirPods | 1000(1000×1) | + +--- + +### 6.5 多维度聚合:按"多个类别"统计 + +#### 场景:统计每个用户每天的消费 + +**明细数据(orders 表)**: + +| order_id | user_id | date | amount | +| :--- | :--- | :--- | :--- | +| 1 | U001 | 2024-01-01 | 100 | +| 2 | U002 | 2024-01-01 | 150 | +| 3 | U001 | 2024-01-02 | 200 | +| 4 | U002 | 2024-01-02 | 120 | +| 5 | U001 | 2024-01-02 | 180 | + +**问题**:统计每个用户每天的消费? + +**SQL 代码**: +```sql +SELECT + user_id, -- 用户 ID + date, -- 日期 + SUM(amount) as daily_amount -- 每天消费总额 +FROM orders +GROUP BY user_id, date; -- 按用户和日期分组 +``` + +**执行过程**: + +``` +步骤 1:按 user_id 和 date 分组 + +分组 1(U001, 2024-01-01): + 订单 1:U001, 2024-01-01, 100 + +分组 2(U002, 2024-01-01): + 订单 2:U002, 2024-01-01, 150 + +分组 3(U001, 2024-01-02): + 订单 3:U001, 2024-01-02, 200 + 订单 5:U001, 2024-01-02, 180 + +分组 4(U002, 2024-01-02): + 订单 4:U002, 2024-01-02, 120 + +步骤 2:对每组进行聚合 + +分组 1(U001, 2024-01-01): + daily_amount = 100 + +分组 2(U002, 2024-01-01): + daily_amount = 150 + +分组 3(U001, 2024-01-02): + daily_amount = 200 + 180 = 380 + +分组 4(U002, 2024-01-02): + daily_amount = 120 +``` + +**聚合后(结果)**: + +| user_id | date | daily_amount | +| :--- | :--- | :--- | +| U001 | 2024-01-01 | 100 | +| U001 | 2024-01-02 | 380 | +| U002 | 2024-01-01 | 150 | +| U002 | 2024-01-02 | 120 | ::: tip 💡 GROUP BY 的核心思想 把"明细数据"按某个维度分组,然后对每组进行统计。 @@ -180,47 +1060,56 @@ GROUP BY user_id; - **指标**:你想统计的数值(订单数、销售额等) ::: -### 3.3 多维度聚合 +--- -**问题**:如何统计每个用户每天的消费? +### 6.6 常见错误:SELECT 中的字段必须在 GROUP BY 中 -```sql -SELECT - user_id, - date, - SUM(amount) as daily_amount -FROM orders -GROUP BY user_id, date; -``` +#### 错误示例 -**结果**: - -| user_id | date | daily_amount | -| :--- | :--- | :--- | -| U001 | 2024-01-01 | 100 | -| U001 | 2024-01-02 | 200 | -| U002 | 2024-01-01 | 150 | - -::: warning 常见错误 ```sql -- ❌ 错误:user_id 没有在 GROUP BY 中 -SELECT user_id, SUM(amount) +SELECT user_id, SUM(amount) as total_amount FROM orders; - --- ✅ 正确:所有非聚合字段都要在 GROUP BY 中 -SELECT user_id, SUM(amount) -FROM orders -GROUP BY user_id; ``` -::: + +**为什么会报错?** + +因为你想要按 user_id 显示,但没有按 user_id 分组,数据库不知道"怎么显示 user_id"。 + +**正确写法**: +```sql +-- ✅ 正确:所有非聚合字段都要在 GROUP BY 中 +SELECT + user_id, -- 非聚合字段,必须在 GROUP BY 中 + SUM(amount) as total_amount -- 聚合字段,可以不在 GROUP BY 中 +FROM orders +GROUP BY user_id; -- 按 user_id 分组 +``` + +#### 记忆规则 + +**SELECT 中的字段,只有两种情况**: +1. **聚合函数**:COUNT(), SUM(), AVG(), MAX(), MIN() → 不需要在 GROUP BY 中 +2. **普通字段**:必须在 GROUP BY 中 + +**例子**: +```sql +-- ✅ 正确 +SELECT + user_id, -- 普通字段 → 在 GROUP BY 中 + date, -- 普通字段 → 在 GROUP BY 中 + SUM(amount) -- 聚合函数 → 不需要在 GROUP BY 中 +FROM orders +GROUP BY user_id, date; -- 所有普通字段都在这里 +``` --- -## 4. 可视化基础:让数据"会说话" +## 7. 可视化基础:让数据"会说话" 好的可视化能让人一眼看懂数据的规律。 -### 4.1 常用图表类型 +### 7.1 常用图表类型 | 图表类型 | 用途 | 示例 | | :--- | :--- | :--- | @@ -229,7 +1118,7 @@ GROUP BY user_id; | **饼图** | 展示占比 | 用户来源分布、商品品类占比 | | **散点图** | 探索关系 | 广告投入 vs 销售额 | -### 4.2 图表选择指南 +### 7.2 图表选择指南 | 想展示 | 选择图表 | | :--- | :--- | @@ -248,11 +1137,11 @@ GROUP BY user_id; --- -## 5. 数据清洗:垃圾进,垃圾出 +## 8. 数据清洗:垃圾进,垃圾出 **"Garbage In, Garbage Out"** —— 如果数据质量差,分析结果就不可信。 -### 5.1 常见数据问题 +### 8.1 常见数据问题 | 问题类型 | 示例 | 影响 | | :--- | :--- | :--- | @@ -261,7 +1150,7 @@ GROUP BY user_id; | **异常值** | 年龄 = 200 岁 | 均值被拉偏 | | **格式不一致** | 日期:2024-01-01 和 01/01/2024 | 无法正确排序 | -### 5.2 数据清洗步骤 +### 8.2 数据清洗步骤 | 步骤 | 操作 | SQL 示例 | | :--- | :--- | :--- | @@ -272,31 +1161,172 @@ GROUP BY user_id; --- -## 6. 漏斗分析:找到转化瓶颈 +## 9. 漏斗分析:找到转化瓶颈 -漏斗分析用于追踪用户在一系列步骤中的转化情况,找到"流失最严重"的环节。 +**漏斗分析**就是追踪用户在一系列步骤中的转化情况,找到"流失最严重"的环节。 -### 6.1 什么是漏斗分析? +### 9.1 什么是漏斗分析? -**示例:电商购物流程** +#### 用生活例子来理解 + +**场景:你开了一家咖啡店** + +``` +进店的人 → 品尝试饮 → 办理会员卡 → 成为常客 + 100人 → 50人 → 20人 → 10人 + 100% → 50% → 20% → 10% +``` + +**问题**:为什么最终只有 10 人成为常客? + +**分析**: +- **进店 → 品尝**:流失 50 人(转化率 50%) +- **品尝 → 会员**:流失 30 人(转化率 40%) +- **会员 → 常客**:流失 10 人(转化率 50%) + +**结论**:最大流失在"品尝 → 会员"环节,说明会员卡吸引力不够。 + +--- + +#### 电商购物流程的漏斗分析 + +**场景:用户在电商 App 购物** ``` 访问商品页 → 加入购物车 → 进入结算页 → 完成支付 - 10000 → 6000 → 4000 → 2500 - 100% → 60% → 40% → 25% + 10000 → 6000 → 4000 → 2500 + 100% → 60% → 40% → 25% ``` -**关键指标**: +**计算过程**: -| 指标 | 定义 | 示例 | -| :--- | :--- | :--- | -| **转化率** | 进入下一步的人数 / 当前步骤人数 | 60% 的用户加入购物车 | -| **整体转化率** | 最终完成人数 / 初始人数 | 25% 的用户完成购买 | -| **流失率** | 1 - 转化率 | 40% 的用户在购物车环节流失 | +**步骤 1:访问商品页** +``` +访问人数 = 10000 人 +占比 = 10000 / 10000 = 100% +``` -### 6.2 如何优化漏斗? +**步骤 2:加入购物车** +``` +加购人数 = 6000 人 +转化率 = 6000 / 10000 = 60% +流失率 = 1 - 60% = 40% +``` -**步骤 1:找到最弱的环节** +**步骤 3:进入结算页** +``` +结算人数 = 4000 人 +转化率 = 4000 / 10000 = 40% +流失率 = 1 - 40% = 60% +``` + +**步骤 4:完成支付** +``` +支付人数 = 2500 人 +转化率 = 2500 / 10000 = 25% +流失率 = 1 - 25% = 75% +``` + +#### 漏斗的 ASCII 图示 + +``` +访问商品页 (10000 人) +███████████████████████████████████████████████████ +│ +│ 6000 人流失 (40%) +↓ +加入购物车 (6000 人) +████████████████████████████████ +│ +│ 2000 人流失 (20%) +↓ +进入结算页 (4000 人) +████████████████████ +│ +│ 1500 人流失 (15%) +↓ +完成支付 (2500 人) +██████████████ +``` + +#### 关键指标 + +| 指标 | 定义 | 计算公式 | 示例 | +| :--- | :--- | :--- | :--- | +| **单步转化率** | 进入下一步的人数 / 当前步骤人数 | 下一步人数 / 当前人数 | 60% 的用户加入购物车 | +| **整体转化率** | 最终完成人数 / 初始人数 | 最终人数 / 初始人数 | 25% 的用户完成购买 | +| **单步流失率** | 1 - 单步转化率 | 1 - 转化率 | 40% 的用户在购物车环节流失 | +| **总体流失率** | 1 - 整体转化率 | 1 - 整体转化率 | 75% 的用户最终未完成购买 | + +--- + +### 9.2 如何计算漏斗的每一步? + +#### SQL 代码示例 + +假设我们有一个用户行为表 `user_events`: + +| event_id | user_id | event_name | timestamp | +| :--- | :--- | :--- | :--- | +| 1 | U001 | view_product | 2024-01-01 10:00:00 | +| 2 | U001 | add_to_cart | 2024-01-01 10:01:00 | +| 3 | U001 | checkout | 2024-01-01 10:02:00 | +| 4 | U001 | purchase | 2024-01-01 10:03:00 | +| 5 | U002 | view_product | 2024-01-01 10:00:00 | +| 6 | U002 | add_to_cart | 2024-01-01 10:01:00 | +| 7 | U003 | view_product | 2024-01-01 10:00:00 | + +**问题**:计算每个步骤的用户数? + +```sql +-- 步骤 1:访问商品页的用户数 +SELECT COUNT(DISTINCT user_id) as view_count +FROM user_events +WHERE event_name = 'view_product'; + +-- 结果: +-- | view_count | +-- | :--- | +-- | 10000 | + +-- 步骤 2:加入购物车的用户数 +SELECT COUNT(DISTINCT user_id) as add_to_cart_count +FROM user_events +WHERE event_name = 'add_to_cart'; + +-- 结果: +-- | add_to_cart_count | +-- | :--- | +-- | 6000 | + +-- 步骤 3:进入结算页的用户数 +SELECT COUNT(DISTINCT user_id) as checkout_count +FROM user_events +WHERE event_name = 'checkout'; + +-- 结果: +-- | checkout_count | +-- | :--- | +-- | 4000 | + +-- 步骤 4:完成支付的用户数 +SELECT COUNT(DISTINCT user_id) as purchase_count +FROM user_events +WHERE event_name = 'purchase'; + +-- 结果: +-- | purchase_count | +-- | :--- | +-- | 2500 | +``` + +--- + +### 9.3 如何优化漏斗? + +#### 步骤 1:找到最弱的环节 + +**分析漏斗数据**: ``` 访问 → 加购 → 结算 → 支付 @@ -304,23 +1334,201 @@ GROUP BY user_id; -40% -20% -15% ``` -最大的流失在"访问 → 加购"环节(-40%),说明**商品页没有吸引力**。 +**流失分析**: +- **访问 → 加购**:流失 40%(最大!) +- **加购 → 结算**:流失 20% +- **结算 → 支付**:流失 15% -**步骤 2:针对性优化** +**结论**:最大流失在"访问 → 加购"环节,说明**商品页没有吸引力**。 -| 问题环节 | 可能原因 | 优化方案 | -| :--- | :--- | :--- | -| 访问 → 加购 | 商品详情不清晰 | 优化图片、描述、评价 | -| 加购 → 结算 | 运费不透明 | 明确显示总价(含运费) | -| 结算 → 支付 | 支付流程复杂 | 减少表单字段,支持一键支付 | +#### 步骤 2:针对性优化 + +| 问题环节 | 流失率 | 可能原因 | 优化方案 | 预期效果 | +| :--- | :--- | :--- | :--- | :--- | +| **访问 → 加购** | 40% | 商品详情不清晰 | 优化图片、描述、评价 | 提升至 60% | +| **加购 → 结算** | 20% | 运费不透明 | 明确显示总价(含运费) | 提升至 85% | +| **结算 → 支付** | 15% | 支付流程复杂 | 减少表单字段,支持一键支付 | 提升至 90% | + +#### 步骤 3:验证优化效果 + +**优化前的漏斗**: +``` +访问 → 加购 → 结算 → 支付 +100% → 60% → 40% → 25% +``` +**整体转化率:25%** + +**优化后的漏斗**: +``` +访问 → 加购 → 结算 → 支付 +100% → 60% → 51% → 46% +``` +**整体转化率:46%** + +**提升**:整体转化率从 25% 提升到 46%,增长了 84%! --- -## 7. 留存分析:衡量产品粘性 +### 9.4 实战案例:优化注册流程 + +#### 背景 + +某社交 App 的注册流程: +``` +打开 App → 输入手机号 → 输入验证码 → 设置密码 → 注册成功 + 10000 → 8000 → 6000 → 3000 → 1000 + 100% → 80% → 60% → 30% → 10% +``` + +**问题**:整体转化率只有 10%,太低了! + +#### 分析 + +**最大流失环节**:设置密码 → 注册成功(流失 67%) + +**用户调研**: +- "密码规则太复杂" +- "不想设置密码,想用微信登录" +- "输入密码后还要再输一遍,太麻烦" + +#### 优化方案 + +**方案一:简化密码规则** +- ❌ 原来:必须包含大小写字母、数字、特殊符号,至少 8 位 +- ✅ 优化后:6-20 位,任意字符 + +**方案二:支持第三方登录** +- 新增微信、Apple ID 一键登录 + +**方案三:去掉确认密码** +- 输入一次密码即可,用"显示密码"按钮代替确认 + +#### 优化后的漏斗 + +``` +打开 App → 输入手机号 → 输入验证码 → 注册成功(第三方登录) + 10000 → 9000 → 8000 → 4000 + 100% → 90% → 80% → 40% +``` + +**整体转化率**:从 10% 提升到 40%,增长了 4 倍! + +--- + +## 10. 留存分析:衡量产品粘性 **留存率**衡量用户在首次使用后持续使用的情况,是产品健康度的核心指标。 -### 7.1 留存率类型 +### 10.1 什么是留存? + +#### 用通俗的语言来理解 + +**生活例子**:你开了一家健身房 + +``` +第一天:100 个人办了健身卡 +第二天:只有 45 个人来锻炼 +第七天:只有 20 个人来锻炼 +第三十天:只有 10 个人来锻炼 +``` + +**这意味着什么?** +- **第二天**:55 个人不来了(流失了) +- **第七天**:80 个人不来了(流失了) +- **第三十天**:90 个人不来了(流失了) + +**问题**:为什么这么多人不来了? + +**可能的原因**: +- 健身房太远 +- 价格太贵 +- 没有私教指导 +- 设施不好 + +--- + +#### 产品的留存:用户会不会"回头" + +**场景:一个新闻 App** + +**用户 A 的故事**: +``` +第 1 天(1 月 1 日):下载 App,看了 3 篇新闻 +第 2 天(1 月 2 日):又打开 App,看了 5 篇新闻 ✅ 留存了! +第 3 天(1 月 3 日):没打开 +... +第 7 天(1 月 7 日):又打开了 App ✅ 留存了! +... +第 30 天(1 月 30 日):没打开 ❌ 没有留存 +``` + +**用户 A 的留存情况**: +- **次日留存**:✅ 留存(1 月 2 日打开) +- **7 日留存**:✅ 留存(1 月 7 日打开) +- **30 日留存**:❌ 未留存(1 月 30 日没打开) + +--- + +### 10.2 留存率类型 + +#### 次日留存(Day 1 Retention) + +**定义**:注册第二天还活跃的用户占比 + +**计算公式**: +``` +次日留存率 = 第二天还活跃的用户数 / 第一天注册的用户数 +``` + +**例子**: +``` +1 月 1 日注册的用户:1000 人 +1 月 2 日还活跃的用户:450 人 + +次日留存率 = 450 / 1000 = 45% +``` + +--- + +#### 7 日留存(Day 7 Retention) + +**定义**:注册第 7 天还活跃的用户占比 + +**计算公式**: +``` +7 日留存率 = 第 7 天还活跃的用户数 / 注册用户数 +``` + +**例子**: +``` +1 月 1 日注册的用户:1000 人 +1 月 7 日还活跃的用户:320 人 + +7 日留存率 = 320 / 1000 = 32% +``` + +--- + +#### 30 日留存(Day 30 Retention) + +**定义**:注册第 30 天还活跃的用户占比 + +**计算公式**: +``` +30 日留存率 = 第 30 天还活跃的用户数 / 注册用户数 +``` + +**例子**: +``` +1 月 1 日注册的用户:1000 人 +1 月 30 日还活跃的用户:180 人 + +30 日留存率 = 180 / 1000 = 18% +``` + +--- + +### 10.3 留存率总结 | 类型 | 定义 | 计算公式 | 健康标准 | | :--- | :--- | :--- | :--- | @@ -328,7 +1536,11 @@ GROUP BY user_id; | **7 日留存** | 注册第 7 天还来的用户占比 | Day 7 活跃 / 注册用户 | > 20% | | **30 日留存** | 注册第 30 天还来的用户占比 | Day 30 活跃 / 注册用户 | > 10% | -### 7.2 如何计算留存率? +--- + +### 10.4 如何计算留存率? + +#### 留存表 **示例:1 月 1 日注册的 1000 名用户** @@ -336,17 +1548,107 @@ GROUP BY user_id; | :--- | :--- | :--- | :--- | :--- | | 2024-01-01 | 1000 | 45% (450 人) | 32% (320 人) | 18% (180 人) | | 2024-01-02 | 1200 | 42% (504 人) | 28% (336 人) | 15% (180 人) | +| 2024-01-03 | 900 | 48% (432 人) | 35% (315 人) | 20% (180 人) | -**次日留存率** = 1 月 2 日还活跃的用户 / 1 月 1 日注册用户 -= 450 / 1000 = 45% +**计算示例(1 月 1 日)**: +``` +次日留存率 = 1 月 2 日还活跃的用户 / 1 月 1 日注册用户 + = 450 / 1000 + = 45% -### 7.3 留存率的意义 +7 日留存率 = 1 月 7 日还活跃的用户 / 1 月 1 日注册用户 + = 320 / 1000 + = 32% -| 留存率 | 产品状态 | 说明 | -| :--- | :--- | :--- | -| **高留存** (>40%) | 健康增长 | 用户喜欢,持续使用 | -| **中等留存** (20-40%) | 需要优化 | 产品还行,但不够吸引人 | -| **低留存** (<20%) | 危险 | 用户来一次就走,产品有问题 | +30 日留存率 = 1 月 30 日还活跃的用户 / 1 月 1 日注册用户 + = 180 / 1000 + = 18% +``` + +--- + +### 10.5 留存率的健康标准 + +| 留存率 | 产品状态 | 说明 | 建议 | +| :--- | :--- | :--- | :--- | +| **高留存** (>40%) | 健康增长 | 用户喜欢,持续使用 | 继续保持,扩大规模 | +| **中等留存** (20-40%) | 需要优化 | 产品还行,但不够吸引人 | 分析用户行为,优化核心功能 | +| **低留存** (<20%) | 危险 | 用户来一次就走,产品有问题 | 重新审视产品定位,解决核心问题 | + +#### 不同产品的留存标准 + +| 产品类型 | 次日留存 | 7 日留存 | 30 日留存 | +| :--- | :--- | :--- | :--- | +| **社交 App** | 40-50% | 25-35% | 15-25% | +| **游戏** | 35-45% | 15-25% | 5-15% | +| **电商** | 25-35% | 10-20% | 5-10% | +| **工具类** | 30-40% | 15-25% | 10-20% | + +--- + +### 10.6 留存率的意义 + +#### 场景一:高 DAU + 低留存 = "烧钱买量" + +**数据**: +``` +DAU:10 万 +次日留存:15% +``` + +**分析**: +- 虽然 DAU 很高,但留存很低 +- 说明大部分用户只来一次就走 +- 这是在"烧钱买量",不可持续 + +**问题**:为什么用户不回头? + +**可能原因**: +- 广告宣传与实际产品不符 +- 注册流程太复杂,用户流失 +- 产品没有核心价值 + +--- + +#### 场景二:低 DAU + 高留存 = "慢热型产品" + +**数据**: +``` +DAU:1 万 +次日留存:50% +30 日留存:30% +``` + +**分析**: +- 虽然 DAU 不高,但留存很高 +- 说明产品很好,用户很喜欢 +- 这是"慢热型产品",需要时间积累 + +**建议**: +- 继续优化产品 +- 加强口碑传播 +- 逐步扩大用户规模 + +--- + +#### 场景三:高 DAU + 高留存 = 健康增长 + +**数据**: +``` +DAU:10 万 +次日留存:50% +30 日留存:30% +``` + +**分析**: +- DAU 高,留存也高 +- 说明产品很成功,用户很喜欢 +- 这是健康增长的标志 🎯 + +**建议**: +- 继续保持 +- 扩大规模 +- 探索商业模式 ::: tip 💡 留存 vs DAU - **高 DAU + 低留存** = "烧钱买量",不可持续 @@ -356,17 +1658,132 @@ GROUP BY user_id; --- -## 8. 实战:用户行为分析 +### 10.7 如何提升留存率? + +#### 步骤 1:分析用户流失原因 + +**方法一:用户访谈** +- 联系流失用户,问他们为什么不用 +- 找到共性问题 + +**方法二:行为分析** +- 分析用户在哪里流失 +- 找到流失前的行为模式 + +**方法三:A/B 测试** +- 测试不同的产品改进方案 +- 看哪个方案能提升留存 + +--- + +#### 步骤 2:针对性优化 + +**问题一:次日留存低** + +**可能原因**: +- 注册流程太复杂 +- 产品不会用 +- 没有找到核心价值 + +**优化方案**: +- 简化注册流程 +- 添加新手引导 +- 优化核心功能的体验 + +--- + +**问题二:7 日留存低** + +**可能原因**: +- 新鲜感消失 +- 没有持续使用的动力 +- 找不到使用场景 + +**优化方案**: +- 添加个性化推荐 +- 推送通知(不要太多) +- 设计"每日任务"或"签到奖励" + +--- + +**问题三:30 日留存低** + +**可能原因**: +- 内容更新太慢 +- 用户需求变化 +- 竞品更好 + +**优化方案**: +- 加快内容更新 +- 添加新功能 +- 建立用户社区 + +--- + +### 10.8 实战案例:如何提升游戏的留存率 + +#### 背景 + +某休闲游戏: +``` +次日留存:25%(目标:40%) +7 日留存:10%(目标:20%) +30 日留存:3%(目标:10%) +``` + +#### 分析 + +**用户行为分析**: +``` +第一天玩游戏的用户: +- 100% 完成了新手教程 +- 60% 玩到了第 5 关 +- 20% 玩到了第 10 关 +- 5% 玩到了第 20 关 +``` + +**结论**:大部分用户在第 5-10 关流失。 + +**用户调研**: +- "第 6 关太难了" +- "每次都要从头开始,太累" +- "没有奖励,不想玩了" + +#### 优化方案 + +**方案一:调整难度曲线** +- ❌ 原来:第 6 关突然变难 +- ✅ 优化后:难度渐进式提升 + +**方案二:增加存档点** +- ❌ 原来:每次都要从头开始 +- ✅ 优化后:每 5 关自动存档 + +**方案三:添加奖励系统** +- ❌ 原来:通关没有奖励 +- ✅ 优化后:通关送金币、道具 + +#### 优化后的效果 + +``` +次日留存:25% → 45% ✅(提升 80%) +7 日留存:10% → 25% ✅(提升 150%) +30 日留存:3% → 12% ✅(提升 300%) +``` + +--- + +## 11. 实战:用户行为分析 假设你负责一个电商 App 的数据分析,以下是完整的分析流程。 -### 8.1 问题定义 +### 11.1 问题定义 **目标**:提高订单转化率 **现状**:访问商品页 10 万人,最终下单 2000 人,转化率 2% -### 8.2 数据收集 +### 11.2 数据收集 | 维度 | 数据 | | :--- | :--- | @@ -374,7 +1791,7 @@ GROUP BY user_id; | **行为数据** | 浏览记录、加购、下单、支付 | | **交易数据** | 订单金额、商品品类、优惠券使用 | -### 8.3 数据分析 +### 11.3 数据分析 **步骤 1:漏斗分析** @@ -430,7 +1847,7 @@ GROUP BY traffic_source; **结论**:搜索引擎用户留存低,说明"质量不高"。 -### 8.4 行动建议 +### 11.4 行动建议 | 问题 | 原因 | 建议 | | :--- | :--- | :--- | @@ -440,11 +1857,11 @@ GROUP BY traffic_source; --- -## 9. 用 AI 辅助数据分析 +## 12. 用 AI 辅助数据分析 AI 可以帮你快速生成 SQL、分析数据、生成报告。 -### 9.1 提示词模板 +### 12.1 提示词模板 ``` 你是一位资深的数据分析师,精通 SQL 和数据可视化。请帮我分析以下数据。 @@ -470,7 +1887,7 @@ AI 可以帮你快速生成 SQL、分析数据、生成报告。 4. 业务洞察 ``` -### 9.2 实战示例:用户分群 +### 12.2 实战示例:用户分群 **输入提示词**: diff --git a/docs/zh-cn/appendix/5-data/data-models.md b/docs/zh-cn/appendix/5-data/data-models.md index 9650c97..4ca55be 100644 --- a/docs/zh-cn/appendix/5-data/data-models.md +++ b/docs/zh-cn/appendix/5-data/data-models.md @@ -42,6 +42,168 @@ posts 表: --- +## 0.5 从零开始:什么是数据? + +在深入了解数据模型之前,我们需要先搞清楚几个最基础的问题。 + +### 0.5.1 什么是数据? + +**数据**就是对事物的记录。 + +**生活中的例子**: + +- 你的购物车里的商品:商品名称、价格、数量、图片链接 +- 你的通讯录:姓名、电话、邮箱、地址 +- 你的书架:书名、作者、出版社、出版年份 +- 你的游戏存档:等级、经验值、装备、金币 + +这些都是"数据"——它们记录了某种信息。 + +### 0.5.2 数据为什么要组织? + +想象一下这些场景: + +**场景一:杂乱的通讯录** + +``` +张三 13800138000 北京朝阳区 +李四 13900139000 上海浦东新区 +王五 13700137000 lisi@example.com +赵六 13600136000 广州天河区 zhaoliu@example.com +``` + +所有信息混在一起,你想: +- 找"李四"的电话号码 → 需要逐行扫描 +- 找所有"广州"的人 → 需要逐行判断 +- 添加一个人的邮箱 → 格式不统一,不知道放哪里 + +**场景二:有组织的通讯录** + +``` +姓名 电话 邮箱 城市 +张三 13800138000 zhangsan@example.com 北京朝阳区 +李四 13900139000 lisi@example.com 上海浦东新区 +王五 13700137000 wangwu@example.com 深圳南山区 +赵六 13600136000 zhaoliu@example.com 广州天河区 +``` + +现在: +- 找"李四"的电话 → 在"姓名"列查找 → 快速定位 +- 找所有"广州"的人 → 在"城市"列筛选 → 一目了然 +- 添加新信息 → 按列填写 → 格式统一 + +**结论**:**数据需要组织才能高效使用!** + +### 0.5.3 一个简单的例子:记录你的书架 + +让我们用一个最简单的例子——"记录你的书架"——来理解数据模型是如何一步步形成的。 + +**第一步:原始想法(用一张纸记录)** + +``` +我的书架: +《JavaScript高级程序设计》 Matt Frisbie 2020年 人民邮电出版社 +《Vue.js设计与实现》 霍春阳 2022年 人民邮电出版社 +《深入浅出Node.js》 朴灵 2013年 人民邮电出版社 +``` + +**问题**: +- 想找"2022年"出版的书 → 需要逐行看 +- 想找"人民邮电出版社"的所有书 → 需要逐行判断 +- 想按"出版年份"排序 → 需要重新整理 + +**第二步:用表格组织(这就是数据模型的雏形)** + +``` +| 书名 | 作者 | 出版年份 | 出版社 | +|---------------------------|-----------|---------|--------------| +| JavaScript高级程序设计 | Matt Frisbie | 2020 | 人民邮电出版社 | +| Vue.js设计与实现 | 霍春阳 | 2022 | 人民邮电出版社 | +| 深入浅出Node.js | 朴灵 | 2013 | 人民邮电出版社 | +``` + +**改进**: +- 每列有明确的含义(书名、作者、出版年份、出版社) +- 可以按任意列排序 +- 可以按任意列筛选(比如找出所有"人民邮电出版社"的书) + +**第三步:发现新需求(多本书有多个作者)** + +``` +问题:《重构:改善既有代码的设计》有两个作者:Martin Fowler 和 Kent Beck +``` + +怎么记录? + +**错误做法**:把两个名字塞进一个字段 + +``` +| 书名 | 作者 | +|------|------------------------| +| 重构 | Martin Fowler, Kent Beck | +``` + +**问题**: +- 无法单独查询"Martin Fowler 写了哪些书" +- 无法统计"每个作者写了几本书" +- 修改作者名字时需要字符串操作 + +**正确做法**:拆分成两张表,用"关系"连接 + +``` +书籍表: +| 书名 | 出版年份 | +|--------------|---------| +| 重构 | 2019 | +| JavaScript高级程序设计 | 2020 | + +作者表: +| 作者姓名 | +|------------------| +| Martin Fowler | +| Kent Beck | +| Matt Frisbie | + +书籍-作者关联表: +| 书名 | 作者姓名 | +|--------------|---------------| +| 重构 | Martin Fowler | +| 重构 | Kent Beck | +| JavaScript高级程序设计 | Matt Frisbie | +``` + +**这就是数据模型设计的核心**: +1. **识别实体**(书、作者) +2. **设计属性**(书名、出版年份、作者姓名) +3. **建立关系**(一本书可以有多个作者) + +### 0.5.4 什么是数据模型? + +**数据模型**(Data Model)就是对"数据如何组织、存储、关联"的设计方案。 + +**用盖房子来类比**: + +| 盖房子 | 数据模型 | +|:-----|:-------| +| 蓝图设计 | 数据模型设计 | +| 客厅、卧室、厨房 | 不同的表(用户表、订单表、商品表) | +| 承重墙、水电管线 | 主键、外键(关系) | +| 房屋的格局 | 表的结构(字段、类型) | + +::: tip 💡 为什么要设计数据模型? +想象一下: +- 没有蓝图盖房子 → 房子可能会倒、功能混乱、无法扩建 +- 没有数据模型写程序 → 数据冗余、查询缓慢、难以维护 + +**好的数据模型让你:** +- 数据不重复(节省空间) +- 查询快速(性能好) +- 易于扩展(新增功能方便) +- 避免错误(数据一致性强) +::: + +--- + ## 1. 数据模型的重要性 **数据模型**(Data Model)是对现实世界的抽象,描述数据如何存储、组织和关联。 @@ -67,9 +229,234 @@ posts 表: ## 2. ER 图:实体关系建模 -**ER 图**(Entity-Relationship Diagram)是用图形化方式描述数据模型的工具。 +### 2.0 为什么需要画图? -### 2.1 核心概念 +想象你和朋友聊天,描述你的家庭关系: + +**用文字描述**: +> "我有一个爸爸、一个妈妈、一个哥哥。我爸爸的父母是我爷爷奶奶,我妈妈的父母是我外公外婆。我哥哥结婚了,有一个嫂子。" + +听的人可能会困惑: +- 你哥哥和你嫂子是什么关系? +- 你和你嫂子是什么关系? +- 如果你哥哥又有孩子,那孩子和你是什么关系? + +**用图画出来**: + +``` + 爷爷 -- 奶奶 + | + 爸爸 -- 妈妈 -- 外公 -- 外婆 + | | + 你 哥哥 -- 嫂子 +``` + +瞬间就清晰了! + +**ER 图的作用**: +- 把复杂的"数据关系"可视化 +- 帮助设计者理清思路 +- 方便团队沟通讨论 +- 在写代码前发现问题 + +**ER 图**(Entity-Relationship Diagram,实体关系图)就是专门用来画"数据关系"的工具。 + +### 2.1 手把手:从零画出你的第一个 ER 图 + +让我们用一个**家庭关系**的例子,一步步画出 ER 图。 + +#### 第一步:识别"实体" + +**实体**(Entity)就是现实世界中的"对象"或"事物"。 + +在家庭关系中,实体有: +- 人(Person) +- 家庭(Family) + +**问题**:我们把"人"作为核心实体,"家庭"可以暂时忽略。 + +#### 第二步:确定"属性" + +**属性**(Attribute)就是实体的"特征"或"信息"。 + +"人"这个实体的属性: +- 姓名(name) +- 性别(gender) +- 出生日期(birth_date) +- 身份证号(id_card) + +#### 第三步:识别"关系" + +**关系**(Relationship)就是实体之间的"联系"。 + +家庭中的关系: +- 结婚(丈夫 ↔ 妻子) +- 亲子(父母 → 孩子) +- 兄弟姐妹(兄弟姐妹之间) + +#### 第四步:确定"关系类型" + +每种关系都有一个"基数"(Cardinality): + +**一对一(1:1)**: +- 一个丈夫 ↔ 一个妻子(假设一夫一妻制) +``` +丈夫 1 ---- 1 妻子 +``` + +**一对多(1:N)**: +- 一个父母 → 多个孩子 +``` +父母 1 ---- N 孩子 +``` + +**多对多(M:N)**: +- 多个兄弟姐妹 ↔ 多个兄弟姐妹 +``` +兄弟姐妹 M ---- N 兄弟姐妹 +``` + +#### 第五步:画出完整的 ER 图 + +**ER 图的基本符号**: + +| 符号 | 名称 | 含义 | 生活类比 | +|:---:|:---:|:---|:---| +| ▭ | 矩形 | 实体(表) | 一个"箱子"装一类数据 | +| ▭ | 椭圆 | 属性(字段) | 箱子里的"物品" | +| ◇ | 菱形 | 关系 | 箱子之间的"连线" | +| — | 线条 | 连接 | 把东西串起来的"绳子" | + +**家庭关系的 ER 图示例**: + +``` + ┌─────────┐ + │ 人 │ + └─────────┘ + │ 姓名 │ + │ 性别 │ + │ 出生日期│ + └─────────┘ + │ + │ + ┌────┴────┐ + │ │ + 结婚 亲子 + 1:1 1:N +``` + +::: tip 💡 为什么要画 ER 图? +1. **设计前理清思路**:画图比改代码容易得多 +2. **团队沟通**:一张图胜过千言万语 +3. **发现遗漏**:画图时容易发现"咦,这个关系好像没考虑" +4. **文档留存**:新人看图就能理解系统结构 +::: + +### 2.2 核心概念详解 + +#### 实体(Entity) + +**实体**就是你想记录的"对象"或"事物"。 + +**示例**: +- 用户(User) +- 订单(Order) +- 商品(Product) +- 文章(Article) + +**如何识别实体?** +问自己:"我需要记录什么信息?" +- 我需要记录用户 → 用户实体 +- 我需要记录订单 → 订单实体 +- 我需要记录商品 → 商品实体 + +#### 属性(Attribute) + +**属性**就是实体的"特征"或"详细信息"。 + +**示例**: +- 用户实体的属性:用户名、邮箱、电话、注册时间 +- 订单实体的属性:订单号、金额、状态、创建时间 + +**如何识别属性?** +问自己:"这个实体有哪些信息需要记录?" + +#### 关系(Relationship) + +**关系**就是实体之间的"联系"。 + +**示例**: +- 用户**下**订单(下单关系) +- 订单**包含**商品(包含关系) +- 用户**购买**商品(购买关系) + +**如何识别关系?** +问自己:"这些实体之间有什么关联?" + +### 2.3 用一个简单例子练习 + +让我们画一个**班级管理系统**的 ER 图。 + +#### 第一步:识别实体 + +在这个系统中,有哪些实体? +- 学生(Student) +- 课程(Course) +- 老师(Teacher) + +#### 第二步:确定属性 + +每个实体有哪些属性? + +**学生**:学号、姓名、班级 +**课程**:课程编号、课程名称、学分 +**老师**:工号、姓名、职称 + +#### 第三步:识别关系 + +- 学生**选修**课程(多对多:一个学生可以选多门课,一门课可以被多个学生选) +- 老师**教授**课程(一对多:一个老师可以教多门课,一门课通常由一个老师教) + +#### 第四步:画出 ER 图 + +``` + ┌─────────┐ + │ 学生 │ + └─────────┘ + │ 学号 │ + │ 姓名 │ + │ 班级 │ + └─────────┘ + │ + │ 选修 (M:N) + │ + ┌─────────┐ + │ 课程 │ + └─────────┘ + │ 课程编号│ + │ 课程名称│ + │ 学分 │ + └─────────┘ + │ + │ 教授 (1:N) + │ + ┌─────────┐ + │ 老师 │ + └─────────┘ + │ 工号 │ + │ 姓名 │ + │ 职称 │ + └─────────┘ +``` + +### 2.4 核心概念总结表 + +| 符号 | 含义 | 示例 | 通俗理解 | +| :--- | :--- | :--- | :--- | +| **矩形** | 实体(表) | 用户、订单、商品 | 一个"箱子"装一类数据 | +| **椭圆** | 属性(字段) | 用户名、邮箱、电话 | 箱子里的"物品" | +| **菱形** | 关系 | 下单、支付、评论 | 箱子之间的"连线" | +| **线条** | 连接 | 表与表的关联 | 把东西串起来的"绳子" | | 符号 | 含义 | 示例 | | :--- | :--- | :--- | @@ -78,7 +465,7 @@ posts 表: | **菱形** | 关系 | 下单、支付、评论 | | **线条** | 连接 | 表与表的关联 | -### 2.2 完整的 ER 图示例 +### 2.5 完整的 ER 图示例 👇 **动手试试看**:探索用户-订单-商品的实体关系模型: @@ -92,9 +479,168 @@ posts 表: ### 3.1 一对一(One-to-One) -**定义**:A 表的一条记录对应 B 表的一条记录。 +#### 3.1.1 什么是"一对一"? -**示例**:用户 ↔ 详细资料 +**定义**:A 表的一条记录对应 B 表的一条记录,反过来也成立。 + +**生活类比**: +- 一个人 ↔ 一个身份证号码 +- 一个丈夫 ↔ 一个妻子(一夫一妻制) +- 一个国家 ↔ 一个首都 + +#### 3.1.2 用生活场景理解 + +想象你在做一个**用户系统**: + +**场景**:用户基本信息 + 详细资料 + +你发现用户信息太多了: +- 基本信息:用户名、邮箱、密码(经常用) +- 详细资料:昵称、头像、个人简介、生日、地址、手机号(不常用) + +**问题**:要不要把所有信息放一张表? + +**方案一:全放一张表** + +``` +users 表: +| id | username | email | password | nickname | avatar | bio | birthday | address | phone | +``` + +**缺点**: +- 表太宽,字段太多(超过 20 个) +- 查询"登录"时只需要基本字段,但会加载所有字段(浪费性能) +- 详细资料可能很长(bio、avatar),拖慢查询速度 + +**方案二:拆分成两张表(一对一关系)** + +``` +users 表(基本信息): +| id | username | email | password_hash | +| 1 | zhangsan | zhangsan@qq.com | xxxxx... | + +user_profiles 表(详细资料): +| user_id | nickname | avatar | bio | birthday | +| 1 | 小张 | ... | 热爱编程... | 1995-06-15 | +``` + +**优点**: +- 登录时只查 users 表,快速 +- 查看资料时再 JOIN user_profiles 表 +- 分离敏感信息(password)和非敏感信息 + +**关系表示**: +``` +users (1) ---- (1) user_profiles + | | + id (主键) user_id (主键 + 外键) +``` + +#### 3.1.3 SQL 实现 + +**方式 1:外键唯一约束(推荐)** + +```sql +-- 用户表 +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 用户详细资料表 +CREATE TABLE user_profiles ( + user_id BIGINT PRIMARY KEY, -- user_id 同时是主键和外键 + nickname VARCHAR(50), + avatar VARCHAR(255), + bio TEXT, + birthday DATE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 插入数据 +INSERT INTO users (id, username, email, password_hash) VALUES +(1, 'zhangsan', 'zhangsan@qq.com', 'hashed_password'); + +INSERT INTO user_profiles (user_id, nickname, bio) VALUES +(1, '小张', '热爱编程,热爱生活'); + +-- 查询:获取用户完整信息 +SELECT u.*, p.nickname, p.bio, p.avatar +FROM users u +LEFT JOIN user_profiles p ON u.id = p.user_id +WHERE u.id = 1; +``` + +**方式 2:在主表添加外键(不推荐)** + +```sql +-- 详细资料表 +CREATE TABLE user_profiles ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + nickname VARCHAR(50), + bio TEXT +); + +-- 用户表(引用 profile) +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50), + profile_id BIGINT UNIQUE, -- 唯一约束 + FOREIGN KEY (profile_id) REFERENCES user_profiles(id) +); +``` + +**为什么方式 1 更好?** +- 用户是"主实体",资料是"从属信息" +- 先有用户,再有资料 +- 删除用户时,资料也应该删除(CASCADE) + +#### 3.1.4 数据样子 + +``` +users 表: +┌────┬──────────┬─────────────────┬───────────────┐ +│ id │ username │ email │ password_hash │ +├────┼──────────┼─────────────────┼───────────────┤ +│ 1 │ zhangsan │ zhangsan@qq.com │ xxxxx... │ +│ 2 │ lisi │ lisi@qq.com │ yyyyy... │ +└────┴──────────┴─────────────────┴───────────────┘ + │ + │ 一对一关系 + │ +user_profiles 表: +┌──────────┬──────────┬─────────┬──────────────┐ +│ user_id │ nickname │ avatar │ bio │ +├──────────┼──────────┼─────────┼──────────────┤ +│ 1 │ 小张 │ avatar1 │ 热爱编程... │ +│ 2 │ 小李 │ avatar2 │ 喜欢读书... │ +└──────────┴──────────┴─────────┴──────────────┘ +``` + +#### 3.1.5 什么时候用一对一? + +**适合使用的场景**: +- 字段数量过多(超过 20 个字段) +- 需要分离敏感信息(密码、支付信息) +- 部分字段很少查询(登录时不需要加载简介、头像) +- 需要独立扩展(用户资料可能有多种类型:个人、企业) + +**不适合使用的场景**: +- 字段不多(少于 15 个),直接放一张表即可 +- 所有字段都会频繁查询(拆分反而需要 JOIN,降低性能) + +::: tip 💡 一对一关系的判断标准 +问自己:"A 的一条记录能对应 B 的多条记录吗?" +- 如果能 → 一对多或多对多 +- 如果不能 → 可能是一对一 + +再问:"B 的一条记录能对应 A 的多条记录吗?" +- 如果能 → 一对多 +- 如果不能 → 一对一 +::: ```sql users 表: user_profiles 表: @@ -127,112 +673,512 @@ CREATE TABLE users ( - 订单表 + 支付信息表(分离支付数据) - 商品表 + 库存表(分离库存管理) -::: tip 💡 什么时候用一对一? -当字段数量过多(超过 20 个)或需要分离敏感信息时,考虑拆分为一对一关系。 +::: tip 💡 一对一关系的判断标准 +问自己:"A 的一条记录能对应 B 的多条记录吗?" +- 如果能 → 一对多或多对多 +- 如果不能 → 可能是一对一 + +再问:"B 的一条记录能对应 A 的多条记录吗?" +- 如果能 → 一对多 +- 如果不能 → 一对一 ::: ### 3.2 一对多(One-to-Many) -**定义**:A 表的一条记录对应 B 表的多条记录。 +#### 3.2.1 什么是"一对多"? -**示例**:用户 → 订单 +**定义**:A 表的一条记录可以对应 B 表的多条记录,但 B 的一条记录只能对应 A 的一条记录。 -```sql -users 表: orders 表: -| id | username | | id | user_id | amount | -| 1 | 张三 | | 1 | 1 | 100 | - | 2 | 1 | 200 | +**生活类比**: +- 一个母亲 → 多个孩子 +- 一个班级 → 多个学生 +- 一个作者 → 多篇文章 + +**这是最常见的关系!约占数据库关系的 70%。** + +#### 3.2.2 用生活场景理解 + +想象你在做一个**电商系统**: + +**场景**:用户和订单 + +**需求**: +- 一个用户可以下多个订单 +- 一个订单只能属于一个用户 + +**怎么设计数据库?** + +**方案一:全放一张表(错误)** + +``` +users 表: +| id | username | order_1 | order_1_amount | order_2 | order_2_amount | ... | +| 1 | 张三 | 100 | 500 | 101 | 300 | ... | ``` -**实现方式**: +**问题**: +- 一个用户最多能下多少单?10 个?100 个? +- 字段数量爆炸,表结构混乱 +- 无法查询"某个订单属于哪个用户" + +**方案二:拆分成两张表(一对多关系)** + +``` +users 表: +| id | username | email | +| 1 | 张三 | zhangsan@qq.com | + +orders 表: +| id | user_id | amount | status | +| 1 | 1 | 500 | paid | +| 2 | 1 | 300 | shipping | +| 3 | 1 | 800 | completed | +``` + +**关系表示**: +``` +users (1) ---- (N) orders + | | + id (主键) user_id (外键) +``` + +**关键点**:在"多"的那张表(orders)添加"外键"(user_id),指向"一"的那张表(users)。 + +#### 3.2.3 SQL 实现 ```sql -CREATE TABLE orders ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - amount DECIMAL(10,2), - FOREIGN KEY (user_id) REFERENCES users(id) +-- 用户表("一"的那一方) +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- 查询某用户的所有订单 -SELECT * FROM orders WHERE user_id = 1; +-- 订单表("多"的那一方) +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, -- 外键,指向 users 表 + amount DECIMAL(10, 2) NOT NULL, -- 订单金额 + status VARCHAR(20) DEFAULT 'pending', -- 订单状态 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 插入数据 +INSERT INTO users (id, username, email) VALUES +(1, '张三', 'zhangsan@qq.com'), +(2, '李四', 'lisi@qq.com'); + +INSERT INTO orders (user_id, amount, status) VALUES +(1, 500.00, 'paid'), +(1, 300.00, 'shipping'), +(1, 800.00, 'completed'), +(2, 200.00, 'pending'); + +-- 查询 1:查询用户的所有订单 +SELECT o.* +FROM orders o +WHERE o.user_id = 1; + +-- 查询 2:查询订单及对应的用户信息 +SELECT o.*, u.username, u.email +FROM orders o +JOIN users u ON o.user_id = u.id +WHERE o.id = 1; + +-- 查询 3:统计每个用户的订单数量 +SELECT u.username, COUNT(o.id) as order_count +FROM users u +LEFT JOIN orders o ON u.id = o.user_id +GROUP BY u.id; ``` -**使用场景**: -- 用户 → 订单 -- 分类 → 商品 -- 部门 → 员工 +#### 3.2.4 数据样子 -::: tip 💡 最常见的关系 -一对多是关系型数据库中最常见的关系,约占 70% 的场景。 +``` +users 表("一"的一方): +┌────┬──────────┬─────────────────┐ +│ id │ username │ email │ +├────┼──────────┼─────────────────┤ +│ 1 │ 张三 │ zhangsan@qq.com │ +│ 2 │ 李四 │ lisi@qq.com │ +└────┴──────────┴─────────────────┘ + │ + │ 一对多关系 + │ +orders 表("多"的一方): +┌────┬─────────┬────────┬───────────┐ +│ id │ user_id │ amount │ status │ +├────┼─────────┼────────┼───────────┤ +│ 1 │ 1 │ 500.00 │ paid │ ← 张三的订单 +│ 2 │ 1 │ 300.00 │ shipping │ ← 张三的订单 +│ 3 │ 1 │ 800.00 │ completed │ ← 张三的订单 +│ 4 │ 2 │ 200.00 │ pending │ ← 李四的订单 +└────┴─────────┴────────┴───────────┘ +``` + +#### 3.2.5 什么时候用一对多? + +**适合使用的场景**: +- 父子关系:用户 → 订单、分类 → 商品 +- 包含关系:文章 → 评论、部门 → 员工 +- 层级关系:菜单 → 子菜单 + +**设计原则**: +- 在"多"的那张表添加外键 +- 外键指向"一"的那张表的主键 +- 使用 LEFT JOIN 可以查询"即使没有订单的用户" + +::: tip 💡 一对多关系的判断标准 +问自己:"A 的一条记录能对应 B 的多条记录吗?" +- 如果能 → 可能是一对多 +- 再问:"B 的一条记录能对应 A 的多条记录吗?" + - 如果不能 → 确定是一对多 + - 如果能 → 是多对多 ::: -### 3.3 多对多(Many-to-Many) +#### 3.3.1 什么是"多对多"? -**定义**:A 表的多条记录对应 B 表的多条记录。 +**定义**:A 表的多条记录可以对应 B 表的多条记录,反过来也成立。 -**示例**:学生 ↔ 课程 +**生活类比**: +- 多个学生 ↔ 多门课程(一个学生可以选多门课,一门课可以被多个学生选) +- 多个顾客 ↔ 多个商品(一个顾客可以买多个商品,一个商品可以被多个顾客买) +- 多个作者 ↔ 多篇文章(一篇文章可以有多个作者,一个作者可以写多篇文章) -```sql -students 表: courses 表: enrollments 表(中间表): -| id | name | | id | title | | student_id | course_id | -| 1 | 小明 | | 1 | 数学 | | 1 | 1 | -| 2 | 小红 | | 2 | 英语 | | 1 | 2 | - | 2 | 1 | +**这是最复杂的关系,需要使用"中间表"!** + +#### 3.3.2 用生活场景理解 + +想象你在做一个**学生选课系统**: + +**场景**:学生和课程 + +**需求**: +- 一个学生可以选多门课程 +- 一门课程可以被多个学生选 + +**怎么设计数据库?** + +**方案一:在学生表添加课程字段(错误)** + +``` +students 表: +| id | name | courses | +| 1 | 小明 | 1,2,3 | +| 2 | 小红 | 1,2 | ``` -**实现方式**: +**问题**: +- 无法查询"选了数学 1 的所有学生"(需要字符串匹配,很慢) +- 无法建立外键约束 +- 无法给"选课"添加额外信息(比如:选课时间、成绩) + +**方案二:在课程表添加学生字段(同样错误)** + +``` +courses 表: +| id | title | students | +| 1 | 数学 | 1,2,3,4,5 | +``` + +**同样的问题**:查询慢、无法建立约束、无法添加额外信息。 + +**方案三:使用中间表(正确!)** + +创建三张表: +1. students 表(学生) +2. courses 表(课程) +3. enrollments 表(选课记录,中间表) + +**关系表示**: +``` +students (M) ---- (M) courses + | | + | enrollments(中间表) + | | student_id | course_id | +``` + +#### 3.3.3 SQL 实现 ```sql -- 学生表 CREATE TABLE students ( - id BIGINT PRIMARY KEY, - name VARCHAR(50) + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + student_no VARCHAR(20) UNIQUE NOT NULL, -- 学号 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 课程表 CREATE TABLE courses ( - id BIGINT PRIMARY KEY, - title VARCHAR(100) + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + credits DECIMAL(3, 1) NOT NULL, -- 学分 + teacher VARCHAR(50) ); --- 中间表(选课记录) +-- 选课记录表(中间表) CREATE TABLE enrollments ( - student_id BIGINT, - course_id BIGINT, - enrolled_at TIMESTAMP, - PRIMARY KEY (student_id, course_id), - FOREIGN KEY (student_id) REFERENCES students(id), - FOREIGN KEY (course_id) REFERENCES courses(id) + student_id BIGINT NOT NULL, + course_id BIGINT NOT NULL, + enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 选课时间 + grade DECIMAL(5, 2), -- 成绩(可选) + PRIMARY KEY (student_id, course_id), -- 联合主键,防止重复选课 + FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE ); --- 查询小明选的所有课程 -SELECT c.* FROM courses c +-- 插入数据 +INSERT INTO students (id, name, student_no) VALUES +(1, '小明', '2021001'), +(2, '小红', '2021002'), +(3, '小刚', '2021003'); + +INSERT INTO courses (id, title, credits, teacher) VALUES +(1, '数学', 4.0, '王老师'), +(2, '英语', 3.0, '李老师'), +(3, '物理', 4.0, '张老师'); + +-- 学生选课 +INSERT INTO enrollments (student_id, course_id) VALUES +(1, 1), -- 小明选了数学 +(1, 2), -- 小明选了英语 +(2, 1), -- 小红选了数学 +(2, 3), -- 小红选了物理 +(3, 2), -- 小刚选了英语 +(3, 3); -- 小刚选了物理 + +-- 查询 1:查询小明选了哪些课程 +SELECT c.* +FROM courses c +JOIN enrollments e ON c.id = e.course_id +WHERE e.student_id = 1; + +-- 查询 2:查询数学课有哪些学生选了 +SELECT s.* +FROM students s +JOIN enrollments e ON s.id = e.student_id +WHERE e.course_id = 1; + +-- 查询 3:查询每个学生选了多少门课 +SELECT s.name, COUNT(e.course_id) as course_count +FROM students s +LEFT JOIN enrollments e ON s.id = e.student_id +GROUP BY s.id; + +-- 查询 4:录入成绩 +UPDATE enrollments +SET grade = 90.5 +WHERE student_id = 1 AND course_id = 1; + +-- 查询 5:查询小明的选课及成绩 +SELECT c.title, e.grade, e.enrolled_at +FROM courses c JOIN enrollments e ON c.id = e.course_id WHERE e.student_id = 1; ``` -**使用场景**: -- 学生 ↔ 课程 -- 用户 ↔ 角色 -- 商品 ↔ 标签 -- 文章 ↔ 分类 +#### 3.3.4 数据样子 -::: tip 💡 多对多需要中间表 -多对多关系必须通过中间表来实现,中间表包含两个外键,分别指向两张表。 +``` +students 表: +┌────┬──────┬───────────┐ +│ id │ name │ student_no│ +├────┼──────┼───────────┤ +│ 1 │ 小明 │ 2021001 │ +│ 2 │ 小红 │ 2021002 │ +│ 3 │ 小刚 │ 2021003 │ +└────┴──────┴───────────┘ + │ ┌──────────────┐ + │ │ courses 表 │ + │ ├────┬─────────┬────────┬────────┐ + │ │ id │ title │ credits│ teacher│ + │ ├────┼─────────┼────────┼────────┤ + │ │ 1 │ 数学 │ 4.0 │ 王老师 │ + │ │ 2 │ 英语 │ 3.0 │ 李老师 │ + │ │ 3 │ 物理 │ 4.0 │ 张老师 │ + │ └────┴─────────┴────────┴────────┘ + │ ▲ + │ │ + │ │ 多对多关系 + │ │ + │ ┌───────────────────┐ + │ │ enrollments(中间表)│ + │ ├──────────┬─────────┬──────────────┬───────┐ + │ │student_id│course_id│ enrolled_at │ grade │ + │ ├──────────┼─────────┼──────────────┼───────┤ + │ │ 1 │ 1 │ 2024-09-01 │ 90.5 │ ← 小明选数学 + │ │ 1 │ 2 │ 2024-09-01 │ NULL │ ← 小明选英语 + │ │ 2 │ 1 │ 2024-09-01 │ NULL │ ← 小红选数学 + │ │ 2 │ 3 │ 2024-09-01 │ NULL │ ← 小红选物理 + │ │ 3 │ 2 │ 2024-09-01 │ NULL │ ← 小刚选英语 + │ │ 3 │ 3 │ 2024-09-01 │ NULL │ ← 小刚选物理 + │ └──────────┴─────────┴──────────────┴───────┘ +``` + +#### 3.3.5 中间表的设计要点 + +**要点 1:联合主键** +```sql +PRIMARY KEY (student_id, course_id) +``` +作用:防止同一个学生重复选同一门课 + +**要点 2:外键约束** +```sql +FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE, +FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE +``` +作用:保证数据完整性 +- 删除学生时,自动删除该学生的选课记录 +- 删除课程时,自动删除该课程的选课记录 + +**要点 3:中间表可以添加额外字段** +```sql +enrolled_at TIMESTAMP, -- 选课时间 +grade DECIMAL(5, 2), -- 成绩 +status VARCHAR(20), -- 状态:选课中、已退课、已完成 +``` +这是多对多关系的一大优势! + +#### 3.3.6 什么时候用多对多? + +**适合使用的场景**: +- 标签系统:文章 ↔ 标签 +- 权限系统:用户 ↔ 角色 +- 社交关系:用户 ↔ 用户(关注、好友) +- 购物车:用户 ↔ 商品 + +**设计原则**: +- 必须创建中间表(也叫关联表、桥接表) +- 中间表包含两个外键,分别指向两张表 +- 使用联合主键防止重复关联 +- 可以在中间表添加额外属性(如:选课时间、成绩) + +::: tip 💡 多对多关系的判断标准 +问自己: +1. "A 的一条记录能对应 B 的多条记录吗?" → 是 +2. "B 的一条记录能对应 A 的多条记录吗?" → 是 +3. "A 和 B 之间的关联需要额外信息吗?" → 可能需要 + +如果 1 和 2 都是"是",这就是多对多关系,需要用中间表! ::: --- ## 4. 范式理论:从混乱到有序 -**范式**(Normalization)是数据库设计的规范,目的是消除数据冗余,避免数据异常。 +### 4.0 什么是"范式"? -### 4.1 第一范式(1NF):字段原子性 +**范式**(Normalization,规范化)是数据库设计的"规范"或"标准"。 -**要求**:每个字段都是不可再分的最小数据单元。 +**目的**: +- 消除数据冗余(重复的数据) +- 避免数据异常(插入、更新、删除时的问题) +- 让数据结构更清晰 -❌ **不符合 1NF**: +**通俗理解**: +就像整理你的衣柜: +- 乱七八糟的衣服堆在一起 → 不符合范式 +- 按类别分开(上衣、裤子、袜子)→ 符合第一范式 +- 再按颜色分开 → 符合第二范式 +- 再按季节分开 → 符合第三范式 + +**范式的级别**: +- 1NF(第一范式):基础要求 +- 2NF(第二范式):在 1NF 基础上进一步优化 +- 3NF(第三范式):在 2NF 基础上再优化 +- BCNF(BC 范式):3NF 的增强版 +- 4NF、5NF:更高级的范式(很少用到) + +**实际开发中**: +- 大部分系统做到 3NF 就够了 +- 有时为了性能,会故意违反范式(反范式化,后面会讲) + +### 4.1 第一范式(1NF):消除重复组 + +#### 4.1.1 1NF 的规则 + +**规则**:每个字段都应该是"原子性"的,不可再分。 + +**什么是不符合 1NF?** +- 一个字段包含多个值 +- 一个字段包含"组合数据"(如:地址 = 城市 + 区 + 详细地址) + +**什么是符合 1NF?** +- 每个字段只存储一个值 +- 每个字段都是"最小数据单元" + +#### 4.1.2 问题场景:订单信息重复 + +想象你在做一个**订单管理系统**: + +**场景**:一个订单包含多个商品 + +**错误的表设计(不符合 1NF)**: + +```sql +orders 表: +| id | user_id | products | total | +| 1 | 100 | iPhone(2台),iPad(1台),AirPods(3台) | 50000 | +``` + +**问题**: +1. `products` 字段包含多个值(不原子) +2. 无法查询"哪些订单包含 iPhone" +3. 无法统计"卖了多少台 iPad" +4. 修改商品数量需要字符串操作 +5. 无法对商品建立索引 + +#### 4.1.3 应用 1NF 后 + +**方案 1:拆分成多行(符合 1NF)** + +```sql +orders 表: +| id | user_id | product_id | product_name | quantity | price | +| 1 | 100 | 1 | iPhone | 2 | 5999 | +| 1 | 100 | 2 | iPad | 1 | 3999 | +| 1 | 100 | 3 | AirPods | 3 | 1299 | +``` + +**改进**: +- 每行只记录一个商品(原子性) +- 可以查询"哪些订单包含 iPhone" +- 可以统计"卖了多少台 iPad" +- 可以对 `product_id` 建立索引 + +**问题**:`user_id` 和 `id` 重复了(这是 2NF 要解决的问题) + +#### 4.1.4 另一个例子:地址字段 + +**不符合 1NF**: + +```sql +users 表: +| id | name | address | +| 1 | 张三 | 北京市朝阳区xxx街道 | +``` + +**问题**:`address` 包含多个信息(城市、区、详细地址),不原子。 + +**符合 1NF**: + +```sql +users 表: +| id | name | city | district | detail_address | +| 1 | 张三 | 北京市 | 朝阳区 | xxx街道 | +``` + +**改进**: +- 每个字段只存储一个信息 +- 可以按城市查询、按区统计 + +::: tip 💡 1NF 是基础 +所有关系型数据库(MySQL、PostgreSQL、Oracle)默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。即使你违反了 1NF(如存储逗号分隔的字符串),数据库也不会报错,但会导致查询和维护困难。 +::: ```sql -- 用户和地址混在一起 @@ -248,14 +1194,269 @@ WHERE e.student_id = 1; ``` ::: tip 💡 1NF 是基础 -所有关系型数据库默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。 +所有关系型数据库(MySQL、PostgreSQL、Oracle)默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。即使你违反了 1NF(如存储逗号分隔的字符串),数据库也不会报错,但会导致查询和维护困难。 ::: ### 4.2 第二范式(2NF):消除部分依赖 -**要求**:非主键字段必须完全依赖于主键(针对复合主键)。 +#### 4.2.1 2NF 的规则 -❌ **不符合 2NF**: +**前提**:表必须先符合 1NF + +**规则**:非主键字段必须**完全依赖于**主键,而不是只依赖主键的一部分。 + +**什么是"部分依赖"?** +- 当主键是"复合主键"(多个字段组成的主键)时才存在 +- 某个非主键字段只依赖主键的一部分,而不是全部 + +**什么是不符合 2NF?** +- 主键是复合主键(如:order_id + product_id) +- 某个字段(如 product_name)只依赖 product_id,不依赖 order_id + +**什么是符合 2NF?** +- 消除部分依赖 +- 所有非主键字段都完全依赖主键 + +#### 4.2.2 问题场景:订单明细表 + +想象你在设计**订单明细表**: + +**场景**:记录每个订单的每个商品 + +**表设计**: +```sql +order_items 表(订单明细): +| order_id | product_id | product_name | quantity | unit_price | subtotal | +| 100 | 1 | iPhone | 2 | 5999 | 11998 | +| 100 | 2 | iPad | 1 | 3999 | 3999 | +``` + +**主键**:(order_id, product_id) —— 复合主键 + +**问题分析**: + +| 字段 | 依赖关系 | 是否符合 2NF | +|:-----|:---------|:------------| +| quantity | 依赖 (order_id, product_id) | ✅ 符合 | +| subtotal | 依赖 (order_id, product_id) | ✅ 符合 | +| product_name | 只依赖 product_id | ❌ 不符合 | +| unit_price | 只依赖 product_id | ❌ 不符合 | + +**为什么不符合?** +- `product_name` 只依赖 `product_id` +- 即使我改变 `order_id`,`product_name` 也不会变 +- 这就是"部分依赖"(只依赖主键的一部分) + +#### 4.2.3 会有什么问题? + +**问题 1:数据冗余** +```sql +| order_id | product_id | product_name | quantity | +| 100 | 1 | iPhone | 2 | +| 101 | 1 | iPhone | 5 | ← 重复了 +| 102 | 1 | iPhone | 1 | ← 又重复了 +``` +每次订购 iPhone,都要重复存储 `product_name`。 + +**问题 2:更新异常** +如果 iPhone 改名为 "iPhone 15",需要更新所有包含该产品的订单记录。 + +**问题 3:插入异常** +无法插入一个还没有被订购的新产品(因为 order_id 不能为空)。 + +#### 4.2.4 应用 2NF 后 + +**方案:拆分成两张表** + +```sql +-- 订单明细表(只依赖复合主键) +order_items 表: +| order_id | product_id | quantity | subtotal | +| 100 | 1 | 2 | 11998 | +| 100 | 2 | 1 | 3999 | +| 101 | 1 | 5 | 29995 | + +-- 商品表(只依赖 product_id) +products 表: +| product_id | product_name | unit_price | +| 1 | iPhone | 5999 | +| 2 | iPad | 3999 | +``` + +**改进**: +- `product_name` 和 `unit_price` 只在 products 表存储一次 +- 修改商品信息时,只需更新 products 表 +- 新商品可以先插入 products 表,即使还没人买 + +#### 4.2.5 查询数据 + +**查询订单 100 的所有商品详情**: + +```sql +SELECT + oi.order_id, + oi.product_id, + p.product_name, + oi.quantity, + oi.subtotal +FROM order_items oi +JOIN products p ON oi.product_id = p.product_id +WHERE oi.order_id = 100; +``` + +虽然需要 JOIN,但数据结构更合理,避免了冗余和异常。 + +::: tip 💡 2NF 针对复合主键 +如果主键是单个字段,则自动满足 2NF。2NF 主要解决复合主键的部分依赖问题。 + +**判断方法**: +- 主键是单个字段? → 自动满足 2NF +- 主键是复合主键? → 检查是否有字段只依赖主键的一部分 +::: + +### 4.3 第三范式(3NF):消除传递依赖 + +#### 4.3.1 3NF 的规则 + +**前提**:表必须先符合 2NF + +**规则**:非主键字段不**传递依赖**于主键。 + +**什么是"传递依赖"?** +- A → B → C +- C 依赖 B,B 依赖 A +- 所以 C 传递依赖 A + +**什么是不符合 3NF?** +- 非主键字段之间有依赖关系 +- 比如:user_level 依赖 user_id,而 user_id 又依赖 id + +**什么是符合 3NF?** +- 所有非主键字段只直接依赖主键 +- 非主键字段之间没有依赖关系 + +#### 4.3.2 问题场景:订单表包含用户等级 + +想象你在设计**订单表**: + +**场景**:记录订单时,需要记录用户的等级(用于计算折扣) + +**表设计**: +```sql +orders 表: +| id | user_id | total | user_level | discount | pay_amount | +| 1 | 100 | 500 | VIP | 0.9 | 450 | +| 2 | 101 | 300 | 普通 | 1.0 | 300 | +``` + +**主键**:id + +**问题分析**: + +| 字段 | 依赖关系 | 是否符合 3NF | +|:-----|:---------|:------------| +| total | 直接依赖 id | ✅ 符合 | +| user_id | 直接依赖 id | ✅ 符合 | +| user_level | 依赖 user_id,再依赖 id | ❌ 不符合(传递依赖) | +| discount | 依赖 user_level | ❌ 不符合(传递依赖) | + +**为什么不符合?** +- `user_level` 依赖 `user_id`(用户的等级存在用户表中) +- `user_id` 依赖 `id`(订单属于用户) +- 所以 `user_level` 传递依赖 `id` +- 同理,`discount` 依赖 `user_level`,也传递依赖 `id` + +#### 4.3.3 会有什么问题? + +**问题 1:数据冗余** +```sql +| id | user_id | user_level | discount | +| 1 | 100 | VIP | 0.9 | +| 2 | 100 | VIP | 0.9 | ← 重复了 +| 3 | 100 | VIP | 0.9 | ← 又重复了 +``` +同一用户的多个订单,重复存储 `user_level` 和 `discount`。 + +**问题 2:更新异常** +如果用户从"VIP"升级到"SVIP",需要更新该用户的所有历史订单。 + +**问题 3:数据不一致** +```sql +| id | user_id | user_level | discount | +| 1 | 100 | VIP | 0.9 | +| 2 | 100 | SVIP | 0.8 | ← 不一致! +``` +同一用户的不同订单,等级可能不同(数据更新不一致)。 + +#### 4.3.4 应用 3NF 后 + +**方案:把用户等级放到用户表** + +```sql +-- 订单表(只存储订单相关字段) +orders 表: +| id | user_id | total | discount | pay_amount | +| 1 | 100 | 500 | 0.9 | 450 | +| 2 | 100 | 300 | 0.9 | 270 | + +-- 用户表(存储用户等级) +users 表: +| id | username | level | +| 100 | 张三 | VIP | +``` + +**改进**: +- `user_level` 只在 users 表存储一次 +- 修改用户等级时,只需更新 users 表 +- 订单表只存储 `discount`(下单时的折扣),作为历史记录 + +::: tip 💡 3NF 与历史数据 +有些场景需要"冗余"历史数据: +- 订单的折扣(下单时的折扣,即使后来用户等级变了) +- 商品的快照(下单时的价格,即使后来商品改价了) + +这不是违反 3NF,而是为了"历史准确性"故意保留的冗余。 +::: + +#### 4.3.5 另一个例子:员工表 + +**不符合 3NF**: + +```sql +employees 表: +| id | name | dept_id | dept_name | dept_location | +| 1 | 张三 | 10 | 技术部 | 北京 | +| 2 | 李四 | 10 | 技术部 | 北京 | ← 重复 +| 3 | 王五 | 20 | 销售部 | 上海 | +| 4 | 赵六 | 20 | 销售部 | 上海 | ← 重复 +``` + +**问题**:`dept_name` 和 `dept_location` 依赖 `dept_id`,再依赖 `id`(传递依赖) + +**符合 3NF**: + +```sql +-- 员工表 +employees 表: +| id | name | dept_id | +| 1 | 张三 | 10 | +| 2 | 李四 | 10 | +| 3 | 王五 | 20 | + +-- 部门表 +departments 表: +| dept_id | dept_name | dept_location | +| 10 | 技术部 | 北京 | +| 20 | 销售部 | 上海 | +``` + +::: tip 💡 3NF 是最常见的范式 +实际业务中,大部分表设计都遵循 3NF,它在数据冗余和查询性能之间取得了平衡。 + +**判断方法**: +- 非主键字段之间有依赖关系吗? → 有,可能不符合 3NF +- 这个依赖是"历史快照"还是"可以外键关联"? → 可以外键关联,应该拆分 +::: ```sql -- 订单明细表:(order_id, product_id) 是复合主键 @@ -391,112 +1592,709 @@ SELECT * FROM orders WHERE id = 123; ## 6. 常见反模式及改进 -**反模式**(Antipattern)是看似正确但实际有害的设计模式。 +**反模式**(Antipattern)是"看似正确但实际有害"的设计模式。 + +就像生活中的"误区": +- 为了省钱买便宜的鞋子 → 结果经常坏,花更多钱 +- 为了省时间不睡觉 → 结果身体垮了,效率更低 + +数据库设计也有很多"反模式",看起来方便,实际上埋雷。 ### 6.1 反模式 1:巨型宽表 -**错误设计**: +#### 6.1.1 错误的做法 + +**错误设计**:把所有数据塞进一张表 ```sql --- 将所有数据塞进一张表 +-- 巨型宽表(错误示例) CREATE TABLE big_table ( id BIGINT, -- 用户字段 - user_name, user_email, user_phone, - -- 订单字段(重复 100 次) - order_1_id, order_1_amount, order_1_status, - order_2_id, order_2_amount, order_2_status, + user_name VARCHAR(50), + user_email VARCHAR(100), + user_phone VARCHAR(20), + -- 订单字段(重复 100 次!) + order_1_id BIGINT, + order_1_amount DECIMAL(10, 2), + order_1_status VARCHAR(20), + order_2_id BIGINT, + order_2_amount DECIMAL(10, 2), + order_2_status VARCHAR(20), -- ... - order_100_id, order_100_amount, order_100_status + order_100_id BIGINT, + order_100_amount DECIMAL(10, 2), + order_100_status VARCHAR(20) ); + +-- 数据样子 +| id | user_name | user_email | order_1_id | order_1_amount | order_2_id | order_2_amount | ... | +| 1 | 张三 | zhang@qq.com | 100 | 500.00 | 101 | 300.00 | ... | ``` -**问题**: -- 字段数量爆炸,超过数据库限制 -- 大量空值,浪费存储空间 -- 新增订单需要修改表结构(DDL 操作) -- 无法查询"某个用户的所有订单" +#### 6.1.2 为什么会这样做? -**正确设计**: +**当时的需求**: +- 产品经理说:"我们想在一个页面显示用户的所有订单" +- 开发者想:"少 JOIN 几次,查询更快" +- 时间紧:"先赶上线,以后再优化" + +**看起来很合理**: +- 查询用户订单时,不需要 JOIN,单表查询快 +- SQL 简单:`SELECT * FROM big_table WHERE id = 1` + +#### 6.1.3 会有什么问题? + +**问题 1:字段数量爆炸** + +```sql +-- MySQL 对表字段数量有限制 +-- InnoDB: 最多 1017 列 +-- MyISAM: 最多 259 列 + +-- 如果用户下了 1000 个订单怎么办? +order_1_id, order_2_id, ..., order_1000_id ← 字段爆炸! +``` + +**问题 2:大量空值,浪费存储** + +```sql +-- 大部分用户只有 5-10 个订单,其他 90-95 个字段都是 NULL +| id | order_1_amount | order_2_amount | order_3_amount | ... | order_100_amount | +| 1 | 500.00 | 300.00 | 800.00 | ... | NULL | ← 浪费 97 个字段 +``` + +**存储浪费**: +- 假设每个字段占用 8 字节(DECIMAL(10,2)) +- 100 个字段 × 8 字节 = 800 字节/行 +- 实际只用 3 个字段 = 24 字节 +- 浪费了 97% 的存储空间! + +**问题 3:新增订单需要修改表结构(DDL 操作)** + +```sql +-- 用户下了第 101 个订单,需要加字段 +ALTER TABLE big_table ADD COLUMN order_101_id BIGINT; +ALTER TABLE big_table ADD COLUMN order_101_amount DECIMAL(10, 2); + +-- 问题: +-- 1. DDL 操作会锁表,影响线上服务 +-- 2. 大表加字段非常慢(可能需要几小时) +-- 3. 需要停机维护,用户体验差 +``` + +**问题 4:无法查询"某个订单的所有信息"** + +```sql +-- 想查询订单 100 的信息 +-- 需要扫描所有 order_XX_id 字段 +SELECT * +FROM big_table +WHERE order_1_id = 100 + OR order_2_id = 100 + OR order_3_id = 100 + -- ... + OR order_100_id = 100; ← SQL 非常复杂,性能极差! +``` + +**问题 5:无法统计订单数据** + +```sql +-- 想统计"所有订单的总金额" +-- 需要写复杂的 SQL +SELECT + SUM(order_1_amount) + + SUM(order_2_amount) + + -- ... + SUM(order_100_amount) as total ← 无法维护! +FROM big_table; +``` + +#### 6.1.4 正确的做法 + +**正确设计**:拆分成多张表,用外键关联 ```sql -- 用户表 -users (id, name, email, phone) +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + phone VARCHAR(20), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); -- 订单表 -orders (id, user_id, amount, status, created_at) +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, -- 外键,指向 users 表 + amount DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 创建索引(提升查询性能) +CREATE INDEX idx_orders_user_id ON orders(user_id); ``` +#### 6.1.5 数据样子 + +```sql +-- users 表 +| id | name | email | phone | +| 1 | 张三 | zhang@qq.com | 13800138000 | + +-- orders 表 +| id | user_id | amount | status | created_at | +| 100 | 1 | 500.00 | paid | 2024-01-01 10:00:00| +| 101 | 1 | 300.00 | shipping | 2024-01-05 14:30:00| +| 102 | 1 | 800.00 | completed| 2024-01-10 09:15:00| +``` + +#### 6.1.6 查询数据 + +**查询用户的所有订单**: + +```sql +SELECT o.* +FROM orders o +WHERE o.user_id = 1; -- 利用索引,快速查询 +``` + +**查询订单及对应的用户信息**: + +```sql +SELECT o.*, u.name, u.email +FROM orders o +JOIN users u ON o.user_id = u.id +WHERE o.id = 100; -- 只 JOIN 需要的字段 +``` + +**统计用户的订单总数和总金额**: + +```sql +SELECT + u.name, + COUNT(o.id) as order_count, + SUM(o.amount) as total_amount +FROM users u +LEFT JOIN orders o ON u.id = o.user_id +GROUP BY u.id; +``` + +#### 6.1.7 优缺点对比 + +| 对比项 | 宽表(错误) | 分表(正确) | +|:-----|:-----------|:-----------| +| **存储空间** | 大量空值,浪费 90%+ | 紧凑存储,无浪费 | +| **扩展性** | 需要修改表结构(DDL) | 直接 INSERT,无需改表 | +| **查询性能** | 简单查询快,复杂查询极慢 | 利用索引,整体性能好 | +| **维护性** | 字段数量爆炸,无法维护 | 结构清晰,易于维护 | +| **数据完整性** | 无法建立外键约束 | 外键保证数据一致 | + +**结论**:**不要用宽表!用一对多关系 + 索引来解决!** + +--- + ### 6.2 反模式 2:逗号分隔值 -**错误设计**: +#### 6.2.1 错误的做法 + +**错误设计**:用逗号分隔存储多个值 ```sql --- 文章表,用逗号分隔标签 -posts (id, title, tags) +-- 文章表(错误示例) +CREATE TABLE posts ( + id BIGINT PRIMARY KEY, + title VARCHAR(200), + content TEXT, + tags VARCHAR(500) -- 用逗号分隔存储标签 +); +-- 数据样子 | id | title | tags | | 1 | Vue入门 | vue,frontend,javascript | +| 2 | React实战 | react,frontend | +| 3 | Node.js后端 | node,backend | ``` -**问题**: -- 无法索引,查询慢 -- 无法关联查询"有哪些文章包含 vue 标签" -- 无法统计"每个标签有多少篇文章" -- 修改标签需要字符串操作 +#### 6.2.2 为什么会这样做? -**正确设计**: +**当时的需求**: +- 产品经理说:"一篇文章可以有多个标签" +- 开发者想:"用逗号分隔最简单,不用建中间表" +- 时间紧:"先赶上线,以后再优化" + +**看起来很合理**: +- 存储简单:直接 `'vue,frontend,javascript'` +- 查询简单:`SELECT * FROM posts WHERE id = 1` +- 不用 JOIN:少一张表,少麻烦 + +#### 6.2.3 会有什么问题? + +**问题 1:无法有效索引** + +```sql +-- 即使给 tags 字段建索引 +CREATE INDEX idx_tags ON posts(tags); + +-- 查询包含 "vue" 标签的文章 +SELECT * FROM posts WHERE tags LIKE '%vue%'; + +-- 问题: +-- 1. LIKE '%xxx%' 无法使用索引(全表扫描) +-- 2. 查询 "vue" 会匹配 "javascript"(包含 "vue" 字符) +-- 3. 性能极差,数据量大时数据库卡死 +``` + +**问题 2:无法精确查询** + +```sql +-- 想查询"有哪些文章包含 frontend 标签" +SELECT * FROM posts WHERE tags LIKE '%frontend%'; + +-- 问题:会匹配到错误的记录 +-- 'vue,frontend,javascript' ← 正确 +-- 'frontendend' ← 错误!但会被匹配到 +-- 'myfrontendapp' ← 错误!但会被匹配到 +``` + +**问题 3:无法统计查询** + +```sql +-- 想统计"每个标签有多少篇文章" +-- 几乎无法用 SQL 实现! +-- 需要: +-- 1. 查出所有文章 +-- 2. 在应用层分割 tags 字符串 +-- 3. 手动统计 + +-- 或者用复杂的 SQL(性能极差) +SELECT + SUBSTRING_INDEX(SUBSTRING_INDEX(tags, ',', n), ',', -1) as tag, + COUNT(*) as count +FROM posts +JOIN ( + SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 +) numbers +ON CHAR_LENGTH(tags) - CHAR_LENGTH(REPLACE(tags, ',', '')) >= n - 1 +GROUP BY tag; +``` + +**问题 4:无法关联查询** + +```sql +-- 想查询"包含 vue 标签的文章 + 作者信息" +-- 需要字符串匹配,无法用 JOIN +SELECT p.*, u.name +FROM posts p +JOIN users u ON p.user_id = u.id +WHERE p.tags LIKE '%vue%'; ← 性能差,不准确 +``` + +**问题 5:修改标签需要字符串操作** + +```sql +-- 想给文章 1 添加一个 "tutorial" 标签 +-- 需要分步操作 +-- 1. 查询当前 tags +SELECT tags FROM posts WHERE id = 1; -- 'vue,frontend,javascript' + +-- 2. 在应用层拼接字符串 +new_tags = old_tags + ',tutorial' -- 'vue,frontend,javascript,tutorial' + +-- 3. 更新数据库 +UPDATE posts SET tags = 'vue,frontend,javascript,tutorial' WHERE id = 1; + +-- 问题: +-- 1. 需要 3 步操作,代码复杂 +-- 2. 并发修改时可能丢失数据 +-- 3. 无法保证标签唯一性(可能添加重复标签) +``` + +#### 6.2.4 正确的做法 + +**正确设计**:用中间表(多对多关系) ```sql -- 文章表 -posts (id, title) +CREATE TABLE posts ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); -- 标签表 -tags (id, name) +CREATE TABLE tags ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) UNIQUE NOT NULL -- 标签名称唯一 +); --- 文章-标签关联表 -post_tags (post_id, tag_id) +-- 文章-标签关联表(中间表) +CREATE TABLE post_tags ( + post_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (post_id, tag_id), -- 联合主键,防止重复关联 + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); --- 查询包含 vue 标签的文章 -SELECT p.* FROM posts p +-- 创建索引(提升查询性能) +CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id); +``` + +#### 6.2.5 数据样子 + +```sql +-- posts 表 +| id | title | content | +| 1 | Vue入门 | Vue 是一个渐进式框架... | +| 2 | React实战| React 是一个 UI 库... | + +-- tags 表 +| id | name | +| 1 | vue | +| 2 | frontend | +| 3 | javascript | +| 4 | react | + +-- post_tags 表 +| post_id | tag_id | +| 1 | 1 | ← 文章 1 有 vue 标签 +| 1 | 2 | ← 文章 1 有 frontend 标签 +| 1 | 3 | ← 文章 1 有 javascript 标签 +| 2 | 2 | ← 文章 2 有 frontend 标签 +| 2 | 4 | ← 文章 2 有 react 标签 +``` + +#### 6.2.6 查询数据 + +**查询包含 "vue" 标签的所有文章**: + +```sql +SELECT p.* +FROM posts p JOIN post_tags pt ON p.id = pt.post_id JOIN tags t ON pt.tag_id = t.id -WHERE t.name = 'vue'; +WHERE t.name = 'vue'; -- 利用索引,快速查询 ``` +**查询文章的所有标签**: + +```sql +SELECT t.name +FROM tags t +JOIN post_tags pt ON t.id = pt.tag_id +WHERE pt.post_id = 1; + +-- 结果 +| name | +| vue | +| frontend | +| javascript | +``` + +**统计每个标签的文章数量**: + +```sql +SELECT t.name, COUNT(pt.post_id) as post_count +FROM tags t +LEFT JOIN post_tags pt ON t.id = pt.tag_id +GROUP BY t.id +ORDER BY post_count DESC; + +-- 结果 +| name | post_count | +| frontend | 2 | +| vue | 1 | +| javascript | 1 | +| react | 1 | +``` + +**给文章添加标签**: + +```sql +-- 先查询或创建标签 +INSERT IGNORE INTO tags (name) VALUES ('tutorial'); + +-- 添加关联 +INSERT INTO post_tags (post_id, tag_id) +VALUES (1, (SELECT id FROM tags WHERE name = 'tutorial')); + +-- 完事!一条 SQL 搞定 +``` + +#### 6.2.7 优缺点对比 + +| 对比项 | 逗号分隔(错误) | 中间表(正确) | +|:-----|:--------------|:-------------| +| **查询性能** | 无法使用索引,全表扫描 | 利用索引,快速查询 | +| **查询精确度** | LIKE 匹配不准确 | 精确匹配 | +| **统计查询** | 几乎无法实现 | 简单的 GROUP BY | +| **数据完整性** | 无约束,可重复、格式混乱 | 外键 + 唯一约束 | +| **维护性** | 字符串操作,复杂 | 标准 SQL,简单 | +| **扩展性** | 难以扩展(如标签权重) | 易于扩展 | + +**结论**:**不要用逗号分隔!用多对多关系 + 中间表来解决!** + +--- + ### 6.3 反模式 3:滥用 JSON 字段 -**错误设计**: +#### 6.3.1 错误的做法 + +**错误设计**:把订单明细存为 JSON ```sql --- 订单表,订单明细存为 JSON -orders (id, user_id, items, total) +-- 订单表(错误示例) +CREATE TABLE orders ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + items JSON, -- 订单明细存为 JSON + total DECIMAL(10, 2), + created_at TIMESTAMP +); -| id | user_id | items | total | -| 1 | 100 | [{"pid":1,"qty":2},{"pid":2,"qty":1}] | 500 | +-- 数据样子 +| id | user_id | items | total | +| 1 | 100 | [{"pid":1,"qty":2},{"pid":2,"qty":1}] | 500 | +| 2 | 101 | [{"pid":3,"qty":5}] | 300 | ``` -**问题**: -- 无法建立外键约束 -- 无法有效索引(MySQL 5.7+ 部分支持) -- 数据完整性差(插入错误数据无法检测) -- 查询"某个商品的所有订单"需要全文扫描 +#### 6.3.2 为什么会这样做? -**正确设计**: +**当时的需求**: +- 产品经理说:"订单明细结构复杂,字段不固定" +- 开发者想:"JSON 灵活,不用定义字段" +- 时间紧:"先赶上线,以后再优化" + +**看起来很合理**: +- 灵活:可以存储任意结构的数据 +- 简单:不需要建 order_items 表 +- 查询快:单表查询,不需要 JOIN + +#### 6.3.3 会有什么问题? + +**问题 1:无法建立外键约束** + +```sql +-- 无法保证 JSON 中的 pid(商品 ID)真实存在 +INSERT INTO orders (id, user_id, items, total) VALUES +(1, 100, '[{"pid":9999,"qty":1}]', 500); ← pid 9999 不存在,但能插入! + +-- 问题:数据完整性无法保证 +``` + +**问题 2:无法有效索引(MySQL 5.7 之前)** + +```sql +-- MySQL 5.7+ 支持生成列 + 索引,但很复杂 +-- MySQL 5.6 及之前版本,JSON 字段完全无法索引 + +-- 查询"购买了商品 1 的所有订单" +SELECT * FROM orders +WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描! +``` + +**问题 3:数据完整性差** + +```sql +-- 可以插入错误格式的 JSON +INSERT INTO orders (id, user_id, items, total) VALUES +(1, 100, '[{"pid":1}]', 500), -- 缺少 qty +(2, 100, '[{"qty":2}]', 300), -- 缺少 pid +(3, 100, 'not-a-json', 200); -- 不是 JSON + +-- 问题:数据库无法检测 JSON 内容的正确性 +``` + +**问题 4:查询"某个商品的所有订单"需要全文扫描** + +```sql +-- 想查询"购买了 iPhone 的所有订单" +-- 需要扫描所有订单的 JSON 字段 +SELECT * FROM orders +WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描,性能极差! +``` + +**问题 5:修改商品信息很困难** + +```sql +-- 想把商品 1 的名称从 "iPhone" 改为 "iPhone 15" +-- 需要更新所有包含该商品的订单的 JSON 字段 +-- 但 JSON 无法用 UPDATE 语句部分更新,只能整体替换 + +-- 步骤: +-- 1. 查询订单 +SELECT items FROM orders WHERE id = 1; + +-- 2. 在应用层解析 JSON,修改,再序列化 +items = JSON.parse(items) +items.forEach(item => { + if (item.pid === 1) { + item.name = "iPhone 15" -- 但订单表没存 name! + } +}) +new_items = JSON.stringify(items) + +-- 3. 更新数据库 +UPDATE orders SET items = new_items WHERE id = 1; + +-- 问题:操作复杂,性能差,容易出错 +``` + +**问题 6:无法统计商品销量** + +```sql +-- 想统计"每个商品卖了多少" +-- 需要在应用层遍历所有订单的 JSON +-- 或用复杂的 SQL(MySQL 5.7+) +SELECT + JSON_UNQUOTE(JSON_EXTRACT(items, '$[0].pid')) as pid, + SUM(JSON_EXTRACT(items, '$[0].qty')) as total_qty +FROM orders +GROUP BY pid; ← 假设每个订单只有一个商品,不通用! +``` + +#### 6.3.4 正确的做法 + +**正确设计**:用关联表(一对多关系) ```sql -- 订单表 -orders (id, user_id, total) +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + total DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); -- 订单明细表 -order_items (id, order_id, product_id, quantity, price) +CREATE TABLE order_items ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + order_id BIGINT NOT NULL, -- 外键,指向 orders 表 + product_id BIGINT NOT NULL, -- 外键,指向 products 表 + product_name VARCHAR(200), -- 冗余,保存下单时的商品名称 + price DECIMAL(10, 2) NOT NULL, -- 冗余,保存下单时的价格 + quantity INT NOT NULL, + subtotal DECIMAL(10, 2) NOT NULL, -- 小计 = price * quantity + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) +); + +-- 创建索引(提升查询性能) +CREATE INDEX idx_order_items_order_id ON order_items(order_id); +CREATE INDEX idx_order_items_product_id ON order_items(product_id); ``` -::: tip 💡 什么时候用 JSON? -JSON 适合存储非结构化、低频查询的数据,如: -- 用户的扩展配置信息 -- 商品的动态属性(不同品类属性不同) -- 日志、埋点数据 +#### 6.3.5 数据样子 + +```sql +-- orders 表 +| id | user_id | total | status | created_at | +| 1 | 100 | 500.00| paid | 2024-01-01 10:00:00| + +-- order_items 表 +| id | order_id | product_id | product_name | price | quantity | subtotal| +| 1 | 1 | 1 | iPhone | 5999.00| 2 | 11998.00| +| 2 | 1 | 2 | iPad | 3999.00| 1 | 3999.00 | +``` + +#### 6.3.6 查询数据 + +**查询订单的所有商品**: + +```sql +SELECT oi.* +FROM order_items oi +WHERE oi.order_id = 1; -- 利用索引,快速查询 +``` + +**查询"购买了商品 1 的所有订单"**: + +```sql +SELECT DISTINCT o.* +FROM orders o +JOIN order_items oi ON o.id = oi.order_id +WHERE oi.product_id = 1; -- 利用索引,快速查询 +``` + +**统计每个商品的销量**: + +```sql +SELECT + p.name, + SUM(oi.quantity) as total_qty, + SUM(oi.subtotal) as total_amount +FROM products p +JOIN order_items oi ON p.id = oi.product_id +GROUP BY p.id +ORDER BY total_qty DESC; +``` + +#### 6.3.7 什么时候可以用 JSON? + +JSON 不是"洪水猛兽",在以下场景可以使用: + +**适合使用 JSON 的场景**: + +1. **非结构化数据** + ```sql + -- 用户的扩展配置信息(每个用户配置不同) + ALTER TABLE users ADD COLUMN preferences JSON; + + -- 数据样子 + | id | username | preferences | + | 1 | zhangsan | {"theme":"dark","lang":"zh","fontSize":14} | + | 2 | lisi | {"theme":"light","lang":"en"} | + ``` + +2. **低频查询的数据** + ```sql + -- 日志、埋点数据(查询频率低) + CREATE TABLE events ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + event_data JSON, -- 事件详情(字段不固定) + created_at TIMESTAMP + ); + ``` + +3. **动态属性** + ```sql + -- 商品的动态属性(不同品类属性不同) + -- 手机:屏幕尺寸、电池容量 + -- 衣服:尺码、材质、颜色 + CREATE TABLE products ( + id BIGINT PRIMARY KEY, + name VARCHAR(200), + attributes JSON -- 动态属性 + ); + + -- 数据样子 + | id | name | attributes | + | 1 | iPhone | {"screen":"6.1 inch","battery":"3000mAh"} | + | 2 | T恤 | {"size":"L","material":"cotton","color":"blue"} | + ``` + +**不适合使用 JSON 的场景**: +- 核心业务数据(订单、用户、商品) +- 需要频繁查询的字段 +- 需要建立索引的字段 +- 需要保证数据完整性的字段(外键约束) + +::: tip 💡 JSON 的使用原则 +**能用关系表就用关系表,实在不行才用 JSON。** + +判断标准: +1. 这个字段需要查询吗? → 需要,别用 JSON +2. 这个字段需要建立索引吗? → 需要,别用 JSON +3. 这个字段需要保证数据完整性吗? → 需要,别用 JSON +4. 这个字段结构经常变化吗? → 是,可以考虑 JSON ::: --- @@ -601,34 +2399,318 @@ promotions (id, name, type, discount, start_time, end_time) ## 8. 数据模型设计流程 -### 8.1 需求分析阶段 +### 8.0 手把手:从零设计一个数据模型 -1. **识别业务实体**:用户、订单、商品、优惠券 -2. **梳理业务关系**:用户下单、商品分类、优惠券使用 -3. **确定数据量级**:预计用户数、订单数、商品数 +让我们用一个**图书馆管理系统**的例子,从头到尾演示数据模型设计过程。 -### 8.2 概念模型阶段 +#### 第一步:识别实体(识别"对象") -1. **绘制 ER 图**:用图形化工具(如 draw.io、MySQL Workbench) -2. **标注关系类型**:一对一、一对多、多对多 -3. **确定主外键**:每个表的主键、外键关联 +**问题**:这个系统需要管理哪些"东西"? -### 8.3 逻辑模型阶段 +**需求分析**: +- 图书馆有很多书 +- 书有不同的类别(小说、科技、历史) +- 读者可以借书、还书 +- 每本书可以被多次借阅 -1. **设计表结构**:字段名、类型、约束 -2. **应用范式理论**:确保满足 3NF -3. **考虑扩展性**:预留扩展字段(如 ext_json) +**识别出的实体**: +1. **图书**(Book):书的基本信息 +2. **读者**(Reader):借书的人 +3. **借阅记录**(Borrow):谁借了哪本书 +4. **分类**(Category):书的分类 -### 8.4 物理模型阶段 +#### 第二步:确定属性(识别"特征") -1. **选择存储引擎**:InnoDB(事务)、MyISAM(只读) -2. **设计索引**:主键索引、外键索引、唯一索引 -3. **分区策略**:按时间、ID 范围分区 +**问题**:每个实体有哪些信息需要记录? -### 8.5 优化迭代阶段 +**图书的属性**: +- 书名(title) +- 作者(author) +- ISBN(isbn)- 唯一标识 +- 出版社(publisher) +- 出版年份(publish_year) +- 库存数量(stock) -1. **性能测试**:模拟真实查询,分析慢查询 -2. **适当反范式化**:高频查询表冗余字段 +**读者的属性**: +- 姓名(name) +- 电话(phone) +- 邮箱(email) +- 注册日期(registered_at) + +**借阅记录的属性**: +- 借阅日期(borrowed_at) +- 应还日期(due_date) +- 实还日期(returned_at) +- 状态(status):借阅中、已归还、逾期 + +**分类的属性**: +- 分类名称(name) +- 分类描述(description) + +#### 第三步:确定关系(识别"关联") + +**问题**:这些实体之间有什么关系? + +**分析**: + +1. **图书 ↔ 读者**:多对多? + - 一个读者可以借多本书 + - 一本书可以被多个读者借(不同时间) + - **但是**:同一时间,一本书只能被一个读者借 + - **所以**:需要**借阅记录**来记录"谁借了哪本书" + +2. **图书 ↔ 分类**:多对多 + - 一本书可以属于多个分类(如:《Python编程》属于"编程"和"Python") + - 一个分类包含多本书 + - **需要中间表**:book_categories + +3. **读者 ↔ 借阅记录**:一对多 + - 一个读者可以有多条借阅记录 + - 一条借阅记录只属于一个读者 + +4. **图书 ↔ 借阅记录**:一对多 + - 一本书可以有多条借阅记录(被不同人借过) + - 一条借阅记录只针对一本书 + +#### 第四步:画出 ER 图 + +``` + ┌─────────┐ + │ 图书 │ + └─────────┘ + │ id │ + │ title │ + │ author │ + │ isbn │ + └─────────┘ + │ + │ 1:N + │ + ┌─────────┐ ┌─────────┐ + │ 借阅记录│────│ 读者 │ + └─────────┘ └─────────┘ + │ book_id │ │ id │ + │ reader_id│ │ name │ + │ borrowed_at│ │ phone │ + └─────────┘ └─────────┘ + │ + │ N:M + │ + ┌─────────┐ + │ 分类 │ + └─────────┘ + │ id │ + │ name │ + └─────────┘ +``` + +#### 第五步:设计表结构(SQL) + +```sql +-- 图书表 +CREATE TABLE books ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + author VARCHAR(100) NOT NULL, + isbn VARCHAR(20) UNIQUE NOT NULL, -- ISBN 唯一 + publisher VARCHAR(100), + publish_year INT, + stock INT DEFAULT 1, -- 库存数量 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 读者表 +CREATE TABLE readers ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + phone VARCHAR(20) UNIQUE NOT NULL, -- 电话唯一 + email VARCHAR(100) UNIQUE, + registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 借阅记录表 +CREATE TABLE borrows ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + book_id BIGINT NOT NULL, + reader_id BIGINT NOT NULL, + borrowed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + due_date TIMESTAMP NOT NULL, -- 应还日期 + returned_at TIMESTAMP NULL, -- 实还日期(NULL 表示未还) + status VARCHAR(20) DEFAULT 'borrowed', -- borrowed: 借阅中, returned: 已归还, overdue: 逾期 + FOREIGN KEY (book_id) REFERENCES books(id), + FOREIGN KEY (reader_id) REFERENCES readers(id) +); + +-- 分类表 +CREATE TABLE categories ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + description TEXT +); + +-- 图书-分类关联表(多对多) +CREATE TABLE book_categories ( + book_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + PRIMARY KEY (book_id, category_id), -- 联合主键 + FOREIGN KEY (book_id) REFERENCES books(id), + FOREIGN KEY (category_id) REFERENCES categories(id) +); + +-- 创建索引(提升查询性能) +CREATE INDEX idx_borrows_reader_id ON borrows(reader_id); +CREATE INDEX idx_borrows_book_id ON borrows(book_id); +CREATE INDEX idx_borrows_status ON borrows(status); +CREATE INDEX idx_book_categories_category_id ON book_categories(category_id); +``` + +#### 第六步:应用范式(检查和优化) + +**检查 1NF**: +- ✅ 所有字段都是原子的(没有字段包含多个值) +- ✅ 没有重复组 + +**检查 2NF**: +- ✅ 没有复合主键的情况(除了 book_categories 的联合主键) +- ✅ 所有非主键字段完全依赖于主键 + +**检查 3NF**: +- ✅ 没有传递依赖 +- 例如:借阅记录中的 `status` 不会因为 `reader_id` 的变化而变化 + +**优化**: +- 考虑是否需要冗余字段? + - 例如:在 `borrows` 表中冗余 `book_title` 和 `reader_name`,避免查询时 JOIN + - **决策**:不冗余,因为查询频率不高,保持数据一致性更重要 + +#### 第七步:设计查询(验证设计) + +**查询 1:查询读者当前借阅的所有图书** + +```sql +SELECT + b.title, + b.author, + bor.borrowed_at, + bor.due_date, + bor.status +FROM borrows bor +JOIN books b ON bor.book_id = b.id +WHERE bor.reader_id = 1 + AND bor.status = 'borrowed'; -- 只查未还的 +``` + +**查询 2:查询某本书的所有借阅历史** + +```sql +SELECT + r.name, + bor.borrowed_at, + bor.returned_at, + bor.status +FROM borrows bor +JOIN readers r ON bor.reader_id = r.id +WHERE bor.book_id = 1 +ORDER BY bor.borrowed_at DESC; +``` + +**查询 3:查询逾期未还的图书** + +```sql +SELECT + r.name as reader_name, + r.phone as reader_phone, + b.title as book_title, + bor.due_date +FROM borrows bor +JOIN readers r ON bor.reader_id = r.id +JOIN books b ON bor.book_id = b.id +WHERE bor.status = 'borrowed' + AND bor.due_date < NOW(); -- 应还日期早于当前时间 +``` + +**查询 4:查询某个分类的所有图书** + +```sql +SELECT b.* +FROM books b +JOIN book_categories bc ON b.id = bc.book_id +JOIN categories c ON bc.category_id = c.id +WHERE c.name = '编程'; +``` + +#### 第八步:迭代优化(根据实际情况调整) + +**问题发现**: +- 每次查询"读者借阅的图书"都需要 JOIN 3 张表(borrows、books、readers) +- 如果数据量大,查询可能慢 + +**优化方案 1:添加冗余字段(反范式化)** + +```sql +-- 在 borrows 表添加冗余字段 +ALTER TABLE borrows ADD COLUMN book_title VARCHAR(200); +ALTER TABLE borrows ADD COLUMN reader_name VARCHAR(50); + +-- 修改插入逻辑 +INSERT INTO borrows (book_id, reader_id, book_title, reader_name, ...) +VALUES (1, 2, (SELECT title FROM books WHERE id = 1), + (SELECT name FROM readers WHERE id = 2), ...); + +-- 现在查询可以简化 +SELECT book_title, borrowed_at, due_date, status +FROM borrows +WHERE reader_id = 1 AND status = 'borrowed'; -- 无需 JOIN +``` + +**代价**: +- 存储空间增加(每次借阅都存储书名和读者名) +- 更新复杂(修改书名时需要同步更新所有借阅记录) + +**优化方案 2:添加缓存** +- 把热门读者的借阅记录缓存到 Redis +- 定期刷新缓存 + +**优化方案 3:添加视图** +- 创建视图简化查询 + +```sql +CREATE VIEW reader_borrows AS +SELECT + bor.id, + r.name as reader_name, + b.title as book_title, + bor.borrowed_at, + bor.due_date, + bor.status +FROM borrows bor +JOIN readers r ON bor.reader_id = r.id +JOIN books b ON bor.book_id = b.id; + +-- 查询时直接用视图 +SELECT * FROM reader_borrows WHERE reader_name = '张三'; +``` + +::: tip 💡 数据模型设计是个迭代过程 +1. **第一步**:先设计一个满足 3NF 的规范模型 +2. **第二步**:根据实际查询需求,考虑反范式化 +3. **第三步**:通过性能测试,找出瓶颈 +4. **第四步**:优化(添加索引、缓存、冗余字段) +5. **第五步**:重复 2-4 步 + +**不要一开始就过度优化**!先保证正确性,再优化性能。 +::: + +### 8.1 数据模型设计流程总结 + +| 阶段 | 目标 | 产出 | 工具 | +|:---|:---|:---|:---| +| **需求分析** | 识别实体和关系 | 实体清单、关系描述 | 文档、脑图 | +| **概念设计** | 画出 ER 图 | ER 图 | draw.io、Workbench | +| **逻辑设计** | 设计表结构 | 表结构 SQL | 文本编辑器 | +| **范式检查** | 消除冗余 | 符合 3NF 的表 | 检查清单 | +| **物理设计** | 索引、分区 | 建表 SQL + 索引 | 数据库工具 | +| **性能优化** | 反范式化 | 冗余字段、缓存 | 慢查询日志 | 3. **数据归档**:历史数据迁移到归档表 --- diff --git a/docs/zh-cn/appendix/5-data/sql.md b/docs/zh-cn/appendix/5-data/sql.md deleted file mode 100644 index ec5ee0c..0000000 --- a/docs/zh-cn/appendix/5-data/sql.md +++ /dev/null @@ -1,652 +0,0 @@ -# SQL:与数据库对话的语言 - -::: tip 核心问题 -**如何高效地查询和操作数据?** 这就像问:图书馆的书怎么快速找到?仓库的货物怎么精准定位?银行的账目怎么安全转账?SQL 解决的就是"与数据对话"的问题。 -::: - ---- - -## 0. SQL 的核心价值 - -在现代软件开发中,数据是核心资产。无论是电商平台的商品信息、社交网络的用户关系,还是银行系统的交易记录,都需要一种高效的方式来管理和查询。 - -**SQL**(Structured Query Language,结构化查询语言)就是这样一种"与数据库对话"的语言。它让我们能够: - -- **精准查询**:从百万级数据中快速找到目标 -- **高效操作**:批量增删改,一条语句搞定 -- **安全保障**:事务机制保证数据一致性 -- **标准通用**:学一次,所有数据库都能用 - ---- - -## 1. SQL vs NoSQL:如何选择? - -在深入了解 SQL 之前,先了解一下它与 NoSQL 的区别。 - -### 1.1 用仓库来类比 - -| 特性 | SQL(关系型数据库) | NoSQL(非关系型数据库) | -| :--- | :--- | :--- | -| **数据结构** | 严格的表结构(像 Excel) | 灵活的文档/键值/图结构 | -| **典型代表** | MySQL、PostgreSQL、Oracle | MongoDB、Redis、Elasticsearch | -| **适用场景** | 金融系统、电商订单、用户管理 | 社交动态、日志分析、实时缓存 | -| **优势** | 数据一致性、事务支持(ACID) | 高并发、灵活扩展、高性能 | -| **劣势** | 扩展性差、schema 固定 | 数据一致性弱、查询功能有限 | - -### 1.2 一个直观的对比 - -**SQL 数据库**就像一个**规范化的仓库**: -- 每个货架有固定的编号、名称、容量 -- 货物必须按照规则摆放 -- 入库、出库有严格的流程和记录 -- 适合需要严格管理的场景 - -**NoSQL 数据库**就像一个**灵活的杂物间**: -- 想放哪里就放哪里 -- 不需要预先规划空间 -- 快速存取,但可能找不到东西 -- 适合需要快速迭代的场景 - -::: tip 💡 实际应用 -大多数企业会**同时使用 SQL 和 NoSQL**: -- MySQL 存储用户信息、订单数据(核心业务) -- Redis 缓存热点数据(提高性能) -- MongoDB 存储日志、用户行为(数据分析) -::: - ---- - -## 2. CRUD 操作:数据的增删改查 - -SQL 的核心操作就是 CRUD(Create, Read, Update, Delete)。 - -### 2.1 用 Excel 来类比 - -| Excel 操作 | SQL 关键字 | 说明 | -| :--- | :--- | :--- | -| 插入新行 | INSERT | 添加数据 | -| 筛选行 | SELECT | 查询数据 | -| 修改单元格 | UPDATE | 更新数据 | -| 删除行 | DELETE | 删除数据 | - -### 2.2 实战演示 - -👇 **动手试试看**:在下方交互式演示中体验 CRUD 操作: - - - -### 2.3 常用查询语法 - -#### **SELECT:查询数据** - -```sql --- 查询所有列 -SELECT * FROM users; - --- 查询指定列 -SELECT name, email FROM users; - --- 带条件查询 -SELECT * FROM users WHERE age > 18; - --- 排序 -SELECT * FROM users ORDER BY age DESC; - --- 限制结果数量 -SELECT * FROM users LIMIT 10; -``` - -#### **INSERT:插入数据** - -```sql --- 插入完整数据 -INSERT INTO users (name, email, age) -VALUES ('张三', 'zhangsan@example.com', 25); - --- 批量插入 -INSERT INTO users (name, email, age) VALUES - ('李四', 'lisi@example.com', 30), - ('王五', 'wangwu@example.com', 28); -``` - -#### **UPDATE:更新数据** - -```sql --- 更新单个字段 -UPDATE users SET age = 26 WHERE id = 1; - --- 更新多个字段 -UPDATE users -SET age = 27, email = 'newemail@example.com' -WHERE id = 1; - --- ⚠️ 危险操作:不带 WHERE 会更新所有行! -UPDATE users SET age = 0; -- 慎用! -``` - -#### **DELETE:删除数据** - -```sql --- 删除指定行 -DELETE FROM users WHERE id = 1; - --- ⚠️ 危险操作:不带 WHERE 会删除所有数据! -DELETE FROM users; -- 慎用! -``` - -::: warning 💡 最佳实践 -- 先用 `SELECT` 验证 WHERE 条件是否正确 -- 再用 `UPDATE/DELETE` 执行操作 -- 生产环境务必加 `LIMIT` 限制影响行数 -::: - ---- - -## 3. SELECT 进阶:JOIN、GROUP BY、子查询 - -当数据分布在多个表中时,我们需要更强大的查询能力。 - -### 3.1 JOIN:连接多个表 - -**场景**:一个电商系统有两个表: -- `users`(用户表):id, name, email -- `orders`(订单表):order_id, user_id, amount - -如何查询"每个用户的订单总金额"? - -#### **INNER JOIN:只返回匹配的行** - -```sql -SELECT users.name, SUM(orders.amount) as total -FROM users -INNER JOIN orders ON users.id = orders.user_id -GROUP BY users.id; -``` - -**结果**:只显示有订单的用户 - -#### **LEFT JOIN:返回左表所有行** - -```sql -SELECT users.name, SUM(orders.amount) as total -FROM users -LEFT JOIN orders ON users.id = orders.user_id -GROUP BY users.id; -``` - -**结果**:显示所有用户,没有订单的用户 total 为 NULL - -::: tip 💡 如何选择 JOIN? -- **INNER JOIN**:只要两边都有数据才需要(如:订单明细) -- **LEFT JOIN**:需要保留主表所有数据(如:用户列表 + 统计信息) -- **RIGHT JOIN**:需要保留从表所有数据(很少用) -- **FULL OUTER JOIN**:需要所有数据(MySQL 不支持,可用 UNION 实现) -::: - -### 3.2 GROUP BY:分组统计 - -**场景**:统计每个部门的平均工资。 - -```sql -SELECT department, AVG(salary) as avg_salary, COUNT(*) as count -FROM employees -GROUP BY department -HAVING AVG(salary) > 10000; -- HAVING 过滤分组后的结果 -``` - -**注意**: -- `WHERE` 过滤行(在 GROUP BY 之前) -- `HAVING` 过滤分组(在 GROUP BY 之后) - -### 3.3 子查询:查询嵌套查询 - -**场景**:查找工资高于平均工资的员工。 - -```sql --- 方式一:WHERE 子查询 -SELECT name, salary -FROM employees -WHERE salary > (SELECT AVG(salary) FROM employees); - --- 方式二:FROM 子查询(派生表) -SELECT dept_name, avg_salary -FROM ( - SELECT department, AVG(salary) as avg_salary - FROM employees - GROUP BY department -) as dept_avg -WHERE avg_salary > 10000; -``` - -::: tip 💡 子查询 vs JOIN -- **子查询**:逻辑清晰,但性能较差(每个子查询都会执行一次) -- **JOIN**:性能更好,但需要理解连接逻辑 -- **最佳实践**:优先使用 JOIN,必要时用子查询 -::: - ---- - -## 4. 索引原理:让查询快起来 - -### 4.1 为什么需要索引? - -**场景**:在一个 100 万行的用户表中,查找 `id = 123456` 的用户。 - -**没有索引**: -- 数据库需要逐行扫描,最多比较 100 万次 -- 时间复杂度:O(n) - -**有索引**: -- 数据库通过 B+ 树快速定位,只需比较 log₂(100万) ≈ 20 次 -- 时间复杂度:O(log n) - -### 4.2 用图书馆来类比 - -| 概念 | 图书馆 | 数据库 | -| :--- | :--- | :--- | -| **数据** | 书籍 | 表的行 | -| **索引** | 目录卡片 | B+ 树 | -| **查询** | 按书名找书 | 按 WHERE 条件找行 | -| **无索引** | 逐排书架找 | 全表扫描 | -| **有索引** | 查目录定位 | 索引查找 | - -### 4.3 索引的可视化演示 - -👇 **动手试试看**:在 SqlDemo 组件的"索引"标签页查看无索引 vs 有索引的对比: - - - -### 4.4 索引的使用建议 - -| 场景 | 是否建索引 | 说明 | -| :--- | :--- | :--- | -| **WHERE 条件** | 是 | 如 `WHERE user_id = 1` | -| **JOIN 连接** | 是 | 如 `JOIN ON user_id` | -| **ORDER BY 排序** | 是 | 如 `ORDER BY created_at` | -| **低选择性列** | 否 | 如性别(只有男/女) | -| **频繁更新的列** | 谨慎 | 索引会降低写入性能 | -| **小表** | 否 | 数据量小不需要索引 | - -**创建索引**: -```sql --- 单列索引 -CREATE INDEX idx_user_id ON orders(user_id); - --- 复合索引(最左前缀原则) -CREATE INDEX idx_user_status ON orders(user_id, status); - --- 唯一索引 -CREATE UNIQUE INDEX idx_email ON users(email); -``` - -::: tip 💡 索引的代价 -- **空间**:每个索引都是额外的存储空间 -- **时间**:INSERT/UPDATE/DELETE 需要更新索引,降低写入速度 -- **建议**:只在查询频繁、更新少的列上建索引 -::: - ---- - -## 5. 事务 ACID:保证数据一致性 - -### 5.1 什么是事务? - -**事务**(Transaction)是一组 SQL 操作,要么全部成功,要么全部失败。 - -**经典案例**:银行转账 - -```sql -BEGIN; -- 开始事务 - --- 账户 A 扣款 100 元 -UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; - --- 账户 B 加款 100 元 -UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; - -COMMIT; -- 提交事务(如果中间出错,自动 ROLLBACK) -``` - -如果第二步失败(比如账户 B 不存在),整个事务会回滚,账户 A 不会被扣款。 - -### 5.2 ACID 四大特性 - -👇 **动手试试看**:在 SqlDemo 组件的"事务"标签页查看 ACID 可视化: - - - -#### **A - Atomicity(原子性)** -- **含义**:事务中的操作要么全部成功,要么全部失败 -- **类比**:转账要么同时成功,要么同时失败,不会出现"扣款了但没到账"的情况 -- **实现**:Undo Log(回滚日志) - -#### **C - Consistency(一致性)** -- **含义**:事务前后数据库状态一致,满足所有约束 -- **类比**:转账前后总金额不变(A 余额 + B 余额 = 总金额) -- **实现**:应用层约束 + 数据库约束 - -#### **I - Isolation(隔离性)** -- **含义**:并发事务之间互不干扰 -- **类比**:两个用户同时转账,不会相互影响 -- **实现**:锁机制 + MVCC(多版本并发控制) - -#### **D - Durability(持久性)** -- **含义**:事务提交后,永久保存,即使系统故障 -- **类比**:转账成功后,断电也不会丢失记录 -- **实现**:Redo Log(重做日志) - -### 5.3 事务隔离级别 - -| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 适用场景 | -| :--- | :--- | :--- | :--- | :--- | :--- | -| **READ UNCOMMITTED** | 是 | 是 | 是 | 高 | 几乎不用 | -| **READ COMMITTED** | 否 | 是 | 是 | 中 | 大多数数据库默认 | -| **REPEATABLE READ** | 否 | 否 | 是 | 低 | MySQL 默认 | -| **SERIALIZABLE** | 否 | 否 | 否 | 最低 | 金融级要求 | - -**设置隔离级别**: -```sql --- 查看 -SELECT @@transaction_isolation; - --- 设置 -SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -``` - -::: tip 💡 如何选择隔离级别? -- **默认使用 READ COMMITTED**:避免脏读,性能可接受 -- **金融场景**:使用 SERIALIZABLE 或 REPEATABLE READ -- **分析场景**:可降低到 READ UNCOMMITTED 提高性能 -::: - ---- - -## 6. SQL 注入:安全的警惕性 - -### 6.1 什么是 SQL 注入? - -**SQL 注入**是一种常见的安全漏洞,攻击者通过构造恶意的输入,篡改 SQL 语句。 - -**示例**:一个登录接口 - -```sql --- 正常 SQL -SELECT * FROM users WHERE username = 'admin' AND password = '123456'; - --- 攻击者输入用户名:admin' -- --- 拼接后的 SQL -SELECT * FROM users WHERE username = 'admin' --' AND password = '123456'; --- ↑ 注释掉后面的密码验证,直接登录成功! -``` - -**更危险的攻击**: - -```sql --- 用户名输入:admin'; DROP TABLE users; -- --- 拼接后的 SQL -SELECT * FROM users WHERE username = 'admin'; DROP TABLE users; --' -``` - -### 6.2 如何防御? - -#### **方法一:参数化查询(推荐)** - -```python -# ❌ 错误:直接拼接字符串(危险!) -sql = f"SELECT * FROM users WHERE username = '{username}'" -cursor.execute(sql) - -# ✅ 正确:使用参数化查询(安全) -sql = "SELECT * FROM users WHERE username = %s" -cursor.execute(sql, (username,)) -``` - -#### **方法二:ORM 框架** - -```python -# Django ORM -user = User.objects.get(username=username) - -# SQLAlchemy -user = session.query(User).filter(User.username == username).first() -``` - -#### **方法三:输入验证** - -```python -# 限制用户名只能包含字母、数字、下划线 -import re -if not re.match(r'^\w+$', username): - raise ValueError('Invalid username') -``` - -::: warning 💡 防御 SQL 注入的黄金法则 -1. **永远不要相信用户输入** -2. **永远使用参数化查询或 ORM** -3. **永远不要拼接 SQL 字符串** -4. **最小权限原则**:数据库用户只给必要权限 -::: - ---- - -## 7. 最佳实践 - -### 7.1 查询优化 - -| 优化技巧 | 说明 | 示例 | -| :--- | :--- | :--- | -| **避免 SELECT \*** | 只查询需要的列 | `SELECT name, email FROM users` | -| **使用 LIMIT** | 限制结果数量 | `SELECT * FROM users LIMIT 10` | -| **索引覆盖** | 查询条件使用索引列 | `WHERE indexed_col = 1` | -| **避免子查询** | 用 JOIN 替代子查询 | 见上文对比 | -| **批量操作** | 减少数据库往返 | `INSERT INTO ... VALUES (...), (...), (...)` | -| **分页查询** | 大数据量分页 | `SELECT * FROM users LIMIT 10 OFFSET 20` | - -### 7.2 命名规范 - -| 类型 | 规范 | 示例 | -| :--- | :--- | :--- | -| **表名** | 小写 + 下划线 | `user_profiles`, `order_items` | -| **列名** | 小写 + 下划线 | `created_at`, `user_id` | -| **索引名** | `idx_表名_列名` | `idx_users_email` | -| **外键名** | `fk_表名_列名` | `fk_orders_user_id` | -| **主键名** | 统一使用 `id` | 无 | - -### 7.3 数据库设计 - -| 设计原则 | 说明 | 示例 | -| :--- | :--- | :--- | -| **规范化** | 消除数据冗余 | 第三范式(3NF) | -| **反规范化** | 适当冗余提高性能 | 在订单表冗余用户姓名 | -| **主键选择** | 优先使用自增 ID | `id BIGINT AUTO_INCREMENT` | -| **时间字段** | 统一使用 DATETIME | `created_at DATETIME` | -| **软删除** | 用 `is_deleted` 标记 | 不真删除,便于恢复 | - ---- - -## 8. 用 AI 辅助编写 SQL - -AI 可以帮助你快速编写复杂的 SQL 查询。关键在于提供清晰的表结构和业务需求。 - -### 8.1 提示词模板 - -``` -你是一位资深的数据库工程师,精通 SQL 查询优化。请帮我编写 SQL 查询。 - -## 数据库表结构 -[提供表的 CREATE TABLE 语句或字段说明] - -## 业务需求 -[描述你想要查询的数据,例如: -- 统计每个月的订单总金额 -- 查找购买过商品 A 和商品 B 的用户 -- 计算用户的留存率] - -## 要求 -1. 使用标准 SQL 语法(兼容 MySQL 8.0) -2. 注释关键逻辑 -3. 考虑性能优化(索引、JOIN 顺序) -4. 提供多种实现方案(如子查询 vs JOIN) - -## 输出格式 -### SQL 查询 -\`\`\`sql -[SQL 语句] -\`\`\` - -### 逻辑说明 -[解释查询的逻辑和优化思路] -``` - -### 8.2 实战示例:复杂统计查询 - -**输入提示词**: - -``` -你是一位资深的数据库工程师,精通 SQL 查询优化。请帮我编写 SQL 查询。 - -## 数据库表结构 - -```sql --- 用户表 -CREATE TABLE users ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(50), - created_at DATETIME -); - --- 订单表 -CREATE TABLE orders ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - user_id BIGINT, - amount DECIMAL(10, 2), - status ENUM('pending', 'paid', 'shipped', 'completed', 'cancelled'), - created_at DATETIME, - FOREIGN KEY (user_id) REFERENCES users(id) -); - --- 订单详情表 -CREATE TABLE order_items ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - order_id BIGINT, - product_id BIGINT, - quantity INT, - price DECIMAL(10, 2), - FOREIGN KEY (order_id) REFERENCES orders(id) -); - --- 商品表 -CREATE TABLE products ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(100), - category VARCHAR(50) -); -``` - -## 业务需求 -1. 统计每个月的订单总金额和订单数量 -2. 查找购买过商品 A 和商品 B 的用户 -3. 计算用户复购率(有多少用户下了多个订单) - -## 要求 -1. 使用标准 SQL 语法(兼容 MySQL 8.0) -2. 注释关键逻辑 -3. 考虑性能优化 -``` - -**AI 输出示例**: - -```sql --- 需求 1:统计每个月的订单总金额和订单数量 -SELECT - DATE_FORMAT(created_at, '%Y-%m') AS month, - COUNT(*) AS order_count, - SUM(amount) AS total_amount -FROM orders -WHERE status IN ('paid', 'shipped', 'completed') -- 只统计有效订单 -GROUP BY DATE_FORMAT(created_at, '%Y-%m') -ORDER BY month DESC; - --- 需求 2:查找购买过商品 A 和商品 B 的用户 --- 方案一:INTERSECT(MySQL 不支持,用 IN 替代) -SELECT DISTINCT user_id -FROM orders o -JOIN order_items oi ON o.id = oi.order_id -JOIN products p ON oi.product_id = p.id -WHERE p.name IN ('商品 A', '商品 B') -GROUP BY user_id -HAVING COUNT(DISTINCT p.name) = 2; -- 确保同时买了两个商品 - --- 方案二:自连接(性能更好) -SELECT - oa.user_id -FROM orders oa -JOIN order_items oia ON oa.id = oia.order_id -JOIN products pa ON oia.product_id = pa.id -JOIN order_items oib ON oa.id = oib.order_id -- 同一个订单 -JOIN products pb ON oib.product_id = pb.id -WHERE pa.name = '商品 A' AND pb.name = '商品 B'; - --- 需求 3:计算用户复购率 -WITH user_order_count AS ( - SELECT - user_id, - COUNT(*) AS order_count - FROM orders - WHERE status IN ('paid', 'shipped', 'completed') - GROUP BY user_id -) -SELECT - COUNT(CASE WHEN order_count >= 2 THEN 1 END) AS repeat_users, - COUNT(*) AS total_users, - ROUND(COUNT(CASE WHEN order_count >= 2 THEN 1 END) * 100.0 / COUNT(*), 2) AS repeat_rate -FROM user_order_count; -``` - -### 8.3 AI 辅助的注意事项 - -| 注意点 | 说明 | -| :--- | :--- | -| **提供完整上下文** | 表结构、索引、数据量都要说清楚 | -| **明确性能要求** | 是否需要优化、数据量大小 | -| **验证 SQL 语法** | AI 生成的 SQL 可能有小错误,需要测试 | -| **理解执行计划** | 用 `EXPLAIN` 查看查询是否使用了索引 | -| **分步实现** | 复杂查询可以拆分成多个简单查询 | - -::: tip 💡 追问技巧 -- "请提供另一种实现方案(如用 JOIN 替代子查询)" -- "请分析这条查询的性能瓶颈" -- "请添加索引建议" -- "请解释每个步骤的逻辑" -::: - ---- - -## 名词速查表 - -| 名词 | 英文 | 解释 | -| :--- | :--- | :--- | -| **SQL** | Structured Query Language | 结构化查询语言,与数据库对话的标准语言 | -| **数据库** | Database | 存储和管理数据的仓库 | -| **表** | Table | 数据的二维表格,类似 Excel | -| **行** | Row | 表中的一条记录 | -| **列** | Column | 表中的一个字段 | -| **主键** | Primary Key | 唯一标识一行的字段(如 id) | -| **外键** | Foreign Key | 关联其他表的字段 | -| **索引** | Index | 加速查询的数据结构(B+ 树) | -| **事务** | Transaction | 一组要么全成功、要么全失败的 SQL 操作 | -| **ACID** | Atomicity, Consistency, Isolation, Durability | 事务的四大特性 | -| **JOIN** | Join | 连接多个表的查询操作 | -| **子查询** | Subquery | 嵌套在另一个查询中的查询 | -| **聚合函数** | Aggregate Function | SUM, AVG, COUNT, MAX, MIN | -| **分组** | Group By | 按字段分组统计 | -| **SQL 注入** | SQL Injection | 通过输入篡改 SQL 语句的攻击方式 | -| **规范化** | Normalization | 消除数据冗余的设计原则 | -| **反规范化** | Denormalization | 适当冗余提高性能的设计 | -| **执行计划** | Execution Plan | 数据库执行 SQL 的详细步骤 | -| **B+ 树** | B+ Tree | 索引的底层数据结构 | -| **MVCC** | Multi-Version Concurrency Control | 多版本并发控制,实现事务隔离 | -| **脏读** | Dirty Read | 读取未提交的数据 | -| **不可重复读** | Non-Repeatable Read | 同一事务两次读取结果不同 | -| **幻读** | Phantom Read | 同一事务两次读取结果集不同 | -| **隔离级别** | Isolation Level | 事务隔离的程度(READ UNCOMMITTED/READ COMMITTED/REPEATABLE READ/SERIALIZABLE) | diff --git a/eslint.config.js b/eslint.config.js index 3073edc..ff7b71a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,11 @@ import vueParser from 'vue-eslint-parser' export default [ { - ignores: ['node_modules/**', 'docs/.vitepress/dist/**', 'docs/.vitepress/cache/**'] + ignores: [ + 'node_modules/**', + 'docs/.vitepress/dist/**', + 'docs/.vitepress/cache/**' + ] }, js.configs.recommended, ...pluginVue.configs['flat/recommended'],