[{"slug":"palantir-likely-domain-model-definition","title":"Palantir 领域模型定义","tags":["系统设计"],"content":"本文档基于 Palantir 公开文档重建一份尽可能完整的领域模型定义。 它不是官方 schema dump，也不假装公开资料已经把全部内部对象结构暴露出来。 它的目标是把三件事拆开： 官方公开文档明确给出的对象、关系和层级 官方明确存在、但结构没有充分公开的能力域 为了工程落地而补入、但必须显式标注为推断层的实现抽象 1. 证据口径 为避免把推断误写成官方定义，本文对每类概念使用四级证据标签。 1.1 证据等级 官方直接定义 官方文档直接给出概念定义、职责或稳定边界 官方明确存在 官方文档明确提到该能力或资源，但未公开完整结构 工程推断 公开文档不足以推出完整 schema，但为实现层建模高度合理 不纳入主干 官方文档中存在，但更适合作为 capability、application feature 或 runtime feature，而不是领域模型主干对象 1.2 本文档的建模原则 优先遵循 Palantir 当前最稳定的顶层公开口径：Ontology = Language + Engine + Toolchain 优先遵循 Palantir 当前最稳定的能力轴：data + logic + action + security 不把 application surface、toolchain surface、runtime service、permission mechanism 混写成同一层 primitive 对公开文档证据不足的领域，只给出“建议的实现抽象”，不写成“官方对象类型” 2. 官方顶层结构 2.1 Ontology system 官方直接定义 官方架构中心当前最稳定的说法不是“Ontology 只有若干 primitive”，而是： OntologySystem - language - engine - toolchain 同时，Ontology 被描述为用于建模企业决策的系统，其核心是对以下四类能力的统一： DecisionModel - data - logic - action - security 这意味着一份“完整的 Palantir 领域模型定义”至少要覆盖： 语义层如何表达世界 运行层如何支撑读写、同步和安全执行 工具链如何把 Ontology 作为后端开放给开发者和产品交付 2.1.1 分层总览图 这张图表达的是官方口径里的顶层结构和主要流向。 flowchart TB subgraph LANGUAGE[Ontology Language] DATA[Data\\nObjects / Properties / Links] LOGIC[Logic\\nFunctions / Model Bindings] ACTION[Action\\nAction Types] SECURITY[Security\\nRoles / Markings / Permissions] end subgraph ENGINE[Ontology Engine] READS[Read Architecture] WRITES[Write Architecture] SYNC[Subscriptions / Sync / CDC] ENFORCE[Security Enforcement] end subgraph SURFACES[Ontology-aware Applications] OV[Object Views] OE[Object Explorer] QV[Quiver] WS[Workshop] SL[Slate] CB[Carbon] MP[Map] end subgraph TOOLCHAIN[Toolchain] OSDK[OSDK] DC[Developer Console] MCP[Palantir MCP] API[Platform APIs / SDKs] CM[Compute Modules] CE[Custom Endpoints] end subgraph DELIVERY[Product Delivery] DEVOPS[Foundry DevOps] MARKET[Marketplace] end LANGUAGE --&gt; ENGINE ENGINE --&gt; SURFACES TOOLCHAIN --&gt; LANGUAGE TOOLCHAIN --&gt; ENGINE TOOLCHAIN --&gt; DELIVERY DELIVERY --&gt; SURFACES SECURITY --&gt; ENFORCE LOGIC --&gt; ACTION DATA --&gt; OV 2.2 最稳妥的根对象 基于官方公开结构，一份完整模型更适合写成： PalantirOntologyDomainModel - language - governance - runtimeResources - applicationSurfaces - modelAndLogicBindings - engine - toolchain - productDelivery - implementationExtensions? 说明： language 是 Ontology Language 层 governance 是 data / logic / action / security 中的安全和权限边界 runtimeResources 是围绕对象工作流展开的运行时资源，如 object views、object sets applicationSurfaces 是官方所谓 ontology-aware applications modelAndLogicBindings 承接 functions、models 与 object workflows 的结合点 engine 是官方 Ontology Engine 及其运行特征 toolchain 是 Developer Console、OSDK、APIs、MCP、Compute Modules 等开发入口 productDelivery 是 Foundry DevOps 与 Marketplace 所代表的产品化交付面 implementationExtensions? 只放工程推断层对象，不冒充官方 language primitive 2.3 关系图 下面这张图只表达主干对象之间最重要的 1:N 聚合或包含关系。 erDiagram PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ OBJECT_TYPE : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ PROPERTY_DEF : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ SHARED_PROPERTY : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ LINK_TYPE : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ ACTION_TYPE : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ FUNCTION_DEF : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ INTERFACE_DEF : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ PROJECT : governs PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ ROLE : governs PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ ORGANIZATION : governs PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ MARKING : governs PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ OBJECT_SET : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ OBJECT_VIEW : contains PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ APPLICATION_SURFACE : exposes PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ MODEL_BINDING : binds PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ COMPUTE_MODULE : enables PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ CUSTOM_ENDPOINT : enables PALANTIR_ONTOLOGY_DOMAIN_MODEL ||--o{ PRODUCT_PACKAGE_DEF : packages OBJECT_TYPE ||--o{ PROPERTY_DEF : has OBJECT_TYPE ||--o{ LINK_TYPE : participates_in OBJECT_TYPE ||--o{ ACTION_TYPE : target_of OBJECT_TYPE ||--o{ OBJECT_SET : groups OBJECT_TYPE ||--o{ OBJECT_VIEW : viewed_as INTERFACE_DEF ||--o{ OBJECT_TYPE : implemented_by SHARED_PROPERTY ||--o{ PROPERTY_DEF : reused_by PROJECT ||--o{ ROLE : grants ORGANIZATION ||--o{ PROJECT : partitions MARKING ||--o{ PROJECT : constrains OBJECT_VIEW ||--o{ APPLICATION_SURFACE : embedded_in MODEL_BINDING ||--o{ FUNCTION_DEF : wrapped_by FUNCTION_DEF ||--o{ CUSTOM_ENDPOINT : backs FUNCTION_DEF ||--o{ COMPUTE_MODULE : executed_by PRODUCT_PACKAGE_DEF ||--o{ APPLICATION_SURFACE : packages PRODUCT_PACKAGE_DEF ||--o{ OBJECT_TYPE : packages PRODUCT_PACKAGE_DEF ||--o{ ACTION_TYPE : packages PRODUCT_PACKAGE_DEF ||--o{ FUNCTION_DEF : packages 读图原则： A ||--o{ B 表示一个 A 可以包含多个 B 这张图优先表达聚合关系，不表达所有 cross-reference PRODUCT_PACKAGE_DEF、COMPUTE_MODULE、CUSTOM_ENDPOINT 属于完整系统中的正式交付或运行对象，但不是 Ontology Language primitive 3. Language 层：官方核心领域模型 本节是公开文档中最稳定、最可信的主干。 3.0.1 核心语义图 这张图只表达 Language 层里最关键的建模关系。 erDiagram OBJECT_TYPE ||--o{ PROPERTY_DEF : has OBJECT_TYPE ||--o{ LINK_TYPE : connects_through OBJECT_TYPE ||--o{ ACTION_TYPE : exposes OBJECT_TYPE ||--o{ OBJECT : instantiates OBJECT_TYPE ||--o{ OBJECT_SET : groups OBJECT_TYPE ||--o{ OBJECT_VIEW : viewed_as SHARED_PROPERTY ||--o{ PROPERTY_DEF : reused_by INTERFACE_DEF ||--o{ OBJECT_TYPE : implemented_by INTERFACE_DEF ||--o{ INTERFACE_DEF : extends FUNCTION_DEF ||--o{ OBJECT_TYPE : reads_from FUNCTION_DEF ||--o{ OBJECT_SET : accepts ACTION_TYPE ||--o{ FUNCTION_DEF : uses_logic 读图重点： ObjectType 是 Language 层中心节点 SharedProperty 复用到多个 PropertyDef InterfaceDef 是 abstract type，由多个 ObjectType 实现 FunctionDef 和 ActionType 共同承接 logic 与 action 的结合点 3.1 ObjectType 官方直接定义 官方定义：Object type is the schema definition of a real-world entity or event。 最稳妥的工作定义： ObjectType - name - displayName - description - identity - propertyRefs[] - linkRefs[] - actionRefs[] - functionRefs[]? - interfaceRefs[]? - backingResourceRefs[] 说明： ObjectType 是 concrete type 由 dataset、virtual table、model-backed workflows 等底层数字资产支撑 可以实例化为 objects 是 Object Views、Object Explorer、Workshop、OSDK 等上层体验的中心锚点 3.1.1 与当前附件相比的校准 原文把 objectKind = ENTITY | EVENT | STATE | DOCUMENT 写成核心字段，这更像工程上的分类扩展，公开文档没有把它作为官方最小结构 原文把 groupRefs[] 写进主定义，这更适合作为组织资源，而不是 object type 的主骨架字段 3.2 PropertyDef 官方直接定义 官方定义：Property is the schema definition of a characteristic of a real-world entity or event。 最稳妥的工作定义： PropertyDef - objectTypeRef - name - displayName - description - dataType - metadata - binding 说明： PropertyDef 是对象特征的 schema 定义 属性值属于 object instance，而不是定义本身 官方公开文档明确强调对象工作流依赖 rich metadata，而不只是底层字段映射 binding 是合理的工程实现位，但公开文档没有给出统一字段模型 3.2.1 关于 binding 工程推断 原文中的 PropertyBinding 思路是合理的，但不能写成官方对象定义。更稳妥的表述方式是： 属性通常需要映射到底层 datasource、virtual table、model output 或派生逻辑 这种映射在实现层应显式建模 但公开文档没有公开一套稳定的 bindingKind / readServiceRef / sourceFieldPath 标准 schema 3.3 SharedProperty 官方直接定义 官方定义：A shared property is a property that can be used on multiple object types。 SharedProperty - name - displayName - description - metadata - reusableAcross[] 说明： SharedProperty 不是底层对象数据共享 共享的是 property metadata 和复用建模语义 它不是最底层 language primitive，但已经是官方 core concepts 中显式存在的正式对象 3.4 LinkType 官方直接定义 官方定义：Link type is the schema definition of a relationship between two object types。 LinkType - name - displayName - description - sourceTypeRef - targetTypeRef - cardinality? - metadata - binding? 说明： LinkType 是对象之间关系的 schema 定义 公开文档稳定确认了 links 是 Ontology 语义层核心组成部分 cardinality、binding 是工程上合理的字段，但公开文档没有把它们定义成稳定 schema 的必备字段 3.5 ActionType 官方直接定义 官方定义：Action type is the schema definition of a set of changes or edits to objects, property values, and links that a user can take at once。 ActionType - name - displayName - description - targetResourceRefs[] - inputSchema - editedResourceRefs[] - sideEffects - executionLogicRef? - permissionBoundary 说明： ActionType 是 kinetic layer 的核心对象 它不只是按钮或 UI 操作，而是事务与写回语义的正式定义 官方文档明确强调 actions 能够改变 objects、property values、links，并带有 side effects action 能与 functions、applications、security scopes 结合 3.5.1 与当前附件相比的校准 原文中的 ActionExecutor 很适合作为实现抽象，但不是公开 language object permissionPolicyRef 的方向是合理的，但公开文档更稳定的表述是 action 编辑权限、resource permissions 和 backing resource permissions，而不是显式的 policy object ref 3.6 FunctionDef 官方直接定义 官方定义：Function is a piece of code-based logic that takes input parameters and returns an output。 FunctionDef - name - displayName - description - inputSchema - outputSchema - ontologyInputRefs[]? - ontologyReadRefs[]? - ontologyEditCapabilities[]? - implementationRef? 说明： 官方文档明确说明 functions 与 Ontology 原生集成 functions 可以接收 objects 和 object sets 作为输入 functions 可以读取对象属性，也可以被应用和 actions 使用 implementationRef 是实现层位置，不是公开 schema 的稳定字段 3.6.1 Functions on Objects 的地位 官方直接定义 官方文档明确说明：functions on objects 是一个常用说法，但不是 Foundry 中正式独立的对象类型。 因此： 不应把 FunctionsOnObjects 建成与 FunctionDef 平级的新 primitive 更稳妥的做法是把它视为 FunctionDef 的 Ontology-native 使用模式 3.7 InterfaceDef 官方直接定义 官方定义：Interface is an Ontology type that describes the shape of an object type and its capabilities。 InterfaceDef - name - displayName - description - localPropertyRefs[] - sharedPropertyRefs[] - linkTypeConstraintRefs[]? - metadata - extends[] - implementedBy[]? - supportStatusBySurface[]? 说明： interface 是 abstract type interface 不由 dataset 直接 backing interface 不能直接实例化，必须由具体 object types 实现 interface 可以 extend 多个接口 object types 可以 implement 多个 interfaces 3.7.1 与当前附件相比的校准 原文关于 localPropertyRefs[]、sharedPropertyRefs[]、linkTypeConstraintRefs[] 的方向是正确的 但官方文档同时明确说 interfaces 在不同产品和服务上的支持程度不一致 因此 supportStatusBySurface[]? 可以保留，但必须标注为工程上有用的兼容性元数据，而不是官方 schema 强制字段 3.7.2 当前支持边界 官方明确存在 公开文档显示： Interfaces 已支持 Ontology Manager、Marketplace、TypeScript v2 Functions 在 Actions、Object Set Service、OSDK 中属于部分支持 在 Workshop、部分函数运行时中尚未完全支持 这意味着： interface 是官方正式类型 但不能把“概念存在”误写成“全平台完全等价支持” 4. Runtime resources 与 application surfaces 4.1 Object 官方直接定义 Object - objectTypeRef - identity - propertyValues - linkInstances 说明： object 是 object type 的实例 object 对应真实世界中的单个实体或事件 4.2 ObjectSet 官方明确存在 官方 core concepts 直接提到 object set 是多个 object instances 的集合；functions on objects 也明确支持 object set 输入与搜索。 ObjectSet - baseObjectTypeRef - membershipDefinition? - state? - savedResourceMetadata? 说明： ObjectSet 是正式概念，不能删除 公开文档足以证明它是工作流和函数运行中的一等输入形态之一 但关于 STATIC / DYNAMIC / TEMPORARY / PERMANENT 的完整结构，当前公开证据不足以把它写成“官方完整 schema” 4.2.1 与当前附件相比的校准 原文保留 ObjectSet 是正确的 但 ObjectSetDefinition、definitionKind、lifecycleKind 的形式化程度应降低到“高可信工程抽象” 4.3 ObjectView 官方直接定义 官方定义：Object Views are reusable representations of object data。 ObjectView - name - baseObjectTypeRef - viewKind - formFactor - propertyRefs[] - linkRefs[] - relatedApplicationRefs[] - relatedActionRefs[] 最小官方稳定枚举： viewKind = CORE | CUSTOM formFactor = FULL | PANEL 说明： object views 是对象数据的可复用表示 核心信息包括 property data、object links 和 related applications 公开文档明确区分 core/custom 与 full/panel 4.4 Ontology-aware applications 官方直接定义 官方页给出了稳定的应用面集合： OntologyAwareApplications - ObjectViews - ObjectExplorer - Quiver - Workshop - Slate - Carbon - Map 4.4.1 ApplicationSurfaceDef 工程推断，但强约束地贴近官方页面 ApplicationSurfaceDef - name - primaryUseCases[] - workflowStyle - configurationModel - backedResourceKinds[] 官方稳定维度： primaryUseCases = DISCOVERY | ANALYSIS | DASHBOARDS | APPLICATIONS | GEOSPATIAL workflowStyle = EXPLORATORY | WORKFLOW_SPECIFIC | HYBRID configurationModel = WALK_UP_USABLE | CUSTOMIZABLE 说明： 把应用面单独建模是合理的 但 OntologyApplicationDomain 本身是我们为了整理领域模型而引入的聚合概念，不是官方 language primitive 名称 4.4.2 各应用面的最小定义 Object Views discovery hub，围绕单个 object 展开 Object Explorer exploratory search 和 object set 操作 Quiver analytical workflows 与 dashboards，支持 time series Workshop workflow-specific、customizable、ontology-native 应用构建 Slate 更技术化的应用构建，可直接访问 datasets Carbon 组合多个资源形成 curated workspace Map geospatial context 5. Governance 与 permissioning domain 治理层是当前附件里最需要校准的部分。 5.0.1 治理与权限图 这张图强调官方公开资料里最稳定的治理边界。 flowchart LR ORG[Organization] PROJ[Project] ROLE[Role] MARK[Marking] ORES[Ontology Resources] ODATA[Object Data] PBAC[Purpose-based Access Controls] ORG --&gt;|mandatory boundary| PROJ PROJ --&gt;|contains| ORES PROJ --&gt;|grants| ROLE MARK --&gt;|mandatory controls| PROJ ROLE --&gt;|view / edit / manage| ORES ORES --&gt;|schema permission distinct from| ODATA PBAC -.officially mentioned.-&gt; ODATA MARK --&gt;|additional restriction| ODATA 读图重点： Project 是资源组织和权限授予的关键边界 Organization 和 Marking 都属于 mandatory control Ontology Resources 和 Object Data 必须区分 PBAC 是官方明确提到的能力，但公开对象结构仍不充分 5.1 官方高置信治理主干 5.1.1 Project 官方直接定义 官方安全文档明确说明：平台中的 data 和 resources 组织在 projects 中。 Project - name - resources[] - roles[] - markings[]? - tags[]? 说明： Project 是权限与资源组织的关键边界 当前附件缺少 Project，这是一个实质缺口 5.1.2 Role 官方直接定义 官方 core concepts 明确说明：roles are the central permissioning model in the Ontology。 Role - name - grantedOn - operations[] - subjectRefs[]? 说明： role 可以作用在 ontology level 或 individual resource level 在 project-based permissions 语境下，role 同时与 Compass/project 权限体系耦合 5.1.3 Organization 官方直接定义 Organization - name - userRefs[] - resourceBoundary 说明： organizations 是 mandatory controls 的一种 用于在用户和资源之间建立强隔离边界 5.1.4 Marking 官方直接定义 Marking - name - appliesTo - requiredPermission - propagationSemantics? 说明： markings 是另一类 mandatory controls 常见场景是 PII、金融敏感、受限数据 官方安全文档明确说明 mandatory controls 会借由 lineage/provenance 传播 5.1.5 Resource permission boundary 官方直接定义 官方对象权限文档明确区分： ontology resources 的权限 object data 的权限 最关键的边界是： 看 object type schema，不一定需要看 backing datasource 数据 看 object instances，需要同时拥有 object type 和 backing datasource 的相应权限 编辑 links，需要 link type 和 linked object types 的编辑权限 编辑 actions，需要 action type 和其涉及资源的编辑权限 这部分是完整治理模型中必须保留的正式关系约束。 5.2 Purpose-based access controls 官方明确存在 官方安全总览已经明确提到 role-, marking-, and purpose-based access controls。 但当前公开文档对其对象结构暴露不足，因此更稳妥的写法是： PurposeBasedAccessControl - purposeDefinitions? - requestWorkflow? - approvalWorkflow? - enforcementBoundary? 说明： Purpose 作为治理能力方向是可信的 但 Purpose、PurposeGrant、ownerRef、status 等字段结构暂时不能写成官方正式 schema 5.3 SecurityPolicy / SecurityConstraint 的定位 工程推断 原文的这部分不应直接删除，但必须降级为实现层抽象： RecommendedSecurityAbstractions - SecurityPolicy - SecurityConstraint - PurposeGrant 更准确的说法是： 官方公开文档确认存在复杂的 role、marking、purpose、attribute-based、row/column-level 控制 但没有公开一套统一的 policy object schema 因此 SecurityPolicy、SecurityConstraint、PurposeGrant 更适合作为本系统实现层的治理对象，而不是“Palantir 官方领域模型中的正式 primitive” 6. Models、logic 与 AI binding 6.0.1 模型与逻辑绑定图 这张图表达模型如何通过 functions 进入对象工作流，而不是直接替代 Ontology 语言对象。 flowchart LR MODEL[Model] DEPLOY[Model Deployment] WF[Wrapper Function] OBJ[Object Type / Object Data] ACT[Action Type] APP[Workshop / Vertex / Other Apps] MODEL --&gt; DEPLOY DEPLOY --&gt; WF OBJ --&gt; WF WF --&gt; ACT WF --&gt; APP 读图重点： 官方推荐路径是 Model -&gt; Deployment -&gt; Wrapper Function model 更常通过 function 进入业务工作流 action 和 applications 再消费这些函数能力 6.1 Models in the Ontology 官方直接定义 官方公开文档明确说明：models 可以映射进 Ontology，用于 live inference 和 operational workflows。 最稳妥的结构是： ModelBinding - modelRef - wrapperFunctionRef - targetOntologyRefs[] - applicationRefs[]? 说明： 官方推荐路径是：创建 model，部署 model，发布 wrapper function，然后在 Workshop、Vertex 等应用中使用 这意味着 model 通常不是直接替代 FunctionDef 更常见的路径是 model 通过 function 进入 Ontology 工作流 6.2 Logic 在 Palantir 完整模型中的地位 官方直接定义 官方顶层架构并没有把 logic 降格为实现细节，而是与 data、action、security 并列。 因此： 如果只写 objects、properties、links，而忽略 logic bindings，这份模型就不完整 FunctionDef 是 logic 的正式载体 model、ML、LLM-driven logic、business rules 都属于 logic 在不同运行时上的表现形式 6.3 当前附件的校准 原文保留 FunctionDef 是正确的 原文把 FunctionExecutor.runtimeKind = MODEL_BACKED 写成字段是工程上合理的 但在官方口径下，更先验的结构是 Model -&gt; wrapper function -&gt; application/workflow，而不是先有统一的 runtime enum 7. Engine 与执行基础设施 7.1 Ontology Engine 官方直接定义 官方公开文档明确说明 Ontology Engine 至少覆盖： 高规模 query reads real-time subscription to state changes transactional updates batch mutations change data capture / low-latency mirroring security enforcement across interactions 因此更稳妥的官方结构是： OntologyEngine - readArchitecture - writeArchitecture - subscriptionCapabilities - synchronizationCapabilities - securityEnforcement 7.2 公开架构背景层 官方直接定义 在 execution architecture 语境下，公开文档里更稳定的锚点是： ExecutionArchitectureContext - MMDP - Rubix - DataServices - LogicServices - WorkflowServices 说明： MMDP 是 open data and compute architecture Rubix 是 hardened、autoscaling compute substrate Data / Logic / Workflow Services 是官方架构语言中的稳定分类轴 7.3 Compute Modules 官方直接定义 ComputeModule - containerImages - entrypointContainer - scalingProfile - callableFrom[] - integrationPatterns[] 说明： compute modules 是 Palantir 平台上的交互式容器部署能力 可用于 container-backed functions、container-based data integration、host custom models 它们属于 toolchain / runtime boundary 对象，而不是 Ontology Language primitive 7.4 Custom Endpoints 官方直接定义 CustomEndpoint - urlPattern - requestShape - responseShape - endpointSpecification - backingOntologyOperationRefs[] 说明： custom endpoints 允许开发者定义 API 形状 官方明确说明它们由 ontology actions 和 functions 支撑 它们表达的是互操作接口面，而不是业务语义主干 7.5 RuntimeServiceRef 与 EngineCapabilityDef 的定位 工程推断 原文里这部分的工程价值很高，但应该重命名为“实现扩展层”： ImplementationExtensions - RuntimeServiceRef - EngineCapabilityDef - InteroperabilitySurfaceDef 推荐保留原因： 在自研 semantic query engine 中，需要把 language layer 连接到 read/write/compute backends planner、router、explain、deployment profile 都需要稳定的引用对象 但必须明确： 这些不是公开文档中的 Palantir 官方核心对象 它们是对官方 Engine、Compute Modules、Custom Endpoints、interoperability 能力的工程化抽象 8. Toolchain 与 developer surfaces 8.0.1 开发与运行入口图 这张图表达开发入口如何连接 Ontology、运行能力和交付面。 flowchart TB subgraph DEV[Developer Surfaces] DC[Developer Console] OSDK[OSDK] API[Platform APIs / SDKs] CWS[Code Workspaces] CREPO[Code Repositories] GB[Global Branching] MCP[Palantir MCP] end subgraph RUNTIME[Runtime / Execution] ONT[Ontology] CM[Compute Modules] CE[Custom Endpoints] end subgraph DELIVERY[Delivery] FD[Foundry DevOps] MP[Marketplace] end DC --&gt; OSDK DC --&gt; API CWS --&gt; OSDK CREPO --&gt; CM GB --&gt; CREPO MCP --&gt; ONT OSDK --&gt; ONT API --&gt; ONT CM --&gt; ONT CE --&gt; ONT OSDK --&gt; FD CM --&gt; FD CE --&gt; FD FD --&gt; MP 读图重点： Developer Console 是多个开发入口的汇聚点 OSDK、APIs、MCP 都是访问 Ontology 的不同入口面 Compute Modules 和 Custom Endpoints 位于 toolchain 与 runtime 的边界 Foundry DevOps 与 Marketplace 构成交付链路 8.1 Developer toolchain 的官方组成 官方直接定义 基于公开 Developer toolchain 文档，更完整的开发面集合应写成： DeveloperToolchain - DeveloperConsole - OSDK - PlatformAPIs - PlatformSDKs - CodeWorkspaces - CodeRepositories - GlobalBranching - PalantirMCP - ComputeModules - CustomEndpoints 8.1.1 Developer Console 官方直接定义 OSDK 应用在 Developer Console 中创建和管理 Developer Console 也是应用绑定和认证配置的重要入口 8.1.2 OSDK 官方直接定义 OSDK - supportedLanguages = TypeScript | Python | Java | OpenAPI - selectedOntologySubset - generatedTypes - generatedFunctions - authClientKind 说明： OSDK 是把 Ontology 作为后端开放给开发者的核心官方能力 支持按应用所选 Ontology 子集生成类型和函数 token scope 与用户权限一起构成 secure by design 的访问边界 8.1.3 Palantir APIs 与 Platform SDKs 官方直接定义 APIs 覆盖 datasets、users、groups、builds、schedules 等平台资源 Platform SDKs 与 OSDK 并存，但不一定绑定具体 Ontology 8.1.4 Code Workspaces / Code Repositories / Global Branching 官方直接定义 Code Workspaces 提供与 VS Code 集成的开发环境 Code Repositories 是原生代码资产入口 Global Branching 是端到端安全开发流程的重要能力 8.1.5 Palantir MCP 官方直接定义 PalantirMCP - contextAccess - metadataAccess - dataAccess - platformActionCapabilities 说明： Palantir MCP 是 Model Context Protocol 的平台实现 面向 AI IDE 和 AI agents 可以查询文档、metadata、data，并在平台上执行高层任务 9. Product delivery domain 9.0.1 产品交付图 这张图只看 product delivery，不混入核心语言对象。 erDiagram FOUNDRY_DEVOPS ||--o{ PRODUCT_PACKAGE_DEF : manages PRODUCT_PACKAGE_DEF ||--o{ PRODUCT_VERSION : versions PRODUCT_PACKAGE_DEF ||--o{ DEPENDENCY_REF : depends_on PRODUCT_PACKAGE_DEF ||--o{ INSTALLATION_PROFILE : installs_as PRODUCT_PACKAGE_DEF ||--o{ RELEASE_CHANNEL : publishes_to MARKETPLACE ||--o{ PRODUCT_PACKAGE_DEF : lists 读图重点： Foundry DevOps 负责 package、version、dependency、installation、release channel Marketplace 是产品的 discoverability 和 installation surface 这部分是完整 Palantir 领域模型的一部分，但不属于 Ontology Language primitive 9.1 Foundry DevOps 官方直接定义 官方 Product delivery 文档明确给出： flexible packaging automated version and dependency management release channels fleet of installations 更稳妥的建模方式是： FoundryDevOps - products[] - versions[] - dependencies[] - releaseChannels[] - installations[] 9.2 Marketplace 官方直接定义 Marketplace - publishedProducts[] - guidedInstallation - relatedProductRecommendations - automaticUpgrades 说明： Marketplace 是 discoverability 和 installation surface 它不是 Ontology primitive，但属于完整领域模型中的正式交付面 9.3 ProductPackageDef 的定位 工程推断，但证据较强 原文的 ProductPackageDef 建议保留，但要改写成： ProductPackageDef - packagedResources[] - inputMappings[] - dependencyRefs[] - version - releaseChannel - installationProfiles[] 理由： 官方文档清楚暴露了 product、installation、inputs、dependencies、release channels、upgrades 这些交付语义 但没有公开统一的 package schema 因此它适合保留为工程上的产品交付对象，而不是官方 language primitive 10. 一份更完整的 Palantir 领域模型定义 把上面的高置信对象与必要扩展合并后，更合理的定义如下。 PalantirOntologyDomainModel - language - objectTypes[] - properties[] - sharedProperties[] - links[] - actions[] - functions[] - interfaces[] - governance - projects[] - roles[] - organizations[] - markings[] - purposeBasedControls? - permissionBoundaries[] - runtimeResources - objects[] - objectSets[] - objectViews[] - applicationSurfaces - objectViews - objectExplorer - quiver - workshop - slate - carbon - map - modelAndLogicBindings - modelBindings[] - engine - ontologyEngine - executionArchitectureContext - toolchain - developerConsole - osdk - platformApis - platformSdks - codeWorkspaces - codeRepositories - globalBranching - palantirMcp - computeModules - customEndpoints - productDelivery - foundryDevOps - marketplace - productPackages? - implementationExtensions? - runtimeServiceRefs[] - engineCapabilities[] - interoperabilitySurfaces[] - securityPolicies[] - securityConstraints[] 11. 对原附件可信度的结论 11.1 可以直接保留的部分 ObjectType / PropertyDef / LinkType / ActionType / FunctionDef / InterfaceDef 作为核心语义主干 ObjectView 作为正式运行时资源 SharedProperty 作为正式邻接类型 Ontology-aware applications 作为 northbound surfaces OSDK 留在 toolchain，而不是 primitive 层 Compute Modules / Custom Endpoints / DevOps / Marketplace 进入执行与交付领域，而不是核心 primitive 层 11.2 需要降级的部分 SecurityPolicy SecurityConstraint PurposeGrant RuntimeServiceRef EngineCapabilityDef InteroperabilitySurfaceDef ProductPackageDef 这些概念不是错误，而是应改写为： 官方资料支持其背后的能力存在 但公开资料没有把这些对象完整地定义为官方领域对象 因而它们属于实现抽象，不属于官方 language core 11.3 需要补入的部分 Project Models in the Ontology Developer Console Platform APIs / Platform SDKs Code Workspaces Code Repositories Global Branching Palantir MCP 11.4 当前公开资料仍然不充分的部分 ObjectSet 的完整生命周期结构 Purpose-based access controls 的完整对象模型 ValueTypeDef 的正式治理结构 ObjectTypeGroup 的正式边界 object backend 更细粒度服务对象的统一 schema 12. 推荐的最小官方版与扩展工程版 12.1 最小官方版 如果目标是“只保留高置信、公开资料充分支持的定义”，最小集合是： OfficialCoreModel - ObjectType - PropertyDef - SharedProperty - LinkType - ActionType - FunctionDef - InterfaceDef - Object - ObjectSet - ObjectView - Project - Role - Organization - Marking - OntologyAwareApplications - ModelBinding - OntologyEngine - OSDK - PalantirMCP - ComputeModule - CustomEndpoint - FoundryDevOps - Marketplace 12.2 扩展工程版 如果目标是“既要贴近官方，又要能支撑 semantic query engine 和平台实现”，建议在上面基础上追加： EngineeringExtensions - RuntimeServiceRef - EngineCapabilityDef - InteroperabilitySurfaceDef - SecurityPolicy - SecurityConstraint - ProductPackageDef - PropertyBinding - LinkBinding - ActionExecutor - FunctionExecutor 12.2.1 工程扩展版的定位 这层的目标不是替代官方模型，而是补上三类官方公开文档没有完整对象化、但实现时必然要落地的结构： 语义到执行 的连接件 例如 RuntimeServiceRef、PropertyBinding、LinkBinding、FunctionExecutor、ActionExecutor 治理到执行 的连接件 例如 SecurityPolicy、SecurityConstraint、PurposeGrant 交付到安装 的连接件 例如 ProductPackageDef 因此，工程扩展版最适合回答的问题是： 一个属性到底从哪里读 一个链接到底如何遍历 一个 action 或 function 到底落到哪个 runtime 一条治理规则到底如何被执行器消费 一组 ontology 资源到底如何被打包、安装、升级和路由 12.2.2 工程扩展版建议结构 EngineeringExtendedModel - officialCoreModel - runtimeBindingLayer - runtimeServiceRefs[] - propertyBindings[] - linkBindings[] - functionExecutors[] - actionExecutors[] - governanceExecutionLayer - securityPolicies[] - securityConstraints[] - purposeGrants[] - deliveryExecutionLayer - productPackages[] - installationProfiles[] - dependencyGraphs[] - releaseChannels[] 12.2.3 工程扩展版关系图 下面这张图只表达扩展层最重要的执行关系，不再重复官方核心对象的全部定义。 erDiagram OBJECT_TYPE ||--o{ PROPERTY_BINDING : uses OBJECT_TYPE ||--o{ LINK_BINDING : uses ACTION_TYPE ||--o{ ACTION_EXECUTOR : executed_by FUNCTION_DEF ||--o{ FUNCTION_EXECUTOR : executed_by PROPERTY_BINDING }o--|| RUNTIME_SERVICE_REF : reads_from LINK_BINDING }o--|| RUNTIME_SERVICE_REF : traverses_via ACTION_EXECUTOR }o--|| RUNTIME_SERVICE_REF : writes_to FUNCTION_EXECUTOR }o--|| RUNTIME_SERVICE_REF : runs_on SECURITY_POLICY ||--o{ SECURITY_CONSTRAINT : materializes_as PURPOSE_BASED_ACCESS_CONTROL ||--o{ PURPOSE_GRANT : grants SECURITY_CONSTRAINT }o--|| ACTION_EXECUTOR : enforced_by SECURITY_CONSTRAINT }o--|| FUNCTION_EXECUTOR : enforced_by PRODUCT_PACKAGE_DEF ||--o{ OBJECT_TYPE : packages PRODUCT_PACKAGE_DEF ||--o{ ACTION_TYPE : packages PRODUCT_PACKAGE_DEF ||--o{ FUNCTION_DEF : packages PRODUCT_PACKAGE_DEF ||--o{ INSTALLATION_PROFILE : installs_as PRODUCT_PACKAGE_DEF ||--o{ RELEASE_CHANNEL : publishes_to 读图重点： RuntimeServiceRef 是工程扩展层的中心连接件 PropertyBinding / LinkBinding 解决的是读路径和遍历路径 ActionExecutor / FunctionExecutor 解决的是执行落点 SecurityConstraint 解决的是治理规则如何下沉到执行层 ProductPackageDef 解决的是 ontology 资源如何被产品化交付 12.2.4 推荐最小枚举 为了让工程扩展版可落地，建议至少保留下面这组最小枚举。 RuntimeServiceKind - METADATA - OBJECT_READ - OBJECT_WRITE - OBJECT_SYNC - FUNCTION_RUNTIME - COMPUTE_MODULE_RUNTIME - CUSTOM_ENDPOINT PropertyBindingKind - DIRECT_FIELD - DERIVED_EXPR - LOOKUP - SERVICE_READ LinkBindingKind - KEY_JOIN - BRIDGE - LOOKUP - SERVICE_TRAVERSAL ExecutionMode - SYNC - ASYNC - TRANSACTIONAL - ORCHESTRATED 12.2.5 使用边界 工程扩展版在本文中的使用边界应明确写死： 允许用于 semantic query engine、planner、router、explain、delivery implementation 不允许回写成“Palantir 官方 language primitive” 不允许在没有证据标注的情况下与官方对象混层 当公开文档将来暴露出更正式结构时，应优先用官方对象替换这些扩展抽象 但必须始终明确： 这层是实现模型 不是 Palantir 公开文档已经正式定义的 language objects 13. 一句话版本 如果把本文压缩成一句话，可以写成： Palantir 最可信的一版完整领域模型，不应只被理解为 ObjectType / Property / Link / Action / Function / Interface 六类 primitive，而应被理解为一个以 Language + Engine + Toolchain 为顶层结构、以 data + logic + action + security 为能力主轴、以 ObjectView / ObjectSet / ontology-aware applications 为运行时与应用面、以 Project / Role / Organization / Marking / permissions 为治理主干、以 Models / Functions / OSDK / MCP / Compute Modules / Custom Endpoints / DevOps / Marketplace 为逻辑、开发和交付扩展面的完整系统。# Palantir 风格最可能领域模型定义 本文档不是 Palantir 官方 schema dump，而是基于当前整理出的官方 taxonomy，反推一版最可能、最稳妥的领域模型定义。 它的目标不是穷尽所有运行时细节，而是给出一套适合 V1 落地、同时尽量贴近官方公开概念边界的模型骨架。 本轮校准额外参考了官方文档中的 core concepts、types reference、object backend、interfaces、object views、ontology-aware applications、ontology permissions、shared properties、value types、object type groups、OSDK、action types、functions、object types、link types、Ontology System、AIP Developers、Developer toolchain、Foundry DevOps、purpose-based access controls 与 Foundry Rules 页面。 1. 建模边界 先明确三条边界，避免再次把不同层次混在一起。 1.1 这份模型里真正的一层 primitive 只有六类 ObjectType PropertyDef LinkType ActionType FunctionDef InterfaceDef 1.2 Security 保留，但不再当作 type primitive Security 更适合作为一个上位治理域，下面展开出： SecurityPolicy Role Marking Purpose Scope Organization SecurityConstraint 1.3 这些概念进入模型，但不进入 primitive 层 ObjectSet ObjectView OntologyApplicationDomain SharedProperty ValueTypeDef ObjectTypeGroup ExecutionInfrastructureDomain RuntimeServiceRef OntologyToolchainDomain 1.4 这些概念不进入本领域模型主干 它们属于 capability 层： Agent Automation Tool Workflow RuleApp / FoundryRules 2. 最可能的顶层结构 如果把这套模型收敛成一个最可能的顶层定义，更合理的组织方式是七组： Semantic primitives Security and governance Runtime resources Application surfaces Ontology modeling and type system Execution infrastructure Toolchain and delivery 可以抽象成下面这个根结构： OntologyDomainModel - metadata - objectTypes[] - interfaces[] - properties[] - links[] - actions[] - functions[] - security - runtimeResources - applications - modelingResources - executionInfrastructure - toolchain 其中： objectTypes[] / interfaces[] / properties[] / links[] / actions[] / functions[] 是核心语义层 security 是上位治理域聚合 runtimeResources 放对象集合与对象视图 applications 的类型是 OntologyApplicationDomain，承载 object-aware applications 与 northbound workflow surfaces modelingResources 放 shared properties、value types、object type groups executionInfrastructure 的类型是 ExecutionInfrastructureDomain，承载后端服务引用、执行能力约束与后续路由扩展点 toolchain 的类型是 OntologyToolchainDomain，承载开发入口、程序化接口与产品交付面 2.1 本轮官方校准后的关键修正 InterfaceDef 应更贴近官方 interface features 更适合强调 interface properties、link type constraints、metadata、extends / implements 不宜把 action 直接作为接口定义的一部分来建模 ObjectSet 应使用官方状态模型 官方明确区分 static / dynamic 官方明确区分 temporary / permanent ObjectView 应区分 core / custom 与 full / panel 这比单纯的对象展示资源更接近官方产品形态 ValueTypeDef 不只是 baseType 包装 官方明确强调它是 space-scoped、permissioned、versioned 的 semantic wrapper ObjectTypeGroup 虽被官方称为 classification primitive 但更像 search / discovery 组织资源，而不是与 object / action 平级的核心语义原语 backend refs 应补上 object databases 官方 object backend 架构不只有 OMS、OSS、Actions、Funnel OSDK 应继续留在 toolchain / capability 层 不进入领域模型主干 Ontology Toolchain 与 Foundry DevOps 已经具备足够稳定的公开对象边界，适合从零散 capability 词汇提升为正式邻接域 object-aware applications 已形成稳定北向对象边界 Object Explorer / Quiver / Workshop / Slate / Carbon / Map 更适合作为独立邻接域，而不是继续隐含在 ObjectView 或 capability prose 中 Foundry DevOps / Marketplace 背后还应有一层 package / installation 对象 只建 DeliverySurfaceDef 还不够，还需要可交付对象来承载 version、inputs、content、release channel 3. 核心语义原语定义 3.1 ObjectType ObjectType 是整个模型的核心节点，定义真实世界中的实体、事件或业务对象。 官方校准补充： object type 是 schema definition of a real-world entity or event object type 是 concrete 的 object type 由 backing datasources 支撑并可实例化为 objects ObjectType - name - displayName - description - objectKind - identityField - interfaceRefs[] - propertyRefs[] - outgoingLinkRefs[] - actionRefs[] - functionRefs[] - groupRefs[] 字段说明： name 稳定语义标识，例如 Employee displayName 面向 UI 与文档展示 description 业务语义说明 objectKind 建议最小取值：ENTITY | EVENT | STATE | DOCUMENT identityField 对象主标识字段 interfaceRefs[] 实现了哪些接口 propertyRefs[] 挂载哪些属性定义 outgoingLinkRefs[] 从该对象出发可遍历的链接 actionRefs[] 在该对象上可触发的动作 functionRefs[] 与该对象强相关的函数能力 groupRefs[] 属于哪些对象分组 3.2 InterfaceDef InterfaceDef 是抽象类型，用于表达一组对象共享的形状与能力。 官方校准补充： interface 是 Ontology type interface 是 abstract 的，不由 dataset 直接 backing，不能直接实例化 interface feature 更接近 interface properties + link type constraints + metadata interface 可以 extend 多个接口 object type 可以 implement 多个接口 InterfaceDef - name - displayName - description - localPropertyRefs[] - sharedPropertyRefs[] - linkTypeConstraintRefs[] - metadata - extends[] - supportStatusBySurface[]? 字段说明： localPropertyRefs[] 在接口上本地定义的 interface properties sharedPropertyRefs[] 复用 shared properties 的接口属性 linkTypeConstraintRefs[] 接口级 link type constraints metadata 面向接口本身的元信息 extends[] 可选，保留接口继承扩展点 supportStatusBySurface[]? 可选，记录接口在 Marketplace / OSDK / Functions / Workshop 等入口上的支持差异，避免把“概念上支持”误写成“全平台完全可用” 补充说明： 是否支持动作、函数直接以接口为主要宿主，在官方平台内仍存在分层支持差异 因此在 V1 模型中，不把 actionRefs[] 放进 InterfaceDef 主定义更稳妥 3.3 PropertyDef PropertyDef 定义对象上的语义字段。 PropertyDef - objectTypeRef - sharedPropertyRef? - name - displayName - description - valueTypeRef - required - searchable - filterable - sortable - aggregatable - binding PropertyBinding - bindingKind - readServiceRef - sourceFieldPath? - expression? - transformRef? 字段说明： objectTypeRef 属性归属对象 sharedPropertyRef? 若来自共享属性模板，则引用该模板 valueTypeRef 指向值类型定义，而不是直接写死为基础类型字符串 binding 使用 PropertyBinding 明确描述读取方式，并通过 readServiceRef 显式引用 RuntimeServiceRef PropertyBinding 建议最小结构： bindingKind 建议最小取值：DIRECT_FIELD | DERIVED_EXPR | LOOKUP | SERVICE_READ readServiceRef 必需，引用一个 RuntimeServiceRef，通常应指向 OBJECT_READ、METADATA 或 PUSHDOWN_ENGINE 类服务 sourceFieldPath? 当属性直接映射到底层字段时使用 expression? 当属性由计算表达式或派生逻辑给出时使用 transformRef? 可选，指向值映射、格式转换或标准化规则 3.4 LinkType LinkType 定义对象之间的可命名关系与可遍历路径。 LinkType - name - displayName - description - sourceTypeRef - targetTypeRef - inverseName? - cardinality - traversable - binding LinkBinding - bindingKind - traversalServiceRef - sourceKeyExpr? - targetKeyExpr? - bridgeRef? - lookupExpr? 字段说明： sourceTypeRef 起点对象或接口 targetTypeRef 终点对象或接口 inverseName? 逆向语义名，例如 DirectReports cardinality 建议最小取值：ONE_TO_ONE | ONE_TO_MANY | MANY_TO_ONE | MANY_TO_MANY binding 使用 LinkBinding 明确描述 join、bridge、lookup 或 service-backed traversal，并通过 traversalServiceRef 引用 RuntimeServiceRef LinkBinding 建议最小结构： bindingKind 建议最小取值：KEY_JOIN | BRIDGE | LOOKUP | GRAPH_TRAVERSAL | SERVICE_TRAVERSAL traversalServiceRef 必需，引用一个 RuntimeServiceRef，通常应指向 OBJECT_READ 或 PUSHDOWN_ENGINE sourceKeyExpr? 起点侧关联键表达式 targetKeyExpr? 终点侧关联键表达式 bridgeRef? 当关系经由中间 bridge object、bridge table 或中间资源表达时使用 lookupExpr? 当关系依赖服务查询或路径查找时使用 3.5 ActionType ActionType 定义可执行、可治理的业务变更入口。 ActionType - name - displayName - description - targetTypeRef - inputSchema - propertyEffectRefs[] - linkEffectRefs[] - executor - permissionPolicyRef? ActionExecutor - serviceRef - executionMode - operationRef? - runtimeDependencies[]? 字段说明： targetTypeRef 动作作用对象 inputSchema 参数定义 propertyEffectRefs[] 可能修改哪些属性 linkEffectRefs[] 可能修改哪些链接 executor 使用 ActionExecutor 明确描述写回执行器，并通过 serviceRef 引用 RuntimeServiceRef permissionPolicyRef? 触发动作需满足的治理规则 ActionExecutor 建议最小结构： serviceRef 必需，引用一个 RuntimeServiceRef，通常应指向 OBJECT_WRITE、FUNCTION_RUNTIME 或 CUSTOM_ENDPOINT executionMode 建议最小取值：SYNC | ASYNC | TRANSACTIONAL | ORCHESTRATED operationRef? 可选，指向后端 operation name、action handler 或 endpoint operation id runtimeDependencies[]? 可选，补充该动作除主执行器之外依赖的其他 RuntimeServiceRef 3.6 FunctionDef FunctionDef 定义逻辑能力，不直接代表业务交易。 FunctionDef - name - displayName - description - inputSchema - reads[] - returns - executor - sideEffectFree FunctionExecutor - serviceRef - runtimeKind - entrypointRef? - runtimeDependencies[]? 字段说明： reads[] 依赖哪些对象、属性、链接 returns 返回值类型，可以是标量、对象引用或对象集合 executor 使用 FunctionExecutor 明确描述逻辑运行时，并通过 serviceRef 引用 RuntimeServiceRef sideEffectFree 是否要求无副作用 FunctionExecutor 建议最小结构： serviceRef 必需，引用一个 RuntimeServiceRef，通常应指向 FUNCTION_RUNTIME、COMPUTE_MODULE_RUNTIME、PUSHDOWN_ENGINE 或 CUSTOM_ENDPOINT runtimeKind 建议最小取值：FUNCTION_SERVICE | COMPUTE_MODULE | PUSHDOWN | CUSTOM_ENDPOINT | MODEL_BACKED entrypointRef? 可选，指向函数入口、容器入口、endpoint operation 或模型调用配置 runtimeDependencies[]? 可选，补充该函数在执行时还会读取或调用的其他 RuntimeServiceRef 4. Security 与治理模型定义 这一层不是 primitive type，但必须进入领域模型主干。 4.1 SecurityDomain SecurityDomain - policies[] - roles[] - markings[] - purposes[] - purposeGrants[]? - scopes[] - organizations[] - constraints[] 说明： 这是一个聚合根，用来承载治理定义 Security 在模型里最好作为聚合域存在，而不是单个 primitive type 4.2 SecurityPolicy SecurityPolicy - name - displayName - description - controlMode - resourceScope - resourceRefs[] - subjectRefs[] - condition - effect 建议最小枚举： controlMode = MANDATORY | DISCRETIONARY | ATTRIBUTE_BASED | PURPOSE_BOUND resourceScope = OBJECT | PROPERTY | LINK | ACTION | FUNCTION | VIEW | OBJECT_SET | APPLICATION effect = ALLOW | DENY | MASK | FILTER 4.3 Role Role - name - displayName - description - grants[] RoleGrant - resourceLevel - resourceRefs[]? - purposeRefs[]? 补充说明： 官方 core concepts 明确指出 roles 是 Ontology 的 central permissioning model 角色可作用在 ontology level，也可作用在 individual resource level Role.grants[] 建议显式建模为 RoleGrant 当组织采用 purpose-based access controls 时，RoleGrant 还可以约束该角色可申请或消费哪些 Purpose 4.4 Marking Marking - name - displayName - description - handlingRule 4.5 Purpose Purpose - name - displayName - description - scopeRef - ownerRef? - resourceRefs[] - justificationRequired PurposeGrant - purposeRef - subjectRef - requestJustification - approvalJustification? - status 字段说明： scopeRef 指向该 Purpose 适用的业务与治理范围 ownerRef? 可选，指向负责批准或维护该 Purpose 的治理主体 resourceRefs[] 该 Purpose 被允许访问的资源边界；公开资料里更接近“用户申请的是 Purpose，而不是单个 dataset” justificationRequired 是否要求申请与审批时记录业务理由 PurposeGrant 建议最小结构： purposeRef 必需，指向某个 Purpose subjectRef 必需，指向用户、组或其他授权主体 requestJustification 必需，记录申请该 Purpose 的理由 approvalJustification? 可选，记录审批侧的理由与限制 status 建议最小取值：REQUESTED | APPROVED | DENIED | REVOKED 补充说明： 公开的 purpose-based access controls 文档表明，Purpose 不只是治理说明文字，而是实际承载 access boundary 的强对象 因此在当前模型里，Purpose 应强于一般性的治理上下文字段，更接近 Role / Marking / SecurityPolicy 这一层级 4.6 Scope Scope - name - displayName - description - applicableResourceKinds[] 4.7 Organization Organization - name - displayName - description - parentRef? 4.8 SecurityConstraint 这是我们更可能在实现层真正落库的安全规则结构。 SecurityConstraint - name - constraintKind - scopeRef - resourceRef - subjectExpr - subjectAttributes[]? - resourceAttributes[]? - conditionExpr - effect - explainMessage 补充说明： 官方权限文档进一步强化了 type-level permission 与 object data permission 的区别 因此 SecurityConstraint 更适合显式区分 mandatory / discretionary / attribute-based / purpose-based 这几类控制语义 建议最小枚举： constraintKind = TYPE_LEVEL | OBJECT_DATA | PURPOSE_GUARD | ATTRIBUTE_FILTER 因此 resourceRef 既可能指向 ontology resource，也可能指向 object data access boundary 5. Runtime resources 定义 这部分是官方明确存在、但不适合抬到 primitive 层的运行时资源。 5.1 ObjectSet ObjectSet - name - displayName - description - baseObjectTypeRef - definition - lifecycleKind - shareable ObjectSetDefinition - definitionKind - filterExpr? 字段说明： baseObjectTypeRef 该集合面向哪类对象 definition 使用 ObjectSetDefinition 明确描述集合定义方式与筛选条件 definitionKind 建议最小取值：STATIC | DYNAMIC lifecycleKind 建议最小取值：TEMPORARY | PERMANENT shareable 是否作为可共享资源保存 ObjectSetDefinition 建议最小结构： definitionKind 必需，用于区分静态集合与动态集合 filterExpr? 当 definitionKind = DYNAMIC 时使用，表示对象集合的筛选定义 补充说明： 官方明确说 object sets are saved as resources 官方明确说 object sets 可按 definition 和 current state 分别描述 5.2 ObjectView ObjectView - name - displayName - description - baseObjectTypeRef - viewKind - formFactor - propertyRefs[] - linkRefs[] - relatedActionRefs[] - relatedAppRefs[] 字段说明： baseObjectTypeRef 视图围绕哪个对象展开 viewKind 建议最小取值：CORE | CUSTOM formFactor 建议最小取值：FULL | PANEL propertyRefs[] 展示哪些属性 linkRefs[] 展示哪些关联上下文 relatedActionRefs[] 视图中允许触发哪些动作 relatedAppRefs[] 与哪些应用或工作台入口关联 补充说明： 官方明确说 object views are reusable representations of object data 官方明确分为 core object views 与 custom object views 官方明确分为 full 与 panel 两种 form factor 5.3 MemoryProfile 如果要吸收官方 agent runtime 里的 memory patterns，更合适的建模方式不是把 memory 当 primitive，而是给对象集合、视图或 agent capability 附加运行时配置。 MemoryProfile - name - memoryKind - targetRef - retentionRule 建议最小枚举： memoryKind = WORKING | EPISODIC | SEMANTIC | PROCEDURAL 6. Ontology application domain 公开资料已经把 object-aware applications 讲成了一组稳定的 northbound surfaces。 它们不适合回落为 primitive，但也不宜继续只作为 ObjectView.relatedAppRefs[] 之类的附带字段存在。 6.1 OntologyApplicationDomain OntologyApplicationDomain - applicationSurfaces[] 字段说明： applicationSurfaces[] 直接承载公开存在的 object-aware application surfaces，例如 Object Views、Object Explorer、Quiver、Workshop、Slate、Carbon、Map 6.2 ApplicationSurfaceDef ApplicationSurfaceDef - name - surfaceKind - description - primaryUseCases[] - workflowStyle - configurationModel - backedResourceKinds[] 字段说明： surfaceKind 建议最小取值：OBJECT_VIEW | OBJECT_EXPLORER | QUIVER | WORKSHOP | SLATE | CARBON | MAP primaryUseCases[] 建议最小取值来自公开对比口径：DISCOVERY | ANALYSIS | DASHBOARD | APPLICATION | GEOSPATIAL workflowStyle 建议最小取值：EXPLORATORY | WORKFLOW_SPECIFIC | HYBRID configurationModel 建议最小取值：WALK_UP | CUSTOMIZABLE backedResourceKinds[] 说明该应用面主要围绕哪些资源工作，例如 OBJECTS | DATASETS | ACTIONS | FUNCTIONS 校准说明： 官方已经把 object-aware applications 的 use case、workflow style、configuration model 讲成稳定比较维度 因此更稳妥的做法是把它们收敛成 northbound application surface，而不是把 Workshop / Slate / Carbon 重新打回 capability prose 7. Ontology modeling and type system 定义 7.1 SharedProperty SharedProperty - name - displayName - description - valueTypeRef - metadata 说明： 它不是新的 primitive 它是 PropertyDef 的复用模板 官方明确强调 shared 的是 property metadata，而不是底层对象数据 7.2 ValueTypeDef ValueTypeDef - name - displayName - baseType - description - validationRule? - format? - governance ValueTypeGovernance - spaceScoped - versioned - permissioned 建议最小 baseType： STRING NUMBER BOOLEAN DATETIME ENUM OBJECT_REF 补充说明： 官方明确说 value types 是 semantic wrappers around a field type 官方明确说 value types 不是 object types / properties 这类 Ontology resources 本身 官方明确说 value types 具有 space scope，并具备 permissions 与 versions spaceScoped / versioned / permissioned 更适合作为 ValueTypeGovernance 7.3 ObjectTypeGroup ObjectTypeGroup - name - displayName - description - objectTypeRefs[] - discoverable 补充说明： 官方将其称为 classification primitive 但其主要职责仍是 search / explore / discovery 组织，而不是核心语义行为建模 在我们的领域模型里继续把它放在 modeling resource 层更稳妥 8. Execution infrastructure domain 第 8 节不再只作为解释性段落，而是正式收敛成一组 runtime 子模型。 本节遵循两条校准原则： 对公开官方架构口径，优先对齐 Ontology Engine、它承担的 reads / writes / sync、Compute Modules、Custom endpoints、MMDP、Rubix 与 Interoperability surfaces 对实现建模，只补入公开口径之下确有必要的一层 RuntimeServiceRef；而 routing、capability、dependency 更适合作为执行规划或部署元数据，而不是当前 V1 的正式领域对象 因此这里的正式结构不是“一个 refs 列表”，而是“一个 execution infrastructure 聚合 + 若干子类型”。 8.1 ExecutionInfrastructureDomain ExecutionInfrastructureDomain - engineCapabilities[] - serviceRefs[] - interoperabilitySurfaces[]? 字段说明： engineCapabilities[] 公开 Ontology Engine 口径下的核心执行能力定义，例如 reads、writes、sync，以及围绕它延展的 logic / workflow execution serviceRefs[] 具体可被 binding / executor / router 引用的逻辑后端服务 interoperabilitySurfaces[]? 对外暴露的 API / SDK / pushdown / container / external orchestration 接口面 约束建议： engineCapabilities[] 在 V1 建议保留 serviceRefs[] 在 V1 必需 interoperabilitySurfaces[]? 视是否要显式建模对外开放面决定是否纳入 V1 routing / capability / dependency 建议先作为 planner、explain、deployment metadata 处理，而不是当前领域模型主干 8.2 EngineCapabilityDef EngineCapabilityDef - name - capabilityKind - serviceDomain - description - responsibilities[]? - serviceRefNames[] 字段说明： name 能力定义的稳定标识，例如 ontology-engine-reads capabilityKind 建议最小取值：READ_COORDINATION | WRITE_COORDINATION | CONTINUOUS_SYNC | LOGIC_EXECUTION | WORKFLOW_ORCHESTRATION serviceDomain 建议最小取值：DATA | LOGIC | WORKFLOW description 对该执行能力的职责概述 responsibilities[]? 可选，补充这项能力还承担的子职责 serviceRefNames[] 归属于该执行能力的具体 service refs 校准说明： 这是第 8 节里最贴近当前公开 Ontology Engine 口径的对象 公开页面最稳定的说法不是多个 service planes，而是 Ontology Engine 负责 reads、writes、sync，并向 logic / workflow execution 延展 但 architecture center 又稳定保留了 Data Services / Logic Services / Workflow Services 这一层公共语言 因此这里最稳妥的做法不是回到 plane taxonomy，而是在 capability 轴之外补一个 serviceDomain 作为二级分类轴 关于 MMDP 与 Rubix： 它们对 execution infrastructure 的校准仍然重要 但更适合作为架构背景与非功能约束来源，而不是当前领域模型里的正式对象类型 换句话说，MMDP / Rubix 用来解释 execution runtime 是建立在什么样的底层能力之上，而不是让业务 binding 或 executor 直接引用它们 8.3 InteroperabilitySurfaceDef InteroperabilitySurfaceDef - name - surfaceKind - description - protocolKinds[] - exposedCapabilities[] 字段说明： surfaceKind 建议最小取值：REST_API | VIRTUAL_TABLE | PUSHDOWN_COMPUTE | CUSTOM_ENDPOINT | EXTERNAL_ORCHESTRATION protocolKinds[] 例如 REST、JDBC、S3_COMPATIBLE、WEBHOOK exposedCapabilities[] 说明该接口面对外开放哪些能力 校准说明： 这一类对象直接对齐官方 Interoperability 文档 它主要承接 custom endpoints、pushdown compute、virtual tables 这类明确公开的开放面；OSDK 更适合放在 toolchain 域 它表达的是 execution infrastructure 如何向外部系统开放，而不是语义对象本身 8.4 RuntimeServiceRef RuntimeServiceRef - name - serviceKind - capabilityRef - description - endpointRef? - consumedBy[]? 字段说明： name 运行服务引用的稳定标识，例如 primary-read-runtime、regional-actions-runtime、default-metadata-runtime serviceKind 指向哪一类实现角色，例如 metadata、object read、object write、object sync、function runtime、compute module runtime、pushdown engine、custom endpoint capabilityRef 该服务隶属哪个执行能力定义，例如 ontology-engine-reads 或 logic-execution description 解释这条引用在当前 ontology 中承担什么职责 endpointRef? 可选，指向具体 endpoint、service registry key、deployment alias 或 connection profile consumedBy[]? 可选，作为派生的反向索引，说明这条引用会被哪些 binding / executor / runtime dependency 消费；它更适合 explain 或索引视图，而不是规范存储字段 建议最小枚举： METADATA OBJECT_READ OBJECT_WRITE OBJECT_SYNC FUNCTION_RUNTIME COMPUTE_MODULE_RUNTIME PUSHDOWN_ENGINE CUSTOM_ENDPOINT 校准说明： 上面这组 serviceKind 也比 OMS / OSS / Actions Service / Funnel 更像稳定抽象层，因为它们描述的是角色，而不是某个实现名 结合最新公开文档，Compute Modules 已值得从 runtimeKind 补充项提升为 serviceKind 级别的正式执行角色 如果需要贴执行实现，可以在实例层把 METADATA / OBJECT_READ / OBJECT_WRITE / OBJECT_SYNC / FUNCTION_RUNTIME / COMPUTE_MODULE_RUNTIME 分别映射到 OMS / Object Database / OSS / Actions Service / Object Data Funnel / Functions on Objects / Compute Modules / Custom Endpoints 它的核心作用不是“描述业务语义”，而是“给语义定义一个明确的执行落点”。 如果没有这一层引用，前面的 binding、executor、runtime dependency 最终只能写成模糊字符串，例如“走对象查询服务”“走动作执行服务”。这样会有三个问题： 语义模型无法稳定指向执行平面 planner / router 很难在不污染 primitive 的前提下决定调用哪类后端 同一种 ObjectType、FunctionDef、ActionType 很难在多环境、多区域、多 runtime 下复用 因此 RuntimeServiceRef 更像一个基础设施适配层，负责把语义层和运行层接起来，但不让运行实现细节反向渗入 ObjectType / LinkType / FunctionDef 本身。 可以把它理解成三层之间的连接件： 语义层定义“是什么” RuntimeServiceRef 定义“去哪执行 / 去哪读取 / 去哪同步” 真实后端服务定义“如何执行” 更具体地说，它主要服务于四类场景： PropertyDef.binding / LinkType.binding 说明属性读取、链接遍历最终落到哪个 read capability 或 object database ActionType.executor 说明动作写回、事务提交、副作用执行由哪类 backend service 承接 FunctionDef.executor 说明函数是在 functions-on-objects、通用 service runtime 还是其他逻辑平面执行 planner / router / explain 说明一次查询或执行计划为什么会选择某个 backend path，并为 explain 输出提供可解释的依赖节点 按 serviceKind 看，它们分别扮演的角色可以写得更清楚： METADATA 面向 schema / metadata definition，例如可落到 OMS OBJECT_READ 面向对象读取、搜索、过滤、聚合，可落到 OSS 或 object database serving path OBJECT_WRITE 面向动作执行、事务提交、写回编排，可落到 Actions Service OBJECT_SYNC 面向 datasource ingest、变更编排、对象后端写入，可落到 Object Data Funnel FUNCTION_RUNTIME 面向函数、logic、containerized code 或 functions-on-objects 执行 COMPUTE_MODULE_RUNTIME 面向由 Compute Modules 承载的容器化业务逻辑与模型执行 PUSHDOWN_ENGINE 面向 Databricks、BigQuery、Snowflake 这类被 Foundry/AIP 原生编排的外部或商用 compute engine CUSTOM_ENDPOINT 面向由平台托管、但以自定义 API 形式暴露的执行入口 一个更贴近实现的例子： PropertyDef(employee.managerId) - binding.readServiceRef = employee-read-runtime LinkType(Employee -&gt; Manager) - binding.traversalServiceRef = employee-graph-runtime ActionType(UpdateEmployeeStatus) - executor.serviceRef = employee-write-runtime FunctionDef(GetEmployeeNetwork) - executor.serviceRef = employee-network-runtime - runtimeDependencies[] = [employee-read-runtime, employee-graph-runtime] 这里的重点是： primitive 只描述业务语义与约束 RuntimeServiceRef 只描述执行依赖与运行位置 真正的 endpoint、deployment、regional routing 继续留在基础设施层，不直接写死到 primitive 使用原则： 只在 binding、executor、runtime dependency 中引用 不让运行后端反向污染语义层定义 OSDK 不放在这里，因为它属于 toolchain / developer capability，而不是 backend service 它更适合被视为 logical service reference，而不是具体 deployment instance 如果未来要支持多运行时 function routing，这一层就是最自然的路由目标抽象 8.5 执行规划与部署元数据 这部分仍然重要，但从公开资料校准后看，更适合作为以下产物，而不是当前 V1 的正式领域对象： planner / router 的内部选路规则 explain graph 的依赖边 deployment profile、SLA、latency、consistency、regional placement 等运维元数据 原因是： 这些概念明显更靠近执行器、调度器、部署系统与 explain engine 公开文档更稳定地描述了 Ontology Engine、Interoperability、Compute Modules、Custom endpoints、OSDK 但没有把 routing policy、capability profile、dependency edge 当作稳定的一层产品对象公开强调 因此在当前领域模型里，更稳妥的做法是： 正式对象保留 ExecutionInfrastructureDomain / EngineCapabilityDef / InteroperabilitySurfaceDef / RuntimeServiceRef 将 routing、capability、dependency 视为后续执行架构或 explain schema 的扩展 9. Ontology toolchain and delivery domain 围绕 Ontology 的开发与交付能力，公开资料里已经形成了稳定的对象边界，因此不宜继续只放在零散 capability 说明中。 9.1 OntologyToolchainDomain OntologyToolchainDomain - developerSurfaces[] - deliverySurfaces[]? - packages[]? 字段说明： developerSurfaces[] 对开发者开放的程序化或构建入口，例如 OSDK、Platform APIs、Developer Console、Palantir MCP deliverySurfaces[]? 产品打包、安装与分发入口，例如 Foundry DevOps 与 Marketplace packages[]? 交付对象集合，用于承载版本、内容、输入映射、release channel 等真正的 product delivery 元数据 9.2 DeveloperSurfaceDef DeveloperSurfaceDef - name - surfaceKind - description - accessMode - selectedResourceRefs[]? - applicationBinding? - authClientKind? - backedResources[] 字段说明： surfaceKind 建议最小取值：OSDK | PLATFORM_API | PLATFORM_SDK | DEVELOPER_CONSOLE | PALANTIR_MCP accessMode 建议最小取值：CODEGEN | PROGRAMMATIC | INTERACTIVE | AGENTIC selectedResourceRefs[]? 可选，表示该开发入口是否只绑定 ontology 的某个预选子集；这点尤其贴近 OSDK 的生成方式 applicationBinding? 可选，表示该入口是否与某个 Developer Console application 或 app registration 绑定 authClientKind? 建议最小取值：PUBLIC | CONFIDENTIAL | TOKEN_SCOPED backedResources[] 该开发入口可操作哪些资源，例如 ontology types、actions、functions、metadata、applications 9.3 DeliverySurfaceDef DeliverySurfaceDef - name - surfaceKind - description - packageRefs[]? 字段说明： surfaceKind 建议最小取值：DEVOPS_PRODUCT | MARKETPLACE packageRefs[]? 指向可被打包、发布、安装、升级的交付对象 9.4 ProductPackageDef ProductPackageDef - name - packageKind - versioningMode - packagedResourceRefs[] - dependencyRefs[]? - installationInputs[]? - releaseChannels[]? 字段说明： packageKind 建议最小取值：USE_CASE | DATA_PRODUCT | APPLICATION_PRODUCT | WORKFLOW_PRODUCT | MIXED_PRODUCT versioningMode 建议最小取值：SEMVER | CHANNEL_VERSIONED packagedResourceRefs[] 该产品包真正承载的内容，例如 ontology、pipelines、applications、AI models、actions、functions dependencyRefs[]? 该产品包依赖的其他 package 或平台资源 installationInputs[]? 安装时必须映射或提供的输入 releaseChannels[]? 可用于分发与升级治理的渠道，例如 DEV | TEST | PROD | PARTNER 校准说明： OSDK、Platform APIs、Platform SDKs、Developer Console、Palantir MCP 已在公开 docs 中具备稳定对象边界 Foundry DevOps 与 Marketplace 也已经不是泛泛 capability，而是正式的 product delivery 体系 仅有 DeliverySurfaceDef 还不够，因为公开 docs 已经稳定暴露了 package、version、content、inputs、release channels、installation 这些交付对象语义 Compute Modules 与 Custom endpoints 虽出现在 developer toolchain 文档里，但更适合作为 execution infrastructure 的执行落点与开放面，而不是单纯的开发入口 10. 关键关系约束 如果这套模型要真正落地，至少要有下面这些关系约束。 10.1 对象与接口 一个 ObjectType 可以实现多个 InterfaceDef 一个 InterfaceDef 可以被多个 ObjectType 实现 10.2 对象与属性 一个 ObjectType 拥有多个 PropertyDef 一个 PropertyDef 必须归属于一个对象，或经由 SharedProperty 模板生成 10.3 对象与链接 一个 LinkType 必须有 sourceTypeRef 与 targetTypeRef sourceTypeRef 和 targetTypeRef 应允许指向对象，也应允许在必要时指向接口 应允许 self-link 10.4 对象与动作 一个 ActionType 必须绑定一个目标对象或目标接口 一个动作可以同时修改 property 与 link 10.5 函数与读取依赖 FunctionDef.reads[] 应能引用对象、属性、链接 返回值应至少支持：标量、对象引用、对象集合 object set 是官方明确支持的函数输入 / 输出形态之一 10.6 安全与资源 SecurityPolicy 与 SecurityConstraint 应可挂到 object、property、link、action、function、view、object set 安全控制既要支持静态角色授权，也要支持上下文条件裁剪 10.7 执行与工具链 一个 RuntimeServiceRef 必须绑定到一个 EngineCapabilityDef DeveloperSurfaceDef 不直接替代 RuntimeServiceRef，而是面向构建和调用的入口面 Compute Modules 与 Custom endpoints 可同时在 toolchain 文档中出现，但在本模型里优先归入 execution infrastructure 11. 一版最可能的最小实现模型 如果目标是先做一个最像官方、但又不至于过度设计的 V1，最合理的最小落地集合是： Core semantic - ObjectType - InterfaceDef - PropertyDef - LinkType - ActionType - FunctionDef Governance - SecurityDomain - SecurityPolicy - Role - Marking - Purpose - SecurityConstraint Runtime resources - ObjectSet - ObjectView Application surfaces - OntologyApplicationDomain - ApplicationSurfaceDef Type system - SharedProperty - ValueTypeDef - ObjectTypeGroup Execution infrastructure - ExecutionInfrastructureDomain - EngineCapabilityDef - RuntimeServiceRef - InteroperabilitySurfaceDef? (optional) Toolchain and delivery - OntologyToolchainDomain - DeveloperSurfaceDef - DeliverySurfaceDef? (optional) - ProductPackageDef? (optional) 其中 InteroperabilitySurfaceDef 在 V1 中按需启用；如果当前目标只覆盖 ontology 内部执行路径，可以先不落库，只保留概念位。 其中 DeliverySurfaceDef 在 V1 中也可以按需启用；如果当前阶段只先建模 ontology 与 execution，本轮可以先保留 developerSurfaces[]。 其中 ProductPackageDef 在 V1 中也可以按需启用；如果当前阶段只需表达“有哪些交付面”，可以先不把 package / installation / version 正式落库。 V1 可以暂时不先做满的内容： Purpose grants 与 Scope / Organization 的复杂继承关系 Interface 的完整继承体系 Interface 在不同 surfaces 上的完整支持矩阵 MemoryProfile 的复杂生命周期管理 多运行时 function routing Security policy simulation engine 这一版 V1 之外但值得保留的官方校准点： project-based ontology permissions 与 legacy ontology roles 的兼容问题 interfaces 在不同应用和运行时中的支持差异 object views 的 core/custom 切换与 full/panel form factor value types 的 space scope、permission、version 生命周期 object databases 在 OSv2 中的独立伸缩角色 object-aware applications 的 northbound 差异化建模 delivery package 的 version / inputs / content / installation 关系 12. 一句话版本 如果把这份文档压缩成一句话，可以写成： 最可能的领域模型定义，是以 ObjectType / PropertyDef / LinkType / ActionType / FunctionDef / InterfaceDef 为核心 primitive，以 SecurityDomain + SecurityPolicy + Purpose 为治理主干，以 ObjectSet / ObjectView 为运行时资源，以 OntologyApplicationDomain 承载 northbound application surfaces，以 SharedProperty / ValueTypeDef / ObjectTypeGroup 为类型系统补充，以 ExecutionInfrastructureDomain 承载执行后端与运行依赖，并以 OntologyToolchainDomain + ProductPackageDef 承载开发入口与产品交付面。 13. 术语表 本节给出这份文档中主要词条的工作定义。目标不是给出学术定义，而是说明它们在当前领域模型里的具体含义。 OntologyDomainModel 整份领域模型的根对象，承载语义原语、治理定义、运行时资源、application surfaces、类型系统资源和基础设施引用。 ObjectType 一个真实世界实体、事件或业务对象的 schema 定义。它是 concrete 的、可实例化的，并由 backing datasource 支撑。 PropertyDef 对象上的语义字段定义，说明对象有哪些可读、可筛选、可排序、可聚合的属性，以及这些属性如何绑定到底层数据。 LinkType 对象之间关系的 schema 定义，说明某类对象可以沿什么语义路径连到另一类对象。 ActionType 可执行的业务变更定义。它不是一个按钮，而是一类受治理的事务入口，可以统一修改对象、属性和值链接。 FunctionDef 可复用的逻辑能力定义。它更偏“计算与读取逻辑”，而不是“业务交易”。 InterfaceDef 抽象的 Ontology type，用于描述一组对象共享的形状与能力。接口不能直接实例化，只能由具体对象类型实现。由于官方公开资料明确显示 interfaces 在不同 surfaces 上存在支持差异，因此 supportStatusBySurface[]? 是合理的配套元数据。 SecurityDomain 上位治理域，用来承载访问控制、角色、标记、用途、作用范围和具体安全约束。它不是 type primitive，而是治理聚合根。 SecurityPolicy 一条面向资源访问的治理策略，定义谁在什么控制模式下、对哪些资源执行 ALLOW / DENY / MASK / FILTER。其中 controlMode 用于区分 mandatory、discretionary、attribute-based、purpose-bound 等不同治理语义。 Role Ontology 的中心授权模型之一，用来表达某类主体拥有哪些资源访问或操作权限。它既可以在 ontology 层生效，也可以在资源层生效。 RoleGrant 角色授予项。它把角色在某个资源层级上的授权边界显式化，避免把资源层级和资源集合继续隐含在 Role.grants[] 的缩进里。 Marking 对敏感性或处理要求的治理标记，例如 PII、财务敏感、受限共享等。它决定数据或资源应如何被保护和处理。 Purpose 访问或处理数据的业务用途上下文。它回答“为什么允许做这件事”，常用于 purpose-based control。在公开 PBAC 口径里，用户申请的是某个 Purpose，而不是直接申请单个数据集。 PurposeGrant Purpose 授予项。它把某个主体对某个 Purpose 的申请、理由、审批与当前状态结构化表达出来。 Scope 治理规则的作用范围定义。它回答“一条规则能落到哪些资源种类上”。 Organization 组织层级中的治理主体或组织单元，例如总部、区域、事业部、站点。它通常参与权限边界和继承传播。 SecurityConstraint 实现层真正落库的安全规则结构，用于把抽象治理规则转成具体可执行的 allow、deny、mask、filter 判断。相比 SecurityPolicy，它更接近执行侧，并可以显式表达 constraintKind、subject attributes 与 resource attributes。 ObjectSet 对象集合资源，表示一组对象实例的保存结果。官方语义下它既有定义维度，也有生命周期维度。 ObjectSetDefinition 对象集合的定义结构，用于显式区分 STATIC 与 DYNAMIC 两类集合，并在动态集合场景下承载 filterExpr。 definitionKind 对象集合的定义方式。STATIC 表示按固定主键保存，DYNAMIC 表示按过滤条件保存并随数据变化而更新。 lifecycleKind 对象集合当前的生命周期状态。TEMPORARY 表示临时资源，PERMANENT 表示持久资源。 ObjectView 对象数据的可复用表示，用来汇总一个对象的关键属性、相关链接、相关应用和可执行动作。 OntologyApplicationDomain Ontology 北向应用域聚合。它承载 object-aware applications 这一组稳定公开存在的上层 surfaces，而不把它们重新压回 capability prose。 ApplicationSurfaceDef 应用入口定义，用于表达 Object Views / Object Explorer / Quiver / Workshop / Slate / Carbon / Map 这类 northbound surfaces，并区分它们的 use case、workflow style 与 configuration model。 viewKind 对象视图的种类。CORE 是平台默认视图，CUSTOM 是围绕特定工作流配置出来的自定义视图。 formFactor 对象视图的展现形态。FULL 偏完整对象中心页，PANEL 偏嵌入式或侧边栏的紧凑视图。 MemoryProfile 运行时记忆配置，不是 ontology primitive。它用来描述某类对象集合、视图或能力应如何保留 working、episodic、semantic、procedural memory。 SharedProperty 可在多个对象类型之间复用的属性模板。共享的是属性定义与元数据，不共享底层对象数据。 ValueTypeDef 值类型定义，是 field type 的语义包装。它在基础类型之外增加业务语义、校验规则、权限和版本信息。 ValueTypeGovernance 值类型治理信息。它承载 spaceScoped、versioned、permissioned 这类不属于格式本身、而属于定义治理边界的属性。 spaceScoped 表示一个值类型只在其所在的 space 中有效，而不是全平台全局通用。 versioned 表示一个定义对象支持版本演进，允许区分兼容修改和 breaking change。 permissioned 表示一个定义对象本身也受到权限控制，而不是默认对所有 builder 或 consumer 可见。 ObjectTypeGroup 对象类型分组资源，用于 search、explore、discovery 场景下组织对象类型，而不是表达核心业务语义行为。 ExecutionInfrastructureDomain 执行基础设施域聚合。它承载语义层与执行层之间的结构化连接。按公开资料校准，这一层最稳妥的锚点是 Ontology Engine、reads / writes / sync、logic/workflow execution、Interoperability、MMDP 与 Rubix。在当前领域模型里，它至少包含执行能力、运行服务引用与对外开放面。 EngineCapabilityDef 执行能力定义。它是 ExecutionInfrastructureDomain 下最贴近当前公开 Ontology Engine 口径的一类对象，用于表达 reads、writes、continuous sync，以及围绕它展开的 logic/workflow execution。除 capabilityKind 外，再补一个 serviceDomain 更能对齐 Data Services / Logic Services / Workflow Services 这组公开公共语言。 InteroperabilitySurfaceDef 互操作接口面定义，用于表达 execution infrastructure 通过哪些 REST APIs、virtual tables、pushdown compute、custom endpoints 或 external orchestration 能力与外部系统对接。 Data Services 官方公开架构中的一类运行服务语言，覆盖 data connectivity、data transformation、data virtualization、data storage、data health monitoring 与 data management。它在本文中作为背景性公开分类词汇保留，而不是 EngineCapabilityDef.capabilityKind 的枚举值。 Logic Services 官方公开架构中的一类运行服务语言，覆盖 business rules、machine learning、external model orchestration、LLM integration、Model Ops 与 Agent Ops。它在本文中作为背景性公开分类词汇保留，而不是 EngineCapabilityDef.capabilityKind 的枚举值。 Workflow Services 官方公开架构中的一类运行服务语言，覆盖 interactive compute、event-driven automations、scheduled automations 与 workflow authoring。它在本文中作为背景性公开分类词汇保留，而不是 EngineCapabilityDef.capabilityKind 的枚举值。 MMDP Multimodal Data Plane。官方公开定义的 open data and compute architecture，强调 Any data, any compute, any model, anywhere。它是 execution infrastructure 的重要校准背景，但在当前领域模型里更适合作为架构语境，而不是正式对象类型。 Rubix Palantir 的 hardened、autoscaling、highly available compute substrate。官方明确说它承载 AIP、Foundry、Apollo 的 runtimes and services，并为多云、on-prem 与高安全场景提供统一的运行基底。在当前领域模型里，它更适合作为 runtime 基础设施的背景约束，而不是正式对象类型。 Interoperability surfaces 执行基础设施对外暴露的互操作接口面，包括 REST APIs、virtual tables、pushdown compute、custom endpoints 与 external orchestration。它回答的是 execution infrastructure 如何与外部 data、compute、metadata、security systems 对接。 RuntimeServiceRef 运行服务引用定义。它是 ExecutionInfrastructureDomain 中最核心的一类对象，作用不是表达业务语义，而是给 binding、executor、runtime dependency 提供稳定的执行落点，让语义层可以引用运行层而不被运行实现细节污染。相对 Ontology Engine / Interoperability / MMDP / Rubix 这些更公开、更稳定的架构词汇，它属于更细粒度、实现导向的服务引用抽象。 OntologyToolchainDomain Ontology 工具链与交付域聚合。它承载围绕 Ontology 的开发入口、程序化接口与产品交付面，对应公开资料中的 Ontology Toolchain、Developer toolchain、Foundry DevOps 与 Marketplace。相比前一版，这里还补入了独立的 package 层，以避免 delivery 只剩“入口名录”。 DeveloperSurfaceDef 开发入口定义。它用于表达 OSDK、Platform APIs、Platform SDKs、Developer Console、Palantir MCP 等开发者直接接触的平台入口。它不仅描述入口种类，还应表达所选资源子集、应用绑定和认证客户端形态。 DeliverySurfaceDef 交付入口定义。它用于表达 Foundry DevOps、Marketplace 这类打包、安装、升级与分发产品的正式交付面。它本身不是 package，而是承载和分发 package 的 surface。 ProductPackageDef 交付对象定义。它承载 version、content、inputs、dependencies、release channels 等正式 product delivery 语义，更贴近 Foundry DevOps 与 Marketplace 公开暴露出来的对象边界。 OMS Ontology Metadata Service。负责定义和管理 object types、link types、action types 等元数据资源。 Object Database 负责存储、索引和查询对象数据的后端服务层。它是官方 object backend 架构中的核心组成部分之一。 OSS Object Set Service。负责 serving reads，包括查询、搜索、过滤、聚合、加载对象数据，也负责 object set 的读取服务。 Actions Service 负责执行用户对 Ontology 的编辑与写回，是动作执行链路中的核心后端服务。 Object Data Funnel 负责把 datasource 数据与 actions 产生的编辑编排并写入 object backend 的服务。 Functions on Objects 官方 object backend 架构中的函数执行面，表示函数可以直接围绕对象数据做读取、逻辑与编辑支持。 OSDK Ontology Software Development Kit。它属于 toolchain / developer capability，而不是领域模型里的语义原语或后端服务。 Capability layer 建立在 Ontology 与 runtime 之上的平台能力层，包括 agent、tool、workflow、automation、rules 等。它们消费语义模型，但不属于语义模型本身。 生命周期 一个定义对象或运行时资源从创建、生效、变更、迁移到废弃的时间过程和状态过程。这里最直接的例子是 ObjectSet.lifecycleKind、MemoryProfile.retentionRule、ValueTypeDef.versioned。 继承关系 模型中上层定义向下层定义传播结构或约束的关系。最典型的是 InterfaceDef.extends[]，更广义上也包括组织层级与治理层级中的传播关系。 多运行时 function routing 同一个 FunctionDef 不固定绑定一个执行器，而是根据上下文把执行请求路由到不同运行时，例如 service、TypeScript、Python、model endpoint 或 functions-on-objects execution path。 Security policy simulation engine 安全策略仿真引擎，用于在策略真正上线前预演其效果，例如某个用户将看到什么、哪些对象会被过滤、哪些动作会被拒绝，以及新旧规则的差异影响。 project-based ontology permissions 官方当前强调的一种权限管理方式，把 ontology resources 作为项目资源纳入统一的权限体系，而不是完全依赖旧的 ontology roles 模式。 legacy ontology roles 旧的 ontology 权限模型，按照 ontology-specific roles 授予资源访问能力。当前仍是兼容现实中的一个重要约束来源。 Tool 由 Ontology、logic 和 action 组合出来、供人或 agent 调用的能力封装。它来自 toolchain / capability 层，而不是 primitive 层。 Workflow 围绕对象、动作、规则和交互编排出来的端到端业务流程。它是上层能力，不是领域模型的基础类型。 Foundry Rules 面向 datasets、objects、time series 的低代码规则与告警能力。它应被视为 capability，而不是 ontology primitive。 "},{"slug":"state-space-model","title":"State Space 模型","tags":["统计","时间序列分析"],"content":"状态空间模型（SSM）通过状态方程描述不可见内部状态的演化，结合观测方程将状态映射为可见输出，是处理动态系统预测的核心数学框架。 背景介绍 一个系统往往可以通过 3 部分进行描述 可观测的系统输出 $y$ 不可观测的系统状态 $x$ 可选的系统输入 $u$ 下面是两个常见的例子 将温度计看作是一个系统，热量是输入，气温是状态，液面高度是输出 将千分尺看作是一个系统，物体长度是状态，千分尺读数是输出 现实中的系统是有误差的，一类被称为系统误差 ，一类称为观测误差 系统误差与系统建模精度有关，比如温度计中液体的热膨胀系数，只能取有有限的小数位近似 观测误差与系统测量精度有关，比如使用千分尺进行测量时，只能无限逼近实际物体长度 为了保证测量的准确性，往往需要重复进行观测，并根据多个观测值求平均来获得更准确的估计 假设第 t 次的输入为 $u_t$，均值 $x_t$ 同时是系统的状态与输出 $y_t$ 真实值估计过程可以表示为一个迭代过程 $$ y_t = x_t = x_{t-1} + \\frac{1}{t}(u_t - x_{t-1}) $$ 每次迭代都能使得状态更为准确 并且该过程有一个十分优良的性质：系统状态 $x_t$ 的变化仅与系统的当前状态 $x_{t-1}$ 相关，而与它的过去历史或未来状态 $x_{t-2},...,x_0$ 无关 上述过程使用线性离散状态空间方程可以表示为 $$ \\begin{array}{ll} x_t = A_tx_{t-1} + B_tu_{t-1}+ \\varepsilon_t, &amp; \\varepsilon_t\\sim \\mathcal N(0,\\sigma_\\varepsilon^2) \\ y_t = C_tx_{t} + \\eta_t, &amp; \\eta_t\\sim \\mathcal N(0,\\sigma_\\eta^2)\\end{array} $$ 状态方程：描述系统内部状态的变化过程 转移矩阵 $A_t$ 根据当前状态 $x_{t-1}$ 估计下一个状态 $x_t$ 输入矩阵 $B_t$ 决定可选的输入 $u_{t-1}$ 如何影响状态跳转 过程噪声 $\\varepsilon_t$ 描述估计状态与真实状态间的误差 观测方程：测量系统当前最新状态 测量矩阵 $C_t$ 将内部状态 $x_t$ 映射为可观察值 $y_t$ 测量噪声 $\\eta_t$ 描述映射过程中引入的误差 基于空间状态模型，可以处理 3 类常见的问题 预测：给定 $x_0,...,x_t$ 预测未来的状态 $x_{t+n|t}$ 滤波：给定 $x_0,...,x_t$ 重建当前的状态 $x_{t|t}$ 平滑：给定 $x_0,...,x_t$ 填充缺失的状态 $x_{t-n|t}$ 此前介绍的 $\\text{ARMA}$ 模型存在下面 3 种空间状态方程形式 Hamilton 形式 状态维度： $r = \\max(p,q+1)$ 状态向量： $\\boldsymbol x_t=\\begin{bmatrix}x_t&amp;x_{t-1}&amp;\\cdots&amp;x_{t-r}\\end{bmatrix}$ 状态方程： $\\boldsymbol x_t=\\begin{bmatrix}\\phi_1&amp;\\phi_2&amp;\\cdots&amp;\\phi_{r-1}&amp;\\phi_{r}\\ 1&amp;0&amp;\\cdots&amp;0&amp;0\\ 0&amp;1&amp;\\cdots&amp;0&amp;0\\ \\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots&amp;\\vdots\\ 0&amp;0&amp;\\cdots&amp;1&amp;0\\ \\end{bmatrix}\\boldsymbol x_{t-1} + \\begin{bmatrix}\\varepsilon_{t}\\0\\0\\\\vdots\\0\\end{bmatrix}$ 观测方程： $y_t = \\begin{bmatrix}1&amp;\\theta_1&amp;\\theta_2&amp;\\cdots\\theta_{r-1}\\end{bmatrix}\\boldsymbol x_t$ 状态方程是一个 $\\text{AR}$ 过程：$\\phi(B)x_t=\\varepsilon_t$ 观测方程是一个 $\\text{ARMA}$ 过程：$y_t=\\theta(B)x_t\\ \\Rightarrow\\ \\phi(B)y_t=\\theta(B)\\varepsilon_t$ Harvey 形式 状态维度： $r = \\max(p,q+1)$ 状态向量： $\\boldsymbol x_t=\\begin{bmatrix}x_t&amp;x_{t-1}&amp;\\cdots&amp;x_{t-r}\\end{bmatrix}$ 状态方程： $\\boldsymbol x_t=\\begin{bmatrix}\\phi_1&amp;1&amp;0&amp;\\cdots&amp;0\\ \\phi_2&amp;0&amp;1&amp;\\cdots&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots\\ \\phi_{r-1}&amp;0&amp;0&amp;\\cdots&amp;1\\ \\phi_r&amp;0&amp;0&amp;\\cdots&amp;0\\ \\end{bmatrix}\\boldsymbol x_{t-1} + \\varepsilon_{t}\\begin{bmatrix}1\\\\theta_1\\\\theta_2\\\\vdots\\\\theta_{r-1}\\end{bmatrix}$ 观测方程： $y_t = \\begin{bmatrix}1&amp;0&amp;0&amp;\\cdots0\\end{bmatrix}\\boldsymbol x_t$ 展开状态方程可以得到 $\\text{ARMA}$ 过程： $x_{t,r}=\\phi_rx_{t-1,1}+\\theta_{r-1}\\varepsilon_t$ $x_{t,r-1}=\\phi_{r-1}x_{t-1,1}+x_{t-1,r}+\\theta_{r-2}\\varepsilon_t = \\phi_{r-1}x_{t-1,1}+(\\phi_rx_{t-2,1}+\\theta_{r-1}\\varepsilon_{t-1})+\\theta_{r-2}\\varepsilon_t $ ... Canonical 形式 $\\text{ARMA}$ 模型可以统一表示为格林函数 $$ y_t = \\sum_{i=0}^\\infty\\psi_i\\varepsilon_{t-i} $$ 用于描述 $\\text{ARMA}$ 模型对白噪声冲击的反应 其中 $\\psi_i= \\begin{cases} 1,&amp;i=0\\ \\theta_i+\\sum_{j=1}^{\\min(p,i)}\\phi_j\\psi_{i-j},&amp;i\\ge1 \\end{cases}$ 是脉冲响应函数的系数，表示白噪声对未来观测值的影响 互补函数用于描述移除外部噪声的情况下 $\\text{ARMA}$ 模型的固有行为，反映了模型的长期趋势和季节性等特征 其定义为 $$ C_t(l)=y_{t+l}-\\sum\\nolimits_{j=0}^{l-1}\\psi_j\\varepsilon_{t+l-j}=C_{t-1}(l+1)+\\psi_l\\varepsilon_t $$ 互补函数可以作为预测方程的初始值，表示在 $t-1$ 时刻向前 $n$ 步的预测值 $\\hat y_{t+n|t-1} = C_{t-1}(n)$，则可以得到以下的状态空间方程 状态维度： $r = \\max(p,q)$ 状态向量： $\\boldsymbol x_t=\\boldsymbol{\\hat y_t} =\\begin{bmatrix}C_t(0)&amp;C_t(1)&amp;\\cdots&amp;C_t(r-1)\\end{bmatrix}$ 状态方程： $\\boldsymbol x_t=\\begin{bmatrix}0&amp;1&amp;0&amp;\\cdots&amp;0\\ 0&amp;0&amp;1&amp;\\cdots&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots\\ 0&amp;0&amp;0&amp;\\cdots&amp;1\\ \\phi_r&amp;\\phi_{r-1}&amp;\\phi_{r-2}&amp;\\cdots&amp;\\phi_{1}\\ \\end{bmatrix}\\boldsymbol x_{t-1} + \\varepsilon_{t}\\begin{bmatrix}1\\\\psi_1\\\\psi_2\\\\vdots\\\\psi_{r-1}\\end{bmatrix}$ 观测方程： $y_t = \\begin{bmatrix}1&amp;0&amp;0&amp;\\cdots0\\end{bmatrix}\\boldsymbol x_t$ 这种表示方式可以简化预测过程表达： $\\hat x_{t|t-1} = \\hat y_t = y_t - \\varepsilon_t = \\sum_{i=0}^\\infty\\psi_i\\varepsilon_{t-i} - \\varepsilon_t=\\sum\\nolimits_{i=1}^\\infty\\psi_i\\varepsilon_{t-i}$ $x_{t+1} = \\varepsilon_{t+1} + \\psi_1\\varepsilon_{t} + \\psi_2\\varepsilon_{t-1} + \\psi_3\\varepsilon_{t-2} + \\cdots \\ = \\varepsilon_{t+1} + \\psi_1\\varepsilon_{t} + \\phi(\\psi_1\\varepsilon_{t-1} + \\psi_2\\varepsilon_{t-2} + \\psi_3\\varepsilon_{t-3}+ \\cdots) \\ = \\varepsilon_{t+1} + \\psi_1\\varepsilon_{t} + \\phi\\hat x_{t|t-1}$ $\\hat x_{t+1|t}=\\phi(\\hat x_{t|t-1})+\\psi_1\\varepsilon_t$ $x_t = \\hat x_{t|t-1} + \\varepsilon_t$ 最后一种形式在书面表达时较为常用，在实际应用中还是以前面两者为准，主要区别在于： Harvey 形式的截距项在状态方程中，Hamilton 形式的截距项在观测方程中 Harvey 形式支持差分，Hamilton 只能表达差分后的稳定过程 目前大多数资料均以 Harvey 形式进行介绍，本文也将采用该形式 卡尔曼滤波器 线性投影定理 在概率论和统计学中，多元随机变量的线性投影 可以理解为将一个随机变量在另一个随机变量或随机向量所张成的空间上的“影子”，这个影子是最能代表原始随机变量在该空间中的信息的线性组合 设随机向量 $x$ 和 $y$ 是两个高维空间中的点，且位于不同的向量空间中 线性投影的目标是找到一个最优的线性组合 $E(x|y) ≈ \\beta + \\gamma y$ 可以将 $y$ 映射至 $x$ 所在的向量空间中，并使得 $E(x|y)$ 在某种距离度量下最接近 $x$ 线性组合 $E(x|y)$ 被称为原始变量 $y$ 的投影 通过将高维随机向量投影到低维空间，可以实现 回归分析：将样本数据映射至回归系数，利用投影后的数据建立预测模型 特征提取：将高维特征映射至低维特征，从原始数据中提取出最能代表数据的特征 信号处理：对信号进行降噪滤波或重建 给定正态随机向量 $$ E\\begin{pmatrix}x\\y\\end{pmatrix}=\\begin{pmatrix}\\mu_x\\\\mu_y\\end{pmatrix}, \\quad Var\\begin{pmatrix}x\\y\\end{pmatrix}=\\begin{pmatrix}\\Sigma_{xx}&amp;\\Sigma_{xy}\\\\Sigma_{yx}&amp;\\Sigma_{yy}\\end{pmatrix} $$ 其条件分布仍服从 $E(x|y) = \\mu_x+ \\Sigma_{xy}\\Sigma_{yy}^{-1}(y-\\mu_y)$ $Var(x|y) = \\Sigma_{xx} -\\Sigma_{xy}\\Sigma_{yy}\\Sigma_{xy}&#39;$ 状态空间方程 对于时间序列模型，常用以下线性高斯模型描述状态空间方程 $$ \\begin{array}{llll} y_t &amp; = Z_t \\alpha_t + d_t + \\varepsilon_t &amp; \\varepsilon_t\\sim \\mathcal N(0,H_t) \\nonumber \\ \\alpha_t &amp; = T_t \\alpha_{t-1} + c_t + R_t \\eta_t &amp; \\eta_t\\sim \\mathcal N(0,Q_t) \\nonumber \\ &amp; &amp; \\alpha_1\\sim \\mathcal N(a_1,P_1) \\nonumber \\ \\end{array} $$ 测量方程 $y_t$ 观测变量 $p\\times 1$ $d_t$ 观测截距 $p\\times 1$ $\\varepsilon_t$ 观测扰动 $p\\times 1$ $Z_t$ 设计矩阵 $p\\times m$ $H_t$ 观测协方差 $m \\times r$ 状态方程 $\\alpha_t$ 状态向量 $m\\times 1$ $c_t$ 状态截距 $m\\times 1$ $\\eta_t$ 状态扰动 $r\\times 1$ $T_t$ 转移矩阵 $m \\times m$ $R_t$ 选择矩阵 $m \\times r$ $Q_t$ 状态协方差 $m \\times r$ 初始化 $a_1$ 初始估计 $m\\times 1$ $P_1$ 初始估计误差 $m \\times m$ 新增加的 $R_t$ 用于重定义扰动项 $\\eta_t$，将 $\\eta_t$ 看作是一个全集，则 $R_t\\eta_t$ 是参与建模的子集 为了保证协方差 $Var(R_t\\eta_t) = R_tQ_tR_t&#39;$ 非负，$R_t$ 必须是正定矩阵 为了方便理解，可以认为初始估计 $a_1$ 与初始估计误差 $P_1$ 是已知量 模型额外假设初始状态向量满足以下假设 当 $y_t$ 可以通过 $\\alpha_1,\\varepsilon_t,\\eta_t$ 的线性组合进行表示时，状态方程描述的系统是线性的 为了保证空间状态方程在任意时刻都是线性的，必须保证系统矩阵 $Z_t,d_t,H_t,T_t,c_t,R_t,Q_t$ 的非随机 即便这些矩阵会随时间发生变化，都必须按照预定义的方式进行变化 当系统矩阵不随时间发生变化时，则称该系统为时不变系统，例如平稳 $\\text{AR}$ 过程是一类特殊的时不变系统 卡尔曼滤波 KalmanFilter 是一种迭代算法，用于在给定当前所有可用信息的情况下计算状态向量的最佳估计量 使用观测值集合 $Y_{t-1} = {y_{t-1},...,y_1}$ 表示 $t=2,3,...$ 时刻的的先验知识，在 $t=1$ 时 $Y_0=\\emptyset$，表示此时系统没有任何先验知识 KalmanFilter 使用正向递归过程构建随机变量$\\alpha_t$与$y_t$的条件概率分布 基于状态预测未来观测： $p(y_t|\\alpha_t) = p(y_t|\\alpha_1,...,\\alpha_t,Y_{t-1})$ 基于预测误差更新状态： $p(\\alpha_{t+1}|\\alpha_t) = p(\\alpha_{t+1}|\\alpha_1,...,\\alpha_t,Y_{t})$ 下面我们考虑：给定信息集 $Y_t$ ，如何计算出： $\\alpha_t$ 条件期望： $a_{t|t} = E(\\alpha_t|Y_t)$ $\\alpha_t$ 条件方差： $P_{t|t} = Var(\\alpha_{t}|Y_t) = E[(\\alpha_t-a_{t|t})(\\alpha_t-a_{t|t})&#39;]$ $\\alpha_{t+1}$ 条件期望： $a_{t+1} = E(\\alpha_{t+1}|Y_t)$ $\\alpha_{t+1}$ 条件方差： $P_{t+1} = Var(\\alpha_{t+1}|Y_t) = E[(\\alpha_{t+1}-a_{t+1})(\\alpha_{t+1}-a_{t+1})&#39;]$ 当 $y_t$未知时，观测最优估计 $E(y_t|Y_{t-1})=E(Z_t\\alpha_t+d_t+\\varepsilon_t|Y_{t-1})=Z_ta_t+d_t$ 当 $y_t$已知时，观测预测误差 $v_t = y_t - E(y_t|Y_{t-1})=y_t-Z_ta_t-d_t$ $v_t$ 称为给定 $Y_{t-1}$时 $y_t$的向前一步预测误差，并且满足以下性质 $E(v_t|Y_{t-1})=E(y_t-Z_ta_t-d_t|Y_{t-1})=E(Z_ta_t+d_t+\\varepsilon_t-Z_ta_t-d_t|Y_{t-1})=0$ $Cov(y_j,v_t)=E[y_jE(v_t|Y_{t-1})&#39;]=0$ $E(v_t) = 0$ 根据线性投影定理，存在以下关系 $$ \\begin{array}{llll} a_{t|t}&amp;=&amp;E(\\alpha_t|Y_t) \\ &amp;=&amp; E(\\alpha_t|v_t,Y_{t-1}) \\ &amp;=&amp; E(\\alpha_t|Y_{t-1})+Cov(\\alpha_t,v_t|Y_{t-1})Var(v_t|Y_{t-1})^{-1}(v_t-E(v_t|Y_{t-1})) \\end{array} $$ $$ \\begin{array}{llll} P_{t|t} &amp;=&amp;Var(\\alpha_t|Y_t) \\ &amp;=&amp;Var(\\alpha_t|Y_{t-1})+Cov(\\alpha_t,v_t|Y_{t-1})Var(v_t|Y_{t-1})^{-1}Cov(\\alpha_t,v_t|Y_{t-1})&#39; \\end{array} $$ 定义 $Cov(\\alpha_t,v_t|Y_{t-1})=E[\\alpha_t(Z_t\\alpha_t + d_t + \\varepsilon_t-Z_ta_t-d_t)&#39;|Y_{t-1}] = E[\\alpha_t(\\alpha_t-a_t)&#39;Z_t&#39;|Y_{t-1}]=P_tZ_t&#39;$ $F_t = Var(v_t|Y_{t-1}) = Var(Z_t\\alpha_t + d_t + \\varepsilon_t-Z_ta_t-d_t|Y_{t-1})=Z_tP_tZ_t&#39;+H_t$ 代入可得 $a_{t|t}=a_t + P_tZ_t&#39;F_t^{-1}v_t$ $P_{t|t} = P_t-P_tZ_t&#39;F_t^{-1}Z_tP_t$ 给定 $a_{t+1} = E(T_t\\alpha_t + c_t +R_t\\eta_t|Y_t)=T_tE(\\alpha_t|Y_t)+ c_t$ $P_{t+1} = Var(T_t\\alpha_t + c_t + R_t\\eta_t|Y_t)=T_tVar(\\alpha_t|Y_t)T_t&#39;+R_tQ_tR_t&#39;$ 分别代入 $E(\\alpha_t|Y_t)=a_{t|t}$ 与 $Var(\\alpha_{t}|Y_t)=P_{t|t}$ 可得到 $a_{t+1}=T_ta_{t|t}+c_t = T_ta_t+K_tv_t + c_t$ $P_{t+1}=T_tP_t(T_t-K_tZ_t)&#39; + R_tQ_tR_t&#39;$ 其中 $K_t=T_tP_tZ_t&#39;F_t^{-1}$ 被称为卡尔曼增益 至此我们得到了 KalmanFilter 中的核心递归公式 计算误差 $v_t=y_t-Z_ta_t-d_t$ $F_t=Z_tP_tZ_t&#39;+H_t$ $K_t=P_tZ_t&#39;F_t^{-1}$（可选） 更新状态 $a_{t|t}=a_t + P_tZ_t&#39;F_t^{-1}v_t$ $P_{t|t} = P_t-P_tZ_t&#39;F_t^{-1}Z_tP_t$ 预测状态 $a_{t+1}=T_ta_{t|t}+c_t $ $P_{t+1}=T_tP_{t|t}T_t&#39; + R_tQ_tR_t&#39;$ 为了保证矩阵 $P$ 的数值稳定性，可以进行以下操作： 修改 $P_{t|t}$ 更新逻辑 $P_{t|t}=(I-K_tZ_t)P_t(I-K_tZ_t)&#39;+K_tH_tK_t&#39;$ 计算完 $P_{t+1}$ 后进行对称化处理 $P_{t+1} = (P_{t+1} + P_{t+1}&#39;)/2$ 预测误差 $v_t$ 在更新中起到了重要作用：预测值 $E(y_t|Y_{t-1})$与 实际值 $y_t$ 的偏差越大，状态估计值 $a_t$ 的变化就越大 KalmanFilter 目的与多元线性回归一致：求解线性最优估计 对于样本数为 t 的线性回归，需要对 $pt\\times pt$矩阵进行求逆 而 KalmanFilter 只需要执行 t 次对 $p\\times p$ 矩阵 $F_t$ 求逆，并且通常情况 $p=1$ 当系统矩阵 $Z_t,H_t, T_t,R_t,Q_t$ 是常量时， $P_{t+1}$收敛为一个常量 $\\bar P$ 该矩阵可以通过求解以下方程得到，其中 $\\bar F = Z\\bar PZ&#39;$ $$ \\bar P = T\\bar PZ&#39;+-T\\bar PZ&#39;\\bar F^{-1}Z\\bar PT&#39; + RQR&#39; $$ 直接使用该结果能够大量减少计算开销 状态预测误差 $x_t = \\alpha_t - a_t$，其对应的方差为 $Var(x_t)=P_t$ 其与向前一步预测误差存在关系 $$ v_t = Z_t\\alpha_t+d_t+\\varepsilon_t-(Z_ta_t+d_t) = Z_tx_t+\\varepsilon_t $$ 并且其自身也存在递归关系 $$ x_{t+1} = \\alpha_{t+1}-a_{t+1}=L_tx_t+R_t\\eta_t-K_t\\varepsilon_t$，其中$L_t=T_t-K_tZ_t $$ 两者并称为状态空间模型的 innovation analogue，并且可以进一步推导出 $$ P_{t+1}=T_tP_tL_t&#39;+R_tQ_tR_t&#39; $$ 观测向量的条件样本概率相互独立，满足 $$ p(y_1,...,y_n)=p(y_1)\\prod\\nolimits_{t=2}^np(y_t|Y_{t-1}) $$ 使用 $v_t=y_t-Z_ta_t$ 替换可以得到 $$ p(v_1,...,v_n)=\\prod\\nolimits_{t=1}^np(v_t) $$ 这意味着 $v_1,...,v_n$相互独立，并且$v_t,...,v_n$独立于 $Y_{t-1}$ 状态平滑 平滑问题可以分为 3 类 fix-interval 基于所有已知样本估计某个状态$E(\\alpha_t|y_t,...y_s)$ fixed-point 使用当前与未来的样本更新当前状态 $\\hat\\alpha_{t|n}=E(\\alpha_t|Y_n)\\ \\small{(n&gt;t)}$ fixed-lag 使用当前与未来的样本更新过去状态$\\hat\\alpha_{n-j|n}=E(\\alpha_{n-j}|Y_n)\\ \\small{(n&gt;j)}$ 给定信息集 $Y_n\\ (n\\ge t)$ ，如何计算出： $\\alpha_t$ 条件期望 $\\hat a_t = E(\\alpha_t|Y_n)$ $\\alpha_t$ 条件方差$V_t = Var(\\alpha_{t}|Y_n) = E[(\\alpha_t-\\hat\\alpha_t)(\\alpha_t-\\hat \\alpha_t)&#39;]$ 使用 $r_{t-1}$表示在 $t-1$ 后的预测误差 $v_t,...,v_n$ 加权和，可以得到 $$ r_t=Z_{t+1}&#39;F_{t+1}^{-1}v_{t+1}+L_{t+1}&#39;Z_{t+2}&#39;F_{t+2}^{-1}v_{t+2}+\\cdots+L_{t+1}&#39;\\cdots L_{n-1}&#39;Z_n&#39;F_n^{-1}v_n $$ 给定 $Y_n$ 时 $Y_{t-1}$是固定的常量，$\\hat \\alpha_{t}$ 可以表示为在 $t-1$后的预测误差 $v_t,...,v_n$ 加权和 $$ \\hat \\alpha_t = a_t+P_tZ_t&#39;F_t^{-1}v_t+P_tL_t&#39;Z_{t+1}&#39;F_{t+1}^{-1}v_{t+1}+\\cdots+P_tL_t&#39;\\cdots L_{n-1}&#39;Z_n&#39;F_n^{-1}v_n $$ $\\alpha_t$ 条件期望可以用递归方程表示，其中 $r_n = 0$ $r_{t-1}=Z_t&#39;F_t^{-1}v_t+L_t&#39;r_t$ $\\hat \\alpha_t = a_t+P_tr_{t-1}$ $\\alpha_t$ 条件方差也可以用递归方程表示，其中 $N_n = 0$ $N_{t-1}=Z_t&#39;F_t^{-1}Z_t+L_t&#39;N_tL_t$ $V_t = P_t-P_tN_{t-1}P_t$ 以上四个公式合称为状态平滑递归，且其方向正好与此前的滤波递归相反 为了提升平滑递归的效率，可以在滤波递归期间缓存 $v_t,\\ F_t,\\ K_t,\\ a_t,\\ P_t$ 的计算结果 也可以只存储 $a_t,\\ P_t$ 并使用这两者计算 $v_t,\\ F_t,\\ K_t$ 扰动平滑 给定信息集 $Y_n\\ (n\\ge t)$ ，如何计算出： $\\varepsilon_t$ 条件期望： $\\hat \\varepsilon_t = E(\\varepsilon_t|Y_{t-1},v_t,...,v_n)= \\sum\\nolimits_{j=t}^nE(\\varepsilon_tv_j&#39;)F_j&#39;v_j$ $\\varepsilon_t$ 条件方差： $Var(\\varepsilon_t|Y_n) = Var(\\varepsilon_t|Y_{t-1},v_t,...,v_n)=H_t- \\sum\\nolimits_{j=t}^nCov(\\varepsilon_tv_j&#39;)F_j^{-1}Cov(\\varepsilon_tv_j&#39;)&#39;$ $\\eta_t$ 条件期望： $\\hat\\eta_t = E(\\eta_t|Y_{t-1},v_t,...,v_n)= \\sum\\nolimits_{j=t}^nE(\\eta_tv_j&#39;)F_j&#39;v_j$ $\\eta_t$ 条件方差： $Var(\\eta_t|Y_n) = Var(\\eta_t|Y_{t-1},v_t,...,v_n)=H_t- \\sum\\nolimits_{j=t}^nCov(\\eta_tv_j&#39;)F_j^{-1}Cov(\\eta_tv_j&#39;)&#39;$ 其中 $$ E(\\varepsilon_tv_j&#39;)=E(\\varepsilon_tx_j&#39;)Z_j&#39;+E(\\varepsilon_t\\varepsilon_j&#39;)\\ \\ \\ \\Longrightarrow \\ \\ \\ E(\\varepsilon_tv_j&#39;)=\\begin{cases} H_t,&amp;j=t\\ E(\\varepsilon_tx_j&#39;)Z_j&#39;&amp;j=t+1,...,n \\end{cases} $$ $$ E(\\eta_tv_j&#39;)= E(\\eta_tx_j&#39;)Z_j&#39;+E(\\eta_t\\eta_j&#39;)\\ \\ \\ \\Longrightarrow \\ \\ \\ E(\\eta_tv_j&#39;)=\\begin{cases} Q_t&#39;R_tZ_{t+1}&#39;,&amp;j=t+1\\ E(\\eta_tx_j&#39;)Z_j&#39;&amp;j=t+2,...,n \\end{cases} $$ 给定平滑误差 $u_t = F_t^{-1}v_t-K_t&#39;r_t$ 与 $D_t = F_t^{-1}+K_t&#39;N_tK_t$ $\\varepsilon_t$ 条件期望 $\\hat \\varepsilon_t = H_tu_t\\ (t=n,...,1)$ $\\varepsilon_t$ 条件方差$Var(\\varepsilon_t|Y_n) = H_t-H_tD_tH_t$ 给定 $r_{t-1}=Z_t&#39;u_t+T_t&#39;r_t$ 与$N_{t-1}=Z_t&#39;D_tZ_t+T_t&#39;N_tT_t-Z_t&#39;K_t&#39;N_tT_t-T_t&#39;N_tK_tZ_t$ $\\eta_t$ 条件期望 $\\hat \\eta_t = Q_tR_t&#39;r_t\\ (t=n,...,1)$ $\\eta_t$ 条件方差$Var(\\eta_t|Y_n) = Q_t-Q_tR_t&#39;N_tR_tQ_t$ 初始化 https://github.com/statsmodels/statsmodels/blob/589f167fed77ebf6031d01ad3de1aa7b0040ced3/statsmodels/tsa/statespace/initialization.py 下面考虑如何设置初始估计 $a_1$ 与初始估计误差 $P_1$ ，使用以下模型表示初始随机变量： $$ \\begin{array}{llll} \\alpha_1 = a + A\\delta+R_0\\eta_0 &amp; &amp; &amp; \\eta_0\\sim\\mathcal N(0,Q_0) \\nonumber \\end{array} $$ 常量部分 $a$ 非平稳部分 $A\\delta$ $\\delta$是 $q\\times1$向量 $A$是$m\\times q$ 选择矩阵 平稳部分 $R_0\\eta_0$ $\\eta_0$是 $(m-q)\\times1$向量 $R_0 $ 是$m\\times(m-q)$ 选择矩阵 通过该模型可以表示 4 种初始化方式 已知 如果用户知晓某些领域先验知识，可以通过常量部分将其引入，用于初始化模型 如果不存在任何确定信息，则可以直接设置 $a=0$ 平稳 如果$\\alpha_t$是平稳的，则所有状态随机变量具有稳定的均值与方差 因此可以通过已知的 $R_0,\\eta_0,Q_0$ 描述模型的初始均值、方差、协方差 扩散 当不存在任何先验知识时，可以指定一个较大的初始状态误差，在迭代过程中逐步矫正 将未知常量看作正态随机变量$\\delta \\sim \\mathcal N(0, \\kappa I_q)$ ，其对应的 KalmanFilter 初始参数为 $a_1 = E(\\alpha_1)=a$ $P_1 = Var(\\alpha_1) = Var(A\\delta) + Var(R_0\\eta_0) = \\kappa P_{\\infty,1}+P_{*,1} $ 当 $\\kappa\\to\\infty$时，有 $P_{\\infty,1}=AA&#39;,\\ P_{*,1}=R_0Q_0R_0&#39;$ 当迭代到某个时间点 $t&gt;d$后 $P_{\\infty,d}=0$，不确定部分不再影响 $P_d$的更新 该过程称为扩散，其迭代次数 $d$ 通常与状态向量 $\\alpha_1$的中的未知元素数量相同 这种方式虽然易于实现，它可能导致较大的舍入误差 并且当样本数量不足以完成扩散时，模型会发生退化 混合 为了减少扩散引入的不确定性，可以在初始化时将 $\\alpha_1$划分为多个区间 部分区间使用常量初始化 部分区间使用平稳初始化 剩余未知部分使用扩散初始化 这里也体现了 Harvey 形式与 Hamilton 形式的一个重要区别 一旦 Harvey 形式包含了差分过程，其状态向量是非平稳的，只能使用扩散初始化 而 Hamilton 形式表示一个平稳过程 ，可以基于模型参数平稳初始化状态 精确扩散 使用$O(\\kappa^{-j})$ 表示 $\\kappa\\to\\infty$ 时，函数 $f(\\kappa)$ 展开项 $\\kappa^jf(\\kappa)$的收敛于有限值，其中 $j=1,2$ 与 $P_1$ 类似，均方差矩阵 $P_t$ 可以分解为 $P_t =\\kappa P_{\\infty,t}+P_{*,t}+O(\\kappa^{-1})$，其中 $t=2,...,n$ 通常情况下，当迭代进行到某个时刻 $d$后 $P_{\\infty,t}=0\\ (t&gt;d)$，此时 $P_t = P_{*,t}\\ (t=d+1,...,n)$ 如果所有初始状态变量为确定值，或者具有已知的联合分布时，有 $P_\\infty = 0$，此时 $d=0$ 维度为 $q$ 的随机变量 $\\delta$ 表示 $\\alpha_1$ 中的扩散部分服从正态分布，当 $\\kappa$ 非无穷时其对数概率密度为 $\\log p(\\delta) = -\\frac q2\\log2\\pi-\\frac q2\\log\\kappa-\\frac1{2\\kappa}\\delta\\delta&#39;$ 其与 $Y_t$的联合概率密度可以表示为 $\\log p(\\delta|Y_t) = \\log p(\\delta,Y_t)-\\log p(Y_t)$ 令 $\\kappa\\to\\infty$，对 $\\delta$求导，求解导数为 0 得到一个 $\\delta = \\tilde \\delta$，该解就是给定 $Y_t$时 $\\delta$的条件期望 由于 $p(\\delta,Y_t) $是高斯函数， $\\log p(\\delta,Y_t) $是关于 $\\delta$的二次函数 其二阶导数为常量不依赖 $\\delta$，且二阶导数的负倒数是 $\\delta$给定 $Y_t$时的方差矩阵 该方差矩阵仅在 $t&gt;d$之后存在，此后可以代替近似估计 $\\kappa I$，成为准确的扩散方差估计 如果着样本中包含的信息不足以完成扩散过程，此时模型发生退化 通过递归展开状态方程 $\\alpha_{t+1}=T_t\\alpha_t+R_t\\eta_t $，可以将 $\\alpha_{t+1}$表示为 $a_1$与 $\\eta_1,...,\\eta_t$的线性组合 $a_1$中除了 $\\delta$之外的部分以及 $\\eta_1,...,\\eta_t$ 的无条件方差是有限的 $t \\ge d$ 之后的 $\\delta$部分的条件方差也是有限的 这意味着 $Var(\\alpha_{t+1}|Y_t)=P_{t+1}=\\kappa P_{\\infty,t+1}+P_{*,t+1}+O(\\kappa^{-1})$也是有限的 由于 $\\kappa \\to \\infty$，因此在 $t \\ge d$ 之后必有 $P_{\\infty,t+1} = 0$ 而当 $t$ 时，$\\delta$ 部分的方差 $Var(\\delta,Y_t)$ 随着 $\\kappa \\to \\infty$ 而趋近于无穷 因此 $Var(\\alpha_{t+1}|Y_t) \\ge Var(\\delta,Y_t)$ 必然也趋近于无穷 由于 $P_{*,t+1}+O(\\kappa^{-1})$是有限的，因此必有 $P_{\\infty,t+1} \\ne 0$ 对于非退化模型，存在 $t=d$ 值满足$\\begin{cases} P_{\\infty,t}\\ne0,&amp;t\\le d\\ P_{\\infty,t}=0,&amp;t &gt; d \\end{cases}$ 因此我们可以使用前 $d$个样本进行迭代，直到 $P_{\\infty,t} = 0$ 得到的的 $a_{d+1}$与 $P_{d+1}=P_{*,d+1}$可以作为 KalmanFilter 的初始估计使用 这种初始化方式被称为 exact diffuse init 平稳参数 如果状态由平稳过程产生，则卡尔曼滤波器的初始估计 $a_1$ 与初始估计误差 $P_1$ 由其无条件均值和方差给出 对于一个平稳时不变的转移方程，其无条件均值与协方差由模型参数 $c,\\ T,\\ R,\\ Q$方程给出 $a_1=Ta_1-c\\ \\to\\ a_1 = (I-T)^{-1}c$ $P_1=TP_1T&#39;+RQR&#39;\\ \\to\\ \\text{vec}(P_1)=[I-T\\otimes T]^{-1}\\text{vec}(RQR&#39;)$ 第二个方程通过过向量化和Kronecker积推导而得： $\\text{vec}(AXB) = (B&#39;\\otimes A) \\text{vec}(X)\\ \\Longrightarrow \\ \\text{vec}(TP_1T&#39;) = (T\\otimes T) \\text{vec}(P_1)$ $ \\text{vec}(P_1)-(T\\otimes T) \\text{vec}(P_1)=\\text{vec}(RQR&#39;)\\ \\Longrightarrow \\text{vec}(P_1)=[I-T\\otimes T]^{-1}\\text{vec}(RQR&#39;)$ 其中$\\text{vec}(X)$表示矩阵 X的向量化，它是把 X 的所有列堆起来所形成的列向量 第一个方程是简单线性方程 $(I-T)a_1=c\\ \\Longrightarrow\\ Ax=b$ 第二个方程是离散 Lyapunov 方程 $TP_1T&#39;-P_1+RQR&#39;=0\\ \\Longrightarrow\\ A&#39;XA - X + Q = 0$ 两者均可通过现成的数值算法进行求解 具体实现 回归估计 通过扩展观测方程，可以将解释变量纳入模型 $y_t = Z_t\\alpha_t + X_t\\beta + \\varepsilon_t$ 其中回归系数 $\\beta$ 是 $k\\times1$向量， 解释变量$X_t$是 $p\\times k$ 矩阵 一种常见的实现方式是将 $\\beta$包含在状态向量中 $$ \\begin{array}{llll} y_t &amp;= \\begin{bmatrix}Z_t &amp;X_t\\end{bmatrix}\\begin{pmatrix}\\alpha_t\\ \\beta_t\\end{pmatrix} + \\varepsilon_t \\nonumber \\ \\begin{pmatrix}\\alpha_{t+1}\\ \\beta_{t+1}\\end{pmatrix} &amp;= \\begin{bmatrix}T_t &amp; 0\\0 &amp;I_k\\end{bmatrix}\\begin{pmatrix}\\alpha_t\\ \\beta_t\\end{pmatrix}+\\begin{bmatrix}R_t \\0\\end{bmatrix}\\eta_t \\nonumber \\end{array} $$ 如果使用扩散初始化，则初始状态向量可以表示为 $$ \\begin{array}{llll} \\begin{pmatrix}\\alpha_1\\ \\beta_1\\end{pmatrix} \\sim \\mathcal N \\bigg{ \\begin{pmatrix}a\\0\\end{pmatrix},\\kappa \\begin{bmatrix}P_\\infty &amp; 0\\0 &amp;I_k\\end{bmatrix}+ \\begin{bmatrix}P_* &amp; 0\\0 &amp;0\\end{bmatrix} \\bigg}\\nonumber \\end{array} $$ 存在两种类型的残差计算方式 递归残差 $v_t=y_t-Z_t\\alpha_t-X_t\\hat\\beta_{t-1}\\ \\ (t=d+1,...n)$ 最小二乘残差 $v_t^+=y_t-Z_t\\alpha_t-X_t\\hat\\beta\\ \\ (t=d+1,...n)$ 递归残差 $v_t$ 中的系数 $\\hat\\beta_{t-1}$是随着递归进行逐渐更新 最小二乘残差$v_t^+$ 则是先用全量样本先估计系数 $\\hat\\beta=\\hat\\beta_n$ $v_t$ 的本质是创新误差，天然存在序列不相关性，$v_t^+$ 则是考虑了所有样本的信息 两者均可用于模型诊断 顺序处理 在此前的标准模型设定基础上，增加以下假设 观测变量 $y_t$的维度 $p_t$ 可以随时间发生变化 观测方差 $H_t$是对角矩阵 预测误差方差 $F_t$可以是奇异矩阵 $$ \\begin{array}{llll} y_t = \\begin{pmatrix}y_{t,1}\\\\vdots\\ y_{t,p_t}\\end{pmatrix} &amp;&amp; \\varepsilon_t = \\begin{pmatrix}\\varepsilon_{t,1}\\\\vdots\\ \\varepsilon_{t,p_t} \\end{pmatrix} &amp;&amp; Z_t = \\begin{pmatrix}Z_{t,1}\\\\vdots\\ Z_{t,p_t} \\end{pmatrix} &amp;&amp; H_t = \\begin{pmatrix}\\sigma^2_{t,1}&amp;0&amp;0\\0&amp;\\ddots&amp;0\\ 0&amp;0&amp;\\sigma^2_{t,p_t} \\end{pmatrix} \\nonumber \\end{array} $$ 得到单变量状态空间方程 $$ \\begin{array}{llll} y_{t,i} &amp;= Z_{t,i} \\alpha_{t,i} + \\varepsilon_{t,i} &amp; i&amp;=1,...,p_t &amp;t=1,...n \\nonumber \\ \\alpha_{t,i+1} &amp;= \\alpha_{t,i} &amp; i&amp;=1,...,p_t-1 \\nonumber \\ \\alpha_{t+1,1} &amp;= T_t \\alpha_{t,p_t} + R_t \\eta_t &amp; t&amp;=1,...n\\nonumber \\ \\alpha_{1,1}&amp;=\\alpha_1\\sim\\mathcal N(a_1,P_1) \\nonumber \\end{array} $$ 定义 $$ \\begin{array}{llll} a_{t,1}&amp;=E(\\alpha_{t,1}) \\nonumber\\ P_{t,1}&amp;=Var(\\alpha_{t,1}|Y_{t-1})\\nonumber\\ a_{t,i}&amp;=E(\\alpha_{t,i}|Y_{t-1},y_{t,1},...,y_{t,i-1})\\nonumber\\ P_{t,i}&amp;=Var(\\alpha_{t,i}|Y_{t-1},y_{t,1},...,y_{t,i-1}) \\end{array} $$ 将变量序列 $y_1,...,y_n$ 转换为标量序列 $y_{1,1},...,y_{n,p_n}$，前向递归可以重写为 计算误差 $v_{t,i}=y_{t,i}-Z_{t,i}a_{t,i}$ $F_{t,i}=Z_{t,i}P_{t,i}Z_{t,i}&#39;+\\sigma^2_{t,i}$ $K_{t,i}=P_{t,i}Z_{t,i}&#39;F_{t,i}^{-1}$ 更新状态 $a_{t,i+1}=a_{t,i} + K_{t,i}v_{t,i}$ $P_{t,i+1} = P_{t,i}-K_{t,i}F_{t,i}K_{t,i}$ 预测状态 $a_{t+1,1}=T_ta_{t,p_t+1}+c_{t,i} $ $P_{t+1,1}=T_tP_{t,p_t+1}T_t&#39; + R_tQ_tR_t&#39;$ 值得注意的是 $v_t$ 中的分量与 $v_{t,i}$并不完全相等，只有 $v_t$首个元素等于 $v_{t,1}$ $F_t$ 中的对角线元素与 $F_{t,i}$也并不完全相等，只有 $F_t$首个对角线上的元素等于 $F_{t,1}$ 并且 $F_{t,i}$允许为 0，这意味着最新的观测量 $y_{t,i}$与信息集 $Y_{t-1},y_{t,1},...,y_{t,i-1}$线性相关 对于存在多重共线性的情况，可以忽略该观测值，直接令 $a_{t,t+1}=a_{t,i}$，$P_{t,t+1}=P_{t,i}$ 后向递归可以重写为，其中 $r_{n,p_n} = 0,\\ N_{n,p_n} = 0,\\ L_{t,i}=I_m-K_{t.i}Z_{t.i}$ $r_{t,i-1}=Z_{t,i}&#39;F_{t,i}^{-1}v_{t,i}+L_{t,i}&#39;r_{t,i}$ $r_{t-1,p_t-1}=T_{t-1}&#39;r_{t,0}$ $N_{t,i-1}=Z_{t,i}&#39;F_{t,i}^{-1}Z_{t,i}+L_{t,i}&#39;N_{t,i}L_{t,i}$ $N_{t-1,p_t-1}=T_{t-1}N_{t,0}&#39;T_{t-1}$ 状态平滑重写为，其中 $a_t=a_{t,1},\\ P_t=P_{t,1},\\ r_{t-1}=r_{t,0},\\ N_{t-1}=N_{t,0}$ $\\hat \\alpha_t = a_t+P_tr_{t-1}$ $V_t = P_t-P_tN_{t-1}P_t$ 观测扰动平滑重写为 $\\hat \\varepsilon_{t,i}=\\sigma_{t,i}^2F_{t,i}^{-1}(v_{t,i}-K_{t,i}&#39;r_{t,i})$ $Var(\\hat \\varepsilon_{t,i})=\\sigma_{t,i}^4F_{t,i}^{-2}(F_{t,i}-K_{t,i}&#39;N_{t,i}K_{t,i})$ 如果 $H_t$不是对角矩阵，意味着扰动项 $\\varepsilon_{t,i}$存在相关性，单变量状态空间方程不再适用 此时可以对$H_t$进行 Cholesky 分解 $H_t=C_tH_t^C_t&#39;$，得到对角矩阵 $H_t^$与对角元素为 1 的下三角矩阵 $C_t$ 接着对观测方程进行线性变换 $y_t^*=Z_t^*\\alpha_t +\\varepsilon_t^*$，其中 $y_t^*=C_t^{-1}y_t$，$Z_t^*=C_t^{-1}Z_t$，$\\varepsilon_t^*=C_t^{-1}\\varepsilon_t\\sim\\mathcal N(0,H_t^*)$ 单变量状态空间方程的计算效率显著高于多变量形式，不仅避免了矩阵 $F_t$ 的求逆，还减少了矩阵相乘所需的额外空间分配，在处理高维观测变量时可以节省大量计算开销 观测折叠 如果观测项链维度过高，会导致 $p\\times p$矩阵 $F_t$ 求逆的性能开销巨大 当 $H_t$是非奇异对角矩阵、$P_t$ 非奇异且满足$m \\ll q$ 时，可以使用以下恒等式求逆 $$ F_t^{-1}=(Z_tP_tZ_t&#39;+H_t)^{-1}=H_t^{-1}-H_t^{-1}Z_t(P_t^{-1}+Z_t&#39;H_t^{-1}Z_t)^{-1}Z_t&#39;H_t^{-1} $$ 将$p\\times 1$的观测向量 $y_t$拆分为互不相关两部分 $y_t^*$ 是与 $\\alpha_t$相关的 $m\\times 1$向量，可以基于其构建新的观测方程 $y_t^+$ 是 与 $\\alpha_t$不相关的$(p-m)\\times 1$向量 ，可以将其整合进状态方程中 给定投影矩阵 $A_t^*=(Z_tH_tZ_t&#39;)^{-1}Z_t&#39;H_t^{-1}$，得到 $y_t^*=A_t^y_t$，$y_t^$ 可以看作是 $E(\\alpha_t|y_t)$的最小二乘估计 使用某个矩阵 $B_t $ 构造一个$(p-m)\\times p$满秩矩阵 $A_t^+=B_t(I-Z_tA_t^*)$，满足 $A_t^*Z_t=I_p$ 与 $A_t^+Z_t=0$ 给定 $y_t^+=A_t^+y_t,\\ \\ \\varepsilon_t^*=A_t^*\\varepsilon_t, \\ \\ \\varepsilon_t^+=A_t^+\\varepsilon_t$，转换后的观测方程为 $$ \\begin{array}{llll} \\begin{pmatrix}y_t^*\\y_t^+\\end{pmatrix} &amp;= \\begin{bmatrix}A_t^*\\A_t^+\\end{bmatrix}y_t=\\begin{pmatrix}\\alpha_0 \\0\\end{pmatrix}+ \\begin{pmatrix}\\varepsilon_t^* \\ \\varepsilon_t^+\\end{pmatrix}\\nonumber \\end{array} $$ 由于 $Cov(\\varepsilon_t^+,\\varepsilon_t^+)= E(\\varepsilon_t^+\\varepsilon_t^+)=A_t^H_t(I_p-A_t^{&#39;}Z_t&#39;)B_t&#39;=0$，观测方程可以简化为 $$ \\begin{array}{llll} y_t^*&amp;=\\alpha_t+\\varepsilon_t^*,&amp;\\varepsilon_t^*\\sim\\mathcal N(0,H_t^*)\\nonumber\\ y_t^+&amp;=\\varepsilon_t^+,&amp;\\varepsilon_t^+\\sim\\mathcal N(0,H_t^+)\\nonumber \\end{array} $$ 其中 $H_t^*=A_t^H_tA_t^,\\ \\ H_t^+=A_t^+H_tA_t^+$，忽略掉第二个与状态无关的方程后得到折叠状态空间方程 $$ \\begin{array}{llll} y_t^*&amp;=\\alpha_t+\\varepsilon_t^*,&amp;\\varepsilon_t^*\\sim\\mathcal N(0,H_t^*)\\nonumber\\ \\alpha_{t+1}^+&amp;=T_t\\alpha_t+R_t\\eta_t,&amp;\\eta_t\\sim\\mathcal N(0,Q_t)\\nonumber \\end{array} $$ 当 $m \\ll q$ 时，折叠状态空间方程可以节省大量的计算开销，对于时不变系统 $A_t^*$与 $H_t^*$ 只需计算一次 引入矩阵 $C_t$满足 $C_t&#39;C_t=(Z_tH_tZ_t&#39;)^{-1}$，折叠后的观测方程可以表示为 $$ \\begin{array}{llll} \\bar y_t^*&amp;=Z_t^*\\alpha_t+\\bar\\varepsilon_t^*,&amp;\\bar\\varepsilon_t^*\\sim\\mathcal N(0,\\bar H_t^*)\\nonumber \\end{array} $$ $\\bar A_t^* = C_tZ_tH_t^{-1}$ $Z_t^* = \\bar A_t^* Z_t = C_tZ_t&#39;H_t^{-1}Z_t=C_t&#39;^{-1}$ $\\bar H_t^*=C_tZ_t&#39;H_t^{-1}Z_tC_t&#39;=I_p$ 似然函数 如果未使用扩散初始化，似然函数为 $L(Y_n) = p(y_1,..,y_n)=p(y_1)\\prod_{t=2}^np(y_t|Y_{t-1})$ $\\log L(Y_n) = \\sum_{t=1}^n\\log p(y_t|Y_{t-1})=-\\frac{np}2\\log2\\pi-\\frac12\\sum_{t=1}^n(\\log|F_t|+v_t&#39;F_t^{-1}v_t)$ 如果使用扩散初始化，需要考虑扩散对预测误差的影响，定义 $$ F_t =\\kappa F_{\\infty,t}+F_{*,t}+O(\\kappa^{-1}) $$ 其中 $F_{\\infty,t}=Z_tP_{\\infty,t}Z_t&#39;$ 增加一项描述扩散过程中的似然度，得到 $$ \\begin{array}{llll} \\log L_d(Y_n) &amp;=&amp; \\lim_{\\kappa\\to\\infty} \\big[\\log L(Y_n)+\\frac q2\\log\\kappa\\big]\\ &amp;=&amp; -\\frac{np}2\\log2\\pi-\\frac12\\sum_{t=1}^dw_t+\\frac12\\sum_{t=d+1}^n(\\log|F_t|+v_t&#39;F_t^{-1}v_t) \\end{array} $$ 其中 $$ w_t=\\begin{cases} \\log|F_{\\infty,t}|, &amp; F_{\\infty,t} \\ \\text{ is positive define }\\ \\log|F_{,t}|+v_t^{(0)&#39;}F_{,t}^{-1}v_t^{(0)}, &amp; F_{\\infty,t} = 0 \\end{cases} $$ 如果将 $\\delta$看作某个固定的未知值而不是随机变量，可以将其从似然函数中消除，得到集中似然度 $$ \\log L_c(Y_n)=-\\frac{np}2\\log2\\pi-\\frac12\\sum_{t=1}^d|F_{\\delta,t}|+\\frac12\\sum_{t=d+1}^n(S_{a_n}-b_n&#39;S_{A,n}^{-1}b_n) $$ 其中 $S_{a,n}=\\sum\\nolimits_{t=1}^nv_{a,t}&#39;F_{\\delta,t}^{-1}v_{a,t}$ 参数估计 当给出似然度公式后，可以使用最大似然估计进行模型拟合，此时往往面临两个问题： 如何约束模型参数：平稳性 ARIMA 模型，需要保证参数不存在单位根 如何计算模型梯度：ARIMA 模型的复杂度较高，梯度函数没有解析解 参数约束 使用带约束的数值优化算法 通过参数映射限制搜索空间 梯度估计 常用的数值优化算法都基于梯度下降法实现 估计方差 https://www.sherrytowers.com/mle_introduction.pdf 对于参数向量 $\\boldsymbol\\theta$的对数似然函数 $\\log L(\\boldsymbol\\theta)$，其负的二阶偏导矩阵期望被称为 Fisher 信息矩阵 $$ \\mathcal{I}(\\boldsymbol\\theta) = -E\\bigg[\\frac{\\partial^2\\log L(\\boldsymbol\\theta)}{\\partial\\boldsymbol\\theta\\partial\\boldsymbol\\theta^T}\\bigg] $$ 在使用最大似然估计进行参数估计时，参数的估计方差可以通过信息矩阵进行计算，信息矩矩阵反映了样本提供的信息量，当样本量较大时，MLE 的估计参数的协方差矩阵可以使用信息矩阵的逆来近似表示 $$ Var(\\hat{\\boldsymbol\\theta}) \\approx \\mathcal{I}(\\hat{\\boldsymbol\\theta})^{-1} $$ 由于信息矩阵涉及期望，实际计算中可能需要用样本信息近似，通常使用对数似然函数的负二阶导数（海森矩阵的负值）来近似信息矩阵 $$ \\mathcal{I}(\\hat{\\boldsymbol\\theta}) \\approx -\\frac{\\partial^2\\log L(\\hat{\\boldsymbol\\theta})}{\\partial\\hat{\\boldsymbol\\theta}\\partial\\boldsymbol\\theta^T} \\approx -\\frac1n\\sum_{i=1}^n \\frac{\\partial^2\\log f(x_i;\\hat{\\boldsymbol\\theta})}{\\partial\\hat{\\boldsymbol\\theta}\\partial\\boldsymbol\\theta^T} $$ 如果二阶导数不可得，可以基于观测数据的平均梯度外积近似估计信息矩阵 $$ \\mathcal{I}(\\hat{\\boldsymbol\\theta}) \\approx -\\frac1n\\sum_{i=1}^n \\bigg(\\frac{\\partial\\log f(x_i;\\boldsymbol\\theta)}{\\partial\\boldsymbol\\theta}\\bigg)\\bigg(\\frac{\\partial\\log f(x_i;\\boldsymbol\\theta)}{\\partial\\boldsymbol\\theta}\\bigg)^T $$ 当目标函数（对数似然函数）没有解析解时，则可以通过通过有限差分法近似对数似然的二阶导数 $$ \\frac{\\partial^2\\log L(\\hat{\\boldsymbol\\theta})}{\\partial\\theta_i\\partial\\theta_j} \\approx \\frac{ \\frac{\\partial\\log L(\\boldsymbol\\theta +he_i)}{\\partial\\theta_j} - \\frac{\\partial\\log L(\\boldsymbol\\theta -he_i)}{\\partial\\theta_j} }{2h} $$ 其中 $h$ 是探测步长， $e_i$ 表示第 $i$个方向的单位向量 该表达式也被称为似然函数的预测误差分解形式 对于一元模型，其参数向量 $\\psi=(\\psi_*&#39;,\\sigma_*^2)&#39;$ 由两部分组成 $\\psi_*&#39;$ 中包含 $n-1$个模型参数 $\\sigma_*^2$ 是一个扰动项方差的缩放因子 其测量方程可以表示为 $$ y_t = z_t&#39;x_t+d_t+\\varepsilon_t, \\ \\ \\ Var(\\varepsilon_t)=\\sigma_*^2h_t, \\ \\ \\ t=1,...,T $$ 其中 $z_t$是一个 $m\\times1$的向量， $h_t$是一个标量 其转移方程无需改动，只需将扰动项 $\\eta_t$的协方差重定义为 $\\sigma_*^2Q_t$即可 如果同时将状态的初始方差等比缩放为 $Var(\\alpha_0)=\\sigma_*^2P_0$，此时 KF 可以独立于 $\\sigma_*^2$ 运行 当该参数未知时，可以将其提出似然函数，简化计算过程 忽略该项后预测误差不受影响，但预测误差方差需要重写为 $Var(v_t)=F_t=\\sigma_*^2f_t$ 此时对数似然函数改写为 $$ \\log L(\\psi_*,\\sigma_*^2) = -\\frac T2\\log2\\pi-\\frac T2\\log\\sigma_*^2-\\frac12\\sum_{t=1}^T\\log f_t-\\frac1{2\\sigma_*^2}\\sum_{t=1}^Tv_t^2/f_t $$ 由于 $v_t,\\ f_t$不依赖于$\\sigma_*^2$ ，对似然函数求偏导后只剩下 $\\partial_^2(\\psi_) = \\frac1T\\sum_{t=1}^Tv_t^2/f_t$ 再将其代入似然函数，得到 concentrated log-likelihood function $$ \\log L_*(\\psi_*)=-\\frac T2\\log2(\\pi+1)-\\frac12\\sum_{t=1}^T\\log f_t-\\frac T2\\sum_{t=1}^T\\log \\partial_^2(\\psi_) $$ 最大化该似然函数等价于最小化以下平方和函数 $$ S(\\psi_*) = \\bigg(\\prod_{t=1}^Tf_t\\bigg)\\sum_{t=1}^T(v_t^2/f_t) $$ 以上公式提供了基于预测误差构造似然函数的方法 但对于非平稳状态向量，还需要提供额外的先验知识 从而保证 $\\alpha_0$ 具有合适的均值 $a_0$ 与有界方差 $P_0 $ 一种常用的方法是使用扩散先验，从观测样本中构造初始值 当状态向量中包含 d 个非平稳元素时，可以使用前 d 个样本为 $\\alpha_0$ 构造一个合适的分布 此时似然函数应该排除这些样本，从 d+1 个预测误差开始构造 在高斯模型中，预测误差相互独立且服从高斯分布 $v_t\\sim \\text{NID}(0,F_t), \\ \\ \\ \\ \\ t=1,...,T$ 因此标准化残差 $F_t^{-1/2}v_t$ 可用于模型诊断 对于非平稳的一元模型，有 $\\tilde v_t=v_t/\\sqrt{f_t} \\sim \\text{NID}(0,\\sigma_*^2)$ 状态空间模型天然具备处理观测值缺失的能力，只需简单地忽略更新方程即可 常用状态空间模型 Regression 给定回归模型 $y_t=X_t\\beta+\\varepsilon_t \\ \\ \\ \\varepsilon_t\\sim\\text{N}(0,H_t)$ $\\beta$ 是 $k\\times1$的回归系数 $X_t$ 是 $n\\times k$ 自变量矩阵 方差 $H_t$可能随时间发生变化 当 $\\beta$ 不随时间发生变化时，其空间状态模型可以表示为 观测方程 $y_t = Z_t\\alpha_t + \\varepsilon_t$ $$ Z_t = X_t $$ 状态方程 $\\alpha_{t+1} = \\alpha_t = \\beta$ $$ T_t = I_k \\ \\ \\ \\ R_t = Q_t = 0 $$ 当 $\\beta$ 随时间发生变化时，其空间状态模型可以表示为 观测方程 $y_t = Z_t\\alpha_t + \\varepsilon_t$ $$ Z_t = X_t $$ 状态方程 $\\alpha_{t+1} = \\alpha_t + \\eta_t $ $$ T_t = R_t =I_k \\ \\ \\ \\ Q_t = \\sigma_\\eta^2I_k $$ ARMA 给定 $\\text{ARMA}(p,q)$模型 $y_t = \\phi_1y_{t-1}+\\cdots+\\phi_my_{t-m} + \\zeta_t+\\theta_1\\zeta_{t-1}+\\cdots+\\theta_{m-1}\\zeta_{t-m+1}$ 令 $r = \\max(p,q+1)$，其状态向量可以表示为 $$ \\alpha_t=\\begin{pmatrix}y_t\\ \\phi_2y_{t-1}+\\cdots+\\phi_ry_{t-r+1}+\\theta_1\\zeta_t+\\cdots+\\theta_{r-1}\\zeta_{t-r+2}\\ \\phi_3y_{t-1}+\\cdots+\\phi_ry_{t-r+2}+\\theta_2\\zeta_t+\\cdots+\\theta_{r-1}\\zeta_{t-r+3}\\ \\vdots\\ \\phi_ry_{t-1}+\\theta_{r-1}\\zeta_t\\ \\end{pmatrix} $$ 令 $d_t=0,\\ c_t=0,\\ \\varepsilon_t=0,\\ H_t=0$，可以得到下面的状态空间方程 $\\alpha_{t}=T_t\\alpha_{t-1}+R_t\\eta_t=\\begin{bmatrix}\\phi_1&amp;1&amp;0&amp;\\cdots&amp;0\\ \\phi_2&amp;0&amp;1&amp;\\cdots&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots\\ \\phi_{r-1}&amp;0&amp;0&amp;\\cdots&amp;1\\ \\phi_r&amp;0&amp;0&amp;\\cdots&amp;0\\ \\end{bmatrix}\\alpha_{t-1} + \\begin{pmatrix}1\\\\theta_1\\\\theta_2\\\\vdots\\\\theta_{r-1}\\end{pmatrix}\\zeta_{t}$ $y_t = Z_t\\alpha_t =\\begin{bmatrix}1&amp;0&amp;0&amp;\\cdots0\\end{bmatrix}&#39;\\alpha_t$ 对于 $\\text{ARMA}(1,1)$ 与 $\\text{ARMA}(2,1)$，其状态空间方程可以表示为 $$ \\begin{pmatrix}y_{t+1}\\\\phi_2y_t+\\theta_1\\zeta_{t+1}\\end{pmatrix}= \\begin{bmatrix}\\phi_1&amp;1\\\\phi_2&amp;0\\end{bmatrix} \\begin{pmatrix}y_t\\\\phi_2y_{t-1}+\\theta_1\\zeta_t\\end{pmatrix}+ \\begin{pmatrix}1\\\\theta_1\\end{pmatrix}\\zeta_{t+1} $$ ARIMA 任意 d 阶差分均可以通过 d-1 阶差分进行表示 $\\Delta y_t = y_t-y_{t-1}$ $\\Delta^2 y_t =\\Delta y_t-\\Delta y_{t-1} \\Longrightarrow \\Delta y_t = \\Delta^2 y_t +\\Delta y_{t-1}$ $\\Delta^3 y_t = \\Delta^2 y_t-\\Delta^2 y_{t-1} \\Longrightarrow \\Delta^2 y_t = \\Delta^3 y_t +\\Delta^2 y_{t-1}$ $\\Delta^d y_t = \\Delta^{d-1} y_t-\\Delta^{d-1} y_{t-1} \\Longrightarrow \\Delta^{d-1} y_t = \\Delta^d y_t +\\Delta^{d-1} y_{t-1}$ 因此将其转换为 $y_t$的形式 $y_t = \\Delta y_t +y_{t-1}$ $y_t = \\Delta^2 y_t + \\Delta y_{t-1}+ y_{t-1}$ $y_t = \\Delta^3 y_t +\\Delta^2 y_{t-1} + \\Delta y_{t-1}+ y_{t-1}$ $y_t = \\Delta^d y_t +\\Delta^{d-1} y_{t-1} + \\cdots + \\Delta^2 y_{t-1} + \\Delta y_{t-1}+ y_{t-1}$ 观测方程可以分为两部分 1 个状态预测值 $\\Delta^dy_t$ d 个历史观测值$\\Delta^{d-1} y_{t-1} + \\cdots + \\Delta^2 y_{t-1} + \\Delta y_{t-1}+ y_{t-1}$ 实现这一点仅需在方程中加入一个 d 阶的缓存部分即可 $y_t = \\begin{bmatrix}1_d&amp;1&amp;0\\end{bmatrix}&#39;\\alpha_t$ 令 $y_t^* = \\Delta^dy_t$，对应 $\\text{ARIMA}(p,d,q)$模型的状态向量可以表示为 $$ \\alpha_t=\\begin{pmatrix} y_{t-1}\\\\Delta y_{t-1}\\\\vdots\\\\Delta^{d-1}y_{t-1}\\y^*t\\ \\phi_2y^*{t-1}+\\cdots+\\phi_ry^*_{t-r+1}+\\theta_1\\zeta_t+\\cdots+\\theta_{r-1}\\zeta_{t-r+2}\\ \\vdots\\ \\end{pmatrix} $$ 对应的状态空间方程为 $$ \\alpha_{t}=T_t\\alpha_{t-1}+R_t\\eta_t= \\left[ \\begin{array}{c|c} \\begin{matrix}1&amp;1&amp;\\cdots&amp;1&amp;1\\ 0&amp;1&amp;\\cdots&amp;1&amp;1\\ \\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots&amp;\\vdots\\ 0&amp;0&amp;\\cdots&amp;1&amp;1\\ 0&amp;0&amp;\\cdots&amp;0&amp;1\\ \\end{matrix} &amp; \\begin{matrix}1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots&amp;\\vdots\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ \\end{matrix} \\ \\hline 0_{d\\times d}&amp; \\begin{matrix}\\phi_1&amp;1&amp;0&amp;\\cdots&amp;0\\ \\phi_2&amp;0&amp;1&amp;\\cdots&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots\\ \\phi_{r-1}&amp;0&amp;0&amp;\\cdots&amp;1\\ \\phi_r&amp;0&amp;0&amp;\\cdots&amp;0\\ \\end{matrix} \\end{array} \\right]\\alpha_{t-1} + \\begin{pmatrix}\\vdots\\0_d\\\\vdots\\1\\\\theta_1\\\\theta_2\\\\vdots\\\\theta_{r-1}\\end{pmatrix}\\zeta_{t} $$ 对于 $\\text{ARIMA}(1,2,1)$ 与 $\\text{ARIMA}(2,2,1)$，其状态空间方程可以表示为 $$ y_t=\\begin{bmatrix}1&amp;1&amp;1&amp;0\\end{bmatrix}&#39;\\alpha_t $$ $$ \\begin{pmatrix}y_{t-1}+\\Delta y_{t-1}+y_t^* \\Rightarrow y_t\\\\Delta y_{t-1}+y_t^* \\Rightarrow \\Delta y_t\\ y_{t+1}^*\\\\phi_2y_t^*+\\theta_1\\zeta_{t+1}\\end{pmatrix}= \\begin{bmatrix} 1&amp;1&amp;1&amp;0\\0&amp;1&amp;1&amp;0\\ 0&amp;0&amp;\\phi_1&amp;1\\ 0&amp;0&amp;\\phi_2&amp;0\\end{bmatrix} \\begin{pmatrix} y_{t-1}\\\\Delta y_{t-1} \\ y_t^* \\\\phi_2y_{t-1}^*+\\theta_1\\zeta_t\\end{pmatrix}+ \\begin{pmatrix}0\\0\\1\\\\theta_1\\end{pmatrix}\\zeta_{t+1} $$ SARIMA 给定 $\\text{SARIMA}(p, d, q) \\times (P, D, Q)_s$ 模型 $$ \\phi_p (L) \\tilde \\phi_P (L^s) \\Delta^d \\Delta_s^D y_t = \\theta_q (L) \\tilde \\theta_Q (L^s) \\zeta_t $$ 将两边模型参数分别进行合并，得到一个 $\\text{ARMA}(p+sP,q+sQ)$模型 $$ \\Phi (L) \\Delta^d \\Delta_s^D y_t = \\Theta (L) \\zeta_t $$ $\\Phi (L) \\equiv \\phi_p (L) \\tilde \\phi_P (L^s) $是一个 $p+sP$阶多项式 $\\Theta (L) \\equiv \\theta_q (L) \\tilde \\theta_Q (L^s)$ 是一个 $q+sQ$阶多项式 其状态方程为 $$ \\alpha_{t}=T_t\\alpha_{t-1}+R_t\\eta_t= \\left[ \\begin{array}{c|c} \\begin{matrix}1&amp;1&amp;\\cdots&amp;1&amp;1\\ 0&amp;1&amp;\\cdots&amp;1&amp;1\\ \\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots&amp;\\vdots\\ 0&amp;0&amp;\\cdots&amp;1&amp;1\\ 0&amp;0&amp;\\cdots&amp;0&amp;1\\ \\end{matrix} &amp; \\begin{matrix}1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots&amp;\\vdots\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ 1&amp;0&amp;0&amp;\\cdots&amp;0&amp;0\\ \\end{matrix} \\ \\hline 0_{d\\times d}&amp; \\begin{matrix}\\phi_1&amp;1&amp;0&amp;\\cdots&amp;0\\ \\phi_2&amp;0&amp;1&amp;\\cdots&amp;0\\ \\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots&amp;\\vdots\\ \\phi_{r-1}&amp;0&amp;0&amp;\\cdots&amp;1\\ \\phi_r&amp;0&amp;0&amp;\\cdots&amp;0\\ \\end{matrix} \\end{array} \\right]\\alpha_{t-1} + \\begin{pmatrix}\\vdots\\0_d\\\\vdots\\1\\\\theta_1\\\\theta_2\\\\vdots\\\\theta_{r-1}\\end{pmatrix}\\zeta_{t} $$ SARIMAX SARIMA 模型可以表示为 $(p, d, q) \\times (P, D, Q)_s$ $$ \\begin{array}{llll} \\phi_p (L) \\tilde \\phi_P (L^s) \\Delta^d \\Delta_s^D y_t = A(t) + \\theta_q (L) \\tilde \\theta_Q (L^s) \\zeta_t\\nonumber \\end{array} $$ 带 SARIMA 误差的回归可以很容易地表示为： $$ \\begin{array}{llll} y_t &amp; = \\beta_t x_t + u_t \\nonumber\\ \\phi_p (L) \\tilde \\phi_P (L^s) \\Delta^d \\Delta_s^D u_t &amp; = A(t) + \\theta_q (L) \\tilde \\theta_Q (L^s) \\zeta_t\\nonumber \\end{array} $$ Regression 考虑回归模型 $y_t=X_t\\beta+\\xi_t \\ \\ \\ \\xi_t\\sim\\text{ARMA}(p,q)$ 观测方程 $y_t = Z_t\\alpha_t$ $$ Z_t = \\begin{bmatrix}X_t&amp;1&amp;0&amp;\\cdots&amp;0\\end{bmatrix} $$ $$ \\alpha_t = \\begin{bmatrix} \\beta_t\\ \\alpha_{t,\\text{ARMA}} \\end{bmatrix} $$ 状态方程 $\\alpha_{t+1} = T_t\\alpha_t + R_t\\eta_t $ $$ T =\\begin{bmatrix} I_k &amp; 0 \\ 0 &amp; T_{\\text{ARMA}} \\end{bmatrix} \\ \\ \\ \\ R =\\begin{bmatrix} 0 \\ R_{\\text{ARMA}} \\end{bmatrix} $$ "},{"slug":"arima-model","title":"ARIMA 模型","tags":["统计","时间序列分析"],"content":"ARIMA（Autoregressive Integrated Moving Average）是将自回归、差分平稳化与移动平均结合，用于预测平稳或非平稳时间序列的经典统计模型。 基本概念 平稳性 如果将时间序列 ${y_t,y_{t-1},...,y_0}$ 看作是一组随机变量的观测值，每个时刻 $t$ 对应一个随机变量 $y_t$ 均值 $\\mu_t = E(y_t)$ 方差 $\\sigma_t^2 = E(y_t-\\mu_t)^2$ 自协方差 $\\gamma(t,k) = Cov(y_t,y_k) = E[(y_t-\\mu_t)(y_k-\\mu_k)]$ 自相关系数 $\\rho(t,k) = \\frac{\\gamma(t,k)}{\\sqrt{\\sigma_t^2\\times \\sigma_k^2}} = \\frac{\\gamma(t,k)}{\\sigma_t\\times \\sigma_k}$ 预测模型成立的前提是：未来数据与历史样本的概率分布一致 在时间序列中，这一特性被称为平稳性，平稳性分为两类 强平稳 序列所有统计性质都不会随着时间 $t$ 推移发生变化 任意时刻的随机变量都来源于完全一致的概率分布 弱平稳 保证序列的低阶矩为常量（与时间无关）即可 均值与方差不随时间 $t$ 发生变化 $E(y_t) = E(y_{t-j}) = \\mu$ $Var(y_t) = Var(y_{t-j}) = \\sigma^2$ 自协方差只与时间间隔 $s$ 有关，与起始位置 $t$ 无关 $Cov(y_t,y_{t-s}) = Cov(y_{t-j},y_{t-j-s}) = \\gamma_s$ 只要时间序列满足弱平稳性，即可用历史观测值估计其统计量，并用于预测 均值的估计值 $\\hat{\\mu} = \\bar{y} = \\frac{1}{T}\\sum^{T}_{t=1}{y_t}$ 方差的估计值 $\\hat{\\sigma}^2 = \\frac{1}{T-1}\\sum^{T-1}_{t=1}{(y_t -\\bar{y})^2}$ 协方差估计值 $\\hat{\\gamma_s} = \\hat{\\gamma_{-s}} = \\frac{1}{T-s}\\sum^{T-s}{t=1}{(y_t -\\bar{y})(y{t+s} -\\bar{y})}$ 自相关系数估计值 $\\hat{\\rho_s} = \\hat{\\rho_{-s}} =\\frac{\\hat{\\gamma_s}}{\\hat{\\gamma_0}}$ 平稳序列的一个特例是白噪声 (WN) 过程 ${\\varepsilon_t } \\sim \\text{WN}(0,\\sigma_{\\varepsilon}^2)$ ，其统计值满足 零均值：$E(\\varepsilon_t) = 0$ 同方差：$Var(\\varepsilon_t) = \\sigma_{\\varepsilon}^2$ 非自相关：$Cov(\\varepsilon_t,\\varepsilon_{t-s}) = 0$ 特别地，如果 ${\\varepsilon_t }$服从正态分布，则称其为高斯白噪声过程 虽然白噪声过程满足平稳性条件，但高度随机（各时刻随机变量间互不相关），因此没有建模研究的价值 随机游走 随机游走是非平稳过程的典型代表，在金融与经济领域被广泛应用，该模型常用于刻画市场行为： 资产下一时刻的价格 $y_t$，只与上一时刻的价格 $y_{t-1}$ 相关，价格变化 $\\varepsilon_t = y_t - y_{t-1}$ 取决于市场中的不确定因素 随机游走模型产生的非平稳序列中，主要包含两种趋势成分 随机趋势：突然且不可预测的方向变化 确定性趋势：长期明显的上升或下降趋势 为了方便理解，可以观察以下随机游走模型 零均值随机游走 (RW) $$ y_t = y_{t-1} + \\varepsilon_t = (y_{t-2} + \\varepsilon_{t-1}) + \\varepsilon_t = ... = y_0 + \\sum\\nolimits^t_{i=1}\\varepsilon_i $$ - 随机趋势：初始值 $y_0$ 与历史扰动 $\\varepsilon_i$ 对 $y_t$ 的影响永不衰减 - 确定性趋势：无 带漂移随机游走 (RWD) $$ y_t = c +y_{t-1} + \\varepsilon_t = c + (c + y_{t-2} + \\varepsilon_{t-1})+ \\varepsilon_t = ... = y_0 + c\\times t + \\sum\\nolimits^t_{i=1}\\varepsilon_i $$ - 随机趋势：同上 - 确定性趋势：随着时间增长，趋势以速度 c 发生线性漂移 包含确定趋势带漂移随机游走 (RWD+DT) $$ y_t = c_1 + c_2t + y_{t-1} + \\varepsilon_t = y_0 + c_1t + c_2\\frac{t(t+1)}{2} + \\sum\\nolimits^t_{i=1}\\varepsilon_i = y_0 + (c_1 + \\frac{c_2}{2})t + \\frac{c_2}{2}t^2 + \\sum\\nolimits^t_{i=1}\\varepsilon_i $$ - 随机趋势：同上 - 确定性趋势：线性趋势 + 二次方趋势 时间序列中存在随机趋势，意味着其未来的观测值是发散的，无法收敛至特定的范围 因此在建模前，需要检验时间序列进行检验，判断其中是否存在随机趋势，这类检验被称为单位根检验 同理，确定性趋势也会导致观测值发散，因此在建模前需要将两者去除，保证时间序列的平稳性 差分法 对于随机趋势与线性的确定性趋势，可以通过差分的方式去除，得到平稳白噪声序列 RW : $y_t - y_{t-1} = \\varepsilon_t$ RWD : $y_t - y_{t-1} = c + \\varepsilon_t$ 但对于非线性的确定性趋势，无法简单通过差分去除 RWD+DT : $y_t - y_{t-1} = c_1 + c_2t + \\varepsilon_t$ 回归法 对于非线性的确定性趋势，可以利用线性回归提取出平稳的残差项，再对残差进行建模 以 RWD+DT 为例： 对时间 $t$ 线性回归，得到 $y_t = a + bt + e_t$ 并通过接着计算残差 $e_t = y_t - a - bt$ 消除趋势 对平稳的残差序列 ${e_t, e_{t-1},...,e_1}$ 进行建模 通过数据变换与增加非线性项，理论上回归法能处理任意非线性趋势 可以通过 t 检验或 F 检验判断回归系数的显著性，来确定回归模型的阶数 差分变换 将非平稳时间序列转换为平稳时间序列，需要进行以下两项操作 平稳方差：通过 Log 或 Box-Cox 对数据进行变换 平稳均值：通过差分消除数据趋势与季节性 差分可以消除随机趋势与线性确定性趋势，避免数据发散，从而起到稳定均值的作用 一阶差分 $y&#39;t = y_t - y{t-1}$ 二阶差分 $y&#39;&#39;t = y&#39;t - y&#39;{t-1} = y_t - 2y{t-1} + y_{t-2}$ 除了消除趋势成分外，差分还可以用于消除季节成分（m 为季节成分的长度） 季节差分 $y&#39;t = y_t - y{t-m}$ 季节差分 + 一阶差分 $y^*t = y&#39;t - y&#39;{t-1} = y_t - y{t-1} - y_{t-m} + y_{t-m-1}$ 对于具有很强的季节性的时间序列，建议优先进行季节差分 如果得到的差分序列平稳，则无需进一步差分 过度差分会引入不必要的噪声，以 RWD 为例： 一阶差分 $y&#39;t = y_t - y{t-1} = c + \\varepsilon_t$ 二阶差分 $y&#39;&#39;t = y&#39;t - y&#39;{t-1} = \\varepsilon_t - \\varepsilon{t-1}$ 计算发现二次差分后方差明显变大： 一阶差分 $Var(y&#39;t) = Var(c) + Var(\\varepsilon_t) =\\sigma{\\varepsilon}^2$ 二阶差分 $Var(y&#39;&#39;t) = Var(\\varepsilon_t) +Var(\\varepsilon{t-1}) =2\\sigma_{\\varepsilon}^2$ 此外，每次差分都会减少一个可用数据点，差分次数过多影响样本质量 后移运算 后移符号 backshift notation 表示将观测值向后移动一个时间周期 一阶后移 $By_t = y_{t-1}$ 二阶后移 $B^2y_t = y_{t-2}$ 季节性后移 $B^my_t = y_{t-m}$ 使用后移运算表示差分 1 阶差分 $y&#39;t = y_t - y{t-1} = (1-B)y_t$ 2 阶差分 $y&#39;&#39;t = y&#39;t - y&#39;{t-1} = y_t - 2y{t-1} + y_{t-2} = (1-B)^2y_t$ d 阶差分 $y^d_t = (1-B)^dy_t$ 季节差分 $y&#39;t = y_t - y{t-m} = (1-B^m)y_t$ 季节差分 + 一阶差分 $y^*_t = y&#39;t - y&#39;{t-1} = (1-B)(1-B^m)y_t$ 后移符号可以相乘，方便观察不同差分组合的实际效果 $$ (1-B)(1-B^m)y_t = (1-B-B^m+B^{m+1})y_t $$ 该特性也可以用于判断模型是否存在冗余项 假设存在某个 ARMA 模型 $y_t = 0.5 × y_{t-1} + 0.24 × y_{t-2} + e_t + 0.6 × e_{t-1} + 0.09 × e_{t-2}$ 使用后移符号简化模型后得到 $(1 + 0.3B)(1 - 0.8B) y_{t} = (1 + 0.3B)(1 + 0.3B) e_t$ 消去共同项可以将其简化为低阶模型 $(1 - 0.8B) y_{t} = (1 + 0.3B) e_t$ AR 模型 自回归模型 Autogregressive Model 是一类基于滞后值 lagged value 的多元回归模型 滞后项的数量被称为 AR 模型的阶数，一个 p 阶 AR 模型可以表示为 $$ ext{AR}(p)\\ :\\ y_t = c + \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p} + \\varepsilon_t $$ $\\phi_i$ 是自回归系数 $\\varepsilon_t$ 是白噪声 其中最为简单是 $\\text{AR}(1)\\ :\\ y_t = c + \\phi_1y_{t-1} + \\varepsilon_t$ $\\phi_1 = 0, c = 0\\ \\to\\ y_t = \\varepsilon_t$ $\\text{AR}(1)$ 过程等价于 WN $\\phi_1 = 1, c = 0\\ \\to\\ y_t =\\phi_1y_{t-1} + \\varepsilon_t$ $\\text{AR}(1)$ 过程等价于 RW $\\phi_1 = 1, c \\ne 0\\ \\to\\ y_t =c + \\phi_1y_{t-1} + \\varepsilon_t$， $\\text{AR}(1)$ 过程等价于 RWD $|\\phi_1|&lt;1$ $\\text{AR}(1)$ 过程是平稳的 $\\phi_1&lt;1$ $\\text{AR}(1)$ 过程是在正值与负值之间震荡 从上面的例子中可以看出，只有当自回归系数满足一定条件时，$\\text{AR}(p)$ 过程才是平稳的 平稳性 对于 $\\text{AR}(p)$ 过程可以构造一个 p 阶系数矩阵 A 第一行：包含了模型的参数 $\\phi_i$，这些参数表示各个滞后值对当前值的影响 后续行：形成一个单位矩阵的下半部分，表示滞后变量 $Y_{t-1},\\cdots,Y_{t-p}$ 相对于当前状态的影响 $$ A=\\begin{bmatrix} \\phi_1 &amp; \\phi_2 &amp; \\cdots &amp; \\phi_p\\ 1 &amp; 0 &amp; \\cdots &amp; 0 \\ 0 &amp; 1 &amp; \\cdots &amp; 0 \\ \\vdots &amp; \\vdots &amp; \\ddots &amp; \\vdots \\ 0 &amp; 0 &amp; \\cdots &amp; 1 \\end{bmatrix} $$ 使用特征值为 $\\lambda$ 构造特征方程 $\\det(A-\\lambda I) = 0$ 得到 $\\lambda^p - \\phi_1\\lambda^{p-1} - \\phi_2\\lambda^{p-2} - ... - \\phi_p = 0$ 令 $z = 1/\\lambda$ 简化特征方程得到 $1 - \\phi_1z - \\phi_2z^2 - ... - \\phi_pz^p = 0$ 求解该方程可以得到特征根 $\\lambda_1,\\lambda_2,...,\\lambda_p$，其绝对值表示了过去观测值对当前观测值的影响强度： $|\\lambda| &lt; 1$：影响逐渐减弱，序列回归均值 $|\\lambda| = 1$：影响不变，可能导致趋势 $|\\lambda| &gt; 1$：影响增强，序列会发散 由于特征根通常复数，因此通常使用复平面中半径为 1、中心在原点的单位圆来表示 $|\\lambda| &lt; 1$：特征值位于单位圆内 $|\\lambda| \\ge 1$：特征值在单位圆上或外部 $\\text{AR}(p)$ 模型的平稳性可以使用单位根表示 平稳模型不存在单位根 $\\forall |\\lambda_i| &lt; 1$ 非平稳模型存在单位根 $\\exists |\\lambda_i| = 1$ $\\text{AR}(1)$ 模型对应的特征方程为 $\\lambda^1 - \\phi_1\\lambda^{0} = \\lambda - \\phi_1 = 0$ 特征根 $\\lambda = \\phi_1$，只要满足 $|\\phi_1| &lt; 1$ 模型就是平稳的 $\\text{AR}(2)$ 模型对应的特征方程为 $\\lambda^2 - \\phi_1\\lambda^{1} - \\phi_2\\lambda^{0} = \\lambda^2 - \\phi_1\\lambda - \\phi_2 = 0$ 根据一元二次求根公式，当判别式 $\\Delta = b^2-4ac = (-\\phi_1)^2 + 4\\phi_2 \\ge 0$ 存在两个实数特征根 $\\lambda = \\frac{\\phi_1 ± \\sqrt{\\phi_1^2 + 4\\phi_2}}{2}$ 只要同时满足 $|\\phi_2| &lt; 1$ 与 $\\phi_2\\pm\\phi_1 &lt; 1$ 模型就是平稳的 统计量 平稳 $\\text{AR}(p)$ 过程的均值 $E(y_t) = E(c) + \\phi_1 E(y_{t-1})+ \\phi_1 E(y_{t-1}) + \\cdots + E(\\varepsilon_t)$ 对于平稳过程，有 $E(y_t) = E(y_{t-1}) = \\cdots = \\mu$ 代入后可得 $\\mu = E(c) + \\phi_1 \\mu+ \\phi_2 \\mu + \\cdots + E(\\varepsilon_t)$ 求解可得 $\\mu = \\frac{c}{1-\\phi_1-\\phi_2-\\cdots-\\phi_p}$ 平稳 $\\text{AR}(p)$ 过程的方差 $Var(y_t) = Var(c) + \\phi_1 Var(y_{t-1})+ \\phi_1 Var(y_{t-1}) + \\cdots + Var(\\varepsilon_t)$ 对于平稳过程，有 $Var(y_t) = Var(y_{t-1}) = \\cdots = \\sigma_y$ 求解可得 $\\sigma_y = \\frac{\\sigma_{\\varepsilon}}{1-\\phi_1-\\phi_2-\\cdots-\\phi_p}$ 平稳 $\\text{AR}(p)$ 过程协方差可以递归计算得到 $Cov(y_t,y_{t-1}) = \\phi_1\\sigma_y$ $Cov(y_t,y_{t-s}) = \\sum\\nolimits_{i=1}^p\\phi_iCov(y_{t-i},y_{t-s})$ 预测误差 ${\\hat{y}_{t+l|t}}$ 表示根据时间序列 ${y_t}$ 的 $t$ 时刻之前全部样本观测值，做出的 $l$ 步预测 给定信息信息集 $I_t$，$\\hat{y}{t+l|t}$ 的预测误差可以表示为 $MES(\\hat{y}{t+l}|I_t) = E[(y_{t+l}-\\hat{y}_{t+l})^2|I_t]$ 给定信息集 $I_t$，$y_{t+l}$ 的条件期望 $E[y_{t+l}|I_t]$ 就是最小均方误差预测 因此 $l$ 步预测误差可以表示为 $e_t(l) = y_{t+l} - \\hat{y}{t+l|t} = y{t+l} - E[y_{t+l}|I_t]$ $\\text{AR}(p)$ 模型向前 1 步预测 预测公式：$y_{t+1} = c + \\phi_1y_{t} + \\phi_2y_{t-1} ... + \\phi_py_{t-p+1} + \\varepsilon_{t+1}$ 条件期望：$\\hat{y}{t+1|t} = E(y{t+1}|I_t) = E(c + \\phi_1y_{t} + \\phi_2y_{t-1} ... + \\phi_py_{t-p+1} + \\varepsilon_{t+1}|I_t)$ 在给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ 的情况下，$y_i\\ (i=t,t-1,...,t-p+1)$ 均为常数 $y_{t+1}$ 的预测条件期望 $\\hat{y}{t+1|t} = E(y{t+1}|I_t) = E(c + \\phi_1y_{t} + \\phi_2y_{t-1} ... + \\phi_py_{t-p+1}) + E(\\varepsilon_{t+1}) = c + \\phi_1y_{t} + \\phi_2y_{t-1} ... + \\phi_py_{t-p+1})$ 向前 1 步预测误差：$e_t(1) = y_{t+1} - \\hat{y}{t+1|t} = y{t+l} - E(y_{t+1}|I_t) = \\varepsilon_{t+1}$ 向前 1 步预测误差的方差：$Var(e_t(1)) = Var(\\varepsilon_{t+1}) = \\sigma_\\varepsilon^2$ 如果 $\\varepsilon_{t}$ 服从正态分布，$y_{t+1}$ 的 95% 向前 1 步区间预测为 ：$\\hat{y}{t+1|t}\\pm1.96\\sigma\\epsilon$ $\\text{AR}(p)$ 模型向前 2 步预测 预测公式：$y_{t+2} = c + \\phi_1y_{t+1} + \\phi_2y_{t} ... + \\phi_py_{t-p+2} + \\varepsilon_{t+2}$ 条件期望：$\\hat{y}{t+2|t} = E(y{t+2}|I_t) = E(c+\\phi_1y_{t+1} + \\phi_2y_{t} ... + \\phi_py_{t-p+2} + \\varepsilon_{t+2}|I_t)$ 在给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ 的情况下，$y_i\\ (i=t,t-1,...,t-p+2)$ 均为常数 但是此时 $y_{t+1}$ 是未知的，因此需要用预测值 $\\hat{y}_{t+1|t}$ 替代 $y_{t+2}$ 的预测条件期望：$\\hat{y}{t+2|t} = E(c + \\phi_1\\hat{y}{t+1|t} + \\phi_2y_{t} ... + \\phi_py_{t-p+2}) + E( \\varepsilon_{t+2}) = c + \\phi_1\\hat{y}{t+1|t} + \\phi_2y{t} ... + \\phi_py_{t-p+2}$ 向前 2 步预测误差：$e_t(2) = y_{t+2} - \\hat{y}{t+2|t} = y{t+2} - E(y_{t+2}|I_t) = \\alpha_1(y_{t+1}-\\hat{y}{t+1|t}) + \\epsilon{t+2}= \\alpha_1\\epsilon_{t+1} + \\epsilon_{t+2}$ 向前 2 步预测误差的方差：$Var(e_t(2)) = Var(\\phi_1\\varepsilon_{t+1} + \\epsilon_{t+2}) = \\phi_1^2Var(\\varepsilon_{t+1}) +Var( \\varepsilon_{t+2}) = (\\phi_1^2+1)\\sigma_\\varepsilon^2$ 如果 $\\varepsilon_{t}$ 服从正态分布，$y_{t+2}$ 的 95% 向前 2 步区间预测为 ：$\\hat{y}{t+1|t}\\pm1.96\\sqrt{(\\phi_1^2+1)\\sigma\\varepsilon^2}$ $\\text{AR}(p)$ 模型向前 $l$ 步预测 预测公式：$y_{t+l} = c + \\phi_1y_{t+l-1} + \\phi_2y_{t+l-2} ... + \\phi_py_{t-p+l} + \\varepsilon_{t+l}$ 条件期望：$\\hat{y}{t+l|t} = E(y{t+l}|I_t)$ 在给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ 的情况下，$y_{t+l}$ 的预测条件期望： $\\hat{y}{t+l|t} = c + \\sum{i=1}^{p}\\phi_i\\hat{y}_{t+l-i|t}$ 当 $l \\to \\infty$，$\\hat{y}_{t+l|t}$ 收敛于 $E(y_t)$，即长期预测收敛于无条件均值 $\\mu$ 向前 $l$ 步预测误差 $$ e_t(l) = y_{t+l} - \\hat{y}{t+l|t} \\\\ \\hphantom{--}= \\sum\\nolimits{i=1}^{p}\\phi_iy_{t+l-i|t}-\\sum\\nolimits_{i=1}^{p}\\phi_i\\hat{y}{t+l-i|t}+\\varepsilon{t+l} \\\\ \\hphantom{--}= \\phi_1(y_{t+l-1}-\\hat{y}{t+l-1|t})+ \\phi_2(y{t+l-2}-\\hat{y}{t+l-2|t}) + \\cdots + \\varepsilon{t+l} \\\\ \\hphantom{--}=\\sum\\nolimits_{i=1}^{p}\\phi_i\\varepsilon_{t+l-i} + \\varepsilon_{t+l} $$ 向前 $l$ 步预测误差的方差 $$ Var(e_t(l)) = Var(\\sum\\nolimits_{i=1}^{p}\\phi_i\\varepsilon_{t+l-i} + \\varepsilon_{t+l}) \\\\ \\ \\ \\ \\hphantom{----}= \\phi_1^2Var(\\varepsilon_{t+l-1})+ \\phi_2^2Var(\\varepsilon_{t+l-2})+ \\cdots + Var( \\varepsilon_{t+l}) \\\\ \\ \\ \\ \\hphantom{----}= (\\phi_1^2+\\phi_2^2+\\cdots+\\phi_p^2+1)\\sigma_\\varepsilon^2 $$ 自相关函数 自相关函数 ACF 刻画的是间接相关性 间隔 s 内所有随机变量 $y_{t-1},...,y_{t-s}$ 对随机变量 $y_t$ 的累积影响 偏相关函数 PACF 刻画直接相关性 间隔 s 内所有随机变量 $y_{t-1},...,y_{t-s}$ 对随机变量 $y_t$ 的纯相关关系 $\\text{AR}(p)$ 模型自协方差： $$ \\gamma_s = Cov(y_t, y_{t-s}) = E[(y_t-c)(y_{t-s}-c)]\\ = E[(\\phi_1y_{t-1} + \\phi_2y_{t-2} ... + \\phi_py_{t-p} + \\varepsilon_t)(y_{t-s}-c)]\\ = \\phi_1E(y_{t-1}y_{t-s})+ \\phi_2E(y_{t-2}y_{t-s})+ ... + \\phi_pE(y_{t-p}y_{t-s})+ E(\\epsilon_t y_{t-s})\\ = \\phi_1\\gamma_{s-1} + \\phi_2\\gamma_{s-2} + \\phi_p\\gamma_{s-p}+ E(\\varepsilon_t y_{t-s}) $$ $\\text{AR}(p)$ 模型自相关函数：$\\rho_s = \\frac{\\gamma_s}{\\gamma_0} =\\phi_1\\rho_{s-1} + \\phi_2\\rho_{s-2} + \\phi_p\\rho_{s-p}+\\frac{ E(\\varepsilon_t y_{t-s})}{\\gamma_0}$ 其中 $\\gamma_0 = Var(y_t)$ 就是序列本身的方差 当 s = 0 时，$1 =\\rho_0 = \\phi_1\\rho_{-1} + \\phi_2\\rho_{-2} + \\phi_p\\rho_{-p}+\\frac{ E(\\varepsilon_t y_{t})}{\\gamma_0} $ 自相关函数满足对称性：$\\rho_0 = \\phi_1\\rho_{1} + \\phi_2\\rho_{2} + \\phi_p\\rho_{p}+\\frac{ E(\\varepsilon_t y_{t})}{\\gamma_0} $ 由于 $\\varepsilon_t$ 与 $ y_{t-1}, y_{t-2}, ...$ 不相关，但与 $ y_t$ 是相关的，因此 $E(\\varepsilon_t y_t) \\ne E(\\varepsilon_t)E(y_t)$，展开计算： $$ \\begin{aligned} E(\\varepsilon_t y_t) &amp;= E\\big[\\varepsilon_t\\big(c + \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p}\\big)+\\varepsilon_t^2\\big] \\ &amp;= E\\big[\\varepsilon_t\\big(c + \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p}\\big)\\big]+E(\\varepsilon_t^2) \\ &amp;= E(\\varepsilon_t)E\\big(c + \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p}\\big)+E(\\varepsilon_t^2) \\ &amp;= 0\\times E\\big(c + \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p}\\big)+\\sigma_\\varepsilon^2 \\end{aligned} $$ 最终得到：$\\rho_0 = \\phi_1\\rho_{1} + \\phi_2\\rho_{2} + \\phi_p\\rho_{p}+\\frac{\\sigma_\\epsilon^2}{\\gamma_0} $ 当 s &gt; 0 时，此时 $y_{t-s}$ 不可能为 $y_{t}$，因此保证了 $\\varepsilon_t$ 与 $ y_{t-1}, y_{t-2}, ...$ 是不相关的 $E(\\varepsilon_t y_{t-s}) = E(\\varepsilon_t )E(y_{t-s}) = 0 \\times E(y_{t-s}) = 0$ 最终得到：$\\rho_s = \\phi_1\\rho_{s-1} + \\phi_2\\rho_{s-2} + \\phi_p\\rho_{s-p}$ 使用系数 $\\alpha_i$ 表示间隔为 $i$ 的随机变量 $y_{t-i}$ 与 $y_t$ 间的直接关系 基于自相关函数 $\\rho_s = \\phi_1\\rho_{s-1} + \\phi_2\\rho_{s-2} + \\phi_p\\rho_{s-p}$ ，可得到以下方程组 $$ \\begin{cases} \\rho_1=\\alpha_1\\rho_0+...+\\alpha_p\\rho_{p-1}\\ \\rho_2=\\alpha_1\\rho_1+...+\\alpha_p\\rho_{p-2}\\ \\hphantom{-----} \\vdots\\ \\rho_s=\\alpha_1\\rho_{s-1}+...+\\alpha_p\\rho_{s-p}\\ \\end{cases} \\to \\begin{bmatrix} \\rho_0 &amp; \\rho_1 &amp; ... &amp; \\rho_{p-1}\\ \\rho_1 &amp; \\rho_0 &amp; ... &amp; \\rho_{p-2}\\ &amp;&amp; \\vdots &amp; \\ \\rho_{s-1} &amp; \\rho_{s-2} &amp; ... &amp; \\rho_{s-p} \\end{bmatrix} \\begin{bmatrix} \\alpha_1\\\\alpha_2\\\\vdots\\\\alpha_q \\end{bmatrix}= \\begin{bmatrix} \\rho_1\\\\rho_2\\\\vdots\\\\rho_s \\end{bmatrix} \\to Р \\boldsymbol\\alpha = \\boldsymbol\\rho $$ 求解 $ \\boldsymbol\\alpha = Р^{-1}\\boldsymbol\\rho$ 即可得到偏自相关系数 $\\alpha_1,...,\\alpha_p$ 当 $s &gt; p$ 时， $y_{t-s}$ 对应的自回归系数为 0，因此 $y_t$ 与 $y_{t-s}$ 不存在直接相关性 $\\text{AR}(p)$ 模型偏相关函数： $$ \\alpha_s = \\begin{cases} 1, &amp; s = 0 \\ \\alpha_s, &amp; 1 \\le s \\le p\\ 0, &amp; s &gt; p \\end{cases} $$ 参数估计 Yule–Walker 对零均值 $\\text{AR}(p)$ 移项并在两边同时乘以 $y_{t-h}\\ (h\\ge0)$ 得到 $$ y_{t-h}(y_t - \\phi_1y_{t-1} + \\phi_2y_{t-2} + \\cdots + \\phi_py_{t-p}) = y_{t-h}\\varepsilon_t $$ 然后求期望并使用自协方差 $\\gamma_i=E(y_ty_{t-i})$ 表示 $$ E(y_ty_{t-h})-\\sum_{i=1}^p\\phi_iE(y_{t-i}y_{t-h})= \\gamma_h-\\sum_{i=1}^p\\phi\\gamma_{h-i} E(y_{t-h}\\varepsilon_t)=\\begin{cases} \\sigma_\\varepsilon^2, &amp; h=0\\ 0,&amp; h\\ge1 \\end{cases} $$ 根据 $\\gamma_h-\\sum\\nolimits_{i=1}^p\\phi\\gamma_{h-i} = 0 \\ \\ (h=1,...,p)$ 可以得到以下方程 $$ \\begin{bmatrix} \\gamma_0 &amp; \\gamma_1 &amp; \\cdots &amp; \\gamma_{p-1}\\ \\gamma_1 &amp; \\gamma_0 &amp; \\cdots &amp; \\gamma_{p-2}\\ \\vdots&amp;\\vdots&amp; \\ddots &amp; \\vdots\\ \\gamma_{p-1} &amp; \\gamma_{p-2} &amp; \\cdots &amp; \\gamma_0\\ \\end{bmatrix} \\begin{bmatrix} \\phi_1\\\\phi_2\\\\vdots\\\\phi_p \\end{bmatrix}= \\begin{bmatrix} \\gamma_1 \\ \\gamma_2 \\ \\vdots \\\\gamma_p \\end{bmatrix} o \\boldsymbol{\\Gamma \\phi = \\gamma} $$ 代入自协方差的估计值 $\\hat\\gamma_h = (\\sum\\nolimits_{t=1}^{n-|h|}y_ty_{t+|h|})/n$ 即可求解 $\\boldsymbol{\\hat \\phi =\\Gamma^{-1} \\hat\\gamma}$ 根据 $\\gamma_h-\\sum\\nolimits_{i=1}^p\\phi\\gamma_{h-i} = \\sigma_\\varepsilon^2 \\ \\ (h=0)$ 可以求解 $\\hat\\sigma_\\varepsilon^2 = \\gamma_0-\\sum\\nolimits_{i=0}^p\\phi_ir_i$ Burg 如果矩阵 $\\boldsymbol \\Gamma$ 是病态的，Yule–Walker 方法将对异常样本十分敏感，导致参数 $\\boldsymbol{\\hat\\phi}$ 的估计结果会很不稳定 为了保证参数估计的稳定性，通常使用 Burg 算法估计 $\\text{AR}(p)$ 参数 Burg 算法是一种自回归模型参数估计方法，在处理短数据序列时具有更好的稳定性 考虑两个方向的 $\\text{AR}(k)$ 模型 正向模型基于过去值估计当前观测值 $y_t^+ = \\phi_1y_{t-1}+\\cdots+\\phi_ky_{t-k}+\\varepsilon_t^+ \\ \\ (t\\in[k,n])$ 该模型的误差被称为前向误差，反映参数对未来数据预测能力，其中 反向模型基于未来值估计当前观测值 $y_t^- = \\phi_1y_{t+1}+\\cdots+\\phi_ky_{t+k}+\\varepsilon_t^- \\ \\ (t\\in[0,n-k])$ 该模型的误差被称为后向误差，反映参数对过去数据的拟合情况 Burg 的优化目标是同时以下两个目标函数 前向误差 $F_k=\\sum\\nolimits_{t=k}^n(y_t-y_t^+)^2=\\sum\\nolimits_{t=k}^n(y_t- \\sum\\nolimits_{i=1}^k\\phi_iy_{n-i})^2$ 后向误差 $B_k=\\sum\\nolimits_{t=k}^n(y_t-y_t^-)^2=\\sum\\nolimits_{t=0}^{n-k}(y_t- \\sum\\nolimits_{i=1}^k\\phi_iy_{n+i})^2$ 令 $\\phi_0=1$，以上两个函数可以简化为 $F_k=\\sum\\nolimits_{t=k}^n(f_k(i))^2$，其中 $f_k(i)=\\sum\\nolimits_{i=0}^k\\phi_iy_{n-i}$ $B_k=\\sum\\nolimits_{t=0}^{n-k}(b_k(i))^2$，其中 $b_k(i)=\\sum\\nolimits_{i=0}^k\\phi_iy_{n+i}$ Burg 的使用递归方程 $A_{k+1} = A_k + \\mu V_k$ 更新参数 $A_k = \\begin{bmatrix}1&amp;\\phi_1&amp;\\cdots&amp;\\phi_k\\end{bmatrix}$ $V_k = \\begin{bmatrix}0&amp;\\phi_k&amp;\\cdots&amp;\\phi_2&amp;\\phi_1&amp; 1\\end{bmatrix}$ 令 $\\phi_{k+1}=1$，递归更新过程可以表示为 $\\phi_i&#39; = \\phi_i+\\mu\\phi_{a+1-i}$ 为了保证 $A_{k+1}$ 优于 $A_{k}$，可以通过最小化 $F_{k+1}+B_{k+1}$ 求解 $\\mu$ 展开 $F_{k+1}+B_{k+1} = \\sum\\nolimits_{t=k+1}^n(f_{k+1}(i))^2 + \\sum\\nolimits_{t=0}^{n-k-1}(b_{k+1}(i))^2$ 得到 $f_{k+1}(i)=\\sum\\nolimits_{i=0}^{k+1}\\phi_i&#39;y_{n-i} = \\sum\\nolimits_{i=0}^{k+1}\\phi_iy_{n-i} + \\mu \\sum\\nolimits_{i=0}^{k+1}\\phi_{k+1-i}y_{n-i} = f_k(i)+ \\mu b_k(i-k-1)$ $b_{k+1}(i)=\\sum\\nolimits_{i=0}^{k+1}\\phi_i&#39;y_{n+i} = \\sum\\nolimits_{i=0}^{k+1}\\phi_iy_{n+i} + \\mu \\sum\\nolimits_{i=0}^{k+1}\\phi_{k+1-i}y_{n+i} = b_k(i)+ \\mu f_k(i+k+1)$ 求解 $\\partial(F_{k+1}+B_{k+1})/\\partial \\mu = 0$ 最终得到 $\\mu = \\frac{-2\\sum_{i=0}^{n-k-1}f_k(i+k+1)b_k(n)}{\\sum_{i=k+1}^{n}f_k(i)^2+\\sum_{i=0}^{n-k-1}b_k(i)^2}$ 最终得到 Burg 算法的流程如下： 选择阶数 p 初始化 $A_0 = [1]$ 初始化 $f_0(n) = b_0(n) = y_n$ 递归更新 $k = 0,..,p-1$ 计算 $\\mu$ 并更新 $A_{k+1}$ 更新 $f_{k+1}(i)$，其中 $i \\in [k+1,n]$ 更新 $b_{k+1}(i)$，其中 $i \\in [0,n-k-1]$ https://c.mql5.com/3/133/Tutorial_on_Burg_smethod_algorithm_recursion.pdf MA 模型 移动平均模型 Moving Average Model 是一类基于往期误差 past errors 的多元回归模型 误差项的数量被称为 MA 模型的阶数，一个 q 阶 MA 模型可以表示为 $$ ext{MA}(q)\\ :\\ y_t = c + \\varepsilon_t + \\theta_1\\varepsilon_{t-1} + \\theta_2\\varepsilon_{t-2} +...+\\theta_q\\varepsilon_{t-q} $$ $\\theta_i$ 是误差权重 $\\varepsilon_t$ 是白噪声 统计量 $\\text{MA}(q)$ 过程的均值 $E(y_t) = c$ $\\text{MA}(q)$ 过程的方差 $Var(y_t) = (1+\\theta_1^2+\\theta_2^2+\\cdots+\\theta_q^2)\\sigma_{\\varepsilon}$ $\\text{MA}(q)$ 过程协方差 $Cov(y_t,y_{t-s}) = E[(y_t-c)(y_{t-s}-c)]=\\begin{cases}(-\\beta_s+\\beta_1\\beta_{s+1}+\\cdots+\\beta_{q-s}\\beta_{q})\\sigma_{\\varepsilon}&amp;1\\le s\\le q\\ 0&amp;s&gt;q \\end{cases}$ 有限阶的 $\\text{MA}(q)$ 模型一定是平稳的，无需对参数 $\\theta$ 施加任何约束条件 可逆性 一个平稳的 $\\text{AR}(p)$ 过程可以改写为 $\\text{MA}(\\infty)$ 形式 $$ y_t = \\phi_1y_{t-1}+\\varepsilon_t =\\phi_1(\\phi_1y_{t-2}+\\varepsilon_{t-1})+\\varepsilon_t \\ \\hphantom{-}=\\phi_1^2y_{t-2}+\\phi_1\\varepsilon_{t-1}+\\varepsilon_t \\ \\hphantom{-}=\\phi_1^3y_{t-3}+\\phi_1^2\\varepsilon_{t-2} + \\phi_1\\varepsilon_{t-1}+\\varepsilon_t \\ \\hphantom{-}=\\cdots $$ 其中 $|\\phi_1| &lt; 1\\ \\to\\ \\phi_1^{\\infty}y_{t-\\infty}=0$ 一个可逆的 $\\text{MA}(q)$ 可以表示为收敛的 $\\text{AR}(\\infty)$ 形式 $$ y_t = \\varepsilon_t + \\theta_1\\varepsilon_{t-1}=\\varepsilon_t+\\theta_1(y_{t-1}-\\theta_1\\varepsilon_{t-2}) \\ \\hphantom{-}=\\varepsilon_t+\\theta_1y_{t-1}+\\theta_1^2\\varepsilon_{t-2} \\ \\hphantom{-}=\\varepsilon_t+\\theta_1y_{t-1}+\\theta_1^2y_{t-2} +\\theta_1^3\\varepsilon_{t-3} \\ \\hphantom{-}=\\cdots \\ \\hphantom{-}=\\theta_1y_{t-1}+\\theta_1^2y_{t-2} +\\theta_1^3y_{t-3} + \\cdots +\\varepsilon_t $$ $\\text{MA}(q)$ 的可逆性也能通过特征方程 $1 - \\theta_1z - \\theta_2z^2 - ... - \\theta_pz^p = 0$ 的根表示 $\\text{MA}(q)$ 模型的可逆性可以使用单位根表示 可逆模型不存在单位根 $\\forall |\\lambda_i| &lt; 1$ 不可逆模型存在单位根 $\\exists |\\lambda_i| = 1$ $\\text{MA}(1)$ 模型对应的特征方程为 $\\lambda^1 - \\theta_1\\lambda^{0} = \\lambda - \\theta_1 = 0$ 只要满足 $|\\theta_1| &lt; 1$ 模型就是可逆的 $\\text{MA}(2)$ 模型对应的特征方程为 $\\lambda^2 - \\theta_1\\lambda^{1} - \\theta_2\\lambda^{0} = \\lambda^2 - \\theta_1\\lambda - \\theta_2 = 0$ 只要同时满足 $|\\theta_1| &lt; 1$ 与 $\\theta_1\\pm\\theta_1 &lt; 1$ 模型就是可逆的 可逆 $\\text{MA}(q)$ 模型与不可逆 $\\text{MA}(q)$ 模型均能生成相同的一阶矩与二阶矩 对于一个可逆 $\\text{MA}(1)$ 模型：$y_t = c + \\varepsilon_t + \\theta_1\\varepsilon_{t-1}$，其中 $\\varepsilon_t \\sim D(0,\\sigma_\\epsilon^2)$ 期望 $E(y_t) = E(c) + E(\\varepsilon_t) + \\theta_1E( \\varepsilon_{t-1}) = c$ 方差 $Var(y_t) = Var(c) + Var(\\varepsilon) + \\theta_1^2Var(\\varepsilon_{t-1}) = \\sigma_\\varepsilon^2 + \\theta_1^2\\sigma_\\varepsilon^2$ 对应的不可逆 $\\text{MA}(1)$ 模型：$y_t^* = c + \\varepsilon_t^* + \\frac{1}{\\theta_1}\\varepsilon_{t-1}^*$，其中 $\\varepsilon_t^* \\sim D(0,\\theta_1^2\\sigma_\\epsilon^2)$ 期望 $E(y_t^*) = E(c) + E(\\varepsilon_t^*) + \\theta_1E( \\varepsilon_{t-1}^*) = c$ 方差 $Var(y_t^*) = Var(c) + Var(\\varepsilon_t^*) + \\frac{1}{\\theta_1^2}Var(\\varepsilon_{t-1}^*) = heta_1^2\\sigma_\\varepsilon^2 + \\sigma_\\varepsilon^2$ 但在计算 $\\varepsilon_t$ 时，两者存在本质的区别： 不可逆模型使用时间序列未来值进行估计 $\\varepsilon_t = f(y_{t+1}, y_{t+2} ,...)$ 可逆模型使用时间序列当前值与过去值进行估计 $\\varepsilon_t = f(y_t, y_{t-1} ,...)$ 当进行预测或使用 MLE 估计模型参数时，都涉及到计算 $\\varepsilon_t$ 此时一个可逆的 $\\text{MA}(q)$ 模型会带来极大的方便 换句话说，只有可逆的 $\\text{MA}(q)$ 模型才具备预测能力 预测误差 $\\text{MA}(1)$ 模型 向前 1 步预测：$y_{t+1} = c + \\varepsilon_{t+1} + \\theta_1\\varepsilon_{t}$ 向前 1 步预测的期望：$\\hat{y}{t+1|t} = c + E(\\epsilon{t+1}|I_t) +\\beta_1E(\\epsilon_{t}|I_t)$ 假设这里使用的是可逆模型，在给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ 的情况下 此时 $c, \\beta_1, y_i$ 均为常量，因此有可以通过以下递推公式，根据数据算出残差 $\\epsilon_t$ ： $y_1 = c + \\varepsilon_1\\ \\ \\to\\ \\ \\varepsilon_1 = y_1 - c$ $y_2 = c + \\varepsilon_2 + \\theta_1\\varepsilon_1\\ \\ \\to\\ \\ \\varepsilon_2 = y_2 - c - \\theta_1(y_1 - c)$ ... $y_t = c + \\varepsilon_t + \\theta_1\\varepsilon_{t-1}\\ \\ \\to\\ \\ \\varepsilon_t = y_t - c - \\theta_1\\varepsilon_{t-1}$ 正常情况下无条件期望 $E(\\varepsilon_{t}) = 0$，但在信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ 的情况下，$\\epsilon_i(i=1,..,t)$ 是常量，其对应的条件期望 $E(\\varepsilon_{t}|I_t) = \\varepsilon_{t}$，因此有： $$ E(\\varepsilon_{t+i}|I_t) = \\begin{cases} 0, &amp; i &gt; 0\\ \\varepsilon,&amp; i\\le 0 \\end{cases} $$ $y_{t+1}$ 的预测条件期望：$\\hat{y}{t+1|t} = c + E(\\varepsilon{t+1}|I_t) +\\theta_1E(\\varepsilon_{t}|I_t) = c + \\theta_1\\varepsilon_{t}$ 向前 1 步预测误差：$e_t(1) = y_{t+1} - \\hat{y}{t+1|t} = y{t+l} - E(y_{t+1}|I_t) = \\varepsilon_{t+1}$ 向前 1 步预测误差的方差：$Var(e_t(1)) = Var(\\varepsilon_{t+1}) = \\sigma_\\varepsilon^2$ 向前 2 步预测的期望：$\\hat{y}{t+2|t} = c + E(\\varepsilon{t+2}|I_t) +\\theta_1E(\\varepsilon_{t+1}|I_t) = c$ 向前 $l$ 步预测的期望：$\\hat{y}{t+l|t} = c + E(\\varepsilon{t+l}|I_t) +\\theta_1E(\\varepsilon_{t+l-1}|I_t) = c$ $\\text{MA}(2)$ 模型 向前 1 步预测：$y_{t+1} = c + \\varepsilon_{t+1} + \\theta_1\\varepsilon_{t}+ \\theta_2\\varepsilon_{t-1}$ 向前 1 步预测的期望：$\\hat{y}{t+1|t} = c + E(\\varepsilon{t+1}|I_t) +\\theta_1E(\\varepsilon_{t}|I_t)+\\theta_2E(\\varepsilon_{t-1}|I_t) = c + \\theta_1\\varepsilon_{t}+ \\theta_2\\varepsilon_{t-1}$ 向前 1 步预测误差：$e_t(1) = y_{t+1} - \\hat{y}{t+1|t} = y{t+1} - E(y_{t+1}|I_t) = \\varepsilon_{t+1}$ 向前 1 步预测误差的方差：$Var(e_t(1)) = Var(\\varepsilon_{t+1}) = \\sigma_\\varepsilon^2$ 向前 2 步预测：$y_{t+2} = c + \\varepsilon_{t+2} + \\theta_1\\varepsilon_{t+1}+ \\theta_2\\varepsilon_{t}$ 向前 2 步预测的期望 $\\hat{y}{t+2|t} = c + E(\\varepsilon{t+2}|I_t) +\\theta_1E(\\varepsilon_{t+1}|I_t)+\\theta_2E(\\varepsilon_{t}|I_t) = c + \\theta_2\\varepsilon_{t}$ 向前 2 步预测误差：$e_t(2) = y_{t+2} - \\hat{y}{t+2|t} = y{t+2} - E(y_{t+2}|I_t) = \\varepsilon_{t+2} +\\theta_1\\varepsilon_{t+1}$ 向前 2 步预测误差的方差：$Var(e_t(2)) = Var(\\varepsilon_{t+2}) +Var(\\theta_1\\varepsilon_{t+1}) = (1+\\theta_1^2)\\sigma_\\varepsilon^2$ 向前 3 步预测的期望：$\\hat{y}{t+3|t} = c + E(\\varepsilon{t+3}|I_t) +\\theta_1E(\\varepsilon_{t+1}|I_t)+\\theta_2E(\\varepsilon_{t+2}|I_t) = c$ 向前 $l$ 步预测的期望：$\\hat{y}{t+l|t} = c + E(\\varepsilon{t+l}|I_t) +\\theta_1E(\\varepsilon_{t+l-1}|I_t) +\\theta_1E(\\varepsilon_{t+l-2}|I_t) = c$ $\\text{MA}(q)$ 模型 向前 $l$ 步预预测：$y_{t+l} = c + \\varepsilon_{t+l} + \\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q}$ 在均方损失函数下，给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$ $$ \\hat{y}{t+l|t} = E(y{t+l}|I_t) = E(c + \\varepsilon_{t+l} + \\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q}|I_t) \\ \\hphantom{--}=E(c) + E(\\varepsilon_{t+l} + \\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q}|I_t) \\ \\hphantom{--}=c + E(\\varepsilon_{t+l}|I_t) + \\theta_1E(\\varepsilon_{t+l-1}|I_t) + \\theta_2E(\\varepsilon_{t+l-2}|I_t) +...+ \\theta_qE(\\varepsilon_{t+l-q}|I_t) $$ 条件期望： $\\hat{y}{t+l|t} = \\begin{cases} (c +\\theta_l\\varepsilon_t+\\theta{l+1}\\varepsilon_{t-1}+...+\\theta_{q}\\varepsilon_{t+l-q}), &amp; l \\le q \\ c, &amp; l &gt; q \\end{cases}$ 预测误差：$e_t(l) = y_{t+l} - \\hat{y}{t+l|t} = \\varepsilon{t+l} +\\theta_1\\varepsilon_{t+l-1}+\\theta_2\\varepsilon_{t+l-2}+...+\\theta_{q}\\varepsilon_{t+l-q}$ 预测误差方差：$Var(e_t(l)) = (1 + \\theta_1^2 + \\theta_2^2 + ... + \\theta_{l-1}^2)\\sigma_\\varepsilon^2$ 如果 $\\varepsilon_{t}$ 服从正态分布，$y_{t+l}$ 的 95% 向前 $l$ 步区间预测为 ：$\\hat{y}_{t+1|t}\\pm1.96\\sqrt{Var(e_t(l))}$ 自相关函数 $\\text{MA}(q)$ 模型自协方差： $$ \\gamma_s = Cov(y_t, y_{t-s}) = E[(y_t-c)(y_{t-s}-c)] = \\begin{cases} (1 +\\theta_1^2+\\theta_2^2+...+\\theta_{q}^2)\\sigma_\\varepsilon^2, &amp; s = 0 \\ (\\theta_s+\\theta_1\\theta_{s+1}+...+\\theta_{q-s}\\theta_{q})\\sigma_\\varepsilon^2, &amp; 1 \\le s \\le q \\ 0, &amp; s &gt; q\\end{cases} $$ $\\text{MA}(q)$ 自相关函数： $$ \\rho_s = \\begin{cases} 1, &amp; s = 0 \\ \\frac{(\\theta_s+\\theta_1\\theta_{s+1}+...+\\theta_{q-s}\\theta_{q})}{(1+\\theta_1^2+\\theta_2^2+...+\\theta_{q}^2)}, &amp; 1 \\le s \\le q \\ 0, &amp; s &gt; q \\end{cases} $$ 由于可逆 $\\text{MA}(q)$ 可以表示为 $\\text{AR}(\\infty)$ $\\text{MA}(q)$ 模型的偏自相关函数是一个无穷项的拖尾函数，这里不实际展开 参数估计 Innovations 对于零均值 $\\text{AR}(t)$ 过程 一步预测 $\\hat y_{t+1} = \\sum\\nolimits_{i=0}^t\\phi_{t,j}y_t=\\boldsymbol{\\phi_ty_t}$ 一步预测误差 $u_{t+1} = y_{t+1} - \\hat y_{t+1}$ 一步预测方差 $v_t = Var(u_{t+1}) = E[(y_{t+1}-\\hat y_{t+1})^2]=\\gamma_0-\\boldsymbol{\\phi_t\\gamma_t}$ 预测方差 $v_t$ 可以看作一个均方差函数 只要保证 $v_{t+1} &lt; v_t$，即可保证参数估计值 $\\boldsymbol{\\hat \\phi_{t+1}}$ 优于 $\\boldsymbol{\\hat \\phi_t}$ 一步预测误差可以理解为未来真实值 $y_{t+1}$ 与当前预测值 $\\hat y_{t+1}$ 的未知信息差 该信息差无法从当前已知的信息集 $I_t ={y_t,...,y_1}$ 中获取 可以认为 $u_{t+1}$ 与 $I_t$ 不存在相关性，$u_{t+1}$ 代表的未知误差被称为创新 这也意味着 $u_t = y_t-\\hat y_t$ 与 $u_s = y_s-\\hat y_s$ 之间也不存在相关性 给定 $t$ 时刻的观测值 ${y_t,...,y_1}$ 与预测值 ${\\hat y_t,...\\hat y_1}$，并令最优估计 $\\hat y_1 = 0$ 预测值可以表示为两者的线性组合 $y_{t+1}=\\sum_{i=1}^t\\theta_{t,i}(y_{t+1-i}-\\hat y_{t+1-i})$ 以上过程被称为 Innovations 创新算法，其系数可以通过迭代的方式更新 线性预测模型的系数 $\\theta_{t,t-k}=(\\gamma_{t-k}-\\sum_{j=0}^{k-1}\\theta_{k,k-j}\\theta_{t,t-j}v_{j})/v_{k}$ 预测方差可以表示为 $v_{t} = \\gamma_0-\\sum_{i=0}^{t-1}\\theta_{t,t-i}^2v_{i+1}$ 为了方便理解，这里展示前 3 次迭代的细节（其中 $v_0=\\gamma_0$） 第 1 次迭代： $\\theta_{1,1} = \\frac{\\gamma_{1-0}-\\sum_{j=0}^{0-1}\\theta_{0,0-j}\\theta_{1,1-j}v_j}{v_0}= \\frac{\\gamma_{1}-0}{v_0}$ $v_{1}=\\gamma_0-\\sum\\nolimits_{j=0}^{1-1}\\theta_{1,1-j}^2v_j=\\gamma_0-\\theta_{1,1}^2v_0$ 第 2 次迭代： $\\theta_{2,2} = \\frac{\\gamma_{2-0}-\\sum_{j=0}^{0-1}\\theta_{0,0-j}\\theta_{2,2-j}v_j}{v_0} = \\frac{\\gamma_{2}-0}{v_0}$ $\\theta_{2,1} = \\frac{\\gamma_{2-1}-\\sum_{j=0}^{1-1}\\theta_{1,1-j}\\theta_{2,2-j}v_j}{v_1} = \\frac{\\gamma_{1}-\\theta_{1,1}\\theta_{2,2}v_0}{v_1}$ $v_{2}=\\gamma_0-\\sum\\nolimits_{j=0}^{2-1}\\theta_{2,2-j}^2v_j=\\gamma_0-\\theta_{2,2}^2v_0-\\theta_{2,1}^2v_1$ 第 3 次迭代： $\\theta_{3,3} = \\frac{\\gamma_{3-0}-\\sum_{j=1}^{0-1}\\theta_{0,0-j}\\theta_{3,3-j}v_j}{v_0} = \\frac{\\gamma_{3}-0}{v_0}$ $\\theta_{3,2} = \\frac{\\gamma_{3-1}-\\sum_{j=0}^{1-1}\\theta_{1,1-j}\\theta_{3,3-j}v_j}{v_1} = \\frac{\\gamma_{2}-\\theta_{1,1}\\theta_{3,3}v_0}{v_1}$ $\\theta_{3,1} = \\frac{\\gamma_{3-2}-\\sum_{j=0}^{2-1}\\theta_{2,2-j}\\theta_{3,3-j}v_j}{v_2} = \\frac{\\gamma_{1}-(\\theta_{2,2}\\theta_{3,3}v_0 + \\theta_{2,1}\\theta_{3,2}v_1)}{v_2}$ $v_{3}=\\gamma_0-\\sum\\nolimits_{j=0}^{3-1}\\theta_{3,3-j}^2v_j=\\gamma_0-\\theta_{3,3}^2v_0-\\theta_{3,2}^2v_1-\\theta_{3,1}^2v_2$ Innovations 算法经常被用于估计 $\\text{MA}(q)$ 模型的参数，其步骤如下 选择阶数 q 计算自协方差 $\\gamma_0,...,\\gamma_q$ 递归更新 $m = 1,..,q$ 初始化 $v_0=\\gamma_0$，$\\theta_{0,0} = 1$ 迭代计算 $\\theta_{m,1},...,\\theta_{m,m},v_{m}$ Hannan-Rissanen 一种估计 $\\text{AR}(p)$ 参数的方法是利用最小二乘法求解下面的线性回归： $$ \\begin{bmatrix} y_{p} &amp; y_{p-1} &amp; \\cdots &amp; y_{1}\\ y_{p+1} &amp; y_{p} &amp; \\cdots &amp; y_{2}\\ \\vdots&amp;\\vdots&amp; \\ddots &amp; \\vdots\\ y_{n-1} &amp; y_{n-2} &amp; \\cdots &amp; y_{n-p}\\\\end{bmatrix} \\begin{bmatrix} \\phi_1\\\\phi_2\\\\vdots\\\\phi_p \\end{bmatrix}= \\begin{bmatrix} y_{p+1} \\ y_{p+2} \\ \\vdots \\ y_n \\end{bmatrix} \\to \\boldsymbol{Y\\phi = y} $$ 但该方法并不适用于 $\\text{MA}(q)$ ，因为随机变量$y_t$是可观测的，而$\\varepsilon_t$是不可观测的 Hannan-Rissanen 是一种建模 $\\text{ARMA}(p,q)$ 过程的算法，核心思想是使用残差 $e_t$ 替代误差项$\\varepsilon_t$并进行线性回归，算法流程如下 第 1 步：估计往期误差 使用 Yule–Walker 估计一个高阶 $\\text{AR}(m)$模型，其中 $m &gt; \\max(p,q)$ 得到模型参数的估计值 $\\hat\\phi_i,...,\\hat\\phi_m$ 并计算 $\\text{AR}(m)$ 残差序列 $e_t = y_t - \\hat\\phi_1y_{t-1}+...+\\hat\\phi_my_{t-m}$ 第 2 步：构建联合回归 基于 $y_t$ 与 $e_t$ 构建数据集合 $$ \\boldsymbol{A} = \\begin{bmatrix} y_{m+q} &amp; y_{m+q-1} &amp; \\cdots &amp; y_{m+q+1-p} &amp; e_{m+q} &amp; e_{m+q-1} &amp; \\cdots &amp; e_{m+1}\\ y_{m+q+1} &amp; y_{m+q} &amp; \\cdots &amp; y_{m+q+2-p} &amp; e_{m+q+1} &amp; e_{m+q} &amp; \\cdots &amp; e_{m+2}\\ \\vdots&amp;\\vdots&amp; \\ddots&amp;\\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots &amp; \\vdots\\ y_{n-1} &amp; y_{n-2} &amp; \\cdots &amp; y_{n-p} &amp; e_{n-1} &amp; e_{n-2} &amp; \\cdots &amp; e_{n-q}\\ \\end{bmatrix} $$ 扩展系数向量 $\\boldsymbol{\\beta =(\\phi,\\theta)} = \\begin{bmatrix}\\phi_1,\\phi_2,...\\phi_p,\\theta_1,\\theta_2,...,\\theta_q\\end{bmatrix}$ 求解线性回归 $\\boldsymbol{A\\beta = y}$ 得到系数估计值 $\\boldsymbol{\\hat\\beta}=\\begin{bmatrix}\\hat\\phi_1,\\hat\\phi_2,...\\hat\\phi_p,\\hat\\theta_1,\\hat\\theta_2,...,\\hat\\theta_q\\end{bmatrix}$ 基于 $\\boldsymbol{\\hat\\beta}$ 计算 $\\text{ARMA}(p,q)$ 残差序列 $$ ilde e_t = \\begin{cases} 0,&amp; t \\le \\max(p,q)\\ y_t-\\sum\\nolimits_{j=1}^p\\hat\\phi_jy_{t-j}-\\sum\\nolimits_{j=1}^p\\hat\\theta_j\\tilde e_{t-j},&amp; t &gt; \\max(p,q) \\end{cases} $$ 第 3 步：更新模型参数 基于 $\\tilde e_t$ 构建数据集合，其中 $t = \\max(p,q) + 1$ $$ \\boldsymbol{\\tilde A} = \\begin{bmatrix} v_{t-1} &amp; v_{t-2} &amp; \\cdots &amp; v_{t-p} &amp; w_{t-1} &amp; w_{w-2} &amp; \\cdots &amp; w_{t-q}\\ v_{t-2} &amp; v_{t-3} &amp; \\cdots &amp; v_{t-p-1} &amp; w_{t-2} &amp; w_{w-3} &amp; \\cdots &amp; w_{t-q-1}\\ \\vdots&amp;\\vdots&amp; \\ddots&amp;\\vdots&amp;\\vdots&amp;\\vdots&amp;\\ddots &amp; \\vdots\\ v_{n-1} &amp; v_{n-2} &amp; \\cdots &amp; v_{n-p} &amp; w_{n-1} &amp; w_{n-2} &amp; \\cdots &amp; w_{n-q}\\ \\end{bmatrix} $$ 其中 $v_t = \\sum\\nolimits^{p}{j=1}\\hat\\phi_jv{t-j} + \\tilde e_t$ $w_t = \\sum\\nolimits^{p}{j=1}-\\hat\\theta_jw{t-j} + \\tilde e_t$ 求解线性回归 $\\boldsymbol{\\tilde A\\beta^\\dagger = \\tilde e}$ 得到系数估计值 $\\boldsymbol{\\hat\\beta^\\dagger}$，并更新模型参数 $\\boldsymbol{\\tilde \\beta = \\hat\\beta^\\dagger + \\hat \\beta}$ 其中第 3 步也被称为偏差校正，仅当第 2 步中求出的参数 满足 $\\text{AR}(p)$ 平稳且 $\\text{MA}(q)$ 可逆时才会执行 单位根检验 对时间序列进行分析建模前，需要判断其中是否存在趋势 与线性趋势不同，随机趋势无法直接用肉眼进行分辨 当确定需要差分变换时，我们需要选择一个合适的阶数 在消除趋势的同时，避免过度过度差分增大模型误差 对于上述两个问题，单位根是一个良好的判断依据 $\\text{AR}(p)$ 过程的单位根接近 1，表明数据需要差分 $\\text{MA}(q)$过程的单位根接近 1，表明数据差分过度 对于不存在单位根但包含趋势的时间序列，将其称之为趋势平稳过程 此类序列可以通过回归法消除趋势，避免引入不必要的差分，导致模型误差变大 对于存在单位根的时间序列，称其为单位根过程，或差分平稳过程 对单位根过程使用 OLS 会得到有偏估计，无法使用 t 检验判断系数显著性 因此无法通过回归法消除趋势，只能进行差分 为了判断时间序列是否存在单位根，需要用到一类被称为单位根检验的统计工具 单位根检验的一个重要场景是选择差分阶数：对数据重复执行差分，直到无法显著检测到单位根 ADF 令 $\\phi_1 = 1$ 可以得到 3 种常见的非平稳 $\\text{AR}(1)$ 过程 $y_t =\\phi_1y_{t-1} + \\varepsilon_t$ (RW) $y_t = c + \\phi_1y_{t-1} + \\varepsilon_t$ (RWD) $y_t = c_1 + c_2t + \\phi_1y_{t-1} + \\varepsilon_t$ (RWD+DT) 如果使用传统的回归 t 检验判断，流程如下： 构建命题 $H_0: \\phi_1 = 1$ 使用 OLS 估计 $\\hat\\phi_1$ 计算 $\\phi_1$的 t 统计量 $t = (\\hat\\phi_1-1)/\\text{SE}(\\hat\\phi_1)$ 查表进行假设检验，判断命题是否成立 问题在于$\\phi_1 = 1$时 ，误差项 $\\varepsilon_t$不满足常方差条件 此时 OLS 会得到有偏估计，故无法应用 t 检验进行判断 为了解决这一问题，需要用到 DF 检验 / Dickey-Fuller Test $$ y_t =\\phi_1y_{t-1} + (\\phi_1y_{t-1}-\\phi_1y_{t-1}) + \\varepsilon_t $$ $$ y_t - y_{t-1} =\\phi_1y_{t-1} + (\\phi_1y_{t-1}-\\phi_1y_{t-1}) + \\varepsilon_t $$ 首先将以上$\\text{AR}(1)$ 两边同时减去 $y_{t-1}$ 得到 $\\Delta y_t =\\gamma y_{t-1} + \\varepsilon_t$ $\\Delta y_t = c + \\gamma y_{t-1} + \\varepsilon_t$ $\\Delta y_t = c_1 + c_2t + \\gamma y_{t-1} + \\varepsilon_t$ 其中 $\\Delta y_t = y_t - y_{t-1}$，$\\gamma=\\phi_1 - 1$ DF 检验以下假设 $H_0: \\gamma = 0$（存在单位根） $H_1: \\gamma &lt; 0$（不存在单位根） 接着使用 OLS 估计 $\\hat \\gamma$ 并计算 DF 统计量 $\\tau = \\frac{\\hat{\\gamma}}{\\text{SE}(\\hat{\\gamma})}$ 该统计量的概率分布没有解析解，Dickey &amp; Fuller 使用蒙特卡洛模拟计算出了 DF 统计量分位数表 需要注意的是，以上 3 种形式的 $\\text{AR}(1)$ 过程对应不同的分位数表，检验时需要指定具体形式 最后根据显著性水平查表获得临界值，并进行单侧检验 当 DF 值小于临界值时，拒绝原假设，此时序列中不存在单位根 DF 检验的是$\\text{AR}(1)$ 过程并且要求扰动项 $\\varepsilon_t$ 不存在自相关性 对于高阶的 $\\text{AR}(p)$模型进行回归，未被参数描述的趋势必然包含在扰动项中 为了能够检验高阶的模型，需要使用 ADF 检验 / augmented Dickey-Fuller Test 首先对 $\\text{AR}(p)$ 过程进行以下变换： 同时加减一项 $\\phi_{p}y_{t-p+1}$ $$ y_t = c + \\phi_1y_{t-1} + \\phi_2y_{t-2} ... + (\\phi_{p-1} + \\phi_p)y_{t-p+1} - \\phi_p\\Delta y_{t-p+1} + \\varepsilon_t $$ 同时加减一项 $(\\phi_{p-1}+\\phi_p)y_{t-p+2}$ $$ y_t = c + \\phi_1y_{t-1} + \\phi_2y_{t-2} ... + (\\phi_{p-2}+\\phi_{p-1}+\\phi_p)y_{t-p+2} - (\\phi_{p-1} + \\phi_p)\\Delta y_{t-p+2} - \\phi_p\\Delta y_{t-p+1} + \\varepsilon_t $$ 同时加减一项 $(\\phi_{p-2}+\\phi_{p-1}+\\phi_p)y_{t-p+3}$ $$ y_t = c + \\phi_1y_{t-1} + \\phi_2y_{t-2} ... - (\\phi_{p-2}+\\phi_{p-1}+\\phi_p)\\Delta y_{t-p+3} - (\\phi_{p-1} + \\phi_p)\\Delta y_{t-p+2} - \\phi_p\\Delta y_{t-p+1} + \\varepsilon_t $$ 以此类推，最终得到 $y_t = c + (\\sum^p_{i=1}\\phi_i)y_{t-1}-\\sum^p_{i=2}(\\sum^p_{j=i}\\phi_j)\\Delta y_{t-i+1} + \\varepsilon_t$ 将其转换为差分形式 $\\Delta y_t = y_t - y_{t-1} = c + \\gamma y_{t-1} + \\sum^p_{i=2}\\beta_i\\Delta y_{t-i+1} + \\varepsilon_t$ 其中 $\\gamma = - (1-\\sum^p_{i=1}\\phi_i)$，$\\beta_i = \\sum^p_{j=i}\\phi_j$ ADF 也支持以下 3 种形式 $\\text{AR}(p)$过程， $\\Delta y_t = \\gamma y_{t-1} + \\sum^p_{i=2}\\beta_i\\Delta y_{t-i+1} + \\varepsilon_t$ $\\Delta y_t = c + \\gamma y_{t-1} + \\sum^p_{i=2}\\beta_i\\Delta y_{t-i+1} + \\varepsilon_t$ $\\Delta y_t = c_1 + c_2t + \\gamma y_{t-1} + \\sum^p_{i=2}\\beta_i\\Delta y_{t-i+1} + \\varepsilon_t$ 这 3 种 $\\text{AR}(p)$ 过程对应不同的分位数表，检验时需要指定具体形式 $\\text{AR}(p)$平稳的必要条件是 $\\sum^p_{i=1}\\phi_i &lt; 1$，因此 DF 检验以下假设 $H_0: \\gamma = 0$（存在单位根） $H_1: \\gamma &lt; 0$（不存在单位根） 使用 ADF 检验时需要指定以下参数 滞后阶数 p $\\text{AR}(p)$ 的检验形式 进行 ADF 检验时，需要指定合适的滞后期 $p$，可用策略有两种： 从一个比较大的滞后期 p 开始，逐渐减小，直到显著为止（都不显著说明存在单位根） 根据信息准则进行选择 一个未知的 $\\text{ARIMA}(p,q,q)$过程能够被一个阶数为 $n=T^{1/3}$的 $\\text{ARIMA}(n,1,0)$过程表示 因此 ADF 检验能够处理数据生成过程中包含未知阶数 $\\text{MA}(q)$成分的情况 但 ADF 存在检验功效偏低的情况，在小样本且数据生成过程高度自相关的场景下，ADF 检验效果不理想 KPSS ADF 检验存在的问题：单位根是要检验的零假设，但由于 ADF 检验功效低下，经常将非单位根过程误判为单位根过程，为了解决这一问题，四位研究人员提出了 KPSS 检验 Kwiatkowski-Phillips-Schmidt-Shin Test LBI 检验 以下状态空间方程表示动态系统中的随机行为 观测方程：$y_t = x_t\\beta_t + z_t&#39;\\gamma + \\varepsilon_t$ 状态转移：$\\beta_t = \\beta_{t-1}+u_t$ 观测方程由两部分组成 $z_t&#39;\\gamma$ 是时不变部分，系数 $\\gamma$ 与时间无关 $x_t\\beta_t$ 是时变部分，其系数 $\\beta_t$ 与时间有关 方程中包含两个误差 $\\varepsilon_t \\sim N(0,\\sigma_\\varepsilon^2)$ 是测量误差，其中 $\\sigma^2_\\varepsilon &gt; 0$ 测量误差来源于系统内部，概率分布通常是稳定的 $u_t\\sim N(0,\\sigma_u^2) $ 是动态噪声，其中 $\\sigma^2_u \\ge 0$ 动态噪声来源于系统外部，其概率分布可能随时间动态发生改变 该方程可以看作是一类 变系数回归模型 varying coefficient regression model $y_t \\in R$ 是因变量 $x_t,\\ z_t \\in R^n$ 是自变量 $\\beta_t,\\ \\gamma \\in R^n$ 是回归系数 $\\sigma_\\varepsilon^2$ 是误差项方差 $\\sigma_u^2$ 是自回归项方差 当 $\\sigma^2_u &gt; 0$时，该模型的时变回归系数 $\\beta_t$ 服从随机游走过程 当 $y_t$服从正态分布时，可以使用 LBI（Locally Best Invariant Test）检验 $\\beta_t$ 的恒常性（是否随时间发生变异） $H_0: \\rho =\\sigma_\\varepsilon^2/\\sigma_u^2 = 0$（模型误差与回归项误差无关） $H_1: \\rho &gt; 0$（模型误差与回归项误差有关） KPSS 检验是 $x_t = 1,\\ \\beta_t = r_t,\\ z_t = t,\\ \\gamma = \\xi$ 时的特例 $$ y_t = \\xi t + r_t + \\varepsilon_t $$ 确定性趋势 $\\xi t$ 随机游走 $r_t = r_{t-1}+u_t$，其初始值为 $r_0$ 且 $u_t\\sim N(0,\\sigma_u^2)$ 平稳误差 $\\varepsilon_t \\sim N(0,\\sigma_\\varepsilon^2)$ KPSS 检验以下假设 $H_0: \\sigma_u^2 = 0$（时间序列是平稳的） $H_1: \\sigma_u^2 \\ne 0$（时间序列是非平稳的） KPSS 支持两种检验形式 当$\\xi \\ne 0$时，$H_0$假定时间序列趋势平稳 trend stationary 当$\\xi = 0$时，$H_0$假定时间序列水平平稳 level stationary 当 $\\varepsilon_t \\sim N(0,\\sigma_\\varepsilon^2)$ 时，KPSS 是标准的 LM 检验（LBI 检验是其特例） 计算残差序列 $e_i$ 当 $\\xi \\ne 0$时，通过 OLS 拟合 $y_t = r_0 + \\xi t$ 模型获得 当 $\\xi = 0$时，通过计算 $e_i = y_i - \\bar y$ 获得 计算 LM 统计量 计算累积残差和 $S_t = \\sum\\nolimits_{i=1}^te_i$ 计算残差方差估计值 $\\hat \\sigma_\\varepsilon^2 = Var(e_i)$ 计算 LM 统计量 $LM = \\sum\\nolimits_{t=1}^TS_t^2/\\hat \\sigma_\\varepsilon^2$ 进行卡方检验 指定显著水平，并根据自由度查表获得临界值 比较 LM 统计量与临界值，得出结论 但通常情况下，非平稳序列与时间高度相关，因此无法满足假设 $\\varepsilon_t \\sim N(0,\\sigma_\\varepsilon^2)$ 为此 KPSS 检验需要使用了一个弱假设的渐进分布 定义长期方差为 $\\sigma^2 = \\lim_{T\\to\\infty}E(S_T^2)/T$ 计算其近似值 $s^2(l)=T^{-1}\\sum\\nolimits_{t=1}^Te_t^2 + 2T^{-1}\\sum\\nolimits_{t=1}^lw(s,l)\\sum\\nolimits_{t=s+1}^Te_te_{t-s}$ 为了保证 $s^2(l)$ 非负，选择 $w(s,l)=1-s/(l+1)$ 作为权重函数 为了保证 $s^2(l)$ 一致，选择 $l =\\text{o}(T^{1/2})$ 作为滞后截断参数 使用 $s^2(l)$ 作为 LM 检验量的分母部分 $\\hat \\sigma_\\varepsilon^2$ 使用 $T^{-2}$对 LM 检验量的分子归一化 $\\eta = T^{-2}\\sum S_t^2$ 当$\\xi = 0$时，使用 $\\eta_\\mu$ 表示水平平稳的统计量 当$\\xi \\ne 0$时，使用 $\\eta_\\tau$ 表示趋势平稳的统计量 最终得到统计量估计值 $\\hat\\eta_\\mu = \\eta_\\mu/s^2(l) = T^{-2}\\sum S_t^2/s^2(l)$ $\\hat\\eta_\\tau = \\eta_\\tau/s^2(l) = T^{-2}\\sum S_t^2/s^2(l)$ 统计量 $\\eta_\\mu$ 与 $\\eta_\\tau$ 分别服从渐进分布 $\\eta_\\mu \\to \\sigma^2\\smallint\\nolimits_0^1V(r)dr$ 与 $\\eta_\\tau \\to \\sigma^2\\smallint\\nolimits_0^1V_2^2(r)dr$ 其中 $V(r)=W(r)-rW(1)$是布朗桥，其在区间 $[0,1]$内服从正态分布 $V(r)\\sim N(0,t(1-t))$ 通过大量模拟计算 $\\smallint\\nolimits_0^1V(r)dr$ 与 $\\smallint\\nolimits_0^1V_2^2(r)dr$，最终得到$\\hat\\eta_\\mu$与$\\hat\\eta_\\tau$的上尾临界值 统计量与分布描述 0.10 0.05 0.025 0.01 $\\eta_\\mu$: $\\int_0^1 V(r)^2 dr$ 0.347 0.463 0.574 0.739 $\\eta_\\tau$: $\\int_0^1 V_2(r)^2 dr$ 0.119 0.146 0.176 0.216 查表即可获得 KPSS 检验的 p-value，并决定是否拒绝零假设 CH Canova-Hansen 检验是一种用于检验时间序列季节性稳定性的统计方法 其本质上是一类用于检验季节性单位根的 LM 检验 CH 检验形式 $y_t = \\mu + x_t&#39; \\beta + S_t + \\varepsilon_t$ $x_t,\\ \\beta \\in R^k$ 是解释性自变量与回归系数 误差项 $\\varepsilon_t \\sim N(0,\\sigma^2)$可能存在异方差现象 周期长度 $s$ 为奇数的确定性季节成分 $S_t$ 季节成分存在两种建模方法 使用 $s$ 个虚拟变量表示 $S_t = d_t&#39;\\alpha$ $d_t\\in R^s$ 是季节性虚拟变量 $\\alpha\\in R^s$ 是权重系数 使用 $2q+1$ 个频率分量表示 $S_t = f_t&#39;\\gamma =\\sum\\nolimits_{j=1}^{q=s/2}f_{jt}&#39;\\gamma_j$ $\\gamma\\in R^q$ 是权重系数 三角函数项 $f_{jt} =\\begin{cases} \\cos(\\frac{2\\pi j}st)+\\sin(\\frac{2\\pi j}st) &amp; j&lt;q\\ \\cos(\\pi t) &amp; j=q\\ \\end{cases}$ 此外，为了保证检验功效，需要满足两个条件 被检验序列 $y_t$中不应该包含非季节单位根，否则会影响检验效果 可以在检验前通过差分或对数转换消除 $y_t$中的含单位根 研究表明这些转换不会影响 $S_t$中的单位根 当自变量 $x_t$被设置为滞后值 $y_{t-1},y_{t-2},...$ 时 需要保证自回归系数 $\\beta$中未捕获任何季节性模型 否则 $S_t$中的单位根可能被回归项 $x_t&#39; \\beta$所吸收 将滞后阶数限制为 1 阶可以避免这一问题 以上两种形式的回归可以重写为 虚拟变量季节模型：$y_t = x_t&#39; \\beta + d_t&#39;\\alpha + e_t\\ \\ (\\beta, \\alpha)$ 三角函数季节模型：$y_t = \\mu + x_t&#39; \\beta + f_t&#39;\\gamma + e_t\\ \\ (\\mu, \\beta, \\gamma)$ 对两者进行辅助回归后，得到的残差序列 $\\hat{e}_i$ 应该是一致的，区别在于 虚拟变量季节成分 $d_t&#39;\\alpha$起到了截距项的作用，因此不需要引入独立截距项 当样本数 $n$是 s 的整数幂时 $\\small\\sum\\nolimits_{i=1}^nf_i=0$，因此三角函数季节模型需要独立引入截距项 两种形式分别对应两种检验 检验截距系数$\\alpha$ 的平稳性，可以判断季节性成分是否平稳 检验频率系数 $\\gamma$的平稳性，可以判断季节成分是否存在单位根 稳健协方差估计 季节性成分 $d_t$ 与误差项的 $e_t$ 协方差矩阵 $\\Omega = \\lim_{n\\to\\infty}\\frac{1}nE(D_nD_n&#39;)\\ \\ (D_n = \\begin{bmatrix}d_1e_1&amp;\\cdots&amp;d_ne_n\\end{bmatrix})$ 频率成分$f_t$ 与误差项的 $e_t$ 协方差矩阵 $\\Omega^f = \\lim_{n\\to\\infty}\\frac{1}nE(F_nF_n&#39;)\\ \\ (F_n = \\begin{bmatrix}f_1e_1&amp;\\cdots&amp;f_ne_n\\end{bmatrix})$ 由于季节性的存在，残差序列 $\\hat{e}_t$ 通常是异方差的且存在相关性，通常使用以下稳健估计替代 $\\hat\\Omega = \\sum_{k=-m}^mw(\\frac km)\\frac1n\\sum_id_{i+k}\\hat e_{i+k}d_i&#39;\\hat e_i$ $\\hat\\Omega^f = \\sum_{k=-m}^mw(\\frac km)\\frac1n\\sum_if_{i+k}\\hat e_{i+k}f_i&#39;\\hat e_i$ 其中 $w(\\cdot)$是一个保证矩阵半正定的窗函数 季节性单位根检验 为了保证建模的合理性，需要假设三角函数季节模型中季节成分系数会随时间发生变化 假设频率分量系数变化是一个随机游走过程 $\\gamma_t = \\gamma_{t-1} + u_t$ 当 $u_t$的协方差矩阵满秩时，$y_t$的所有频率成分都存在单位根 可以建立一个满秩的选择矩阵 $A\\in R^{(s-1)\\times a}$ 选择其中的 a 个频率进行检测 令 $A=I_{s-1}$ 可以检验整个 $\\gamma$的稳定性， 令 $A=(\\tilde0,1)$ 可以检验最后一个频率 $\\pi$对应的系数 $\\gamma_q$ 是否存在单位根 令 $A=(\\tilde0,I_2,\\tilde0)$ 可以检验特定频率 $(j/q)\\pi$ 对应的系数 $\\gamma_j$是否存在单位根 随机游走过程可以重写为随机过程 $A&#39;\\gamma_t = A&#39;\\gamma_{t-1} + u_t$ 此时 $u_t$的协方差矩阵 $E(u_tu_t&#39;)=\\tau^2 G$，其中 $\\tau^2 \\ge 0$是实数，$G = (A&#39;\\Omega^fA)^{-1}\\in R^{a\\times a}$是满秩矩阵 当 $\\tau^2 = 0$时，系数向量恒定不变 $\\gamma_t = \\gamma_0$ 当 $\\tau^2 &gt; 0$时，选择矩阵对应的频率分量存在单位根 CH 检验以下假设 $H_0: \\tau^2 = 0$（季节成分不存在单位根） $H_1: \\tau^2 &gt; 0$（季节成分存在单位根） 对应的 LM 统计量为 $$ L = \\frac{1}{n^2}\\big((A&#39;\\hat\\Omega^fA)^{-1}A&#39;\\small\\sum\\nolimits_{i=1}^n\\hat F_i\\hat F_iA\\big) $$ 在零假设的前提下，该统计量渐进服从冯·密斯分布 von mises distribution $L \\to \\text{VM}(a)\\ \\ (a=\\text{rank}(A))$ 非恒定季节模式检验 为了保证建模的合理性，需要假设虚拟变量季节模型中季节成分系数会随时间发生变化 引入一个满秩的选择矩阵 $A\\in R^{s\\times a}$ 用于构造关于 $\\alpha_t$的随机过程 $A&#39;\\alpha_t = A&#39;\\alpha_{t-1} + u_t$ 此时 $u_t$的协方差矩阵 $E(u_tu_t&#39;)=\\tau^2 G$，其中 $\\tau^2 \\ge 0$是实数，$G = (A&#39;\\Omega^fA)^{-1}\\in R^{a\\times a}$是满秩矩阵 当 $\\tau^2 = 0$时，系数向量恒定不变 $\\alpha_t = \\alpha_0$ 当 $\\tau^2 \\ne 0$时，系数向量存在不稳定性，下面是两个特例 高斯随机游走过程：$\\alpha_t$随时间缓慢发生变化 罕见创新过程：$\\alpha_t$在某个未知时刻发生突变 CH 检验以下假设 $H_0: \\tau^2 = 0$（季节模式是恒定的） $H_1: \\tau^2 \\ne 0$（季节模式是非是恒定的） 对应的 LM 统计量为 $L = \\frac{1}{n^2}\\big((A&#39;\\hat\\Omega^fA)^{-1}A&#39;\\small\\sum\\nolimits_{i=1}^n\\hat D_i\\hat D_iA\\big)$ 在零假设的前提下，该统计量渐进服从冯·密斯分布 von mises distribution $L \\to \\text{VM}(a)\\ \\ (a=\\text{rank}(A))$ 冯·密斯分布 自由度为 $p$的 $\\text{VM}(p)$ 分布临界值如表所示 p 1% 2.5% 5% 7.5% 10% 20% 1 .748 .593 .470 .398 .353 .243 2 1.070 .898 .749 .670 .610 .469 3 1.350 1.160 1.010 .913 .846 .679 4 1.600 1.390 1.240 1.140 1.070 .883 5 1.880 1.630 1.470 1.360 1.280 1.080 6 2.120 1.890 1.680 1.580 1.490 1.280 7 2.350 2.100 1.900 1.780 1.690 1.460 8 2.590 2.330 2.110 1.990 1.890 1.660 9 2.820 2.550 2.320 2.190 2.100 1.850 10 3.050 2.760 2.540 2.400 2.290 2.030 11 3.270 2.990 2.750 2.600 2.490 2.220 12 3.510 3.180 2.960 2.810 2.690 2.410 一项研究指出可以通过 response surface regressions 获得任意样本尺寸与季节周期对应的 p-value https://github.com/GeoBosh/uroot/blob/master/R/ch-rs-pvalue. ARMA 模型 自回归移动平均模型 Autoregressive Moving Average 同时基于滞后值与过往误差进行预测 $$ \\text{ARMA}(p,q)\\ :\\ y_t = c + \\phi_1y_{t-1} + \\cdots + \\phi_py_{t-p} + \\theta_1\\varepsilon_{t-1} + \\cdots-+\\theta_q\\varepsilon_{t-q}+ \\varepsilon_t $$ $\\phi_i$是自回归系数 $\\theta_i$是误差权重 $\\varepsilon_t$ 是白噪声 $\\text{ARMA}(p,q)$ 的系数$\\phi_i$与$\\theta_i$必须保证 $\\text{AR}(p)$平稳以及 $\\text{MA}(q)$ 可逆 $\\text{ARMA}(p,q)$ 的可解释性较差，但可以有效捕获时间序列中的模式 在拟合 $\\text{ARMA}(p,q)$ 模型前，通常会对时间序列进行差分，消除其中的趋势 如果将该操作整合进 $\\text{ARMA}(p,q)$模型，可以得到差分自回归移动平均模型 $\\text{ARIMA}(p,d,q)$ Autoregressive Integrated Moving Average 模型，其中 d 用于表示差分阶数 $\\text{ARIMA}(0,0,0), c=0 \\ \\to \\ \\text{WN}$ $\\text{ARIMA}(0,1,0), c=0 \\ \\to \\ \\text{RW}$ $\\text{ARIMA}(0,1,0), c\\ne0 \\ \\to \\ \\text{RWD}$ $\\text{ARIMA}(p,0,q) \\ \\to \\ \\text{ARMA}(p,q)$ 常数项 c 与差分阶数 d 对长期预测值的影响 $c = 0,\\ d =0$ 预测结果收敛于 0 $c = 0,\\ d =1$ 预测结果收敛于非零常量 $c = 0,\\ d =2$ 预测结果呈现线性趋势 $c \\ne 0,\\ d =0$ 预测结果收敛于均值 $c \\ne 0,\\ d =1$ 预测结果呈现线性趋势 $c \\ne 0,\\ d =2$ 预测结果呈现二次趋势 差分阶数 d 越大，预测区间增长的越快 d = 0 时，预测标准差等于历史数据的标准差 d = 1 时，预测标准差会随着预测步长线性增长 d = 2 时，预测标准差会随着预测步长快速增长 使用后移运算表示 $\\text{ARMA}(p,q)$ $y_t = c + \\phi_1By_t + \\cdots + \\phi_pB^py_t + \\theta_1B\\varepsilon_t + \\cdots-+\\theta_qB^p\\varepsilon_t+ \\varepsilon_t$ 简化后得到 $(1-\\phi_1B - \\cdots - \\phi_pB^p)y_t = c +(1+\\theta_1B+\\cdots+\\theta_qB^q)\\varepsilon_t$ 进一步简化得到 $\\phi(B)y_t = c +\\theta(B)\\varepsilon_t$ $\\text{ARIMA}(p,d,q)$在 $\\text{ARMA}(p,q)$ 基础上增加了差分项 $y_t&#39;=(1-B)^dy_t$ $$ (1-\\phi_1B - \\cdots - \\phi_pB^p)(1-B)^dy_t = c +(1+\\theta_1B+\\cdots+\\theta_qB^q)\\varepsilon_t $$ 进一步简化得到 $\\phi(B)(1-B)^dy_t = c +\\theta(B)\\varepsilon_t$，通常情况下$d\\le 2$ 在对季节性数据拟合 $\\text{ARIMA}(p,d,q)$模型时，需要先去除其中的季节性成分 此前介绍过的一种简单方式是对数据进行季节差分 $(1-B^m)y_t$，其中 $m$ 为季节周期长度 更为通用的方法是使用 $\\text{SARIMA}(p,d,q)(P,D,Q)_m$ Seasonal ARIMA 建模 其在 $\\text{ARIMA}$基础上增加了季节差分$(1-B^m)^D$与参数 $\\Phi(B^m)$与$\\Theta(B^m)$ 使用后移运算表示为 $\\phi(B)\\Phi(B^m)(1-B)^d(1-B^m)^Dy_t = c +\\theta(B)\\Theta(B^m)\\varepsilon_t$ 通常情况下$D\\le1,\\ P,Q\\le 3$ 传统的季节性分解模型假设季节性模式会按照完全相同的方式逐周期重复 而 $\\text{SARIMA}$模型则允许季节性模式存在随机性，并通过参数 $\\Phi(B^m)$与$\\Theta(B^m)$进行刻画 需要注意的是，$\\text{SARIMA}$ 无法处理多季节性与季节周期长度较大的情况 $\\text{SARIMA}$ 可以进一步简化表示为 $\\phi^*(B)\\Delta^d\\Delta^D_m y_t = c +\\theta^*(B)\\varepsilon_t$ $\\Delta^d\\Delta^D_m y_t$ 表示 $(1-B)^d(1-B^m)^D$差分后的数据 $\\phi^*(B)$是一个由 $\\phi(B)\\Phi(B^m)$合并而成的 $p+mP$ 阶多项式 $\\theta^*(B)$是一个由 $\\theta(B)\\Theta(B^m)$合并而成的 $q+mQ$ 阶多项式 此时 $\\Delta^d\\Delta^D_m y_t$ 构成一个 $\\text{ARMA}(p+mP,q+mQ)$ 过程，其中 $\\phi^*(B)$ 与 $\\theta^*(B)$部分系数为 0 这意味着可以 $\\text{SARIMA}$与$\\text{ARMA}$ 可以共用一套似然函数 相关性质 如果将 $\\text{ARMA}$ 模型看作是一个映射函数 $\\mathcal{F}(\\cdot)$，输入 $y_{t-1},...,y_{1}$ 看作是一个关于时间的函数 $x(t)$ 两者构成一个线性系统，满足 $\\mathcal{F}[\\lambda_1x_1(t)+\\lambda_2x_2(t)]=\\lambda_1\\mathcal{F}[x_1(t)]+\\lambda_2\\mathcal{F}[x_2(t)]$ 如果 $\\text{ARMA}$ 模型的参数固定时，其映射关系不随时间发生变化 可以为其是时不变系统，满足 $y(t) = \\mathcal{F}[x(t)] \\to y(t-\\tau) = \\mathcal{F}[x(t-\\tau)]$ 如果 $\\text{ARMA}$ 模型是时不变的，且其输入与输出服从相同的概率分布 则认为其是稳定系统，满足 $y(t) = \\mathcal{F}[x(t)] \\to y(t),x(t)\\sim(\\mu,\\sigma^2)$ 如果 $\\text{ARMA}$ 模型是平稳的，且其输出仅取决于已知输入 $x(t)$，与未来的输入 $x(t+\\tau)$ 无关 则认为其是因果系统，满足 $y(t) = \\mathcal{F}[x(t)] = \\mathcal{F}[x(t+\\tau)]$ $\\text{ARMA}(p,q)$系数项$\\phi(B)$与 $\\theta(B)$ 可以分别转换为下面两个多项式 $\\phi(z) = 1 - \\phi_1z - ... - \\phi_pz^p$ $\\theta(z) = 1 - \\theta_1z - ... - \\theta_pz^q$ 通过对多项式施加约束，可以保证 $\\text{ARMA}(p,q)$ 的性质 平稳性条件：$\\phi(z) = 1 - \\phi_1z - ... - \\phi_pz^p \\ne 0\\ \\ \\ (\\forall |z|=1)$ 因果性条件：$\\phi(z) = 1 - \\phi_1z - ... - \\phi_pz^p \\ne 0\\ \\ \\ (\\forall |z|\\le1)$ 可逆性条件：$\\theta(z) = 1 - \\theta_1z - ... - \\theta_pz^q\\ne 0\\ \\ \\ (\\forall |z|\\le1)$ 对于可逆 $\\text{ARMA}(p,q)$ 模型，可以将其统一表示为 $y_t = \\sum_{i=0}^\\infty\\psi_i\\varepsilon_{t-i}$，其中 $$ \\psi_i= \\begin{cases} 1,&amp;i=0\\ \\theta_i+\\sum_{j=1}^{\\min(p,i)}\\phi_j\\psi_{i-j},&amp;i\\ge1 \\end{cases} $$ 由于 $\\varepsilon_t\\overset{\\mathrm{iid}}{\\sim} N(0,\\sigma_\\varepsilon^2)$，其无条件方差 $Var(y_t) = \\sum\\nolimits_{j=0}^\\infty Var(\\psi_i\\varepsilon_{t-j}) =\\sigma_\\varepsilon^2\\sum\\nolimits_{j=0}^\\infty\\psi_j^2$ 对于因果 $\\text{ARMA}(p,q)$ 模型，令 $m =\\max(p,q)$ 可以将 $y_t$ 其转换为 $w_t = \\begin{cases} \\sigma^{-1}y_t,&amp;t=1,...,m\\ \\sigma^{-1}\\phi(B)y_t,&amp;t &gt;m\\ \\end{cases}$ 并使用 $w_t$表示自协方差 $$ \\gamma_{w(i,j)}=\\begin{cases} \\sigma^{-2}\\gamma_{y(i-j)} &amp; 1\\le i,j\\le m\\ \\sigma^{-2}[\\gamma_{y(i-j)}-\\sum_{r=1}^p\\phi_r\\gamma_{y(r-|i-j|)}] &amp; \\min(i,j)\\le m &lt; \\max(i,j) \\le 2m\\ \\sum_{r=1}^q\\theta_r\\theta_{r+|i-j|} &amp; \\min(i,j)&gt; m\\ 0 &amp; \\text{otherwise} \\end{cases} $$ 给定 $\\gamma_w$的计算方式后，接下来可以借助 Innovations 算法求解 $\\theta_{t,i}$ 与$r_t$ 创新方程 $\\hat w_{t+1} =\\begin{cases} \\sum\\nolimits_{i=0}^t\\theta_{t,i}(w_{t+1-i}-\\hat w_{t+1-i})&amp;1\\le t&lt;m\\ \\sum\\nolimits_{i=0}^q\\theta_{t,i}(w_{t+1-i}-\\hat w_{t+1-i})&amp; t\\ge m \\end{cases}$ 预测方差 $r_t = E[(w_{t+1}-\\hat w_{t+1})^2]$ 此外，误差方差存在关系 $$ v_t = E[(y_{t+1}-\\hat y_{t+1})^2]=\\sigma_\\varepsilon^2E[(w_{t+1}-\\hat w_{t+1})^2]=\\sigma_\\varepsilon^2r_t $$ 预测误差 $\\text{ARMA}(p,q)$ 模型向前预测 $l$步的公式 $$ y_{t+l} = c + \\phi_1y_{t+l-1} + \\phi_2y_{t+l-2} ... + \\phi_py_{t-p+l} + \\varepsilon_{t+l} + \\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q} $$ $\\text{ARMA}(p,q)$ 模型向前预测 $l$步的期望 $$ \\hat{y}{t+l|t} = E(y{t+l}|I_t) = E(c) + E(\\varepsilon_{t+l}|I_t) \\\\ \\ \\hphantom{--} +E(\\phi_1y_{t+l-1} + \\phi_2y_{t+l-2} ... + \\phi_py_{t-p+l}|I_t) \\\\ \\ \\hphantom{--} +E(\\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q}|I_t) $$ 在给定信息集 $I_t = {y_{t},y_{t-1},...,y_{t-p}}$的情况下 $E(\\varepsilon_{t+l}) = 0$ $E(\\phi_1y_{t+l-1} + \\phi_2y_{t+l-2} ... + \\phi_py_{t-p+l}) = \\sum\\nolimits_{i=1}^{p}\\phi_i\\hat{y}_{t+l-i|t}$ $E(\\theta_1\\varepsilon_{t+l-1} + \\theta_2\\varepsilon_{t+l-2} +...+ \\theta_q\\varepsilon_{t+l-q})= \\begin{cases} \\sum\\nolimits_{i=1}^p\\theta_i\\varepsilon_{t+l-i}, &amp; l \\le q \\ 0, &amp; l &gt; q \\end{cases}$ $\\text{ARMA}(p,q)$ 模型预测误差 $$ e_t(1) = y_{t+1} - \\hat{y}{t+1|t} = \\varepsilon{t+1} $$ $$ e_t(2) = y_{t+2} - \\hat{y}{t+2|t} = \\phi_1(y{t+1} - \\hat{y}{t+1|t}) +\\theta_1\\varepsilon{t+1} + \\varepsilon_{t+2} \\\\ \\ \\hphantom{--} = \\phi_1\\varepsilon_{t+1}+\\theta_1\\varepsilon_{t+1} + \\varepsilon_{t+2} \\\\ \\ \\hphantom{--} = (\\phi_1+\\theta_1)\\varepsilon_{t+1} + \\varepsilon_{t+2} $$ $$ e_t(3) = y_{t+3} - \\hat{y}{t+3|t} = \\phi_1(y{t+2} - \\hat{y}{t+2|t}) +\\phi_2(y{t+1} - \\hat{y}{t+1|t})+\\theta_1\\varepsilon{t+2}+\\theta_2\\varepsilon_{t+1} + \\varepsilon_{t+3} \\\\ \\ \\hphantom{--} = \\phi_1(\\phi_1\\varepsilon_{t+1}+\\theta_1\\varepsilon_{t+1} + \\varepsilon_{t+2})+ \\phi_2\\varepsilon_{t+1}+\\theta_1\\varepsilon_{t+2}+\\theta_2\\varepsilon_{t+1} + \\varepsilon_{t+3} \\\\ \\ \\hphantom{--} = (\\phi_1(\\phi_1+\\theta_1)+\\phi_2+\\theta_2)\\varepsilon_{t+1} + (\\phi_1+\\theta_1)\\varepsilon_{t+2} + \\varepsilon_{t+3} $$ $$ e_t(l) = y_{t+l} - \\hat{y}{t+l|t} = \\sum\\nolimits{i=1}^{l}\\psi_i\\varepsilon_{t+l-i} + \\varepsilon_{t+l} , \\ \\ \\ \\psi_j = \\begin{cases} -1, &amp; j=0\\ -(\\sum\\nolimits_i^j \\phi_i\\psi_{j-i})+\\theta_j, &amp; 1\\le j\\le q \\ -\\sum\\nolimits_i^j \\phi_i\\psi_{j-i},&amp; j&gt;q \\end{cases} $$ $\\text{ARMA}(p,q)$ 模型预测误差的方差 $Var(e_t(l)) = (\\psi_1^2+\\psi_2^2+\\cdots+\\psi_{l-1}^2+1)\\sigma_\\varepsilon^2$ 参数估计 条件最小二乘法 CSS https://github.com/SurajGupta/r-source/blob/a28e609e72ed7c47f6ddfbb86c85279a0750f0b7/src/library/stats/src/arima.c#L753 https://github.com/SurajGupta/r-source/blob/master/src/library/stats/R/arima.R#L248 似然函数 给定$\\text{ARMA}(p,q)$ 模型 一步预测 $$ \\hat y_{t+1}=\\begin{cases} \\sum\\nolimits_{j=1}^n\\theta_{j}(y_{t+1-j}-\\hat y_{t+1-j}), &amp; 1\\le t &lt; \\max(p,q) \\ \\sum\\nolimits_{j=1}^p\\phi_jy_{t+1-j}+ \\sum\\nolimits_{j=1}^q\\theta_{j}(y_{t+1-j}-\\hat y_{t+1-j}), &amp; t &gt; \\max(p,q) \\end{cases} $$ 一步预测方差 $$ v_t = E[(y_{t+1}-\\hat y_{t+1})^2] $$ 条件随机变量 $y_t$ 服从正态分布 $$ y_t|y_{t-1},...,y_1 \\sim N(\\hat y_t,v_t) $$ 概率密度函数 $$ P(y_t|y_{t-1},...,y_1)=\\frac{1}{\\sqrt{2\\pi v_t}}\\exp\\bigg(-\\frac{(y_t-\\hat y_t)^2}{2v_t}\\bigg) $$ 令 $v_t = \\sigma^2 r_t$，$S(\\hat\\phi,\\hat\\theta)=\\sum_{t=1}^n\\frac{(y_t-\\hat y_t)^2}{r_{t-1}}$ 可以得到以下似然函数 $$ L(\\phi,\\theta,\\sigma^2) = \\prod_{t=1}^nP(y_t|y_{t-1},...,y_1) = (2\\pi\\sigma^2)^{-n/2}(\\prod\\nolimits_{t=1}^nr_{t-1})^{-1/2}\\exp(-\\frac{S(\\phi,\\theta)}{2\\sigma^2}) $$ 然后求对数似然 $$ -2\\ell(\\phi,\\theta,\\sigma^2) = n\\log(2\\pi\\sigma^2) + \\sum_{t=1}^n\\log r_{t-1} + \\frac{ S(\\phi,\\theta)}{\\sigma^2} $$ 对 $\\sigma^2$求偏导得到估计值 $\\hat\\sigma^2=\\frac{S(\\hat\\phi,\\hat\\theta)}{n}$，将其代入对数似然函数可消去 $\\sigma^2$ 忽略常数项，最终得到 $$ \\ell(\\phi,\\theta) = \\log(\\frac{S(\\phi,\\theta)}{n}) +\\frac{1}{n}\\sum_{t=1}^n\\log r_{t-1} $$ 由于 $\\ell(\\phi,\\theta)$ 不存在封闭的解析解，需要用到数值优化实现最大似然估计 基于似然函数可以计算一系列的信息准则： $AIC = -2\\log(L)+2(k+1)$ $BIC = -2\\log(L)+\\log(n)(k+1)$ $AIC_C = AIC + \\frac{2(k+1)(k+2)}{n-k-2}$ 其中 $$ k = \\begin{cases} p+q+P+Q,&amp;c=0\\ p+q+P+Q+1,&amp;c\\ne0 \\end{cases} $$ 建模流程 建模流程主要分为两类 Box-Jenkins 方法 Step-Wise 方法 Box-Jenkins 方法 该方法是手工建模，适用于 $\\text{AR}(p)$ 与 $\\text{MA}(q)$ 模型 观察数据，并转换数据 应用对数转换或 BoxCox 转换稳定方差 对数据差分消除确定性趋势成分 绘制 ACF与 PACF 图像 $\\text{MA}(q)$ 过程在 ACF 的第 q 阶滞后处截断 $\\text{AR}(p)$ 过程在 PACF 的第 p 阶滞后处截断 使用此前介绍的参数估计算法拟合模型 基于 $AIC$进行模型选择 Step-Wise 方法 该方法是自动建模，适用于所有模型 其主要流程如下： 通过 CH 检验确定季节差分阶数 D 通过 KPSS 检验确定差分阶数 d 通过差分获得平稳序列$\\nabla y_t = (1-B)^d(1-B^m)^Dy_t$ 使用 $\\nabla y_t$ 拟合 $\\text{ARMA}(p+mP,q+mQ)$ 模型 尝试不同的 $p,q,P,Q$ 并根据信息准则选择最优组合 使用 ljung_box 假设检验判断模型残差是否存在自相关性 https://otexts.com/fpp3/arima-r.html 残差建模 在标准线性回归中，通常假设误差项 $\\varepsilon$为白噪声过程 但在检验残差序列 $e = y - \\hat y$时，往往会发现其存在自相关性 此时应假设误差项 $\\varepsilon$为零均值弱平稳过程 如果使用 $\\text{ARMA}(p,q)$ 对残差建模，以上模型可表示为 $\\boldsymbol {y = X\\beta + \\eta} $ $y_t = \\beta_1x_{1,t} + ... + \\beta_kx_{k,t} + \\eta_t$ $\\phi(B)\\eta_t = \\theta(B)\\varepsilon_t$ $\\varepsilon_t \\sim \\text{WN}(0,\\sigma^2)$ 如果 $\\eta_t$ 是非平稳的，则需要对 $y_t,x_t,\\eta_t$ 进行差分，实现$\\text{ARIMA}(p,d,q)$ 建模的效果 $y_t&#39; = \\beta_1x&#39;{1,t} + ... + \\beta_kx&#39;{k,t} + \\eta&#39;_t$ $\\phi(B)\\eta&#39;_t = \\theta(B)\\varepsilon_t$ 分别使用 OLS 与 GLS 估计的参数 $\\boldsymbol{\\hat \\beta}$ 可以得到： OLS $\\min_{\\beta}\\boldsymbol{(y -X\\beta)&#39;(y -X\\beta)}$ $\\boldsymbol{\\hat \\beta}_{\\text{OLS}} = \\boldsymbol{(X&#39;X)^{-1}X&#39;y}$ $Cov(\\boldsymbol{\\hat \\beta}_{\\text{OLS}} )= \\boldsymbol{(X&#39;X)^{-1}X&#39;\\Gamma_nX(X&#39;X)^{-1}}$ GLS $\\min_{\\beta}\\boldsymbol{(y -X\\beta)&#39;\\Gamma_n^{-1}(y -X\\beta)}$ $\\boldsymbol{\\hat \\beta}_{\\text{GLS}} = \\boldsymbol{(X&#39;\\Gamma_n^{-1}X)^{-1}X&#39;\\Gamma_n^{-1}y}$ $Cov(\\boldsymbol{\\hat \\beta}_{\\text{GLS}} )= \\boldsymbol{(X&#39;\\Gamma_n^{-1}X)^{-1}}$ GLS 在拟合时引入了 $\\eta_t$ 的协方差矩阵 $\\Gamma_n = E(\\boldsymbol{\\eta\\eta&#39;})$作为权重 $ \\boldsymbol{\\hat \\beta}_{\\text{GLS}}$是参数的最优的线性无偏估计，其估计误差比 OLS 更小 但问题在于计算 $\\Gamma_n$ 需要用到$\\text{ARMA}(p,q)$ 模型的参数 引入矩阵 $V(\\phi,\\theta)$ 与 $T(\\phi,\\theta)$，并且满足 $V = \\sigma^{-2}\\Gamma_n$ 与 $T&#39;T = V^{-1}$ 此时得到计算 $Cov(T\\eta) = T\\Gamma_nT&#39;=$ 如果有需要可以 非平稳$\\boldsymbol{\\hat \\beta}_{\\text{OLS}}$ 此时使用 OLS 得到估计值 $\\boldsymbol{\\hat \\beta}_{\\text{OLS}}$ 存在以下问题 如果 $\\eta_t$ 是非平稳的，该估计值不能保证最优 系数的显著性统计量会失效 "},{"slug":"linear-regression","title":"线性回归","tags":["统计","时间序列分析"],"content":"线性回归提供了一种简洁而强大的方式来量化变量之间的关系。 它的核心思想是找到一个线性方程，来描述两个或多个变量之间的线性关系，并利用这种关系进行预测或分析。 尽管现实世界中的关系可能更复杂，但线性回归模型仍是许多数据分析和预测任务的基石，为更高级的模型提供了重要的基础和参考。 模型设定 一个模型通常由两部分组成： 外生变量 （Exogenous Variables） 由模型以外的因素决定的已知变量，通常使用向量 $x \\in R^k$ 表示 内生变量 （Endogenous Variables） 由模型决定的未知变量，通常使用标量 $y \\in R$ 表示 如果两者间满足一个线性关系，则可以使用线性回归进行建模 $$y = \\beta_0 + \\beta_1x_1 + \\cdots + \\beta_kx_k + \\varepsilon$$ $\\beta \\in R^{k+1}$ 是回归系数 regression parameters $\\varepsilon \\in R$ 是随机误差 random error 改写为矩阵形式 $\\boldsymbol{y = X\\beta + \\varepsilon}$ $\\boldsymbol y = (y_1, \\ldots, y_n)$ $\\boldsymbol \\beta = (\\beta_0, \\beta_1, \\ldots, \\beta_k)$ $\\boldsymbol \\varepsilon = (\\varepsilon_1, \\ldots, \\varepsilon_n)$ $\\boldsymbol X = \\begin{bmatrix} 1 &amp; x_{1,1} &amp; \\cdots &amp; x_{k,1} \\ 1 &amp; x_{1,2} &amp; \\cdots &amp; x_{k,2} \\ \\vdots &amp; \\vdots &amp; \\cdots &amp; \\vdots \\ 1 &amp; x_{1,n} &amp; \\cdots &amp; x_{k,n} \\ \\end{bmatrix}$ 为了保证模型的有效性，通常假设误差项是一个白噪声序列，即 $\\boldsymbol \\varepsilon \\sim \\text{NID}(0,\\sigma^2)$ 这意味着误差来源于系统内的一些随机噪声，模型无法从中提取更多的信息 独立同分布 假设均值为 0 是因为系统均值能被截距项 $\\beta_0$ 捕获了 方差 $\\sigma^2$ 是一个系统相关的常量 假设数据来源于某次问卷调查，则该值与样本分布有关 假设数据来源于某个传感器，则该值由传感器精度决定 当通过某种形式求解出系数 $\\boldsymbol \\beta$ 后，可以根据系数计算估计值 $\\boldsymbol{\\hat y = \\beta x}$ 估计值与实际值间的误差 $\\boldsymbol{e = y -\\hat y} = (e_1, \\ldots, e_n)$ 称之为残差 residual 误差项是建模前人为设定的误差假设，而残差项是模型的实际误差 衡量一个模型优劣的方式，就是判断残差项是否符合建模前的误差假设 一些常见违反模型假设的情况： 均值非零：缺乏截距项 存在非线性趋势：增加非线性项目 自相关 异方差 参数估计 估计系数 $\\boldsymbol \\beta$ 的方式有两种： 最小二乘估计 Ordinary least squares 极大似然估计 Maximum likelihood estimation 在 $\\boldsymbol \\varepsilon \\sim \\text{NID}(0,\\sigma^2)$ 的假设前提下，两者是等价的 OLS 模型的拟合误差可以表示为残差之和 $\\sum_{i=1}^n e_i^2 = \\sum_{i=1}^n (y_i-\\hat y_i)^2$ 只要找到一组参数 $\\boldsymbol \\beta$ 使得残差和最小，即 $\\min_\\beta\\big[(\\boldsymbol {y -X \\beta})&#39;(\\boldsymbol {y -X \\beta})\\big]$ 展开函数 $(\\boldsymbol {y -X \\beta})&#39;(\\boldsymbol {y -X \\beta}) = \\boldsymbol{y}&#39;\\boldsymbol{y} - 2\\boldsymbol{\\beta}&#39;\\boldsymbol{X}&#39;\\boldsymbol{y} + \\boldsymbol{\\beta}&#39;\\boldsymbol{X}&#39;\\boldsymbol{X\\beta}$ 系数求导 $\\dfrac{\\partial}{\\partial \\beta}[(\\boldsymbol {y -X \\beta})&#39;(\\boldsymbol {y -X \\beta})] = -2\\boldsymbol{X&#39;y} + 2\\boldsymbol{X&#39;X\\beta}$ 求解系数 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol y$ MLE 由于误差项服从正态分布 $\\varepsilon_i \\sim \\mathcal{N}(0, \\sigma^2)$ $$P(\\varepsilon_i)=\\frac{1}{\\sqrt{2\\pi\\sigma^2}}\\exp\\bigg(-\\frac{(\\varepsilon_i-0)^2}{2\\sigma^2}\\bigg) $$ 某个样本来源于参数 $\\boldsymbol \\beta$ 的概率可以表示为 $$P(x_i,y_i|\\beta)=\\frac{1}{\\sqrt{2\\pi\\sigma^2}}\\exp\\bigg(-\\frac{(y_i-x_i\\beta)^2}{2\\sigma^2}\\bigg)$$ 这意味着样本符合正态分布 $\\boldsymbol y \\sim \\mathcal{N}(\\boldsymbol X \\boldsymbol\\beta, \\sigma^2I)$ 整个样本集的联合概率密度函数（即似然函数）可以表示为 $$L(\\beta,\\sigma^2) = \\prod_{i=1}^n P(x_i,y_i|\\beta) = (\\frac{1}{\\sqrt{2\\pi\\sigma^2}})^n\\exp\\bigg(-\\frac{1}{2\\sigma^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)\\bigg)$$ 此时要求得一组参数 $\\boldsymbol \\beta$，使得样本出现的概率最大，即 $\\max_\\beta L(\\beta,\\sigma^2)$ 为了方便计算，将似然函数改写为对数似然函数 log-likelihood $$ \\ell(\\beta,\\sigma^2) = \\log L(\\beta,\\sigma^2) = -\\frac{n}{2}\\log(2\\pi\\sigma^2) -\\frac{1}{2\\sigma^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta) $$ 对 $\\beta$ 与 $\\sigma^2$ 分别进行求导 $\\dfrac{\\partial \\ell}{\\partial \\beta} = \\dfrac{1}{\\sigma^2}\\boldsymbol{X&#39;}(\\boldsymbol{y-X\\beta})$ $\\dfrac{\\partial \\ell}{\\partial \\sigma^2} = -\\dfrac{n}{2\\sigma^2} + \\dfrac{1}{(2\\sigma^2)^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)$ 求解得到 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol y$ $\\hat\\sigma^2 = \\dfrac{1}{n}(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta})&#39;(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta}) = \\dfrac{1}{n}\\sum^n_i e_i^2$ $\\log L = -\\dfrac{n}{2}\\log(2\\pi\\hat\\sigma^2)-\\dfrac{1}{2\\sigma^2}(n\\hat\\sigma^2) = -\\dfrac{n}{2}\\log\\big(2\\pi\\tfrac{\\sum^n_i e_i^2}{n}\\big) - \\dfrac{n}{2} = -\\dfrac{n}{2}\\big[\\log(2\\pi)+\\log(\\tfrac{\\sum^n_i e_i^2}{n}) + 1\\big]$ 模型预测 使用模型进行预测时，需要考虑以下误差： 模型误差 $Var(\\boldsymbol\\varepsilon)$ 表示系统随机噪声引入的误差 估计误差 $Var(\\hat{\\boldsymbol\\beta})$ 表示估计值 $\\hat{\\boldsymbol\\beta}$ 与真实值 $\\boldsymbol \\beta$ 间的差异 预测误差 $Var(y^*)$ 表示预测值 $y^*$ 与真实值 $y$ 间的差异 模型误差 由于模型是现实世界的抽象，因此不可避免的会存在误差 模型误差表示真实值与预测值之间的差异，通常可以通过残差进行衡量 通常情况下，模型误差 $Var(\\boldsymbol\\varepsilon) = \\sigma^2$ 会使用残差误差的无偏估计替代 $$\\hat\\sigma^2 = \\frac{1}{n-k-1}(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta})&#39;(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta}) = \\frac{1}{n-k-1}\\sum^n_i e_i^2$$ 估计误差 设定模型时，假设误差为白噪声且独立同分布，这意味着误差项具有以下特性： 模型误差期望 $E(\\boldsymbol\\varepsilon) = 0$ 模型误差方差 $Var(\\boldsymbol\\varepsilon) = \\sigma^2$ 由于 $\\boldsymbol\\beta$ 是 $\\hat{\\boldsymbol\\beta}$ 的无偏估计，因此 $E(\\hat{\\boldsymbol\\beta}) = \\boldsymbol\\beta$ 将 $\\boldsymbol {y = X\\beta + \\varepsilon}$ 代入 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol y$ 得到 $$ \\begin{matrix}\\hat{\\boldsymbol\\beta} &amp;=&amp;(\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;(\\boldsymbol{X\\beta + \\varepsilon}) \\ &amp;=&amp;(\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol{X\\beta} +(\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol\\varepsilon\\ &amp;=&amp;\\boldsymbol{\\beta} +(\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol\\varepsilon\\end{matrix} $$ $$ \\begin{matrix}Var(\\hat{\\boldsymbol\\beta}) &amp;=&amp; E(\\hat{\\boldsymbol\\beta}^2) - E^2(\\hat{\\boldsymbol\\beta}) \\ &amp;=&amp; E(\\hat{\\boldsymbol\\beta}^2) - \\boldsymbol\\beta^2 \\ &amp;=&amp; E\\big[\\big(\\boldsymbol{\\beta} +(\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol\\varepsilon\\big)^2\\big]- \\boldsymbol\\beta^2 \\ &amp;=&amp; \\boldsymbol\\beta^2 + E\\big[\\big((\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol\\varepsilon\\big)^2\\big]- \\boldsymbol\\beta^2 \\ &amp;=&amp; \\big((\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\big)^2E(\\boldsymbol\\varepsilon^2)\\end{matrix} $$ 其中： $E(\\boldsymbol\\varepsilon^2) = Var(\\boldsymbol\\varepsilon) - E^2(\\boldsymbol\\varepsilon) = \\sigma^2$ $\\big((\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\big)^2 = (\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol X (\\boldsymbol{X&#39; X})^{&#39;-1} = (\\boldsymbol{X&#39; X})^{-1}$ 最终得到：$Var(\\hat{\\boldsymbol\\beta}) = \\hat\\sigma^2(\\boldsymbol{X&#39; X})^{-1}$ 预测误差 给定参数估计值 $\\hat{\\boldsymbol \\beta}$ 与预测变量向量 $\\boldsymbol x^*$，对应的预测观测值为 $$\\hat{y}^* = E(y^*|\\boldsymbol y,\\boldsymbol X, \\boldsymbol x^*) = \\boldsymbol x^* \\hat{\\boldsymbol\\beta} = \\boldsymbol x^*(\\boldsymbol X&#39;\\boldsymbol X)^{-1}\\boldsymbol X&#39;\\boldsymbol y$$ 预测误差来源于 $\\hat{\\boldsymbol \\beta}$ 的估计误差 $Var(\\hat{\\boldsymbol\\beta}) = \\sigma^2(\\boldsymbol{X&#39; X})^{-1}$，因此预测均值的不确定性为 $$Var(y^*|\\boldsymbol X, \\boldsymbol x^*) = Var(\\boldsymbol x^* \\hat{\\boldsymbol\\beta}|\\boldsymbol X) =\\boldsymbol x^Var(\\hat{\\boldsymbol\\beta}) (\\boldsymbol x^)&#39; = \\hat\\sigma^2\\boldsymbol x^*(\\boldsymbol X&#39;\\boldsymbol X)^{-1}(\\boldsymbol x^*)&#39;$$ 此外，预测时还需要考虑模型误差 $Var(\\boldsymbol\\varepsilon) = \\sigma^2$ 对观测值 $\\hat{y}^*$ 的影响 $$Var(y^*|\\boldsymbol X, \\boldsymbol x^*) + Var(\\boldsymbol\\varepsilon) = \\hat\\sigma^2 \\big[1 + \\boldsymbol x^*(\\boldsymbol X&#39;\\boldsymbol X)^{-1}(\\boldsymbol x^*)&#39; \\big]$$ 基于方差公式，可以得到对应的标准差为 均值预测标准误差 $\\text{SE}_{\\text{mean}} = \\sqrt{Var(y^*|\\boldsymbol X, \\boldsymbol x^*) }$ 观测预测标准误差 $\\text{SE}_{\\text{obs}} = \\sqrt{Var(y^*|\\boldsymbol X, \\boldsymbol x^*) + Var(\\boldsymbol\\varepsilon)}$ 注意，这里我们假定 $\\boldsymbol x^*$ 是已知的，因此未将其作为方差来源 如果 $\\boldsymbol x^*$ 是估计值，还需要将其本身的估计误差也考虑进去 置信区间 预测始终会存在误差，为了量化这种误差，需要用到置信区间 模型预测的底层原理是 中心极限定理 / Central Limit Theorem，当样本数量足够多时，样本均值会呈现正态分布 模型预测值实际上是正态分布的均值，围绕这个均值存在一个对称的 概率分布区间 / probability distribution interval 通过 百分位函数 / quantile function 可以在概率与标准误差之间建立联系，一个典型的例子是 3-sigma 定律 随机抽样落在 $\\mu\\pm1\\sigma$ 区间的概率为 68.27% 随机抽样落在 $\\mu\\pm2\\sigma$ 区间的概率为 95.45% 随机抽样落在 $\\mu\\pm3\\sigma$ 区间的概率为 99.73% 使用模型进行预测时，除了给出预测值 $\\hat y$ 外，还需要给出对应的置信区间 真实值 $y$ 落在 $\\hat y \\pm1.64\\ \\text{SE}_{\\text{obs}}$ 区间内的概率为 90% 真实值 $y$ 落在 $\\hat y \\pm1.96\\ \\text{SE}_{\\text{obs}}$ 区间内的概率为 95% 真实值 $y$ 落在 $\\hat y \\pm2.57\\ \\text{SE}_{\\text{obs}}$ 区间内的概率为 99% 置信区间的大小取决于： 置信水平：置信水平越高，预测成立的概率越高，但预测精度会下降 模型精度：模型精度越高，相同置信水平下区间更小，预测精度越高 计算方式 LU 分解 计算参数估计值 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39; X})^{-1}\\boldsymbol X&#39;\\boldsymbol y$ 时，涉及到对矩阵 $\\boldsymbol X&#39;\\boldsymbol X$ 进行求逆 然而并不是所有矩阵都有逆，只有当矩阵是方阵且行列式非零时，才存在逆矩阵 当存在多重共线性时， $\\boldsymbol X&#39;\\boldsymbol X$ 是奇异矩阵，无法求逆 一种常用的求逆方式是 高斯消元法 另一种方式是使用 LU 分解进行求逆 使用 LU 分解求方阵 $A$ 的逆矩阵 $A^{-1}$ 的步骤如下： 将方阵 A 分解为一个下三角矩阵 L 和一个上三角矩阵 U 的乘积 $$A \\to LU = \\begin{bmatrix}\\ell_{11}&amp;0&amp;0\\\\ell_{21}&amp;\\ell_{22}&amp;0\\\\ell_{31}&amp;\\ell_{32}&amp;\\ell_{33}\\end{bmatrix} \\times \\begin{bmatrix}u_{11}&amp;u_{12}&amp;u_{13}\\0&amp;u_{22}&amp;u_{23}\\0&amp;0&amp;u_{33}\\end{bmatrix}$$ 使用 LU 替换 A 得到 $$A^{-1}=(LR)^{-1} = R^{-1} L^{-1}$$ 求解三角矩阵 $L^{-1}$ 与 $R^{-1}$ $LL^{-1}=I \\to \\begin{bmatrix}\\ell_{11}&amp;0&amp;0\\\\ell_{21}&amp;\\ell_{22}&amp;0\\\\ell_{31}&amp;\\ell_{32}&amp;\\ell_{33}\\end{bmatrix} \\times \\begin{bmatrix}x_{11}&amp;0&amp;0\\x_{21}&amp;x_{22}&amp;0\\x_{31}&amp;x_{32}&amp;x_{33}\\end{bmatrix} = \\begin{bmatrix}1\\1\\1\\end{bmatrix}$ $UU^{-1}=I \\to \\begin{bmatrix}u_{11}&amp;u_{12}&amp;u_{13}\\0&amp;u_{22}&amp;u_{23}\\0&amp;0&amp;u_{33}\\end{bmatrix} \\times \\begin{bmatrix}y_{11}&amp;y_{12}&amp;y_{13}\\0&amp;y_{22}&amp;y_{23}\\0&amp;0&amp;y_{33}\\end{bmatrix} = \\begin{bmatrix}1\\1\\1\\end{bmatrix}$ LU 分解也是基于高斯消元法实现，两者时间复杂度均为 $O(n^3)$ 但后续求逆过程基于三角矩阵实现，计算量相较于直接使用消元法更少 package main import ( &quot;fmt&quot; &quot;gonum.org/v1/gonum/blas&quot; &quot;gonum.org/v1/gonum/blas/blas64&quot; &quot;gonum.org/v1/gonum/lapack/lapack64&quot; ) func main() { swap := []int{0, 0, 0} work := []float64{0, 0, 0} // Decompose A = LU A := blas64.General{3, 3, []float64{ 2, -1, 0, -1, 2, -1, 0, -1, 2, }, 3} if ok := lapack64.Getrf(A, swap); !ok { panic(&quot;LU decomposition unstable&quot;) } // Print L fmt.Printf(&quot;[1 0 0]\\n[%.2f 1 0]\\n[%.2f %.2f 1]\\n&quot;, A.Data[3], A.Data[6], A.Data[7]) // Print U fmt.Printf(&quot;[%.2f %.2f %.2f]\\n[0 %.2f %.2f]\\n[0 0 %.2f]\\n&quot;, A.Data[0], A.Data[1], A.Data[2], A.Data[4], A.Data[5], A.Data[8]) // Solve LL⁻ = I with iteration // Solve UU⁻ = I with iteration // Calculate A⁻ = U⁻L⁻ if ok := lapack64.Getri(A, swap, work, len(work)); !ok { panic(&quot;LU inverse failed&quot;) } fmt.Printf(&quot;[%.2f %.2f %.2f]\\n[%.2f %.2f %.2f]\\n[%.2f %.2f %.2f]\\n&quot;, A.Data[0], A.Data[1], A.Data[2], A.Data[3], A.Data[4], A.Data[5], A.Data[6], A.Data[7], A.Data[8]) // Verify AxA⁻ = I B := blas64.General{3, 3, []float64{ 2, -1, 0, -1, 2, -1, 0, -1, 2, }, 3} C := blas64.General{3, 3, []float64{ 0, 0, 0, 0, 0, 0, 0, 0, 0, }, 3} blas64.Gemm(blas.NoTrans, blas.NoTrans, 1, A, B, 0, C) fmt.Printf(&quot;[%.2f %.2f %.2f]\\n[%.2f %.2f %.2f]\\n[%.2f %.2f %.2f]\\n&quot;, C.Data[0], C.Data[1], C.Data[2], C.Data[3], C.Data[4], C.Data[5], C.Data[6], C.Data[7], C.Data[8]) } QR 分解 实际应用中，直接对高阶方阵进行求逆并不是一个明智的选择 稀疏矩阵的逆矩阵可能是一个稠密矩阵，会占用大量的内存与计算资源 求逆过程中存在大量的浮点数计算，误差累积最终会影响数值稳定性 通常情况下，求逆只是一个中间过程 对于线性回归，我们最终的目的是求解方程组 $\\boldsymbol{X&#39;X\\beta} = \\boldsymbol{X&#39;y}$，求逆只是其中一个过程 https://math.stackexchange.com/questions/3185211/what-does-qr-decomposition-have-to-do-with-least-squares-method 对于方程组 $A&#39;Ax=A&#39;b$，可以考虑使用 QR 分解进行求解 将矩阵 A 分解为一个下正交方阵 Q 和一个上三角矩阵 R 的乘积 $$A \\to QR = \\begin{bmatrix}a_{11}&amp;a_{12}&amp;a_{13}\\a_{21}&amp;a_{22}&amp;a_{23}\\a_{31}&amp;a_{32}&amp;a_{33}\\a_{41}&amp;a_{42}&amp;a_{43}\\end{bmatrix} \\to \\begin{bmatrix}q_{11}&amp;q_{12}&amp;q_{13}&amp;q_{14}\\q_{21}&amp;q_{22}&amp;q_{23}&amp;q_{24}\\q_{31}&amp;q_{32}&amp;q_{33}&amp;q_{34}\\q_{41}&amp;q_{42}&amp;q_{43}&amp;q_{44}\\end{bmatrix} \\times \\begin{bmatrix}r_{11}&amp;r_{12}&amp;r_{13}\\0&amp;r_{22}&amp;r_{23}\\0&amp;0&amp;r_{33}\\0&amp;0&amp;0\\end{bmatrix}$$ 使用 QR 替换 A 得到 $$(QR)&#39;(QR)x=(QR)&#39;b \\ \\to\\ R&#39;Q&#39;QRx=R&#39;Q&#39;b$$ 代入 $QQ&#39;=I$ 得到 $$R&#39;Rx=R&#39;Q&#39;b \\ \\to\\ Rx=Q&#39;b$$ 求解方程组即可得到 $x$ $$\\begin{bmatrix}r_{11}&amp;r_{12}&amp;r_{13}\\0&amp;r_{22}&amp;r_{23}\\0&amp;0&amp;r_{33}\\0&amp;0&amp;0\\end{bmatrix} \\times \\begin{bmatrix}x_{1}\\x_{2}\\x_{3}\\end{bmatrix} = \\begin{bmatrix}q_{11}b_1+q_{21}b_2+q_{31}b_3+q_{41}b_4\\q_{12}b_1+q_{22}b_2+q_{32}b_3+q_{42}b_4\\q_{13}b_1+q_{23}b_2+q_{33}b_3+q_{43}b_4\\q_{14}b_1+q_{24}b_2+q_{34}b_3+q_{44}b_4\\end{bmatrix}$$ 使用 QR 分解时，估计误差的计算方式如下： $$Var(\\hat{\\boldsymbol\\beta}) / \\sigma^2 = (\\boldsymbol{X&#39; X})^{-1} =((QR)&#39;QR)^{-1} = R^{-1}(Q&#39;Q)R&#39;^{-1} = R^{-1}R&#39;^{-1}=(R&#39;R)^{-1}$$ package main import ( &quot;fmt&quot; &quot;gonum.org/v1/gonum/blas&quot; &quot;gonum.org/v1/gonum/blas/blas64&quot; &quot;gonum.org/v1/gonum/lapack/lapack64&quot; ) func main() { y := []float64{2, 3, 5, 7, 10} X := []float64{ 1, 1, 10, 1, 1, 2, 8, 0, 1, 3, 9, 1, 1, 4, 7, 1, 1, 5, 6, 0, } A := blas64.General{5, 4, X, 4} b := blas64.Vector{4, y, 1} // Decompose A = QR QR := blas64.General{5, 4, make([]float64, len(A.Data)), 4} copy(QR.Data, A.Data) work := []float64{0} tau := make([]float64, min(QR.Rows, QR.Cols)) lapack64.Geqrf(QR, tau, work, -1) work = make([]float64, int(work[0])) lapack64.Geqrf(QR, tau, work, len(work)) // Restore Q R := blas64.Triangular{blas.Upper, blas.NonUnit, 4, QR.Data, 4} Q := blas64.General{5, 4, make([]float64, len(QR.Data)), 4} copy(Q.Data, QR.Data) lapack64.Orgqr(Q, tau, work, -1) work = make([]float64, int(work[0])) lapack64.Orgqr(Q, tau, work, len(work)) // Print Q fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;, Q.Data[0], Q.Data[1], Q.Data[2], Q.Data[3], Q.Data[4], Q.Data[5], Q.Data[6], Q.Data[7], Q.Data[8], Q.Data[9], Q.Data[10], Q.Data[11], Q.Data[12], Q.Data[13], Q.Data[14], Q.Data[15], Q.Data[16], Q.Data[17], Q.Data[18], Q.Data[19]) // Print R fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[0 %.2f %.2f %.2f]\\n&quot;+ &quot;[0 0 %.2f %.2f]\\n&quot;+ &quot;[0 0 0 %.2f]\\n&quot;, R.Data[0], R.Data[1], R.Data[2], R.Data[3], R.Data[5], R.Data[6], R.Data[7], R.Data[10], R.Data[11], R.Data[15]) // Calculate Qᵀb Qb := blas64.Vector{4, make([]float64, 4), 1} blas64.Gemv(blas.Trans, 1, Q, b, 0, Qb) // Solve Rx = Qᵀb x := blas64.General{4, 1, Qb.Data, 1} if ok := lapack64.Trtrs(blas.NoTrans, R, x); !ok { panic(&quot;Solve X failed&quot;) } // Print x fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;, x.Data[0], x.Data[1], x.Data[2], x.Data[3]) // Calculate RᵀR RR := blas64.General{4, 4, make([]float64, 16), 4} for i := 0; i &lt; R.N; i++ { for j := i; j &lt; R.N; j++ { RR.Data[i*R.N+j] = R.Data[i*R.N+j] } } blas64.Trmm(blas.Left, blas.Trans, 1, R, RR) // Calculate (RᵀR)⁻ swap := make([]int, 4) work = make([]float64, 4) if ok := lapack64.Getrf(RR, swap); !ok { panic(&quot;LU decomposition unstable&quot;) } if ok := lapack64.Getri(RR, swap, work, len(work)); !ok { panic(&quot;LU inverse failed&quot;) } // Print (RᵀR)⁻ fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;, RR.Data[0], RR.Data[1], RR.Data[2], RR.Data[3], RR.Data[4], RR.Data[5], RR.Data[6], RR.Data[7], RR.Data[8], RR.Data[9], RR.Data[10], RR.Data[11], RR.Data[12], RR.Data[13], RR.Data[14], RR.Data[15]) // Calculate residual e beta := blas64.Vector{4, x.Data, 1} residual := blas64.Vector{len(y), make([]float64, len(y)), 1} copy(residual.Data, y) blas64.Gemv(blas.NoTrans, 1, A, beta, -1, residual) // Calculate unbiased variance σ² = Σe² / (n-k-1) freedomDeg := float64(len(y) - (beta.N - 1) - 1) unbiasedVar := blas64.Dot(residual, residual) / freedomDeg fmt.Printf(&quot;[%.2f %.2f %.2f %.2f %.2f] / %.2f -&gt; %.2f\\n&quot;, residual.Data[0], residual.Data[1], residual.Data[2], residual.Data[3], residual.Data[4], freedomDeg, unbiasedVar) // Predicate xStar := blas64.Vector{4, []float64{1, 2, 1, 2}, 1} yStar := blas64.Dot(beta, xStar) // Calculate (σ²(XᵀX)⁻)xᵀ xStarT := blas64.General{4, 1, xStar.Data, 1} predVar := blas64.General{beta.N, 1, make([]float64, beta.N), 1} blas64.Gemm(blas.NoTrans, blas.NoTrans, unbiasedVar, RR, xStarT, 0, predVar) // Calculate prediction variance x(σ²(XᵀX)⁻)xᵀ predVarT := blas64.Vector{beta.N, predVar.Data, 1} yVar := blas64.Dot(xStar, predVarT) fmt.Printf(&quot;%.2f (±%.2f)\\n&quot;, yStar, yVar) } SVD 分解 当矩阵不可逆时，一种替代方案是求伪逆 $A^+$ 作为近似，其满足以下 Moore-Penrose 性质： $AA^+A = A$ $A^+AA^+ = A^+$ $(AA^+)^* = AA^+$ $(A^+A)^* = A^+A$ 即便无法求逆，也可以借助 $A^+$ 的性质实现消元与求解方程组 一种构造的 $A^+$ 的方式是借助 SVD 分解 将方矩阵 $A$ 分解为正交方阵 $U、V$ 与对角矩阵 $\\Sigma$ 的乘积 $$ A \\to U\\Sigma V^* = \\begin{bmatrix}a_{11}&amp;a_{12}\\a_{21}&amp;a_{22}\\a_{31}&amp;a_{32}\\end{bmatrix} \\to \\begin{bmatrix}u_{11}&amp;u_{12}&amp;u_{13}\\u_{21}&amp;u_{22}&amp;u_{23}\\u_{31}&amp;u_{32}&amp;u_{33}\\end{bmatrix} \\times \\begin{bmatrix}\\sigma_{1}&amp;0\\0&amp;\\sigma_{2}\\0&amp;0\\end{bmatrix} \\times \\begin{bmatrix}\\nu_{11}&amp;\\nu_{12}\\\\nu_{21}&amp;\\nu_{22}\\end{bmatrix} $$ 通过以下方式构造 对角矩阵 $\\Sigma$ 的伪逆 $\\Sigma^+$ $$ \\Sigma^+ = \\begin{bmatrix}\\frac{1}{\\sigma_{1}}&amp;0&amp;0\\0&amp;\\frac{1}{\\sigma_{2}}&amp;0\\end{bmatrix} $$ 接着使用以下公式即可构造伪逆 $$ A^+ = V\\Sigma^+ U^* = \\begin{bmatrix}\\nu_{11}&amp;\\nu_{21}\\\\nu_{12}&amp;\\nu_{22}\\end{bmatrix} \\times \\begin{bmatrix}\\frac{1}{\\sigma_{1}}&amp;0&amp;0\\0&amp;\\frac{1}{\\sigma_{2}}&amp;0\\end{bmatrix} \\times \\begin{bmatrix}u_{11}&amp;u_{21}&amp;u_{31}\\u_{12}&amp;u_{22}&amp;u_{32}\\u_{13}&amp;u_{23}&amp;u_{33}\\end{bmatrix} $$ 具体实现时，可以分情况处理： - 当 dim(V) &lt; dim(U) 时 $$\\begin{bmatrix}\\frac{\\nu_{11}}{\\sigma_1}&amp;\\frac{\\nu_{21}}{\\sigma_2}&amp;0\\\\\\frac{\\nu_{12}}{\\sigma_1}&amp;\\frac{\\nu_{22}}{\\sigma_2}&amp;0\\end{bmatrix} \\times \\begin{bmatrix}u_{11}&amp;u_{21}&amp;u_{31}\\\\u_{12}&amp;u_{22}&amp;u_{32}\\\\u_{13}&amp;u_{23}&amp;u_{33}\\end{bmatrix} \\to \\begin{bmatrix}\\frac{\\nu_{11}}{\\sigma_1}&amp;\\frac{\\nu_{21}}{\\sigma_2}\\\\\\frac{\\nu_{12}}{\\sigma_1}&amp;\\frac{\\nu_{22}}{\\sigma_2}\\end{bmatrix} \\times \\begin{bmatrix}u_{11}&amp;u_{21}\\\\u_{12}&amp;u_{22}\\\\u_{13}&amp;u_{23}\\end{bmatrix}$$ - 当 dim(U) &lt; dim(V) 时 $$\\begin{bmatrix}\\nu_{11}&amp;\\nu_{21}&amp;\\nu_{31}\\\\\\nu_{12}&amp;\\nu_{22}&amp;\\nu_{32}\\\\\\nu_{13}&amp;\\nu_{23}&amp;\\nu_{33}\\end{bmatrix} \\times \\begin{bmatrix}\\frac{u_{11}}{\\sigma_1}&amp;\\frac{u_{21}}{\\sigma_1}\\\\\\frac{u_{12}}{\\sigma_2}&amp;\\frac{u_{22}}{\\sigma_2}\\\\0&amp;0\\end{bmatrix} \\to \\begin{bmatrix}\\nu_{11}&amp;\\nu_{21}\\\\\\nu_{12}&amp;\\nu_{22}\\\\\\nu_{13}&amp;\\nu_{23}\\end{bmatrix} \\times \\begin{bmatrix}\\frac{u_{11}}{\\sigma_1}&amp;\\frac{u_{21}}{\\sigma_1}\\\\\\frac{u_{12}}{\\sigma_2}&amp;\\frac{u_{22}}{\\sigma_2}\\end{bmatrix} $$ 可验证该 $A^+$ 其满足以下 Moore-Penrose 性质： $AA^+A=UΣV^VΣ^+U^UΣV^*=UΣΣ^+ΣV^*=UΣV^*=A$ $A^+AA^+=VΣ^+U^UΣV^VΣ^+=VΣ^+ΣΣ^+V^*=VΣ^+V^*=A^+$ $(AA^+)^*=(UΣV^VΣ^+U^)^*=UΣΣ^+U^*=AA^+$ $(A^+A)^*=(VΣ^+U^UΣV^)^*=VΣ^+ΣV^*=A^+A$ 根据这篇博客中的证明过程，OLS 中存在关系 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X^* X})^+\\boldsymbol X^*\\boldsymbol y = \\boldsymbol X^+\\boldsymbol y$ 因此可以使用 SVD 构造伪逆的方式估计模型参数 https://math.stackexchange.com/questions/4440503/moore-penrose-pseudoinverse-solves-the-least-squares-problem-svd-framework 使用伪逆求解时，可以使用 $(\\boldsymbol{X^* X})^+\\boldsymbol X^*\\boldsymbol = \\boldsymbol X^+$ 简化估计误差公式： $$ Var(\\hat{\\boldsymbol\\beta}) / \\sigma^2 = (\\boldsymbol{X&#39; X})^{-1} = (\\boldsymbol{X&#39; X})^{+}=\\boldsymbol X^+(\\boldsymbol X&#39;)^{+}=\\boldsymbol X^+(\\boldsymbol X^{+}){&#39;} $$ package main import ( &quot;fmt&quot; &quot;gonum.org/v1/gonum/blas&quot; &quot;gonum.org/v1/gonum/blas/blas64&quot; &quot;gonum.org/v1/gonum/lapack&quot; &quot;gonum.org/v1/gonum/lapack/lapack64&quot; ) func main() { y := []float64{2, 3, 5, 7, 10} X := []float64{ 1, 1, 10, 1, 1, 2, 8, 0, 1, 3, 9, 1, 1, 4, 7, 1, 1, 5, 6, 0, } A := blas64.General{5, 4, X, 4} b := blas64.Vector{4, y, 1} // Decompose A = UΣVᵀ SVD := blas64.General{5, 4, make([]float64, len(A.Data)), 4} copy(SVD.Data, A.Data) U := blas64.General{A.Rows, A.Rows, make([]float64, A.Rows*A.Rows), A.Rows} V := blas64.General{A.Cols, A.Cols, make([]float64, A.Cols*A.Cols), A.Cols} S := make([]float64, min(A.Rows, A.Cols)) work := []float64{0} lapack64.Gesvd(lapack.SVDAll, lapack.SVDAll, SVD, U, V, S, work, -1) work = make([]float64, int(work[0])) if ok := lapack64.Gesvd(lapack.SVDAll, lapack.SVDAll, SVD, U, V, S, work, len(work)); !ok { panic(&quot;SVD decomposition failed&quot;) } // Print U fmt.Printf(&quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;, U.Data[0], U.Data[1], U.Data[2], U.Data[3], U.Data[4], U.Data[5], U.Data[6], U.Data[7], U.Data[8], U.Data[9], U.Data[10], U.Data[11], U.Data[12], U.Data[13], U.Data[14], U.Data[15], U.Data[16], U.Data[17], U.Data[18], U.Data[19], U.Data[20], U.Data[21], U.Data[22], U.Data[23], U.Data[24]) // Print Σ fmt.Printf(&quot;[%.2f 0 0 0 0]\\n&quot;+ &quot;[0 %.2f 0 0 0]\\n&quot;+ &quot;[0 0 %.2f 0 0]\\n&quot;+ &quot;[0 0 0 %.2f 0]\\n&quot;+ &quot;[0 0 0 0 0]\\n&quot;, S[0], S[1], S[2], S[3]) // Print Vᵀ fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;, V.Data[0], V.Data[1], V.Data[2], V.Data[3], V.Data[4], V.Data[5], V.Data[6], V.Data[7], V.Data[8], V.Data[9], V.Data[10], V.Data[11], V.Data[12], V.Data[13], V.Data[14], V.Data[15]) // Calculate Σ⁺ for i := 0; i &lt; len(S); i++ { if S[i] &gt; 0 { S[i] = 1 / S[i] } } if A.Rows &gt; A.Cols { // Calculate V = (Σ⁺ᵀVᵀ)ᵀ for i := 0; i &lt; len(V.Data); i++ { V.Data[i] *= S[i/A.Cols] } U.Cols = A.Cols // trim U } else { // Calculate U = (UΣ⁺ᵀ)ᵀ for i := 0; i &lt; len(U.Data); i++ { U.Data[i] *= S[i%A.Rows] } V.Rows = A.Rows // trim V } // Calculate A⁺ = VΣ⁺Uᵀ = Vᵀ x Uᵀ INV := blas64.General{A.Cols, A.Rows, make([]float64, A.Cols*A.Rows), A.Rows} blas64.Gemm(blas.ConjTrans, blas.ConjTrans, 1, V, U, 0, INV) fmt.Printf(&quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f %.2f]\\n&quot;, INV.Data[0], INV.Data[1], INV.Data[2], INV.Data[3], INV.Data[4], INV.Data[5], INV.Data[6], INV.Data[7], INV.Data[8], INV.Data[9], INV.Data[10], INV.Data[11], INV.Data[12], INV.Data[13], INV.Data[14], INV.Data[15], INV.Data[16], INV.Data[17], INV.Data[18], INV.Data[19]) // Calculate x = A⁺b x := blas64.Vector{4, make([]float64, 4), 1} blas64.Gemv(blas.NoTrans, 1, INV, b, 0, x) // Print x fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;, x.Data[0], x.Data[1], x.Data[2], x.Data[3]) // Calculate A⁺A⁺ᵀ AA := blas64.General{4, 4, make([]float64, 16), 4} blas64.Gemm(blas.NoTrans, blas.Trans, 1, INV, INV, 0, AA) // Print A⁺A⁺ᵀ fmt.Printf(&quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;+ &quot;[%.2f %.2f %.2f %.2f]\\n&quot;, AA.Data[0], AA.Data[1], AA.Data[2], AA.Data[3], AA.Data[4], AA.Data[5], AA.Data[6], AA.Data[7], AA.Data[8], AA.Data[9], AA.Data[10], AA.Data[11], AA.Data[12], AA.Data[13], AA.Data[14], AA.Data[15]) // Calculate residual e beta := blas64.Vector{4, x.Data, 1} residual := blas64.Vector{len(y), make([]float64, len(y)), 1} copy(residual.Data, y) blas64.Gemv(blas.NoTrans, 1, A, beta, -1, residual) // Calculate unbiased variance σ² = Σe² / (n-k-1) freedomDeg := float64(len(y) - (beta.N - 1) - 1) unbiasedVar := blas64.Dot(residual, residual) / freedomDeg fmt.Printf(&quot;[%.2f %.2f %.2f %.2f %.2f] / %.2f -&gt; %.2f\\n&quot;, residual.Data[0], residual.Data[1], residual.Data[2], residual.Data[3], residual.Data[4], freedomDeg, unbiasedVar) // Predicate xStar := blas64.General{2, 4, []float64{ 1, 2, 1, 2, 1, 2, 2, 1, }, 4} yStar := blas64.Vector{2, []float64{0, 0}, 1} blas64.Gemv(blas.NoTrans, 1, xStar, beta, 0, yStar) // Calculate prediction variance x(σ²(XᵀX)⁻)xᵀ predVar := blas64.General{xStar.Rows, beta.N, make([]float64, beta.N*xStar.Rows), beta.N} blas64.Gemm(blas.NoTrans, blas.Trans, unbiasedVar, xStar, AA, 0, predVar) yVar := make([]float64, 2) for i := 0; i &lt; len(predVar.Data); i++ { yVar[i/beta.N] += predVar.Data[i] * xStar.Data[i] } fmt.Printf(&quot;%.2f ± %.2f, %.2f ± %.2f\\n&quot;, yStar.Data[0], yVar[0], yStar.Data[1], yVar[1]) } 评价指标 决定系数 决定系数，也称为判定系数，是一个统计学概念，用于衡量回归模型的拟合优度 总平方和 total sum of squares $\\text{SS}_{\\text{tot}} = \\sum^n_i( y_i-\\bar y)^2$ 残差平方和 residual sum of squares $\\text{SS}_{\\text{res}} = \\sum^n_i(y_i-\\hat y_i)^2 = \\sum_i e_i^2$ 决定系数 coefficient of dtermination $R^2 = 1 - \\dfrac{\\text{SS}{\\text{res}}/n}{\\text{SS}{\\text{tot}}/n} = 1 - \\dfrac{\\text{SS}{\\text{res}}}{\\text{SS}{\\text{tot}}}$ 以上各项表示的含义 $\\text{SS}_{\\text{tot}}$ 表示数据样本的总变异 $\\text{SS}_{\\text{res}}$ 表示模型未能解释的因变量变异 $R^2$ 模型所能解释的因变量变异的比例 $R^2$ 的取值范围在 $[0, 1]$ $R^2 = 1$ 模型完美拟合数据，即模型能够完全解释因变量的变异 $R^2 = 0$ 模型不能解释因变量的任何变异 即便引入无关的估计参数，也能提升决定系数的值 这不仅会导致模型复杂度上升，并使得模型对数据噪声过度敏感（过拟合），影响预测效果 为了惩罚这一行为，需要在评分时考虑模型参数数量：参数越多，评分越低 为此需要在调整系数中，引入两个自由度 degrees of freedom 的概念 样本自由度: 样本总数 n 减去 1 残差自由度: 样本总数 n 减去模型参数的数量 k 和截距项的数量 1 引入自由度后得到调整决定系数 $$\\bar R^2 = 1 - \\frac{\\text{SS}{\\text{res}}/(n-k-1)}{\\text{SS}{\\text{tot}}/(n-1)} = 1-\\frac{(1-R^2)(n-1)}{n-k-1}$$ 信息准则 在统计建模中，常常会面临多个候选模型，而信息准则是一类用于模型选择和比较的统计指标 信息准则通过在拟合优度和模型复杂度之间进行权衡，帮助我们找到一个最佳的模型 信息准则中的拟合优度通常使用对数似然度进行表示 线性回归的似然度函数为 $\\log(L) = -\\tfrac{n}{2}\\big(\\log(2\\pi)+\\log(\\tfrac{\\text{SS}_{\\text{res}}}{n}) + 1\\big)$ 常用的 3 种信息准则： 赤池信息准则 Akaike Information Criterion $$AIC = -2\\log(L)+2k$$ 贝叶斯信息准则 Bayesian Information Criterion $$BIC = -2\\log(L)+\\log(n)k$$ 校正赤池信息量准则 Corrected Akaike Information Criterion $$AIC_C = AIC + \\frac{2k(k+1)}{n-k-1}$$ AIC：AIC 同时考虑了模型似然度 $L$ 与参数数量 $k$， 在样本量较大时，AIC 是一个比较好的选择 BIC：BIC 对模型复杂度的惩罚更大，倾向于选择参数较少的模型， 如果更注重模型的简约性，或者担心过拟合，BIC 是一个更好的选择。 AICc：当样本数量较小时易产生过拟合，导致 AIC 倾向于参数更多的模型 在样本量较小的情况下，使用 AICc 是一个比较稳健的选择 BIC 在计量经济学领域中比较受欢迎 因为计量经济学通常假设数据来源于某个具体模型，BIC 能评估这一假设的准确性 如果不关心数据是否符合模型，只关注模型预测效果，则通常使用 AIC 评价模型 条件数 https://www.cnblogs.com/daniel-D/p/3219802.html 显著性检验 得到估计参数后，还可以通过一些假设检验来判断模型的简洁性 t 检验 t 统计量主要用于检验某个自变量对因变量的影响是否显著 提出假设 $H_0 : \\beta = 0$（即该自变量对因变量没有影响） $H_1 : \\beta \\ne 0$（即该自变量对因变量有影响） 计算 t 统计量 $t = \\dfrac{\\hat\\beta - \\beta_0}{SE(\\hat\\beta)}$ $\\hat\\beta$：估计的回归系数 $\\beta_0$：原假设中的系数值（通常为 0，即假设该自变量对因变量没有影响） $SE(\\hat\\beta)$：$\\hat\\beta$ 的标准误 进行假设检验 根据样本数量与参数数量计算自由度 $n-k-1$ 根据显著性水平与自由度查表获得临界值 使用临界值进行双尾检验，判断是否能够拒绝 $H_0$ 得出结论 如果拒绝 $H_0$，说明该自变量对因变量有显著影响 如果无法拒绝 $H_0$，说明该自变量对因变量的影响不显著 F 检验 F 统计量主要用于检验整个模型的显著性 残差检验 Durbin-Watson检验：用于检测自相关，尤其是序列数据中的一阶自相关 Shapiro-Wilk检验：用于检验残差的正态性，判断残差是否符合正态分布 Levene检验或Bartlett检验：用于检验残差的方差齐性，判断不同组别的残差是否具有相同的方差 "},{"slug":"stl-decomposition","title":"STL 分解","tags":["统计","时间序列分析"],"content":"STL（Seasonal-Trend decomposition using Loess）是一种稳健的时间序列分解算法。它利用 Loess 平滑技术，将序列准确分解为趋势、季节性和残差三个部分，广泛用于异常检测和数据预测。 模型设定 时间序列通常具有以下特性 趋势 **Trend **：长期的增长或下跌 季节 **Seasonal **：频率固定的波动，时间跨度通常在一年内，并且重复趋势明显（气温、旅游人数） 周期 **Cyclic **：频率不固定的波动，时间跨度往往大于一年，并且周期的长度可能发生变化（宏观经济） 分解时间序列助于理解时间序列，通常可以分为 3 部分 趋势-周期部分 trend-cycle 季节部分 seasonal 剩余部分 remainder 可选的分解方式有两种 加性分解 $$y_t = S_t + T_t + R_t$$ 乘性分解 $$y_t = S_t \\times T_t \\times R_t$$ 季节性波动的幅度或趋势周期的变化，与时间序列水平变化不相关，使用加性分解 季节性波动的幅度或趋势周期的变化，与时间序列水平变化成比例，使用乘性分解 可以先通过对数变换将原始序列转换为稳定的时间序列，再对稳定的序列使用加法分解 $$y_t = S_t \\times T_t \\times R_t \\ \\ \\to\\ \\ \\ \\log y_t = \\log S_t + \\log T_t + \\log R_t$$ 如果对季节性不感兴趣，可以从原始数据中去除季节性成分，得到 “季节性调整”数据 比如，对每月失业数据进行季节性调整，可以突出经济基本状况的变化，而不是季节性变化 经典分解法 使用滑动均值获得趋势成分 $$\\hat{T}_t$$ 去除趋势成分得到去趋势成分 $$y_t - \\hat{T}_t $$ 或 $$y_t / \\hat{T}_t$$ 基于去趋势成分计算季节成分 $$\\hat{S}_t$$ 去除季节成分得到 $$\\hat{R}_t$$ 计算趋势成分 假设 $m$ 为季节性成分的周期长度，可以通过滑动均值消除数据的随机性，从而得到平滑的趋势成分 当 $m$ 为奇数时，计算 $m\\text{-MA}$ $$\\hat{T}t = \\frac{1}{m}\\sum{j=-k}^ky_{t+j}$$，其中 $k = \\frac{m-1}{2}$ 当 $m$ 为偶数时，计算 $(2\\times m)\\text{-MA}$ $$\\hat{T}t = \\frac{1}{2m}\\sum{j=-k}^{k-1}y_{t+j} + \\frac{1}{2m}\\sum_{j=-(k-1)}^ky_{t+j}$$，其中 $k = \\frac{m}{2}$ 计算季节成分 假设季节周期长度为 m，则可以对每个季节内的去趋势值求均值 得到 m 个季节成分 $$S^{(1)}, ... , S^{(m)} $$，这些季节成分需满足以下约束 加性周期 $$S^{(1)} + ... + S^{(m)} = 0 $$ 乘性周期 $$S^{(1)} + ... + S^{(m)} = m$$ package main import &quot;fmt&quot; func main() { trend := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9} seasonal := []float64{1, 3, 9, 1, 3, 9, 1, 3, 9} remainder := []float64{0.47, 0.12, 0.33, 0.03, 0.18, 0.14, 0.1, 0.33, 0.45} mulTs := make([]float64, 9) addTs := make([]float64, 9) for i := 0; i &lt; 9; i++ { mulTs[i] = trend[i] * seasonal[i] * remainder[i] addTs[i] = trend[i] + seasonal[i] + remainder[i] } at, as, ar := decomposeClassic(addTs, 3, true) fmt.Printf(&quot;%v\\n&quot;, at) fmt.Printf(&quot;%v\\n&quot;, as) fmt.Printf(&quot;%v\\n&quot;, ar) mt, ms, mr := decomposeClassic(mulTs, 3, false) fmt.Printf(&quot;%v\\n&quot;, mt) fmt.Printf(&quot;%v\\n&quot;, ms) fmt.Printf(&quot;%v\\n&quot;, mr) } func decomposeClassic(ts []float64, period int, additive bool) (trend, seasonal, residual []float64) { trend = movingAvg(ts, period) deTrended := make([]float64, len(trend)) k := (len(ts) - len(trend)) / 2 if additive { for i, v := range ts[k : len(ts)-k] { deTrended[i] = v - trend[i] } } else { for i, v := range ts[k : len(ts)-k] { deTrended[i] = v / trend[i] } } summary := 0. seasonal = make([]float64, period) for i := 0; i &lt; period; i++ { n, m := 0., 0 for j := i; j &lt; len(deTrended); j += period { n += deTrended[j] m++ } seasonal[(i+k)%period] = n / float64(m) summary += seasonal[(i+k)%period] } mean := summary / float64(period) if additive { for i := 0; i &lt; period; i++ { seasonal[i] -= mean } } else { for i := 0; i &lt; period; i++ { seasonal[i] /= mean } } residual = make([]float64, len(trend)) if additive { for i := 0; i &lt; len(residual); i++ { residual[i] = deTrended[i] - seasonal[(i+k)%period] } } else { for i := 0; i &lt; len(residual); i++ { residual[i] = ts[i+k] / trend[i] / seasonal[(i+k)%period] } } return trend, seasonal, residual } func movingAvg(x []float64, m int) []float64 { k := m / 2 y := make([]float64, len(x)-k*2) if m%2 == 1 { // m-MA for i := k; i &lt; k+len(y); i++ { n := 0. for j := -k; j &lt;= k; j++ { n += x[i+j] } y[i-k] = n / float64(m) } } else { // (2 x m)-MA for i := k; i &lt; k+len(y); i++ { n := (x[i-k] + x[i+k]) / 2 for j := 1 - k; j &lt;= k-1; j++ { n += x[i+j] } y[i-k] = n / float64(m) } } return y } Loess 当使用线性回归拟合非线性数据时，存在以下几种可选方式 相关项 $$y = \\beta_0 + \\beta_1x_1+ \\beta_2x_2+ \\beta_3x_1x_2$$ 高阶项 $$y = \\beta_0 + \\beta_1x_1+ \\beta_2x_1^2+ \\beta_3 x_1^{-1}$$ 超越函数项 $$y = \\beta_0 + \\beta_1x_1+ \\beta_2\\log x_1 + \\beta_3 \\sin x_1$$ 但无论使用哪种方式，都需要人工构造、选择、组合特征，这带来了以下问题 特征构造过程繁琐，无法实现自动化，当数据集非常多时效率很低 无法自动适应数据变化，当数据分布发生变化时，此前构造的特征会失效 为了解决以上问题，提出了局部权重回归 Locally Weighted Regression，其主要优点可以概括为： 自动化：没有参数假设，无需人工构造特征 稳定性：具有局部自适应能力，对数据分布变化不敏感，对异常值不敏感 LWR 是一种用于处理非线性关系的非参数回归方法 模型中不包含参数 $\\boldsymbol \\beta$，而是直接使用样本集合 $(\\boldsymbol X,\\boldsymbol y)$ 进行预测 其核心思想是为每个样本点 $(x_i,y_i)$ 分配一个权重函数 $w_i(x)$，然后使用以下公式进行预测 $$y = w_1(x)y_1 + \\cdots + w_n(x)y_n = \\sum_{i=1}^n w_i(x) y_i$$ 该模型能够更好地拟合局部的数据分布，从而提高模型的预测精度 权重函数 $w_i(x)$ 的作用是计算输入向量 $x$ 与样本 $x_i$ 间的距离 $x$ 与 $x_i$ 的距离越近，$y_i$ 的权重越大 $x$ 与 $x_i$ 的距离越远，$y_i$ 的权重越小 最常用的权重函数有 Gaussian $w_i(x) = \\exp\\bigg(-\\frac{(x - x_i)^2}{2\\tau^2}\\bigg)$ Tri-cubic $w_i(x) = \\begin{cases} \\left[1 - \\left(\\frac{|x - x_i|}{h}\\right)^3\\right]^3, &amp; \\text{if } |x - x_i| \\le h \\ 0, &amp; \\text{otherwise} \\end{cases}$ Bi-square $w_i(x) = \\begin{cases} \\left[1 - \\left(\\frac{|x - x_i|}{h}\\right)^2\\right]^2, &amp; \\text{if } |x - x_i| \\le h \\ 0, &amp; \\text{otherwise} \\end{cases}$ 函数中的超参数（hyper-parameter）$\\tau$ 与 $h$ 被称为带宽（bandwidth） 带宽越大，权重衰减越慢，影响范围越广 带宽越小，权重衰减越快，影响范围越窄 带宽选择影响最终的拟合效果 带宽越大，曲线越光滑，但过大可能导致欠拟合 带宽越小，曲线越粗糙，但过小可能导致过拟合 参数估计 LWR 本身是无参的，但给定输入向量 $x$ 时，则可以基于 $x$ 计算出一系列的权重 $w_1(x), \\cdots, w_n(x)$ 然后借助 OLS 或 MLE 进行参数估计，得到一组对应的估计参数 $\\hat{\\boldsymbol\\beta}$ 每当输入向量 $x$ 发生变化时，都需要更新估计参数 $\\hat{\\boldsymbol\\beta}$ OLS 如果将 LWR 看作是在每个点 $(x_i,y_i)$ 处构建一个线性模型，其目标是最小化加权平方误差 模型的拟合误差可以表示为加权残差和 $\\sum_{i=1}^n w_i(x)e_i^2 = \\sum_{i=1}^n w_i(x) (y_i - x_i^T \\boldsymbol\\beta)^2$ 改写为矩阵形式 $(\\boldsymbol {y -X \\beta})&#39;\\boldsymbol W(\\boldsymbol {y -X \\beta})$ $\\boldsymbol y = (y_1, ..., y_n)$ $\\boldsymbol \\beta = (\\beta_1, ..., \\beta_k)$ $\\boldsymbol X = \\begin{bmatrix} 1 &amp; x_{1,1} &amp; \\cdots &amp; x_{k,1} \\ 1 &amp; x_{1,2} &amp; \\cdots &amp; x_{k,2} \\ \\vdots &amp; \\vdots &amp; \\cdots &amp; \\vdots \\ 1 &amp; x_{1,n} &amp; \\cdots &amp; x_{k,n} \\ \\end{bmatrix}$ $\\boldsymbol W = \\begin{bmatrix} w_1(x) &amp; 0 &amp;\\cdots &amp; 0 \\ 0 &amp; w_2(x) &amp; \\cdots &amp; 0 \\ \\vdots &amp; \\vdots &amp; \\ddots &amp; \\vdots \\ 0 &amp; 0 &amp;\\cdots &amp; w_n(x) \\ \\end{bmatrix}$ 此时 LWR 问题转化为一个加权最小二乘问题，目标是找到一组参数 $\\boldsymbol\\beta$ 使得残差和最小，即 $$\\min_\\beta\\big[(\\boldsymbol {y -X \\beta})&#39;\\boldsymbol W(\\boldsymbol {y -X \\beta})\\big]$$ 展开函数 $$(\\boldsymbol {y -X \\beta})&#39;\\boldsymbol W(\\boldsymbol {y -X \\beta}) = \\boldsymbol{y}&#39;\\boldsymbol{W}\\boldsymbol{y} - 2\\boldsymbol{\\beta}&#39;\\boldsymbol{X}&#39;\\boldsymbol{W}\\boldsymbol{y} + \\boldsymbol{\\beta}&#39;\\boldsymbol{X}&#39;\\boldsymbol{W}\\boldsymbol{X}\\boldsymbol{\\beta}$$ 系数求导 $$\\frac{\\partial}{\\partial \\boldsymbol{\\beta}} (\\boldsymbol {y -X \\beta})&#39;\\boldsymbol W(\\boldsymbol {y -X \\beta}) = -2\\boldsymbol{X}&#39;\\boldsymbol{W}\\boldsymbol{y} + 2\\boldsymbol{X}&#39;\\boldsymbol{W}\\boldsymbol{X}\\boldsymbol{\\beta}$$ 求解系数 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39;WX})^{-1}\\boldsymbol{X&#39;Wy}$ MLE 假设样本 $\\boldsymbol X$ 与 $\\boldsymbol y$ 服从正态分布，即 $\\boldsymbol y \\sim \\mathcal{N}(\\boldsymbol X \\boldsymbol\\beta, \\sigma^2I)$ 整个样本集的联合概率密度函数（即似然函数）可以表示为 $$L(\\beta,\\sigma^2) = \\prod_{i=1}^n P(x_i,y_i|\\beta) = (\\frac{1}{\\sqrt{2\\pi\\sigma^2}})^n\\exp\\bigg(-\\frac{1}{2\\sigma^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;\\boldsymbol W(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)\\bigg)$$ 为了方便计算，将似然函数改写为对数似然函数 $$\\ell(\\beta,\\sigma^2) = \\log L(\\beta,\\sigma^2) = −\\frac{n}{2}\\log(2\\pi\\sigma^2)−\\frac{1}{2\\sigma^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;\\boldsymbol W(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)$$ 对 $\\beta$ 与 $\\sigma^2$ 分别进行求导 $$\\frac{\\partial \\ell }{\\partial \\beta} = \\frac{1}{\\sigma^2}\\boldsymbol{X&#39;W}(\\boldsymbol{y-X\\beta})$$ $$\\frac{\\partial \\ell }{\\partial \\sigma^2} = -\\frac{n}{2\\sigma^2} + \\frac{1}{(2\\sigma^2)^2}(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)&#39;\\boldsymbol W(\\boldsymbol y -\\boldsymbol X\\boldsymbol\\beta)$$ 求解得到 $\\hat{\\boldsymbol\\beta} = (\\boldsymbol{X&#39;WX})^{-1}\\boldsymbol{X&#39;Wy}$ $\\hat\\sigma^2 = \\frac{1}{n}(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta})&#39;\\boldsymbol W(\\boldsymbol y -\\boldsymbol X\\hat{\\boldsymbol\\beta}) = \\frac{1}{n}\\sum^n_i w_i(x)e_i^2$ $\\log L = −\\frac{n}{2}[\\log(2\\pi)+\\log(\\frac{\\sum^n_i w_i(x)e_i^2}n) + 1]$ 样例代码 package main import ( &quot;fmt&quot; &quot;gonum.org/v1/gonum/blas/blas64&quot; &quot;gonum.org/v1/gonum/mat&quot; &quot;math&quot; ) type Gaussian struct { tau float64 } func (g Gaussian) Weight(x blas64.General, x0 blas64.Vector) []float64 { weight := make([]float64, x.Rows) for i := 0; i &lt; x.Rows; i++ { sum, pos, end := 0., i*x.Stride, x0.Inc*x0.N for j := 0; j &lt; end; j += x0.Inc { v := x.Data[pos] - x0.Data[j] sum += v * v pos++ } weight[i] = math.Exp(-math.Sqrt(sum) / 2 * g.tau * g.tau) } return weight } func main() { y := mat.NewVecDense(5, []float64{2, 3, 5, 7, 10}) X := mat.NewDense(5, 4, []float64{ 1, 1, 10, 1, 1, 2, 8, 0, 1, 3, 9, 1, 1, 4, 7, 1, 1, 5, 6, 0, }) x0 := mat.NewVecDense(4, []float64{1, 2, 1, 2}) w := Gaussian{1}.Weight(X.RawMatrix(), x0.RawVector()) W := mat.NewDiagDense(len(w), w) fmt.Printf(&quot;%v\\n&quot;, w) n, k := X.Dims() XW := mat.NewDense(k, n, make([]float64, k*n)) XW.Mul(X.T(), W) XWY := mat.NewVecDense(k, make([]float64, k)) XWY.MulVec(XW, y) XWX := mat.NewDense(k, k, make([]float64, k*k)) XWX.Mul(XW, X) INV := mat.NewDense(k, k, make([]float64, k*k)) if err := INV.Inverse(XWX); err != nil { panic(err) } beta := mat.NewVecDense(k, make([]float64, k)) beta.MulVec(INV, XWY) fmt.Printf(&quot;%v\\n&quot;, mat.Formatted(beta)) y0 := mat.Dot(beta, x0) fmt.Printf(&quot;%v -&gt; %v\\n&quot;, mat.Formatted(x0.T()), y0) } STL 分解 STL（Seasonal-Trend decomposition using LOESS）是一种使用局部加权回归（LOESS）进行趋势和季节性成分的估计分解算法，相较于经典分解方法，其具有几个显著的优势： 能更好地适应数据中的非线性趋势和季节性变化 支持非整数周期的季节性数据，提供更大的灵活性 对异常值不敏感，可以有效地减少其对分解结果的影响 LOESS 平滑 STL 中的一个重要改进，是使用 LOESS 替换了简单的滑动均值 使其对数据的局部特征更为敏感，避免丢失重要的数据细节 q 邻近权重 使用 $\\lambda$ 表示数据点间距离，并将其按照从近到远的顺序 $\\lambda_1(x), \\cdots, \\lambda_n(x)$ 进行排序 q 邻近距离 $\\lambda_q(x)$ 的定义如下 当 $q \\le n$ 时，$\\lambda_q(x) = \\lambda_n(x)$ 当 $q &gt; n$ 时，$\\lambda_q(x) = \\lambda_n(x)\\frac{q}{n}$ 使用 $\\lambda_q(x)$ 作为三立方权重 tri-cubic 的带宽 $h$，得到 q 邻近权重 q-neighbourhood weight $$w_i(x) = \\begin{cases} \\left[1 - \\left(\\frac{|x - x_i|}{\\lambda_q(x)}\\right)^3\\right]^3, &amp; \\text{if } |x - x_i| &lt; \\lambda_q(x) \\ 0, &amp; \\text{otherwise} \\end{cases}$$ 当 $x_i$ 与 $x$ 距离 $|x_i - x|$ 大于等于 $\\lambda_q(x)$ 时，其权重 $w_i(x) = 0$ 当 $x_i$ 与 $x$ 距离 $|x_i - x|$ 小于 $\\lambda_q(x)$ 时，其权重 $w_i(x) &gt; 0$ d 阶多项式拟合 使用 q 计算出权重后，接下来需要使用权重拟合曲线 根据数据的特性不同，可以选择不同阶数的多项式进行拟合 当曲线较为平缓时，使用 1 阶多项式实现 局部线性 locally-linear 拟合 当曲线较为显著时，使用 2 阶多项式实现 局部二次 locally-quadratic 拟合 STL 通常使用 1 阶多项式拟合 整体设计 STL 分为两层循环 内循环：使用 LOESS 平滑更新季节成分与趋势成分 外循环：执行内循环 + 更新稳健权重 robustness weight 稳健权重用于保证内循环的鲁棒性，减少瞬时异常值的对季节成分与趋势成分的影响 此外，STL 是一种加性分解，但可以通过 Log 或 Box-Cox 对原数据进行转换，实现乘性分解 算法参数 周期长度 时间序列会按照周期长度 $n_{(p)}$ 拆分为多个周期子序列 Cycle-subseries LOESS 平滑参数 $n_{(s)}$ 季节成分的平滑参数 $n_{(t)}$ 趋势成分的平滑参数 $n_{(\\ell)}$ 低通滤波的平滑参数 迭代次数 $n_{(i)}$ 内循环次数 $n_{(o)}$ 外循环次数 内循环 第一次执行内循环时，需要初始化趋势成分 $T^{(0)} = 0$ 执行完第 $k$ 次内循环时，对应的季节与趋势成分为 $S^{(k)}$ 与 $T^{(k)}$ 紧接着第 $k+1$ 次内循环开始： Detrending 去趋势 计算去趋势序列 $Y-T^{(k)}$ Cycle-subseries smoothing 周期子序列平滑 使用参数为 $q=n_{(s)}, d=1$ 的 loess 平滑去趋势序列 $Y-T^{(k)}$ 平滑后的周期子序列组成临时季节序列 $C^{(k+1)}$ Low-Pass filtering of smoothed Cycle-subseries 对平滑周期子序列进行低通滤波 对 $C^{(k+1)}$ 进行低通滤波得到低频成分 $L^{(k+1)}$ 2 次步长为 $n_{(p)}$ 的滑动平均 1 次步长为 3 的滑动平均 1 次参数为 $q=n_{(\\ell)}, d=1$ 的 loess 平滑 Detrending of smoothed Cycle-subseries 对平滑周期子序列去趋势 减去的低频成分得到纯净的季节成分 $S^{(k+1)} = C^{(k+1)} - L^{(k+1)}$ Deseasonalizing 去季节性 计算去季节序列 $Y-S^{(k+1)}$ Trend Smoothing 趋势平滑 使用参数为 $q=n_{(t)}, d=1$ 的 loess 平滑去季节序列 $Y-S^{(k+1)}$ 得到趋势成分 $T^{(k+1)}$ 外循环 执行完内循环后，可以根据季节成分 $S$ 与趋势成分 $T$ 计算剩余成分 $R = Y - T - S$ 对于离群点，其剩余成分的绝对值 $|R|$ 必然很大 设置 $h = 6 \\text{median}(|R|)$ 为离群点阈值，并将其作为双平方权重的带宽 可以得到稳定权重函数 robustness weight $$\\rho_i = \\begin{cases} \\left[1 - \\left(\\frac{|R_i|}{h}\\right)^2\\right]^2, &amp; \\text{if } |R_i| &lt; h \\ 0, &amp; \\text{otherwise} \\end{cases}$$ 当 $x_i$ 的剩余成分 $|R_i|$ 大于等于 $h$ 时，将其作为离群值处理，设置其权重 $\\rho_i$ 为 0 算法实现 https://github.com/statsmodels/statsmodels/blob/main/statsmodels/tsa/stl/_stl.pyx 参考了原论文中 Fortran 的实现，没有实现全量 LOESS，而是通过 LOESS + 线性插值的方式提升计算效率 package main import ( &quot;errors&quot; &quot;fmt&quot; &quot;math&quot; &quot;slices&quot; ) func main() { co2 := []float64{315.58, 316.39, 316.79, 317.82, 318.39, 318.22, 316.68, 315.01, 314.02, 313.55, 315.02, 315.75, 316.52, 317.1, 317.79, 319.22, 320.08, 319.7, 318.27, 315.99, 314.24, 314.05, 315.05, 316.23, 316.92, 317.76, 318.54, 319.49, 320.64, 319.85, 318.7, 316.96, 315.17, 315.47, 316.19, 317.17, 318.12, 318.72, 319.79, 320.68, 321.28, 320.89, 319.79, 317.56, 316.46, 315.59, 316.85, 317.87, 318.87, 319.25, 320.13, 321.49, 322.34, 321.62, 319.85, 317.87, 316.36, 316.24, 317.13, 318.46, 319.57, 320.23, 320.89, 321.54, 322.2, 321.9, 320.42, 318.6, 316.73, 317.15, 317.94, 318.91, 319.73, 320.78, 321.23, 322.49, 322.59, 322.35, 321.61, 319.24, 318.23, 317.76, 319.36, 319.5, 320.35, 321.4, 322.22, 323.45, 323.8, 323.5, 322.16, 320.09, 318.26, 317.66, 319.47, 320.7, 322.06, 322.23, 322.78, 324.1, 324.63, 323.79, 322.34, 320.73, 319, 318.99, 320.41, 321.68, 322.3, 322.89, 323.59, 324.65, 325.3, 325.15, 323.88, 321.8, 319.99, 319.86, 320.88, 322.36, 323.59, 324.23, 325.34, 326.33, 327.03, 326.24, 325.39, 323.16, 321.87, 321.31, 322.34, 323.74, 324.61, 325.58, 326.55, 327.81, 327.82, 327.53, 326.29, 324.66, 323.12, 323.09, 324.01, 325.1, 326.12, 326.62, 327.16, 327.94, 329.15, 328.79, 327.53, 325.65, 323.6, 323.78, 325.13, 326.26, 326.93, 327.84, 327.96, 329.93, 330.25, 329.24, 328.13, 326.42, 324.97, 325.29, 326.56, 327.73, 328.73, 329.7, 330.46, 331.7, 332.66, 332.22, 331.02, 329.39, 327.58, 327.27, 328.3, 328.81, 329.44, 330.89, 331.62, 332.85, 333.29, 332.44, 331.35, 329.58, 327.58, 327.55, 328.56, 329.73, 330.45, 330.98, 331.63, 332.88, 333.63, 333.53, 331.9, 330.08, 328.59, 328.31, 329.44, 330.64, 331.62, 332.45, 333.36, 334.46, 334.84, 334.29, 333.04, 330.88, 329.23, 328.83, 330.18, 331.5, 332.8, 333.22, 334.54, 335.82, 336.45, 335.97, 334.65, 332.4, 331.28, 330.73, 332.05, 333.54, 334.65, 335.06, 336.32, 337.39, 337.66, 337.56, 336.24, 334.39, 332.43, 332.22, 333.61, 334.78, 335.88, 336.43, 337.61, 338.53, 339.06, 338.92, 337.39, 335.72, 333.64, 333.65, 335.07, 336.53, 337.82, 338.19, 339.89, 340.56, 341.22, 340.92, 339.26, 337.27, 335.66, 335.54, 336.71, 337.79, 338.79, 340.06, 340.93, 342.02, 342.65, 341.8, 340.01, 337.94, 336.17, 336.28, 337.76, 339.05, 340.18, 341.04, 342.16, 343.01, 343.64, 342.91, 341.72, 339.52, 337.75, 337.68, 339.14, 340.37, 341.32, 342.45, 343.05, 344.91, 345.77, 345.3, 343.98, 342.41, 339.89, 340.03, 341.19, 342.87, 343.74, 344.55, 345.28, 347, 347.37, 346.74, 345.36, 343.19, 340.97, 341.2, 342.76, 343.96, 344.82, 345.82, 347.24, 348.09, 348.66, 347.9, 346.27, 344.21, 342.88, 342.58, 343.99, 345.31, 345.98, 346.72, 347.63, 349.24, 349.83, 349.1, 347.52, 345.43, 344.48, 343.89, 345.29, 346.54, 347.66, 348.07, 349.12, 350.55, 351.34, 350.8, 349.1, 347.54, 346.2, 346.2, 347.44, 348.67} stl, err := NewSTL(12, true, nil, nil, nil) if err != nil { panic(err) } season, trend, residual, weight := stl.Fit(co2) fmt.Printf(&quot;%v\\n\\n&quot;, season) fmt.Printf(&quot;%v\\n\\n&quot;, trend) fmt.Printf(&quot;%v\\n\\n&quot;, residual) fmt.Printf(&quot;%v\\n\\n&quot;, weight) } type stlSmooth struct { len, deg, jmp int } type stlCtx struct { useRW bool value, season, trend, robust []float64 work [5][]float64 } type STL struct { period int robust bool seasonal, trend, lowPass stlSmooth } func NewSTL( period int, robust bool, seasonal, trend, lowPass *stlSmooth) (*STL, error) { if period &lt; 2 { return nil, errors.New(&quot;period must be at least 2&quot;) } if seasonal == nil { seasonal = &amp;stlSmooth{7, 1, 1} } if seasonal.len &lt; 3 || seasonal.len%2 == 0 { return nil, errors.New(&quot;seasonal.len must be an odd number greater than 3&quot;) } else if seasonal.jmp &lt; 0 { return nil, errors.New(&quot;seasonal.jmp must be a positive number&quot;) } if trend == nil { t := int(math.Ceil(1.5 * float64(period) / (1 - 1.5/float64(seasonal.len)))) trend = &amp;stlSmooth{t + (1 - t%2), 1, 1} } if trend.len &lt; 3 || trend.len%2 == 0 || trend.len &lt;= period { return nil, errors.New(&quot;trend.len must be an odd number greater than 3 and period&quot;) } else if seasonal.jmp &lt; 0 { return nil, errors.New(&quot;trend.jmp must be a positive number&quot;) } if lowPass == nil { l := period + 1 lowPass = &amp;stlSmooth{l + (1 - l%2), 1, 1} } if lowPass.len &lt; 3 || lowPass.len%2 == 0 || lowPass.len &lt;= period { return nil, errors.New(&quot;lowPass.len must be an odd number greater than 3 and period&quot;) } else if seasonal.jmp &lt; 0 { return nil, errors.New(&quot;lowPass.jmp must be a positive number&quot;) } return &amp;STL{ period, robust, *seasonal, *trend, *lowPass, }, nil } func (stl *STL) Fit(y []float64) ( season, trend, residual, weight []float64) { var innerIter, outerIter int if stl.robust { innerIter, outerIter = 2, 15 } else { innerIter, outerIter = 5, 0 } n := len(y) var work [5][]float64 for i := 0; i &lt; len(work); i++ { // reserve 2 x p space // temporary seasonal series C range [-p+1, n+p] work[i] = make([]float64, n+2*stl.period) } ctx := &amp;stlCtx{ false, y, make([]float64, n), make([]float64, n), make([]float64, n), work, } for i := 0; i &lt; n; i++ { ctx.robust[i] = 1 } for k := 0; ; k++ { stl.decompose(ctx, innerIter) if k+1 &gt; outerIter { break } stl.robustWeight(ctx) ctx.useRW = true } residual = make([]float64, n) for i := 0; i &lt; n; i++ { residual[i] = ctx.value[i] - ctx.season[i] - ctx.trend[i] } return ctx.season, ctx.trend, residual, ctx.robust } // _onestp func (stl *STL) decompose(ctx *stlCtx, innerIter int) { y, trend, season, work := ctx.value, ctx.trend, ctx.season, ctx.work for n, j := len(ctx.value), 0; j &lt; innerIter; j++ { for i := 0; i &lt; n; i++ { work[0][i] = y[i] - trend[i] // step-1 detrending: work[0] = Y - T } stl.cycleSubSeries(ctx) // step-2 smoothing cycle-sub-series : work[1] = C = CycleSubSeries(work[0]) stl.lowPassFilter(ctx) // step-3 low-pass filtering cycle-sub-series: work[0] = L = LowPassFilter(work[1]) for i := 0; i &lt; n; i++ { // step-4 detrending cycle-sub-series : S = C - L = work[1] - work[0] season[i] = work[1][stl.period+i] - work[0][i] // step-5 deseasonalizing : work[0] = Y - S work[0][i] = y[i] - season[i] } // step-6 trend smoothing : T = smooth(work[0]) stl.trend.smooth(work[0], n, ctx.useRW, ctx.robust, trend, work[2]) } } // _ss func (stl *STL) cycleSubSeries(ctx *stlCtx) { n, period, work, weight := len(ctx.value), stl.period, ctx.work, ctx.robust deTrend, cycle, work1, work2, work3, work4 := work[0], work[1], work[2], work[3], work[4], ctx.season for j := 0; j &lt; period; j++ { k := (n-(j+1))/period + 1 for i := 0; i &lt; k; i++ { work1[i] = deTrend[i*period+j] } if ctx.useRW { for i := 0; i &lt; k; i++ { work3[i] = weight[i*period+j] } } stl.seasonal.smooth(work1, k, ctx.useRW, work3, work2[1:], work4) right := min(stl.seasonal.len, k) work2[0] = stl.seasonal.loess(work1, k, 0, 1, right, work4, ctx.useRW, work3) if math.IsNaN(work2[0]) { work2[0] = work2[1] } left := max(1, k-stl.seasonal.len+1) work2[k+1] = stl.seasonal.loess(work1, k, k+1, left, k, work4, ctx.useRW, work3) if math.IsNaN(work2[k+1]) { work2[k+1] = work2[k] } for m := 0; m &lt; k+2; m++ { cycle[m*period+j] = work2[m] } } } // _fts func (stl *STL) lowPassFilter(ctx *stlCtx) { n, period, work := len(ctx.value)+2*stl.period, stl.period, ctx.work movingAvg(work[1], n, period, work[2]) movingAvg(work[2], n-period+1, period, work[0]) movingAvg(work[0], n-2*period+2, 3, work[2]) stl.lowPass.smooth(work[2], len(ctx.value), false, work[3], work[0], work[4]) } // _rwts func (stl *STL) robustWeight(ctx *stlCtx) { y, n, trend, season, weight := ctx.value, len(ctx.value), ctx.trend, ctx.season, ctx.robust for i := 0; i &lt; n; i++ { weight[i] = math.Abs(y[i] - trend[i] - season[i]) } sorted := ctx.work[0][:n] copy(sorted, weight) slices.Sort(sorted) a, b := sorted[n/2], sorted[n-(n/2)-1] c := 3.0 * (a + b) // outlier threshold = 6 * median if c == 0 { for i := 0; i &lt; n; i++ { weight[i] = 1 } } else { c1, c9 := .001*c, .999*c for i, w := range weight { if w &lt;= c1 { weight[i] = 1 } else if w &lt;= c9 { w /= c w2 := 1 - (w * w) weight[i] = w2 * w2 } else { weight[i] = 0 // outlier } } } } func movingAvg(x []float64, n, step int, avg []float64) { v, s := 0.0, float64(step) for i := 0; i &lt; step; i++ { v += x[i] } avg[0] = v / s for j, k, m := 1, step, 0; j &lt; n-step+1; j++ { v += x[k] - x[m] avg[j] = v / s k, m = k+1, m+1 } } // _ess func (smooth *stlSmooth) smooth(y []float64, n int, useRW bool, robust, ys, tmp []float64) { if n &lt; 2 { ys[0] = y[0] return } // smooth below positions with LOESS: // 1, 1+1*jmp, 1+2*jmp, 1+3*jmp ..., N jmp := min(smooth.jmp, n-1) var left, right int if smooth.len &gt;= n { left, right = 1, n for i := 0; i &lt; n; i += jmp { ys[i] = smooth.loess(y, n, i+1, left, right, tmp, useRW, robust) if math.IsNaN(ys[i]) { ys[i] = y[i] } } } else if jmp == 1 { nsh := (smooth.len + 2) / 2 left, right = 1, smooth.len for i := 0; i &lt; n; i++ { if (i+1) &gt; nsh &amp;&amp; right != n { left, right = left+1, right+1 } ys[i] = smooth.loess(y, n, i+1, left, right, tmp, useRW, robust) if math.IsNaN(ys[i]) { ys[i] = y[i] } } } else { nsh := (smooth.len + 1) / 2 for i := 0; i &lt; n; i += jmp { if (i + 1) &lt; nsh { left, right = 1, smooth.len } else if (i + 1) &gt;= (n - nsh + 1) { left, right = n-smooth.len+1, n } else { left, right = i+1-nsh+1, smooth.len+i+1-nsh } ys[i] = smooth.loess(y, n, i+1, left, right, tmp, useRW, robust) if math.IsNaN(ys[i]) { ys[i] = y[i] } } } if jmp == 1 { return // all positions are smoothed by LOESS } // other position is smoothed by linear interpolation for i := 0; i &lt; (n - jmp); i += jmp { delta := (ys[i+jmp] - ys[i]) / float64(jmp) for j := i; j &lt; i+jmp; j++ { ys[j] = ys[i] + delta*float64((j+1)-(i+1)) } } // make sure position N is smoothed by LOESS k := ((n-1)/jmp)*jmp + 1 if k != n { ys[n-1] = smooth.loess(y, n, n, left, right, tmp, useRW, robust) if math.IsNaN(ys[n-1]) { ys[n-1] = y[n-1] } if k != (n - 1) { delta := (ys[n-1] - ys[k-1]) / float64(n-k) for j := k; j &lt; n; j++ { ys[j] = ys[k-1] + delta*float64((j+1)-k) } } } } // _est func (smooth *stlSmooth) loess(y []float64, n int, x0, left, right int, w []float64, useWeight bool, weight []float64) float64 { // calculate q-neighbourhood weight for point x0 h := max(x0-left, right-x0) if smooth.len &gt; n { h += (smooth.len - n) / 2 } ws := 0.0 h1, h9 := .001*float64(h), .999*float64(h) for j := left - 1; j &lt; right; j++ { w[j] = 0 r := math.Abs(float64(j + 1 - x0)) if r &lt;= h9 { // distance &lt; q-neighbourhood if r &lt;= h1 { w[j] = 1 } else { u := r / float64(h) u3 := 1 - (u * u * u) w[j] = u3 * u3 * u3 } if useWeight { w[j] *= weight[j] // apply robust weight to ignore outlier } ws += w[j] } } if ws &lt;= 0 { // loess weight sum &lt;= 0, can&#39;t smooth position x0, ignore... return math.NaN() } for j := left - 1; j &lt; right; j++ { w[j] /= ws // normalize loess weight } // deg=0 : constant only // deg=1 : constant &amp; trend if h &gt; 0 &amp;&amp; smooth.deg &gt; 0 { a := 0.0 // weighted distance for j := left - 1; j &lt; right; j++ { a += w[j] * float64(j+1) } b := float64(x0) - a c := 0.0 for j := left - 1; j &lt; right; j++ { v := float64(j+1) - a c += w[j] * v * v } rng := .001 * float64(n-1) if math.Sqrt(c) &gt; rng { b /= c for j := left - 1; j &lt; right; j++ { w[j] *= b*(float64(j+1)-a) + 1.0 } } } ys := 0.0 for j := left - 1; j &lt; right; j++ { ys += w[j] * y[j] } return ys } MSTL 分解 MSTL 是一种稳健、准确的季节性趋势分解算法，用于处理具有多个季节性周期的时间序列 与其他替代方案相比，MSTL 具有更高的计算效率，可以处理海量的时间序列数据 算法参数 periods 指定季节成分的数量 windows 每个季节成分的粒度，粒度越小季节成分变化越快，粒度越大季节成分变化越快 lambda 可选的 BoxCox 系数 算法流程如下 填充缺失数据 对数据进行 boxcox 转换（可选） 重复执行 STL 分解，从数据中提取季节成分 返回季节成分与最后一次分解得到的趋势成分 package main import ( &quot;errors&quot; &quot;fmt&quot; &quot;golang.org/x/exp/rand&quot; &quot;gonum.org/v1/gonum/stat/distuv&quot; &quot;math&quot; &quot;sort&quot; ) func main() { t := make([]float64, 1000) norm := distuv.Normal{Mu: 0, Sigma: 1, Src: rand.NewSource(0)} for i := range t { v := float64(i + 1) trend := 0.0001*v*v + 100. dailySeason := 5 * math.Sin(2*math.Pi*v/24) weeklySeason := 10 * math.Sin(2*math.Pi*v/(24*7)) noise := norm.Rand() t[i] = trend + dailySeason + weeklySeason + noise } stl, err1 := NewSTL(2, false, nil, nil, nil) if err1 != nil { panic(err1) } mstl, err2 := NewMSTL([]int{24, 24 * 7}, nil, math.NaN(), 0, stl) if err2 != nil { panic(err2) } season, trend, residual, weight := mstl.Fit(t) fmt.Printf(&quot;%v\\n\\n&quot;, season) fmt.Printf(&quot;%v\\n\\n&quot;, trend) fmt.Printf(&quot;%v\\n\\n&quot;, residual) fmt.Printf(&quot;%v\\n\\n&quot;, weight) } type MSTL struct { stl STL season [][2]int iterate int lambda float64 } func NewMSTL( periods, windows []int, lambda float64, iterate int, stl *STL) (*MSTL, error) { if periods == nil { return nil, errors.New(&quot;periods is required&quot;) } if windows == nil { windows = make([]int, len(periods)) for i := 0; i &lt; len(windows); i++ { windows[i] = 7 + 4*(i+1) } } if len(periods) != len(windows) { return nil, errors.New(&quot;periods and windows must have same length&quot;) } var season [][2]int for i := 0; i &lt; len(periods); i++ { season = append(season, [2]int{periods[i], windows[i]}) } sort.SliceStable(season, func(i, j int) bool { a, b := season[i], season[j] if a[0] == b[0] { return a[1] &lt; b[1] } else { return a[0] &lt; b[0] } }) if iterate &lt;= 0 { iterate = 2 } return &amp;MSTL{*stl, season, iterate, lambda}, nil } func (mstl *MSTL) Fit(y []float64) ( season [][]float64, trend, residual, weight []float64) { for i, half := 0, len(y)/2; i &lt; len(mstl.season); i++ { period := mstl.season[i][0] if period &gt;= half { panic(&quot;a period(s) is larger than half the length of time series&quot;) } } deSeason := make([]float64, len(mstl.season)) copy(deSeason, y) if math.IsNaN(mstl.lambda) { //TODO: BoxCox} n := len(y) season = make([][]float64, len(mstl.season)) for it := 0; it &lt; mstl.iterate; it++ { for i := range mstl.season { if s := season[i]; s != nil { for j := 0; j &lt; n; j++ { deSeason[j] += s[j] } } period, window := mstl.season[i][0], mstl.season[i][1] seasonSmooth := stlSmooth{window, mstl.stl.seasonal.deg, mstl.stl.seasonal.jmp} stl, err := NewSTL(period, mstl.stl.robust, &amp;seasonSmooth, &amp;mstl.stl.trend, &amp;mstl.stl.lowPass) if err != nil { panic(err) } season[i], trend, residual, weight = stl.Fit(deSeason) for j, s := 0, season[i]; j &lt; n; j++ { deSeason[j] += s[j] } } } for i := 0; i &lt; n; i++ { residual[i] = deSeason[i] - trend[i] } return season, trend, residual, weight } "},{"slug":"how-statistics-works","title":"统计学基础知识","tags":["统计"],"content":"统计学是机器学习 (ML) 和人工智能 (AI) 的核心基石。它提供了理解数据、发现模式和量化不确定性的基本工具。 从数据的预处理、模型训练到评估，ML 和 AI 的每一个环节都离不开统计学，它为这些技术提供了理论支撑和方法论指导。 基本概念 相关术语 样本空间（Ω） 是指一个随机实验所有可能结果的完整集合： 抛掷一个骰子： $\\Omega_{\\text{dice}}={1,2,3,4,5,6}$ 磁盘故障间隔： $\\Omega_{\\text{MTBF}}=[0,∞)$ 服务每秒请求： $\\Omega_{\\text{QPS}}={1,2,3,...,∞}$ 日内股价变化： $\\Omega_{\\text{return}}=[-100%,∞)$ 随机变量（X : Ω → R） 是一个函数，它将样本空间中的每一个基本结果映射到一个实数： 抛骰子结果的点数为 5 ： $X_{\\text{dice}} = 5$ 磁盘持续运行超过 10 万小时不发生故障 ： $X_{\\text{MTBF}} &gt; 100\\text k$ 服务的在某段时间的 QPS 为 10k ： $X_{\\text{QPS}} = 10\\text k$ 股价下跌 ：$X_{\\text{return}} &lt; 0$ 事件 是样本空间的一个子集，用于描述我们感兴趣的结果及其对应的概率： “掷骰子得到偶数” ${2,4,6} \\supset\\Omega_{\\text{dice}}$ $P(X\\text{ is odd})$ “掷骰子得到小于3的点” ${1,2} \\supset\\Omega_{\\text{dice}}$ $P(X&lt;3)$ “磁盘持续无故障运行超过 10 万小时” $[100\\text k,∞) \\supset\\Omega_{\\text{dice}}$ $P(X &gt; 100\\text k)$ “磁盘在运行 5 万到 8 万小时后发生故障” $[50\\text k,80\\text k] \\supset\\Omega_{\\text{dice}}$ $P(50\\text k ≤ X ≤ 80\\text k)$ 概率分布 样本空间包含了所有可能性，因此整个样本空间的概率之和为 $1$。事件是样本空间的一个子集，因此某个事件发生的概率范围为 $[0,1]$。 根据样本空间是否可数，可以将随机变量分为两类： 离散型随机变量：样本空间是可数的，可以计算某个样本的概率例如：$P(X_{\\text{dice}} = 5) = 1/6$ 连续型随机变量：样本空间是不可数的，只能计算某个区间的概率例如：$P(X_{\\text{return}} &lt; 0) = 50%$ 当样本空间由可数样本点 ω 组成时 ，某个事件（子集）发生的概率计算方式如下： 遍历子集中的每个样本点 $\\omega$ 计算每个样本点对应的概率 $P(\\omega)$ 对所有样本点的概率求和 $∑P(\\omega)$ 但以上朴素的事件概率计算公式仅适用于离散型变量，不适用于连续性随机变量。为了统一事件概率的计算方式，我们需引入概率分布的概念。 概率分布是一组函数，描述了一个随机变量 $X$ 在各个可能取值上出现的概率。当使用随机变量表示事件时，我们可以通过概率分布计算事件发生的概率。 概率分布本质上是一些日常生活中总结出的规律： 假设骰子质地分布均匀，掷骰子的结果服从 均匀分布$X_{\\text{dice}} \\sim \\text{Uniform}(a,b)$， 其中 a 与 b 分别对应最大与最小点数 假设故障率与使用时间无关，磁盘故障间隔服从 指数分布\\$X_{\\text{MTBF}} \\sim \\text{Exp}(\\lambda)$， 其中 λ 是平均故障率 假设每个请求之间互不相关，服务的 QPS 服从 泊松分布$X_{\\text{QPS}} \\sim \\text{Poisson}(\\lambda)$， 其中 λ 是 QPS 均值 假设股价变化不可预测（随机游走），对数股价变化率服从 正态分布$\\ln(X_{\\text{return}}) ~ \\text{Normal}(\\mu,\\sigma)$， 其中 μ 是昨收价，σ 是波动率，其中：$\\ln(X_{\\text{return}}) =\\ln (S_{t+1}/S_t) = \\ln S_{t+1} − \\ln S_t = ε_{t+1} \\sim \\text{Normal}(\\mu,\\sigma) $ 分布函数 为了准确描述随机变量的概率分布，需要借助 3 个核心函数 概率质量函数 $p(x)$（Probability Mass Function，简称 PMF） 描述离散随机变量在特定取值上的概率的函数： $p_{X_{\\text{dice}}}(x) = \\text{Uniform}(1,6)$ $p_{X_{\\text{QPS}}}(x) = \\text{Poisson}(10)$ 概率密度函数 $f(x)$（Probability Density Function，简称 PDF） 描述连续随机变量在特定取值点附近的可能性的函数： $f_{X_{\\text{MTBF}}} = \\text{Exp}(1/10000) $ $f_{X_{\\text{log-return}}} = \\text{Normal}(100,0.1)$ 累积分布函数 $F(x)$（Cumulative Distribution Function，简称 CDF） 描述随机变量取值小于或等于某个特定值的概率的函数： $P(X≤x)=F_X(x)$ 随机变量 $X$ 小于或等于 $x$ 的概率 $P(X&gt;x)=1−F_X(x)$ 随机变量 $X$ 大于 $x$ 的概率 $P(a&lt;X≤b)=F_X(b)-F_X(a)$ 随机变量 $X$ 取值落在区间 $(a,b]$ 的概率 其中，CDF 的逆函数 $F_X^{-1}(x)$ 被称为 逆累积分布函数（Inverse CDF） 或 百分位函数（Quantile Function）。用于计算随机变量 $X$ 小于某个特定概率 $p$ 时的取值 $x = F_X^{-1}(p) \\to P(X≤x) = p$。虽然其并不直接描述概率分布，但在假设检验中扮演重要角色。 概率性质 在实际应用中，我们主要考察随机变量的两个核心性质： 期望（Expectation）：随机变量所有可能取值与其对应概率的乘积之和（加权均值） 连续型随机变量：$E(X)=∫xf(x)dx$ 离散型随机变量：$E(X)=∑_ix_ip(x_i)$ 方差（Variance）：随机变量所有可能取值与期望之间的平均距离（不确定性） 连续型随机变量：$Var(X)=E[(X−E(X))^2]=∫(x−μ)^2f(x)dx$ 离散型随机变量：$Var(X)=E[(X−E(X))^2]=∑(x_i−μ)^2 p(x_i)$ 如果某个随机变量的概率分布已知，则对应的方差与均值可以直接由概率分布给出。 以 $\\text{Uniform}(a,b)$ 为例，其期望与方差由以下公式给出： $E(X)=\\frac{a+b}2 $ $Var(X)=\\frac{(b-a+1)^2-1}{12}$ 因此随机变量 $X_\\text{dice} \\sim \\text{Uniform}(1,6)$ 的期望与方差为： $E(X_\\text{dice})=\\frac{1+6}2=3.5$ $Var(X_\\text{dice})=\\frac{(6-1+1)^2-1}{12}=3$ 抽样调查 在实践中，我们需要处理以下情况： 随机变量的概率分布未知 样本空间过大导致无法普查 为了研究随机变量的性质，只能从 总体（Population） 中抽取 样本（Sample） 进行研究： 总体均值：$$\\mu = \\frac{1}{N}\\sum^N_ix_i$$ 总体方差：$$\\sigma^2 = \\frac{1}{N}\\sum^n_i(x_i -\\mu)^2$$ 样本均值：$$\\bar{x} = \\frac{1}{n}\\sum^n_ix_i$$ 样本方差（有偏估计）：$$\\hat{\\sigma^2} = \\frac{1}{n}\\sum^n_i(x_i -\\bar{x})^2$$ 样本方差（无偏估计）：$$s^2 = \\frac{1}{n-1}\\sum^n_i(x_i -\\bar{x})^2$$ 抽样调查的目的是为了得到一个尽可能接近总体 $\\mu,\\sigma^2$ 的估计值 $\\bar x,s^2$。当抽样次数 $m$ 足够多时，样本均值能够逼近总体均值 ：$\\mu = \\frac{1}{m}\\sum^m_{i}X_i\\ (m \\to \\infty)$。 总体的数量 $N$ 的数量是一个很大的值，而 $n$ 则是一次抽样调查中的样本数量。当 $n \\ll N $ 时，抽中极端值的概率较小，从而低估方差，导致 $\\hat{\\sigma^2}$ 偏小。当样本数量较少时，通常使用无偏估计 $s^2$ 作为 $\\sigma^2$ 近似。 正态分布 中心极限定理 借助随机变量的概念，我们可以通过以下方式对样本数据进行研究： 假设总体属于某个特定分布 根据样本估计该分布的参数 基于概率分布函数进行分析 当没有足够的先验知识时，通常会假设随机变量服从正态分布 该假设的理论基石是统计学中的 渐近正态性（Asymptotic Normality）： 在样本量趋于无穷大时，随机变量的分布趋近于正态分布 渐近正态性的一个重要特例是 中心极限定理（Central Limit Theorem, CLT）： 对于服从任意分布的的随机变量，只要抽样次数足够多，其均值总会服从正态分布 为了方便大家理解，这里提供了一个小实验，大家可以动手调整采样次数，并观察分布变化： 均匀分布抽样 指数分布抽样 渐近正态性与中心极限定理是众多统计学应用的基石： 基于样本均值和标准差，可以构建总体均值的置信区间，即使总体分布未知 这使得我们能够对许多非正态分布的数据进行基于正态分布的统计推断。 中心极限定理的公式化描述如下（这部分可以跳过）： 给定一个服从任意分布的的随机变量 $X$。 使用 $$Y_n$$ 表示对 $X$ 抽样 $n$ 次后的均值 $$Y_n = \\frac{1}{n}\\sum^n_{i=1}X_i$$，其对应的统计量为： 期望 $$E[Y_n] = E[\\frac{1}{n}\\sum^n_{i=1}X_i] = \\frac{1}{n}\\sum^n_{i=1}E[X_i] = \\frac{1}{n}nE[X] = E[X]$$ 方差 $$Var[Y_n] = Var[\\frac{1}{n}\\sum^n_{i=1}X_i] = \\frac{1}{n^2}\\sum^n_{i=1}Var[X_i] = \\frac{1}{n^2}nVar(X) = \\frac{Var(X)}{n}$$ 当 $n &gt; 30$ 时，$$Y_n$$ 服从正态分布 $$Y_n \\sim \\mathcal{N}(E[X],\\frac{Var(X)}{n})$$，标准化后 $$\\frac{Y_n-E[X]}{\\sqrt{Var(X)/n}} \\sim \\mathcal{N}(0,1)$$。 正态分布 正态分布（Normal Distribution） 是概率论和统计学中最重要、最常见的连续概率分布之一。它的概率密度函数曲线呈现出独特的钟形曲线（Bell Curve）。大多数数据点都集中在均值附近，距离均值越远的数据点，其出现的概率越低。 正态分布由两个参数完全决定： 均值（Mean，μ）：决定了分布的中心位置 标准差（Standard Deviation，σ）：决定了分布的“宽度”或分散程度 标准差越大，数据越分散，钟形曲线就越扁平、越宽 标准差越小，数据越集中，钟形曲线就越高耸、越窄 正态分布衍生出了 3 个统计学中常用的分布： Z 分布 任何一个正态分布随机变量 $X$ 都可以转化为标准正态分布 $Z = \\frac{(X−μ)}σ \\sim \\mathcal N(0,1)$ 该随机变量的取值被称为 z-score ，表示原始数据点 X 距离其均值 μ 有多少个 σ 的距离 后续我们将基于 z-score 构建置信区间 卡方分布 假设 $Z_1,...Z_k$ 是 $k$ 个相互独立且服从标准正态分布 $\\mathcal N(0,1)$ 的随机变量 那么它们的平方和服从自由度为 $k$ 的卡方分布 $Z_1^2+\\cdots+Z_k ^2 \\sim χ^2(k)$ 通常用于检验多个随机变量间的独立性（取值概率互不相关）与同质性（服从相同分布） t 分布 给定 $Z \\sim N(0,1)$ 与 $V \\sim χ^2(k) $，两者比值服从自由度为 $k$ 的 t 分布 $T=\\frac{Z}{\\sqrt{V/k}} \\sim t(k) $ 主要用于在总体标准差未知且样本量较小时，对正态总体均值进行统计推断 经验法则 正态分布的核心价值在于 经验法则（Empirical Rule） ： 大约 68% 的数据点落在距均值 1 个标准差的范围内（即 $μ±σ$） 大约 95% 的数据点落在距均值 2 个标准差的范围内（即 $μ±2σ$） 大约 99.7% 的数据点落在距均值 3 个标准差的范围内（即 $μ±3σ$） 尽管经验法则只是一个近似，但在许多实际应用中，它足以提供有价值的洞察和决策支持。比如：一个的数据点落在均值 3 个标准差之外（即在 $μ−3σ$ 以下或 $μ+3σ$ 以上）的概率小于 0.3%。因此可以基于 3-sigma 法则快速发现 异常值（Outliers）。 在实际应用中，通常使用 6-sigma 而不是简单的 3-sigma： 3-sigma 意味着 99.73% 的合格率，即每百万次机会 (DPMO) 有 2700 个缺陷 6-sigma 意味着 99.99966% 的合格率，这意味着每百万次机会仅有 3.4 个缺陷 在许多现代工业和服务领域，这个 3-sigma 的缺陷率是远远不能接受的： 医疗领域： 2700‱ 的医疗差错率，将导致大量患者受到伤害 航空航天： 2700‱ 的飞机部件缺陷，将是灾难性的 金融服务： 2700‱ 的交易错误，会造成巨大的经济损失和信任危机 只有 6-sigma 这种近乎零缺陷的追求，才符合当今对质量要求极高的市场环境。 置信区间 统计任务中，通常需要面对以下误差： 通过样本得到的估计均值 $\\bar x$，与总体的实际均值 $\\mu$ 之间存在误差 根据样本估计回归模型参数，估计值$\\bar \\beta$与实际值 $\\beta$ 之间存在误差 基于估计参数进行回归预测，预测值$\\bar y$与实际值$y$之间存在误差 由于总体的不可知性，因此这些误差是未知且无法消除的。 为了衡量某个统计任务的有效性，需要引入 置信区间（Confidence Interval） 的概念： 将估计值看作正态分布的样本均值 $x̄$ 以总体均值 $μ$ 为中心设置一个对称区间 计算样本均值 $x̄$ 落入该区间的概率 置信区间的两侧称为 误差边界（Margin of Error）： 样本均值 $x̄$ 落在误差边界外的概率称为 显著水平（Significance Level），记为 $\\alpha$ 样本均值 $x̄$ 落在误差边界内的概率称为 置信水平（Confidence Level），记为 $1-\\alpha$ 置信水平理解方式有两种： 以 $μ$ 为中心的置信区间，$x̄$ 出现在该区间内的概率 以 $x̄$ 为中心的置信区间，该区间内包含 $μ$ 的概率 在样本数量 $n$ 不变的情况下： 置信水平越高，置信区间越大，包含均值的概率更高，但是与均值的误差更大 置信水平越低，置信区间越小，包含均值的概率更低，但是与均值的误差更小 在置信水平 $1-\\alpha$ 相同的情况下： 样本越多，均值分布的方差越小，样本均值与总体均值的误差越小 样本越少，均值分布的方差越大，样本均值与总体均值的误差越大 置信水平 $1-\\alpha$ 的公式化表述（可以跳过）： 根据 CLT，样本均值$$\\bar{x} $$服从正态分布 $$\\bar{X} \\sim \\mathcal{N}(\\mu,\\frac{\\sigma^2}{n})$$，标准化后得到 $$\\frac{\\bar{X} - \\mu}{\\frac{\\sigma}{\\sqrt{n}}} \\sim \\mathcal{N}(0,1)$$ 计算样本均值 $$\\bar{x} $$与标准差 $$\\frac{\\sigma}{\\sqrt{n}}$$（假设总体标准差 $$\\sigma$$ 已知） 给定置信水平 $$1-\\alpha$$ 根据 $F_Z^{-1}(\\frac{1-\\alpha}2)$ 计算对应的 z-score $$z_{(1-\\alpha)/2}$$ = $$\\frac{\\bar{x} - \\mu}{\\frac{\\sigma}{\\sqrt{n}}} $$ 得到样本均值误差边界 $$\\mu - z_{(1-\\alpha)/2} \\cdot \\frac{\\sigma}{\\sqrt{n}} &lt; \\bar{x} &lt; \\mu + z_{(1-\\alpha)/2} \\cdot \\frac{\\sigma}{\\sqrt{n}}$$ 变换不等式得到 $$\\bar{x} - z_{(1-\\alpha)/2} \\cdot \\frac{\\sigma}{\\sqrt{n}} &lt; \\mu &lt; \\bar{x} + z_{(1-\\alpha)/2} \\cdot \\frac{\\sigma}{\\sqrt{n}}$$ 最终我们认为：有 $$1-\\alpha$$ 的把握，认为均值 $$\\mu$$ 位于区间 $$\\bar{x} \\pm z_{(1-\\alpha)/2} \\cdot \\frac{\\sigma}{\\sqrt{n}}$$。 通常 $$\\frac{\\bar{x} - \\mu}{\\frac{\\sigma}{\\sqrt{n}}}$$ 中的总体标准差 $$\\sigma$$ 无法得到，需要使用样本标准差无偏估计 $s$ 替代。得到的$$\\frac{\\bar{x} - \\mu}{\\frac{s}{\\sqrt{n}}}$$ 不再服从 Z 分布，而是服从自由度为 $n-1$ 的学生 t 分布 $$\\mathcal{t}{n-1}$$。并将统计量替换为 t-score ，得到置信区间 $$\\bar{x} \\pm t{(1-\\alpha)/2} \\cdot \\frac{s}{\\sqrt{n}}$$。 假设检验 假设检验是统计推断中一个非常重要的工具，它允许我们基于样本数据对总体参数做出判断或推论。简单来说，就是根据收集到的样本数据，来判断某个关于总体的假设是否成立。 样本统计量与总体实际值间始终存在误差，假设检验能够帮助我们在不确定性中做出数据驱动的决策。它提供了一个严谨的框架，用于评估观测到的现象是否仅仅是随机波动，还是真实存在的效应。 其核心思想是反证法： 先提出一个假设 $H_0$，然后收集样本数据 计算在该假设成立的情况下，出现样本数据的概率 如果概率非常小，意味着假设和现实不符，从而拒绝该假设 在假设检验中，需要提出两个互斥假设： 原假设 (Null Hypothesis, $H_0$)：通常是研究者希望推翻的声明，或者说是“无效应”、“无差异”的声明 备择假设 (Alternative Hypothesis, $H_1$)：通常是研究者希望证明的声明，与原假设相反 当 $H_0$ 被拒绝时，意味着 $H_1$ 成立 需要注意的是，$H_0$ 与 $H_1$ 均为对未知总体的提出的假设： 只能基于总体设定假设，比如 $$H_0: \\mu = 12$$ 不能根据样本设定假设，比如 $$H_0: \\bar{x} \\ge 12$$ 显著性水平 使用 $H_0$ 表示我们要拒绝的假设： $H_0 = \\text{true}$ 表明假设不成立（假设与现实不符） $H_0 = \\text{false}$ 表明假设成立（假设与现实相符） 当我们进行决策时，可能存在 4 种情况： 拒绝 $H_0$ 且 $H_0 = \\text{true}$（第一类错误） 拒绝 $H_0$ 且 $H_0 = \\text{false}$ (正确) 未拒绝 $H_0$ 且 $H_0 = \\text{true}$（正确） 未拒绝 $H_0$ 且 $H_0 = \\text{false}$（第二类错误） 其中存在两种错误的情况： 第一类错误（Type-1 Error）：拒绝了正确的假设，也称为 假阳性（False Positive） 第二类错误（Type-2 Error）：未拒绝错误的假设，也称为 假阴性（False Negative） 由于抽样本身存在误差，这两类错误都是不可避免的，两者发生的概率分别为： 第一类错误 $$\\alpha = P(\\text{Reject } H_0|H_0)$$ 第二类错误 $$\\beta = P(\\text{Accept } H_0|H_1)$$ 显著性水平 α 用于决定是否应该拒绝 $$H_0$$ 通过设置显著性水平，可以控制这两类错误发生的概率，原理如下： 假设 $H_0$ 基于总体均值 $\\mu$ 进行设定 显著性水平 $\\alpha$ 越低，意味着置信区间 $1-\\alpha$越大 样本的均值 $\\bar x$ 落在置信区间外的概率越低 这意味着需要更强的证据才能拒绝 $H_0$ 调整显著性水平会同时影响两类错误发生的概率： 更低的显著性水平 $\\alpha$，会使得 $H_0$ 更容易被接受，更难以被拒绝。因此可以降低第一类错误风险，但会增大第二类错误风险。 更高的显著性水平 $\\alpha$，会使得 $H_0$ 更容易被拒绝，更难以被接受。因此可以降低第二类错误风险，但会增大第一类错误风险。 假设检验中，另一个需要关注的指标是 检验功效（Power of Test）。其计算方式为 $1-β = P(\\text{Reject } H_0|H_1)$，即： 当 $H_1$ 成立时，成功地拒绝 $H_0$ 的概率 功效越高，检验敏感度越高，越能发现真实存在的统计效应。 检验统计量 设计检验时，需要根据数据的类型、分布、样本大小以及要检验的假设类型，选择合适的统计检验方法，例如： Z检验：当总体标准差 $\\sigma$ 已知且样本量很大时，判断样本均值 $\\bar x$ 与 $H_0$ 中的总体均值 $\\mu$ 是否一致 t检验：当总体标准差 $\\sigma$ 未知或样本量较少时，判断样本均值 $\\bar x$ 与 $H_0$ 中的总体均值 $\\mu$ 是否一致 F检验：通过计算两组样本的方差，判断两者的总体方差是否相等 卡方检验：通过计算卡方统计量，判断两组样本是否服从相同的总体分布 不同的检验方法对应不同的 检验统计量（Test Statistic） 单样本 Z 检验的统计量为 $Z=\\frac{\\bar{x}-\\mu}{\\sigma/\\sqrt n}$ $μ$ 为假设的总体均值 $σ$ 为已知的总体标准差 $\\bar{x}$ 为样本均值 $n$ 为样本量 单样本 t 检验的统计量为 $T=\\frac{\\bar X-\\mu}{s\\sqrt n}$ $μ$ 为假设的总体均值 $\\bar{x}$ 为样本均值 $s$ 为样本标准差 $n$ 为样本量 总体方差 F 检验的统计量为 $F=\\frac{s_1^2}{s_2^2}$ $s_1^2$ 是较大的样本方差 $s_2^2$ 是较小的样本方差 卡方检验的统计量 $\\chi^2=\\sum\\frac{(O_i-E_i)^2}{E_i}$ $E_i$ 为假设的期望频数（Expected Frequency） $O_i$ 为样本的观察频数（Observed Frequency） 可以看到，统计量都是使用随机变量进行表示，这些随机变量都服相应的概率分布。 基于假设与样本计算出统计量后，接下来就是根据显著性水平 $\\alpha$ 来判断是否拒绝 $H_0$。 检验类型 为了方便理解，后续将将基于 Z 均值检验进行介绍。 已知总体分布的方差为 $σ = 3$ ，基于假设 $μ = 66.7$，可以设计 3 类检验： 右尾检验（Right-Tailed Test） $H_0: μ ≤ 66.7$ $H_1: μ &gt; 66.7$ 左尾检验（Left-Tailed Test） $H_0: μ ≥ 66.7$ $H_1: μ &lt; 66.7$ 双尾检验（Two-Tailed Test） $H_0: μ = 66.7$ $H_1: μ ≠ 66.7$ 选择哪种检验类型，取决于你的研究问题和你想在数据中寻找的差异方向： 检验类型 $H_1$ 形式 拒绝域位置 检验目的 右尾检验 参数 &gt; 某个值 右侧 证明参数“增加了”或“大于”某个基准值 左尾检验 参数 &lt; 某个值 左侧 证明参数“减少了”或“小于”某个基准值 双尾检验 参数 ≠ 某个值 两侧 证明参数“不同于”某个基准值，不关心是增大还是减小 得出结论 根据样本计算出检验统计量后，我们有两种方法来做决定： 使用检验统计量计算 p-value，比较 p-value 与显著性水平 $\\alpha$ 根据显著性水平 $\\alpha$ 计算临界值，比较检验统计量与临界值 p-value p-value 代表在 $H_0 = \\text{true}$ 的情况下，观察到当前数据或更极端数据的概率。 p-value 是与预先设定的显著性水平 α 进行比较的： $\\text{p-value} ≤α$ 意味着观测结果在 $H_0 = \\text{true}$ 时非常罕见，这提供了强有力的证据来拒绝原假设 $\\text{p-value} &gt;α$ 意味着观测结果在 $H_0 = \\text{true}$ 时比较常见，这表明没有足够的证据来拒绝原假设 p-value 本质上是一种概率，如果将统计量看作随机变量的取值，则 p-value 可以通过 CDF 进行计算： Z统计量是随机变量 $X \\sim Z$ 的观测值，通过 $F_X(x)$ 可以计算统计量 $x$ 对应的 p-value 卡方统计量是随机变量 $X \\sim \\chi^2$ 的观测值，通过 $F_X(x$) 可以计算统计量 $x$ 对应的 p-value 使用 $X$ 表示假设的总体分布，$x_{\\text{obs}}$ 表示观测到的样本统计量，不同检验类型的 p-value 计算方式为： 左尾检验 $\\text{p-value}=P(X≤x_{\\text{obs}}∣H_0=true)=F_X(x_{\\text{obs}})$ 右尾检验 $\\text{p-value}=P(X≥x_{\\text{obs}}∣H_0=true)=1-F_X(x_{\\text{obs}})$ 对称分布的双尾检验 $\\text{p-value}=2×P(X≥∣x_{\\text{obs}}∣∣H_0=true)=2×F_X(∣x_{\\text{obs}}∣)$ 回到之前 Z 均值检验的例子，基于 p-value 的决策流程如下： 右尾检测：$$H_1: \\mu &gt; 66.7$$ 样本观测值 $\\bar x = 68.442$ 计算统计量 $Z = \\frac{68.442 - 66.7}{3/\\sqrt{10}} = 1.8362$ 计算 CDF $F_Z(1.8362) = 0.9668 $ 右尾 p-value $$P(\\hat{X}&gt;68.442|\\mu=66.7) = 1 - 0.9668 =0.0332$$ 由于 p-value &lt; 0.05，拒绝 $$H_0$$ 是合理的，$$H_1$$成立 双尾检验 $$H_1: \\mu \\ne 66.7$$ 样本观测值 $\\bar x = 68.442$ 计算统计量 $Z = \\frac{68.442 - 66.7}{3/\\sqrt{10}} = 1.8362$ 计算 CDF $F_Z(1.8362) = 0.9668 $ 双尾 p-value 为 $$P(|\\hat{X}-66.7|&gt;|68.442 - 66.7|\\ |\\mu=66.7) = (1-0.9668) * 2 = 0.0663$$ 由于 p-value &gt; 0.05，接受 $$H_0$$ 是合理的，$$H_1$$不成立 左尾检验 $$H_1: \\mu &lt; 64.252$$ 样本观测值 $\\bar x = 64.252$ 计算统计量 $Z = \\frac{64.252 - 66.7}{3/\\sqrt{10}} = −2.581$ 计算 CDF $F_Z(−2.581) = 0.0049 $ 左尾 p-value 为 $$P(\\hat{X}&lt;64.252|\\mu=66.7) = 0.0049 $$ 此时 p-value &lt; 0.05，拒绝 $$H_0$$ 是合理的，$$H_1$$成立 临界值 另一种更直观的形式是比较 临界值（Critical Value）。临界值与统计量具有相同量纲，两者可以直接进行比较，因此更为直观。为了将显著性水平转换为对应的临界值，需要借助 Inverse CDF。 使用 α 表示显著性水平，不同检验类型的临界值计算方式为 左尾检验 $k_{\\alpha}=F^{−1}(α)$ 右尾检验 $k_{\\alpha}=F^{−1}(1-α)$ 对称分布的双尾检验 $k_{\\alpha 2}=F_X^{−1}(α/2),\\ k_{\\alpha 1}=F_X^{−1}(1-\\frac{α}2)$ 只要观测值 $x_{\\text{obs}}$（这里不是统计量）比临界值 $k_{\\alpha}$ 更极端，则我们有足够的理由拒绝 $H_0$： 左尾检验 $x_{\\text{obs}} &lt; k_{\\alpha}$ 右尾检验 $x_{\\text{obs}} &gt; k_{\\alpha}$ 对称分布的双尾检验 $x_{\\text{obs}} &lt; k_{\\alpha 2} \\text{ or } x_{\\text{obs}} &gt; k_{\\alpha 1}$ 回到之前 Z 均值检验的例子，基于 p-value 的决策流程如下： 右尾检测：$$H_1: \\mu &gt; 66.7$$ 样本观测值 $\\bar x = 68.442$ 计算逆 CDF $F_Z^{-1}(1-0.05) = 1.645 $ 将 Z 分布映射至 $\\mathcal N(66.7,3)$ 得到右尾临界值 $k_{\\alpha} = 66.7 + 1.645 \\times (3/\\sqrt{10}) = 68.2607$ 由于 $68.442 &gt; 68.2607$，拒绝 $$H_0$$ 是合理的，$$H_1$$成立 双尾检验 $$H_1: \\mu \\ne 66.7$$ 样本观测值 $\\bar x = 68.442$ 计算逆 CDF $F_{Z 2}^{-1}(0.05/2) = −1.96$ $F_{Z 1}^-1(1-0.05/2) = 1.96$ 将 Z 分布映射至 $\\mathcal N(66.7,3)$ 得到临界值 $k_{\\alpha 2} = 66.7 - |−1.96| \\times (3/\\sqrt{10}) = 64.8406$ $k_{\\alpha 1} = 66.7 + |1.96| \\times (3/\\sqrt{10}) = 68.5594$ 由于 $68.442 \\in [64.8406,68.5594]$，接受 $$H_0$$ 是合理的，$$H_1$$不成立 左尾检验 $$H_1: \\mu &lt; 64.252$$ 样本观测值 $\\bar x = 64.252$ 计算逆 CDF $F_Z^{-1}(0.05) = -1.645 $ 将 Z 分布映射至 $\\mathcal N(66.7,3)$ 得到左尾临界值 $k_{\\alpha} = 66.7 - 1.645 \\times (3/\\sqrt{10}) = 65.1393$ 由于 $64.252 &lt; 65.1393$，拒绝 $$H_0$$ 是合理的，$$H_1$$成立 流程总结 最后总结一下假设检验的基本步骤： 提出互斥假设 选择显著性水平 选择合适的统计检验方法 计算检验统计量 根据对应的 p-value 或临界值得出结论 注意：不拒绝原假设并不意味着原假设是真，只是说没有足够证据证明它是假。 "},{"slug":"you-need-to-know-about-memory","title":"你需要了解的内存知识","tags":["操作系统"],"content":"在操作系统与编译器的帮助下，应用开发者可以在不了解硬件细节的情况编写出高效稳定的代码。 但如果要让硬件资源发挥出极致的性能，程序员有必要深入了解一些硬件架构与实现原理。 在计算机硬件发展的过程中，CPU 与 内存 朝着两个完全相反的方向狂奔。 前者侧重速度，后者则注重容量，这导致了CPU核心与内存总线的频率差距越来越大。 为了弥合两者间的差距，现代硬件架构进行了几项重要的革新： 多级缓存： 位于CPU核心和主内存之间增加多层高速存储器，用于存储了CPU最近访问过的指令和数据副本，以便快速访问。 非对称多处理 (NUMA)： 将内存分为多个独立区块并交由不同的 CPU 核心进行管理，避免内存访问总线频率限制 CPU 访问内存的效率。 缓存 由于程序代码和数据具有时间和空间局部性，相同的代码或数据在短时间内很有可能被重用。 因此现代硬件架构会在 CPU 与内存间引入一个空间有限的多层高速缓存，越靠近 CPU 的缓存通常速度更快，尺寸也更小。 block-beta columns 6 Core0:2 Core1:2 Core2:2 L1D0[&quot;L1 Data&quot;] L1I0[&quot;L1 Inst&quot;] L1D1[&quot;L1 Data&quot;] L1I1[&quot;L1 Inst&quot;] L1D2[&quot;L1 Data&quot;] L1I2[&quot;L1 Inst&quot;] L20[&quot;L2&quot;]:2 L21[&quot;L2&quot;]:2 L22[&quot;L2&quot;]:2 L3:6 Memory:6 classDef core0 fill:#e0eafd,stroke:#777; classDef core1 fill:#fee3df,stroke:#777; classDef core2 fill:#dff5e3,stroke:#777; classDef l3 fill:#fef1ce,stroke:#777; class Core0,L1D0,L1I0,L20 core0 class Core1,L1D1,L1I1,L21 core1 class Core2,L1D2,L1I2,L22 core2 class L3 l3 L1 层缓存由单个 CPU 核心独占，通常会分为相互独立的两部分： 代码缓存：缓存解码后的机器指令 数据缓存：缓存最近访问过的数据 L2 与 L3 则是统一缓存，没有明确的功能划分。 其中 L3 由多个核心共享，而 L2 是否共享取决于具体的 CPU 架构。 缓存一致性 缓存中的最基本的存储单位被称为行，其大小通常为 64 字节： 当 CPU 访问某个内存数据时，会将邻近的数据同时加载到缓存中，以提升访问效率 当 CPU 修改缓存中的数据时，需要将修改同步会内存，可选的策略有两种： write-through：同步更新缓存和内存 write-back：仅将缓存行标记为脏缓存，当其被逐出时才回写内存 block-beta columns 3 space:3 block:cpu1[&quot;CPU A&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] thread1[&quot;Thread A&quot;] end space block:cpu2[&quot;CPU B&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] thread2[&quot;Thread B&quot;] end space:3 block:cache1[&quot;Cache A&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] line11[&quot;Line1&quot;] line12[&quot;Line2&quot;] line13[&quot;Line3&quot;] line14[&quot;Line4&quot;] end space block:cache2[&quot;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;Cache B&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] line21[&quot;Line1&quot;] line22[&quot;Line2&quot;] line23[&quot;Line3&quot;] line24[&quot;Line4&quot;] end space:3 block:memory[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;Memory&quot;]:3 columns 4 A[&quot;64 Byte&quot;] B[&quot;...&quot;] C[&quot;...&quot;] D[&quot;...&quot;] end space:3 thread1 --&gt; line14 thread2 --&gt; line22 C --&gt; line14 C --&gt; line22 classDef title color:#777 classDef core0 fill:#e0eafd,stroke:#777; classDef core1 fill:#fee3df,stroke:#777; classDef core2 fill:#dff5e3,stroke:#777; class cpu1,cpu2,cache1,cache2,memory title class thread1,line14 core0 class thread2,line22 core1 class C core2 虽然 write-back 策略可以有效节省访存带宽，但会引入多副本的并发修改问题： 同一个缓存行在不同 CPU 的独占缓存中对应不同的副本，不同 CPU 可以并发修改这一数据。 因此需要将该缓存行在其他 CPU 缓存中对应的副本也标记为失效，并在稍后重新加载。 为了在多个 CPU 间建立一致的缓存视图，需要引入 缓存一致性协议(cache coherence protocol)。 缓存一致性协议会对同一位置的写操作进行排序，保证所有 CPU 按照相同的顺序观察到该位置的状态变化。 一种较为流行的缓存一致性协议是 MESI，该协议为将缓存行设置以下 4 个状态之一： Modified：仅当前 CPU 的缓存加载了该行，且其内容与内存不一致 Exclusive：仅当前 CPU 的缓存加载了该行，且其内容与内存一致 Shared：同时有多个 CPU 的缓存加载了该行，且其内容与内存一致 Invalid：该缓存行已失效 状态转换如图所示： 初始状态下所有缓存行的状态均为 I 加载数据后其状态变为 S 或 E 当本地 CPU 对其进行修改后其状态变为 M 当其他 CPU 修改其副本后其状态变为 I RFO 区分 S 和 E 主要是出于性能考虑： 当修改处于 E 状态的缓存行时，可以直接将其状态修改为 M 当修改处于 S 状态的缓存行时，需要向其他 CPU 发送 RFO (Request For Ownership) 消息通知其他 CPU 将对应的本地副本状态设置为 I，并将其内容传回 RFO 的发起者，之后才能将缓存行状态修改为 M 下面两种场景会频繁触发 RFO 通信，应该尽可能避免： 在两个不同核心的并行运行的线程，需要访问同一个缓存行数据（比如：线程调度未考虑 CPU 亲和性） 同一个线程在不同 CPU 核心上交替运行，需要在不同 CPU 的本地缓存间移动数据（比如：线程间发生 Cache 伪共享） 多线程应用的通信需要依赖 RFO 实现内存同步。因此其并发度不仅受限于 CPU 核心数，也受限于内存同步引入的通信延迟。 需要仔细设计程序，以最大程度地减少从不同处理器对同一内存位置的访问。 并发修改 值得注意的是，MSEI 的缓存一致性只是对值进行排序，而不是对写操作本身进行排序。 假设某个值为 1 计数器所在内存处于 S 状态，并且两个线程需要同时对其执行递增操作。 CPU 不必等待缓存行变为 E 状态后再从缓存中取值相加，而是会直接取当前缓存中的值 1 相加得到新值 2。 新值会基于 MSEI 协议进行排序，一旦缓存行在 E 状态下可用，新值 2 就会被写入缓存行。 如果这两个线程中的缓存读取同时发生，其中一次递增修改操作将会丢失：发生了两次递增操作，期望值应该是 3，但实际结果是 2。 因此，如果多个线程同时修改同一个内存位置，处理器不保证任何特定的结果。 要解决这一问题，必须依赖 CPU 提供的原子操作指令，这里不展开描述。 特殊地址 某些特殊的地址空间没有实际对应的物理内存，无法适用于此前提及的回写策略，这类空间主要分为两类： 一类被映射到外设内存（比如：显卡显存地址） 一类用于控制外设本身（比如：单片机的LED地址） 前者通常使用 write-combining 策略，当多个连续的写操作完成后，再一次性将缓存行写回外设内存。后者通常使用 uncacheable 策略，这类地址的数据不会经过 CPU 缓存。 性能指引 精简指令 代码量越大对 L1 指令缓存的压力越大，因此不要滥用循环展开与内联，除非其对性能有明显提升。 评估一个函数是否应该内联的依据主要有两个，两者相乘可以评估到内联后代码的体积增长量： 函数体的大小 函数调用次数 对于一些调用次数或者代码量较少的函数，内联往往是有优势的。 但对于一些调用频率很高的小体积函数来说，其代码指令大概率会出现在 L1 缓存中， 如果 L1 内容可以重用并且总体占用空间减少，通常可以弥补额外函数调用的引入的性能开销。 此时可以选择禁用内联来提升缓存的命中率，从而提升整体性能。 gcc 提供了 always_inline 与 noinline 两个编译属性供程序员控制是否内联 void __attribute__((noinline)) my_function(int arg) { // Function body } void __attribute__((always_inline)) my_function(int arg) { // Function body } 消除分支 为了提升处理效率，现代 CPU 会以 流水线(pipeline) 的形式运行：在执行当前指令的同时，预取并解码后续需要执行的指令。 block-beta columns 9 Cycle[&quot;Cycle&quot;]:2 1 2 3 4 5 6 7 Fetch:2 f1[&quot;A&quot;] f2[&quot;B&quot;] f3[&quot;C&quot;] f4[&quot; &quot;] f5[&quot; &quot;] f6[&quot; &quot;] f7[&quot; &quot;] Decode:2 d1[&quot; &quot;] d2[&quot;A&quot;] d3[&quot;B&quot;] d4[&quot;C&quot;] d5[&quot; &quot;] d6[&quot; &quot;] d7[&quot; &quot;] Execute:2 e1[&quot; &quot;] e2[&quot; &quot;] e3[&quot;A&quot;] e4[&quot;B&quot;] e5[&quot;C&quot;] e6[&quot; &quot;] e7[&quot; &quot;] Memory:2 m1[&quot; &quot;] m2[&quot; &quot;] m3[&quot; &quot;] m4[&quot;A&quot;] m5[&quot;B&quot;] m6[&quot;C&quot;] m7[&quot; &quot;] Write:2 w1[&quot; &quot;] w2[&quot; &quot;] w3[&quot; &quot;] w4[&quot; &quot;] w5[&quot;A&quot;] w6[&quot;B&quot;] w7[&quot;C&quot;] classDef empty color:#777,fill:none,stroke:none; classDef o fill:#bcc0c2,stroke:#666c73,stroke-width:2px classDef a fill:#e0eafd,stroke:#537ac5,stroke-width:2px classDef b fill:#fee3df,stroke:#d35f5c,stroke-width:2px classDef c fill:#dff5e3,stroke:#5a996c,stroke-width:2px class Cycle,1,2,3,4,5,6,7 empty class Fetch,Decode,Execute,Memory,Write o class f1,d2,e3,m4,w5 a class f2,d3,e4,m5,w6 b class f3,d4,e5,m6,w7 c 但当代码中出现分支跳转时，但是预取的指令并不一定是实际需要执行的分分支。以下面的代码为例： if (TEST()) // TEST() == false BR1() ... else BR2() ... 分支 BR1 的代码紧跟在条件判断 TEST 后，因此会被预先取到流水线中，但实际需要执行的分支是 BR2。 此时流水线执行会被中断，CPU 只能空转等待 BR2 指令完成加载。 block-beta columns 11 Cycle[&quot;Cycle&quot;]:2 1 2 3 4 5 6 7 8 9 Fetch:2 f1[&quot;TEST&quot;] f2[&quot;BR1&quot;] f3[&quot;...&quot;] f4[&quot;BR2&quot;] f5[&quot;...&quot;] f6[&quot; &quot;] f7[&quot; &quot;] f8[&quot; &quot;] f9[&quot; &quot;] Decode:2 d1[&quot; &quot;] d2[&quot;TEST&quot;] d3[&quot;BR1&quot;] d4[&quot;...&quot;] d5[&quot;BR2&quot;] d6[&quot;...&quot;] d7[&quot; &quot;] d8[&quot; &quot;] d9[&quot; &quot;] Execute:2 e1[&quot; &quot;] e2[&quot; &quot;] e3[&quot;TEST&quot;] e45[&quot;Stall&quot;]:2 e6[&quot;BR2&quot;] e7[&quot;...&quot;] e8[&quot; &quot;] e9[&quot; &quot;] Memory:2 m1[&quot; &quot;] m2[&quot; &quot;] m3[&quot; &quot;] m4[&quot;TEST&quot;] m56[&quot;Stall&quot;]:2 m7[&quot;BR2&quot;] m8[&quot;...&quot;] m9[&quot; &quot;] Write:2 w1[&quot; &quot;] w2[&quot; &quot;] w3[&quot; &quot;] w4[&quot; &quot;] w5[&quot;TEST&quot;] w67[&quot;Stall&quot;]:2 w8[&quot;BR2&quot;] w9[&quot;...&quot;] classDef empty color:#777,fill:none,stroke:none; classDef o fill:#bcc0c2,stroke:#666c73,stroke-width:2px classDef a fill:#e0eafd,stroke:#537ac5,stroke-width:2px classDef b fill:#fee3df,stroke:#d35f5c,stroke-width:2px classDef c fill:#dff5e3,stroke:#5a996c,stroke-width:2px classDef stall color:#777,fill:none,stroke-width:2px,stroke-dasharray: 3 5 class Cycle,1,2,3,4,5,6,7,8,9 empty class Fetch,Decode,Execute,Memory,Write o class f1,d2,e3,m4,w5 a class f2,f3,d3,d4 b class f4,f5,d5,d6,e6,e7,m7,m8,w8,w9 c class e45,m56,w67 stall 为了减少空转时间，现代 CPU 通过 分支预测 (branch prediction) 来猜测跳转的目标代码，并预加载相应的指令到缓存中： 当分支预测正确时，可以有效提升 CPU 的执行效率 当分支预测错误时，会有大量无用的指令被加载到 L1 缓存中，反而会拖慢 CPU 执行速度 尽管无法直接控制 L1 指令缓存，但可以通过减少代码中的分支跳转，提升指令预取的命中率： // branch int add_condtional(int a, unsigned int b) { if (b &lt; 16) a += b; return a; } // branchless int add_condtional(int a, unsigned int b) { a += b &amp; 0xf; return a; } 代码布局 在指令进入 L1 缓存前，其对应的代码也会被预取至 L2 缓存。 当代码中存在分支时，代码布局可能会影响其在 L2 缓存的命中率。 以下面这段代码为例，该函数中存在 A B C 三个相邻的代码块，这些代码块会以缓存行的形式加载到 L2 缓存中。 并且该函数中的分支判断条件大概率为 false ，这意味着B 代码块执行的概率远低于 C 代码块。 int branch_layout() { ... code block A ... if (I()) { // I represents conditional jump instruction ... code block B ... } ... code block C ... } 当 B 代码块较大时，每次函数调用都会有大量不需要执行的代码被预取到 L2 缓存中。 并且一旦分支预测错误，还会有大量的无用指令被加载到 L1 缓存中。 对于这类触发概率较低的分支代码，可以将其抽离为为一个独立函数并禁止内联。 使其位于一个独立的代码块中，并将低概率分支简化为一个函数调用指令。 程序员可以通过 gcc 提供的两个宏来提示编译器的哪个代码分支的执行概率更高，接着在编译时开启 -freorder-blocks 优化选项即可实现自动调整代码布局。 #define unlikely(expr) __builtin_expect(!!(expr), 0) #define likely(expr) __builtin_expect(!!(expr), 1) 缓存行对齐 为了保证访问效率，结构体的内存地址默认按照以下规则对齐： 当字段类型长度为 n 字节时，其起始地址必须为 n 的整数倍 结构体的起始地址，必须是其最长类型字段的整数倍 对于需要频繁频繁改写的共享数据，以上对齐方式仍不能保证访问效率。 假设 A / B 两个线程分别持有一个计数器 X / Y，并且 X / Y 恰好被分配在同一个缓存行中。 此时即便这两个线程修改的数据毫不相关，也会对对方的访问性能产生严重影响。 block-beta columns 3 thread1[&quot;Thread A&quot;] space thread2[&quot;Thread B&quot;] space:3 block:cache1 line11[&quot;Line1&quot;] line12[&quot;Line2&quot;] line13[&quot;Line3&quot;] line14[&quot;Line4&quot;] end space block:cache2 line21[&quot;Line1&quot;] line22[&quot;Line2&quot;] line23[&quot;Line3&quot;] line24[&quot;Line4&quot;] end block:memory:3 columns 4 A[&quot;64 Byte&quot;] B[&quot;...&quot;] block:C space X space Y end D[&quot;...&quot;] end thread1 --&gt; line14 thread2 --&gt; line22 X --&gt; line14 Y --&gt; line22 classDef core0 fill:#e0eafd,stroke:#777; classDef core1 fill:#fee3df,stroke:#777; classDef blk fill:#eee,stroke:#999; class thread1,line14,X core0 class thread2,line22,Y core1 class C blk 对于这种情况，可以通过某些手段强制结构体按照缓存行长度进行对齐： 保证分配内存块的起始地址是 64 的整数倍 通过填充占位字段，保证结构体能够独占一个缓存行 gcc 提供了 aligned 编译属性用于控制内存对齐： struct strtype variable __attribute((aligned(64))); struct strtype { ...members... } __attribute((aligned(64))); 对于 Java 这类托管语言，则需要手动填充字段，或通过特殊注解提示 JVM 进行缓存行对齐。 虚拟地址 每个进程启动时，操作系统会为其分配一个连续的 虚拟地址空间 (virtual memory)： 当用户通过 malloc() 分配内存时，实际上将该空间的某段地址，映射到具体的物理内存上 当用户访问某个未关联物理内存的虚拟地址，则会发生 segmentation fault 异常 该设计通过牺牲一定的访问效率，极大提升了机器资源的利用率： 每个进程在运行时按需分配物理内存，避免空间的浪费 允许通过 swap 操作实现内存超用，允许更多进程同时在机器上运行 虚拟空间的大小与虚拟地址的长度直接相关： 当虚拟地址长度为 32 位时，可寻址的虚拟内存空间为 4GB 当虚拟地址长度为 64 位时，理论可寻址的虚拟内存空间为 16EB 整个虚拟地址被划分为 内核空间 (kernel space) 与 用户空间 (user space) ，用户空间主要有两部分组成： 加载可执行文件 这部分布局与编译器生成的可执行文件相关，通常由 3 部分组成： Code 中包含编译器生成机器指令，由于可执行文件中的 .text 段派生而来 Data 中包含全局静态变量及其对应的初始值，由于可执行文件中的 .data 与 .rodata 段派生而来 BSS 中包含没有设置初始值的全局静态变量，由于可执行文件中的 .bss 段派生而来 区分 Data 与 BSS 的主要目的是减少可执行文件的体积，避免引入不必要的初始零值。为了保证系统安全，不同的内存段对应的物理内存页会设置不同的权限： section execute read write .text ✔ ✔ ✘ .rodata ✘ ✔ ✘ .data ✘ ✔ ✔ .bss ✘ ✔ ✔ 运行时动态创建 Stack 栈用于存储函数中的局部变量，函数调用通过 栈桢 (stack frame) 管理内存的生命周期： 函数调用时会在堆上自动申请内存生成栈桢，程序员也可以通过 alloca() 在栈桢上分配空间 当函数返回时栈桢会被销毁，其关联的内存空间会被同时回收 Heap 堆用于存储需要动态分配空间的复杂数据结构，比如：链表、二叉查找树...程序员需要通过 malloc() 与 free() 在堆上动态分配与释放空间，并负责管理其上内存的生命周期。 出于灵活性原因，在大多数情况下，进程的堆栈和堆区域分配在地址空间的相反的两端。该布局允许其中任意一方尽可能地增长。 Shared Memory 两者间的内存地址空间可用于实现共享内存，方便与其他进程共享物理内存页。借助内存共享的特性，可以实现两个常用的功能： 可以通过将不同进程的虚拟地址映射至同一块物理内存，实现进程间通信 子进程的虚拟地址默认共享父进程关联的物理内存，仅当子进程修改数据时才会创建新的副本进行编辑 看到这里，不知道你考虑过一个问题：JIT 编译器生成的代码应该放哪里？： 尝试将其写入 .text 所在的区域会导致进程崩溃 如果简单将其放在堆上，当指令流跳转至该内存页时进程也会崩溃。 为了解决这一问题，JIT 编译器需要执行以下操作： 通过系统调用直接在堆上申请独占的物理内存页，避免 malloc() 从将该页面分配内存 将改内存页的权限设置为可执行，并将生成的代码复制进改内存页中 内存分页 操作系统分配物理内存的最小单位是页。 CPU 通过 MMU (Memory Management Unit) 将虚拟地址映射为物理地址。该映射过程需要一个被称为页表的结构参与： 一级页表将虚拟内存地址分为两部分： index 与该虚拟地址关联的 page offset 该地址对应的物理地址在 page 中的偏移量 内存页大小决定了 offset 部分的长度： 当 page 大小为 4MB 时，offset 部分的长度为 22bit 当 page 大小为 4KB 时，offset 部分的长度为 12bit 在一个 32 位的系统中使用 4kB 内存页，将有 20bit 将被用于表示 index。如果使用一个连续的指针数组来作为页表，则页表占用空间可达 $2^{20} \\times 4$ = 4MB。 由于虚拟内存的独立性，操作系统会为每个进程维护独立的页表，这种分配方式无疑会浪费大量的内存空间。为了节省空间，通常使用多级页表来维护映射关系，实现按需分配空间。 连续的虚拟地址能够共享高级页表空间，减少不必要的内存分配。为了减少页表占用的内存空间，应该尽可能在连续的虚拟地址上分配内存。 但这种多级跳转的访问方式性能较差，为了提升寻址效率， 现代 CPU 引入了一个名为 TLB (Translation Look-Aside Buffer) 的硬件缓存， 其中缓存着 index 到 page 的映射关系，避免了多次跳转的性能开销： 当查找 index 命中时，会根据 page 与 offset 计算得到物理地址 当查找未命中时，则会查询主存中的多级页表，并将结果缓存在 TLB 中 TLB 的命中率高低直接影响到内存访问性能，优化手段可以分为两类： 减少上下文切换 运行在同个 CPU 核上的进程共享 TLB 资源。 但由于不同进程间的虚拟地址相互隔离，同个 index 会指向不同的 page。 因此每当发生上下文切换时，都需要清空 TLB 中的缓存记录。 为了提升缓存命中率，部分 CPU 在硬件层面上对缓存的 index 增加了标记位，用于区分不同的虚拟地址空间： 区分内核空间与用户空间，发生系统调用时无需清空 TLB 区分宿主机空间与虚拟机空间，提升虚拟机的执行效率 区分不同进程的地址空间，发生进程切换时无需再清空 TLB 在连续的虚拟地址上分配内存 通过在连续的地址上分配内存，可以避免不必要的页表分配，降低页表占用的内存空间。 同时还能保证热点内存页常驻于 TLB 中，减少页表查询。 多线程优化 线程的一个特性是它们都共享相同的地址空间。 当线程不共享任何内存数据时，每个不同 CPU 核的 L1 中拥有独立的缓存行。 线程并发修改缓存时，无需发送 RFO 请求，没有额外的通信开销。 当多个线程访问同一内存位置时，不同 CPU 核的 L1 需要互相通信来保证缓存一致性。 线程并发修改缓存时会发送大量的 RFO 请求，这意味着同一时刻只能有一个 CPU 能操作其 L1 中的缓存行副本。 有其他 CPU 核心都会被延迟，无法做任何事情。并发线程越多，同步开销越大，每个额外的处理器只会带来更多的延迟。 为例避免这一问题，需要对于多线程间共享的数据进行读写分离：将常量或者只初始化一次的变量分组到一起。 当这些数据被加载进缓存后，其所在的缓存行将长期处于 S 状态，不受修改操作的影响。 一种方式是使用 const 修饰全局静态变量，当 gcc 编译生成可执行文件时： 有 const 修饰的变量会被放入 .rodata section 没有 const 修饰的变量将被放入 .data section 程序运行时，同 section 的变量会加载到相邻连续的内存段中。 不同 section 的内存段相互独立，避免相互影响。 当无法使用 const 关键字时，可以通过编译属性自定义 section ： int foo = 1; int bar __attribute__((section(&quot;.data.ro&quot;))) = 2; int baz = 3; int xyzzy __attribute__((section(&quot;.data.ro&quot;))) = 4; NUMA 超立方体 在传统的 一致性内存访问 (Uniform Memory Access) 架构中，所有 CPU 通过统一的 前端总线 (Front Side Bus) 访问内存。 FSB 是 CPU 与北桥芯片之间的连接，北桥芯片再连接到内存控制器，所有的内存访问都通过这条总线进行。 该架构的一致性保证了 CPU 对所有内存的访问时间是一致的。 而在 非一致性内存访问 (Non-Uniform Memory Access) 架构中，北桥芯片被的内存控制功能被整合进 CPU。 为了解决多处理器系统中内存访问延迟的问题，NUMA 将内存被划分为多个节点，每个节点通常包含一组 CPU 核心和与之紧密相连的内存。 NUMA 是应用分治法解决复杂问题的一个极佳案例。 该架构下 CPU 可以直接与同节点内存通信，无需再通过统一的内存总线来访问内存。 由于总线带宽不再成为系统扩展瓶颈，使得该架构具备更强的水平扩展性，因此 NUMA 还特别用于大型商用硬件。 为了准确的描述 CPU 与内存间的通信拓扑，需要引入一个被称为 超立方体 (hypercube) 的结构： 每个节点包含一组处理器核心以及对应的本地内存，这些节点通过通信网络相互连接，形成了一个高度并行的系统。 假设每个 NUMA 节点具备 $C$ 个互连接口，则超立方体中最多能包含 $2^C$ 个互联节点，此时两两节点间的最长访问距离也恰好是 $C$。 该拓扑具备以下优点： 局部性：将处理器核心与本地节点上的内存绑定，可以提高局部性，减少访问远程内存的延迟。 易扩展：可以很容易地进行水平扩展，避免单个节点或维度成为系统瓶颈，从而提高系统的整体性能。 容错性：拓扑的分布对称性使其易于实现冗余和备份，提高系统的容错性和可靠性。 常见问题 在 NUMA 系统中，内存访问的延迟不再是一致的，而是取决于内存所在的节点以及执行访问的 CPU 核心。 每个 CPU 核心可以更快地访问与其在同一节点内的内存，而访问其他节点的内存则会相对较慢。 为了管理这种复杂的内存访问模式，操作系统需要具备 NUMA 感知能力，以便能够优化内存分配和数据布局，从而减少跨节点访问的延迟。 共享内存 对于 libc.so 这类常用的共享库，通常只会常驻一组特定的在物理内存页中。 这意味着除了少数处理器之外的所有处理器都必须远程访问该物理内存。 一个对 NUMA 架构友好的操作系统需要为每个节点维护独立的 libc.so 副本，从而避免频繁的远程内存访问。 线程调度 当发生线程调度时，需要保证线程与其经常访问的物理内存都常驻于同个 NUMA 节点上。 操作系统不应经常性地将线程从一个节点迁移到另一个节点。 此外线程调度不应仅考虑 CPU 负载，还需要考虑内存负载。 在保证 CPU 资源利用率低情况下，尽可能将需要占用大量占用内存空间的线程分配到不同的节点上，避免特点节点的内存资源耗尽。 优化手段 NUMA 架构下的程序员可用的优化手段与 UMA 架构并无区别： 大批量连续访问内存，充分利用局部缓存缓解远程访问的性能开销 预先配置 CPU 核心与线程的亲和度，避免线程跨 NUMA 节点调度 "},{"slug":"smid-varint-with-ffm-api","title":"使用 FFM API 封装 SIMD 本地函数","tags":["后端开发","数值编码"],"content":"最新发布的 JDK 22 中包含稳定版本的 Foreign-Memory Access API，官方声称该 API 能够提供比 JNI 更优的性能。 恰好最近有尝试使用 SIMD 优化编码算法的想法，因此准备结合这两种技术对 VarInt 编码进行优化。 SMID 简介 多线程并发能够有效提升应用吞吐量，但是本质上每个线程也只是在执行串行的指令流。 当线程数量过多时，频繁的上下文切换会耗费硬件资源，最终导致性能不升反降。 SIMD 是一种在硬件层面上实现的并行计算的优化手段：将 4 个 32bit 数值放入 128bit 寄存器后，即可通过一条指令执行 4 次计算。 除了需要在寄存器与内存间搬运数据外，SIMD 本身并不会引入任何性能损耗，是一种可靠的优化手段。 block-beta columns 14 block:32bit[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;＋&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;4 x 32bit&quot;]:4 columns 4 1 2 3 4 space:4 5 6 7 8 end to1&lt;[&quot; &quot;]&gt;(right) block:128bit[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;＋&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;128bit&quot;]:4 columns 1 a[&quot;1&amp;emsp;&amp;emsp;2&amp;emsp;&amp;emsp;3&amp;emsp;&amp;emsp;4&quot;] space b[&quot;5&amp;emsp;&amp;emsp;6&amp;emsp;&amp;emsp;7&amp;emsp;&amp;emsp;8&quot;] end to2&lt;[&quot; &quot;]&gt;(right) block:sum[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;128bit&quot;]:4 columns 1 space c[&quot;6&amp;emsp;&amp;emsp;8&amp;emsp;&amp;emsp;10&amp;emsp;&amp;emsp;12&quot;] space end classDef empty color:#777,stroke-width:0px,fill:none class 32bit,128bit,sum empty SIMD 不仅能够提升单位时间内的 CPU 计算能力，由于指令数量减少与批量加载数据的特性，L1 缓存的命中率也会随之提升，从而进一步提升计算效率。 指令集 JDK 中某些带有 @IntrinsicCandidate 注解的方法，会在 JVM 内部被替换为特定的汇编实现。 比如 Long.numberOfLeadingZeros 与 Long.numberOfTrailingZeros 可能会被替换为 lzcnt 与 tzcnt 指令。 具体是否能进行替换，取决 JVM 所运行的 CPU 是否支持这些指令。 除了某些特殊指令（比如：popcnt) 外，大多数指令都以指令集的形式存在，可以通过 lscpu 查看 CPU 支持的指令集合： $ lscpu | grep Flags Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc aperfmperf eagerfpu pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid dca sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch epb cat_l3 cdp_l3 intel_ppin intel_pt ssbd mba ibrs ibpb stibp ibrs_enhanced tpr_shadow vnmi flexpriority ept vpid fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm cqm mpx rdt_a avx512f avx512dq rdseed adx smap clflushopt clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local dtherm ida arat pln pts pku ospke avx512_vnni md_clear spec_ctrl intel_stibp flush_l1d arch_capabilities 本次优化 VarInt 主要涉及两个指令集： BMI (Bit Manipulation Instructions) _pdep_u64: 将 64bit 以每 7bit 为一组进行拆分 SSE (Streaming SIMD Extensions) _mm_set_epi8 : 将 16 个 8bit 数据加载进 128bit 寄存器 _mm_cmpgt_epi8 : 按每 8bit 一组进行比较寄存器中的数据 _mm_movemask_epi8 : 按照 8bit 一组判断是否非 0 并将结果聚合为 int (仅最低 16bit 有效) _mm_bsrli_si128 : 按照 8bit 一组进行按位右移 代码实现 不同编译器对对汇编指令的支持方式不同，为了保证 SMID 代码的可移植性并编码效率，现代操作系统在其标准 C 库函数种提供了 内嵌函数(intrinsic function)。 每个 SMID 指令都有其对应的内嵌函数，这使得开发者可以通过 C/C++ 之类的高级语言使用 SMID 汇编指令。 接下来要用到的是实际代码也将使用内嵌函数实现，来源于 varint-simd 。 如果对具体的指令感兴趣，可以在这里查询。 #include &lt;stdio.h&gt; #include &lt;immintrin.h&gt; #include &lt;x86intrin.h&gt; typedef unsigned char u8; typedef unsigned short u16; typedef unsigned long u64; void print_out(u8 val[]) { printf(&quot;out: %x %x %x %x %x %x %x %x %x %x %x %x \\n&quot;, val[0], val[1], val[2], val[3], val[4], val[5], val[6], val[7], val[8], val[9], val[10], val[11]); } int encode_varint64(u64 val, u8 out[]) { // Break the number into 7-bit parts and spread them out into a vector __m128i stage1 = _mm_set_epi64x( _pdep_u64(val &gt;&gt; 56, 0x000000000000017f), _pdep_u64(val, 0x7f7f7f7f7f7f7f7f)); // Create a mask for where there exist values // This signed comparison works because all MSBs should be cleared at this point // Also handle the special case when num == 0 __m128i minimum = _mm_set_epi8(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF); __m128i exists = _mm_or_si128(_mm_cmpgt_epi8(stage1, _mm_setzero_si128()), minimum); // Count the number of bytes used int bits = _mm_movemask_epi8(exists); u8 bytes = 1 + _bit_scan_reverse(bits); // Fill that many bytes into a vector __m128i ascend = _mm_setr_epi8(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15); __m128i mask = _mm_cmplt_epi8(ascend, _mm_set1_epi8(bytes)); // Shift it down 1 byte so the last MSB is the only one set, and make sure only the MSB is set __m128i shift = _mm_bsrli_si128(mask, 1); __m128i msbmask = _mm_and_si128(shift, _mm_set1_epi8((u8)0x80)); // Merge the MSB bits into the vector __m128i merged = _mm_or_si128(stage1, msbmask); _mm_storeu_si128((__m128i*) out, merged); return bytes; } int main(int argc, char** argv) { u8 out[16]; encode_varint64(127, out); print_out(out); encode_varint64(128, out); print_out(out); encode_varint64(16383, out); print_out(out); encode_varint64(16384, out); print_out(out); encode_varint64(~0, out); print_out(out); return 0; } 执行 gcc -mbmi2 -o test test.c &amp;&amp; ./test 运行代码将得到以下结果： out: 7f 0 0 0 0 0 0 0 0 0 0 0 out: 80 1 0 0 0 0 0 0 0 0 0 0 out: ff 7f 0 0 0 0 0 0 0 0 0 0 out: 80 80 1 0 0 0 0 0 0 0 0 0 out: ff ff ff ff ff ff ff ff ff 1 0 0 FFM 封装 基础知识 JDK22 中已经将 FFM (Foreign Function and Memory API) 作为正式特性进行发布。 其前身是 Panama 项目，致力在 JVM 与 NativeCode 间建立一个统一高效的调用机制。 其中就包含一个用于封装 SIMD 指令的 Vector API 提案。 FFM 提供了两个重要的 API： Foreign-Memory Access API : 基于 Aerna 与 MemorySegment 实现的的堆外内存管理机制 Foreign Linker API : 基于 Linker 与 MethodHandle 实现的动态链接库调用机制 Foreign-Memory Access API 的出现主要是为了取代 Unsafe，在未来成为标准的堆外内存管理器。 出于安全考虑，其中包含了大量的边界与生命周期检查，因此性能相对直接使用 Unsafe 会有所下降。令人遗憾的是，为了强制推行新 API，目前官方已经提出了废弃 Unsafe 的草案。 Foreign Linker API 可以看作是 JNI(Java Native Interface) 的后继者，但在易用性上有了质的飞跃。 用户可以无需编写任何 C/C++ 代码，即可直接访问现存的动态链接库： public class FFMTestStrlen { public static void main(String[] args) throws Throwable { // 1. Get a linker – the central element for accessing foreign functions Linker linker = Linker.nativeLinker(); // 2. Get a lookup object for commonly used libraries SymbolLookup stdlib = linker.defaultLookup(); // 3. Get the address of the &quot;strlen&quot; function in the C standard library MemorySegment strlenAddress = stdlib.find(&quot;strlen&quot;).orElseThrow(); // 4. Define the input and output parameters of the &quot;strlen&quot; function FunctionDescriptor descriptor = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS); // 5. Get a handle to the &quot;strlen&quot; function MethodHandle strlen = linker.downcallHandle(strlenAddress, descriptor); // 6. Get a confined memory area (one that we can close explicitly) try (Arena offHeap = Arena.ofConfined()) { // 7. Convert the Java String to a C string and store it in off-heap memory MemorySegment str = offHeap.allocateFrom(&quot;Happy Coding!&quot;); // 8. Invoke the &quot;strlen&quot; function long len = (long) strlen.invoke(str); System.out.println(&quot;len = &quot; + len); } // 9. Off-heap memory is deallocated at end of try-with-resources } } 如果不想人工维护大量的模版代码，可以尝试使用 FFM 提供的代码生成工具 jextract 。 实现步骤 首先，编写 C 代码并生成动态链接库。这一步十分简单，不涉及到任何与 Java 的相关依赖。 编译命令 gcc -Wl,-z,relro -Wl,-z,now -Wl,--as-needed -O3 -mbmi2 varint64.c -shared -o /tmp/varint64.so -fPIC 代码实现 #include &lt;immintrin.h&gt; #include &lt;x86intrin.h&gt; extern int encodeLong(unsigned long value, unsigned char out[]) { // Break the number into 7-bit parts and spread them out into a vector unsigned long a = _pdep_u64(value, 0x7f7f7f7f7f7f7f7f); unsigned long b = _pdep_u64(value &gt;&gt; 56, 0x000000000000017f); __m128i stage1 = _mm_set_epi64x( _pdep_u64(value &gt;&gt; 56, 0x000000000000017f), _pdep_u64(value, 0x7f7f7f7f7f7f7f7f)); // Create a mask for where there exist values // This signed comparison works because all MSBs should be cleared at this point // Also handle the special case when num == 0 __m128i minimum = _mm_set_epi8(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF); __m128i exists = _mm_or_si128(_mm_cmpgt_epi8(stage1, _mm_setzero_si128()), minimum); // Count the number of bytes used int bits = _mm_movemask_epi8(exists); unsigned char bytes = 1 + _bit_scan_reverse(bits); // Fill that many bytes into a vector __m128i ascend = _mm_setr_epi8(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15); __m128i mask = _mm_cmplt_epi8(ascend, _mm_set1_epi8(bytes)); // Shift it down 1 byte so the last MSB is the only one set, and make sure only the MSB is set __m128i shift = _mm_bsrli_si128(mask, 1); __m128i msbmask = _mm_and_si128(shift, _mm_set1_epi8((unsigned char)0x80)); // Merge the MSB bits into the vector __m128i merged = _mm_or_si128(stage1, msbmask); _mm_storeu_si128((__m128i*) out, merged); return bytes; } 接着使用 FFM 编写胶水代码，作为链接库的调用入口。 public class VarIntFFM { static final Arena LIBRARY_ARENA; static final SymbolLookup SYMBOL_LOOKUP; public static final FunctionDescriptor DESC = FunctionDescriptor.of( ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS.withTargetLayout(MemoryLayout.sequenceLayout(Long.MAX_VALUE, JAVA_BYTE)) ); public static final MemorySegment ADDR; public static final MethodHandle HANDLE; static { try { LIBRARY_ARENA = Arena.ofAuto(); SYMBOL_LOOKUP = SymbolLookup.libraryLookup(Paths.get(&quot;/tmp/varint64.so&quot;), LIBRARY_ARENA) .or(SymbolLookup.loaderLookup()) .or(Linker.nativeLinker().defaultLookup()); ADDR = SYMBOL_LOOKUP.find(&quot;encodeLong&quot;).orElseThrow(); HANDLE = Linker.nativeLinker().downcallHandle(ADDR, DESC); } catch (Throwable e) { e.printStackTrace(); throw new RuntimeException(e); } } public static int encodeLong(long value, MemorySegment out) throws Throwable { return (int) HANDLE.invokeExact(value, out); } } 性能测试 最后使用 JMH 进行测试，模拟以下场景：使用编码函数将中间结果写入中间数组 buf 中，接着将 buf 中的数据拷贝进 result 数组。 public class VarIntPerf { @Benchmark @Warmup(time = 3, iterations = 3) @Measurement(time = 5, iterations = 3) public void testNative() throws Throwable { byte[] result = new byte[10]; try (Arena offHeap = Arena.ofConfined()) { MemorySegment buf = offHeap.allocate(16, 16); for (int i=0; i&lt;1000; i++) { int n = TestFFM.encodeLong(i, buf); Unsafe.COPY_MEMORY.copyMemory(null, buf.address(), result, CodecSlice.BYTES_OFFSET, n); } } } @Benchmark @Warmup(time = 3, iterations = 3) @Measurement(time = 5, iterations = 3) public void testPlain() { byte[] result = new byte[10]; byte[] buf = new byte[10]; for (int i=0; i&lt;1000; i++) { int n = VarInt.encodeLong(i, buf, 0); System.arraycopy(buf, 0, result, 0, n); } } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(VarIntFFMPerf.class.getSimpleName()) .forks(1) .build(); new Runner(opt).run(); } } 最后在在 pom.xml 中引用 maven-shade-plugin 插件用于生成 benchmarks.jar。 &lt;plugin&gt; &lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt; &lt;artifactId&gt;maven-shade-plugin&lt;/artifactId&gt; &lt;version&gt;3.2.0&lt;/version&gt; &lt;executions&gt; &lt;execution&gt; &lt;phase&gt;package&lt;/phase&gt; &lt;goals&gt; &lt;goal&gt;shade&lt;/goal&gt; &lt;/goals&gt; &lt;configuration&gt; &lt;finalName&gt;benchmarks&lt;/finalName&gt; &lt;transformers&gt; &lt;transformer implementation=&quot;org.apache.maven.plugins.shade.resource.ManifestResourceTransformer&quot;&gt; &lt;mainClass&gt;org.openjdk.jmh.Main&lt;/mainClass&gt; &lt;/transformer&gt; &lt;/transformers&gt; &lt;/configuration&gt; &lt;/execution&gt; &lt;/executions&gt; &lt;/plugin&gt; 最后执行以下命令即可进行测试： mvn clean package java -jar target/benchmarks.jar VarIntFFM 测试结果如下： Benchmark Mode Cnt Score Error Units VarIntPerf.testFFM thrpt 15 47364.288 ± 1190.314 ops/s VarIntPerf.testPlain thrpt 15 109338.097 ± 973.650 ops/s 不得不说，这个结果比较令人失望。 FFM 的调用本地代码的开销依然比较可观，其上下文切换的开销仍无法做到跟 JVM 内部调用同个数量级。 JNI 封装 接下来我们将使用 JNI 实现相同的功能，并以此作为性能对照。 实现步骤 首先，编写作为调用入口的 Java 类。 public abstract class VarIntJNI { static { System.load(&quot;/tmp/varintjni.so&quot;); } public static native int encodeLong(long value, long pointer); } 接着使用命令 javac -h . VarIntJNI.java 生成 JNI 所需的头文件。 /* DO NOT EDIT THIS FILE - it is machine generated */ #include &lt;jni.h&gt; /* Header for class VarIntJNI */ #ifndef _Included_VarIntJNI #define _Included_VarIntJNI #ifdef __cplusplus extern &quot;C&quot; { #endif /* * Class: VarIntJNI * Method: encodeLong * Signature: (JJ)I */ JNIEXPORT jint JNICALL Java_VarIntJNI_encodeLong (JNIEnv *, jclass, jlong, jlong); #ifdef __cplusplus } #endif #endif 最后编译生成 JNI 版本的动态连接库： 编译命令 gcc -I $JAVA_HOME/include -I $JAVA_HOME/include/linux/ -Wl,-z,relro -Wl,-z,now -Wl,--as-needed -O3 -mbmi2 varintjni.c -shared -o /tmp/varintjni.so -fPIC 代码实现 #include &lt;immintrin.h&gt; #include &lt;x86intrin.h&gt; #include &quot;varintjni.h&quot; JNIEXPORT jint JNICALL Java_VarIntJNI_encodeLong (JNIEnv * env, jclass obj, jlong value, jlong out) { // Break the number into 7-bit parts and spread them out into a vector unsigned long a = _pdep_u64(value, 0x7f7f7f7f7f7f7f7f); unsigned long b = _pdep_u64(value &gt;&gt; 56, 0x000000000000017f); __m128i stage1 = _mm_set_epi64x( _pdep_u64(value &gt;&gt; 56, 0x000000000000017f), _pdep_u64(value, 0x7f7f7f7f7f7f7f7f)); // Create a mask for where there exist values // This signed comparison works because all MSBs should be cleared at this point // Also handle the special case when num == 0 __m128i minimum = _mm_set_epi8(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF); __m128i exists = _mm_or_si128(_mm_cmpgt_epi8(stage1, _mm_setzero_si128()), minimum); // Count the number of bytes used int bits = _mm_movemask_epi8(exists); unsigned char bytes = 1 + _bit_scan_reverse(bits); // Fill that many bytes into a vector __m128i ascend = _mm_setr_epi8(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15); __m128i mask = _mm_cmplt_epi8(ascend, _mm_set1_epi8(bytes)); // Shift it down 1 byte so the last MSB is the only one set, and make sure only the MSB is set __m128i shift = _mm_bsrli_si128(mask, 1); __m128i msbmask = _mm_and_si128(shift, _mm_set1_epi8((unsigned char)0x80)); // Merge the MSB bits into the vector __m128i merged = _mm_or_si128(stage1, msbmask); _mm_storeu_si128((__m128i*) out, merged); return bytes; } 性能测试 为了公平起见，这里直接使用 Unsafe 分配的堆外内存进行通信，避免发生潜在的内存复制： public class VarIntPerf { static final sun.misc.Unsafe unsafe = (sun.misc.Unsafe) Unsafe.UNSAFE; @Benchmark @Warmup(time = 3, iterations = 3) @Measurement(time = 3, iterations = 3) public void testJNI() throws Throwable { byte[] result = new byte[16]; long address = unsafe.allocateMemory(16); for (int i=0; i&lt;1000; i++) { int n = VarIntJNI.encodeLong(i, address); Unsafe.COPY_MEMORY.copyMemory(null, address, result, CodecSlice.BYTES_OFFSET, n); } unsafe.freeMemory(address); } //... public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(VarIntJNIPerf.class.getSimpleName()) .forks(1) .build(); new Runner(opt).run(); } } 最终测试结果如下： Benchmark Mode Cnt Score Error Units VarIntPerf.testJNI thrpt 15 46634.574 ± 904.912 ops/s VarIntPerf.testFFM thrpt 15 44151.628 ± 815.886 ops/s VarIntPerf.testPlain thrpt 15 109448.067 ± 1382.802 ops/s 虽然 JNI 封装的性能表现依然不佳，但是相较于 FFM 封装的方式相比仍有少量优势，这也与官方的介绍预期相符。 FFM 与 JNI 的调用路径可以分为两类： upcall: 在本地方法中调用 java 代码 downcall: 在 java 代码中调用本地方法 FFM 主要优化的调用路径是 upcall，而对 downcall 的优化不甚明显。 本次测试仅包含 downcall 路径，无法体现出 FFM 的优势。 总结 随着 FFM 的出现，在 JVM 中访问本地代码正在变得越来越简单。 但是 FFM 的上下文切换开销仍然较大，因此不适合频率过高的调用，可能的优化方式有两种： 单次调用尽可能处理更多的数据。但这也意味着更多的内存分配与数据复制开销。 将尽可能多的业务逻辑使用本地代码进行实现。但这也意味着 JVM 语言本身内存安全与易于调试的优势将不复存在。 而作为一种消除计算热点的的手段，SIMD 通常仅适合用于优化热点代码。 这恰好与 FFM 和 JNI 的设计理念相互违背。 如果想在 JVM 语言中体验 SIMD 的加速能力，似乎只能将希望寄托于尚未成熟的 Vector API 上了。 "},{"slug":"variable-length-numeric-compression","title":"变长数值压缩算法","tags":["后端开发","数值编码"],"content":"在众多压缩编码中，有一类专门为数值压缩而生。这类编码无需依赖样本数据构建数据字典，即可对实时时序数据进行高效无损压缩。 这些压缩编码在时序数据处理领域大放异彩，为用户节省了大量的存储空间与传输带宽，提升了海量数据的处理能力。 本片文章将对其中较为常用的一些编码进行介绍。 基本概念 在现代互联网中，我们无时无刻不在享受着压缩编码带来的便利。 根据解压后的数据是否发生精度丢失，压缩算法可以分为两类： 有损压缩：通过滤波、变换消除图像、声音信号中的细节，减少音视频文件的体积。 无损压缩：通过扫描、构建数据字典，来消除数据中的冗余信息，并生成压缩文件。 而在时序数据领域，上面两种方法并不是最优解。时序数据具有以下特点： 冗余度高 样本量少 实时性高 这类数据在我们身边比比皆是，比如下面图中展示的股票委托数据： 如果使用常规的无损压缩算法处理这些数据，不仅无法实现压缩效果。 并且由于引入了压缩字典，可能反而会使得数据更为膨胀。 针对这类特殊的数据，就需要用到一类特殊的压缩方式 —— 变长数值编码。 变长数值编码是一类专门用于处理小范围数值的编码格式。 由于其压缩效率高，且实现简单的特点，被广泛应用于搜索引擎、列式存储领域： Protobuf 通过了变长整数编码，实现了高效的二进制序列化 时序数据库 InfluxDB 利用变长浮点数编码，实现了高效的数据存储 接下来我们会介绍两类常用编码格式： 整数编码 Varint: 无符号小整数 ZigZag: 有符号小整数 Simple8b: 重复小整数 浮点数编码 Gorilla: 数值相近的浮点数 Chimp: 周期规律的浮点数 整数编码 空间浪费 按照存储空间进行划分，常用的整型数据有 4 种： int8 / byte int16 / short int32 / int int64 / long 占用空间越大，能表达的范围越广，但也意味着更高的存储开销。 以刚才的委托数据中的 volume 为例子： 热门股票中存在大量活跃散户，因此 volume 通常较小，只需要 int16 就足够存储了 冷门股票中则是少量大户比较活跃，volume 可能成千上万，需要使用 int32 进行存储 但在实际应用中，我们不可能针对具体的股票进行数据建模。 为了避免溢出，因此只能选择 int32 来表示 volume，这无疑会消耗更多的存储空间与网络带宽。 Varint 为了找到一种泛用性更广的表示方式，VarInt 编码应运而生。 对于 int32 数据，其编码规则如下： 将 32bit 数据划分为 n 组 7bit 每组数据使用一个 byte 进行存储 第 1 位为标记位，表示编码是否已经结束 后 7 位为数据位，存储分组后的数据 以 big-endian 的 int32 无符号整数为例： 12 → 1100 → 00001100 (1 byte) 289 → 1|00100001 → 00000010|10100001 (2 byte) 65990 → 1|00000001|11000110 → 00001000|0000011|11000110 (3 byte) 通过这套编码规则，VarInt 能够将 int32 转换为长度为，1 - 5 不等的字节数组。根据占用字节数的同，编码能表达的数据范围为： 1 byte → 0 ~ 127 2 byte → 0 ~ 16383 3 byte → 0 ~ 2097151 4 byte → 0 ~ 268435455 5 byte → 0 ~ 4294967295 不难看出，当应用场景中无符号小整数出现概率较高时，使用 VarInt 能够实现不错的压缩效果。 并且即使偶尔出现值较大的数据，也不会出现溢出的情况。在保证了存储、传输效率同时，提升了数据模型的泛用性。 ZigZag 不过 VarInt 也存在一个明显的缺点：其编码方式对负数不是很友好。 对于 big-endian 数据来说，VarInt 是通过削减 leading-zero 来实现的压缩。 而负数的首个 bit 永远非零，因此不但无法压缩数据，反而会引入不必要的空间开销。 以 -1 为例，其 int32 的补码对应的无符号整数为 4294967295，这意味着需要使用 5 个字节对其进行编码。 为了提升负数的编码效率，ZigZag 编码应运而生。 ZigZag 编码解决这一问题的思路很简单：在进行 VarInt 编码前，对补码进行调整，最大限度地增加其 leading-zero。 其实现也不复杂，只需在 VarInt 编码前后，增加两个映射操作即可： 编码前增加映射 (n &lt;&lt; 1) ^ (n &gt;&gt; 31) 解码后增加映射 (n &gt;&gt;&gt; 1) ^ -(n &amp; 1) 仍然以 -1 为实例，我们直观感受一下这两个操作的实际效果： 当 n = -1 时，VarInt 编码前映射： n = -1 -&gt; 11111111111111111111111111111111 a = n &lt;&lt; 1 -&gt; 11111111111111111111111111111110 b = n &gt;&gt; 31 -&gt; 11111111111111111111111111111111 a ^ b -&gt; 00000000000000000000000000000001 当 n = -1 时，VarInt 解码后映射： m = a ^ b -&gt; 00000000000000000000000000000001 a = m &gt;&gt;&gt; 1 -&gt; 00000000000000000000000000000000 b = -(m &amp; 1) -&gt; 11111111111111111111111111111111 a ^ b -&gt; 11111111111111111111111111111111 ZigZag 映射能够有效增加小负数的 leading-zero 数量，进而提高编码效率。 但是天下没有免费的午餐，增加 ZigZag 映射之后，编码能表达的数据范围为： 1 byte → -64 ~ 63 2 byte → -8192 ~ 8191 3 byte → -1048576 ~ 1048575 4 byte → -134217728 ~ 134217727 5 byte → -2147483648 ~ 2147483647 从中我们也能看到 ZigZag 编码所做的权衡：通过占用了部分非负数的编码空间，极大提升了小值负数的压缩效果。 Simple8 前面提到的两种编码方式仍然存在一个局限：1 个 byte 最多只能存储一个数值。 为了突破这一限制，出现了一类被称为 work packing 的编码：通过将多个整数据按照一定的规则打包成更紧凑的形式，从而节省存储空间。 该类编码不仅实现小数据的压缩，还能实现类似 RLE 的功能，接下来要讲的 Simple8 就是其中一员。 Simple8 将 64bit 分为两部分： selector(4bit) 用于指定剩余 60bit 中存储的整数的个数与有效位长度 payload(60bit) 则是用于存储多个定长的整数 下面这张编码表给出了 selector 值不同的情况下，simple8b 的存储情况： selector value 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 integers coded 240 120 60 30 20 15 12 10 9 8 7 6 5 4 3 2 bits per integer 0 0 1 2 3 4 5 6 7 8 10 12 15 20 30 60 Simple8 的解码方式十分简单高效，仅需对照上面的表格进行相应的位运算即可。 但其编码过程相对复杂，传统的编码方式使用回溯法实现，时间复杂度较高。 一种更高效的实现方式： 在一个 60 x 240 的矩阵枚举所有可能的组合，然后并对不可能的状态进行剪枝，最终得到一个长度为 261 的一维查找表 查表法能够将编码复杂度由 $O(N^2)$ 降至 $O(N)$，实现约 5 倍的吞吐量提升。 浮点数编码 Gorilla 下面我们来谈谈关于浮点数的编码方式，以 IEEE 754 位浮点数为例，其存储结构主要分为 3 部分： $S$ 符号位 $E$ 以 2 为底数偏置指数 $F$ 数字有效数的小数 其对应的浮点数可以表示为：$(-1)^S \\times 2^{(E-127)} \\times 1.F$ 此前介绍的 VarInt 等编码，仅在数值的 leading-zero 数量较多时表现良好。 如果要保证压缩效果，意味着此时 sign 与 exponent 必须均为 0。 而该区间仅能表示 [0, 1) 之间的小数，仅有少数的应用场景的能够满足这一严苛要求。 既然单个数据不好压缩，那么如果是一批数据呢？ 怀着这一想法，Facebook 的工程师们深入研究了公司内部的时序数据。 并在浮点数序列中发现了大量完全相同的二进制位。 于是借鉴 Delta2 编码的思想发明了 Gorilla 算法，通过异或算法提取相邻两个数据间的差异，消除了大量的冗余比特。 该算法的效果十分显著，使用该算法压缩 2 小时的时序数据，每个数据点仅仅需 1.37 byte。 整个编码流程十分简单： 计算两个相邻数据的异或值 xor，并得到 前置 0 数量 leading-zero 连续区块长度 block-size 如果前后两个 xor 的 leading-zero 与 block-size 不同，记录下这两个值 如果前后两个 xor 的 leading-zero 与 block-size 相同，则不记录 最后记录下有差异的区块数据即可 在我们应用场景中，该算法能够将数据压缩为原来的 33%，压缩率可达 60% 以上。 Chimp Gorilla 编码的速度很快，但仍然存在一些不足： 使用固定 5bit 记录 leading-zero 可能造成空间浪费 仅考虑邻近数据样本，无法识别时序数据中的周期性，导致其在特定场景下的压缩效果不尽理想 为了解决以上问题，一个名为 Chimp 的算法应运而生，其主要改进有两点： 通过映射函数与增加 1bit 的标记位，将 leading-zero 编码降至 3bit 当相邻两个数的 leading-zero 数量有差异时才记录 leading-zero 变化 为了提升算法的自适应能力，Chimp 的研究团队还提供了名为 ChimpN 的变种算法。 该变种在原有算法的基础上，增加一个长度为 N 的滑动窗口，计算异或值时可以从中选择 tailing-zero 相似度最高的参考值，从而进一步提升编码压缩效率。 扩展资料 随着 SIMD 指令的普及，已经出现了一批使用使用 SIMD 加速的编码算法，比如整数编码 SIMD-FastPFOR 与浮点数编码 ALP。 虽然这些算法在各自的领域中均取得了不错的效果，但是对于在单次数据样本量较小的场景下，其性能优势不甚明显。 并且 SIMD 对连续内存块的需求，使得这些实现与 Java 的 Streaming API 难以匹配。 因此我们针对自身的应用需求，使用 Java 实现几类常用算法，并开源在这里。 欢迎有需要的朋友使用，并给我们提供宝贵的反馈意见。 "},{"slug":"file-versioning-with-minio-and-mysql","title":"基于 Minio 与 MySQL 实现多版本存储","tags":["系统设计","后端开发"],"content":"近期负责一个简单的多版本文件管理系统研发。 由于种种原因，该项目最终未能上线，因此决定将其间的思考整理成文章，希望能够帮助到有需要的朋友。 整个系统的功能可以归纳为以下 2 点： 支持树状的目录结构 基于 PBAC 模型实现多租户隔离 本篇文章主要介绍如何基于 minio 与 mysql 实现多版本的文件存储。 基本概念 OSS 在我们的日常浏览的网页与APP应用中，存在海量的非结构化数据（比如：图片视频、压缩文件）。 针对这一需求，广大云提供商提供了一类被称为 OSS（Object Storage Service）的文件存储服务。 OSS 不仅为这些文件提供了低廉的存储方案，更提供了一套通用且安全的 HTTP API。 外网用户可以直接访问得这些资源，避免多层代理中转，有效降低了传输延迟与服务器成本。 OSS 常见应用场景有： 静态网站托管 内容分发网络 数据湖存储层 数据备份 对于无法使用公有云设施，但对 OSS 有需求的存储场景，Minio 是最常用的解决方案。 Minio 是一个高性能的开源对象存储服务，并提供了与 Amazon S3 兼容的访问接口。 Minio 具有以下特点： 完全兼容 S3 接口标准 支持多种私有化部署方案（IDC, VPC, K8s） 可靠性高（多副本、纠删码） 安全性高（访问控制、对象加密） 特性丰富（事件监听、空间配额、多版本控制、冷热数据分离） Minio 唯一的缺点就是缺点就是运维复杂，学习成本较高。 CTE 受限于数据模型，关系型数据库并不擅长存储与检索树状结构的数据。 通常情况下解决方案有两种： 发起多次 SQL 查询，每次仅查询一层数据 使用一个 varchar 类型的字段存储路径，在查询时进行前缀匹配 前者的缺点在于需要多次查询，性能较差。 后者则缺点则是难以维护，更新父节点路径会影响子节点。 为了解决这一问题 SQL 标准中提供了 CTE（Common Table Expressions）。 CTE 通过 WITH 语句生成临时的具名数据集合，其中包含子查询返回的结果。 该集合类似于一个临时表，用户可以在后续查询中访问其中的数据。 WITH cte_name (column1, column2, ...) AS ( -- CTE query definition SELECT column1, column2, ... FROM your_table WHERE conditions ) -- Main query that can reference the CTE SELECT * FROM cte_name; CTE 的主要特点有： SQL 可读性好，易于维护 CTE 中的具名结果集可以被重复访问 更易生成更高性能的查询计划 可以实现递归查询 为了满足日渐复杂的应用场景，MySQL8 开始提供了 CTE 支持。 其提供的递归功能极大简化了树状数据的 SQL 查询语句。 系统建模 业务功能点梳理： 多租户隔离与文件共享 按照文件路径查找与授权 版本合并与回滚操作 记录变更日志 erDiagram Asset ||--o{ Changelog : change ResourcePath ||--o{ Changelog : change ResourceCommit ||--o{ Changelog : change ResourceCommit ||--|{ Asset : refer ResourcePath ||..|| ResourceCommit : contains 变更日志 由于安全审计要求每次操作都需要记录变更日志， 使用一个全局的 changelog 表进行记录，并使用 JSON 字段保证其通用性。 该表采用了复合主键的设计方式： 变更时间：记录操作发生的时间，并保证顺序插入 变更ID：应用方预生成的长整型 ID，用于保证唯一性 这一设计有考量主要有两方面： 在应用中预生成 ID，可以实现异步记录日志，减少事务时间 方便实现数据版本与变更日志间的一对一关系 CREATE TABLE IF NOT EXISTS `changelog` ( `op_time` timestamp NOT NULL COMMENT &#39;PK&#39;, `op_code` bigint NOT NULL COMMENT &#39;PK&#39;, `op_type` tinyint unsigned NOT NULL COMMENT &#39;Change event type&#39;, `op_json` json NOT NULL DEFAULT (JSON_OBJECT()) COMMENT &#39;Change event json&#39;, PRIMARY KEY (`op_time`,`op_code`) ) ENGINE=InnoDB COMMENT=&#39;Changelog for data modification.&#39; PARTITION BY KEY(`op_time`) PARTITIONS 5; 文件去重 由于需要支持分享功能，系统中可能同时存在大量完全相同的文件副本。 为了降低 Minio 的存储压力，需要对文件进行去重与复用。 因此设置一张 asset 表记录文件元数据，并通过引用计数维护其生命周期。 CREATE TABLE IF NOT EXISTS `asset` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `bucket` varchar(50) NOT NULL COMMENT &#39;Bucket name&#39;, `checksum` varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Hash and MD5 summary of the whole file&#39;, `file_size` int unsigned NOT NULL COMMENT &#39;File size (unit: byte)&#39;, `file_url` varchar(255) NOT NULL COMMENT &#39;Cloud file storage path&#39;, `mime_type` varchar(255) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;MimeType&#39;, `extension_name` varchar(255) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;File extension&#39;, `preview` varchar(255) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;Preview Token&#39;, `ref_count` int unsigned NOT NULL DEFAULT 0 COMMENT &#39;Reference counting&#39;, `is_deleted` tinyint unsigned NOT NULL DEFAULT 0 COMMENT &#39;Delete tag(0:No,1:Yes)&#39;, `create_by` bigint NOT NULL COMMENT &#39;Create Code&#39;, `update_by` bigint NOT NULL COMMENT &#39;Last Update Code&#39;, `create_at` timestamp NOT NULL COMMENT &#39;Create Time&#39;, `update_at` timestamp NOT NULL COMMENT &#39;Update Time&#39;, UNIQUE KEY `uniq_bucket_checksum` (`bucket`,`checksum`), KEY `idx_file_url` (`file_url`(20)) ) ENGINE=InnoDB COMMENT=&#39;Basic information of objects stored in OSS.&#39;; 版本控制 由于前端支持多个版本文件的合并与回滚，因此后端需要维护不同版本提交历史之间的依赖关系。 每个提交版本都可能指向一个或多个父节点，变更历史可以通过 DAG 的方式进行展现。 每个版本对应一个唯一的文件资产，并通过记录父版本记录ID维护引用关系。 CREATE TABLE IF NOT EXISTS `resource_commit` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `asset_id` bigint unsigned NOT NULL COMMENT &#39;Asset ID&#39;, `asset_meta` json NOT NULL DEFAULT (JSON_OBJECT()) COMMENT &#39;Asset Meta&#39;, `commit_path` bigint unsigned NOT NULL COMMENT &#39;Resource path associated with this commit&#39;, `commit_root` bigint unsigned NOT NULL COMMENT &#39;Root resource of associated resource&#39;, `commit_msg` varchar(50) NOT NULL COMMENT &#39;Commit message&#39;, `main_parent` bigint unsigned NOT NULL COMMENT &#39;Main parent commit&#39;, -- root commit point to zero `sub_parent` bigint unsigned NOT NULL COMMENT &#39;Sub parent commit resulting from merging&#39;, `ref_count` int unsigned NOT NULL DEFAULT 0 COMMENT &#39;Reference counting&#39;, `is_deleted` tinyint unsigned NOT NULL DEFAULT 0 COMMENT &#39;Delete Tag(0: No, 1: Yes)&#39;, `create_by` bigint NOT NULL COMMENT &#39;Create Code&#39;, `update_by` bigint NOT NULL COMMENT &#39;Last Update Code&#39;, `create_at` timestamp NOT NULL COMMENT &#39;Create Time&#39;, `update_at` timestamp NOT NULL COMMENT &#39;Update Time&#39;, KEY `idx_commit_path` (`commit_path`), KEY `idx_main_parent` (`main_parent`) ) ENGINE=InnoDB COMMENT=&#39;Relationship between resource versions.&#39;; 目录结构 系统使用树状目录结构维护资源，并通过为每个用户分配不同的的根节点，实现资源隔离。 这里并没有使用自增 ID 来维护资源间的关联关系，而是使用业务系统生成的字符串 ID，以方便后续实现 PBAC 鉴权。 表中的记录分为目录与文件两类，文件类型的记录有一个 HEAD 指针，指向最新的版本记录。 为了方便使用文件路径查询，该表对节点名称设置了唯一索引。 在实现逻辑删除时，需要对最顶层的节点重命名，避免该名称后续不可用。 其子节点无需进行该操作，简单设置删除标记即可。 CREATE TABLE IF NOT EXISTS `resource_path` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `node_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Node ID&#39;, `parent_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Parent ID&#39;, -- root path point to itself `node_name` varchar(50) NOT NULL COMMENT &#39;Node Name&#39;, `node_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, `node_type` tinyint unsigned NOT NULL COMMENT &#39;Type (0:Dir,1:File)&#39;, `fork_root` bigint unsigned NOT NULL COMMENT &#39;Fork Parent ID (maybe from other space)&#39;, `fork_parent` bigint unsigned NOT NULL COMMENT &#39;Fork Root ID (maybe from other space)&#39;, `commit_init` bigint unsigned NOT NULL COMMENT &#39;Initial commit&#39;, -- never change after init `commit_head` bigint unsigned NOT NULL COMMENT &#39;Commit head&#39;, `version_num` int unsigned NOT NULL DEFAULT 0 COMMENT &#39;Total number of commits&#39;, `is_deleted` tinyint unsigned NOT NULL DEFAULT 0 COMMENT &#39;Delete tag(0:No,1:Yes)&#39;, `is_rubbish` tinyint unsigned NOT NULL DEFAULT 0 COMMENT &#39;Recycle Bin Tag (0: No, 1: Yes)&#39;, `create_by` bigint NOT NULL COMMENT &#39;Create Code&#39;, `update_by` bigint NOT NULL COMMENT &#39;Last Update Code&#39;, `create_at` timestamp NOT NULL COMMENT &#39;Create Time&#39;, `update_at` timestamp NOT NULL COMMENT &#39;Update Time&#39;, UNIQUE KEY `uniq_node_id` (`node_id`), UNIQUE KEY `uniq_node_name` (`parent_id`,`node_name`), -- The uniqueness of parent_id is guaranteed by uniq_node_id KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Path of a resource tree node.&#39;; 实现细节 CTE 查询 当给定节点ID {nodeId} 时，可以使用以下语句查询出所有子节点： 为了避免性能问题，系统限制目录树的最大深度为 {maxDepth} 由于根节点先于所有子节点插入，因此使用根节点的自增ID {rootPk} 限定查询范围 WITH RECURSIVE path_view AS ( SELECT *, {maxDepth} lv FROM resource_path WHERE parent_id = {nodeId} AND is_deleted = 0 UNION ALL SELECT p.*, lv - 1 FROM path_view pv INNER JOIN resource_path p ON p.parent_id = pv.node_id WHERE lv &gt; 0 AND p.id &gt; {rootPk} AND p.is_deleted = 0 ) SELECT * FROM path_view ORDER BY lv DESC 当给定文件路径时，首先要将其转换为节点名称列表 {pathNames} ，然后使用以下语句查询出所有子节点： 使用根节点的自增ID {rootPk} 指定查询起点 使用 IN 语句的多值匹配特性降低 SQL 复杂度 WITH RECURSIVE path_view AS ( SELECT *, 0 lv FROM resource_path WHERE id = {rootPk} UNION ALL SELECT p.*, lv + 1 FROM path_view pv INNER JOIN resource_path p ON p.parent_id = pv.node_id WHERE lv &lt; {pathNames.size} AND (p.node_name, lv) IN (({pathNames[1]},1), ({pathNames[2]},2), ...) ) SELECT * FROM path_view 版本更新 文件版本的更新操作借鉴了 Git 概念： 提交：创建新版本，并移动 HEAD 引用（可通过 HEAD 实现乐观锁） @Data public class CommitOp { private Long dataId; // The asset pk refered by this commit private String dataHash; // The expect hash checksum of asset private String dataMeta; // The json metadata for this commit (optional) private String message; // Commit message private Long expectHead; // Optimistic locking with head (optional) private Long mergeCommit; // Specify sub_parent for merge op (optional) } 撤销：回滚至旧版本，并移动 HEAD 引用（可实现删除功能） @Data public class RevertOp { private Long checkoutCommit; // Set the path head to specific commit private Long deleteCommit; // Delete specific commit (optional) } 分叉：根据特定提交创建新版本 @Data public class ForkOp { private Long commitId; private String commitMsg; } 预签名URL 为了提升文件传输效率，可以利用 Minio 提供的预签名机制，从而允许前端直接与其进行交互。 以文件上传为例，整体流程如下： 根据 bucket 与 key 预先生成 SignedURL 生成一个全局唯一的 Token 记录 SignedURL 对应的 bucket 与 key 信息 前端使用 SignedURL 将文件上传至 Minio 使用 Token 检查上传情况并删除重复的文件 sequenceDiagram Browser-&gt;&gt;Service: getUploadToken() Service-&gt;&gt;Service: minioCli.getPresignedObjectUrl() Service-&gt;&gt;Browser: Token + SignedURL Browser-&gt;&gt;Minio: File Browser-&gt;&gt;Service: Token Service-&gt;&gt;Minio: minioCli.statObject() "},{"slug":"multitenancy-based-on-pbac","title":"基于 PBAC 实现多租户隔离","tags":["系统设计","后端开发","PBAC"],"content":"在完成 资源目录设计 后，接下来是多租户功能的实现，其中有两项核心功能： 组织架构 资源授权 接下来我们将拆解如何实现这两项功能。 基本概念 组织架构 清晰的组织架构是高效协作的前提：通过定义各个团队的职责和权力范围，可以避免混乱和冲突，从而提高整体工作效率。 一个典型组织架构通常由两类实体组成：团队与成员。 根据权责关系，不同的团队之间可以互相嵌套，形成一棵树或者 DAG 每个成员可以隶属于一个或者多个团队 权限控制模型 权限控制是保证组织数据安全的基石，目前常见的鉴权模型有 3 种： RBAC stateDiagram direction LR User --&gt; Role Role --&gt; Engine state Engine { PredefinedRules } Engine --&gt; Allow Engine --&gt; Deny 授权步骤： 将权限授予预定义好的角色 将组织中的团队或成员关联至具体的角色 鉴权步骤： 获取访问者的拥有的角色列表 判断角色中是否具备访问资源的权限 其优点是清晰易懂，鉴权过程简单高效。 缺点是粒度较粗，并且可能出现组合爆炸的情况。 ABAC stateDiagram direction LR UserAttrs --&gt; Engine ResourceAttrs --&gt; Engine EnvContext --&gt; Engine state Engine { PredefinedRules } Engine --&gt; Allow Engine --&gt; Deny 授权步骤： 预定义鉴权是需要的属性 用户属性（角色、部门 ...） 资源熟悉（类型、所有者 ...） 环境上下文（时间、地点、设备 ...） 围绕预定义好的属性实现鉴权引擎 根据需要在鉴权引擎中维护鉴权规则 鉴权步骤： 获取用户属性、资源属性与环境上下文 鉴权引擎根据属性查找相关的鉴权规则 判断用户是否具备访问权限 其优点是授权粒度细，可以实现灵活的鉴权策略。 缺点是系统复杂难以理解，且维护成本高。 PBAC stateDiagram direction LR state Policy { UserAttrs ResourceAttrs EnvContext Rules } Policy --&gt; Engine Engine --&gt; Allow Engine --&gt; Deny PBAC 与 ABAC 十分相似，唯一的差异是将维护鉴权规则的责任交给了授权者： 鉴权引擎提供描述鉴权规则的语言（通常是JSON） 授权者通过编写规则实现授权 这一设计在保留 ABAC 优点的同时，避免了鉴权引擎与具体规则间的耦合，降低了系统维护成本。 系统建模 业务功能点梳理： 每个用户有自己的存储空间 可以邀请其他用户加入当前空间成为成员，一个用户在不同空间内具有不同的成员身份 空间管理员内可以建立团队与角色，并为成员赋予特定的职责 空间管理员可以创建 Key (AccessKey + SecretKey) 实现免登陆访问 实现灵活的授权策略 管理员可以为角色、团队、成员、Key独立分配角色 团队和 Key 可以从角色继承访问权限 成员可以从团队继承访问权限 可以指定权限到期日期 erDiagram User ||--|| Space : own User ||--o{ Member : is Space ||--o{ Role : has Space ||--o{ Team : has Space ||--o{ Member : has Space ||--o{ Key : has Role }o--o{ Link : refer Team }o--o{ Link : refer Member }o--o{ Link : refer Key }o--o{ Link : refer 基础信息 user 与 space 是整个多租户模块的最基础的实体，一旦创建不可变更。 CREATE TABLE IF NOT EXISTS `user` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `uuid` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;User ID&#39;, `name` varchar(50) NOT NULL COMMENT &#39;Nick Name&#39;, `mobile` varchar(50) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;Phone Number&#39;, `email` varchar(100) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;Email&#39;, ... UNIQUE KEY `uniq_uuid` (`uuid`), KEY `idx_email` (`email`) ) ENGINE=InnoDB COMMENT=&#39;Basic user information.&#39;; CREATE TABLE IF NOT EXISTS `space` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space unique identifier character&#39;, `name` varchar(50) NOT NULL COMMENT &#39;Space Name&#39;, `bucket` varchar(50) NOT NULL COMMENT &#39;Space Bucket&#39;, `props` json NOT NULL DEFAULT (JSON_OBJECT()) COMMENT &#39;Space properties&#39;, ... UNIQUE KEY `uniq_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Workspace for resource isolation.&#39;; 组织结构 空间内可能存在的实体类型：unit_role、unit_team、unit_member、unit_key。 权限传递关系通过 unit_link 表示，总共包含以下 4 类传递关系： Team -&gt; Member Role -&gt; Team Role -&gt; Member Role -&gt; Key CREATE TABLE IF NOT EXISTS `unit_role` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `role_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Role ID&#39;, `role_name` varchar(100) NOT NULL COMMENT &#39;Role Name&#39;, `role_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, ... UNIQUE KEY `uniq_role_id` (`role_id`), KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Authorized roles in a certain workspace.&#39;; CREATE TABLE IF NOT EXISTS `unit_team` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `team_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Team ID&#39;, `parent_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Team ID&#39;, `team_name` varchar(50) NOT NULL COMMENT &#39;Department Name&#39;, `team_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, ... UNIQUE KEY `uniq_team_id` (`team_id`), UNIQUE KEY `uniq_team_name` (`parent_id`,`team_name`), -- The uniqueness of parent_id is guaranteed by uniq_node_id KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Organization information in a certain workspace.&#39;; CREATE TABLE IF NOT EXISTS `unit_member` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `user_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;User UUID&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `member_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Member ID&#39;, `member_name` varchar(255) NOT NULL COMMENT &#39;Member Name&#39;, `member_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, `job_number` varchar(60) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;Job Number&#39;, `position` varchar(255) NOT NULL DEFAULT &#39;&#39; COMMENT &#39;Position&#39;, ... UNIQUE KEY `uniq_member_id` (`member_id`), KEY `idx_user_id` (`user_id`), KEY `idx_mobile` (`mobile`), KEY `idx_email` (`email`) ) ENGINE=InnoDB COMMENT=&#39;Member information in a certain workspace.&#39;; CREATE TABLE IF NOT EXISTS `unit_key` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `key_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Key ID&#39;, `key_name` varchar(100) NOT NULL COMMENT &#39;Key Name&#39;, `key_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, `access_key` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Access Key&#39;, `secret_key` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Secret Key&#39;, ... UNIQUE KEY `uniq_key_id` (`key_id`), UNIQUE KEY `uniq_access_key` (`access_key`), KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Access keys in a certain workspace.&#39;; CREATE TABLE IF NOT EXISTS `unit_link` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `link_index` double NOT NULL DEFAULT 0 COMMENT &#39;Sort&#39;, `link_type` tinyint unsigned NOT NULL COMMENT &#39;Link Type(0: Team-Member, 1: Role-Team, 2: Role-Member, 3:Role-Key)&#39;, `main_unit_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Main Unit(0: Team, 1: Role, 2: Role, 3:Role)&#39;, `sub_unit_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Sub Unit(0: Member, 1: Team, 2: Member, 3:Key)&#39;, ... UNIQUE KEY `uniq_link_pair` (`main_unit_id`,`sub_unit_id`), KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Permission delegation link.&#39;; 授权策略 在 PBAC 模型中的 policy 定义借鉴 AWS IAM 并进行简化： 规则效果（Allow / Deny） 授权主体（角色、团队 ...） 访问行为（修改、删除 ...） 访问资源 条件表达式 CREATE TABLE IF NOT EXISTS `policy` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT &#39;PK&#39;, `space_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Space ID&#39;, `policy_id` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Policy ID&#39;, `principal_type` tinyint unsigned NOT NULL COMMENT &#39;Principal Type(0: Role, 1: Team, 2: Member, 3: Key, 4: User)&#39;, `resource_type` tinyint unsigned NOT NULL COMMENT &#39;Resource Type&#39;, `principal` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Principal ID&#39;, `resource` varchar(50) COLLATE utf8mb4_bin NOT NULL COMMENT &#39;Resource ID&#39;, `effect` tinyint unsigned NOT NULL COMMENT &#39;Policy effect(0: Deny, 1: Allow)&#39;, `action` json NOT NULL COMMENT &#39;Policy action array&#39;, `condition` json NOT NULL COMMENT &#39;Policy condition object&#39;, ... UNIQUE KEY `uniq_policy_id` (`policy_id`), KEY `idx_space_id` (`space_id`) ) ENGINE=InnoDB COMMENT=&#39;Authorization policy.&#39;; 访问行为 以下是系统中预定义的行为类型：每个通配符都有一个域前缀，可以通过通配符实现批量关联这个域下的所有行为。 使用通配符的好处：当该域后续新增行为时，使用通配符的策略会默认引入该行为，避免批量修改。 @Getter @RequiredArgsConstructor @Accessors(fluent = true) public enum PolicyAction { AnyAdmAction(&quot;adm:*&quot;), AnyResAction(&quot;res:*&quot;), AnyOrgAction(&quot;org:*&quot;), AdmAddTeam(&quot;adm:addTeam&quot;), AdmRemoveTeam(&quot;adm:removeTeam&quot;), AdmAddRole(&quot;adm:addRole&quot;), AdmRemoveRole(&quot;adm:removeRole&quot;), AdmAddMember(&quot;adm:addMember&quot;), AdmRemoveMember(&quot;adm:removeMember&quot;), AdmAddKey(&quot;adm:addKey&quot;), AdmRemoveKey(&quot;adm:removeKey&quot;), AdmGrantTeam(&quot;adm:grantTeam&quot;), AdmGrantRole(&quot;adm:grantRole&quot;), AdmGrantPolicy(&quot;adm:grantPolicy&quot;), ResListDir(&quot;res:listDir&quot;), ResCreateDir(&quot;res:createDir&quot;), ResDeleteDir(&quot;res:deleteDir&quot;), ResForceDelDir(&quot;res:forceDelDir&quot;), ResReadNode(&quot;res:readNode&quot;), ResEditNode(&quot;res:editNode&quot;), ResCreateNode(&quot;res:createNode&quot;), ResDeleteNode(&quot;res:deleteNode&quot;), ... ; } 条件表达式 条件表达式是一个 JSON 字符串，可以表达复杂的规则： 当表达式结果为 true 时，规则生效 当表达式结果为 false 时，规则不生效 如果不需要支持复杂的匹配功能，只需将其设置为常量 true 即可。 @RequiredArgsConstructor public abstract class PolicyMatcher { private final String expression; public abstract boolean match(PolicyContext ctx); static class TrueMatcher extends PolicyMatcher { TrueMatcher() { super(&quot;true&quot;); } @Override public boolean match(PolicyContext ctx) { return true; } } static PolicyMatcher compile(String expression) { if (&quot;true&quot;.equals(expression)) { return new TrueMatcher(); } ... } } 实现细节 资源可见性 按照目前的设计，资源访问权限具有传递性： 如果用户有父节点的访问权限，则其默认对子节点也有访问权限 如果用户无父节点的访问权限，则其默认对子节点也无访问权限 但是管理员也可以通过配置达到以下效果： 父节点有访问权限，但是子节点无访问权限（通过 Deny 规则实现） 父节点无访问权限，但是子节点具有访问权限（通过 Allow 规则实现） flowchart TB root(Root) --- a(A) root(Root) --- b(B) a(A)---a1(A1) a(A)---a2(A2) subgraph aa[&quot; &quot;] a1(A1)---a11(...) a1(A1)---a12(...) end a2(A2)---a21(...) a2(A2)---a22(...) b(B)---b1(B1) b(B)---b2(B2) subgraph bb[&quot; &quot;] b1(B1)---b11(...) b1(B1)---b12(...) end b2(B2)---b21(...) b2(B2)---b22(...) classDef group fill:none,stroke-width:2px,stroke-dasharray: 5 5 classDef allow fill:#e0eafd,stroke:#5084fd,stroke-width:3px classDef deny fill:#ffa640,stroke:#f76b66,stroke-width:3px class root,a,a2,a21,a22,b1,b11,b12 allow; class b,a1,a11,a12,b2,b21,b22 deny; class aa,bb group; flowchart LR Allow(Allow):::allow ~~~ Deny(Deny):::deny classDef allow fill:#e0eafd,stroke:#5084fd,stroke-width:3px classDef deny fill:#ffa640,stroke:#f76b66,stroke-width:3px 为了保证展示的完整性，只要子节点存在，则父节点就必须是对用户可见的： 当用户获取 A 节点的子节点时，流程如下： 用户查询 A 节点的访问列表，发现本身具备访问权限 查询 A 节点的子节点信息，得到 A1 与 A2 两个节点 查询规则树，发现 A1 存在 Deny 规则，因此将 A1 节点从返回列表中剔除 返回 A2 数据给用户 当用户获取 B 节点的子节点时，流程如下： 用户查询 B 节点的访问列表，发现本身不具备访问权限 查询规则树，发现 B1 存在 Allow 规则，因此判断 B 节点具有访问权限 查询 B 节点的子节点信息，得到 B1 与 B2 两个节点 返回 B1 数据给用户 ACL 与 ACT 为了实现上述的资源可见性，需要引入两个数据结构实现匹配规则： ACL 仅加载与特定资源相关的策略，并判断用户是否具备访问该资源的权限 List&lt;ResourcePath&gt; paths = resourceService.getResourcesById(nodeId); List&lt;Policy&gt; policies = policyService.getPolicies(spaceId, ResourceType.Path); PolicyACL&lt;ResourcePath&gt; ctrl = PolicyACL.attachPolicyToResource(paths, policies, ResourcePath::getNodeId); Preconditions.checkState(ctrl.hasPermission(currentContext(), PolicyAction.ResListDir)); ACT 则会加载出所有资源的的策略，判断当前用户是否具备访问该资源的权限 List&lt;ResourcePath&gt; nodes = resourceService.getResources(rootId); List&lt;Policy&gt; policies = policyService.getPolicies(spaceId, ResourceType.Path); PolicyACT&lt;ResourceNode&gt; tree = PolicyACT.buildTreeAndGetRoot(nodes, ResourceNode::new, ResourcePath::getNodeId, ResourcePath::getParentId); tree.attachPolicyToTree(policies); for (ResourcePath node: nodes) { Preconditions.checkState(tree.hasAllowChild(node.getNodeId(), currentContext(), PolicyAction.ResListDir)); Preconditions.checkState(!tree.hasDenyParent(node.getNodeId(), currentContext(), PolicyAction.ResListDir)); } AccessControlList @Data @Accessors(chain = true) @RequiredArgsConstructor public class PolicyCarrier&lt;R&gt; { private static class CarrierChain { CarrierNode allowances; CarrierNode denials; @Override public String toString() { List&lt;PolicyMatcher&gt; allow = null; List&lt;PolicyMatcher&gt; deny = null; if (allowances != null) allowances.visit((allow = new ArrayList&lt;&gt;())::add); if (denials != null) denials.visit((deny = new ArrayList&lt;&gt;())::add); return &quot;(&quot; + &quot;allow:&quot; + allow + &quot;, deny:&quot; + deny + &#39;)&#39;; } } private record CarrierNode(PolicyMatcher matcher, CarrierNode next) { boolean match(PolicyContext context) { CarrierNode node = this; while (node != null &amp;&amp; !node.matcher.match(context)) node = node.next; return node != null; } void visit(Consumer&lt;PolicyMatcher&gt; visitor) { CarrierNode node = this; while (node != null) { visitor.accept(node.matcher); node = node.next; } } } R object; Map&lt;PolicyAction, CarrierChain&gt; matchers; public void addMatcher(PolicyAction action, PolicyEffect effect, PolicyMatcher matcher) { if (matchers == null) matchers = new IdentityHashMap&lt;&gt;(0); CarrierChain chain = matchers.computeIfAbsent(action, k -&gt; new CarrierChain()); switch (effect) { case Allow -&gt; chain.allowances = new CarrierNode(matcher, chain.allowances); case Deny -&gt; chain.denials = new CarrierNode(matcher, chain.denials); } } public PolicyEffect matchEffect(PolicyContext context, PolicyAction action) { if (matchers != null) { CarrierChain chain = matchers.get(action); if (chain != null) { // Allow effect has higher priority if (chain.allowances != null &amp;&amp; chain.allowances.match(context)) { return PolicyEffect.Allow; } if (chain.denials != null &amp;&amp; chain.denials.match(context)) { return PolicyEffect.Deny; } } } return null; } } /** * ACL is a node list, indicating the path from the root node to the current node &lt;p/&gt; * Pass the permission information of the parent node to the child node through bottom-up query */ public class PolicyACL&lt;R&gt; extends ArrayList&lt;PolicyCarrier&lt;R&gt;&gt; { public PolicyACL(int capacity) { super(capacity); } public static &lt;T&gt; PolicyACL&lt;T&gt; attachPolicyToResource(List&lt;T&gt; resources, List&lt;Policy&gt; policies, Function&lt;T,String&gt; toId) { PolicyACL&lt;T&gt; acl = new PolicyACL&lt;&gt;(resources.size()); for (T res : resources) { PolicyCarrier&lt;T&gt; attach = new PolicyCarrier&lt;T&gt;().setObject(res); for (Policy policy : policies) { // The resource ID may be repeated, can&#39;t use map here if (policy.getResource().equals(toId.apply(res))) { PolicyMatcher condition = PolicyMatcher.compile(policy.getCondition()); PolicyAction.parse(policy.getAction(), action -&gt; attach.addMatcher(action, policy.getEffect(), condition)); } } acl.add(attach); } return acl; } public void checkAllPermission(PolicyContext context, Collection&lt;PolicyAction&gt; actions) { Checker.check(!hasAllPermission(context, actions), PermissionException.NODE_ACCESS_DENIED); } public boolean hasAllPermission(PolicyContext context, Collection&lt;PolicyAction&gt; actions) { for (PolicyAction action : actions) { if (!hasPermission(context, action)) return false; } return !actions.isEmpty(); } public void checkAnyPermission(PolicyContext context, Collection&lt;PolicyAction&gt; actions) { Checker.check(!hasAnyPermission(context, actions), PermissionException.NODE_ACCESS_DENIED); } public boolean hasAnyPermission(PolicyContext context, Collection&lt;PolicyAction&gt; actions) { for (PolicyAction action : actions) { if (hasPermission(context, action)) return true; } return false; } public void checkPermission(PolicyContext context, PolicyAction action) { Checker.check(!hasPermission(context, action), PermissionException.NODE_ACCESS_DENIED); } // Bottom-up query public boolean hasPermission(PolicyContext context, PolicyAction action) { for (int i = size()-1; i &gt;= 0; i--) { PolicyEffect effect = get(i).matchEffect(context, action); if (effect != null) { // Inherit permissions from the nearest parent node that specifies permissions return effect == PolicyEffect.Allow; } } return false; } public R resource() { return isEmpty() ? null : get(size()-1).getObject(); } } AccessControlTree @Setter @RequiredArgsConstructor @SuppressWarnings(&quot;unchecked&quot;) public class PolicyNode&lt;T&gt; extends PolicyCarrier&lt;T&gt; { PolicyNode&lt;T&gt; parent; List&lt;PolicyNode&lt;T&gt;&gt; children; public &lt;Node extends PolicyNode&lt;T&gt;&gt; Node getParent() { return (Node) parent; } public &lt;Node extends PolicyNode&lt;T&gt;&gt; List&lt;Node&gt; getChildren() { return (List&lt;Node&gt;) children; } public boolean containsNode(Predicate&lt;T&gt; predicate) { if (predicate.test(getObject())) { return true; } return hasChildren(predicate, false); } public boolean hasChildren(Predicate&lt;T&gt; predicate, boolean direct) { Deque&lt;PolicyNode&lt;T&gt;&gt; stack = new ArrayDeque&lt;&gt;(); stack.push(this); boolean visited = false; while(!stack.isEmpty()) { PolicyNode&lt;T&gt; node = stack.pop(); if (ObjectUtils.isNotEmpty(node.getChildren()) &amp;&amp; (!visited || !direct)) { for (PolicyNode&lt;T&gt; child : node.getChildren()) { if (predicate.test(child.getObject())) { return true; } } stack.addAll(node.getChildren()); } visited = true; } return false; } @Override public String toString() { return &quot;Node(&quot; + &quot;obj=&quot; + getObject() + &quot;, matchers=&quot; + getMatchers() + &#39;)&#39;; } } /** * ACT is a sparse node tree that only contains nodes for which the user has explicitly specified rules&lt;p/&gt; * Pass the permission information of the child node to the parent node through top-down query */ @SuppressWarnings({&quot;unchecked&quot;,&quot;rawtypes&quot;}) public record PolicyACT&lt;Node extends PolicyNode&gt;(Node root, Map&lt;String, Node&gt; lookup) { public static &lt;T, Node extends PolicyNode&lt;T&gt;&gt; PolicyACT&lt;Node&gt; buildTreeAndGetRoot( List&lt;T&gt; resources, Supplier&lt;Node&gt; newNode, Function&lt;T, String&gt; toId, Function&lt;T, String&gt; toParentId) { List&lt;PolicyACT&lt;Node&gt;&gt; trees = buildTreeAndGetRoots(resources, newNode, toId, toParentId); Checker.check(trees.size() != 1, &quot;unexpected tree topology&quot;); return trees.get(0); } public static &lt;T, Node extends PolicyNode&lt;T&gt;&gt; List&lt;PolicyACT&lt;Node&gt;&gt; buildTreeAndGetRoots(List&lt;T&gt; resources, Supplier&lt;Node&gt; newNode, Function&lt;T, String&gt; toId, Function&lt;T, String&gt; toParentId) { Map&lt;String, Node&gt; lookup = new HashMap&lt;&gt;(); resources.forEach(x -&gt; lookup.put(toId.apply(x), (Node) newNode.get().setObject(x))); for (Node node : lookup.values()) { Node parent = lookup.get(toParentId.apply(node.getObject())); if (parent != null &amp;&amp; parent != node) { node.setParent(parent); if (parent.getChildren() == null) { parent.setChildren(new ArrayList&lt;&gt;()); } parent.getChildren().add(node); } } List&lt;PolicyACT&lt;Node&gt;&gt; roots = new ArrayList&lt;&gt;(1); for (Node node : lookup.values()) { if (node.getParent() == null) { roots.add(new PolicyACT&lt;&gt;(node, lookup)); } } return roots; } public void attachPolicyToTree(List&lt;Policy&gt; policies) { for (Policy policy : policies) { PolicyMatcher condition = PolicyMatcher.compile(policy.getCondition()); Node node = lookup().get(policy.getResource()); PolicyAction.parse(policy.getAction(), action -&gt; node.addMatcher(action, policy.getEffect(), condition)); } } // Top-down query public boolean hasAllowChild(String nodeId, PolicyContext context, PolicyAction action) { Node node = lookup().get(nodeId); if (node != null) { Queue&lt;Node&gt; queue = new LinkedList&lt;&gt;(); queue.add(node); while (!queue.isEmpty()) { Node n = queue.poll(); PolicyEffect effect = n.matchEffect(context, action); if (effect == PolicyEffect.Allow) { return true; // Return if any child node is specified with Allow } if (ObjectUtils.isNotEmpty(n.getChildren())) { queue.addAll(n.getChildren()); } } } return false; } // Bottom-up query public boolean hasDenyParent(String nodeId, PolicyContext context, PolicyAction action) { Node node = lookup().get(nodeId); while (node != null) { PolicyEffect effect = node.matchEffect(context, action); if (effect == PolicyEffect.Deny) { return true; // Return if any parent node is specified with Deny } node = (Node) node.getParent(); } return false; } } "},{"slug":"opening-remarks","title":"开篇闲聊 —— 我所经历的前端技术演进","tags":["前端开发"],"content":"作为一个略懂前端的全栈开发者，在这里简单回顾一下近几年的所见所闻，以此作为这个博客的开篇。 初代王者 JQuery 刚毕业时，进入了一家电商公司做 web 应用开发。 公司处于初创阶段，一切事物都显得自由奔放，技术栈更是如此。 当时的架构师夹带私货，把他业余时间鼓捣出来的“全功能商城系统”作为所有项目的的开发模版。 不出意外地，意外出现了：第一次上线秒杀抢购活动，200 不到的 TPS 直接把系统干垮了…… 半年过后公司黄了。虽然老板亏麻了，我也失业了，但这位大佬成功市中心上车豪宅。 看了一下他的近况，发现商城系统已经被包装成 SaaS，静静等待下个冤大头上门。 作为这个系统的受害者之一，我在这里简单分享一下其前端技术栈： 由于项目开发语言是 Java， 因此所有 HTML 页面都是通过 JSP 动态渲染生成的。 并在此基础上，引入了下面两个前端框架： JQuery jQuery 于 2006 年发布，是一个快速而轻量的 JavaScript 库，旨在简化 HTML 的客户端脚本编写。 (function($) { // 经典闭包 var hiddenBox = $( &quot;#banner-message&quot; ); // 事件响应 + DOM动画 $( &quot;#button-container button&quot; ).on( &quot;click&quot;, function( event ) { hiddenBox.show(); }); $.ajax({ // Ajax 异步通信 url: &quot;/api/getWeather&quot;, data: { zipcode: 97201 }, success: function( result ) { $( &quot;#weather-temp&quot; ).html( &quot;&lt;strong&gt;&quot; + result + &quot;&lt;/strong&gt; degrees&quot; ); } }); })(jQuery); Bootstrap Bootstrap 于 2011 年发布，基于类名预定义了一套统一的 CSS 样式，用于创建多端一致的用户界面。 &lt;!DOCTYPE html&gt; &lt;head&gt; &lt;link rel=&quot;stylesheet&quot; href=&quot;./css/bootstrap.min.css&quot;&gt; &lt;/head&gt; &lt;body&gt; &lt;div class=&quot;container&quot;&gt; &lt;div class=&quot;row&quot;&gt; &lt;button type=&quot;button&quot; class=&quot;btn btn-primary&quot;&gt;Large&lt;/button&gt; &lt;!-- 蓝色普通按钮 --&gt; &lt;button type=&quot;button&quot; class=&quot;btn btn-success btn-lg&quot;&gt;Small&lt;/button&gt; &lt;!-- 绿色大号按钮 --&gt; &lt;button type=&quot;button&quot; class=&quot;btn btn-warning btn-sm&quot;&gt;XSmall&lt;/button&gt; &lt;!-- 橙色小号按钮 --&gt; &lt;/div&gt; &lt;/div&gt; &lt;/body&gt; &lt;/html&gt; 这两个框架之所以流行，还和当时的历史背景相关 —— 浏览器兼容性。 彼时 HTML5 规范尚未完全普及，并且大部分浏览器的对该标准的支持也并不完善。 开发者不仅要面对 firefox、chrome 这类功能不稳定的浏览器新秀，还有凭借 windows 高占有率为祸一方的 IE。 在这一时期，JQuery 与 Bootstrap 不仅提供了开箱即用的跨浏览器兼容性。 并且还提供了众多 polyfill 插件，使 IE 这类老旧的浏览器也能支持部分 HTML5 特性。 这极大提升了开发效率，于是众多开发者纷纷投入他们的怀抱。 魔法起源 Angular 后来入职了一家物流公司，负责内部管理系统的全栈开发。 在这里初次接触到前后端分离的概念，并开始学习 NPM 相关生态。 这段时期的我技术比较菜，但学习热情高涨。 在领导画饼的激励下，用百度地图 API 搞出了一个 “快递站点边界管理系统”，这是我职业生涯的第一个独立完成的项目。 虽然最终由于 ”发展机会“ 不足而离开了公司，但还是十分感谢当时的领导和同事们，从他们上学到了很多。 SPA 随着 HTML5 规范被广泛支持，兼容性不再是前端领域的重要议题。 越来越严苛的用户体验要求，成为了开发者需要面临的新挑战。 在各种因素中，影响最大的莫过于页面刷新操作： 每次页面跳转都需要发起一次网络请求，网络通信延迟影响了页面的加载速度。 JS 与 CSS 这类资源文件需要重新加载并初始化，进一步增加了页面渲染时间。 为了从根本上解决这一问题，单页应用 (Single Page Application) 这一设计模式应运而生。 其关键特征如下： 动态加载： SPA 首次加载 HTML、CSS 和 JavaScript 资源，然后在用户与应用程序交互时动态更新内容。 客户端路由： SPA 使用客户端路由来管理应用程序内的导航。基于用户交互，URL 动态更改，但实际页面不重新加载。 性能提升： 由于 SPA 只获取更新当前视图所需的数据，因此通常减少了客户端和服务器之间传输的数据量，从而提高了性能。 使用 SPA 模式的应用具备以下优点 增强用户体验： SPA 通过消除全页面重新加载的延迟，提供更流畅和引人入胜的用户体验。 减轻服务器负担： SPA 仅请求必要的数据，减少了服务器的负荷，优化了带宽使用。 前后端分离： 通过 API 对前后端进行分离，通过专业化的技术团队建设，进一步提升组织的开发效率。 在 SPA 模式下，客户端需要实现大量复杂的 DOM 监听与修改逻辑，以常见的提交表单操作为例： 实时监听用户对表单的修改行为，当数据有误时及时提醒用户 用户点击提交按钮后，需要将其将禁用，避免重复提交 监听到 Ajax 返回时，需要提示用户操作成功，并清空表单数据 $(document).ready(function() { // 监听用户修改 $(&#39;#numberInput&#39;).on(&#39;input&#39;, function() { var inputValue = $(this).val(); if ($.isNumeric(inputValue)) { // 校验数据 $(&#39;button[type=&quot;submit&quot;]&#39;).prop(&#39;disabled&#39;, false); } else { $(&#39;button[type=&quot;submit&quot;]&#39;).prop(&#39;disabled&#39;, true); } }); // 监听表单提交 $(&#39;#myForm&#39;).submit(function(event) { var inputValue = $(&#39;#numberInput&#39;).val(); if ($.isNumeric(inputValue)) { $.ajax({ // 发起请求 url: &#39;your_ajax_endpoint&#39;, type: &#39;POST&#39;, data: { number: inputValue }, success: function(response) { $(&#39;#numberInput&#39;).val(&#39;&#39;); // 清空表单 alert(&#39;Ajax Request Successful&#39;); }, error: function() { alert(&#39;An error occurred during the Ajax request&#39;); } }); } else { alert(&#39;Please enter a valid number&#39;); } }); }); 注意，这还是表单仅包含一个字段的情况。 正常情况下表单不仅会包含多个字段，并且字段间还可能产生联动。 AngularJS 顺应 SPA 的潮流，Google 于 2010 年发布了名为 AngularJS 的 JavaScript 的前端框架。 AngularJS 通过采用更具声明性和表达性的语法，引领了一种新的开发范式，极大提升了 SPA 开发效率。 AngularJS 开创性地提供了两个魔法般的特性： 双向数据绑定： AngularJS 在模型和视图之间建立了无缝连接。模型的任何更改都会自动反映在视图中，反之亦然。 指令： AngularJS 扩展了HTML语法，使得可以创建定制和可重用的组件。它们增强了应用程序的结构和功能。 下面是一段能够直接运行的 HTML 代码，用户无需编写任何 JS 代码，就可以通过指令实现 DOM 之间的联动： &lt;!DOCTYPE html&gt; &lt;html&gt; &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/angularjs/1.6.9/angular.min.js&quot;&gt;&lt;/script&gt; &lt;body&gt; &lt;!-- ng-app 指令表示该 DOM 是 SPA 应用的根元素 --&gt; &lt;form ng-app=&quot;&quot; name=&quot;myForm&quot;&gt; &lt;!-- ng-model 指令定义需要双向绑定的字段，可以同时指定校验条件 --&gt; &lt;p&gt;Number : &lt;input type=&quot;number&quot; min=&quot;0&quot; max=&quot;99&quot; ng-model=&quot;numberInput&quot;&gt;&lt;/p&gt; &lt;!-- ng-show 指令监听表单校验结果，并动态提示错误 --&gt; &lt;span ng-show=&quot;!myForm.$valid&quot;&gt;Invalid number!&lt;/span&gt; &lt;/form&gt; &lt;/body&gt; &lt;/html&gt; 相较于 jQuery，AngularJS 实现的 DOM 绑定功能更为简洁，并且元素间的联动十分顺滑，极大减少了重复的模板代码。 更为重要的是，AngularJS 将原本的过程式编程，转换为基于指令的声明式编程，开创了新的前端开发范式。 效率至上 React 再后来入职了一家数字金融公司，负责一些历史数据的管理，并开始转向纯粹的后端开发。 某一次公司提出要盘活海量的成交明细数据，同时满足人工查询与合规审查的需要。 我给出了一个基于 HBase 的实现方案，存储 10 年数据只需要 6TB 空间即可。 但由于各种各样的原因，大数据运维与前端资源一直没有到位，于是我只能一手包办整个流程。 期间开始了解 React 相关生态，并使用 AntDesign 搞了个后台管理系统。 React 的出现，是为了解决日趋复杂的用户交互场景（数据可视化、在线协作……）下的开发痛点。 与传统的表单应用不同，这类场景面临着两个新问题： 复杂的状态管理： 状态变更不再是简单的线性流程，需要管理多个变更事件间的依赖与传递。 DOM节点渲染效率低： 单个变更事件可能触发多个 DOM 重绘，频繁更改会消耗大量浏览器资源。 为了解决以上问题，Facebook 于 2013 推出了 React 框架，其特点如下： 单向数据流： 遵循单向数据流，确保数据变化以可预测的方式处理，避免双向绑定引入的额外复杂度。 声明性语法： 基于JSX模板提供了声明性语法，开发者只需描述期望的结果，而React负责高效地更新DOM。 虚拟DOM： 虚拟DOM是真实DOM的轻量级副本，可以通过比较两者间的差异实现增量更新，减少不必要的渲染。 下面是一段能够直接运行的 HTML 代码，实现稍显复杂，但是语法灵活度比 AngularJS 高出了一个等级： &lt;html&gt; &lt;body&gt; &lt;div id=&quot;root&quot;&gt;&lt;/div&gt; &lt;script src=&quot;https://unpkg.com/babel-standalone@6.26.0/babel.min.js&quot;&gt;&lt;/script&gt; &lt;script src=&quot;https://unpkg.com/react@17.0.2/umd/react.development.js&quot;&gt;&lt;/script&gt; &lt;script src=&quot;https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js&quot;&gt;&lt;/script&gt; &lt;script type=&quot;text/babel&quot;&gt; const MyForm = () =&gt; { // 声明一个函数组件 const [invalid, setInvalid] = React.useState(false) // 使用 hook 函数声明数值校验状态 const validateInput = (e) =&gt; { // 通过回调函数监听变化，并实时修改校验状态 const num = e.target.value setInvalid(!!e.target.value &amp;&amp; (num &lt; 0 || num &gt; 99)) } return ( // 声明 JSX 模板，当状态发生变化时，React 会根据该模板自动重绘 DOM 节点 &lt;form id=&quot;myForm&quot;&gt; &lt;p&gt;Number :&lt;input type=&quot;text&quot; onChange={validateInput}/&gt;&lt;/p&gt; {invalid &amp;&amp; &lt;span&gt;Invalid number!&lt;/span&gt;} &lt;/form&gt; ) } // 渲染 JSX 组件 ReactDOM.render(&lt;MyForm/&gt;, document.getElementById(&#39;root&#39;)) &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; 返璞归真 Nextjs 随着时间的流逝，愈发感受受到记录的重要性，于是萌发出搭建个人博客的想法。 博客需满足以下要求： 文章使用 markdown 编写，方便切换前端技术栈 构建静态资源站点，有利于 SEO 并降低服务器成本 市面上找了一圈，发现 Nextjs 是一个不错的选择。 于是经过一番折腾，这篇文章最终出现在了你的眼前。 SSR 虽然 SPA 能够提供不错的用户体验，但这种设计模式本身也存在一些固有的缺陷： 首屏加载慢： 由于所有客户端逻辑都打包进一个 bundle.js 文件中，首次加载会耗费大量时间下载与执行 JS 代码，最终导致较长的白屏时间。 不利于SEO： 所有页面内容都是浏览器动态渲染出来的，对于只解析静态 HTML 的搜索引擎很不友好，影响网站搜索排名，不利于企业推广。 为了解决这一问题，出现了一批支持 服务端渲染 (Server-Side Rendering) 的前端解决方案。 在服务端对页面进行预渲染操作，可以减少客户端所需的渲染时间，压缩 bundle.js 文件中不必要的逻辑，提升页面加载速度。 返回给客户端的文件中包含完整的 HTML 页面，搜索引擎可以轻松抓取和索引内容，有利于提升网站的搜索排名。 SSG 与 SSR 类似，还有一种被称为 静态站点生成 (Static Site Generation) 的功能。 SSG 会在网页构建过程中，提前生成一组静态 HTML、CSS 和 JavaScript 文件，当接收到请求时可以直接返回给给用户，不再需要渲染页面。 相较于 SSR ，SSG 具备两个额外的优势： 无需渲染页面，不仅能降低服务端的负载，还能提供更快的加载时间，降本增效神器。 的静态文件可以轻松分发和缓存在 CDN 上，这确保了站点在全球范围内的可用性，并为全球用户提供更快的访问速度。 Nextjs 在 React 领域中最为流行的 SSR 方案是 Nextjs。 该框架简化了构建服务器渲染 React 应用程序的过程。并提供了一套规范和工具，帮助开发人员专注于构建应用程序的功能，而不是处理配置。 Nextjs 同时支持 SSR 与 SSG 模式，两者区别主要是部署环境： 如果项目需要支持 SSR 特性，则必须将项目部署在 Node.js 环境中 如果项目中仅包含 SSG 特性，则可以部署在任意静态服务器上，比如 Nginx SSR 模式需要实现 getServerSideProps 函数，每次用户请求时都会调用该函数。 import type { InferGetServerSidePropsType, GetServerSideProps } from &#39;next&#39; type Repo = { name: string stargazers_count: number } // 服务端发起请求，实时获取 github 仓库的星标数量 export const getServerSideProps = (async (context) =&gt; { const res = await fetch(&#39;https://api.github.com/repos/vercel/next.js&#39;) const repo = await res.json() return { props: { repo } } }) satisfies GetServerSideProps&lt;{ repo: Repo }&gt; // 根据仓库星标数量渲染页面，并返回给客户端 export default function Page({ repo, }: InferGetServerSidePropsType&lt;typeof getServerSideProps&gt;) { return repo.stargazers_count } SSG 模式需要实现 getStaticProps 函数，该函数只在构建页面时被调用一次。 // 获取博客文章列表 export async function getStaticProps() { const res = await fetch(&#39;https://.../posts&#39;) const posts = await res.json() return { props: { posts, }, } } // 根据文章列表生成静态页面 export default function Blog({ posts }) { return ( &lt;ul&gt; {posts.map((post) =&gt; ( &lt;li&gt;{post.title}&lt;/li&gt; ))} &lt;/ul&gt; ) } 此外，Nextjs 还提供了以下的重要功能： 代码分割： 将全局统一的入口文件 bundle.js 拆分为多个 chunk.js，每个页面仅加载自身所需的 JS 代码 增量静态重生成： 允许在网站构建完成后，对单独的静态页面进行重新生成，可以在不重新构建整个网站的情况下，更新特定静态页面。 图像加载优化： 使用 Image 组件提供了众多常用的功能，比如：自动生成多尺寸缩略图、加载时防止布局抖动... 未来已来 在写作这篇文章的过程中，尝试了使用生成样例代码，效果出乎意料得好：由 ChatGPT 产出的框架代码，只需稍加修改即可运行。 这波兴起于年初的 AI 浪潮，已经对游戏从业者产生了巨大的冲击，并且逐渐向着其他行业蔓延。 近期发布的 GPT-4 更是具备将图片转换为 HTML 页面的能力，这意味着低端的编程在不远的将来被 AI 取代。 很多从事自由职业的人员，已经从中发掘出不少红利： 独立游戏制作人使用 AI 替代美工 个人创业者使用 AI 创建项目企划书 自媒体从业者使用 AI 自动生成文章 作为开发者，如何正视 AI 所带来的冲击，并使用 AI 提升开发效率，是我们不得不面对的新课题。 "},{"slug":"redis-eviction","title":"Redis 的缓存淘汰机制（Eviction）","tags":["Redis","系统设计"],"content":"本文从源码层面分析了 redis 的缓存淘汰机制，并在文章末尾描述使用 Java 实现的思路，以供参考。 相关配置 为了适配用作缓存的场景，redis 支持缓存淘汰（eviction）并提供相应的了配置项： maxmemory 设置内存使用上限，该值不能设置为小于 1M 的容量。 选项的默认值为 0，此时系统会自行计算一个内存上限。 maxmemory-policy 熟悉 redis 的朋友都知道，每个数据库维护了两个字典： db.dict：数据库中所有键值对，也被称作数据库的 keyspace db.expires：带有生命周期的 key 及其对应的 TTL（存留时间），因此也被称作 expire set 当达到内存使用上限maxmemory时，可指定的清理缓存所使用的策略有： noeviction 当达到最大内存时直接返回错误，不覆盖或逐出任何数据 allkeys-lfu 淘汰整个 keyspace 中最不常用的 (LFU) 键 (4.0 或更高版本) allkeys-lru 淘汰整个 keyspace 最近最少使用的 (LRU) 键 allkeys-random 淘汰整个 keyspace 中的随机键 volatile-ttl 淘汰 expire set 中 TTL 最短的键 volatile-lfu 淘汰 expire set 中最不常用的键 (4.0 或更高版本) volatile-lru 淘汰 expire set 中最近最少使用的 (LRU) 键 volatile-random 淘汰 expire set 中的随机键 当 expire set 为空时，*volatile- ** 与 noeviction 行为一致。 maxmemory-samples 为了保证性能，redis 中使用的 LRU 与 LFU 算法是一类近似实现。简单来说就是： 算法选择被淘汰记录时，不会遍历所有记录，而是以 随机采样 的方式选取部分记录进行淘汰。 maxmemory-samples 选项控制该过程的采样数量，增大该值会增加 CPU 开销，但算法效果能更逼近实际的 LRU 与 LFU 。 lazyfree-lazy-eviction 清理缓存就是为了释放内存，但这一过程会阻塞主线程，影响其他命令的执行： 当删除某个巨型记录（比如：包含数百条记录的 list）时，会引起性能问题，甚至导致系统假死。 延迟释放 机制会将巨型记录的内存释放，交由其他线程异步处理，从而提高系统的性能。 开启该选项后，可能出现使用内存超过 maxmemory 上限的情况。 缓存淘汰机制 一个完整的缓存淘汰机制需要解决两个问题： 确定淘汰哪些记录 —— 淘汰策略 删除被淘汰的记录 —— 删除策略 淘汰策略 缓存能使用的内存是有限的，当空间不足时，应该优先淘汰那些将来不再被访问的数据，保留那些将来还会频繁访问的数据。因此淘汰算法会围绕 时间局部性 原理进行设计，即：如果一个数据正在被访问，那么在近期很可能会被再次访问。 为了适应缓存读多写少的特点，实际应用中会使用哈希表来实现缓存。当需要实现某种特定的缓存淘汰策略时，需要引入额外的 簿记（book keeping） 结构。 下面回顾 3 种最常见的缓存淘汰策略。 FIFO (先进先出) 越早进入缓存的数据，其不再被访问的可能性越大。因此在淘汰缓存时，应选择在内存中停留时间最长的缓存记录。 使用队列即可实现该策略： flowchart TB latest --&gt;|cache| FIFO subgraph FIFO[&quot;Queue&quot;] direction LR e1[&quot;Entry&quot;] e2[&quot;Entry&quot;] e3[&quot;Entry&quot;] oldest[&quot;oldest&quot;] end oldest --x|evict| X(((&quot; &quot;))) 优点：实现简单，适合线性访问的场景 缺点：无法适应特定的访问热点，缓存的命中率差 簿记开销：时间 $O(1)$，空间 $O(N)$ LRU (最近最少使用) 一个缓存被访问后，近期再被访问的可能性很大。可以记录每个缓存记录的最近访问时间，最近未被访问时间最长的数据会被首先淘汰。 使用链表即可实现该策略： flowchart LR latest --&gt;|cache| e1 subgraph LRU[&quot;Linked List&quot;] direction LR e1[&quot;Entry&quot;] e2[&quot;Entry&quot;] e3[&quot;Entry&quot;] e1 --&gt; e2 e2 --&gt; e3 end e3 ---x |evict| oldest 当更新 LRU 信息时，只需调整指针： %%{init: {&quot;flowchart&quot;: {&quot;curve&quot;: &quot;linear&quot; }} }%% flowchart LR subgraph LRU[&quot;Linked List&quot;] direction LR A --&gt; B B --&gt; C C --&gt; D D --&gt; E end subgraph X[&quot; &quot;] direction TB latest --&gt; A oldest --&gt; E end style X fill:none,stroke:none block-beta columns 5 space:2 down&lt;[&quot;hit E again&quot;]&gt;(down) space:2 %%{init: {&quot;flowchart&quot;: {&quot;curve&quot;: &quot;linear&quot; }} }%% flowchart LR subgraph LRU[&quot;Linked List&quot;] direction LR A --&gt; B B --&gt; C C --&gt; D D -.-x E E --&gt; A end subgraph X[&quot; &quot;] direction TB latest --&gt; E oldest --&gt; D end style X fill:none,stroke:none 优点：实现简单，能适应访问热点 缺点：对偶发的访问敏感，影响命中率 簿记开销：时间 $O(1)$，空间 $O(N)$ LRU 改进 原始的 LRU 算法缓存的是最近访问了 1 次的数据，因此不能很好地区分频繁和不频繁缓存引用。 这意味着，部分冷门的低频数据也可能进入到缓存，并将原本的热点记录挤出缓存。 为了减少偶发访问对缓存的影响，后续提出的 LRU-K 算法作出了如下改进： 在 LRU 簿记的基础上增加一个历史队列 History Queue 当记录访问次数小于 K 时，会记录在历史队列中（当历史队列满时，可以使用 FIFO 或 LRU 策略进行淘汰） 当记录访问次数大于等于 K 时，会被从历史队列中移出，并记录到 LRU 缓存中 K 值越大，缓存命中率越高，但适应性差，需要经过大量访问才能将过期的热点记录淘汰掉。 综合各种因素后，实践中常用的是 LRU-2 算法： flowchart LR latest --&gt;|enqueue| FIFO oldest --x|dequeue| #(((&quot; &quot;))) subgraph FIFO[&quot;History Queue&quot;] direction TB A[&quot;&amp;nbsp;&amp;nbsp;A&amp;nbsp;&amp;nbsp;&quot;] B[&quot;&amp;nbsp;&amp;nbsp;B&amp;nbsp;&amp;nbsp;&quot;] C[&quot;&amp;nbsp;&amp;nbsp;C&amp;nbsp;&amp;nbsp;&quot;] D[&quot;&amp;nbsp;&amp;nbsp;D&amp;nbsp;&amp;nbsp;&quot;] oldest[&quot;oldest&quot;] end subgraph LRU[&quot;LRU Cache&quot;] direction LR X[&quot;&amp;nbsp;&amp;nbsp;X&amp;nbsp;&amp;nbsp;&quot;] Y[&quot;&amp;nbsp;&amp;nbsp;Y&amp;nbsp;&amp;nbsp;&quot;] Z[&quot;&amp;nbsp;&amp;nbsp;Z&amp;nbsp;&amp;nbsp;&quot;] X --&gt; Y Y --&gt; Z C --&gt;|hit C again| X end 优点：减少偶发访问对缓存命中率的影响 缺点：需要额外的簿记开销 簿记开销：时间 $O(1)$，空间 $O(N+M)$ LFU (最不经常使用) 一个缓存近期内访问频率越高，其再被访问的可能性越大。可以记录每个缓存记录的最近一段时间的访问频率，访问频率低的数据会被首先淘汰。 实现 LFU 的一个简单方式，是在缓存记录设置一个记录访问次数的计数器，然后将其放入一个小顶堆： flowchart TB subgraph Heap[&quot;&amp;nbsp; Min-Heap&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] direction TB A[&quot;&amp;nbsp;&amp;nbsp;2&amp;nbsp;&amp;nbsp;&quot;] B[&quot;&amp;nbsp;&amp;nbsp;3&amp;nbsp;&amp;nbsp;&quot;] C[&quot;&amp;nbsp;&amp;nbsp;5&amp;nbsp;&amp;nbsp;&quot;] D[&quot;&amp;nbsp;&amp;nbsp;3&amp;nbsp;&amp;nbsp;&quot;] E[&quot;&amp;nbsp;&amp;nbsp;3&amp;nbsp;&amp;nbsp;&quot;] A --- B A --- C B --- D B --- E end X[&quot;&amp;nbsp;&amp;nbsp;1&amp;nbsp;&amp;nbsp;&quot;] X --&gt;|replace top| A 为了保证数据的时效性，还要以一定的时间间隔对计数器进行衰减，保证过期的热点数据能够被及时淘汰： block-beta columns 8 block:1:2 columns 4 space:2 A1[&quot;A:3&quot;] space space:4 space B1[&quot;B:4&quot;] space C1[&quot;C:6&quot;] space:4 D1[&quot;D:5&quot;] space E1[&quot;E:5&quot;] space end decay&lt;[&quot;decay twice&quot;]&gt;(right) block:2:2 columns 4 space:2 A2[&quot;A:1&quot;] space space:4 space B2[&quot;B:2&quot;] space C2[&quot;C:4&quot;] space:4 D2[&quot;D:3&quot;] space E2[&quot;E:3&quot;] space end hit&lt;[&quot;hit A twice&quot;]&gt;(right) block:3:2 columns 4 space:2 A3[&quot;B:2&quot;] space space:4 space B3[&quot;E:3&quot;] space C3[&quot;C:4&quot;] space:4 D3[&quot;D:3&quot;] space E3[&quot;A:3&quot;] space end A1 --- B1 A1 --- C1 B1 --- D1 B1 --- E1 A2 --- B2 A2 --- C2 B2 --- D2 B2 --- E2 A3 --- B3 A3 --- C3 B3 --- D3 B3 --- E3 删除策略 常见删除策略可以分为以下几种： 实时删除 每次增加新的记录时，立即查找可淘汰的记录，如果存在则将该记录从缓存中删除 优点：实时性好，最节省内存空间 缺点：查找淘汰记录会影响写入的效率，需要额外的簿记结构提高查找效率（比如 LRU 中的链表） 惰性删除 在缓存中设置两个计数器，一个统计访问缓存的次数，一个统计可淘汰记录的数量 每经过 N 次访问后或当前可淘汰记录数量大于 M，则触发一次批量删除（M 与 N 可调节） 优点：对正常缓存操作影响小，批量删除减少维护开销 缺点：实时性较差，偶发的删除操作会导致访问耗时波动 异步删除 设置一个独立的定时器线程，每隔固定的时间触发一次批量删除 优点：对正常缓存操作影透明，无额外性能开销 缺点：需要增加维护线程，并且需要提前规划缓存的负载，以此决定如何在多个缓存实例上调度 redis 实现 redis 中实现了 LRU 与 LFU 两种淘汰策略 为了节省空间，redis 没有使用前面描述的簿记结构实现 LRU 或 LFU，而是在 robj 中使用一个 24bits 的空间记录访问信息： #define LRU_BITS 24 typedef struct redisObject { ... unsigned lru:LRU_BITS; /* LRU 时间 (相对与全局 lru_clock 的时间) 或 * LFU 数据 (8bits 记录访问频率，16 bits 记录访问时间). */ } robj; 每当记录被命中时，redis 都会更新 robj.lru 作为后面淘汰算法运行的依据： robj *lookupKey(redisDb *db, robj *key, int flags) { // ... // 根据 maxmemory_policy 选择不同的更新策略 if (server.maxmemory_policy &amp; MAXMEMORY_FLAG_LFU) { updateLFU(val); } else { val-&gt;lru = LRU_CLOCK(); } } LFU 与 LRU 的更新关键在于 updateLFU 函数与 LRU_CLOCK 宏，下面分别进行分析。 更新 LRU 时间 当时使用 LRU 算法时，robj.lru 记录的是最近一次访问的时间戳，可以据此找出长时间未被访问的记录。 为了减少系统调用，redis 设置了一个全局的时钟 server.lruclock 并交由后台任务进行更新： #define LRU_CLOCK_MAX ((1&lt;&lt;LRU_BITS)-1) /* Max value of obj-&gt;lru */ #define LRU_CLOCK_RESOLUTION 1000 /* 以毫秒为单位的时钟精度 */ /** * server.lruclock 的更新频率为 1000/server.hz * 如果该频率高于 LRU 时钟精度，则直接用 server.lruclock * 避免调用 getLRUClock() 产生额外的开销 */ #define LRU_CLOCK() ((1000/server.hz &lt;= LRU_CLOCK_RESOLUTION) ? server.lruclock : getLRUClock()) unsigned int getLRUClock(void) { return (mstime()/LRU_CLOCK_RESOLUTION) &amp; LRU_CLOCK_MAX; } 计算 LRU 时间方法如下： unsigned long long estimateObjectIdleTime(robj *o) { unsigned long long lruclock = LRU_CLOCK(); if (lruclock &gt;= o-&gt;lru) { return (lruclock - o-&gt;lru) * LRU_CLOCK_RESOLUTION; } else { // 处理 LRU 时间溢出的情况 return (lruclock + (LRU_CLOCK_MAX - o-&gt;lru)) * LRU_CLOCK_RESOLUTION; } } 当LRU_CLOCK_RESOLUTION为 1000ms 时，robj.lru最长可记录的 LRU 时长为 194 天（0xFFFFFF / 3600 / 24）。 更新 LFU 计数 当时使用 LFU 算法时，robj.lru 被分为两部分：16bits 记录最近一次访问时间，8bits 用作计数器 void updateLFU(robj *val) { unsigned long counter = LFUDecrAndReturn(val); // 衰减计数 counter = LFULogIncr(counter); // 增加计数 val-&gt;lru = (LFUGetTimeInMinutes()&lt;&lt;8) | counter; // 更新时间 } 更新访问时间 前 16bits 用于保存最近一次被访问的时间： /** * 获取 UNIX 分钟时间戳，且只保留最低 16bits * 用于表示最近一次衰减时间 LDT (last decrement time) */ unsigned long LFUGetTimeInMinutes(void) { return (server.unixtime/60) &amp; 65535; } 增加访问计数 后 8bits 是一个对数计数器（logarithmic counter），里面保存的是访问次数的对数： #define LFU_INIT_VAL 5 // 对数递增计数器，最大值为 255 uint8_t LFULogIncr(uint8_t counter) { if (counter == 255) return 255; double r = (double)rand()/RAND_MAX; double baseval = counter - LFU_INIT_VAL; if (baseval &lt; 0) baseval = 0; double p = 1.0/(baseval*server.lfu_log_factor+1); if (r &lt; p) counter++; return counter; } 当 server.lfu_log_factor = 10 时，p = 1/((counter-LFU_INIT_VAL)*server.lfu_log_factor+1) 的增长函数如图所示： ```mermaid config: themeVariables: xyChart: plotColorPalette: &quot;#555&quot; xychart-beta title &quot;1/((x-5)*10+1)&quot; x-axis 4 --&gt; 20 y-axis 0 --&gt; 1 line [0, 1, 0.09091, 0.04762, 0.03226, 0.02439, 0.01961, 0.01639, 0.01408, 0.01235, 0.01099, 0.0099, 0.00901, 0.00826, 0.00763, 0.00709] 使用函数 `rand()` 生成的介于 0 与 1 之间随机浮点数 `r` 符合均匀分布，随着 `counter` 的增大，其自增成功的概率迅速降低。 下列表格展示了 `counter` 在不同 `lfu_log_factor` 情况下，达到饱和（255）所需的访问次数： ```text +--------+------------+------------+------------+------------+------------+ | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | +--------+------------+------------+------------+------------+------------+ | 0 | 104 | 255 | 255 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 1 | 18 | 49 | 255 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 10 | 10 | 18 | 142 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 100 | 8 | 11 | 49 | 143 | 255 | +--------+------------+------------+------------+------------+------------+ 衰减访问计数 同样的，为了保证过期的热点数据能够被及时淘汰，redis 使用如下衰减函数： // 计算距离上一次衰减的时间 ，单位为分钟 unsigned long LFUTimeElapsed(unsigned long ldt) { unsigned long now = LFUGetTimeInMinutes(); if (now &gt;= ldt) return now-ldt; return 65535-ldt+now; } /** * 衰减函数，返回根据 LDT 时间戳衰减后的 LFU 计数 * 不更新计数器 */ unsigned long LFUDecrAndReturn(robj *o) { unsigned long ldt = o-&gt;lru &gt;&gt; 8; unsigned long counter = o-&gt;lru &amp; 255; /** * 衰减因子 server.lfu_decay_time 用于控制计数器的衰减速度 * 每过 server.lfu_decay_time 分钟访问计数减 1 * 默认值为 1 */ unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0; if (num_periods) counter = (num_periods &gt; counter) ? 0 : counter - num_periods; return counter; } 16bits 最多能保存的分钟数，换算成天数约为 45 天，因此 LDT 时间戳每隔 45 天就会重置一次。 执行删除 每当客户端执行命令产生新数据时，redis 会检查内存使用是否超过 maxmemory，如果超过则尝试根据 maxmemory_policy 淘汰数据： // redis 处理命令的主方法，在真正执行命令前，会有各种检查，包括对OOM情况下的处理: int processCommand(client *c) { // ... // 设置了 maxmemory 时，如果有必要，尝试释放内存(evict) if (server.maxmemory &amp;&amp; !server.lua_timedout) { int out_of_memory = (performEvictions() == EVICT_FAIL); // ... // 如果释放内存失败，并且当前将要执行的命令不允许OOM（一般是写入类命令） if (out_of_memory &amp;&amp; reject_cmd_on_oom) { rejectCommand(c, shared.oomerr); // 向客户端返回OOM return C_OK; } } } 实际执行删除的是 performEvictions 函数： int performEvictions(void) { // 循环，尝试释放足够大的内存 while (mem_freed &lt; (long long)mem_tofree) { // ... if (server.maxmemory_policy &amp; (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) || server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) { /** * redis 使用的是近似 LRU / LFU 算法 * 在淘汰对象时不会遍历所有记录，而是对记录进行采样 * EvictionPoolLRU 被用于临时存储应该被优先淘汰的样本数据 */ struct evictionPoolEntry *pool = EvictionPoolLRU; // 根据配置的 maxmemory-policy，拿到一个可以释放掉的bestkey while(bestkey == NULL) { unsigned long total_keys = 0, keys; // 遍历所有的 db 实例 for (i = 0; i &lt; server.dbnum; i++) { db = server.db+i; dict = (server.maxmemory_policy &amp; MAXMEMORY_FLAG_ALLKEYS) ? db-&gt;dict : db-&gt;expires; // 根据 policy 选择采样的集合（keyspace 或 expire set） if ((keys = dictSize(dict)) != 0) { // 采样并填充 pool evictionPoolPopulate(i, dict, db-&gt;dict, pool); total_keys += keys; } } // 遍历 pool 中的记录，释放内存 for (k = EVPOOL_SIZE-1; k &gt;= 0; k--) { if (pool[k].key == NULL) continue; bestdbid = pool[k].dbid; if (server.maxmemory_policy &amp; MAXMEMORY_FLAG_ALLKEYS) { de = dictFind(server.db[pool[k].dbid].dict, pool[k].key); } else { de = dictFind(server.db[pool[k].dbid].expires, pool[k].key); } // 将记录从 pool 中剔除 if (pool[k].key != pool[k].cached) sdsfree(pool[k].key); pool[k].key = NULL; pool[k].idle = 0; if (de) { // 提取该记录的 key bestkey = dictGetKey(de); break; } else { /* Ghost... Iterate again. */ } } } } // 最终选中了一个 bestkey if (bestkey) { // 如果配置了 lazyfree-lazy-eviction，尝试异步删除 if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); // ... } else { goto cant_free; /* nothing to free... */ } } } 负责采样的 evictionPoolPopulate 函数： #define EVPOOL_SIZE 16 #define EVPOOL_CACHED_SDS_SIZE 255 struct evictionPoolEntry { unsigned long long idle; /* LRU 空闲时间 / LFU 频率倒数（优先淘汰该值较大的记录） */ sds key; /* 参与淘汰筛选的键 */ sds cached; /* 键名缓存 */ int dbid; /* 数据库ID */ }; // evictionPool 数组用于辅助 eviction 操作 static struct evictionPoolEntry *evictionPoolEntry; /** * 在给定的 sampledict 集合中进行采样 * 并将其中应该被淘汰的记录记录至 evictionPool */ void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { int j, k, count; dictEntry *samples[server.maxmemory_samples]; // 从 sampledict 中随机获取 maxmemory_samples 个样本数据 count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); // 遍历样本数据 for (j = 0; j &lt; count; j++) { // 根据 maxmemory_policy 计算样本空闲时间 idle if (server.maxmemory_policy &amp; MAXMEMORY_FLAG_LRU) { idle = estimateObjectIdleTime(o); } else if (server.maxmemory_policy &amp; MAXMEMORY_FLAG_LFU) { idle = 255-LFUDecrAndReturn(o); } else { // ... } k = 0; // 根据 idle 定位样本在 evictionPool 中的索引（样本按照 idle 升序） while (k &lt; EVPOOL_SIZE &amp;&amp; pool[k].key &amp;&amp; pool[k].idle &lt; idle) k++; if (k == 0 &amp;&amp; pool[EVPOOL_SIZE-1].key != NULL) { // 样本空闲时间不够长，不参与该轮 eviction continue; } else if (k &lt; EVPOOL_SIZE &amp;&amp; pool[k].key == NULL) { // 样本对应的位置为空，可以直接插入至该位置 } else { // 样本对应的位置已被占用，移动其他元素空出该位置 } // ... // 将样本数据插入其对应的位置 k int klen = sdslen(key); if (klen &gt; EVPOOL_CACHED_SDS_SIZE) { pool[k].key = sdsdup(key); } else { // 如果 key 长度不超过 EVPOOL_CACHED_SDS_SIZE，则复用 sds 对象 } pool[k].idle = idle; pool[k].dbid = dbid; } } Java 实现 在了解以上知识后，尝试使用 Java 实现 线程安全 的淘汰策略。 确定簿记结构 在一个多线程安全的缓存中，很重要的一点是减少簿记： 一方面避免额外状态的维护开销 另一方面可以减少系统处于不一致状态的边界情况 因此参考 redis 使用计数器来记录访问模式： /** * 缓存记录 */ public abstract class CacheEntry { // CAS Updater private static final AtomicLongFieldUpdater&lt;CacheEntry&gt; TTL_UPDATER = AtomicLongFieldUpdater.newUpdater(CacheEntry.class, &quot;ttl&quot;); // 缓存记录的剩余存活时间（无符号长整数） private volatile long ttl; protected CacheEntry(long ttl) { this.ttl = ttl; } public long ttl() { return ttl; } // 支持并发更新 TTL public boolean casTTL(long old, long ttl) { return TTL_UPDATER.compareAndSet(this, old, ttl); } } /** * 淘汰策略 */ public interface EvictStrategy { // 更新缓存记录的 TTL void updateTTL(CacheEntry node); // 根据当前时间戳，计算缓存记录的 TTL long weightTTL(CacheEntry node, long now); } 确定删除策略 受限于簿记结构，redis 只能通过采样来规避大量的遍历，减少 实时删除 策略对主线程的阻塞。 而在对于内存限制没那么严谨的情况下，可以使用 懒惰删除 策略，减少单次请求的开销： public abstract class EvictableCache { EvictStrategy evicting; // 淘汰策略 /** * 在读写缓存记录时，更新该记录的 TTL * @param entry 最近被访问的缓存记录 */ void accessEntry(CacheEntry entry) { evicting.updateTTL(entry); } /** * 批量淘汰缓存 * @param evictSamples 缓存样本 * @param evictNum 最大淘汰数量 * @return 应该被淘汰的记录 */ Collection&lt;CacheEntry&gt; evictEntries(Iterable&lt;CacheEntry&gt; evictSamples, int evictNum) { // 比较两个 CacheEntry 的 TTL（优先淘汰 TTL 较小的记录） Comparator&lt;CacheEntry&gt; comparator = new Comparator&lt;CacheEntry&gt;() { final long now = System.currentTimeMillis(); public int compare(CacheEntry o1, CacheEntry o2) { long w1 = evicting.weightTTL(o1, now); long w2 = evicting.weightTTL(o2, now); return -Long.compareUnsigned(w1, w2); } }; // 使用大顶堆记录 TTL 最小的 K 个 CacheEntry PriorityQueue&lt;CacheEntry&gt; evictPool = new PriorityQueue&lt;&gt;(evictNum, comparator); Iterator&lt;CacheEntry&gt; iterator = evictSamples.iterator(); while (iterator.hasNext()) { CacheEntry entry = iterator.next(); if (evictPool.size() &lt; evictNum) { evictPool.add(entry); } else { // 如果 CacheEntry 的 TTL 小于堆顶记录 // 则弹出堆顶记录，并将 TTL 更小的记录放入堆中 CacheEntry top = evictPool.peek(); if (comparator.compare(entry, top) &lt; 1) { evictPool.poll(); evictPool.add(entry); } } } return evictPool; } } 实现淘汰策略 FIFO 策略 /** * FIFO 策略 */ public class FirstInFirstOut implements EvictStrategy { // 计数器，每发生一次访问操作自增 1 private final AtomicLong counter = new AtomicLong(0); // 第一次访问时才更新 TTL public void updateTTL(CacheEntry node) { node.casTTL(0, counter.incrementAndGet()); } // 返回第一次被访问的序号 public long weightTTL(CacheEntry node, long now) { return node.ttl(); } } LRU 策略 /** * LRU-2 策略 */ public class LeastRecentlyUsed implements EvictStrategy { // 逻辑时钟，每发生一次访问操作自增 1 private final AtomicLong clock = new AtomicLong(0); /** * 更新 LRU 时间 */ public void updateTTL(CacheEntry node) { long old = node.ttl(); long tick = clock.incrementAndGet(); long flag = old == 0 ? Long.MIN_VALUE: 0; // flag = Long.MIN_VALUE 表示放入 History Queue // flag = 0 表示放入 LRU Cache long ttl = (tick &amp; Long.MAX_VALUE) | flag; while ((old &amp; Long.MAX_VALUE) &lt; tick &amp;&amp; ! node.casTTL(old, ttl)) { old = node.ttl(); ttl = tick &amp; Long.MAX_VALUE; // CAS 失败说明已经是二次访问 } } /** * 根据 LRU 时间计算 TTL */ public long weightTTL(CacheEntry node, long now) { long ttl = node.ttl(); return -1L - ttl; } } LFU 策略 /** * LFU-AgeDecay 策略 */ public class LeastFrequentlyUsed implements EvictStrategy { private static final int TIMESTAMP_BITS = 40; // 40bits 记录访问时间戳（保证 34 年不溢出） private static final int FREQUENCY_BITS = 24; // 24bits 作为对数计数器（可以忽略计数溢出的情况） private final long ERA = System.currentTimeMillis(); // 起始时间（记录相对于该值的时间戳） private final double LOG_FACTOR = 1; // 对数因子 private final TimeUnit DECAY_UNIT = TimeUnit.MINUTES; // 时间衰减单位 /** * 更新 LFU 计数器与访问时间 * 与 redis 不同，更新时不会对计数进行衰减 */ public void updateTTL(CacheEntry node) { final long now = System.currentTimeMillis(); long old = node.ttl(); long timestamp = old &gt;&gt;&gt; FREQUENCY_BITS; long frequency = old &amp; (~0L &gt;&gt;&gt; TIMESTAMP_BITS); // 计算访问时间 long elapsed = Math.min(~0L &gt;&gt;&gt; FREQUENCY_BITS, now - ERA); while (timestamp &lt; elapsed) { // 增加访问计数 double rand = ThreadLocalRandom.current().nextDouble(); if (1./(frequency * LOG_FACTOR + 1) &gt; rand) { frequency++; frequency &amp;= (~0L &gt;&gt;&gt; TIMESTAMP_BITS); } // 更新 TTL long ttl = elapsed &lt;&lt; FREQUENCY_BITS | frequency &amp; (~0L &gt;&gt;&gt; TIMESTAMP_BITS); if (node.casTTL(old, ttl)) { break; } old = node.ttl(); timestamp = old &gt;&gt;&gt; FREQUENCY_BITS; frequency = old &amp; (~0L &gt;&gt;&gt; TIMESTAMP_BITS); } } /** * 返回衰减后的 LFU 计数 */ public long weightTTL(CacheEntry node, long now) { long ttl = node.ttl(); long timestamp = ttl &gt;&gt;&gt; FREQUENCY_BITS; long frequency = ttl &amp; (~0L &gt;&gt;&gt; TIMESTAMP_BITS); long decay = DECAY_UNIT.toMinutes(Math.max(now - ERA, timestamp) - timestamp); return frequency - decay; } } "},{"slug":"redis-statistics-and-hyperloglog","title":"Redis 集合统计（HyperLogLog）","tags":["Redis"],"content":"统计功能是一类极为常见的需求，比如下面这个场景： 为了决定某个功能是否在下个迭代版本中保留，产品会要求统计页面在上新前后的 UV 作为决策依据。简单来说就是统计一天内，某个页面的访问用户量，如果相同的用户再次访问，也只算记为一次访问。 下面我们将从这个场景出发，讨论如何选择的合适的 Redis 数据结构实现统计功能。 Redis与统计 聚合统计 要完成这个统计任务，最直观的方式是使用一个SET保存页面在某天的访问用户 ID，然后通过对集合求差SDIFF和求交SINTER完成统计： # 2020-01-01 当日的 UV SADD page:uv:20200101 &quot;Alice&quot; &quot;Bob&quot; &quot;Tom&quot; &quot;Jerry&quot; # 2020-01-02 当日的 UV SADD page:uv:20200102 &quot;Alice&quot; &quot;Bob&quot; &quot;Jerry&quot; &quot;Nancy&quot; # 2020-01-02 新增用户 SDIFFSTORE page:new:20200102 page:uv:20200102 page:uv:20200101 # 2020-01-02 新增用户数量 SCARD page:new:20200102 # 2020-01-02 留存用户 SINTERSTORE page:rem:20200102 page:uv:20200102 page:uv:20200101 # 2020-01-02 留存用户数量 SCARD page:rem:20200102 优点 操作直观易理解，可以复用现有的数据集合 保留了用户的访问细节，可以做更细粒度的统计 缺点 内存开销大，假设每个用户ID长度均小于 44 字节（使用 embstr 编码），记录 1 亿用户也至少需要 6G 的内存 SUNION、SINTER、SDIFF计算复杂度高，大数据量情况下会导致 Redis 实例阻塞，可选的优化方式有： 从集群中选择一个从库专门负责聚合计算 把数据读取到客户端，在客户端来完成聚合统计 二值统计 当用户 ID 是连续的整数时，可以使用BITMAP实现二值统计： # 2020-01-01 当日的 UV SETBIT page:uv:20200101 0 1 # &quot;Alice&quot; SETBIT page:uv:20200101 1 1 # &quot;Bob&quot; SETBIT page:uv:20200101 2 1 # &quot;Tom&quot; SETBIT page:uv:20200101 3 1 # &quot;Jerry&quot; # 2020-01-02 当日的 UV SETBIT page:uv:20200102 0 1 # &quot;Alice&quot; SETBIT page:uv:20200102 1 1 # &quot;Bob&quot; SETBIT page:uv:20200102 3 1 # &quot;Jerry&quot; SETBIT page:uv:20200102 4 1 # &quot;Nancy&quot; # 2020-01-02 新增用户 BITOP NOT page:not:20200101 page:uv:20200101 BITOP AND page:new:20200102 page:uv:20200102 page:not:20200101 # 2020-01-02 新增用户数量 BITCOUNT page:new:20200102 # 2020-01-02 留存用户 BITOP AND page:rem:20200102 page:uv:20200102 page:uv:20200101 # 2020-01-02 留存用户数量 BITCOUNT page:new:20200102 优点 内存开销低，记录 1 亿个用户只需要 12MB 内存 统计速度快，计算机对比特位的异或运算十分高效 缺点 对数据类型有要求，只能处理整数集合 基数统计 前面两种方式都能提供准确的统计结果，但是也存在以下问题： 当统计集合变大时，所需的存储内存也会线性增长 当集合变大时，判断其是否包含新加入元素的成本变大 考虑下面这一场景： 产品可能只关心 UV 增量，此时我们最终要的结果是访问用户集合的数量，并不关心访问集合里面包含哪些访问用户。 只统计一个集合中不重复的元素个数，而并不关心集合元素内容的统计方式，我们将其称为基数计数（cardinality counting） 针对这一特定的统计场景，Redis 提供了 HyperLogLog 类型支持基数统计： # 2020-01-01 当日的 UV PFADD page:uv:20200101 &quot;Alice&quot; &quot;Bob&quot; &quot;Tom&quot; &quot;Jerry&quot; PFCOUNT page:uv:20200101 # 2020-01-02 当日的 UV PFADD page:uv:20200102 &quot;Alice&quot; &quot;Bob&quot; &quot;Tom&quot; &quot;Jerry&quot; &quot;Nancy&quot; PFCOUNT page:uv:20200102 # 2020-01-01 与 2020-01-02 的 UV 总和 PFMERGE page:uv:union page:uv:20200101 page:uv:20200102 PFCOUNT page:uv:union 优点 HyperLogLog 计算基数所需的空间是固定的。只需要 12KB 内存就可以计算接近 $2^{64}$ 个元素的基数。 缺点 HyperLogLog 的统计是基于概率完成的，其统计结果是有一定误差。不适用于精确统计的场景。 HyperLogLog 解析 概率估计 HyperLogLog是一种基于概率的统计方式，该如何理解？ 我们来做一个实验：不停地抛一个均匀的双面硬币，直到结果是正面为止。 用 0 和 1 分别表示正面与反面，则实验结果可以表示为如下二进制串： +-+ 第 1 次抛到正面 |1| +-+ +--+ 第 2 次抛到正面 |01| +--+ +---+ 第 3 次抛到正面 |001| +---+ +---------+ 第 k 次抛到正面 |000...001| (总共 k-1 个 0) +---------+ 由于每次抛硬币得到正面的概率均为$\\frac{1}{2}$，因此实验在第 k 次结束的可能性为 $(\\frac{1}{2})^k$（二进制串中首个 1 出现在第 k 位的概率）。 进行 n 实验后，将每次实验抛硬币的次数记为 $k_1, k_3,\\cdots,k_n$，其中的最大值记为 $k_{max}$。 理想情况下有 $k_{max} = log_2(n)$，反过来也可以通过 $k_{max}$ 来估计总的实验次数 $n = 2^{k_{max}}$。 处理极端情况 实际进行实验时，极端情况总会出现，比如在第 1 次实验时就连续抛出了 10 次反面。 如果按照前面的公式进行估计，会认为已经进行了 1000 次实验，这显然与事实不符。 为了提高估计的准确性，可以同时使用 m 枚硬币进行 分组实验。 然后计算这 m 组实验的平均值 $\\hat{k}{max} = \\frac{\\sum{i=0}^{m}{k_{max}}}{m}$，此时能更准确的估计实际的实验次数 $\\hat{n}=2^{\\hat{k}_{max}}$。 基数统计 通过前面的分析，我们可以总结出以下经验： 可以通过二进制串中首个 1 出现的位置 $k_{max}$ 来估计实际实验发生的次数 $n$ HyperLogLog 借鉴上述思想来统计集合中不重复元素的个数： 使用 hash 函数集合中的每个元素映射为定长二进制串 利用 分组统计 的方式提高准确性，将二进制串分到 $m$ 个不同的桶 bucket 中分别统计： 二进制串的前 $log_2{m}$ 位用于计算该元素所属的桶 剩余二进制位中，首个 1 出现的比特位记为 $k$，每个桶中的只保存最大值 $k_{max}$ 当需要估计集合中包含的元素个数时，使用公式 $\\hat{n}=2^{\\hat{k}_{max}}$ 计算即可 下面来看一个例子： 某个 HyperLogLog 实现，使用 8bit 输出的 hash 函数 并以 4个桶 进行分组统计。 使用该 HLL 统计 Alice，Bob，Tom，Jerry，Nancy 这 5 个用户访问页后的 UV： 映射为二进制串 分组 计算k | | | V V V +---------+ hash(&quot;Alice&quot;) =&gt; |01|101000| =&gt; bucket=1, k=1 +---------+ 分组统计 k_max +---------+ hash(&quot;Bob&quot;) =&gt; |11|010010| =&gt; bucket=3, k=2 +----------+----------+----------+----------+ +---------+ | bucket_0 | bucket_1 | bucket_2 | bucket_3 | +---------+ ==&gt; +----------+----------+----------+----------+ hash(&quot;Tom&quot;) =&gt; |10|001000| =&gt; bucket=2, k=3 | k_max= 1 | k_max= 2 | k_max= 3 | k_max= 2 | +---------+ +----------+----------+----------+----------+ +---------+ hash(&quot;Jerry&quot;) =&gt; |00|111010| =&gt; bucket=0, k=1 +---------+ +---------+ hash(&quot;Nancy&quot;) =&gt; |01|010001| =&gt; bucket=1, k=2 +---------+ 分组计数完成后，用之前的公式估计集合基数为 $2^{\\hat{k}_{max}}= 2^{(\\frac{1+2+3+2}{4})} = 4$。 误差分析 在 Redis 的实现中，对于一个输入的字符串，首先得到 64 位的 hash 值： 前 14 位来定位桶的位置（共有16384个桶） 后 50 位用作元素对应的二进制串（用于更新首次出现 1 的比特位的最大值 $k_{max}$） 由于使用了 64 位输出的 hash 函数，因此可以计数的集合的基数没有实际限制。 HyperLogLog 的标准误差计算公式为 $\\frac{1.04}{\\sqrt{m}}$（$m$ 为分组数量），据此计算 Redis 实现的标准误差为 $0.81%$。 下面这幅图展示了统计误差与基数大小的关系： 红线和绿线分别代表两个不同分布的数据集 x 轴表示集合实际基数 y 轴表示相对误差（百分比） 分析该图可以得出以下结论： 统计误差与数据本身的分布特征无关 集合基数越小，误差越小（小基数时精度高） 集合基数越大，误差越大（大基数时省资源） "},{"slug":"redis-persistence","title":"Redis 持久化 (Persistence)","tags":["Redis"],"content":"作为内存数据库，Redis 依然提供了持久化机制，其主要目的有两个： 安全：保证进程崩溃后数据不会丢失 备份：方便数据迁移与快速恢复 Redis 同时提供两种持久化机制： RDB 快照：数据库在某个时间点的完整状态，其存储内容为键值对 AOF 日志：包含所有改变数据库状态的操作，其存储内容为命令 RDB 快照 生成 RDB 快照的方式有两种： 服务进程定期生成 手动执行 SAVE 或 BGSAVE 命令 定期生成 用户可以通过设置保存点save point，控制 RDB 快照的自动生成： save 900 1 # 最近 15 分钟内，至少有 1 个 key 发生过变更 save 300 10 # 最近 5 分钟内，至少有 10 个 key 发生过变更 save 60 10000 # 最近 1 分钟内，至少有 10000 个 key 发生过变更 struct saveparam { time_t seconds; // 秒数 int changes; // 变更数 }; struct redisServer { // ... struct saveparam *saveparams; /* RDB 保存点数组 */ int saveparamslen; /* 保存点数量 */ long long dirty; /* 上一次执行快照后的变更数 */ time_t lastsave; /* 上一次执行快照的 UNIX 时间戳 */ } +---------------+ | redisServer | +---------------+ +---------------+---------------+---------------+ | saveparams | -&gt; | saveparams[0] | saveparams[1] | saveparams[2] | +---------------+ +---------------+---------------+---------------+ | saveparamslen | | seconds | seconds | seconds | | 3 | | 900 | 300 | 60 | +---------------+ +---------------+---------------+---------------+ | dirty | | changes | changes | changes | | 120 | | 1 | 10 | 10000 | +---------------+ +---------------+---------------+---------------+ | lastsave | | 1378270800 | +---------------+ 自动保存的过程： 每执行一个数据库修改命令，计数器 dirty 就会记录该记录导致的变更数量 Redis 的定时任务 serverCron 会周期性地检查是否满足保存点条件： int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { // ... for (j = 0; j &lt; server.saveparamslen; j++) { struct saveparam *sp = server.saveparams+j; if (server.dirty &gt;= sp-&gt;changes &amp;&amp; // 检查变更数是否足够 server.unixtime-server.lastsave &gt; sp-&gt;seconds) // 检查最近一次快照时间 { // 如果当前状态满足保存点设置，打印日志并开始执行 BGSAVE serverLog(LL_NOTICE,&quot;%d changes in %d seconds. Saving...&quot;, sp-&gt;changes, (int)sp-&gt;seconds); // ... // 执行 BGSAVE rdbSaveBackground(server.rdb_filename,rsiptr); break; } } } 手动备份 为了避免在流量高峰期发生性能抖动，在生产环境中往往会关闭 Redis 的自动生成快照的功能。为了保证数据安全，此时运维会使用定时脚本的方式，在系统空闲时执行 BGSAVE 命令备份 Redis 数据。 int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) { // ... if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) { // 产生子进程 /* 子进程负责生成 RDB 快照 */ int retval = rdbSave(filename,rsi); // ... } else { /* 主进程不阻塞直接返回 */ serverLog(LL_NOTICE,&quot;Background saving started by pid %d&quot;,childpid); updateDictResizePolicy(); // 如果子进程正生成快照，禁止 dict 进行 rehash 操作 // ... return C_OK; } } RDB 文件由子进程生成的，操作系统写时复制 copy-on-write 的优化特性，决定了父子进程间的内存在逻辑上是独立的。 因此主进程所产生的任何修改操作都不会被包含在 RDB 文件中，间接保证了 RDB 所记录状态的一致性。 RDB 文件 RDB 快照是一个二进制文件，其格式大致如下： # 有 n 个数据库的 RDB 文件 +-------+------------+-------+-----+-------+-----+-----------+ | REDIS | db_version | db[0] | ... | db[n] | EOF | check_sum | +-------+------------+-------+-----+-------+-----+-----------+ # 每个数据库包含任意长度的键值对 +-------+ +----------+---+------------+-----+------------+ | db[0] | =&gt; | SELECTDB | 0 | kv_pair[0] | ... | kv_pair[n] | +-------+ +----------+---+------------+-----+------------+ # 键值对，常量 TYPE 指示了 value 的编码类型 +---------+ +------+-----+-------+ | kv_pair | =&gt; | TYPE | key | value | +---------+ +------+-----+-------+ # 带过期时间的键值对，常量 EXPIRETIME_MS 紧接着一个 8 字节的时间戳 +------------------+ +---------------+--------------+------+-----+-------+ | kv_pair_with_ttl | =&gt; | EXPIRETIME_MS | ms_timestamp | TYPE | key | value | +------------------+ +---------------+--------------+------+-----+-------+ RDB 快照存储了数据库在某个时间点的完整状态，且格式紧凑，十分适合作为数据备份： 方便通过网络传输到异地机柜，实现多机房容灾 通过使用 RESTORE 命令加载 RDB 快照，可以实现数据初始化或者紧急回滚 AOF 日志 生成 RDB 快照的过程比较耗时，无法频繁执行 BGSAVE。但如果状态变更长时间不落盘，一旦进程崩溃，将会丢失大量未持久化的数据。 为了避免全量备份的开销，Redis 支持以增量更新的方式，将状态变更持久化到 AOF 日志中，减少对磁盘 I/O 的压力。 由于 AOF 日志落盘是由主线程完成的，因此落盘策略会明显影响到 Redis 的性能。下列配置项可用于控制这一行为： appendonly no # 是否开启 AOF # 落盘策略 # always：每次发生变更会立即落盘 # everysec：每秒落盘一次 # no：由操作系统决定落盘时机 appendfsync everysec struct redisServer { // ... int aof_enabled; /* AOF 开关 */ int aof_state; /* AOF 状态（开启、关闭、等待重写）*/ int aof_fsync; /* fsync 策略 */ sds aof_buf; /* AOF 缓冲 */ time_t aof_flush_postponed_start; /* AOF 延迟刷新 UNIX 时间戳 */ } 追加命令 每当成功执行完一条命令，会通过 processCommand -&gt; call -&gt; propagate -&gt; feedAppendOnlyFile 这条调用链，将命令写入 AOF 缓存： void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) { // 将命令追加到缓冲末尾，在向客户端返回结果前将其写入 AOF 文件中 if (server.aof_state == AOF_ON) server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf)); // 如果有子线程正在执行 AOF 重写，期间会将新增的修改记录入一个新的 AOF 日志 if (server.aof_child_pid != -1) aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf)); } 写入文件 在 serverCron 事件循环结束前，会调用 flushAppendOnlyFile 将缓冲中的命令写入到 AOF 日志文件中： int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { // ... // AOF延迟刷新：每个 cron 循环都执行执行一次 fsync if (server.aof_flush_postponed_start) flushAppendOnlyFile(0); } void flushAppendOnlyFile(int force) { ssize_t nwritten; int sync_in_progress = 0; if (sdslen(server.aof_buf) == 0) { // 缓冲为空直接返回 // ... return; } // 将命令写入 AOF 文件，此时尚未落盘 nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf)); server.aof_flush_postponed_start = 0; // 写入完成，重置延迟刷新时间戳，避免再次触发 // ... if (server.aof_fsync == AOF_FSYNC_ALWAYS) { // 落盘策略为 always，则立即执行 fsync redis_fsync(server.aof_fd); server.aof_fsync_offset = server.aof_current_size; server.aof_last_fsync = server.unixtime; } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &amp;&amp; server.unixtime &gt; server.aof_last_fsync)) { // 落盘策略为 everysec，则 fsync 交由后台进程异步完成 if (!sync_in_progress) { aof_background_fsync(server.aof_fd); server.aof_fsync_offset = server.aof_current_size; } server.aof_last_fsync = server.unixtime; } } 值得注意的是，如果写入 AOF 文件过程中发生错误，且落盘策略为 always，此时 Redis 进程会直接退出。 日志重写 在不断接收写命令的过程中，AOF 文件会越来越大，这将导致以下问题： 文件系统对文件大小有限制，无法保存过大的文件 故障恢复时，需要逐个执行 AOF 日志的命令，如果日志文件太大，将导致整个过程会非常缓慢 导致该问题的一个重要原因就是存在冗余命令： # 执行命令 127.0.0.1:6379&gt; INCR counter (integer) 1 127.0.0.1:6379&gt; INCR counter (integer) 2 127.0.0.1:6379&gt; INCR counter (integer) 3 # 对应的 AOF 日志 *2\\r\\n$6\\r\\nSELECT\\r\\n$1\\r\\n0\\r\\n *2\\r\\n$4\\r\\nINCR\\r\\n$7\\r\\ncounter\\r\\n *2\\r\\n$4\\r\\nINCR\\r\\n$7\\r\\ncounter\\r\\n *2\\r\\n$4\\r\\nINCR\\r\\n$7\\r\\ncounter\\r\\n Redis 提供了重写机制rewrite，能够大幅缩减不必要的冗余命令： # 重写日志，并输出到一个新的文件中 127.0.0.1:6379&gt; BGREWRITEAOF # 重写后的 AOF 日志将 3 个 INCR 命令转化为 1 个 SET 命令 *2\\r\\n$6\\r\\nSELECT\\r\\n$1\\r\\n0\\r\\n *3\\r\\n$3\\r\\nSET\\r\\n$7\\r\\ncounter\\r\\n$1\\r\\n3 除了手动执行 BGREWRITEAOF 命令之外，Redis 也支持自动触发 AOF 重写。下列配置项可用于控制这一行为： # 重写策略 no-appendfsync-on-rewrite no # 重写 AOF 日志时禁止落盘 auto-aof-rewrite-percentage 100 # 当增长百分比超过该值时，触发 AOF 重写 auto-aof-rewrite-min-size 64mb # 当日志文件体积超过该值后，触发 AOF 重写 struct redisServer { // ... int aof_no_fsync_on_rewrite; /* 重写 AOF 过程中禁止调用 fsync 落盘 */ int aof_rewrite_perc; /* 触发 AOF 重写的文件增长百分比 */ off_t aof_rewrite_min_size; /* 触发 AOF 重写的最小文件体积 */ int aof_rewrite_scheduled; /* 是否有重写操作在等待 BGSAVE 完成 */ list *aof_rewrite_buf_blocks; /* AOF 重写缓冲 */ } Redis 的定时任务 serverCron 会周期性地检查是否满足重写条件： int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { /* 延迟重写：在服务器执行 BGSAVE 命令期间，如果接收到 BGWRITEAOF 命令，会将其延迟到 BGSAVE 完成后再执行，避免相互争抢磁盘资源 I/O */ if (!hasActiveChildProcess() &amp;&amp; // 无执行后台操作的子进程，意味着 BGSAVE 已经完成 server.aof_rewrite_scheduled) // 存在等待执行的 BGWRITEAOF 命令 { rewriteAppendOnlyFileBackground(); } // ... if (server.aof_state == AOF_ON &amp;&amp; server.aof_rewrite_perc &amp;&amp; server.aof_current_size &gt; server.aof_rewrite_min_size) // 检查日志体积是否达标 { // 检查日志增量是否达标 long long base = server.aof_rewrite_base_size ? server.aof_rewrite_base_size : 1; long long growth = (server.aof_current_size*100/base) - 100; if (growth &gt;= server.aof_rewrite_perc) { // 如果当前状态满足重写条件，打印日志并开始执行 BGREWRITEAOF serverLog(LL_NOTICE,&quot;Starting automatic rewriting of AOF on %lld%% growth&quot;,growth); rewriteAppendOnlyFileBackground(); } } } int rewriteAppendOnlyFileBackground(void) { // ... if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) { /* 子进程负责重写 AOF 日志 */ char tmpfile[256]; if (rewriteAppendOnlyFile(tmpfile) == C_OK) { // ... } } else { /* 主进程不阻塞直接返回 */ serverLog(LL_NOTICE, &quot;Background append only file rewriting started by pid %d&quot;,childpid); updateDictResizePolicy(); return C_OK; } } 重写过程中，主线程仍然正常对外服务，数据库状态仍然会进行变更，但子进程重写后的 AOF 不会包含这些变更。 因此，这些新增的命令会被同时追加到 AOF 缓冲 server.aof_buf 与 重写缓冲 server.aof_rewrite_buf_blocks 中。当子进程重写完成后，将 重写缓冲 追加至重写完成的 AOF 日志中即可。 此外，为了避免与子进程的重写过程争抢磁盘I/O，可以通过 aof_no_fsync_on_rewrite 禁止主进程在重写期间调用 fsync 落盘 AOF 日志。 两者比较 RDB 快照 优点：文件结构紧凑，节省空间，易于传输，能够快速恢复 缺点：生成快照的开销只与数据库大小相关，当数据库较大时，生成快照耗时，无法频繁进行该操作 AOF 日志 优点：细粒度记录对磁盘I/O压力小，允许频繁落盘，数据丢失的概率极低 缺点：恢复速度慢；记录日志开销与更新频率有关，频繁更新会导致磁盘 I/O 压力上升 "},{"slug":"redis-replication","title":"Redis 主从复制 (Replication)","tags":["Redis"],"content":"为了保证服务的可用性，Redis 提供了复制功能，同时在多个进程中维护一致的数据状态。 通过 tcpdump 观察 Redis 支持一主多从的复制架构，该功能被简化成了一条 SLAVEOF 命令，下面通过条命令来解析 Redis 的主从复制机制。 在本机上通过 redis-server 启动两个服务，然后通过 tcpdump 观察主从间的交互情况： redis-server --port 6379 --requirepass 123456 # 启动 master redis-server --port 6380 --masterauth 123456 # 启动 slave tcpdump -t -i lo0 host localhost and port 6379 | awk -F &#39;]&#39; &#39;{print $1&quot;]&quot;$3}&#39; # 在 localhost:6380 上执行 SLAVEOF localhost 6379 建立同步连接，进入 Full-ReSync 阶段 localhost.59297 &gt; localhost.6379: Flags [S] localhost.6379 &gt; localhost.59297: Flags [S.] localhost.59297 &gt; localhost.6379: Flags [P.] &quot;PING&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;NOAUTH Authentication required.&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;AUTH 123456&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;OK&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF listening-port 6380&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;OK&quot;: localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF capa eof&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;OK&quot;: localhost.59297 &gt; localhost.6379: Flags [P.] &quot;PSYNC ? -1&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;FULLRESYNC 8efb6ca4edf1258c05a5ced43b0c73fe4deb1908 1&quot; localhost.6379 &gt; localhost.59297: Flags [P.] [|RESP: localhost.6379 &gt; localhost.59297: Flags [P.] &quot;REDIS0007M-z^Iredis-ver^F3.2.11M-z&quot; [|RESP # 完成 Full-ReSync 后进入 Propagation 阶段 localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;1&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;1&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;15&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;15&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;SELECT&quot; &quot;0&quot; &quot;SET&quot; &quot;KEY&quot; &quot;VALUE&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;85&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;85&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;SET&quot; &quot;KEY2&quot; &quot;VALUE2&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;MSET&quot; &quot;KEY3&quot; &quot;VALUE3&quot; &quot;KEY4&quot; &quot;VALUE4&quot; &quot;KEY5&quot; &quot;VALUE5&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;256&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;256&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;270&quot; localhost.59297 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;270&quot; # 在 localhost:6380 上执行 DEBUG SLEEP 60 模拟网络中断的情况 localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;SET&quot; &quot;KEY6&quot; &quot;VALUE6&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;SET&quot; &quot;KEY7&quot; &quot;VALUE7&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;MSET&quot; &quot;KEY8&quot; &quot;VALUE8&quot; &quot;KEY9&quot; &quot;VALUE9&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.6379 &gt; localhost.59297: Flags [P.] &quot;PING&quot; localhost.59297 &gt; localhost.6379: Flags [.] localhost.59297 &gt; localhost.6379: Flags [R.] # 旧的同步连接断开后重新建立同步连接，进入 Partical-ReSync 阶段 localhost.59313 &gt; localhost.6379: Flags [S] localhost.6379 &gt; localhost.59313: Flags [S.] localhost.59313 &gt; localhost.6379: Flags [P.] &quot;PING&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;NOAUTH Authentication required.&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;AUTH 123456&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;OK&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;REPLCONF listening-port 6380&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;OK&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;REPLCONF capa eof&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;OK&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;PSYNC 8efb6ca4edf1258c05a5ced43b0c73fe4deb1908 271&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;CONTINUE&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;PING&quot; &quot;PING&quot; &quot;SET&quot; &quot;KEY6&quot; &quot;VALUE6&quot; &quot;PING&quot; &quot;SET&quot; &quot;KEY7&quot; &quot;VALUE7&quot; &quot;PING&quot; &quot;MSET&quot; &quot;KEY8&quot; &quot;VALUE8&quot; &quot;KEY9&quot; &quot;VALUE9&quot; &quot;PING&quot; &quot;PING&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;519&quot; localhost.59313 &gt; localhost.6379: Flags [P.] &quot;REPLCONF&quot; &quot;ACK&quot; &quot;519&quot; localhost.6379 &gt; localhost.59313: Flags [P.] &quot;PING&quot; localhost.59313 &gt; localhost.6379: Flags [P.]: &quot;REPLCONF&quot; &quot;ACK&quot; &quot;533&quot; localhost.59313 &gt; localhost.6379: Flags [P.]: &quot;REPLCONF&quot; &quot;ACK&quot; &quot;533&quot; 整个过程可以分为 3 个阶段： Full-ReSync Command-Propagate Partical-ReSync +----------------------+ +---------------------+ | redisServer (master) | | redisServer (slave) | | localhost:6379 | | localhost:6380 | +----------------------+ +---------------------+ | slaves | | master | +----------------------+ +---------------------+ | | +----------------+ +-------------+ | redisClient[?] | | redisClient | +----------------+ +-------------+ | ^ &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; PING &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; | | Step 1 : 检查套接字与 master 状态 | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; PONG / NOAUTH &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | | | &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; AUTH &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; | | Step 2 : 身份验证 | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; OK &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | | | &lt;&lt;&lt;&lt; REPLCONF listening-port [port] &lt;&lt;&lt;&lt;&lt; | | Step 3 : 发送 slave 端口 Full-ReSync &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; OK &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | | | &lt;&lt;&lt;&lt;&lt;&lt; REPLCONF capa [eof|psync2] &lt;&lt;&lt;&lt;&lt;&lt;&lt; | | Step 4 : 检查命令兼容性 | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; OK &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | | | &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; PSYNC ? -1 &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; | | | &gt;&gt;&gt;&gt;&gt;&gt; FULLRESYNC [replid] [offset] &gt;&gt;&gt;&gt;&gt; Step 6 : 执行全量同步 | V | BGSAVE | V v &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; RDB Snapshot &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; ^ &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; REPLCONF ACK [offset] &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; COMMAND 1 &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; COMMAND 2 &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; REPLCONF ACK [offset+?] &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; 心跳检测 &amp; 命令传播 Command-Propagate &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; PING &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; COMMAND 3 &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; REPLCONF ACK [offset+?] &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; | &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; REPLCONF ACK [offset+?] &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; v &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; PING &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; ^ ========================================= | ====== The Same With Full-ReSync ======== | ========================================= | | Partical-ReSync &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; PSYNC [replid] [offset] &lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt; 重连后执行部分同步 | | | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; CONTINUE &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; | &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; COMMAND N &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; v &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; COMMAND ... &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; PSYNC 命令 最初 Redis 用于同步的命令是SYNC，每次重连执行该命令时都会生成、传输、加载整个完整的 RDB 快照，严重占用机器资源与网络带宽。为了解决这一问题，后续版本的 Redis 追加了PSYNC命令，该命令支持以下两种同步模式： Full-ReSync slave 首次连接 master master 与 slave 之间的状态差异过大 Partical-ReSync 网络抖动导致同步连接断开重连 sentinel 机制导致 master 节点发生变更 数据结构 下面看看 redisServer 中与PSYNC相关的数据结构： struct redisServer { /* * 节点ID 与 复制偏移量 * * 若当前节点是 master * server.replid 就是 server.runid * * 若当前节点原本是 master，转化为 slave 节点后 * server.replid 与 server.master_repl_offset 会被新 master 的同步信息覆盖 * * 若当前节点原本是 slave，被提升为 master 节点后 * rserver.eplid2 与 server.second_replid_offset 会记录当前节点作为 slave 时的同步信息 */ char runid[CONFIG_RUN_ID_SIZE+1]; /* 当前节点的运行时ID（每次重启都会发生变化） */ char replid[CONFIG_RUN_ID_SIZE+1]; /* 当前 master 节点的 runid */ char replid2[CONFIG_RUN_ID_SIZE+1]; /* 当前 master 节点作为 slave 节点时连接的 master 的 runid */ long long master_repl_offset; /* 当前 master 节点的复制偏移量 */ long long second_replid_offset; /* 当前 master 节点作为 slave 节点时的同步偏移量 */ /* * 复制积压缓冲 * * master 只维护一个全局的 server.repl_backlog，由所有 slave 节点共享 * 为了减少内存占用，server.repl_backlog 仅在 slave 节点存在时按需创建 */ char *repl_backlog; /* 复制积压缓冲（环形缓冲）*/ long long repl_backlog_size; /* 积压缓冲大小 */ long long repl_backlog_histlen; /* 积压数据长度 */ long long repl_backlog_idx; /* 积压缓冲尾部（可写位置）*/ long long repl_backlog_off; /* 积压缓冲首字节对应的同步偏移量（master offset）*/ } 运行ID 无论主从，每个 Redis 服务器会在启动时生成一个长度为 40 的十六进制字符串作为 runid： 当 slave 首次请求同步时，会将 master 返回的server.runid保存至server.replid 当 slave 重新请求同步时，会将之前保存的server.replid发送给 master： 如果该 ID 与当前 master 的server.runid不一致，则必须执行一次全量重新同步 如果该 ID 与当前 master 的server.runid一致，则可以尝试执行部分同步操作 复制偏移量 主从双方都会维护一个单位为字节的复制偏移量offset，通过该偏移量可以判断主从间的状态是否一致： master 向 slave 传播 N 字节数据后，会将自己的复制偏移量增加 N slave 接收到 master 传来的 N 个字节数据时，会将自己的复制偏移量增加 N 当 master 接收到 REPLCONF ACK 中的偏移量时，可以据此判断发送给 slave 的数据是否发生了丢失，并重发丢失的数据。 积压缓冲 master 端维护了一个定长的积压缓冲队列backlog： master 向 slave 传播命令时会同时将命令放入该队列，因此缓冲区里会保留一部分最新的命令 slave 发出同步请求时，如果 slave 的偏移量之后 (offset+1) 的数据存在于积压缓冲，master 才会执行部分同步 同步流程 SLAVE 视角 slave 接收到 SLAVEOF 命令后，会调用replicaofCommand开始执行主从同步： void replicaofCommand(client *c) { // ... if (!strcasecmp(c-&gt;argv[1]-&gt;ptr,&quot;no&quot;) &amp;&amp; !strcasecmp(c-&gt;argv[2]-&gt;ptr,&quot;one&quot;)) { if (server.masterhost) { // 如果接收到的命令是 SLAVE NO ONE 则断开主从同步 // ... } } else { if (c-&gt;flags &amp; CLIENT_SLAVE) { return; // 如果已经是客户端是一个 slave 节点，则拒绝该命令 } if (server.masterhost &amp;&amp; !strcasecmp(server.masterhost,c-&gt;argv[1]-&gt;ptr) &amp;&amp; server.masterport == port) { return; // 如果已经连接上 SLAVEOF 中指定的 master 节点，则直接返回 } // 如果尚未连接任意 master 节点，则根据 masterhost 与 masterport 建立 TCP 连接 // 并注册监听函数 syncWithMaster } } void syncWithMaster(connection *conn) { // 向 master 节点发送 PING 命令 if (server.repl_state == REPL_STATE_CONNECTING) { server.repl_state = REPL_STATE_RECEIVE_PONG; err = sendSynchronousCommand(SYNC_CMD_WRITE,conn,&quot;PING&quot;,NULL); // 发送 PING 命令 // ... } // 监听到 master 对 PING 命令的响应 if (server.repl_state == REPL_STATE_RECEIVE_PONG) { if (err[0] != &#39;+&#39; &amp;&amp; strncmp(err,&quot;-NOAUTH&quot;,7) != 0 &amp;&amp; strncmp(err,&quot;-NOPERM&quot;,7) != 0 &amp;&amp; strncmp(err,&quot;-ERR operation not permitted&quot;,28) != 0) { goto error; } server.repl_state = REPL_STATE_SEND_AUTH; // 只处理 master 响应值为 PONG、NOAUTH、NOPERM 的情况 } // 根据 master 对 PING 的响应值，判断是否需要授权 if (server.repl_state == REPL_STATE_SEND_AUTH) { if (server.masteruser &amp;&amp; server.masterauth) { err = sendSynchronousCommand(SYNC_CMD_WRITE,conn,&quot;AUTH&quot;, server.masteruser,server.masterauth,NULL); // 发送 AUTH 命令 // ... server.repl_state = REPL_STATE_RECEIVE_AUTH; } else { // 如果没有设置 server.masteruser 与 server.masterauth 授权信息，则跳过 AUTH server.repl_state = REPL_STATE_SEND_PORT; } } // 此处略过以下步骤： // 使用 REPLCONF listening-port 命令将 slave 的端口告知 master // 使用 REPLCONF ip-address 命令将 slave 的 IP 告知 master // 使用 REPLCONF capa eof / capa psync2 命令将 slave 兼容性（支持的特性）告知 master // 开始发送 PSYNC 命令 if (server.repl_state == REPL_STATE_SEND_PSYNC) { if (slaveTryPartialResynchronization(conn,0) == PSYNC_WRITE_ERROR) { goto write_error; } server.repl_state = REPL_STATE_RECEIVE_PSYNC; return; } // 读取 PSYNC 命令的响应 psync_result = slaveTryPartialResynchronization(conn,1); // 如果监听到 CONTINUE 响应，跳过全量同步 if (psync_result == PSYNC_CONTINUE) return; // 如果返回值为 PSYNC_FULLRESYNC 或 PSYNC_NOT_SUPPORTED // 开始执行执行全量同步，注册 readSyncBulkPayload 监听 RDB 文件下载 if (connSetReadHandler(conn, readSyncBulkPayload) == C_ERR) { // ... goto error; } server.repl_state = REPL_STATE_TRANSFER; // ... } int slaveTryPartialResynchronization(connection *conn, int read_reply) { if (!read_reply) { if (server.cached_master) { // server.cached_master 中存在记录，尝试执行部分同步 psync_replid = server.cached_master-&gt;replid; } else { psync_replid = &quot;?&quot;; // server.cached_master 中不存在记录，只能执行全量同步 } // 发起 PSYNC 命令 reply = sendSynchronousCommand(SYNC_CMD_WRITE,conn,&quot;PSYNC&quot;,psync_replid,psync_offset,NULL); // ... return PSYNC_WAIT_REPLY; } reply = sendSynchronousCommand(SYNC_CMD_READ,conn,NULL); // 读取 PSYNC 响应 // 如果 master 响应 FULLRESYNC 则直接进行全量同步 if (!strncmp(reply,&quot;+FULLRESYNC&quot;,11)) { // ... return PSYNC_FULLRESYNC; } // 如果 master 响应 CONTINUE 则尝试执行部分同步 if (!strncmp(reply,&quot;+CONTINUE&quot;,9)) { // ... return PSYNC_CONTINUE; } // master 暂时无法处理 PSYNC 命令 —&gt; PSYNC_TRY_LATER // master 不支持 PSYNC 命令 -&gt; PSYNC_NOT_SUPPORTED } MASTER 视角 master 接收到 PSYNC 命令后，会调用syncCommand开启同步流程： void syncCommand(client *c) { // 接收到 slave 发送的 PSYNC 命令 if (!strcasecmp(c-&gt;argv[0]-&gt;ptr,&quot;psync&quot;)) { if (masterTryPartialResynchronization(c) == C_OK) { return; // 无需全量同步，直接返回 } } // 若代码运行至此处，意味着部分同步失败，需要执行全量同步 // master 会执行 BGSAVE 命令生成快照并传输给 slave // 同步 RDB 快照的方式有两种： // 基于磁盘(Disk-backed)：在磁盘\b生成 RDB 快照文件，然后再传输给 slave // 无盘(Diskless)：直接将 RDB 快照数据写入 slave socket } int masterTryPartialResynchronization(client *c) { long long psync_offset; // 该 slave 最新的同步偏移量 char *master_replid; // slave 同步偏移量对应的 master 的 runid /* * 以下情况可以避免全量同步： * 1. slave 最近一次同步的 master 是当前实例（网络抖动） * 2. slave 与当前节点原本是同个 master 的从节点，且当前节点的同步偏移量 second_replid_offset 较大（维护重启、故障切换）*/ if (strcasecmp(master_replid, server.replid) &amp;&amp; (strcasecmp(master_replid, server.replid2) ||psync_offset &gt; server.second_replid_offset)) { goto need_full_resync; // 不满足 PSYNC 条件，需要执行全量同步 } /* * 以下情况只能执行全量同步： * 1. master 没有初始化积压缓冲 * 2. slave 的同步偏移量落后于积压缓冲 */ if (!server.repl_backlog || psync_offset &lt; server.repl_backlog_off || psync_offset &gt; (server.repl_backlog_off + server.repl_backlog_histlen)) { goto need_full_resync; // 进行全量同步 } // 若代码运行至此处，意味着可以执行部分同步 listAddNodeTail(server.slaves,c); // 根据客户端是否兼容 PSYNC2，返回不同的 CONTINUE 响应 if (c-&gt;slave_capa &amp; SLAVE_CAPA_PSYNC2) { buflen = snprintf(buf,sizeof(buf),&quot;+CONTINUE %s\\r\\n&quot;, server.replid); } else { buflen = snprintf(buf,sizeof(buf),&quot;+CONTINUE\\r\\n&quot;); } // CONTINUE 命令后面，紧接着就是 server.repl_backlog 的内容 psync_len = addReplyReplicationBacklog(c,psync_offset); // ... } 心跳 &amp; 命令传播 Redis 每秒会执行一次定时任务replicationCron，其中就包含主从同步间的心跳，可以发现主从双方的心跳频率是不一致的： void replicationCron(void) { // slave 定时向 master 发送 REPLCONF ACK 命令 if (server.masterhost &amp;&amp; server.master &amp;&amp; !(server.master-&gt;flags &amp; CLIENT_PRE_PSYNC)) { addReplyArrayLen(c,3); addReplyBulkCString(c,&quot;REPLCONF&quot;); addReplyBulkCString(c,&quot;ACK&quot;); addReplyBulkLongLong(c,c-&gt;reploff); } // master 定时向 slave 发送 PING 命令 if ((replication_cron_loops % server.repl_ping_slave_period) == 0 &amp;&amp; listLength(server.slaves)) { robj *ping_argv[1]; ping_argv[0] = createStringObject(&quot;PING&quot;,4); replicationFeedSlaves(server.slaves, server.slaveseldb, ping_argv, 1); decrRefCount(ping_argv[0]); } } master 在调用call函数执行客户端传过来的命令时，会将命令传播给 slave 并同时写入积压缓冲： void call(client *c, int flags) { // ... if (flags &amp; CMD_CALL_PROPAGATE &amp;&amp; (c-&gt;flags &amp; CLIENT_PREVENT_PROP) != CLIENT_PREVENT_PROP) { // 当前命令是否需要传播 if (propagate_flags != PROPAGATE_NONE &amp;&amp; !(c-&gt;cmd-&gt;flags &amp; CMD_MODULE)) propagate(c-&gt;cmd,c-&gt;db-&gt;id,c-&gt;argv,c-&gt;argc,propagate_flags); } } void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, int flags) { // ... if (flags &amp; PROPAGATE_REPL) replicationFeedSlaves(server.slaves,dbid,argv,argc); } void replicationFeedSlaves(list *slaves, int dictid, robj **argv, int argc) { // 如果当前节点没有 slave 节点或复制积压缓冲，立即返回 if (server.repl_backlog == NULL &amp;&amp; listLength(slaves) == 0) return; // 向 repl_backlog 中批量写入命令 if (server.repl_backlog) { char aux[LONG_STR_SIZE+3]; // 命令缓冲，用于序列化 redis 命令 /* 写入当前批次的命令数量 */ aux[0] = &#39;*&#39;; len = ll2string(aux+1,sizeof(aux)-1,argc); aux[len+1] = &#39;\\r&#39;; aux[len+2] = &#39;\\n&#39;; feedReplicationBacklog(aux,len+3); /* 逐个遍历命令，将其序列化后写入 repl_backlog */ for (j = 0; j &lt; argc; j++) { long objlen = stringObjectLen(argv[j]); aux[0] = &#39;$&#39;; len = ll2string(aux+1,sizeof(aux)-1,objlen); aux[len+1] = &#39;\\r&#39;; aux[len+2] = &#39;\\n&#39;; feedReplicationBacklog(aux,len+3); feedReplicationBacklogWithObject(argv[j]); feedReplicationBacklog(aux+len+1,2); } } // 将命令批量传播给所有 slaves 对应的 client listRewind(slaves,&amp;li); while((ln = listNext(&amp;li))) { client *slave = ln-&gt;value; /* 写入当前批次的命令数量 */ addReplyArrayLen(slave,argc); /* 逐个遍历命令，将传播给 slave 节点 */ for (j = 0; j &lt; argc; j++) addReplyBulk(slave,argv[j]); } } 相关参数 slave-serve-stale-data 主从节点断开时或同步未完成时，slave 如何响应客户端请求 yes：正常响应命令，但是不保证数据质量 no：拒绝响应命令，返回 SYNC with master in progress repl-diskless-sync 执行全量同步时，master 如何将 RDB 快照传输给 slave no：先在磁盘生成 RDB 文件再进行传输（低带宽网络） yes：直接将 RDB 快照写入 slave 的 socket（低速磁盘 + 高带宽网络） repl-ping-slave-period master 向 slave 发送 PING 心跳的间隔，默认 10s 发送一次 repl-backlog-size 同步积压缓冲的空间，默认值大小为 1mb。 由于主从连接断开后，所有的命令都会积压在这里，如果该值太小会导致 PSYNC 命令会无法执行部分同步。 如果 master 需要执行大量写命令，或者 slave 需要较长时间才能重连成功，则需要根据实际情况进行估算。 min-slaves-to-write &amp; min-slaves-max-lag 则当不满足下列条件时，master 会拒绝写命令直至恢复： 连接当前 master 的 slave 数量大于等于 min-slaves-to-write 个节点连接正常 连接正常的 slave 节点中不少于 min-slaves-to-write 个节点的延迟时间小于 *min-slaves-max-lag 秒 启用这两个选项后，写命令大概率能够被复制到 min-slaves-to-write 个从节点中，减少了命令丢失的概率。 "},{"slug":"redis-sentinel","title":"Redis 哨兵高可用（Sentinel）","tags":["Redis"],"content":"Redis Sentinel 是 Redis 的一个分布式监控系统，可以并发现主节点宕机时自动执行故障转移，将请求流量转发至健康的从节点。 哨兵机制是 Redis 高可用中重要的一环，其核心是 通过高可用哨兵集群，监控主从复制的健康状态，并实现自动灾备： flowchart LR subgraph ss[&quot; &quot;] s1([Sentinel]) ---|keepalive| s2([Sentinel]) s2([Sentinel]) -- &quot;keepalive&quot; --- s3([Sentinel]) end slave((Slave)) ---|replicate&lt;br/&gt;&lt;br/&gt;| master((Master)) master &lt;---&gt; client{{Client}} ss -- &quot;monitor&lt;br/&gt;&lt;br/&gt;&quot; --- master ss -- &quot;monitor&lt;br/&gt;&lt;br/&gt;&quot; --- slave ss -- &quot;notify&lt;br/&gt;&lt;br/&gt;&quot; --&gt; client style ss fill:none,stroke-width:2px,stroke-dasharray: 5 5 linkStyle 4,5,6 stroke-dasharray: 5 5 哨兵集群以集群的方式进行部署，这种分布式特性具有以下优点： 避免系统中存在单点，防止灾备机制失效 切换 master 必须经过多个 sentinel 节点协商同意，避免出现误判 为了保证 Redis 服务的高可用，哨兵机制提供了以下功能： 监控(Monitoring)：实时监控主从节点的健康状况 通知(Notification)：通过事件 API 将服务实例异常情况即时告知监听者 自动灾备(Automatic failover)：当 master 节点失效时从 slave 中选举出新的 master 服务发现(Configuration provider)：客户端通过哨兵集群获取 master 实例信息，在发生自动灾备时能及时将 master 变化告知客户端 相关配置 配置项 建立哨兵集群的配置相对简单，只需配置以下选项即可： sentinel monitor &lt;master-name&gt; &lt;ip&gt; &lt;port&gt; &lt;quorum&gt; 需要监控的 master 节点的信息（由于 sentinel 会自动发现 slave 节点信息，因此无需配置） master-name 用于区分不同的 master 节点，\u001e会被用于 master 发现 quorum 是启动故障转移failover时，所需的最小 sentinel 数量 故障转移前需要选举出一个 leader 节点执行 master 切换，为了达成共识，该过程必须有半数以上的节点 majority 参与。 假设当前哨兵集群总共有 m 个节点，当 quorum 设置为 n 时（n &le; m）： 若同时有 n 个节点判断当前 master 已经下线，则其中的某个 sentinel 会尝试发起故障转移 实际执行故障转移需要进行 leader 选举，因此仅当集群中至少有 m/2 以上的 sentinel 节点可用时，故障转移才可能启动 简而言之，quorum 仅影响故障检测流程，用于控制发起故障转移的时机，但是无法决定 failover 是否会被执行。 因此，哨兵实例应不少于 3 个，否则一旦某个哨兵节点失效后，即便 quorum 设置为 1，依然无法启动 faiover。 sentinel down-after-milliseconds &lt;master-name&gt; &lt;milliseconds&gt; 当某个 redis 节点超过该时间无法正常响应（对 PING 请求没有响应或返回错误码），sentinel 会认为其已经下线 sentinel parallel-syncs &lt;master-name&gt; &lt;numslaves&gt; 将 slave 中的某个节点中提升为 master 后，剩余节点立即重连新 master 的数量 重新同步会导致 slave 节点从 master 批量同步数据，间接会造成 slave 的短暂停顿。 假如当前总共有 m 个 slave 节点，当 parallel-syncs 设置为 n 时，则 failover 会将 slave 分为 m/n 批进行调整。 该值越小，则 failover 所需的时间越长，但对访问 slave 客户端的影响越小。 sentinel failover-timeout &lt;master-name&gt; &lt;milliseconds&gt; 故障转移对重试间隔，默认值为 3min，该选项会影响： sentinel 对同个 master 发起 failover 后，再次重试的时间间隔 (2 * failover-timeout) sentinel 发现 salve 配置过期后，将其指向新的 master 所需的时间 取消一个正在进行的 failover 流程所需的时间 failover 等待 slave 重连新 master 完成的时间 配置发现 仔细观察哨兵配置时，会发现缺失了 其他哨兵节点信息 与 从节点信息。 这是因为哨兵机制本身支持配置发现，每个哨兵节点能够自行从监控的 master 节点处获取到上面两项信息： 哨兵发现 flowchart LR subgraph ss[&quot; &quot;] s1([Sentinel]) s2([Sentinel]) s3([Sentinel]) end s1 ---|SUBSCRIBE| topic[&quot;__sentinel__:hello&quot;] s2 ---&gt;|PUBLISH sentinel-ip:port| topic s3 ---|SUBSCRIBE| topic topic -.- master((Master)) style ss fill:none,stroke:none 哨兵利用了 Redis 的 发布/订阅 实现来实现互相通信，哨兵和主库建立连接后： 向主库上名为 __sentinel__:hello 的频道发布消息，将自己的 IP 和端口告知其他节点 订阅 __sentinel__:hello 消息，获得其他哨兵发布的连接信息 当多个哨兵实例都在主库上做了发布和订阅操作后，它们互相感知到彼此的 IP 地址和端口。 因此扩容哨兵集群十分简单，只需要启动一个新的哨兵节点即可，剩余的操作交由自动发现机制完成即可。 从库发现 flowchart TB subgraph sd[&quot; &quot;] master --&gt;|SLAVE1,2| s s([Sentinel]) --&gt;|INFO| master((Master)) subgraph ms[&quot; &quot;] direction TB s1((Slave)) s2((Slave)) end master -.- s1 master -.- s2 end style sd fill:none,stroke:none style ms fill:none,stroke:none 哨兵向主库发送 INFO 命令，主库接受到这个命令后，就会把从库列表返回给哨兵。 然后哨兵根据从库列表中的连接信息，和每个从库建立连接，并在这个连接上持续地对从库进行监控。 同时，哨兵也会向从库发送 INFO 命令以获取以下信息： run_id : 从库的运行ID slave_priority : 从库的优先级 slave_repl_offset : 从库的复制偏移量 节点下线 哨兵永远不会忘记已经见过的节点，无论这个节点是哨兵还是从库。如果需要下线集群中的节点时，需要用到SENTINEL RESET命令： 当需要下线哨兵节点时，首先停止该节点进程，然后在剩余哨兵节点上执行SENTINEL RESET *更新集群信息 当需要下线某个 master 的从库时，首先停止该节点进程，然后在所有哨兵节点上执行SENTINEL RESET &lt;master-name&gt;更新监控列表 状态监控 为了保证主库的可用性，哨兵集群会以一定的间隔向主从库发送PING命令，并根据命令的返回结果判断主库是否健康： 返回值为+PONG、-LOADING或-MASTERDOWN，则认为这个节点健康 返回其他值或没有响应，则认为这个节点不健康 不健康的状态持续超过 down-after-milliseconds，则认为这个节点已经下线。 还有一种特殊情况：某个本应是 master 的节点，在INFO命令返回值中将自己标榜为 slave，那么哨兵也会认为该节点已经下线。 为了降低误判率，哨兵集群将节点下线分为两个阶段： 主观下线SDOWN：某个 sentinel 实例认为该节点已经下线 客观下线ODOWN：某个 sentinel 通过 SENTINEL is-master-down-by-addr 命令向其他节点询问，发现同时有 quorum 个 sentinel 实例认为该节点已经下线 只有 master 节点会被标记为ODOWN，并且仅当 master 节点被标记为ODOWN时才肯会触发 failover 流程。而 slave 与 sentinel 节点仅会被标记为SDOWN。 故障转移 故障转移过程被设计为一个异步的状态机，其主要步骤如下： void sentinelFailoverStateMachine(sentinelRedisInstance *ri) { serverAssert(ri-&gt;flags &amp; SRI_MASTER); if (!(ri-&gt;flags &amp; SRI_FAILOVER_IN_PROGRESS)) return; switch(ri-&gt;failover_state) { // 选举 leader case SENTINEL_FAILOVER_STATE_WAIT_START: sentinelFailoverWaitStart(ri); break; // 从已下线 master 的 slave 中挑选出一个候选节点 case SENTINEL_FAILOVER_STATE_SELECT_SLAVE: sentinelFailoverSelectSlave(ri); break; // 向候选节点发送 SLAVEOF NO ONE 命令将其转化为 master 节点 case SENTINEL_FAILOVER_STATE_SEND_SLAVEOF_NOONE: sentinelFailoverSendSlaveOfNoOne(ri); break; // 通过 INFO 命令检查新的 master 节点是否已经就绪 case SENTINEL_FAILOVER_STATE_WAIT_PROMOTION: sentinelFailoverWaitPromotion(ri); break; // 向剩余的 slave 节点发送 SLAVEOF 命令指向新的 master case SENTINEL_FAILOVER_STATE_RECONF_SLAVES: sentinelFailoverReconfNextSlave(ri); break; } } 选举 leader 当 master 被判断为客观下线时，会触发一次故障转移。为了保证系统最终能够收敛于一致的状态，每次对主从配置进行修改前，都会将变更关联到一个全局唯一的单调递增版本号 —— 配置纪元epoch：epoch 较小的变更会被更大的变更覆盖，从而保证来并发修改的分布式一致性。 此外，哨兵集群每个会为每个epoch选举出一个 leader 来实施配置变更，避免发生不必要的故障转移： block-beta columns 3 block:hint:3 space space space title{{&quot;epoch++&quot;}} space space space end block:epoch:3 space start space space space space space x end block:ss1:1 space s1(&quot;epoch = 1 leader = B&quot;) space end block:ss2:1 space s2(&quot;epoch = 3 leader = A&quot;) space end block:ss3:1 space s3(&quot;epoch = 10 leader = C&quot;) space end s1A([&quot;Sentinel A&quot;]) s2A([&quot;Sentinel A&quot;]) s3A([&quot;Sentinel A&quot;]) s1B([&quot;Sentinel B&quot;]) s2B([&quot;Sentinel B&quot;]) s3B([&quot;Sentinel B&quot;]) s1C([&quot;Sentinel C&quot;]) s2C([&quot;Sentinel C&quot;]) s3C([&quot;Sentinel C&quot;]) start(((&quot; &quot;))) --&gt; x(&quot; &quot;) classDef hidden fill:none,stroke:none classDef leader stroke-width:4px,stroke-dasharray: 10 5 class x,epoch,hint,ss1,ss2,ss3 hidden class s1B,s2A,s3C leader 选举通过命令SENTINEL IS-MASTER-DOWN-BY-ADDR &lt;ip&gt; &lt;port&gt; &lt;current-epoch&gt; &lt;runid&gt;完成： char *sentinelVoteLeader(sentinelRedisInstance *master, uint64_t req_epoch, char *req_runid, uint64_t *leader_epoch) { // 如果投票请求的 epoch 比已知更大，则更新本地的 epoch if (req_epoch &gt; sentinel.current_epoch) { sentinel.current_epoch = req_epoch; sentinelFlushConfig(); sentinelEvent(LL_WARNING,&quot;+new-epoch&quot;,master,&quot;%llu&quot;, (unsigned long long) sentinel.current_epoch); } // 如果投票请求的的 epoch 比当前 leader 更大 if (master-&gt;leader_epoch &lt; req_epoch &amp;&amp; sentinel.current_epoch &lt;= req_epoch) { // 根据 FCFS 原则，增将 epoch 的票投给该 sentinel sdsfree(master-&gt;leader); master-&gt;leader = sdsnew(req_runid); master-&gt;leader_epoch = sentinel.current_epoch; sentinelFlushConfig(); sentinelEvent(LL_WARNING,&quot;+vote-for-leader&quot;,master,&quot;%s %llu&quot;, master-&gt;leader, (unsigned long long) master-&gt;leader_epoch); // 如果是接收到来自其他 sentinel 的投票请求，则更新 failover 开始时间 // 避免本实例在 failover timeout 时间内触发不必要的投票 if (strcasecmp(master-&gt;leader,sentinel.myid)) master-&gt;failover_start_time = mstime()+rand()%SENTINEL_MAX_DESYNC; } // 小于 sentinel.current_epoch 的请求会被忽略 // 更新 leader 信息 *leader_epoch = master-&gt;leader_epoch; return master-&gt;leader ? sdsnew(master-&gt;leader) : NULL; } 该选举流程是 Raft 协议的简化版，有兴趣的朋友可以深入了解。 筛选 slave 为了保证新的 master 拥有最新的状态，leader 会排除以下 slave 节点： 排除所有处于主观下线状态的节点（节点健康） 排除最近 5 秒内没有响应 leader 发出 INFO 命令的节点（通信正常） 排除与原 master 断线时间超过 down-after-milliseconds * 10 的节点（副本较新） 最后，按照 slave_priority、slave_repl_offset、run_id 对进行排序，选择其中优先级最高、偏移量最大、运行ID最小的节点作为新的 master。 提升 master 首先调用sentinelFailoverSendSlaveOfNoOne提升候选节点为 master： void sentinelFailoverSendSlaveOfNoOne(sentinelRedisInstance *ri) { int retval; // 如果候选节点不可用，则一直尝试直到 failover 超时 if (ri-&gt;promoted_slave-&gt;link-&gt;disconnected) { if (mstime() - ri-&gt;failover_state_change_time &gt; ri-&gt;failover_timeout) { sentinelEvent(LL_WARNING,&quot;-failover-abort-slave-timeout&quot;,ri,&quot;%@&quot;); sentinelAbortFailover(ri); } return; } // 发送 SLAVEOF ON ONE 命令并等待其转化为 master retval = sentinelSendSlaveOf(ri-&gt;promoted_slave,NULL,0); if (retval != C_OK) return; sentinelEvent(LL_NOTICE, &quot;+failover-state-wait-promotion&quot;, ri-&gt;promoted_slave,&quot;%@&quot;); ri-&gt;failover_state = SENTINEL_FAILOVER_STATE_WAIT_PROMOTION; ri-&gt;failover_state_change_time = mstime(); } 之后调用sentinelFailoverReconfNextSlave令剩余 slave 复制新的 master 节点： void sentinelFailoverReconfNextSlave(sentinelRedisInstance *master) { // ... // 批量调整 slave 节点，并保证每批数量不超过 parallel syncs 配置 di = dictGetIterator(master-&gt;slaves); while(in_progress &lt; master-&gt;parallel_syncs &amp;&amp; (de = dictNext(di)) != NULL) { sentinelRedisInstance *slave = dictGetVal(de); int retval; // 跳过调整完成的节点 if (slave-&gt;flags &amp; (SRI_PROMOTED|SRI_RECONF_DONE)) continue; // 如果 slave 长时间没有完成配置修改，则依然认为已经完成 // 哨兵节点会在后续流程中检测出配置异常并进行修复 if ((slave-&gt;flags &amp; SRI_RECONF_SENT) &amp;&amp; (mstime() - slave-&gt;slave_reconf_sent_time) &gt; SENTINEL_SLAVE_RECONF_TIMEOUT) { sentinelEvent(LL_NOTICE,&quot;-slave-reconf-sent-timeout&quot;,slave,&quot;%@&quot;); slave-&gt;flags &amp;= ~SRI_RECONF_SENT; slave-&gt;flags |= SRI_RECONF_DONE; } // 跳过已发出过命令或已经下线的 slave 节点 if (slave-&gt;flags &amp; (SRI_RECONF_SENT|SRI_RECONF_INPROG)) continue; if (slave-&gt;link-&gt;disconnected) continue; // 发送 SLAVEOF 令其复制新的 master retval = sentinelSendSlaveOf(slave, master-&gt;promoted_slave-&gt;addr-&gt;ip, master-&gt;promoted_slave-&gt;addr-&gt;port); if (retval == C_OK) { slave-&gt;flags |= SRI_RECONF_SENT; slave-&gt;slave_reconf_sent_time = mstime(); sentinelEvent(LL_NOTICE,&quot;+slave-reconf-sent&quot;,slave,&quot;%@&quot;); in_progress++; } } // 检查是否已经完成所有 slave 的配置修改 sentinelFailoverDetectEnd(master); } 当已下线的 master 再次上线时，哨兵节点会检测出其配置已经失效，并会将其作为 slave 对待，令其复制新的 master 数据。这也意味着该节点上未被同步到新 master 的那部分数据会永远丢失。 为了减少数据丢失，可以配合参数min-replicas-to-write与min-replicas-max-lag阻止客户端向失去 slave 的 master 节点写入数据。 事件API 为了方便客户端感知集群状态变化，哨兵集群定义了一系列的事件event，客户端可以通过订阅 sentinel 节点上与这些事件同名的 channel 来监听状态变化。 大部分事件的内容格式如下（@ 之后的部分是可选的）： &lt;instance-type&gt; &lt;name&gt; &lt;ip&gt; &lt;port&gt; @ &lt;master-name&gt; &lt;master-ip&gt; &lt;master-port&gt; 这里列出部分可供监听事件： switch-master : 最新的 master 节点信息，其内容为 &lt;master-name&gt; &lt;oldip&gt; &lt;oldport&gt; &lt;newip&gt; &lt;newport&gt; +sdown : 某节点进入主观下线状态 -sdown : 某节点退出主观下线状态 +odown : 某节点进入客观下线状态 -odown : 某节点退出客观下线状态 +tilt : 哨兵集群进入 TILT 模式 -tilt : 哨兵集群退出 TILT 模式 +reset-master : 重置了某个 master-name 下的监控信息 +failover-detected : 感知到故障转移（可能是由 sentinel 发起的，也可能是人工将某个 slave 节点提升为 master） failover-end : 故障转移结束，并且所有 slave 已经指向新 master failover-end-for-timeout : 故障转移结束超时，部分 slave 未指向新 master，集群状态尚需时间完成收敛 如果需要订阅所有事件，只需要执行命令PSUBSCRIBE *即可。 JedisSentinelPool 为了加深印象，下面通过分析 jedis-3.3.0 中 JedisSentinelPool 的源码来观察如何使用事件 API。 JedisSentinelPool启动时调用初始化函数initSentinels获取 master 信息： private HostAndPort initSentinels(Set&lt;String&gt; sentinels, final String masterName) { HostAndPort master = null; // 遍历 sentinel 信息并建立连接 for (String sentinel : sentinels) { final HostAndPort hap = HostAndPort.parseString(sentinel); Jedis jedis = null; try { jedis = new Jedis(hap.getHost(), hap.getPort(), sentinelConnectionTimeout, sentinelSoTimeout); // ... // 发送 get-master-addr-by-name 命令获取 master 节点 List&lt;String&gt; masterAddr = jedis.sentinelGetMasterAddrByName(masterName); if (masterAddr == null || masterAddr.size() != 2) { log.warn(&quot;Can not get master addr, master name: {}. Sentinel: {}&quot;, masterName, hap); continue; } // 获取到 master 节点信息后退出 master = toHostAndPort(masterAddr); break; } catch (JedisException e) { log.warn( &quot;Cannot get master address from sentinel running @ {}. Reason: {}. Trying next one.&quot;, hap, e); } finally { if (jedis != null) { jedis.close(); } } } if (master == null) { // 无法获取到 master 信息，此处会抛出异常 // ... } // 启动监听线程，监听所有 sentinel，保证及时感知到集群变化 for (String sentinel : sentinels) { final HostAndPort hap = HostAndPort.parseString(sentinel); MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort()); masterListener.setDaemon(true); masterListeners.add(masterListener); masterListener.start(); } return master; } MasterListener类通过事件 API 监听 master 节点变化并在重新初始化连接池： class MasterListener extends Thread { protected String masterName; protected String host; protected int port; protected long subscribeRetryWaitTimeMillis = 5000; protected volatile Jedis j; protected AtomicBoolean running = new AtomicBoolean(false); public MasterListener(String masterName, String host, int port) { super(String.format(&quot;MasterListener-%s-[%s:%d]&quot;, masterName, host, port)); this.masterName = masterName; this.host = host; this.port = port; } @Override public void run() { running.set(true); while (running.get()) { try { // 与 sentinel 建立连接 j = new Jedis(host, port, sentinelConnectionTimeout, sentinelSoTimeout); // ... // 再次获取 master 信息 List&lt;String&gt; masterAddr = j.sentinelGetMasterAddrByName(masterName); if (masterAddr == null || masterAddr.size() != 2) { log.warn(&quot;Can not get master addr, master name: {}. Sentinel: {}:{}.&quot;, masterName, host, port); } else { // 如果 master 发生变化则重新重新初始化连接池 initPool(toHostAndPort(masterAddr)); } // 监听 +switch-master 事件感知 master 节点变化 j.subscribe(new JedisPubSub() { @Override public void onMessage(String channel, String message) { // master 发生了变化 String[] switchMasterMsg = message.split(&quot; &quot;); if (switchMasterMsg.length &gt; 3) { // 只处理与当前 master-name 相关的信息 if (masterName.equals(switchMasterMsg[0])) { // 如果 master 发生变化则重新重新初始化连接池 initPool(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4]))); } } else { log.error( &quot;Invalid message received on Sentinel {}:{} on channel +switch-master: {}&quot;, host, port, message); } } }, &quot;+switch-master&quot;); } catch (JedisException e) { if (running.get()) { // 连接断开后，等待 5s 重连 log.error(&quot;Lost connection to Sentinel at {}:{}. Sleeping 5000ms and retrying.&quot;, host, port, e); try { Thread.sleep(subscribeRetryWaitTimeMillis); } catch (InterruptedException e1) { log.error(&quot;Sleep interrupted: &quot;, e1); } } else { log.debug(&quot;Unsubscribing from Sentinel at {}:{}&quot;, host, port); } } finally { if (j != null) { j.close(); } } } } public void shutdown() { try { log.debug(&quot;Shutting down listener on {}:{}&quot;, host, port); running.set(false); // This isn&#39;t good, the Jedis object is not thread safe if (j != null) { j.disconnect(); } } catch (Exception e) { log.error(&quot;Caught exception while shutting down: &quot;, e); } } } "},{"slug":"redis-data-structures-and-object-encoding","title":"Redis 数据结构与对象编码 (Object Encoding)","tags":["Redis"],"content":"Redis 对常用的数据结构提供了开箱即用的支持，这边文章会深入解析这些数据结构的实现。 数据结构实现 相信大家对 redis 的数据结构都比较熟悉： string：字符串（可以表示字符串、整数、位图） list：列表（可以表示线性表、栈、双端队列、阻塞队列） hash：哈希表 set：集合 zset：有序集合 为了将性能优化到极致，redis 作者为每种数据结构提供了不同的实现方式，以适应特定应用场景。 以最常用的 string 为例，其底层实现就可以分为 3 种：int, embstr, raw： 127.0.0.1:6379&gt; SET counter 1 OK 127.0.0.1:6379&gt; OBJECT ENCODING counter &quot;int&quot; 127.0.0.1:6379&gt; SET name &quot;Tom&quot; OK 127.0.0.1:6379&gt; OBJECT ENCODING name &quot;embstr&quot; 127.0.0.1:6379&gt; SETBIT bits 1 1 (integer) 0 127.0.0.1:6379&gt; OBJECT ENCODING bits &quot;raw&quot; 这些特定的底层实现在 redis 中被称为 编码（encoding），下面逐一介绍这些编码实现。 string redis 中所有的 key 都是字符串，这些字符串是通过一个名为 **简单动态字符串（SDS）**的数据结构实现的。 typedef char *sds; // SDS 字符串指针，指向 sdshdr.buf struct sdshdr? { // SDS header，[?] 可以为 8, 16, 32, 64 uint?_t len; // 已用空间，字符串的实际长度 uint?_t alloc; // 已分配空间，不包含&#39;\\0&#39; unsigned char flags; // 类型标记，指明了 len 与 alloc 的实际类型，可以通过 sds[-1] 获取 char buf[]; // 字符数组，保存以&#39;\\0&#39;结尾的字符串，与传统 C 语言中的字符串的表达方式保持一致 }; 内存布局如下： +-------+---------+-----------+-------+ | len | alloc | flags | buf | +-------+---------+-----------+-------+ ^--sds[-1] ^--sds 相较于传统的 C 字符串，其优点如下： 高效：记录了已用空间，获取字符串长度的操作为$O(1)$ 安全：记录了空闲空间，可以避免写缓冲区越界的问题 内存友好：通过记录了空间信息，可以预分配空间，实现惰性删除，减少内存分配的同时不会造成内存泄露 二进制安全：字符串内容可以为非 ASCII 编码，任意数据都能被编码为二进制字符串 兼容 C 字符串：可以复用部分 C 标准库代码，避免无用重复 list redis 中 list 的底层实现之一是双向链表，该结构支持顺序访问，并提供了高效的元素增删功能。 typedef struct listNode { struct listNode *prev; // 前置节点 struct listNode *next; // 后置节点 void *value; // 节点值 } listNode; typedef struct list { listNode *head; // 头节点 listNode *tail; // 尾节点 unsigned long len; // 列表长度 void *(*dup) (void *ptr); // 节点值复制函数 void (*free) (void *ptr); // 节点值释放函数 int (*match) (void *ptr); // 节点值比较函数 } list; 这里使用了函数指针来实现动态绑定，根据 value 类型，指定不同 dup, free, match 的函数，实现多态。 该数据结构有以下特征： 有长：获取列表长度的操作为 $O(1)$ 双端：可以同时支持正向和逆向遍历，获取前后位置的节点复杂度为 $O(1)$ 无环：没有设置哨兵节点，列表为空时，表头表尾均为 NULL 多态：通过函数指针实现多态，数据结构可以复用 dict redis 中使用 dict 来保存键值对，其底层实现之一是哈希表。 typedef struct dictEntry { void* key; // 键 union { // 值，可以为指针、有符号长整，无符号长整，双精度浮点 void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; } dictEntry; typedef struct dictht { dictEntry **table; // 哈希表数组，数组中的每个元素是一个单向链表 unsigned long size; // 哈希表数组大小 unsigned long sizemask; // 哈希掩码，用于计算索引 unsigned long used; // 已有节点数量 } dictht; typedef struct dictType { unsigned int (*hashFunction) (const void *key); // 哈希函数，用于计算哈希值 int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 键比较函数 void *(*keyDup)(void *privdata, const void *key); // 键复制函数 void *(*valDup)(void *privdata, const void *obj); // 值复制函数 void *(*keyDestructor)(void *privdata, const void *key); // 键销毁函数 void *(*valDestructor)(void *privdata, const void *obj); // 值销毁函数 } dictType; typedef struct dict { dictType *type; // 类型函数，用于实现多态 void *privdata; // 私有数据，用于实现多态 dictht ht[2]; // 哈希表，字典使用 ht[0] 作为哈希表，ht[1] 用于进行 rehash int rehashidx; // rehash索引，当没有执行 rehash 时，其值为 -1 } dict; 该数据结构有以下特征： 哈希算法：使用 murmurhash2 作为哈希函数，时间复杂度为 $O(1)$ 冲突解决：使用链地址法解决冲突，新增元素会被放到表头，时间复杂度为 $O(1)$ 重新散列：每次 rehash 操作都会分成 3 步完成： 为dict.ht[1]分配空间，其大小为 $2^n$ 将dict.ht[0]中的所有键值对 rehash 到dict.ht[1]上 释放dict.ht[0]的空间，用dict.ht[1]替换 dict.ht[0] rehash 的一些细节 分摊开销 为了减少停顿，步骤 2 会分为多次渐进完成，将 rehash 键值对所需的计算工作，平均分摊到每个字典的增加、删除、查找、更新操作，期间会使用dict.rehashidx记录dict.ht[0]中已经完成 rehash 操作的dictht.table索引： 每执行一次 rehash 操作，dict.rehashidx计数器会加 1 当 rehash 完成后，dict.rehashidx会被设置为 -1 触发条件 计算当前负载因子：loader_factor = ht[0].used / ht[0].size 收缩： 当 loader_factor &lt; 0.1 时，执行 rehash 回收空闲空间 扩展： 没有执行 BGSAVE 或 BGREWRITEAOF 命令，loader_factor &gt;= 1 执行 rehash 正在执行 BGSAVE 或 BGREWRITEAOF 命令，loader_factor &gt;= 5 执行 rehash 大多操作系统都采用了 写时复制（copy-on-write） 技术来优化子进程的效率： 父子进程共享同一份数据，直到数据被修改时，才实际拷贝内存空间给子进程，保证数据隔离 在执行 BGSAVE 或 BGREWRITEAOF 命令时，redis 会创建子进程，此时服务器会通过增加 loader_factor 的阈值，避免在子进程存在期间执行不必要的内存写操作，节约内存 skiplist 跳表是一种有序数据结构，并且通过维持多层级指针来达到快速访问的目的，是典型的空间换时间策略。 其查找效率与平衡树相近，但是维护成本更低，且实现简单。 typedef struct zskiplistNode { sds ele; // 成员对象 double score; // 分值 struct zskiplistNode *backward; // 后退指针 struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度，当前节点和前进节点之间的距离 } level[]; } zskiplistNode; typedef struct zskiplist { struct zskiplistNode *header, *tail;// 头尾指针 unsigned long length; // 长度 int level; // 最大层级 } zskiplist; 该数据结构有以下特征： 查找：平均查找时间为$O(\\log N)$，最坏查找时间为$O(N)$，并且支持范围查找 概率：每次创建节点的时候，程序根据幂次定律随机生成一个 1 至 32 之间的随机数，用于决定层高 排位：在查找节点的过程中，沿途访问过所有的跨度 span 累计起来，得到目标节点在表中的排位 intset 有序整型集合，具有紧凑的存储空间，添加操作的时间复杂度为$O(N)$。 typedef struct intset { uint32_t encoding; // 编码方式，指示元素的实际类型 uint32_t length; // 元素数量 int8_t contents[]; // 元素数组，元素实际类型可能为 int16_t,int32_t,int64_t, } intset; 该数据结构有以下特征： 有序：元素数组中的元素按照从小到大排列，使用二分查找时间复杂度为$O(\\log N)$ 升级：当有新元素加入集合，且新元素比所有现有元素类型都长时，集合需要进行升级： 根据新元素的类型，扩展元素数组空间 将现有元素都转换为新类型 将新元素添加到数组中 ziplist 压缩列表是为了节约内存而开发的，是存储在连续内存块上的顺序数据结构。 一个压缩列表可以包含任意多的 entry 节点，每个节点包含一个字节数组或整数。 redis 中并没有显式定义 ziplist 的数据结构，仅仅提供了一个描述结构 zlentry 用于操作数据。 typedef struct zlentry { unsigned int prevrawlensize;// 用于记录前一个 entry 长度的字节数 unsigned int prevrawlen; // 前一个 entry 的长度 unsigned int lensize // 用于记录当前 entry 类型/长度的字节数 unsigned int len; // 实际用于存储数据的字节数 unsigned int headersize; // prevrawlensize + lensize unsigned char encoding; // 用于指示 entry 数据的实际编码类型 unsigned char *p; // 指向 entry 的开头 } zlentry; 其实际的内存布局如下： +----------+---------+---------+--------+-----+--------+--------+ | zlbytes | zltail | zllen | entry1 | ... | entryN | zlend | +----------+---------+---------+--------+-----+--------+--------+ &lt;--------------------------- zlbytes ---------------------------&gt; ^--zltail &lt;------- zllen -------&gt; zlbytes : 压缩列表占用的字节数 (u_int32) zltail : 压缩列表表尾偏移量，无需遍历即可确定表尾地址，方便反向遍历 (u_int32) zllen : 压缩列表节点数量，当节点数量大于 65535 时，具体数量需要通过遍历得出 (u_int16) entryX : 列表节点，具体长度不定 zlend : 列表末端，特殊值 0xFF (u_int8) entry 的内存布局如下： +-------------------+----------+---------+ | prev_entry_length | encoding | content | +-------------------+----------+---------+ prev_entry_length : 前一个节点的长度，可以根据当前节点的起始地址，计算前一个节点的起始地址（变长：1字节/5字节） encoding : 节点保存数据的类型和长度（变长：1字节/2字节/5字节） content : 节点保存的数据，可以保存整数或者字节数组 该数据结构具有以下特征： 结构紧凑：一整块连续内存，没有多余的内存碎片，更新会导致内存 realloc 与内存复制，平均时间复杂度为 $O(N)$ 逆向遍历：从表尾开始向表头进行遍历 连锁更新：对前一条数据的更新，可能导致后一条数据的 prev_entry_length 与 encoding 所需长度变化，产生连锁反应，更新操作最坏时间为 $O(N^2)$ quicklist 在较早版本的 redis 中，list 有两种底层实现： 当列表对象中元素的长度比较小或者数量比较少的时候，采用压缩列表 ziplist 来存储 当列表对象中元素的长度比较大或者数量比较多的时候，则会转而使用双向列表 linkedlist 来存储 两者各有优缺点： ziplist 的优点是内存紧凑，访问效率高，缺点是更新效率低，并且数据量较大时，可能导致大量的内存复制 linkedlist 的优点是节点修改的效率高，但是需要额外的内存开销，并且节点较多时，会产生大量的内存碎片 为了结合两者的优点，在 redis 3.2 之后，list 的底层实现变为快速列表 quicklist。 快速列表是 linkedlist 与 ziplist 的结合: quicklist 包含多个内存不连续的节点，但每个节点本身就是一个 ziplist。 typedef struct quicklistNode { struct quicklistNode *prev; // 上一个 ziplist struct quicklistNode *next; // 下一个 ziplist unsigned char *zl; // 数据指针，指向 ziplist 结构，或者 quicklistLZF 结构 unsigned int sz; // ziplist 占用内存长度（未压缩） unsigned int count : 16; // ziplist 记录数量 unsigned int encoding : 2; // 编码方式，1 表示 ziplist ，2 表示 quicklistLZF unsigned int container : 2; // unsigned int recompress : 1; // 临时解压，1 表示该节点临时解压用于访问 unsigned int attempted_compress : 1; // 测试字段 unsigned int extra : 10; // 预留空间 } quicklistNode; typedef struct quicklistLZF { unsigned int sz; // 压缩数据长度 char compressed[]; // 压缩数据 } quicklistLZF; typedef struct quicklist { quicklistNode *head; // 列表头部 quicklistNode *tail; // 列表尾部 unsigned long count; // 记录总数 unsigned long len; // ziplist 数量 int fill : 16; // ziplist 长度限制，每个 ziplist 节点的长度（记录数量/内存占用）不能超过这个值 unsigned int compress : 16; // 压缩深度，表示 quicklist 两端不压缩的 ziplist 节点的个数，为 0 表示所有 ziplist 节点都不压缩 } quicklist; 该数据结构有以下特征： 无缝切换：结合了 linkedlist 与 ziplist 的优点，无需在两种结构之间进行切换 中间压缩：作为队列使用的场景下，list 中间的数据被访问的频率比较低，可以选择进行压缩以减少内存占用 robj 为了实现动态编码技术，redis 构建了一个对象系统。 redis 可以在执行命令前，根据对象类型判断当前命令是否能够执行。 此外，该系统通过引用计数实现内存共享，并记录来对象访问时间，为优化内存回收策略提供了依据。 typedef struct redisObject { unsigned type:4; // 类型，当前对象的逻辑类型，例如：set unsigned encoding:4; // 编码，底层实现的数据结构，例如：intset / ziplist unsigned lru:24; /* LRU 时间 (相对与全局 lru_clock 的时间) 或 * LFU 数据 (8bits 记录访问频率，16 bits 记录访问时间). */ int refcount; // 引用计数 void *ptr; // 数据指针，指向具体的数据结构 } robj; 该数据结构有以下特征： 高效：同个类型的 redis 对象可以使用不同的底层实现，可以在不同的应用场景上优化对象的使用效率 节约内存：对于整数值的内存字符串对象，redis 可以通过记录引用计数来减少内存复制 空转时长：对象系统会记录对象的访问时间，方便 LRU 算法优先回收较少使用的对象 编码格式 string 类型 string 的编码类型可能为： int (OBJ_ENCODING_INT) ：long 类型整数 raw (OBJ_ENCODING_RAW) ：sds 字符串 embstr (OBJ_ENCODING_EMBSTR) ：嵌入式字符串（编码后长度小于 44 字节的字符串） 127.0.0.1:6379&gt; SET str &quot;1234567890 1234567890 1234567890 1234567890&quot; OK 127.0.0.1:6379&gt; STRLEN str (integer) 43 127.0.0.1:6379&gt; OBJECT ENCODING str &quot;embstr&quot; 127.0.0.1:6379&gt; APPEND str _ (integer) 44 127.0.0.1:6379&gt; OBJECT ENCODING str &quot;raw&quot; 使用 embstr 编码是为了减少短字符串的内存分配次数，参考 redis 作者原话： REDIS_ENCODING_EMBSTR_SIZE_LIMIT set to 39.The new value is the limit for the robj + SDS header + string + null-term to stay inside the 64 bytes Jemalloc arena in 64 bits systems. 对比两者内存布局可以发现： embstr 是一个完整连续的内存块，只需要 1 次内存分配 raw 的内存是不连续的，需要申请 2 次内存 &lt;------------------------------------------ Jemalloc arena (64 bytes) ----------------------------------------------&gt; +-------------------------------------------------------------------------------+---------------------+--------------+ | redisObject (16 bytes) | sdshdr8 (3 bytes) | 45 bytes | +--------------------+---------------------------------+-------+----------+-----+-----+-------+-------+---------+----+ | type(REDIS_STRING) | encoding(REDIS_ENCODING_EMBSTR) | lru | refcount | ptr | len | alloc | flags | buf | \\0 | +--------------------+---------------------------------+-------+----------+-----+-----+-------+-------+---------+----+ +--------------------+ | redisObject | +--------------------+ | type | | REDIS_STRING | +--------------------+ | encoding | | REDIS_ENCODING_RAW | +--------------------+ +---------+ | ptr | ---&gt; | sdshdr? | +--------------------+ +---------+ | len | +---------+ | alloc | +---------+ | flags | +---------++---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ | buf || T | h | e | r | e | | i | s | | n | o | | c | e | r | t | a |...| +---------++---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ list 类型 list 默认的编码类型为 quicklist (OBJ_ENCODING_QUICKLIST) list-max-ziplist-size：每个 quicklist 节点上的 ziplist 长度 list-compress-depth：quicklist 两端不压缩的节点数目 hash 类型 hash 的编码类型有 ziplist (OBJ_ENCODING_ZIPLIST) 与 hashtable (OBJ_ENCODING_HT)，具体使用哪种编码受下面两个选项控制： hash-max-ziplist-value：当 key 与 value 的长度都小于该值时使用 ziplist 编码（默认为 64） hash-max-ziplist-entries：当 hash 中的元素数量小于该值时使用 ziplist 编码（默认为 512） key 长度超过 64 的情况： 127.0.0.1:6379&gt; HSET table x &#39;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&#39; (integer) 0 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;ziplist&quot; 127.0.0.1:6379&gt; HSET table x &#39;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&#39; (integer) 0 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;hashtable&quot; 127.0.0.1:6379&gt; DEL table (integer) 1 127.0.0.1:6379&gt; HSET table xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx &#39;x&#39; (integer) 1 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;ziplist&quot; 127.0.0.1:6379&gt; HSET table xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx &#39;x&#39; (integer) 1 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;hashtable&quot; value 长度超过 64 的情况： 127.0.0.1:6379&gt; HSET table x &#39;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&#39; (integer) 0 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;ziplist&quot; 127.0.0.1:6379&gt; HSET table x &#39;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&#39; (integer) 0 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;hashtable&quot; 127.0.0.1:6379&gt; DEL table (integer) 1 127.0.0.1:6379&gt; HSET table xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx &#39;x&#39; (integer) 1 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;ziplist&quot; 127.0.0.1:6379&gt; HSET table xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx &#39;x&#39; (integer) 1 127.0.0.1:6379&gt; OBJECT ENCODING table &quot;hashtable&quot; 元素数量度超过 512 的情况： 127.0.0.1:6379&gt; EVAL &quot;for i=1,512 do redis.call(&#39;HSET&#39;, KEYS[1], i, i) end&quot; 1 numbers (nil) 127.0.0.1:6379&gt; HLEN numbers (integer) 512 127.0.0.1:6379&gt; OBJECT ENCODING numbers &quot;ziplist&quot; 127.0.0.1:6379&gt; DEL numbers (integer) 1 127.0.0.1:6379&gt; EVAL &quot;for i=1,513 do redis.call(&#39;HSET&#39;, KEYS[1], i, i) end&quot; 1 numbers (nil) 127.0.0.1:6379&gt; HLEN numbers (integer) 513 127.0.0.1:6379&gt; OBJECT ENCODING numbers &quot;hashtable&quot; set 类型 set 的编码类型有 intset (OBJ_ENCODING_INTSET) 与 hashtable (OBJ_ENCODING_HT)，具体使用哪种编码受下面两个选项控制： 当 set 中的所有元素都是整数时考虑使用 intset 编码，否则只能使用 hashtable 编码 set-max-intset-entries：当 set 中的元素数量小于该值时使用 intset 编码（默认为 512） 包含非整数元素的情况： 127.0.0.1:6379&gt; SADD set 1 2 (integer) 2 127.0.0.1:6379&gt; OBJECT ENCODING set &quot;intset&quot; 127.0.0.1:6379&gt; SADD set &quot;ABC&quot; (integer) 1 127.0.0.1:6379&gt; OBJECT ENCODING set &quot;hashtable&quot; 元素数量度超过 512 的情况： 127.0.0.1:6379&gt; EVAL &quot;for i=1,512 do redis.call(&#39;SADD&#39;, KEYS[1], i, i) end&quot; 1 numbers (nil) 127.0.0.1:6379&gt; SCARD numbers (integer) 512 127.0.0.1:6379&gt; OBJECT ENCODING numbers &quot;intset&quot; 127.0.0.1:6379&gt; DEL numbers (integer) 1 127.0.0.1:6379&gt; EVAL &quot;for i=1,513 do redis.call(&#39;SADD&#39;, KEYS[1], i, i) end&quot; 1 numbers (nil) 127.0.0.1:6379&gt; SCARD numbers (integer) 513 127.0.0.1:6379&gt; OBJECT ENCODING numbers &quot;hashtable&quot; zset 类型 set 的编码类型有 ziplist (OBJ_ENCODING_ZIPLIST) 与 skiplist (OBJ_ENCODING_SKIPLIST)。 使用 ziplist 编码时，每个集合元素使用两个相邻的 entry 节点保存，第一个节点保存成员值 member，第二节点保存元素的分值 score，并且 entry 按照 score 从小到大进行排序： +----------------------+ | redisObject | +----------------------+ | type | | REDIS_ZSET | +----------------------+ | encoding | | OBJ_ENCODING_ZIPLIST | +----------------------+ +----------+----------+---------+--------------------+-------------------+-----+-----------------------+--------------------+-------+ | ptr | ---&gt; | zlbytes | zltail | zllen | entry 1 (member 1) | entry 2 (score 1) | ... | entry 2N-1 (member N) | entry 2N (score N) | zlend | +----------------------+ +----------+----------+---------+--------------------+-------------------+-----+-----------------------+--------------------+-------+ &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; score increase &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; 使用 skiplist 实现时，使用会使用一个名为 zset 的数据结构： typedef struct zset { dict *dict; // 维护 member -&gt; score 的映射，查找给的成员的分值 zskiplist *zsl; // 按 score 大小保存了所有集合元素，支持范围操作 } zset; // dict 与 zsl 会共享成员与分值 +----------------------+ +--------+ +------------+ +---------+ | redisObject | +--&gt;| dictht | | StringObj | -&gt; | long | +----------------------+ +-------+ | +--------+ +------------+ +---------+ | type | +--&gt;| dict | | | table | --&gt; | StringObj | -&gt; | long | | REDIS_ZSET | | +-------+ | +--------+ +------------+ +---------+ +----------------------+ | | ht[0] | --+ | StringObj | -&gt; | long | | encoding | +--------+ | +-------+ +-----+ +------------+ +---------+ | OBJ_ENCODING_ZIPLIST | | zset | | | L32 | -&gt; NULL +----------------------+ +--------+ | +-----+ | ptr | ---&gt; | dict | --+ | ... | +----------------------+ +--------+ +--------+ +-----+ +-----------+ +-----------+ | zsl | ---&gt; | header | --&gt; | L4 | -&gt; | L4 | ------------------&gt; | L4 | -&gt; NULL +--------+ +--------+ +-----+ +-----------+ +-----------+ | tail | | L3 | -&gt; | L3 | ------------------&gt; | L3 | -&gt; NULL +--------+ +-----+ +-----------+ +-----------+ +-----------+ | level | | L2 | -&gt; | L2 | -&gt; | L2 | -&gt; | L2 | -&gt; NULL +--------+ +-----+ +-----------+ +-----------+ +-----------+ | length | | L1 | -&gt; | L1 | -&gt; | L1 | -&gt; | L1 | -&gt; NULL +--------+ +-----+ +-----------+ +-----------+ +-----------+ NULL &lt;- | BW | &lt;- | BW | &lt;- | BW | +-----------+ +-----------+ +-----------+ | StringObj | | StringObj | | StringObj | +-----------+ +-----------+ +-----------+ | long | | long | | long | +-----------+ +-----------+ +-----------+ zset 具体使用哪种编码受下面两个选项控制： zset-max-ziplist-value：当 member 的长度都小于该值时使用 ziplist 编码（默认为 64） zset-max-ziplist-entries：当 zset 中的元素数量小于该值时使用 ziplist 编码（默认为 128) Redis 整体结构 每个数据库都是一个 redisDb 结构体： typedef struct redisDb { dict *dict; /* 据库的键空间 keyspace */ dict *expires; /* 设置了过期时间的 key 集合 */ dict *blocking_keys; /* 客户端阻塞等待的 key 集合 (BLPOP)*/ dict *ready_keys; /* 已就绪的阻塞 key 集合 (PUSH) */ dict *watched_keys; /* 在事务中监控受监控的 key 集合 */ int id; /* 数据库 ID */ long long avg_ttl; /* 平均 TTL, just for stats */ unsigned long expires_cursor; /* 过期检测指针 */ list *defrag_later; /* 内存碎片回收列表 */ } redisDb; redis 所有数据库都保存着 redisServer.db 数组中，redisServer.dbnum 保存了数据库的数量，简化后的内存布局大致如下： +-------------+ | redisServer | +-------------+ +------------+------+-------------+ | db | -&gt; | redisDb[0] | .... | redisDb[15] | +-------------+ +------------+------+-------------+ | dbnum | | | 16 | | +-------------+ | +---------+ +------------+ +-&gt;| redisDb | +-&gt; | ListObject | +---------+ +------------+ | +------------+ | dict | -&gt; | StringObj | --+ +---------+ +------------+ +------------+ | expires | | StringObj | ----&gt; | HashObject | +---------+ +------------+ +------------+ | | StringObj | --+ | +------------+ | +------------+ | +-&gt; | StringObj | | +------------+ | | +------------+ +-------------+ +----&gt; | StringObj | -&gt; | long | +------------+ +-------------+ | StringObj | -&gt; | long | +------------+ +-------------+ "},{"slug":"consistency-with-raft","title":"Raft 协议","tags":["系统设计","分布式系统"],"content":"Raft协议是一种分布式一致性协议，用于维护复制日志的一致性。它通过领导者选举、日志复制和安全性机制，确保系统在节点故障或网络分区情况下保持一致性。 Paxos 存在的问题 Paxos 算法的描述偏学术化，缺失了很多细节，无法直接应用于工程领域。实际工程应用中的分布式算法大多是 Paxos 的变种，验证这些算法的正确性也成为了一个难题。 举个例子：上一篇文章的 最后 介绍了一个应用 Paxos 算法的工程模型，这个模型存在明显的写性能瓶颈： 使用多主架构，写入冲突的概率高 每次更新操作都需要至少 2 轮以上的网络通信，通信开销大 如果要提高该模型的性能，仍需要在很多细节上做进一步调整，最终实现出来的算法已经和原始的版本的 Paxos 相去甚远。 为了解决以上问题，另一个高性能且易于理解的一致性算法横空出世：Raft 在学习算法的过程中，使用 Java 实现了一个功能完善的 Raft 协议：rafting 代码忠实于论文原文，包含了其中的众多算法细节，希望对各位学习 Raft 的朋友有所帮助 基本概念 Raft 算法基于 复制状态机（Replicated State Machine） 模型，本质上就是一个管理 日志复制 的算法。 Raft 集群采用 Single Leader 架构，集群中有唯一的 Leader 进程负责管理日志复制，其职责有： 接受 Client 发送的请求 将日志记录同步到其他进程 告知其他进程的何时能够提交日志 复制状态机 复制状态机的本质就是：Paxos + WAL 每个进程维护一个 状态机（State Machine），并且使用一个日志存储其所要执行指令如果两个状态机执行按照相同的顺序，执行相同的指令，则这两个进程最终能够收敛到同个状态如果能保证所有进程的日志一致，则每个进程的状态必定也是一致的 任期 为了减少不必要的网络通信，日志追加顺序由集群唯一的 Leader 决定，无须与其他节点协商。通信开销从最低 2 次降为固定的 1 次，从而大幅提高了算法的性能。 出于可用性考虑，当前 Leader 下线后，集群需要从存活的节点中挑选一个新的 Leader，这个过程被称为 选举（election） 。 每次选举都会产生一个新的 任期号（term）（单调递增），如果选举中产生了一个新的 Leader，那么这个任期号会伴随这个 Leader 直到其下线。 每个 参与者 进程都会维护一个 current_term 用于表示已知的最新任期，进程之间通过彼此交换该值来感知 Leader 变化。 /** * 基础信息 */ public abstract class RaftMember implements RaftParticipant { // 响应 RPC 前需要持久化以下两个属性 protected final long currentTerm; // 已知的最新的 term（初始为 0） protected final ID lastCandidate; // 最近一次赞成投票的 candidate protected RaftMember(long term, ID candidate) { this.currentTerm = term; this.lastCandidate = candidate; stableStorage().persist(currentTerm, lastCandidate); } /** * @see RaftParticipant#currentTerm() */ @Override public long currentTerm() { return currentTerm; } /** * @see RaftParticipant#votedFor() */ @Override public ID votedFor() { return lastCandidate; } } 日志 日志是 Raft 的核心概念。Raft 保证日志是连续且一致的，并且最终能够被所有进程按照日志索引的顺序提交。 每条日志记录包含： 任期term：生成该条记录的 Leader 对应的任期 索引index：其在日志中的顺序 命令command：可执行的状态机指令 一旦某条日志中的命令被状态机执行了，那么我们称这条记录为已提交（committed），Raft 保证已提交的记录不会丢失。 角色 Raft集群中每个进程只能担任其中一个角色： Leader：发送心跳、管理日志复制与提交 Follower：被动响应其他节点发送过来的请求 Candidate：主动发起并参与选举 Raft 进程间使用 RPC 的方式进行通信，实现最基础的共识算法只需 两种RPC： RequestVote：用于选举产生 Leader AppendEntries：复制日志与发送心跳 /** * RPC 接口 * */ public interface RaftService { /** * 复制日志+发送心跳（由 leader 调用） * @param term leader 任期 * @param leaderId leader 在集群中的唯一标识 * @param prevLogIndex 紧接在新的之前的日志条目索引 * @param prevLogTerm prevLogIndex 对应的任期 * @param entries 日志条目（发送心跳时为空） * @param leaderCommit leader 已经提交的日志条目索引 * @return 当 follower 中的日志包含 prevLogIndex 与 prevLogTerm 匹配的日志条目返回 true * */ Async&lt;RaftResponse&gt; appendEntries( long term, ID leaderId, long prevLogIndex, long prevLogTerm, Entry[] entries, long leaderCommit) throws Exception; /** * 选主（由 candidate 调用） * @param term candidate 任期 * @param candidateId candidate 在集群中的唯一标识 * @param lastLogIndex candidate 最后一条日志条目的索引 * @param lastLogTerm candidate 最后一条日志条目的任期 * @return 当收到赞成票时返回 true * */ Async&lt;RaftResponse&gt; requestVote( long term, ID candidateId, long lastLogIndex, long lastLogTerm) throws Exception; } 算法流程 基于 Single Leader 模型，Raft 将一致性问题分解为 3 个独立的子问题： Leader选举（Election）：Leader 进程失效后能够自动选举出一个新的 Leader 日志复制（Replication）：Leader 保证其他节点的日志与其保持一致 状态安全（Safety） ：Leader 保证状态机执行指令的顺序与内容完全一致 为了方便理解，下面结合 动画 进行介绍。 选举 使用 心跳超时（heartbeat timeout） 机制来触发 Leader 选举： 节点启动时默认处于 Follower 状态，如果 Follower 超时未收到 Leader 心跳信息，会转换为 Candidate 并向其他节点发起 RequestVote 请求。 当 Candidate 收到半数以上的选票之后成为 Leader，开始定时向其他节点发起 AppendEntries 请求以维持其 Leader 的地位。 Leader 失效之后停止发送心跳，Follower 的心跳超时机制又会被触发，开始新一轮的选举。 复制 集群中只有 Leader 对外提供服务： 客户端与 Leader 进行通信时，每个请求包含一条可以被状态机执行的命令。 当 Leader 在接收到命令之后，首先会将命令转换为一条对应的 日志记录（log entry），并追加到本地的日志中。然后调用 AppendEntries 将这条日志复制到其他节点的日志中。 当日志被复制到过半数节点上时，Leader 会将这条日志中包含的命令 提交（commit） 状态机执行，最后将执行结果告知客户端。 网络分区 使用 过半数（majority） 机制来处理脑裂： 发生网络分区后，集群中可能同时出现多个 Leader，复制机制保证了最多只有一个 Leader 能够正常对外提供服务。如果日志无法复制到多数节点，Leader 会拒绝提交这些日志，当网络分区消失后，集群会自动恢复到一致的状态。 安全性保证 选举时… 保证新的 Leader 拥有所有已经提交的日志 每个 Follower 节点在投票时会检查 Candidate 的日志索引，并拒绝为日志不完整的 Candidate 投赞成票 半数以上的 Follower 节点都投了赞成票，意味着 Candidate 中包含了所有可能已经被提交的日志 提交日志时… Leader 只主动提交自己任期内产生的日志 如果记录是当前 Leader 所创建的，那么当这条记录被复制到大多数节点上时，Leader 就可以提交这条记录以及之前的记录 如果记录是之前 Leader 所创建的，则只有当前 Leader 创建的记录被提交后，才能提交这些由之前 Leader 创建的日志 总结 一致性算法的本质：一致性与可用性之间的权衡。 Raft 的优点：Single Leader 的架构简化日志管理 所有日志都由 Leader 流向其他节点，无需与其他节点进行协商。其他节点只需要记录并应用 Leader 发送过来日志内容即可，将原来的两阶段请求优化为一次 RPC 调用 Raft 的缺点：对日志的连续性有较高要求 为了简化日志管理，Raft 的日志不允许存在空隙，限制了并发性。某些应用场景下，需要通过 Multi-Raft 的模式对无关的业务进行解耦，从而提高系统的并发度 "},{"slug":"consistency-with-paxos","title":"Paxos 协议","tags":["系统设计","分布式系统"],"content":"Paxos 是一种分布式一致性算法，用于实现分布式系统中的共识。它通过提供基于消息传递的方式，在面对网络故障和节点故障时保证系统的可靠性和一致性。 可用性与一致性 为了向用户提供更好的服务体验，现代软件架构越来越注重系统的 可用性（availability）。 正是在这种趋势的驱动下，微服务与容器化技术才能在今天大行其道。 而高可用架构的前提是冗余： 一个高可用服务必然由多个进程组成，这些进程互为备份，部分进程失效不会导致整个服务不可用。 如果服务是有状态的，那么每个进程都需要维护自己的一个 状态副本（replica）。为了保证有状态进程的可替代性，如何维护这些副本的 一致性（consistency） 成为了至关重要的问题。 以提供锁服务的分布式协调服务为例，这类服务必须满足一下两个特性： 高可用：服务失效会导致下游服务不可用 强一致：下游服务观察到不一致的状态会导致锁失效 根据分布式数据库的 CAP 理论，同时实现这个以下 3 特性是困难的： 在网络通信正常的时候，分布式数据库可以同时保证 C 与 A 在发生 网络分区（进程间无法正常通信）的时候，数据库必须在 C 和 A 中做出权衡： AP 系统 —— 保证可用性，牺牲一致性每个节点可以对外服务，但整个数据库会处于不一致的状态 CP 系统 —— 保证一致性，牺牲可用性每个节点拒绝对外服务，但整个数据库会始终保持一致的状态 在 CAP 的框架下，我们似乎陷入了一个两难的境地：实现一个 CA 的系统是不可能的吗？ 在回答这个问题前，首先要指出 CAP 理论存在的一个问题： CAP 理论在权衡时仅考虑了 3 个因素，而忽略了其他的指标，比如：性能、实现复杂度、机器数量... 举个例子：存在一种流行的观点 —— ZooKeeper 是一个 CP 系统。然而，在 ZooKeeper 集群中，只要有半数以上的节点之间能够正常通信，集群仍然能够正常对外提供服务。这意味着：ZooKeepr 在网络发生分区的情况下，依然是可用且一致。 在发生网络分区时，如果要保证系统的可用且一致，需要付出一些额外的代价。以 ZK 为例子，其付出的的代价就是更多的机器资源（必须部署 3 个以上的节点）与更高的系统复杂度（使用一致性算法）。 共识问题 所谓共识（Consensus），就是在某件事情上达成一致意见。分布式系统的共识问题可以表述为： 系统中有一组可以提出 提案（Proposal） 的进程集合，其中一个或多个进程提出一个提案，然后通过 共识算法（Consensus Algorithm） 从中 选定（Chosen） 一个提案作为最终结果。 block-beta columns 1 block:MS columns 3 space C(((&quot;Client&quot;))) space space:3 L1[(&quot;Leader&quot;)] block:SYNC columns 1 S1&lt;[&quot;&amp;nbsp;x = 1&quot;]&gt;(right) S2&lt;[&quot;x = 2&amp;nbsp;&quot;]&gt;(left) end L2[(&quot;Leader&quot;)] end C -- &quot;write&lt;br/&gt;[x=1]&quot; --&gt; L1 C -- &quot;write&lt;br/&gt;[x=2]&quot; --&gt; L2 style SYNC fill:none,stroke:none block-beta block:U columns 3 block:Proposal columns 1 block:P1[&quot;Proposal 1&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] space PS1[&quot;x=1;x=2;&quot;] end block:P2[&quot;Proposal 2&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] space PS2[&quot;x=2;x=1;&quot;] end end C&lt;[&quot;&amp;nbspChosen&quot;]&gt;(right) block:Consensus columns 1 block:V[&quot;Consensus&quot;] Consensus end block:W Result[&quot;x = ?&quot;] end end end style P1 fill:none,stroke:none style P2 fill:none,stroke:none style U fill:none,stroke:none style V fill:none,stroke:none style W fill:none,stroke:none 以上图中的场景为例：图中是一个多主的分布式数据库，同时允许多个节点接收写操作请求，并且两者会彼此交换修改指令以保证数据的一致性。 刚好在某个时刻，两个客户端同时发起对同个记录的变更操作，因此可能同时存在两种不同的更新顺序（提案）。为了保证数据副本的一致性，两个数据库必须在更新顺序上达成一致（共识）。 容错 由于运行环境的不确定性，系统故障总是是不可避免的，这些故障大致可以分为两类： 非拜占庭故障（Byzantine fault） 进程间通过不可靠的网络来传输消息 进程在运行过程中可能出现卡顿、宕机、重启，但是不会发送错误的消息 消息在传输过程中可能丢失、重复、失序，但是不会损坏或被篡改 拜占庭故障（Non-Byzantine fault） 系统中可能出现恶意的进程，故意向其他进程发出错误的讯息 引发其他进程出现异常的行为，导致整个系统失效 因此共识算法又可以分为 拜占庭容错 与 非拜占庭容错 两类： 前者主要是应用于区块链领域，通过高昂的计算开销来杜绝进程作恶的可能性，功能强大但不适合用于提供高性能的一致性保障 后者主要是为数据管理服务，通过额外的安全性假设，减少容错开销，从而能够提供高性能的一致性保障 接下来要解析的分布式一致性协议都属于非拜占庭容错的共识算法。这类算法能够在不确定的环境中，为系统提供以下保证： 合法：提案必须由某个进程提出，不能凭空出现 完整：每个进程只能表决一次，不能改变结果 一致：所有进程的最终决策必须一致 容错共识算法的核心可以概括为以下两点： 安全性（Safety）：所有进程收敛到一致的合法状态 容错性（Fault-tolerance）：少量进程崩溃，系统可持续运行 Paxos 算法 最经典的一致性协议莫过于 Paxos 算法，下面对其进行简单的分析。 基本概念 提案：每个提案 [n,v] 由两部分组成 n : 提案被提出的顺序 v : 提案的值 角色：每个进程都能够同时担任以下角色中的一个或多个 提议者（Proposer）：主动发起提案 决策者（Acceptor）：对提案进行表决 提议者（Learner）：被动接受表决结果 提案状态：每个提案可能处于以下 3 个状态之一 提出（prepare）：被某个 Proposer 提出 通过（accept）：被某个 Acceptor 通过 选定（chosen）：被半数以上 Acceptor 通过 通信模型：进程之间的通信是通过消息传递进行的，并且消息是单向传递的，消息接收进程可以选择拒绝响应。网络是不可靠的，进程需要自行处理消息丢失，乱序的情况。 block-beta columns 5 space:2 A1((&quot;Acceptor&quot;)) space:2 P1((&quot;Proposer&quot;)) space:4 space:2 A2((&quot;Acceptor&quot;)) space L((&quot;Lerner&quot;)) P2((&quot;Proposer&quot;)) space:4 space:2 A3((&quot;Acceptor&quot;)) space:2 P1 ----&gt; A1 P2 ---&gt; A1 P1 ----&gt; A2 P2 ---&gt; A2 P1 ---&gt; A3 P2 ---&gt; A3 A1 ---&gt; L A2 ---&gt; L A3 ---&gt; L 表决 Paxos 的基本思想很简单：通过对多个提案进行表决，从中选定一个提案作为最终的共识，并且一旦这个提案被选定之后，结果不会再发生改变。参与表决的角色有 Proposer（主动发起请求）与 Acceptor（被动响应请求），而 Learner 只关注表决结果，本身并不参与表决过程。 Paxos 的每轮表决分为两阶段：生成提案（Prepare） -&gt; 表决提案（Accept）为了支持表决过程，Acceptor 进程需要在本地维护以下两个状态： $n^{\\text{max_prepare}}$ ：已响应 Prepare 请求的最大的提案编号 $[n^{\\text{max_accept}}, v&#39;]$ ：已通过 Accept 请求的最大编号的提案 为了更好地理解表决过程，下面结合一个具体的场景进行描述： 集群启动时需要选举出一个 Leader 进程，其中两个名为 Alice 与 Bob 的进程参选两者分别以 Proposer A / B 的身份提出了选举自己为 Leader 的提案并向集群中的 3 个 Acceptor X / Y / Z 发起表决流程 Prepare 阶段 Proposer 在提出提案 [n,v] 前，要保证编号 n 是未被其他提案占用的因此 Proposer 向多个 Acceptor 发送编号为 n 的 prepare 请求，锁定这个编号 首先两个 Proposer 先通过某些方式（比如：维护一个全局计数器、询问所有Acceptor……）获取到可用的提案编号，A、B 分别得知的可用的编号为 2、3。然后使用这个编号向 Acceptor 集群发出 prepare 请求。 Acceptor 在接收到编号 n 的 prepare 请求后 如果 $n &gt; n^{\\text{max_prepare}}$，返回已知编号的最大的提案$[n^{\\text{max_accept}}, v&#39;]$ 否则，拒绝响应这个请求 X、Y 先后接收到 A、B 的请求，并且 B 的编号要比 A 大，因此 A、B 都接收到了响应。 而 Z 则是先接收到了 B 的请求，后接收到 A 的请求，因此只响应了 B 的请求，忽略了 A 的请求。 只有当 Proposer 在 prepare 阶段接收到半数以上的 Acceptor 响应时，才能发起 accept 请求 在 prepare 阶段中，A、B 都接收到了半数以上的响应，因此两者都能够进入 accept 阶段。 Accept 阶段 在锁定编号 n 后，Proposer 需要在发起 accept 请求前生成一个新提案 $[n, v^{\\text{new}}]$。其中，提案值 $v^{\\text{new}}$ 的生成需要遵循以下规则： Acceptor 返回的所有已知提案形成一个集合 $S^{\\text{accept}}$ 如果 $S^{\\text{accept}}=\\emptyset$（不存在任何已知提案），$v^{\\text{new}}$ 可以指定为任意值 如果 $S^{\\text{accept}}\\ne\\emptyset$ 则找到编号最大的已知提案 $[n^{\\text{max}}, v&#39;] \\in S^{\\text{accept}}$ 并令 $v^{\\text{new}}= v&#39;$ 在 prepare 阶段的响应中，A、B 没有接收到任何已知的提案，因此都将自己的进程 ID 作为 $v^{\\text{new}}$ 并生成新的提案，并发起 accept 请求。 当 Acceptor 接受到提案为 $[n^{\\text{new}},v^{\\text{new}}]$ 的 accept 请求之后 如果 $n^{\\text{new}} \\ge n^{\\text{max_prepare}}]$，通过提案并更新 $[n^{\\text{max_accept}},v&#39;]=[n^{\\text{new}},v^{\\text{new}}]$ 否则，拒绝通过该提议 由于 X、Y 已经响应了 B 的 prepare 请求，于是认为 A 的提案已经过期，拒绝通过其 accept 请求。 而 X、Y、Z 已知编号的最大的 prepare 请求均由 B 所发出，因此 B 的 accept 请求顺利通过。 最终，进程 Alice 落选，进程 Bob 当选为新的 Leader。 Learner 如何得知表决结果？ 每次通过一个提案后，Acceptor 主动通知 Learner 优点：实时性高，在 Learner 数量少时效率高 缺点：网络复杂度高，并且 Acceptor 的通知消息也可能丢失，需要重试机制 Learner 主动向 Acceptor 轮询通过的提案信息 优点：可靠性高，能够处理 Acceptor 崩溃或者消息丢失的情况 缺点：实时性较差，需要控制好轮询频率在 安全性 一旦半数以上的 Acceptor 通过了某个提案后，意味着该提案被选定了并且这个被选定的提案值 $v^{\\text{chosen}}$ 今后不会再发生改变 由于此前 A 的 accept 请求并未通过，因此发起了新的一轮表决。 尽管此次 A 的 accept 请求被通过了，但是被选定的提案值是此前已经被选定的 Bob。 容错性 只要半数以上的 Acceptor 存活，系统的表决机制依然能够正常运作，此时系统仍然是可用的 假设在此前的 accpet 阶段，其中一个 Acceptor 崩溃了，系统中剩下两个 Acceptor 仍然能够继续进行后续的表决流程，系统依然是可用的。 边界情况 在 accept 阶段，X、Y 通过并选定了提案值 Bob，由于消息丢失，Z 并未通过该提案。后来 X、Y 进程崩溃了，系统中只剩下 Z 正常运作，此时 A 发起新的一轮表决。由于过半数的限制，A 无法生成值为 Alice 的提案，系统的状态不会被改变。 在 accept 阶段，X 通过了提案值 Bob，由于消息丢失，Y、Z 并未通过该提案。后来 X 进程崩溃了，系统中剩下 Y、Z 正常运作，此时 A 发起新的一轮表决。由于过半数的限制，此前的提案值 Bob 未被选定，系统选定了 A 提出的值 Alice。 安全性证明 Paxos 算法的安全性保证： 合法：参与决策的提案必定来源于某个 Proposer 完整：编号为 n 的提案只能被某个 Proposer 提出一次，也只能被同一个 Acceptor 表决一次 一致：一旦提议 $[n,v^{\\text{chosen}}]$ 被选定，之后所有的提案都会包含 $v^{\\text{chosen}}$ Paxos 的一致性是通过约束提案的生成来实现的，下面进行简单的证明： 假设： 第一个被选定的提案是 $[m^0,v^0]$，那么必然存在一个多数 Acceptor 集合 $S^0$ 通过了编号 $m^0$ 的 accept 请求。 下一个被提出的提案是 $[m^1,v^1]$，那么必然存在一个多数 Acceptor 集合 $S^1$ 响应了编号 $m^1$ 的 prepare 请求。 证明：$v^1=v^0$ 根据过半数原则，$S_{0}$ 与 $S_{1}$ 的交集必然非空，$S_{1}$ 中必然至少存在一个通过了提案 $[n_{0},v_{0}]$ 的 Acceptor， 因此 $S_{0}$ 与 $S_{1}$ 的交集中的所有 Acceptor 最大的 $n^{\\text{max_accept}}$ 只能是 $m_{0}$。 根据 prepare 成功的条件，$S_{1}$ 中所有 Acceptor 通过的提案中，最大的 $n^{\\text{max_accept}}$ 必然小于 $m_{1}$。 假设 $S_{1}$ 中所有 Acceptor 通过的所有提案中编号最大为 $x$，显然有 $m_{0} \\le x &lt; m_{1}$，由于序号 $x$ 必须是由 $m_{1}$ 前某个提案提出的，此时 $x$ 必定是 $m_{0}$。 因此 prepare 的响应中，必定至少有一个通过了 $m_{0}$ 提案的 Acceptor 返回提案 $[n_{0},v_{0}]$， 因此 $v0 = v1$ 成立。 根据数学归纳法，$v_{n+1} = v_{n}$ 亦可得证。 应用构想 在实际工程应用中，并不会直接使用原始的 Paxos 算法，但是这并不妨碍我们用这个算法实现一个强一致的分布式数据库。 block-beta columns 4 block:WR[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;Write Op&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&quot;]:2 columns 4 C1(((&quot;Client&quot;))) C2(((&quot;Client&quot;))) C3(((&quot;Client&quot;))) space:6 LOG1&gt;&quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;Log&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] space DB1[(&quot;Database&quot;)] space:6 DISK[[&quot;&lt;br/&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;Disk&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;&amp;nbsp;&quot;]] end block:RE[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;Read Op&quot;]:2 columns 3 space C4(((&quot;Client&quot;))) space DB2[(&quot;Database&quot;)] space LOG2&gt;&quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;Log&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] end C1 -- &quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;1. append-log&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot; --&gt; LOG1 C2 --&gt; LOG1 C3 --&gt; LOG1 LOG1 -- &quot;3. apply-change&lt;br/&gt;&amp;nbsp;&lt;br/&gt;&amp;nbsp;&lt;br/&gt;&amp;nbsp&quot; --&gt; DB1 LOG1 -- &quot;2. fsync-log&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot; --&gt; DISK DB1 -- &quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;4. fsync-db&quot; --&gt; DISK DB2 -- &quot;1. read-db &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;&amp;nbsp;&lt;br/&gt;&amp;nbsp;&lt;br/&gt;&quot; --&gt; C4 LOG2 -- &quot; &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;2. merge-change &lt;br/&gt;&amp;nbsp;&lt;br/&gt;&amp;nbsp;&lt;br/&gt;&quot; --&gt; C4 在数据库领域，预写日志（WAL） 是一种常见的提高数据库性能的手段： 任何修改数据库状态的命令在执行之前，都会先写入一个顺序日志并落盘，然后通过异步线程按照命令的写入顺序将其应用到数据库中 读取数据时，会将数据库中的状态与 WAL 中的状态进行合并，保证返回给客户端的数据是最新的 这一机制能在提供较高写入性能的同时，保证了数据的安全性：即便机器断电，数据也不会丢失。 WAL 中的条目是有序的，每个条目包含一个或一组原子性的变更命令。根据上一篇文章的结论：只要所有数据库按照相同的顺序执行相同的命令，则这两个数据库的状态就能保持一致。换而言之，通过使用 Paxos 算法，保证多个数据库本地的 WAL 副本中的内容完全一致，那么数据库最终肯定能收敛到一致的状态。 假设存在一个类似 Redis 的 KV 数据库，用户可以向数据库发送指令存取键值对。该数据库支持多主架构，并通过使用 Paxos + WAL 保证数据的一致性： graph LR C1((&quot;Client&quot;)) subgraph X1[&quot;Leader 1&quot;] AX1[&quot;Accpetor&quot;] PX[&quot;Proposer&quot;] end subgraph Y1[&quot;Leader 2&quot;] AY1[&quot;Accpetor&quot;] end subgraph Z1[&quot;Leader 3&quot;] AZ1[&quot;Accpetor&quot;] end C1 ---&gt;|&lt;br/&gt;&lt;br/&gt;request| PX X1 ~~~ Y1 X1 ~~~ Z1 PX &lt;---&gt;|&lt;br/&gt;&lt;br/&gt;prepare / accept| AX1 PX &lt;---&gt;|&lt;br/&gt;&lt;br/&gt;prepare / accept| AY1 PX &lt;---&gt;|&lt;br/&gt;&lt;br/&gt;prepare / accept| AZ1 block-beta block:x then&lt;[&quot;2PC Paxos&quot;]&gt;(down) end style x display:none %%{init: {&quot;flowchart&quot;: {&quot;rankSpacing&quot;: 5, &quot;nodeSpacing&quot;: 5 }} }%% flowchart TB subgraph Fin[&quot; &quot;] direction LR C1((&quot;Client&quot;)) LOG&gt;&quot;&amp;nbsp;&amp;nbsp;WAL&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;] subgraph X1[&quot;Leader 1&quot;] AX1[&quot;Accpetor&quot;] LX1[&quot;Learner&quot;] end end subgraph Rest direction TB subgraph Y1[&quot;Leader 2&quot;] direction LR AY1[&quot;Accpetor&quot;] LY1[&quot;Learner&quot;] end subgraph Z1[&quot;Leader 3&quot;] direction LR AZ1[&quot;Accpetor&quot;] LZ1[&quot;Learner&quot;] end end LX1 ---&gt;|&lt;br/&gt;&lt;br/&gt;response| C1 LX1 ---&gt;|&lt;br/&gt;&lt;br/&gt;chosen| LOG AX1 ---&gt;|&lt;br/&gt;&lt;br/&gt;accepted| LX1 AY1 ---&gt;|&lt;br/&gt;&lt;br/&gt;accepted| LY1 AZ1 ---&gt;|&lt;br/&gt;&lt;br/&gt;accepted| LZ1 Fin ~~~ Rest classDef hidden fill:none class Fin,Rest hidden 当某个数据库 X 接收到请求时： 作为 Proposer 提出提案（向 WAL 中的特定位置中写入一条记录)，并且交由 X、Y、Z 组成的 Acceptor 集群进行表决 每个 Acceptor 通过提案后，会将结果告知由 X、Y、Z 组成的 Learner 集群 当 Learner 感知到某个提案被选定了（该 WAL 位置尚未被占用，允许写入），就会往本地的 WAL 追加命令（图中省略了 Y、Z 节点的 WAL）并返回响应 如果提案没有被选定（该 WAL 位置已经被占用，拒绝写入），则发起下一轮表决，直到提案被通过或客户端请求超时 从 WAL 的角度来观察，上述过程如下图所示： %%{init: {&quot;flowchart&quot;: {&quot;diagramPadding&quot;: 50 }} }%% block-beta columns 1 CLI(((&quot;Client&quot;))) block:cluster[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;Multi-Leader-Cluster&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&quot;]:1 DB1[(&quot;Leader 1&quot;)] DB2[(&quot;Leader 2&quot;)] DB3[(&quot;Leader 3&quot;)] end block:paxos[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;Paxos 2PC&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&quot;]:1 columns 5 block:pa[&quot;proposal A&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] columns 1 space PC1[&quot;prepare/accept&quot;] C1&lt;[&quot;chosen&quot;]&gt;(down) end space block:pb[&quot;proposal B&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] columns 1 space PC2[&quot;prepare/accept&quot;] C2&lt;[&quot;chosen&quot;]&gt;(down) end space block:pc[&quot;proposal C&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] columns 1 space PC3[&quot;prepare/accept&quot;] C3&lt;[&quot;chosen&quot;]&gt;(down) end end block:wal[&quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;WAL&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&amp;emsp;&quot;]:1 columns 5 log1&gt;&quot;&amp;nbsp;index = 1&amp;nbsp;&quot;] o1&lt;[&quot; &quot;]&gt;(right) log2&gt;&quot;&amp;nbsp;index = 2&amp;nbsp;&quot;] o2&lt;[&quot; &quot;]&gt;(right) log3&gt;&quot;&amp;nbsp;index = 3&amp;nbsp;&quot;] cmd1[[&quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;x=1&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;]] space cmd2[[&quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;x++&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;]] space cmd3[[&quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;x-=2&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;]] end block:rsm:1 columns 3 space sm&lt;[&quot;Statue Machine&quot;]&gt;(down) space space res[&quot;x = 0&quot;] space end DB1 ---&gt; pc DB2 ---&gt; pa DB3 ---&gt; pb CLI -- &quot;x-=2&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot; --&gt; DB1 CLI -- &quot;x=1&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot; --&gt; DB2 CLI -- &quot;x++&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot; --&gt; DB3 style res display:none style rsm display:none "},{"slug":"consistency-in-distributed-systems","title":"浅谈分布式一致性","tags":["系统设计","分布式系统"],"content":"本文是《Designing Data-Intensive Application》一书的读书笔记。 副本一致性 现代的数据库系统来说，几乎都具备了复制机制（replication），这种设计方式至少能对系统带来两个好处： 多副本容灾：只要存在一个可用数据副本，数据就能够恢复 读性能水平扩展：通过分布到不同的机器上，同一份数据能够在多个节点上同时供外部访问 保证多副本的 数据一致性（consistency） 是一个难题。 最简单的实现方式是使用 同步复制机制（sync-replication）：保证写操作在所有副本上执行成功后，再响应客户端。不过这一方式通常意味着糟糕的写性能，因此较少使用。 与之相对的则是 异步复制机制（async-replication）：写操作在某些副本上成功后即可响应客户端，数据库会异步将修改同步到剩余副本上。其优势在于较高的写入性能，但是数据副本的一致性无法保证。 以最基础的主从复制架构为例，虽然主库与从库最终都会到达一个一致的状态，但是主从状态同步会存在时间延迟，这类延迟被称为 复制滞后（replication lag） 现象。 期间可能同时存在两份相互冲突的数据副本，依赖这些数据的应用如果没有做好预防处理，最终会引发系统行为异常。 在分布式数据库中，维护副本状态的进程分为以下两类： leader / master： 能够同时处理读写请求的进程 follower / slave： 只能处理读请求的进程 基于以上定义，常见的复制架构又可以分为下图中的 3 类： block-beta columns 9 space A[&quot;Master-Slave&quot;] space:2 B[&quot;Single-Leader&quot;] space:2 C[&quot;Multi-Leader&quot;] space block-beta columns 3 block:MS:1 columns 3 space C1(((&quot;Client&quot;))) space space:3 M1[(&quot;Master&quot;)] space S1[(&quot;Slave&quot;)] end block:SL:1 columns 3 space C2(((&quot;Client&quot;))) space space:3 F21[(&quot;Follower&quot;)] space F22[(&quot;Follower&quot;)] space:3 space L2[(&quot;&amp;nbsp;Leader&amp;nbsp;&quot;)] space end block:ML:1 columns 3 space C3(((&quot;Client&quot;))) space space:3 L31[(&quot;Leader&quot;)] space L32[(&quot;Leader&quot;)] block:BL1[&quot;WAL&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] columns 1 R11[&quot;&amp;nbsp;x=1&amp;nbsp;&quot;] R12[&quot;&amp;nbsp;x=x+1&amp;nbsp;&quot;] end space block:BL2[&quot;WAL&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot;] columns 1 R21[&quot;&amp;nbsp;x=x+1&amp;nbsp;&quot;] R22[&quot;x=1&quot;] end end C1 -- &quot;1.write&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;&lt;br/&gt;&quot; --&gt; M1 C1 -- &quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;2.read&lt;br/&gt;&lt;br/&gt;&quot; --&gt; S1 M1 -- &quot;&lt;br/&gt;&lt;br/&gt;sync&quot; --&gt; S1 C2 -. &quot;1.read&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;&lt;br/&gt;&quot; .-&gt; F21 C2 -- &quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;2.read&lt;br/&gt;&lt;br/&gt;&quot; --&gt; F22 L2 -- &quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;sync&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;fast&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot; --&gt; F21 L2 -- &quot;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;sync&lt;br/&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;slow&quot; --&gt; F22 C3 -. &quot;write&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp; [x=1]&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot; .-&gt; L31 C3 -- &quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;write &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp&amp;nbsp;&amp;nbsp;&amp;nbsp;[x=x+1]&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&quot; --&gt; L32 L31 ----&gt; L32 L32 -- &quot;&lt;br/&gt;&lt;br/&gt;sync&quot; --&gt; L31 不同的架构会面临不一样的一致性问题 一主一从 (master-slave) &nbsp;&nbsp;-&gt;&nbsp;&nbsp; 写后读一致性 (read-after-write consistency) 现象： 客户端修改数据，变更未同步到从库，此时读取从库数据，得到的是未修改的结果。 解决方案：写操作对之后的读操作立即可见。 根据应用特点，强制部分功能只访问主库，保证读写顺序一致 维护用户修改时间戳与从库修改的时间戳，根据时间戳选择是否读取从库 一主多从 (single-leader) &nbsp;&nbsp;-&gt;&nbsp;&nbsp; 单调读一致性 （monotonic read) 现象：客户端多次读取同一条记录，但请求路由到不同的从库上面，可能读取到旧版本的数据。 解决方案：之后读取到的数据一定要比之前读取到的数据要新。保证同个用户的请求只会路由到同个从库，保证读命令顺序一致。 多主多从 (multi-leader / leaderless) &nbsp;&nbsp;-&gt;&nbsp;&nbsp; 因果一致性（consistent read prefix） 现象：客户端多次修改数据路由到不同的主库上，且数据之间有因果关系（例如：问答记录）。可能读取到次序混乱的数据，也可能修改一条尚未存在的数据（例如：leader 间的网络延迟）。 解决方案：写操作的结果必须按照其执行的顺序被读取到。具有因果关系的写操作在同个主库上执行，保证写命令顺序一致。 冲突解决 在大规模互联网应用中，多数据中心正变得越来越流行，其优点如下： 地理位置近，访问速度快 高可用，单个数据中心宕机或者网络出现问题不会导致不可用的情况 当系统需要部署到多个数据中心的时候，不可避免地会使用到 multi-leader 架构，这带来以下问题： 同一份数据可能被两个数据中心并发修改，导致写冲突 数据库的某些特性不能很好地支持多 leader 架构，例如：自增主键、触发器 写入冲突 single-leader 架构下的写是顺序的，对同一份数据的修改，在每个副本上都能得到最终一致的结果。 multi-leader 架构下每个 leader 中的写也是有序的，但是不同 leader 之间的写操作是无序的，因此对同一份数据的修改也是无序的，最终导致副本的状态可能不一致。 如何保证所有库收敛到同一个状态？—— 解决冲突 每个写操作关联一个唯一 ID，选取优先级最高的操作结果 每个副本关联一个唯一ID，选取优先级最高的副本对应的结果 将两个冲突的数据合并成同一条数据 保存所有冲突数据，由后续的操作来进行解决 解决冲突的时机又可以分为： 写时解决：向数据库注入解决冲突的逻辑代码，当发生冲突时由数据库进行调用。比如：MySQL MGR 中通过维护全局一致的 Binlog 实现一致性。 读时解决：当存在冲突数据时，应用会获取到这些冲突数据，并自动或手动解决这些冲突。比如：Dynamo NRW 通过调整读写副本数量来保证读取到最新的数据。 一致性模型 作为一个开发者，我们关心的一个重要问题是：数据库本身究竟为我们提供了哪种等级的一致性保证？ 为了支持并发操作，单机数据库引入了事务的概念，从而避免数据不一致导致行为异常。数据库的事务模型有一个重要概念：事务隔离级别 隔离级别 读未提交 Read Uncommitted 读已提交 Read Committed 可重复读 Repeatable Read 序列化 Serializable 脏读 Dirty Read ✔ ✘ ✘ ✘ 不可重复读 Unrepeatable Read ✔ ✔ ✘ ✘ 幻读 Phantom Read ✔ ✔ ✘ ✘ 写偏斜 Write Skew ✔ ✔ ✔ ✘ 在不同的事务级别下，开发者能够得到数据库不同程度的一致性保证。越高的隔离级别，提供的一致性更强，也同时意味着更大的性能开销。 这一模型存在的好处，是的我们能够在一致性与性能之间进行权衡，作出适合自己应用场景的选择。在分布式场景中，我们要面对更为复杂的一致性问题。为了方便接下来的讨论，首先简单介绍几种一致性模型。 最终一致性 某个特点时间点下，数据库系统中的各个副本间的状态很可能是不一致的。 上面我们看到的几个一致性问题，都是由应用层解决的，数据库本身只提供以下保证： 经过一段任意长的时间后，数据库中的所有副本最终都能 收敛（convergence） 到相同的状态。\\ 这种极弱的一致性保证，就是我们常说的 最终一致性（Eventual Consistency）： 优点：这种弱一致性保证使得系统设计较为灵活，从而能够达到较高的性能。比如：副本间使用异步复制策略、设计专用的对账系统来离线解决数据冲突…… 缺点：当系统设计中涉及到最终一致性时，应用层需要十分关注复制滞后对系统的影响。并且需要根据业务所需的一致性保证来设计系统，变相增加了应用开发者的工作量。此外，某些问题在网络错误或者高并发时才会暴露出来，难以测试。 线性一致性 数据库的事务机制本身就是一个容错协议，能够为基于事务运行的应用提供数据安全的保障。 为了向应用层隐藏复杂度，事务为应用提供了以下的抽象保障： 原子性：数据库中的数据是完整的，事务执行是完整的（无需担心执行过程中进程崩溃） 隔离性：数据库不会被并发修改，事务之间是不会相互影响（无需担心竞态条件影响执行结果） 持久性：数据库的存储是可靠的，事务的变更不会丢失（无需担心存储器故障导致数据丢失） 事务机制提供的抽象保证，将应用开发者从繁杂的错误处理中解放出去，使其只需要专注于业务。不但提高了开发效率，也减少了 bug 出现的概率，系统更稳定且易于测试。 理想情况下，我们希望能够分布式数据库能够像事务一样，为我们提供一个更强的一致性保证： 全局写后读一致性： 系统只对外暴露一份数据，不存在同时存在多个版本的数据的问题 所有的修改操作都是原子性的，且每次读取到的数据都是最新的 全局单调读一致性：一旦写入操作成功，结果对所有之后的读取操作均可见，并且不会读取到旧版本的数据 这种跨进程的全局强一致性保证，被称为 线性一致性（Linearizability）。 下面通过一些具体场景来具体介绍这一模型。 首先，我们假定 x 是数据库中的某个条目： 在键值数据库中，x 就是一个 key 在关系数据库中，x 就是一行 在文档数据库中，x 就是一个文档 线性一致性模型定义了 3 种基本操作： $\\text{read}(x) \\Rightarrow v$：客户端从数据库中读取 x 对应的值 v $\\text{write}(x, v) \\Rightarrow r$：客户端向数据库中写入 x 对应的值 v，返回操作结果 r $\\text{cas}(x, v_1, v_2) \\Rightarrow r$：客户端使用 compare-and-set 操作将 x 对应的值从 $v_1$ 修改为 $v_2$ 并返回操作结果 r 线性一致性是 跨进程（Cross-Process） 的，可作为实现以下分布式应用场景的基础： 分布式锁与选主用 CAS 操作实现锁，获得锁的节点就是 leader 唯一性约束使用 CAS 操作来获取某个值对应的锁，如果获取成功，则这个值是唯一的，否则这个值就不是唯一的 多信道间的时序依赖一个进程A修改数据成功后通知进程B，此时线程B一定能够获取到进程A的修改结果 一个满足线性一致性的场景 图中有 3 个客户端，其中客户端 A、B 读 x，客户端 C 写 x 。 当 B 第一次读 x 时，C 正在进行写操作，此时 B 读到的值是 0（此时 C 写操作未提交） 当 A 第二次读 x 时，C 正在进行写操作，此时 A 读到的值是 1（此时 C 写操作已提交） 当 B 第二次读 x 时，由于此前 A 读 x 结果为 1，此时 B 读取到的值必然也是 1（全局单调读一致性） 一个违反线性一致性的场景 图中有 4 个客户端 A、B、C、D 并发进行读写操作，图中的连线表明了事务提交与读操作实际发生的时间点。图中存在一个违反线性一致性的行为：在 A 读取到 4 之后 B 读取到了 2 虽然单从客户端 B 本身来说并没有违反单调读一致性，但是在全局上来说违反了单调读一致性：后发的 B 读请求的结果，滞后于先发的 A 读请求的结果 一个实际应用场景【生成相册缩略图】 ![LinearModel3] 图中的 FileStorage 是个多副本的分布式文件存储，用于存储用户的照片数据，后台需要生成缩略图加快 web 预览： 当用户上传或修改照片时候，WebServer 将原始尺寸的用户图片储于 FileStorage 通过 MQ 将图片 id 异步通知 ImageResizer ImageResizer 根据 MQ 给出的图片 id 从 FileStorage 读取数据并生成缩略图 在步骤 B 中，MQ 消息传递的同时， FileStorage 内部进行副本复制。如果 FileStorage 不满足线性一致性，ImageResizer 可能读取不到图片（违反全局写后读）或者读取到旧（违反全局单调读）的照片。从而导致处理失败，甚至会生成错误的缩略图，整个系统最终会处于一个不一致的状态。 实现方式 实现线性一致性语义的最简单方式就是只使用一个数据副本，但这种做法会使得系统不具有容错性。 为了提高系统的容错能力，多副本的架构是唯一的选择，下面我们按照不同的多副本架构分情况讨论。 Single-leader 在不使用快照隔离（例如：mysql 的 MVCC）的前提下，同时使用以下两种策略可以满足线性一致性： 从 leader 读写取数据（只访问 leader 的数据副本，避免受其他 follower 不一致的数据副本影响） 使用同步复制策略（异步复制无法保证 follower 数据副本最终与 leader 一致） 实现的风险点： 脑裂(split-brain) 时可能出现多个 leader （同时对外暴露多个可写的副本数据，并且最终会导致数据不一致），违反线性一致性 自动灾备(failover) 对新 leader 副本的选择（如果选择一个数据不完整的副本作为新 leader，则相当于数据丢失），违反线性一致性 Multi-leader 允许多节点同时写，并且需要支持异步复制，可能引起写冲突，因此需要暴露多个副本来解决冲突，因此肯定无法满足线性一致性。 Consensus algorithms 共识算法涵盖了 single-leader 的功能，并且同时具备防止脑裂和过期副本的机制，因此天然满足线性一致性。 性能权衡 尽管线性一致性是个强有力的一致性保证，这类强一致模型在实践中的应用并不广泛。 举个例子，现代计算机的内存模型并不能保证线性一致性： 为了提升系统性能，现代 CPU 采用了多级缓存的架构。 当 CPU 需要访问修改 RAM 中的数据，首先会修改缓存，然后异步将修改刷新到实际的 RAM 中（多副本 + 异步复制机制）。 牺牲一致性以换取更好的性能，这类权衡在数据库系统中更为常见。 为了保证强一致性，线性一致性模型会带来糟糕的性能，这是不可改变的事实。 因果一致性 事件的发生先后顺序包含了因果关系（Causality）。 一个违反因果一致性的场景【医生排班表】 每个医院都有一个排班表 (on-call shift)，以 保证至少有一个或者以上的值班医生 (on-call docotor) 在场，以应付急诊之类的突发情况。如果值班当天医生身体不适，可以在排班系统申请提前下班，值班系统会检测当前值班医生人数，判断是否允许下班。 某天，医院只有两个值班医生 Alice 和 Bob，但是恰巧两人身体都出现了不适，并且在同一时刻申请了提前下班，此时可能出现以下情况： 系统首先开启了两个并发的事务，分别由 Alice 和 Bob 两人发起 两个事务同时查询了值班人数，并且得到值班人数为 2 (currently_on_call = 2) 两个事务同时更新了值班记录，分别将 Alice 和 Bob 改为非值班状态，并提交事务成功 最终医院的值班人数为 0，急诊病人 R.I.P 上面这个例子违反了因果一致性：事务中的 write 操作依赖于 read 操作 Alice 的 write 事务先提交，从而导致 Bob 事务中 read 的结果失效。但是 Bob 的事务没有检测出 read 失效的情况，而是直接提交了事务，最终导致系统违反了排班约束 这种由并发 read-write 事务导致的不一致现象被称为 写偏斜（Write Skew）。值得注意的是，这种事务并发的情况不一定是人为引起的，网络延迟拉长事务周期而也可能间接引发这一问题。 模型对比 我们先回顾一下两个顺序相关的定义： 全序（total order/linear order） 集合中，任意两个元素之间都是可以比较 偏序（partial order） 集合中，部分元素之间是可以比较的 这两种顺序分别代表了两种一致性模型： 线性一致性：系统对外只暴露一份数据，所有操作在唯一一份数据上串行执行（没有并发操作），因此任意操作之间肯定是有先后顺序的 因果一致性：有因果关系的操作之间是有序的，但并发操作之间没有因果关系，因此也没有先后顺序 线性一致性模型更简单，容易理解，并且能够处理多信道时序依赖的因果问题。 然而实现线性一致性需要付出较高的性能代价，操作之间需要相互等待，并且在网络延迟较高的环境下，系统不可用的概率会增大。 因果一致性模型比较抽象，难以理解，但是足以应付多数应用场景。 因果一致性模型最终可以达到接近最终一致性模型的标准，并且对网络延迟不敏感，在面临网络故障的情况下仍能保证可用性。 相比线性一致性，因果一致性的一个重大差异是：允许不相关数据的并发 线性一致性只有一个单一的时间线 因果一致性则是一棵具有多个分叉的树（可以参考 Git 的分支模型） 序列号生成 因果关系本身就是一个先后顺序的问题，因此只要知道了顺序，就能够据此推导出因果关系。 在讨论因果一致性模型前，我们需要找到一种合适的方法来表示因果顺序，从而使得我们能够分析因果依赖。在实际应用中，我们无法记录所有依赖关系，否则将会造成巨大的开销。 一个可行方法是：为每个操作分配一个表示顺序的序列号，序列号本身占用空间少，并且本身具有全序关系。 常见的大规模序列号生成手段有： 时间戳：使用高精度的时间戳作为序列号 提前规划：使用取模的方式，按照生成器个数来划分可用序列号，部署多个序列号生成服务（例如：两个节点可以分别使用奇偶序列号的生成器） 批量生成：生成器以批量分配的方式，每次向节点分配一个连续的区间 问题在于，这些序列号不能保证全局有序： 系统时钟会有偏差，多个节点间时钟之间不一定同步，时间戳不一定能表示操作的先后顺序 如果服务节点的负载不均，则旧的序列号可能会被应用到新的操作上面，先后顺序也无法保证 为此，我们需要一个能够保证全局顺序的序列号生成机制。 Lamport timestamps 先介绍一种通过逻辑时钟生成具有因果关系的序列号的方法：Lamport timestamps。 每个进程要维护以下两个信息： $\\texttt{ID}$：全局唯一的不可变进程标识 $\\texttt{Counter}$：初值为 0 的单调递增的整型计数器 系统的所有交互会被封装为一系列的事件，每个事件都会关联一个全局唯一序列号 $(\\texttt{C},\\texttt{ID})$，其生成规则如下： 进程生成一个事件时，首先递增计数器得到一个局部唯一的序列号 $\\texttt{C}^{latest} = \\texttt{++Counter}$，然后将其与进程标识组合成 $(\\texttt{C}^{latest},\\texttt{ID})$ 用于表示这个事件的发生顺序。 进程接收一个来自其他进程的事件时，会更新本地计数器 $\\texttt{Counter} = \\max(\\texttt{C}^{other}, \\texttt{Counter})+1$ 该序列号满足以下全序关系：C 越大优先级越高；C相同时，ID 越大优先级越高。通过为每个操作关联上这样一个序列号，间接为所有操作建立起一个全序的顺序关系，此时任意两个操作都是有序的。 不过这一方案也存在缺陷：操作顺序只能在操作发起之后才能得知，无法立刻对数据冲突作出反应。比如： 两个客户端分别在两个节点上同时发起了一次有冲突的操作（例如：添加同名账户）。当发生冲突时，系统会自动用序列号更大的值来解决冲突，从而导致序列号较小的操作失效，但是在客户端看来自己操作已经成功了。 这类不能实时解决的冲突可能会导致一致性的问题，出现这种问题的主要原因是： 虽然最终的序列号是全序，但是某个时刻的实时的序列并不完整，后面可能有未知的序列号插入其中。例如：进程 A 已生成最大序列号 (A,1)，进程 B 已生成最大序列号是 (B,5)，若 A 此时接收到一个来自进程 C 的事件 (C,2)，则后续可能生成一个序列号为 (A,4) 的事件。 为了保证操作的安全性（不可逆），我们需要保证当前已知的序列是不变的。 全序广播 全序广播（total order broadcast/atomic broadcast） 通常被描述为一个在节点间交换消息的协议，该协议具有两个特征： 可靠传递：一旦一个节点接收到某条消息，其他节点也一定会收到这个消息。不会出现消息丢失的情况，并且每条消息只会出现一次。 全序传递：所有节点都以相同的顺序接收到消息。 一旦消息发出，此时顺序就确定下来了。节点不允许往已经存在的消息序列中插入消息，只能往后追加，因此可以将全序广播看作是一个记录日志的过程：所有节点都在异步记录一个全局顺序一致的日志。 当出现故障时，需要有一个重试机制来保证上面两个约束。 很多一致性服务都实现了全序广播协议，全序广播可以用于实现以下功能： 一致的复制机制把对数据库的写操作当作是消息，只要能够保证所有节点以相同的顺序接收到这些消息，则能够保证所有副本的一致性 序列化事务把事务操作当作是消息，每个节点以相同的顺序处理事务，那么每个节点都保证具有一致的状态 分布式锁每个获取锁的请求被记录在一个有序日志中，可以根据请求的顺序来决定锁的获取顺序 CAS 操作以使用 CAS(username, A, B) 修改用户名为例： 发出一条 assert(username = A) 消息 监听 username 的相关日志，在接收到第一条日志之后： 如果是自己发出的 assert 日志，则通过追加一条 commit(username = B) 消息来完成修改， 如果是其他节点的 assert 或 commit 日志，则修改失败 全局一致性读 利用消息在日志中的位置确定读发生的时间，发出一条消息，监听这条消息返回后，再进行读操作（etcd） 如果日志系统能够获取到最新的日志所在的位置，则可以等待日志追加到这一位置之后，再进行读操作（zk） 只在写操作同步更新的副本上读取数据 将最后两个功能合并起来，则相当于实现了线性一致性。 实现方式 在 single-leader 架构中，只有一个 leader 能接受写操作，从而能够保证所有写操作都是有序的，进而保证所有副本都是有序的。这意味着：single-leader 架构本身就具有全序传递的特性，只要在此基础上解决了可靠传输问题，就能实现全序广播。 常规的 single-leader 架构需要在启动时，人为指定一个 leader 节点，一旦这个节点失效，整个系统将会陷入一个不可用的状态，直到人工介入指定一个新的 leader。这对系统的可用性无疑会造成严重的影响。 为了实现自动灾备（auto-failover），系统本身需要支持 leader 选举功能： 当 leader 失效时，从健康的 follower 中选择一个新的 leader，继续对外提供服务。选举过程中需要防止脑裂的情况发生，避免同时出现多个个 leader，影响系统的一致性。 此类选举场景中，不可避免的会用到分布式一致性算法（distributed consensus algorithm） ，后面我们将用独立的篇幅对其进行介绍。 "}]