# 衣舞晨风: full content index > 专注 AI Gateway、Elasticsearch、Java、Go、云原生网关与后端架构实践,分享复杂系统设计、性能优化、稳定性治理和源码分析。 Canonical site: https://jiankunking.com/ Author: 衣舞晨风 (jiankunking) Author profile: https://jiankunking.com/about/ Generated from 171 indexable articles. Each entry keeps its canonical URL and content provenance. ## Core topics - [AI Gateway](https://jiankunking.com/topics/ai-gateway/): 系统理解 AI Gateway 的能力边界、模型协议转换、流量治理、可观测性、运行时性能、多云高可用与选型方法。 - [后端与云原生架构](https://jiankunking.com/topics/backend-cloud-native/): 从架构原则、领域建模和分布式一致性,到 Kubernetes、云原生网关、多云高可用与稳定性治理的系统实践路径。 - [Elasticsearch](https://jiankunking.com/topics/elasticsearch/): Elasticsearch 从原理入门、数据建模和查询优化,到写入检索源码、分片恢复、容量规划与生产事故复盘的系统阅读路径。 - [AI Agent 工程](https://jiankunking.com/topics/ai-agent-engineering/): 从 Tool Calling、MCP、A2A、AG-UI 到上下文工程和 Harness,理解 AI Agent 应用从 Demo 到生产系统的关键工程问题。 ## Articles ### 我用一个点餐 Demo,串了一遍 Agent 应用开发 - URL: https://jiankunking.com/understand-ai-agent-development-with-agentmesh-demo.html - Content type: original - Published: 2026-08-21 - Updated: 2026-08-21 - Summary: 通过一个可运行的点餐 Demo,串联 Tool Calling、MCP、A2A、AG-UI 与多 Agent 协作的完整开发链路。 - Categories: AI - Tags: AI Agent, AgentScope, AG-UI, MCP, A2A, Tool-Calling Article text: 文章速览 通过一个可运行的点餐 Demo,串联 Tool Calling、MCP、A2A、AG-UI 与多 Agent 协作的完整开发链路。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:AI 这段时间看 Agent 相关的东西,一个很直接的感受是:概念越来越多,但单独看每个概念,又好像都不复杂。 比如 Tool Calling 是让模型选择函数,MCP 是连接工具,A2A 是 Agent 之间通信,AG-UI 负责前后端交互。介绍文章看了不少,真正把它们放到同一个项目里时,还是会遇到很多具体问题:请求从哪里进来,工具结果怎么回给模型,多个 Agent 怎么传递上下文,用户中途取消以后后台任务要不要继续跑。 为了把这些问题串起来,我写了一个 AgentMesh Demo。业务场景很简单,就是查食堂菜单、价格和库存,再加一个模拟订餐能力。场景虽然小,但刚好可以把 AG-UI、AgentScope、MCP 和 A2A 都接进来。 这篇文章不准备逐个介绍协议,而是沿着一次请求看一遍这个项目。它不覆盖模型训练、微调、RAG 等内容,主要讨论的是:拿到一个大模型接口之后,怎样把它做成一个能交互、会调用工具、能委派任务的应用。 为什么是一个点餐 Demo 一开始最容易做的是聊天页面:输入一句话,请求模型,然后把结果显示出来。 但这种形式很难把 Agent 和普通聊天的区别表现出来。为了让系统里真的出现工具调用和任务委派,场景至少需要两类能力: - 一类是确定性的,比如查价格、查库存; - 一类是需要理解用户意图的,比如推荐菜品、处理订餐请求。 点餐正好符合这个条件。 菜品价格和库存存在 SQLite 里,查出来是多少就是多少,不应该让模型猜。菜单推荐和订餐则可以交给一个独立的 Food Agent。这样一来,MCP 和 A2A 在同一个请求里的分工就比较清楚了。 项目最后拆成了五个进程: 1 2 3 4 5 | Web Frontend 负责输入、流式输出和工具状态展示 Orchestrator 负责会话、模型调用和工具编排 MCP Server 负责菜品目录查询 A2A Gateway 负责 Agent 路由和请求转发 Food Agent 负责菜单、推荐和模拟订餐 | 大致链路如下: 1 2 3 4 5 6 7 8 9 | flowchart LR U["用户"] --> UI["AG-UI Frontend"] UI -->|"SSE"| O["AgentScope Orchestrator"] O --> L["LLM"] O -->|"MCP"| M["MCP Server"] M --> D["Food Catalog SQLite"] O -->|"A2A"| G["A2A Gateway"] G --> F["Food Agent"] O --> S["Run / Message SQLite"] | 这里没有使用消息队列、注册中心或者复杂的基础设施。这个项目的目的不是展示一套生产架构,而是尽量少引入别的东西,把 Agent 调用链本身暴露出来。 先把项目跑起来 项目需要 Python 3.11、Node.js 20.19+ 和 npm。 1 2 3 | git clone https://github.com/jiankunking/agentmesh-demo.git cd agentmesh-demo cp .env.example .env | 在 .env 中配置模型: 1 2 3 | LLM_API_KEY=your-api-key LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini | 然后执行: 1 | ./scripts/start.sh | 脚本会安装依赖、构建前端,并启动五个组件。浏览器访问 http://127.0.0.1:3000 就可以开始测试,Orchestrator 的 Swagger 地址是 http://127.0.0.1:8000/docs。 停止服务使用: 1 | ./scripts/stop.sh | 第一次打开页面后,我建议不要马上去翻所有代码,可以先试几类问题: 1 2 3 4 5 | 你是谁? 现在几点? 今天食堂有什么菜? 水煮牛肉多少钱,还有库存吗? 帮我推荐一道菜。 | 这几个问题看起来差不多,后台经过的路径其实不同。 “你是谁”通常由模型直接回答;“现在几点”会调用本地函数;查询价格和库存会走 MCP;菜单和推荐则可能通过 A2A 调用 Food Agent。 先把这些路径跑一遍,再去看代码,会比从 main.py 第一行一路往下读容易得多。 一次请求是怎么跑完的 以这个问题为例: 今天有什么推荐?水煮牛肉还有库存吗? 它同时包含了推荐和库存查询。前者适合交给 Food Agent,后者适合查询菜品目录。 前端发起一次 Run 普通聊天接口经常是一问一答:前端提交文本,后端返回文本。 Agent 应用不太一样。模型可能先输出几句话,然后调用工具;工具执行完成以后,模型继续输出;中间还可能失败或者被用户取消。因此,前端面对的是一次持续变化的运行,而不只是一个字符串响应。 项目中的前端使用 AG-UI 客户端发送 RunAgentInput,Orchestrator 通过 SSE 返回事件。前端主要处理几类内容: - 模型输出的文本增量; - 工具开始执行; - 工具执行结果; - Run 成功、失败或取消。 这也是我接入 AG-UI 后比较明显的一个认识:流式交互不只是把模型 token 一个个推到页面上。工具调用同样有开始和结束,整个任务也有自己的状态。如果前端只处理文本流,模型一旦开始调用工具,用户看到的往往就是页面突然停住。 前端入口比较集中,主要代码在: 1 2 | frontend/src/main.ts frontend/src/styles.css | 想知道请求发了什么、事件回来后如何更新页面,从这两个文件开始就够了。 Orchestrator 不只是转发模型请求 请求进入 Orchestrator 后,需要先建立这次运行的上下文。 项目里把 Conversation、Message 和 Run 分开保存: - Conversation 对应一个持续存在的会话; - Message 是用户或 assistant 的一条消息; - Run 是处理某次用户输入的执行过程。 一个会话中会有多次 Run。每次 Run 都有独立状态: 1 2 3 | PENDING → RUNNING → SUCCEEDED → FAILED → CANCELLED | 这种拆分在刚做聊天 Demo 时可能显得有些多余,但加入取消和失败处理以后就很有用。 例如,用户发了一条消息,模型已经输出一部分内容,随后 MCP 调用超时。这时对话还在,但本次 Run 失败了。那段没有完成的 assistant 内容可以保留在执行记录里,却不应该当作一条成功消息加入下一轮模型上下文。 Orchestrator 收到请求后,大概会做这些事: - 校验用户、会话、消息和 Run 标识; - 创建或读取 Conversation; - 保存用户消息和 Run; - 获取当前会话对应的 Agent; - 等待会话锁; - 恢复此前成功完成的历史消息; - 调用 AgentScope 开始流式推理; - 根据结果更新 Run 和 assistant 消息。 这一段代码主要分布在: 1 2 3 4 | services/orchestrator/routes.py services/orchestrator/run_service.py services/orchestrator/persistence.py services/orchestrator/session.py | routes.py 是 HTTP 入口,真正的运行逻辑更多在 run_service.py。如果只看路由,很容易误以为 Orchestrator 就做了一层协议转换。 模型选择要不要调用工具 AgentScope 会把系统提示、历史消息、当前用户消息和工具 Schema 一起发给模型。 模型当前可以看到四个真实工具: 1 2 3 4 | get_current_time 查询服务器当前时间 search_food_catalog 搜索菜品目录 get_food_item 查询某个菜品的价格和库存 call_food 调用 Food Agent | 此外还保留了模拟搜索和模拟 Python 工具,但默认不会暴露给模型,需要显式打开 DEMO_TOOLS_ENABLED。 模型拿到工具描述后,会决定直接回答,或者返回 tool_calls。工具结果再进入下一轮模型请求,直到生成最终答案。AgentScope 在这里负责 ReAct 循环和流式事件。 代码中设置了最大迭代次数,避免模型不断调用工具却无法结束。不过限制迭代次数只是最后一道保护,更关键的还是工具描述要写清楚。如果两个工具的职责重叠,模型很容易在它们之间选错。 我在这个项目里刻意把能力分成 Local、MCP 和 A2A 三类,并通过 CapabilityRegistry 统一注册。对应代码在: 1 2 3 | services/orchestrator/capabilities.py services/orchestrator/tool_registry.py services/orchestrator/tool_impls.py | 注册信息除了函数名和参数,还包含: 1 2 3 4 5 | type enabled readOnly requiresConfirmation timeoutSeconds | 其中 requiresConfirmation 目前只是预留字段,项目还没有做完整的用户确认流程。这一点后面还会提到。 MCP 在这里负责什么 “水煮牛肉还有几份”是一个确定性问题。 库存存放在 SQLite 里,模型不应该凭训练数据或者对话上下文回答。Orchestrator 会调用 MCP 工具,MCP Server 再查询菜品目录数据库。 1 2 3 4 5 | Orchestrator ↓ search_food_catalog / get_food_item MCP Server ↓ SQL Food Catalog SQLite | 这个调用完全可以直接写成一个 HTTP API。之所以使用 MCP,是为了看清楚工具能力如何被声明、发现和调用,以及同一套工具接口如何被不同的 AI Host 使用。 在这个 Demo 里,MCP 并没有承担推理工作。它接收明确参数,执行查询,然后返回结构化结果。模型负责判断什么时候查,MCP Server 负责返回数据库中的真实数据。 相关代码不多: 1 2 3 | services/orchestrator/mcp_client.py services/mcp_server/main.py services/mcp_server/catalog.py | 如果之前只看过 MCP 的概念说明,可以在这里重点观察三个地方: - Orchestrator 如何连接 MCP Server; - MCP 工具如何转换成模型可以调用的工具; - Trace 信息如何跟随调用传到 MCP 请求中。 第一次启动 MCP Server 时,会根据配置创建菜品目录 SQLite。可以直接调用工具查询 food-001,验证返回的价格和库存,而不必每次都经过模型。 A2A 在这里负责什么 “帮我推荐一道菜”不是简单的数据库查询。 它需要先理解用户想要什么,再结合菜单组织回答。后续如果加入忌口、预算、历史订单等信息,这部分逻辑还会继续增长。因此项目没有把它做成一个普通查询函数,而是拆成了独立的 Food Agent。 Orchestrator 调用 call_food 时,会构造 A2A JSON-RPC 请求,经 A2A Gateway 转发给 Food Agent。 请求里除了用户文本,还会携带: 1 2 3 4 5 6 | messageId contextId traceId runId threadId toolInvocationId | contextId 用来保持 A2A 侧的上下文,其余标识主要用于关联一次请求在多个服务中的日志。 A2A Gateway 当前做的事情比较直接:校验请求、检查 API Key、从注册表找到 Agent 地址,然后根据 message/send 或 message/stream 转发请求。 相关代码在: 1 2 | services/a2a_gateway/main.py services/food_agent/main.py | 这个 Gateway 目前还是轻量实现。Agent Registry 是代码配置,不支持后台动态上架、版本管理或者租户隔离。不过即使只是这样,也能把 Orchestrator 和 Food Agent 的地址、鉴权及转发逻辑分开。 如果以后 Agent 数量变多,Gateway 才有可能继续承担限流、熔断、路由和统一观测。Demo 阶段没有必要提前把这些全部做完。 MCP 和 A2A,我是怎么区分的 刚开始把两者放到一起时,最容易产生的问题是:Food Agent 能不能也做成 MCP 工具?菜品查询能不能也包成一个 Agent? 技术上当然都能做,但边界会变得模糊。 我现在主要看两点。 第一,被调用方是否需要独立推理。 查询数据库有明确输入输出,不需要再调用一次模型,适合做工具。推荐菜品、处理复杂订餐意图,可能有自己的提示词、上下文和工具,更适合作为 Agent。 第二,这项能力是否需要独立演进。 如果它有单独的模型、权限、发布节奏和领域逻辑,拆成 Agent 会更自然。如果只是一个函数,就没必要为了使用 A2A 再增加一个服务。 简单对比如下: MCP A2A 调用对象 | 工具或数据源 | 另一个 Agent | 是否推理 | 通常不需要 | 可以有自己的模型和决策 | 状态 | 多数是单次调用 | 可以维护任务或会话上下文 | 项目中的例子 | 查价格、查库存 | 菜单推荐、模拟订餐 | 可以把 MCP 理解成“使用工具”,A2A 理解成“找另一个人协作”。这个类比不够严谨,但在做架构选择时比较直观。 真正花时间的不是把协议接通 AG-UI、MCP 和 A2A 的基本链路跑通以后,项目其实还不能算完整。后面花时间更多的是一些看起来不太“Agent”的问题。 同一个会话不能随便并发 如果用户在上一条消息还没有处理完时又发了一条,两次推理可能同时读写上下文。 项目按 (userId, threadId) 创建 Agent 和 asyncio.Lock。同一个会话串行执行,不同会话可以并发。 这不是唯一方案,但实现和行为都比较清楚。至少不会出现后一条消息先完成,然后上下文顺序混乱的问题。 AG-UI 中间件的流式状态则使用 contextvars 隔离,避免多个并发 SSE 请求共享中间变量。 失败内容不能直接进入下一轮对话 假设模型已经输出一半,工具调用突然失败。如果把这段不完整的文本加入历史,下一轮模型可能会把它当成已经完成的回答。 项目只会把成功完成的消息恢复到 AgentScope 上下文。失败和取消的 Run 仍然有记录,但不会污染后续推理。 这里需要区分“为了排查而保存”和“适合作为模型上下文”是两件事。 用户离开以后,任务也应该停下来 流式请求中,用户可能点击取消,也可能直接关闭页面。 如果 Orchestrator 不处理这个情况,后台可能继续等待模型、MCP 或 Food Agent,最后生成一份已经没有人接收的结果。 项目通过 ActiveRunRegistry 保存 runId 到 asyncio.Task 的关系。收到取消请求后,会记录取消时间并调用 Task.cancel(),让取消沿当前 await 链传播。 这个实现目前只适用于单进程。多实例部署以后,任务可能运行在另一个实例,需要共享任务系统或者单独的取消通道,不能继续依赖内存映射。 服务重启后,RUNNING 不应该一直存在 Orchestrator 重启以后,内存任务已经消失,但 SQLite 里可能还留着 PENDING 或 RUNNING。 项目启动时会把这些 Run 标记成 FAILED,并记录 orchestrator_restarted。它不会自动恢复中断的模型和工具调用。 自动续跑当然更理想,但需要可持久化的工作流、幂等工具和更完整的恢复机制。对这个 Demo 来说,先保证状态不撒谎更重要。 日志需要能串起来 一次用户请求可能调用两次模型、三个工具,还可能经过 A2A Gateway 和 Food Agent。只有一个 traceId 还不够,因为它无法区分同一次 Run 中的多轮操作。 项目里使用了几类标识: 1 2 3 4 5 6 7 | traceId 整条调用链 runId 一次 Agent 运行 threadId 一个会话 llmCallId 某一轮模型请求 toolInvocationId 某一次工具调用 rpcId 一次 A2A JSON-RPC 请求 contextId A2A 会话上下文 | 日志统一使用 [FLOW] 摘要,相关 Header 会从 Orchestrator 传到 A2A Gateway 和 Food Agent。MCP 客户端也会带上调用上下文。 有了这些标识,才比较容易回答:到底是模型慢、MCP 慢,还是 Food Agent 根本没有收到请求。 有副作用的工具还差一步 当前 call_food 被标记为非只读能力,但 Demo 中的订餐只是模拟操作,调用前也没有用户确认。 如果接入真实下单接口,不能让模型选中工具后直接执行。至少需要增加一个确认过程:把菜品、数量、价格等参数展示给用户,用户明确同意后再继续。 此外还要考虑: - 用户拒绝或者长时间不确认怎么办; - 重试会不会重复下单; - 谁有权限执行这项操作; - 参数在确认后还能不能被修改; - 如何保留审计记录。 这些问题不是 MCP 或 A2A 自动解决的。协议把调用链连接起来,权限、幂等和确认仍然属于应用本身。 项目的能力注册表预留了 readOnly 和 requiresConfirmation,但 HITL 闭环还没有实现。这也是后续比较值得补的一块。 如果从头读代码,我会按这个顺序 第一步先看前端和路由: 1 2 | frontend/src/main.ts services/orchestrator/routes.py | 先确认请求格式、SSE 响应和取消接口,不要急着钻进模型细节。 第二步看 Run 怎么执行: 1 2 3 4 | services/orchestrator/run_service.py services/orchestrator/run_control.py services/orchestrator/session.py services/orchestrator/persistence.py | 这一部分可以看到会话锁、历史恢复、状态迁移和取消处理。 第三步看工具注册和实现: 1 2 3 4 | services/orchestrator/capabilities.py services/orchestrator/tool_registry.py services/orchestrator/tool_impls.py services/orchestrator/mcp_client.py | 这里重点对比 search_food_catalog 和 call_food:一个走 MCP,一个走 A2A,但最终都会作为工具暴露给 Orchestrator Agent。 最后再看下游服务: 1 2 3 4 | services/mcp_server/main.py services/mcp_server/catalog.py services/a2a_gateway/main.py services/food_agent/main.py | 如果想确认自己对代码的理解是否正确,可以直接看测试。项目中对 Capability Registry、MCP、Food Agent、Gateway、Orchestrator、会话和持久化都做了测试。测试用例通常比实现代码更容易看出一段逻辑原本想保证什么。 可以继续做的几个实验 把项目跑起来只是第一步。如果想用它熟悉 Agent 开发,我觉得下面几个改动比继续看概念文章更有用。 新增一个 MCP 工具 可以接天气、汇率或者自己的业务查询接口。重点不是工具本身,而是完整走一遍: 1 | 定义工具 → 注册能力 → 暴露给模型 → 执行调用 → 返回 AG-UI 事件 | 新增一个独立 Agent 例如 Travel Agent 或售后 Agent,然后把它注册到 A2A Gateway。过程中会遇到 Agent Card、上下文 ID、鉴权和错误转发等问题。 人为制造失败 把 MCP 地址改错、让 Food Agent 超时,或者在请求过程中停止 Gateway。观察 Run 最终状态、前端提示和日志是否一致。 正常路径只能证明功能能跑,失败路径更容易暴露状态设计的问题。 给订餐加确认 让模型先生成待执行的订餐参数,前端显示确认卡片,用户同意后再继续执行。这个改动会同时涉及工具状态、Run 暂停和恢复、幂等及审计,能把项目从“工具调用 Demo”往真实业务推进一步。 尝试多实例 当前取消、会话锁和部分运行状态依赖单进程。把 Orchestrator 启动成多个实例,很快就能看到哪些假设需要改成分布式实现。 目前没有做的事情 这个项目主要用来观察协议怎么配合,因此有不少地方有意保持简单: - 菜品目录和 Run 使用 SQLite; - 订单没有做真实持久化; - Capability Registry 是静态配置; - 取消只支持单个 Orchestrator 进程; - 服务重启后不会自动续跑未完成任务; - 订餐没有完整的用户确认; - 没有实现正式的身份系统、租户隔离和细粒度权限; - 模拟搜索和 Python 工具默认关闭,不能当成生产能力。 它也没有覆盖 RAG、模型训练、微调、评测和推理优化。把这些内容都塞进一个 Demo,项目反而会失去重点。 如果准备对外部署,API Key、CORS、HTTPS、限流、密钥管理和工具权限都需要重新检查。README 中的默认配置主要面向本机实验。 最后 做完这个 Demo 后,我对 Agent 应用的理解反而没有以前那么“模型中心”了。 模型仍然是最重要的决策节点,但一个请求能否可靠完成,还取决于前端事件、工具边界、会话状态、取消传播、下游超时和日志关联。模型只负责其中一段,剩下的大部分仍然是熟悉的软件工程问题,只是调用链里多了不确定的模型输出。 如果刚开始接触 AgentScope、MCP 或 A2A,我不建议先把每份协议文档从头背一遍。可以先找一条具体请求,把它从页面一路跟到模型、工具和数据库,再跟着结果返回。链路跑明白以后,再去看协议细节,会更容易知道每个字段为什么存在。 AgentMesh Demo 只是我用来串这条链路的一个小项目。它离生产系统还有不少距离,但用来回答“一个 Agent 应用到底由哪些部分组成”,已经够用了。 项目地址:https://github.com/jiankunking/agentmesh-demo ### 当AI开始自主进化,普通人最该守住什么? - URL: https://jiankunking.com/when-ai-starts-evolving-on-its-own-what-must-we-hold-on-to.html - Content type: original - Published: 2026-08-14 - Updated: 2026-08-14 - Summary: 结合阅读与工程实践思考AI深度参与工作和生活后,普通人如何守住独立判断、责任意识、基本功和长期能力,避免把决策完全交给模型。 - Categories: AI - Tags: AI, Autonomous AI, Human Agency, Critical Thinking Article text: 文章速览 结合阅读与工程实践思考AI深度参与工作和生活后,普通人如何守住独立判断、责任意识、基本功和长期能力,避免把决策完全交给模型。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:AI 最近在微信读书上看林军锋的《当AI开始自主进化,普通人最该守住什么?》,第六章谈到一个很现实的问题:AI用得越顺手,人越容易把自己的判断也一并交出去。 书里有句话我记得很牢: 判断力,是干粗活的副产品。 细想确实如此。 一个工作多年的工程师拿到AI生成的代码,往往扫几眼就会觉得某个地方“不太对”。有时他一开始也说不清原因,继续看下去,可能是异常没兜住,可能是并发场景有问题,也可能只是这段实现放进现有系统后很难维护。 这种感觉不是看几篇最佳实践得来的。它来自以前写过的烂代码、线上出过的故障、半夜查过的日志,以及那些当时觉得麻烦、后来却反复救过自己的基本功。坑踩得多了,心里才慢慢有了一把尺子。 AI只认目标,不懂你的本意 我们让AI做事,通常要先给它一个目标。目标写得越清楚,结果往往越像样。但“像样”和“正确”之间,还有一段不短的距离。 比如让AI写一个能运行的功能,它确实很快就能写出来。至于边界条件是否完整、数据量上来以后会不会出问题、这段代码半年后还有没有人敢改,就不一定了。它完成的是提示词里写出来的任务,而我们心里默认却没有说出口的部分,它未必知道。 所以验收AI的结果,不能只看它有没有做完,还得看它做的是不是原来那件事。 这件事听起来简单,实际最考验人。因为要发现结果偏了,首先得知道正确的方向大概在哪里。自己心里没有标准,AI给出的内容又足够流畅,就很容易顺手接受。 被省掉的粗活,也是成长的台阶 AI接手重复劳动当然是好事。资料不用一页页翻,代码不用从空文件开始写,很多问题也不用在搜索引擎里来回拼关键词。工作效率确实提高了。 可书里提到的另一个问题也让我有些警惕:过去新人正是靠这些粗活入门的。 刚开始写代码时,谁不是从简单接口、重复逻辑和低级错误一路过来的?代码写得多了,才知道哪些抽象是多余的,哪些看似省事的写法迟早要还债。排查过几次线上问题以后,才会对超时、重试、日志和监控真正敏感。 现在AI可以直接越过中间过程,把一个完成度很高的答案摆在面前。对已经有经验的人来说,这是节省时间;对还没有形成基本判断的人来说,也可能是把梯子的前几级直接抽掉了。 答案拿到了,经验却没有留下。 我觉得这才是AI进入工作以后比较隐蔽的风险。人不会因为用了AI就立刻失去能力,更多时候,是在一次次“这次先让它做吧”之后,慢慢失去了独立完成和判断结果的耐心。 AI放大的,还是人本来的水平 很多人觉得AI普及以后,大家都站到了差不多的起点。工具层面或许如此,结果却未必。 同样一段生成内容,有经验的人会继续追问:假设是什么,数据从哪里来,有没有反例,放到真实环境里能不能成立。没经验的人更容易被完整的格式和肯定的语气说服。 最后,一个人借助AI把多年经验放大,另一个人借助AI更快地产出自己无法验证的东西。表面上两个人都在熟练使用AI,时间一长,差距反而可能更大。 这也解释了为什么现在“会不会用AI”已经没那么重要。工具很快就会变得人人都会用,难的是你能不能看出它什么时候说错了,什么时候绕开了关键问题,什么时候给出了一个漂亮却不能落地的方案。 给自己留一点笨功夫 看完这一章,我能想到的办法并不复杂:别把所有过程都交出去。 重要的东西,还是要自己做一遍。代码可以让AI生成,但核心逻辑要看懂,关键路径最好亲手推演;文章可以让AI帮忙整理,但观点是不是自己的,自己应该说得清;遇到一个过于顺滑的结论,先别急着接受,试着问一句:如果它是错的,我能从哪里发现? 有些基础工作确实低效,却不一定毫无价值。它们让人熟悉细节,也让人知道一件事做坏时会是什么样子。完全跳过这些过程,也就很难凭空长出判断力。 以后AI能做的事情肯定还会更多。我倒不太担心某个具体工具会不会取代谁,因为工具一直在换。更值得担心的是,有一天面对一个看起来无可挑剔的答案,自己已经不知道该从哪里怀疑了。 能自己判断,能验证结果,也愿意为最后的选择负责——这个位置,还是要尽量守住。 ### 《Harness工程:从上下文管理到Agent系统构建》笔记 - URL: https://jiankunking.com/harness-engineering-from-context-management-to-agent-system-building.html - Content type: original - Published: 2026-07-22 - Updated: 2026-07-22 - Summary: 《Harness工程》读书笔记,梳理上下文管理、规划执行、反思记忆、Skills、多 Agent 协作与 Agent 产品设计。 - Categories: AI - Tags: AI Agent, Harness Engineering, Context Engineering, LLM, Skills Article text: 文章速览 《Harness工程》读书笔记,梳理上下文管理、规划执行、反思记忆、Skills、多 Agent 协作与 Agent 产品设计。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:AI 《Harness工程:从上下文管理到Agent系统构建》读书笔记。本书从上下文工程出发,依次讨论意图识别、规划、执行、反思、记忆、Skills、多 Agent 协作,以及 Claude Code、Claw 类产品背后的 Harness 设计思想。 但我始终坚信,无论模型如何迭代,Harness 工程作为连接模型与真实世界的桥梁,都将是未来 AI 应用工程师的核心竞争力。 一、核心观点:Agent = LLM + Harness 大语言模型提供推理与生成能力,但仅有模型并不足以构成一个能够稳定完成真实任务的 Agent。模型之外还需要一套工程系统,为它提供上下文、工具、记忆、权限、状态管理、任务调度和运行环境,这套系统就是 Harness。 可以用一个简单的公式概括三者之间的关系: Agent = LLM + Harness Harness 工程不直接干预模型内部如何思考,而是围绕模型构建一套可靠的外部支撑环境,使模型能够: - 理解用户真正想做什么; - 将复杂目标拆分为可执行步骤; - 调用工具与真实世界交互; - 根据执行结果调整后续行动; - 在长任务中保持状态和目标一致; - 在必要时引入人工审核; - 控制上下文规模,减少噪声和 Token 消耗。 因此,构建 Agent 的重点正在从“如何写出一个更复杂的 Prompt”,逐渐转向“如何为模型设计一个更好的工作环境”。 二、从提示词工程到上下文工程 传统的“提示—响应”模式在简单任务中已经足够,但进入复杂业务场景后会逐渐暴露问题: - 意图偏移:单条指令很难完整表达复杂业务背后的真实目标; - 长程记忆缺失:多轮交互后,模型容易遗忘最初的任务目标; - 逻辑断层:处理多步骤、跨领域任务时,缺少结构化引导容易导致执行失控。 上下文工程不只是编写 Prompt,而是管理所有进入模型上下文窗口的信息,包括: - 用户当前输入; - 业务意图与任务约束; - 历史对话和用户偏好; - 可使用的工具及其调用方式; - 工具调用返回的观察结果; - 当前计划、执行进度和中间状态; - 外部检索获得的知识; - 反思、审核与纠错信息。 目标不是把尽可能多的信息交给模型,而是确保进入上下文的信息准确、相关、结构清晰且足以支撑当前决策。 2.1 ReAct 本质上是动态构建上下文 ReAct(Reasoning and Acting)通过“推理—行动—观察”循环驱动任务执行: - 理解问题并进行推理; - 判断是否需要调用外部工具; - 选择工具并生成参数; - 获取工具执行结果; - 将观察结果加入上下文; - 基于更新后的上下文继续推理,或者输出最终答案。 从上下文工程的视角看,ReAct 的每一次行动都在产生新的观察,每一次观察又会成为下一轮决策的依据。Agent 的运行过程,本质上就是上下文不断构建、更新和收敛的过程。 2.2 Agent 的四个核心模块 无论 Agent 产品形态如何变化,其核心逻辑通常都可以归纳为四个模块。 感知:识别用户意图 感知模块是系统入口,负责把非结构化的自然语言转换为结构化任务,并将请求路由到合适的模型、工具或工作流。 规划:构建执行骨架 规划模块决定任务应当如何推进,常见模式包括: - ReAct:在推理、行动和观察之间循环,边执行边规划; - Plan-Act:先生成完整计划,或由人工注入 SOP,再按步骤执行; - Sequential Thinking:通过多轮思考、调整与反思处理复杂任务。 执行:获取实时信息 执行模块通过 API、代码执行器、文件系统或其他工具与外部世界交互。工具调用的结果会形成新的上下文片段,推动下一轮推理。 反思:检查并修正结果 反思模块检查规划和执行中是否存在逻辑错误、信息遗漏、工具误用或结果不完整,并在必要时触发重新规划和执行。 除上述四个模块外,一个完整的 Agent 系统还需要记忆管理,以维持多轮对话和长周期任务的连续性。 2.3 上下文不是越多越好 过多信息会带来上下文污染:工具描述、中间日志、历史对话、失败尝试和不同子任务的推理轨迹混在一起,模型反而难以抓住当前重点。 上下文管理的关键是卸载与按需加载: - 详细分析写入外部 Markdown 文件,主上下文只保留摘要和文件路径; - 暂时不需要的工具说明不进入上下文; - 历史工具调用结果经过压缩后再保留; - 专业知识和长期记忆在需要时检索; - 子任务放入独立上下文执行,只将最终结论返回主 Agent。 这是一种“引用而非堆砌”的设计思路。 三、Agent 设计模式的演进 3.1 Function Calling:让模型学会调用工具 Function Calling 通过结构化的工具交互数据,让模型学会: - 识别调用工具的时机; - 选择正确的工具; - 生成符合规范的参数; - 根据工具结果继续完成任务。 它让 LLM 从纯文本生成器迈向了可以采取行动的 Agent。不过,Function Calling 往往依赖模型训练、工具协议和推理引擎的协同,并非所有模型都原生支持。 3.2 ReAct:用提示词构造行动循环 ReAct 不依赖专门训练,只需要通过提示词描述任务目标、工具列表和交互格式,就可以让模型交替进行推理和行动。 它的优点是简单、灵活和容易实现,但也有明显局限: - 执行效果高度依赖模型的推理和指令遵循能力; - 复杂任务需要多次串行调用模型,延迟和成本较高; - 对提示词格式较为敏感; - 缺少全局视角时,容易陷入局部最优或重复循环。 3.3 CodeAct:代码即工具 CodeAct 是 ReAct 的重要衍生模式。它不再把工具限制为预先注册的业务 API,而是把 Python、Shell 等编程语言作为通用工具空间。 模型可以根据当前任务临时生成代码,再交给代码执行器运行。这样一来,工具不再是固定的黑盒函数,而是可以即时构造的行动。该模式适合: - 运维自动化; - 数据分析与清洗; - 文件批量处理; - 系统诊断; - 软件工程任务。 CodeAct 的核心思想可以概括为:将代码视为动作,而不是上下文中的普通文本。 3.4 Plan-Act:先谋而后动 Plan-Act 将规划和执行拆为两个阶段:规划 Agent 先对复杂目标进行全局拆解,执行 Agent 再严格按照计划推进。 真正的工程难点不只是生成计划,而是保证模型能够: - 知道当前执行到了哪一步; - 不跳过必要步骤; - 根据结果更新任务状态; - 明确判断任务是继续执行还是已经完成。 可以在每轮执行时注入结构化的历史步骤,例如 past_steps,并使用严格的结构化输出区分状态: 1 | {"actions": {"steps": ["下一步操作"]}} | 或者: 1 | {"actions": {"response": "最终结果"}} | 结构化输出使下游系统不必猜测模型意图,是工作流稳定运行的重要基础。 3.5 Reflection:让 Agent 检查自己的结果 反思模式通常由两个角色组成: - 执行器 LLM:生成初步结果; - 评判器 LLM:检查正确性、完整性、安全性和合理性。 系统通过“生成—评估—改进”的闭环不断提高结果质量。典型流程包括: - 生成节点产生结果; - 反思节点进行多维度审查; - 条件分支判断是否需要再次迭代; - 达到质量要求或迭代上限后结束。 反思不应是无限循环,工程实现中需要明确终止条件、重试次数和评价标准。 3.6 Human-in-the-loop:在关键节点保留人工控制 资金操作、生产变更、信息发布等高风险任务不适合完全自动执行。Human-in-the-loop 模式允许工作流在关键节点暂停,等待人工确认后继续。 以 LangGraph 为例,暂停与恢复依赖四个核心要素: - interrupt:主动挂起工作流; - memory:保存中断时的上下文状态; - checkpointer:记录执行进度和中断位置; - thread_id:标识并隔离不同会话或任务。 人机协作的关键不是简单地“弹出确认框”,而是保证暂停前的状态能够持久化,并在确认后从准确位置恢复。 3.7 多 Agent:分工背后是上下文隔离 多 Agent 系统通常由一个协调者 Agent 和多个专家 Agent 组成: - 协调者负责任务分解、调度、冲突处理和结果整合; - 专家 Agent 专注于某个领域或子任务。 多 Agent 的表面价值是专业分工和工具分类,更深层的价值则是隔离上下文窗口。子任务中的多轮试错、工具日志和中间推理都留在子 Agent 内部,主 Agent 只接收最终结果,从而减少上下文污染。 因此,多 Agent 系统不只是组织架构的变化,也是复杂任务下上下文工程的自然延伸。 四、意图识别与任务路由 成熟的 Agent 不能只理解用户字面上的问题,还需要结合当前对话、历史交互、用户画像和系统状态,推断背后的真实需求。 从工程角度看,意图识别通常是一个分类与路由问题:判断请求属于哪个领域、需要执行哪类操作,以及应该交给哪个模型、工具或流程。 4.1 领域与操作 路由系统可以将请求拆分为两个维度: - 领域(Domain):法律、医疗、编程、旅游等高层主题; - 操作(Operation):摘要、翻译、代码编写、图片编辑、预约等任务类型。 推理阶段根据输入的语义、关键词和上下文线索判断领域及操作,再按照预设规则选择下游能力。 可靠的路由输出需要满足三个条件: - 支持兜底:无法准确匹配时返回 other,不要强行分类; - 精准匹配:依据选项描述和用户最新输入进行判断; - 严格格式:输出稳定、可直接解析的 JSON。 例如: 1 | {"route": "other"} | 4.2 小模型分类,大模型兜底 通用路由模型面对金融、医疗、工业运维等垂直领域时,可能无法准确识别业务边界。书中给出了一种小模型与大模型并行的思路: - 使用企业自有标注数据微调小模型,处理高频、明确的业务意图; - 对低置信度、长尾或未覆盖请求,使用大模型通过零样本或少样本提示进行二次判断。 小模型负责低成本、高吞吐的常规分类,大模型负责复杂语义和长尾场景。 训练数据应覆盖: - 正样本; - 负样本; - 长尾意图样本。 训练方式可根据数据和算力条件选择全量微调或 LoRA。相比全量训练,LoRA 冻结原模型参数,仅训练额外的低秩模块,更适合资源有限和需要快速迭代的场景。 五、Deep Research:主动构建研究上下文 检索增强能力经历了从静态到动态、从被动到主动的演进: - RAG:从静态知识库检索资料并生成答案; - WAG:通过搜索引擎获取动态网络信息; - Deep Research Agent:主动规划研究路径,执行多轮搜索、交叉验证和迭代总结。 Deep Research 的关键不是“搜索一次然后总结”,而是让 Agent 根据当前证据判断信息缺口,再决定下一轮应搜索什么。研究过程本身就是一个持续构建和修正上下文的过程。 六、Agent 的长期记忆 上下文窗口中的历史信息属于短期记忆,但随着对话增长,不可能把全部历史永久保留在窗口中。 常见的记忆管理方式包括: - 滑动窗口:只保留最近若干轮对话; - 上下文压缩:将较早内容总结后继续保留; - RAG 长期记忆:将历史对话和用户画像向量化,存入外部数据库,需要时按语义召回。 6.1 Mem0 的记忆生命周期 Mem0 并非简单保存全部对话,而是通过提取、仲裁和持久化管理记忆。 第一步:提取结构化事实 利用 LLM 从原始对话中剔除闲聊,重点提取: - 个人偏好; - 姓名、关系、重要日期等基本信息; - 计划、目标和未来意图; - 餐饮、旅游、兴趣等服务偏好; - 健康与饮食限制; - 职业、工作习惯和专业目标; - 书籍、电影、品牌等其他长期偏好。 第二步:执行冲突仲裁 新事实进入记忆库前,需要和已有记忆比较,并执行四种原子操作之一: - ADD:新增有效信息; - UPDATE:更新已经变化的信息; - DELETE:删除矛盾或过期信息; - NONE:忽略重复或无效信息。 这一步决定了记忆系统能否长期保持一致,而不是不断积累互相冲突的数据。 第三步:混合存储 - 向量数据库解决语义相似性检索,适合模糊召回; - 图数据库保存实体关系,适合沿关系链进行逻辑推理。 向量检索回答“哪些记忆和当前问题相似”,图检索则回答“这些事实之间有什么关系”。两者结合可以提升复杂记忆查询的质量。 6.2 高并发记忆服务的缓存策略 在高频对话场景中,可以在 Qdrant 等向量数据库前增加 Redis 缓存热点记忆,并采用“写时失效、读时回填”的策略: - 写入记忆后,立即清除对应用户的 Redis 缓存; - 查询时优先读取 Redis; - 缓存未命中时查询向量数据库,并把结果回填到 Redis。 这种方式既减少热点读取压力,也避免返回已经过期的记忆。 七、Skills:上下文卸载的艺术 Skill 可以理解为一个标准化的专家经验包。它将完成特定任务需要的思考路径、执行流程、工具、参考资料和输出规范封装在独立目录中。 典型目录结构如下: 1 2 3 4 5 | skill-name/ ├── SKILL.md ├── scripts/ ├── references/ └── assets/ | 各部分职责为: - SKILL.md:必选,描述触发条件、目标、输入输出、执行步骤和示例; - scripts/:可选,保存 Python、Shell 等可执行脚本; - references/:可选,保存领域资料和参考文档; - assets/:可选,保存图片、配置模板和输出样例等静态资源。 7.1 渐进式加载 传统 Agent 初始化时如果加载全部工具描述,很容易浪费上下文窗口。Skills 通过三级加载降低信息噪声: - L1:元数据。初始阶段只加载名称和描述,让 Agent 知道有哪些 Skill; - L2:使用说明。确认某个 Skill 可能适用后,再读取 SKILL.md 正文; - L3:执行资源。任务真正需要时,才读取脚本、参考资料或模板。 没有被使用的资源始终留在上下文之外。尤其是 scripts/ 中的代码,可以由 Bash 等工具直接执行,只将执行结果返回给模型,而不必将整个脚本内容放入上下文。 7.2 如何写好 SKILL.md 高质量的 SKILL.md 应当回答五个问题: - 定时机:什么情况下应该触发这个 Skill? - 立目标:它要解决的具体问题是什么? - 理规则:输入如何处理,任务按什么步骤执行,输出遵循什么格式? - 给示例:正确用法和常见错误分别是什么? - 划边界:哪些任务不处理,信息不足或发生异常时如何兜底? Skill 的质量主要不取决于目录里堆了多少材料,而取决于是否把专家经验转化成了模型能够稳定遵循的操作手册。 八、Claude Code 中的 Harness 设计 书中将 Claude Code 作为编程领域 Harness 工程的典型案例。它的核心不是不断增加专用工具和复杂工作流,而是围绕模型的需求构建一个适合持续工作的环境。 主要体现在四个维度: - Agent Loop:让模型能够持续执行“观察—思考—行动”循环; - 上下文工程:通过压缩、筛选和隔离控制上下文噪声; - 持久化与异步:让长任务能够跨会话继续执行; - 多 Agent 系统:通过不同 Agent 实例实现协作和上下文隔离。 在本地编程场景中,只提供 Read、Write、Bash 等少量通用能力,也可以让模型组合完成复杂任务。相比堆砌大量业务工具,这种设计降低了中间层损耗,给模型保留了更大的自主决策空间。 8.1 SubAgent:隔离执行细节 在 SubAgent 模式中,Agent 本身也可以被视为一种工具: - 主 Agent 通过一次工具调用唤起 SubAgent; - SubAgent 在独立上下文中处理具体任务; - 任务完成后只返回结论和必要产物; - 子任务中的日志、试错和中间推理不会回流到主上下文; - 生命周期结束后,SubAgent 的临时上下文随之销毁。 主 Agent 因而可以专注于任务下发、全局协调和结果验收。 8.2 Compact:三级上下文压缩 书中给出了一种三级压缩思路: - 每轮对话开始前,压缩较早的工具调用及返回结果; - Token 超过阈值时,将完整历史保存到磁盘,再由 LLM 生成摘要替代原始历史; - 为 Agent 提供 Compact 工具,使模型在判断上下文过长时可以主动触发压缩。 压缩的目标不是简单删除历史,而是在减少 Token 的同时保留继续完成任务所需的目标、约束、关键决策和当前状态。 8.3 从 Todo List 到 Task Graph 简单的 Todo 模式可以让模型生成待办列表,并保证同一时间只执行一个步骤。但扁平列表存在两个问题: - 无法表达任务之间的依赖和并行关系; - 状态只保存在内存中,压缩上下文或进程退出后可能丢失。 Task System 将列表升级为有状态的任务图: - 节点表示任务; - 边表示前置依赖; - 状态持久化到磁盘; - 支持串行、并行和跨会话恢复。 这使任务生命周期不再受限于单次上下文窗口,是 Agent 处理长周期软件工程任务的重要基础。 九、Claw 类产品:把 Agent 接入日常工作入口 Claw 类产品的重要价值是进入用户最常使用的接口层。Agent 接入飞书、钉钉等即时通信软件后,便可以作为持续在线的数字员工,用户通过私聊或群聊中的 @ 即可下达任务。 定时任务机制进一步让 Agent 从“被动响应”走向“自主调度”:它可以记录未来计划,并在指定时间主动执行,而不必等待用户实时触发。 NanoClaw 的架构可以概括为“一内一外”: - 内层:运行在 Docker 容器中的核心 Agent,负责推理和任务执行; - 外层:运行在容器之外的通信与调度机制,负责 IM 消息接入、定时触发和生命周期管理。 这种分层将 Agent 的推理执行环境与外部通信、调度能力解耦,既便于隔离权限,也便于独立扩展不同的用户入口。 十、读后总结 这本书最重要的启发,是把 Agent 开发从“模型能力崇拜”拉回到了系统工程。 一个可靠的 Agent 系统至少需要处理好以下问题: - 用意图识别确定用户真正需要什么; - 用计划和任务状态保证复杂目标持续推进; - 用工具与代码连接真实世界; - 用反思和人工审核控制错误风险; - 用短期记忆和长期记忆维持连续性; - 用 Skills 实现知识与能力的按需加载; - 用 SubAgent 隔离复杂子任务产生的上下文噪声; - 用压缩、外部文件和检索控制上下文规模; - 用持久化任务图支撑跨会话、长周期执行; - 用权限、容器、通信和调度系统构成模型的运行边界。 最终决定 Agent 能否进入真实生产环境的,往往不只是模型在单次回答中的聪明程度,而是 Harness 能否让它在长时间、多步骤、有风险的任务中保持可靠、可控、可恢复和可观测。 注:本文为邢云阳《Harness工程:从上下文管理到Agent系统构建》的个人读书笔记,内容依据阅读标注整理。 ### 什么样的网关,才算好的 AI 网关? - URL: https://jiankunking.com/how-to-judge-an-ai-gateway-from-boundaries-to-scorecard.html - Content type: original - Published: 2026-07-11 - Updated: 2026-07-11 - Summary: 从网关基本功、AI 流量理解、运行时性能和工程治理四个维度,建立可量化的 AI Gateway 选型评分框架。 - Categories: AI - Tags: Performance, Gateway, AI Gateway, AI, LLM, LLMOps, Observability, Traffic Governance Article text: 文章速览 从网关基本功、AI 流量理解、运行时性能和工程治理四个维度,建立可量化的 AI Gateway 选型评分框架。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:AI 这篇不做具体产品排名,只回答一个问题:怎么判断一个 AI 网关好不好。 我的结论是:好的 AI 网关不是功能最多的那个,而是能在你的生产边界里长期稳定运行的那个。它至少要同时满足四件事: - L7 网关基本功扎实:代理、路由、LB、超时、TLS、限流、认证、观测都不能短板明显; - 真正理解 AI 流量:能处理模型路由、body 改写、token 治理、SSE 流式、协议转换、Agent / Tool Call; - 性能和运行时行为可证:打开 AI 插件后,CPU、内存、GC、p99、流式背压和取消处理都经得起压测; - 生产成熟度足够:核心能力可用、可运维、可回滚、可观测,有足够生产背书。 没有绝对最好的 AI 网关,只有最适合当前团队边界的网关。模型平台、日志、告警、安全旁路都成熟时,网关应该“薄而快”;这些能力还不完整时,网关可以“厚而全”,但必须接受数据面复杂度上升,并用压测证明这份复杂度是可控的。 本文适合用来做 AI 网关选型、现有网关评估,或者自研网关能力拆解;不适合当某个产品的安装教程,也不讨论个人转发代理。 导读:先给结论 一句话模型 1 | 好的 AI 网关 = 合格 L7 网关 + AI 流量能力 + 性能可证 + 生产成熟度 | 这四项缺一不可: 层级 关键词 判断重点 L7 地基 | 代理、路由、LB、超时、TLS、限流、认证、观测 | 普通网关基本功是否扎实 | AI 能力 | body 路由 / 改写、Token 治理、SSE、协议转换、Agent | 是不是真的理解 LLM / Agent 流量 | 性能与运行时安全 | JSON codec、SSE flush、背压、yield、GC、p99 | 打开 AI 能力后,网关自己会不会成为瓶颈 | 生产成熟度 | GA、生产背书、核心能力可用、可运维 | 能不能长期跑在生产核心链路上 | 职责边界先于功能对比 选型前先回答一个问题:哪些能力必须放在网关,哪些能力应该放在模型侧或旁路系统? 模型侧 / 旁路家底 适合的网关形态 网关重点职责 厚:模型平台、日志、告警、安全旁路都成熟 | “瘦”网关 | 路由、鉴权、基础限流、轻量观测、低额外开销 | 薄:裸跑 vLLM 或主要依赖第三方 API | “厚”网关 | 可用性兜底、日志富化、内容审查、协议转换、成本治理 | 核心取舍是:稳定性、灵活性、性能很难同时拿满。网关越厚,能力越强,但数据面越重,故障面也越大。能放到模型侧或旁路的能力,不要因为“网关能做”就全部塞进去。 选型三步法 步骤 做什么 目的 ① 划边界 | 明确网关、模型平台、日志系统、安全系统各自负责什么 | 避免把所有复杂度都压到网关 | ② 过底线 | 用第 5 章的一票否决线先筛 | 排掉不适合生产核心链路的方案 | ③ 打分 | 按第 7 章对照 P0 / P1 需求评分 | 找到最适合自己的,而不是功能最多的 | 1. AI 流量到底特殊在哪 普通 API 多数无状态、体积小、结构固定;AI 流量则同时具备“请求体有语义、响应可能长时间流式、成本按 token 计算、后端异构且不稳定、Agent 可能触发副作用工具调用”等特点。 AI 流量特征 对网关的新要求 请求 / 响应体有语义 | 能读懂并改写 model、messages、max_tokens、stream 等字段 | 响应常是 SSE 流式 | 限流、审查、观测、协议转换、断开处理都要能在流上工作 | 成本按 token 算 | 不能只看 QPS,还要看 TPM、TTFT / TPOT、token 归因和预算 | 后端异构且容易限流 / 挂掉 | 需要多 Provider 协议适配、fallback、健康检查和降权 | Agent 会触发工具调用 | trace、权限、审计、幂等和成本要能跨模型调用与工具调用串起来 | 所以,只会改 URL 和 header、把 body 当黑盒透传的,本质上还是普通网关;真正的 AI 网关必须理解 body、stream、token、provider、tool call 这些运行时语义。 2. AI 网关能力图谱 把能力摊开,一个 AI 网关大致有十类能力。注意:这是能力图谱,不代表所有能力都必须由网关实现。 能力域 解决的问题 典型能力 管理与运维面 | 能不能系统化管理 | REST API、CRD、GitOps、证书自动更新、配置热更新、插件热加载 | 基础网关数据面 | 是不是合格 L7 网关 | HTTP 代理、统一入口、连接复用、超时、TLS、路径重写 | 性能与运行时安全 | 高并发、大 body、流式场景下是否稳 | 额外 CPU、JSON decode / encode 次数、SSE flush、背压、yield、公平性、内存与 GC | AI 路由与负载均衡 | 请求该打到哪里 | model 路由、body 条件路由、会话亲和、权重 + 优先级、多供应商混合 | Fallback 与高可用 | 后端异常能否兜底 | 429 / 5xx / 超时 fallback、健康检查、自动摘除 / 恢复、告警降权 | 请求 / 响应改写与协议适配 | 屏蔽后端差异 | 模型名映射、认证注入、body 字段注入、max_tokens 管控、OpenAI / Anthropic / Gemini / Bedrock 转换 | 流量治理与成本 | 管 token、并发和钱 | RPM、TPM、组合限流、实例限流、token 统计、预算配额、语义缓存 | 可观测性与审计 | 出问题能否查清楚 | OTel、结构化日志、Prometheus、TTFT / TPOT、trace、upstream、risk level | 安全与内容治理 | 请求和内容是否安全 | 消费者认证、凭证托管、请求体限制、双向审查、多模态审查、PII 处理 | 扩展性与生态 | 能否持续扩展 | 插件机制、执行顺序、外部审查服务、AlertManager、Kafka / ES / HTTP sink | 几个容易误判的点: - 性能不能只看 hello world QPS:要看 AI 插件打开后的额外 CPU、内存分配、GC、JSON 编解码次数,以及 SSE 小 chunk、高速上游、慢下游、客户端断开这些运行时边界。 - 流式能力不能只看 SSE 透传:真正要看的是限流、审查、观测、fallback、协议转换、客户端断开处理这些能力,在流式请求里是不是同样可用。 - 协议转换属于高级改写:只转非流式请求不够,还要处理 SSE、错误码、tool call、usage 和 provider 特有字段。 - 可观测不是无限记录正文:body 日志越完整,合规风险越高,必须有脱敏、采样、敏感字段过滤和过期清理。 - 多地域不是天然高可用:部署多个地域不够,还要看路由、探测、切换、日志、告警和恢复是否闭环。 3. 核心能力域:合格线 / 优秀线 3.1 路由与负载均衡 合格线:能按 model 路由,支持加权负载均衡和基础 fallback;普通 HTTP 请求也能透传,不能强行按 Chat Completions 解析。 优秀线:能基于 body 做条件路由,比如带图片走多模态实例、长 prompt 走大上下文模型;支持权重 + 优先级、会话亲和,以及按 429、5xx、超时分别配置 fallback。 流式场景要注意 fallback 边界:首 token 前切换相对安全;一旦开始向客户端输出,再切后端可能导致重复输出、上下文断裂或重复计费。tool call 还要区分是否有副作用。 3.2 性能与运行时安全 性能是 AI 网关最容易被低估的能力。很多产品会展示普通 HTTP 转发 QPS,但真正上生产后,瓶颈往往来自 AI 插件打开后的额外工作:读取 body、解析 JSON、改写字段、流式解析、内容审查、日志富化、token 统计、协议转换。 合格线:能拿出面向 AI 场景的压测结果,而不是只给普通 HTTP hello world QPS。压测至少覆盖: - body 路由 / 改写; - 32 KB、128 KB、1 MB 等不同大小请求体; - SSE 流式转发; - 上游快速返回小 chunk; - 下游慢消费; - 客户端中途断开; - 内容审查、日志、token 统计等插件组合。 指标至少包括 worker CPU、RSS、Lua / runtime GC、p95 / p99 latency、TTFT、TPOT、token throughput 和火焰图。 优秀线:性能工程成为产品能力的一部分。关键热路径知道每个请求会做几次 JSON decode / encode;只读 model、stream 等少数字段时能走 raw get fast path;只覆盖 model、temperature、stream 等少数字段时能走 raw patch fast path;复杂协议转换、RAG、深度改写时再 fallback 到 full decode / encode。SSE 循环有明确的背压、flush、取消和 yield 策略,每次版本升级都有性能回归测试。 这里重点看两个坑。 第一,“看起来是 IO”不等于一定会让出 CPU。 在 OpenResty / Nginx 这类协作式调度模型里,如果上游 socket buffer 里一直有数据,body_reader() 可能近似变成内存拷贝;如果 ngx.flush() 又没有形成有效背压,循环就可能长时间不 yield,单个 SSE 请求也可能把一个 worker 打满。这类问题靠读静态逻辑很难发现,必须把“上游快速返回 + 下游慢/断开 + 小 chunk 高频 flush”放进压测矩阵。可参考之前的复盘:运行时行为盲区:API7 AI 网关 CPU 打满故障的 AI 辅助事后复盘。 第二,body JSON 处理不是免费能力。 AI 网关经常只为了 post_arg.model 路由、协议检测或覆盖 model/temperature/stream 这几个字段,就把整个 messages 做 full decode,甚至再 full encode 一遍。对于大 prompt、多轮消息、多模态 body,这会直接变成 worker CPU、内存分配和 GC 压力。更合理的策略是:热点路径 raw get / raw patch,复杂场景保留 full decode / encode,并用火焰图验证 cjson.decode、encode 是否真是热点。 一个可执行的性能验收线可以这样定: 场景 最低验收问题 建议指标 post_arg.model 路由 | 只读一个字段时,是否还 full decode 整个 body | 32 KB+ body 下 worker CPU 至少可见下降 | AI passthrough + 少字段覆盖 | 是否避免 decode + encode 整个请求体 | decode / encode 火焰图占比明显下降 | SSE 快速小 chunk | 循环在 buffer 就绪时是否会长期不 yield | 单请求不能独占 worker,p99 不拖垮同 worker 请求 | 客户端取消请求 | 下游断开后是否尽快停止读上游和解析 JSON | wasted CPU、上游取消耗时、499 / 断开日志 | 内容审查 / 日志 | 打开插件后额外开销是否可解释 | CPU、RSS、GC、外部调用耗时分开统计 | 性能的核心不是“这个网关用什么语言写”,而是:打开你真正要用的 AI 能力后,数据面额外做了多少工作、最坏情况下会不会失去调度公平性,以及这些开销是否能被压测和火焰图证明。 3.3 请求 / 响应改写与协议适配 合格线:路径重写、header 注入、后端鉴权注入、模型名映射可配置。 优秀线:body 字段注入能区分“缺失才补”和“强制覆盖”;改 body 时尽量保持字段顺序;OpenAI、Anthropic、Gemini、Bedrock 等协议能互转,并正确处理 tool、错误码、usage 和流式格式。 一个简单判断:body 改写最好配置即可完成。如果每个场景都要写插件,说明产品抽象还不够。 3.4 流量治理与成本 合格线:消费者级 RPM、并发限流,以及 SSE 透传。 优秀线:按 token 限流(TPM),支持消费者 × 模型、租户 × 模型、路由级、实例级等组合维度;能做费用归因、预算配额、超限告警或熔断。 语义缓存可以省钱和降延迟,但要处理命中错误、权限隔离、时效性和审计问题。对企业场景来说,缓存命中错了比缓存没命中更危险,不能无脑上。 3.5 可观测性 合格线:接入 OpenTelemetry,有结构化 access log,body 日志可按租户 / 路由开关。 优秀线:TTFT、TPOT 这类延迟指标用 histogram 看 P90 / P99;token 总量能按模型、消费者、项目归因;日志覆盖模型映射、token、upstream、trace、风险等级等关键字段,并能推 Kafka、ES、HTTP、OTLP。 完整 body 日志必须支持脱敏、采样、敏感字段过滤和过期清理,否则观测越强,合规风险越大。 3.6 健康检查与高可用 合格线:被动健康检查,连续失败自动摘除。 优秀线:主动健康检查能 POST 真实推理请求,而不是只 GET /health。LLM 后端常见问题是“进程活着但推理不可用”,浅层探测发现不了。更进一步,是告警联动降权和恢复:告警触发时实例降权,恢复后自动加回流量。 3.7 安全:认证授权和内容审核 合格线:消费者认证、API Key 管理、后端凭证托管、请求体大小限制。 优秀线:JWT / OAuth、RBAC、多租户隔离;消费者身份能贯通到限流和观测;内容审查支持请求 / 响应双向、流式生效、按消费者或路由差异化配置,并能接外部服务。 3.8 Agent / Tool Call 流量 如果只服务普通 Chat Completions,这一项是加分项;如果要支撑 Agent,就是必答题。 Agent 一次用户请求可能触发多次模型调用和工具调用。网关至少要能串起 conversation id、tool call id、trace id;对工具调用做权限、审计、幂等和成本归因。发邮件、下订单、改配置这类工具调用,重试一次就可能变事故。 3.9 扩展性与可运维性 合格线:插件机制、配置热更新、完整管理接口(REST API、CRD 或 GitOps)。 优秀线:扩展点顺序可控,插件和策略可热加载,证书能自动续期,声明式和命令式管理都能支持;关键配置变更可审计、可回滚、可灰度。 4. 厚网关与薄网关怎么选 很多争论不是“哪个网关更好”,而是没有先说清职责边界。 适合薄网关的情况 如果你已经有成熟模型平台、统一日志、告警、安全审查、成本归因和调度系统,网关就不必承担太多 AI 逻辑。此时优先级应该是: - 数据面稳定、低额外开销; - 路由、鉴权、基础限流、轻量观测可靠; - 与现有平台边界清晰; - 不把复杂协议转换、深度审查、重日志处理塞到请求热路径。 适合厚网关的情况 如果你的模型侧和旁路系统还很薄,比如主要依赖第三方 API、裸跑 vLLM,或者暂时没有统一 LLMOps 平台,那么网关可以承担更多能力: - 多 provider 协议适配; - fallback、熔断、降权、主动健康检查; - 请求 / 响应内容审查; - token 统计、预算、成本归因; - 日志富化和告警集成。 但厚网关必须付出性能验证成本。越多逻辑进入数据面,越要证明它在大 body、流式小 chunk、客户端断开、内容审查和高 QPS 下不会成为新的故障源。 5. 五条一票否决线 有些短板不是扣分,而是直接不适合上生产核心链路。 - L7 地基不扎实:普通 HTTP 代理、路由、LB、超时、TLS、限流、认证、观测都不稳,却宣称自己是 AI 网关,这类要谨慎。 - 数据面性能不可证或运行时行为不安全:Rust / Go / Envoy / Nginx 这类成熟数据面更稳,但语言和框架不是免死金牌。必须用真实 AI 工作负载证明额外开销可控:body 路由 / 改写是否反复 JSON decode / encode,SSE 小 chunk 是否会忙等,客户端断开后是否继续空耗 CPU,打开内容审查 / 日志后 worker CPU、RSS、GC 和 p99 是否仍可接受。 - 成熟度不够:pre-1.0、没有生产案例、社区太小、升级路径不清晰、历史严重问题没有透明修复记录,都要大幅打折。 - 核心能力不可用:body 条件路由、模型级 fallback、TPM 限流、语义审查等核心能力如果全锁企业版,开源版价值会很有限;如果企业版也缺少关键能力,就更不适合核心链路。 - 没有可信主动健康检查:只会被动摘除或 GET /health,很难发现“服务活着但推理不可用”。AI 后端需要真实推理探测,至少要能按模型、路由、实例维度发现异常并联动降权 / 恢复。 6. 好坏对照速查 快速判断一个网关有没有明显短板,可以先看这些高风险点。 快速判断点 平庸的做法 好的做法 L7 地基 | 只能转 AI 请求,普通 HTTP 能力薄 | 普通 L7 网关能力完整,AI 能力是增强而不是替代 | body 能力 | body 当黑盒,只改 header / path | 能基于 body 路由、改写、注入字段,并尽量保序 | 性能证据 | 只给普通 HTTP QPS 或宣传语言 / 框架 | 给出 AI 插件开启后的 CPU、RSS、GC、火焰图和 p99 数据 | JSON 热路径 | 为读 / 改少数字段反复 full decode / encode | 热点字段 raw get / patch,复杂场景 fallback | 流式链路 | 只有非流式路径完整 | 限流、审查、观测、协议转换、断开处理都能在流上跑 | 流式运行时 | 上游快、下游慢或断开时行为说不清 | 有背压、flush、yield、公平性和取消策略 | fallback | 只按状态码重试 | 区分异常类型、首 token 前后、实例限流和 tool call 副作用 | 指标与日志 | 只有平均延迟和请求数 | TTFT / TPOT 看分位数,token、模型、消费者、上游、trace 都能归因 | 健康检查 | 被动摘除或 GET /health | 主动 POST 真实推理探测,支持自动摘除 / 恢复 | Agent / Tool Call | 当普通 HTTP 请求转发 | trace、权限、审计、幂等和成本能串起来 | 生产成熟度 | pre-1.0,性能不可证 | GA、有生产背书、关键能力可用,额外开销可压测 | 7. 打分清单(权重合计 100%) 下面是通用权重,实际用时应该按团队 P0 / P1 调整。比如你已经有成熟安全旁路,就降低“内容治理”权重;如果你大量使用 SSE 和多轮长上下文,就提高“性能与运行时安全”权重。 A. 基础网关能力(10%) - 普通 HTTP 代理 / 路由也能用,不是只能绑 AI provider - 路径重写、header 注入、后端鉴权注入 - 连接复用、路由级超时、TLS 校验可控 - 限流、认证、基础观测能力完整 B. 性能与运行时安全(15%) - 有 AI 场景压测,而不是只有普通 HTTP QPS - 清楚热路径每个请求会做几次 JSON decode / encode,能用火焰图证明 - 只读 / 只改少数字段时有 fast path 或明确优化计划 - SSE 小 chunk、客户端断开、下游慢消费时不会忙等或独占 worker - 观测 worker CPU、RSS、GC、p95 / p99、TTFT / TPOT 和 token throughput C. 路由与负载均衡(12%) - model 字段路由,权重 + 优先级负载均衡 - 能基于 body 内容做条件路由 - 支持会话亲和、实例级限流和供应商混合 - fallback 能分异常类型,并处理流式阶段和 tool call 副作用 D. 请求 / 响应改写与协议适配(12%) - 字段注入分得清兜底和覆盖 - 字段顺序尽量保持,避免无意义重排导致签名 / 缓存 / 审计问题 - 模型名映射和多 Provider 协议适配可用 - 错误码、流式格式、tool call 字段、usage 能正确转换 E. 流量治理与成本(13%) - TPM 限流,支持任意维度组合 - 成本归因和预算配额,超限可告警 / 熔断 - SSE 全链路支持,不只支持非流式请求 - 语义缓存有权限隔离、时效性和审计边界 F. 可观测性(13%) - TTFT / TPOT 用 histogram,能看 P90 / P99 - token 可按模型、消费者、项目、上游实例归因 - 结构化日志字段足够全,能推多种 sink - body 日志支持脱敏、采样、按租户 / 路由开关和过期清理 G. 健康检查与高可用(10%) - 被动 outlier detection 和主动健康检查都支持 - 主动探测能 POST 真实推理请求 - 能按模型、路由、实例维度摘除和恢复 - 告警集成,实例能联动降权和恢复 H. 安全与合规(10%) - API Key / JWT / OAuth / RBAC,后端凭证托管 - 请求和响应双向内容审查,含流式 - PII、prompt 注入、多模态审查能接外部服务 - 多租户隔离、审计和密钥生命周期可控 I. 扩展性与可运维性(5%) - 插件机制、扩展点顺序、策略热加载可控 - REST API / CRD / GitOps 至少一种管理方式顺手 - 证书、配置、密钥生命周期可自动化 - 关键变更可审计、可灰度、可回滚 底线项:一票否决,不算分但必须过 - L7 地基扎实 - 数据面性能可证,且流式运行时行为安全 - 成熟度够:GA、有生产案例、社区活跃或商业支持可靠 - 核心能力可用,或已明确接受企业版成本 - 有可信主动健康检查,能证明模型实例真的可推理 8. 最后 判断 AI 网关,不要上来就比功能列表长度。正确顺序应该是: - 先划边界:哪些能力放网关,哪些能力放模型平台、日志系统、安全系统; - 再过底线:L7 地基、性能、成熟度、核心能力、主动健康检查是否过关; - 最后打分:按自己的 P0 / P1 需求调整权重。 模型侧、日志、告警、安全旁路都强,网关就该薄一点,把稳定性和性能放前面;模型侧家底薄,网关就要承担更多协议适配、可用性兜底、内容治理和成本控制,但也要接受数据面复杂度上升。 越厚的 AI 网关,越要把性能当成一等能力:不仅要会转发,还要能证明自己在大 body、SSE 小 chunk、客户端断开、内容审查和高 QPS 下不会成为新的瓶颈。 最终一句话:好的 AI 网关不是最厚的,也不是最炫的,而是在清晰职责边界内,功能、性能和生产稳定性都能闭环的那个。 2026-07-11 | 依据:主流 AI 网关源码 / 文档核验 + 落地经验 ### 运行时行为盲区:API7 AI 网关 CPU 饱和故障的 AI 辅助复盘 - URL: https://jiankunking.com/runtime-behavior-blind-spots-an-ai-assisted-post-mortem-on-api7-ai-gateway-cpu-saturation-bugs.html - Content type: original - Published: 2026-05-16 - Updated: 2026-08-17 - Summary: 代码逻辑正确,不代表运行时行为正确。本文复盘 API7 AI 网关连续出现的 CPU 饱和问题,以及如何借助 AI 扩展排查思路,再用源码、PR 和实验约束 AI 的结论。 - Categories: Debugging - Tags: CPU-Saturation, APISIX, AI Gateway, OpenResty, Lua, Coroutine, Backpressure, SSE, AI-Assisted-Debugging Article text: 文章速览 代码逻辑正确,不代表运行时行为正确。本文复盘 API7 AI 网关连续出现的 CPU 饱和问题,以及如何借助 AI 扩展排查思路,再用源码、PR 和实验约束 AI 的结论。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Debugging 代码逻辑正确,不代表运行时行为正确。本文复盘 API7 AI 网关连续出现的 CPU 饱和问题,以及如何借助 AI 扩展排查思路,再用源码、PR 和实验约束 AI 的结论。 一、问题背景 最近在使用 API7 AI 网关时,我反复遇到 worker CPU 打满的问题。相关修复分散在多个 APISIX PR 中: 问题状态说明 - 问题待修复 / 已知缺陷 - 已反馈作者定位原因并通过邮件工单反馈给 API7 - 主要原因显著增加 CPU 或资源消耗的问题 - 设计取舍实时性、公平性与吞吐之间的权衡 时间 问题与修复 解决的核心问题 2026-04-18 ~ 2026-04-20 | PR #13254 | 下游客户端断开后,通过同步 flush 的错误传播终止上游流 | 2026-04-19 ~ 2026-04-20 | PR #13255 已反馈 | 在流式循环中增加显式调度点,避免单个请求长期占用 worker | 2026-05-12 ~ 2026-05-13 | PR #13356 已反馈 | 缓存 post_arg.* 的请求体解析结果,避免重复 decode | 2026-05-15 ~ 2026-05-18 | PR #13377 已反馈 | 缓存 AI 请求体的 JSON 解析结果,消除重复 decode | 2026-05-19 ~ 2026-05-22 | PR #13391 | 优化 SSE 解析和 flush 策略,并增加流式请求保护机制 | 最初我试图找到一个可以解释全部现象的“根本原因”,后来发现这种归因方式本身就是误区。这几个 PR 实际上分别处理了四类问题: - 取消传播:下游已经离开,上游工作是否还在继续; - 调度公平性:一个协程是否长时间占用 worker; - 单位工作成本:每个请求、每个 chunk 做了多少解析、复制与分配; - flush 与背压:追求逐 token 实时输出时,需要付出多少系统调用和调度成本。 它们会互相放大,但不是同一个问题,也不能用同一个修复解决。 本文最重要的结论:yield 解决公平性,缓存和批处理降低 CPU 成本,取消传播回收无效工作,背压约束生产者与消费者的速度差。 二、为什么静态阅读代码时没看出来 当时阅读代码时,我主要在检查: - 分支是否正确; - 错误是否处理; - 数据是否能完整转发; - 循环是否有退出条件。 从这个角度看,下面的流式循环没有明显错误: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | while true do local chunk, err = body_reader() if not chunk then break end local ok, flush_err = send_to_downstream(chunk) if not ok then close_upstream(flush_err) return end ngx.sleep(0) end | 但运行时审查需要继续追问: - body_reader() 这一轮会等待,还是立即返回? - send_to_downstream() 会等待,还是只把数据放进缓冲区? - 每轮循环创建了多少临时字符串和 Lua 对象? - 一秒可能执行多少轮? - 下游断开后,谁负责终止上游? - 如果上游持续高速输出,有没有时间、字节数或速率预算? 因此,真正的盲区不是“没有看懂 while 循环”,而是默认了几个未经验证的运行时假设: 1 2 3 4 5 | 调用了网络 API ≠ 本轮一定发生 I/O 等待 调用了 flush ≠ 客户端应用已经收到数据 代码中有 yield ≠ 总 CPU 消耗一定下降 客户端已断开 ≠ 上游工作自动停止 单轮逻辑很轻 ≠ 高频执行后的总成本很低 | 三、问题一:下游取消后,上游为什么还在运行 3.1 上游连接和下游连接是两条独立链路 AI 网关位于客户端与大模型之间: 1 | 客户端 <-- downstream --> API7/APISIX <-- upstream --> LLM | 网关从上游读取响应,并不意味着它同时知道下游连接的最新状态。 如果客户端关闭浏览器、取消请求或发送 TCP RST,网关仍可能继续: - 从 LLM 读取数据; - 解析 SSE; - 执行响应过滤器; - 尝试向下游输出。 只有当下游关闭事件被 Nginx/OpenResty 观察到,并传播到当前 Lua 请求处理逻辑后,代码才有机会停止上游工作。 3.2 ngx.flush(true) 到底保证什么 OpenResty 对 ngx.flush(wait?) 的定义是: - ngx.flush(false):发起异步 flush,不等待输出写入系统发送缓冲区; - ngx.flush(true):等待输出写入系统发送缓冲区,或等待到 send_timeout/错误发生。 这里必须避免一个常见误解: ngx.flush(true) 等待的是数据进入系统发送缓冲区,不等于客户端应用已经收到或消费了数据。 同步 flush 的价值在于,它会以可等待的方式推进下游输出。当写入路径已经观察到连接错误时,调用可以返回失败,APISIX 随后关闭上游响应,避免继续处理已经无人接收的数据。 这也是 PR #13254 处理的核心问题。 更准确的时序是: 1 2 3 4 5 6 7 8 9 | T0 客户端取消请求 ↓ T1 内核/Nginx 在某个时刻观察到连接关闭 ↓ T2 网关推进下游输出或收到连接关闭事件 ↓ T3 flush/write 返回错误 ↓ T4 APISIX 终止循环并关闭上游连接 | 这个过程不是“客户端一断开,Lua 代码就立即知道”,也不能理解为“每次 ngx.flush(true) 都能百分之百实时探测客户端存活”。 OpenResty 还提供 lua_check_client_abort on 和 ngx.on_abort 用于监控下游提前关闭。该机制默认关闭,也有自己的适用条件和运行成本。工程上需要根据请求模型选择: - 通过输出错误传播终止上游; - 显式监听 client abort; - 或组合使用两者。 3.3 这一问题的本质 这个问题首先是取消传播和资源生命周期管理问题,而不是单纯的调度问题: 1 2 3 4 5 6 7 | 下游请求已经没有价值 ↓ 上游请求没有及时取消 ↓ 继续读取、解析、过滤和 flush ↓ 产生无效 CPU、网络和连接消耗 | 代码审查时应当把双向代理看成两个生命周期不同的资源,而不是一个天然同步结束的请求。 四、问题二:协作式调度与 ngx.sleep(0) 4.1 cosocket 调用不一定发生 yield OpenResty worker 通常以单线程事件循环运行。Lua coroutine 只有在执行到可让出的操作时,才会把控制权归还给 Nginx 调度器。 cosocket API 是非阻塞的,但“调用了 cosocket”不等于“本次调用一定 yield”: 1 2 3 4 5 6 7 8 | socket 暂无数据 → 注册读事件 → 当前 coroutine yield → worker 可以处理其他事件 socket 缓冲区已有数据 → receive() 立即返回 → 当前 coroutine 继续执行 | 当上游 LLM 以突发方式返回大量小 chunk 时,连续多次 body_reader() 都可能立即完成。若循环中没有其他显式调度点,一个请求就可能连续占用 worker 较长时间。 4.2 ngx.sleep(0) 是真实的调度让出 PR #13255 在每轮流式处理后加入了: 1 | ngx.sleep(0) | 它会通过零延时 timer 把控制权交还给 Nginx 调度器。因此它不是“形式上让出,实际上没让”,而是一个真实的调度点。 它能解决的是: - 防止一个请求无限连续执行; - 给健康检查、定时任务和其他请求运行机会; - 改善同一 worker 内的尾延迟和调度公平性。 它不能解决的是: - 减少 JSON/SSE 解析次数; - 减少每个 chunk 的字符串分配; - 限制单条流每秒处理多少 chunk; - 限制上游总响应字节数; - 对快速上游实施背压; - 保证 worker CPU 下降。 因此,Issue #13256 和后续代码把它称为 workaround 是合理的:它防止单请求垄断 worker,但不限制单请求消耗多少 CPU。 4.3 为什么增加 yield 后 CPU 仍可能是 100% 假设一条流每秒产生大量小 chunk,每个 chunk 都需要解析、转换和输出: 1 2 3 4 5 6 7 | 处理 chunk A → ngx.sleep(0),归还调度权 → 处理其他 ready 任务 → 再次调度当前请求 处理 chunk B → ngx.sleep(0) → …… | 调度是公平的,但只要 ready task 足够多,或者当前流持续有工作,worker 仍然可以始终处于 runnable 状态,CPU 使用率自然可能接近 100%。 这时需要区分两个指标: 指标 关注的问题 调度公平性 | 其他请求是否得到运行机会,尾延迟是否失控 | CPU 利用率 | 所有请求加起来需要执行多少工作 | ngx.sleep(0) 主要改善前者,不直接降低后者。 如果 yield 时没有其他 ready coroutine,当前请求随后再次运行是正常现象;此时没有其他任务正在被“饿死”。当其他任务变为 ready 时,显式 yield 才为它们提供调度机会。 五、问题三:重复 JSON decode 放大单请求成本 5.1 post_arg.* 的重复解析 表达式系统在多次访问 post_arg.* 时,如果每次都重新读取并解析请求体,就会重复执行相同工作。 PR #13356 通过缓存解析结果,避免同一个请求在表达式匹配过程中多次 decode。 这一问题的特点是: - 单次调用看起来成本可接受; - 规则数量增加后会线性放大; - 请求体越大,重复解析越昂贵; - 它发生在请求处理阶段,与流式响应循环不是同一个问题。 5.2 AI 请求路径中的三次 decode PR #13377 表明,APISIX AI 请求路径曾对同一个 JSON 请求体执行三次 decode。缓存解析后的 Lua 对象,可以消除其中的重复解析。 这里需要准确描述“无效工作”: - 在当前设计下,至少一次 JSON decode 通常是必要的; - 可以消除的是后续重复 decode; - 将修改后的请求发送给上游时,JSON encode 可能是必要步骤,不能在没有证据时和重复 decode 一起定义为无效开销。 因此,更严谨的结论是: 重复 JSON decode 是已经由源码和 PR 验证的 CPU 放大因素;它是否是某次生产 CPU 饱和的唯一根因,还需要结合线上 profile、请求体大小和修改前后对照数据确认。 5.3 为什么代码审查容易漏掉 开发者通常会在每个函数内部判断复杂度,却不容易注意同一份数据跨层重复转换: 1 2 3 4 5 6 | 原始请求体 → 表达式系统 decode → AI provider decode → 插件再次 decode → 修改对象 → encode 后发往上游 | 每一步局部上都可能是“合理的”,但组合起来就形成了重复工作。 运行时审查需要追踪数据生命周期: - 原始字节在哪里读取? - 第一次结构化解析在哪里发生? - 解析结果能否放入 request context 复用? - 哪些层只需要读取,哪些层确实会修改? - encode 是否只在最终边界执行一次? 六、问题四:流式响应的每 chunk 成本 6.1 不要把 body_reader() 想象成必然阻塞 lua-resty-http 的 chunked body reader 会维护 HTTP chunk framing 状态,并调用 cosocket receive() 读取 chunk size 和内容。 它并不是一个简单的 self.buffer Lua 字符串缓存器,因此不能在没有绑定准确依赖版本和源码的情况下,假设“第一次读取进入 self.buffer,后续读取完全不触碰 cosocket”。 但核心风险仍然成立: 即使代码调用了 sock:receive(),只要 cosocket/Nginx 接收缓冲区里已经有足够数据,它仍可能立即返回,不发生 I/O 等待。 这也是为什么评估循环时不能只数“网络 API 调用了几次”,而要判断这些调用在目标负载下是否真的等待。 6.2 高频小 chunk 为什么昂贵 当上游返回相同总字节数时,小 chunk 越多,固定成本执行次数越多: 1 2 3 4 5 6 | 读取 chunk → 拼接或扫描 SSE 边界 → JSON 解析/转换 → 执行响应过滤器 → 写入下游 → flush | 例如,同样是 1 MiB 数据: - 1,024 个 1 KiB chunk; - 16,384 个 64 B chunk; 后者会执行更多循环、函数调用、边界扫描、临时对象分配和 flush。即使每轮都很快,总成本也可能显著上升。 6.3 实时性与吞吐的 flush 取舍 逐 chunk 同步 flush 的优点是首 token 和 token 间延迟低,也更容易及时暴露下游写错误;缺点是 flush 次数与 chunk 数量直接相关。 周期 flush 会把一个短时间窗口内的多个 chunk 合并输出: 1 2 | 逐 chunk flush:chunk → flush → chunk → flush → chunk → flush 周期 flush: chunk → chunk → chunk → 每 10ms flush | 这减少了 flush 和调度次数,但会引入一个可控的额外延迟窗口。 PR #13391 的处理不是简单删除 flush,而是把几种机制拆开: - 优化 SSE framing,减少字符串扫描和分配; - 增加 streaming_flush_interval_ms,默认以 10ms 周期批量 flush; - 正数间隔下由后台 light thread 周期执行异步 flush; - 配置为 0 时保留逐 chunk 同步 flush; - 流结束时执行最终同步 flush; - 传播异步 flush 错误; - 增加最大流持续时间和最大响应字节数保护; - 继续保留 ngx.sleep(0) 作为公平性保护。 这个设计恰好说明: 1 2 3 4 | ngx.sleep(0) → 调度公平性 SSE parser 优化 → 降低单位 chunk CPU 成本 周期 flush → 降低高频输出成本 duration/byte limit → 限制异常流的资源上界 | 四者解决的不是同一个问题。 6.4 周期 flush 仍不等于完整背压 背压的目标是:当下游消费速度赶不上上游生产速度时,让生产者减速或限制中间缓冲增长。 周期 flush 主要减少输出频率,并不必然让上游减速。完整的流式资源治理还需要考虑: - 最大流持续时间; - 最大响应字节数; - 最大未发送缓冲量; - 上游读取节奏; - 下游写入速度; - 超限后的取消和连接关闭策略。 因此,不应把“增加 flush interval”直接等同为“已经实现背压”。 七、重新整理故障因果链 经过拆分后,几个问题可以放进一条更准确的资源模型: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 请求体重复 JSON decode → 增加每个请求的固定 CPU 成本 上游高速产生大量小 chunk → 放大 SSE 解析、字符串分配和 flush 次数 → 增加流式阶段 CPU 成本 流式循环缺少显式调度点 → 单个请求可能连续占用 worker → 其他请求和定时任务尾延迟恶化 下游取消未及时传播 → 已经失去业务价值的上游流仍继续运行 → 前述所有 CPU 和网络成本继续发生 | 这四条链路会互相叠加。例如: 1 2 3 4 5 | 下游已经取消 + 上游仍高速输出 + 每个 chunk 处理成本高 + 缺少资源上限 = 无效工作持续占用 worker | 但仍然要避免一句“没有 yield 导致 CPU 打满”覆盖全部事实。 更合理的故障分类是: 维度 故障表现 主要修复 生命周期 | 下游取消后上游继续运行 | abort propagation、关闭上游 | 公平性 | 单请求长期占用 worker | 显式 yield | 效率 | 重复 decode、SSE 分配、高频 flush | 缓存、parser 优化、批量 flush | 资源上界 | 异常长流或大响应无限消耗资源 | duration/bytes/buffer limit | 背压 | 上游长期快于下游 | 限速、暂停读取或有界缓冲策略 | 八、AI 在这次复盘中应该扮演什么角色 我最初把问题提交给 Claude Opus,希望 AI 帮助分析: - 为什么阅读代码时没有发现问题; - 需要补充哪些 OpenResty 和协作式调度知识; - 如何形成可复用的代码审查 checklist。 AI 的价值首先是快速扩展假设空间: 1 2 3 4 5 6 7 8 | CPU 饱和 ├─ 重复 JSON 编解码? ├─ 流式循环没有 yield? ├─ cosocket 持续立即返回? ├─ 高频 flush? ├─ SSE parser 分配过多? ├─ 下游取消未传播? └─ 缺少流持续时间和字节数上限? | 但 AI 给出的解释不能直接作为事故结论。它可能: - 根据常见库实现虚构当前版本不存在的 self.buffer; - 把“调用可能立即返回”夸大为“绝对不会 yield”; - 把 ngx.sleep(0) 的局限错误解释成“它没有真正让出”; - 把优化 PR 的 benchmark 当成生产事故根因证明; - 混淆系统发送缓冲区和客户端实际接收。 因此,我认为更可靠的 AI 辅助复盘流程是: 第一步:让 AI 生成候选假设 要求 AI 尽量列全:调度、I/O、缓冲、序列化、生命周期、超时、限流和可观测性。 第二步:固定准确版本 记录并提供: - API7/APISIX commit; - OpenResty 版本; - lua-resty-http 版本; - 配置文件; - 实际调用链。 没有版本约束的源码分析,很容易分析到另一版实现。 第三步:建立证据等级 等级 示例 能说明什么 现象 | worker CPU 100%、延迟上升 | 确实发生了问题 | 源码 | 同一请求体被 decode 三次 | 存在重复工作 | profile | JSON decode 占用主要 CPU | 重复工作与线上 CPU 相关 | 对照实验 | 缓存后相同流量 CPU 明显下降 | 建立较强因果关系 | 生产验证 | 发布后指标恢复且问题不再复现 | 修复对事故有效 | PR、源码和 benchmark 可以证明问题存在,但要把它称为某次生产事故的“根本原因”,最好仍有 profile 和对照实验。 第四步:主动验证 AI 最自信的结论 优先检查带有以下词语的回答: - “一定”; - “绝对”; - “必然”; - “等于没有”; - “唯一根因”。 越确定的表述,越值得回到官方文档和准确版本源码验证。 第五步:让实验而不是叙事完成归因 建议使用固定流量回放,分别控制变量: 实验 观察指标 目标 保留/移除 ngx.sleep(0) | 其他请求 P99、worker event loop 延迟 | 验证公平性 | 缓存/重复 JSON decode | CPU profile、单请求 CPU time | 验证解析成本 | 逐 chunk/周期 flush | CPU、首 token 延迟、token 间延迟 | 量化实时性与吞吐取舍 | 大/小 chunk,相同总字节数 | 循环次数、分配量、CPU | 验证 per-chunk 固定成本 | 客户端中途取消 | 上游关闭延迟、取消后字节数 | 验证取消传播 | 上游快、下游慢 | 缓冲量、内存、读取速率 | 验证背压和资源上界 | AI 最适合帮助设计这些实验、解释结果和发现遗漏,而不是替代实验。 九、运行时代码审查 Checklist 9.1 循环与调度 - 循环单轮在最坏情况下做多少 CPU 工作? - 循环中的 I/O 是否可能因为缓冲区已有数据而立即返回? - 是否存在明确且可验证的 yield 点? - yield 是为了公平性,还是错误地被当成了限流? - 一个请求是否可能连续处理大量 ready data? - 是否监控 worker event loop latency 和请求尾延迟? 9.2 取消与资源生命周期 - 下游断开后,如何通知上游停止? - 上游连接、light thread、timer 是否都会被清理? - flush/write 错误是否完整传播? - 是否需要 lua_check_client_abort / ngx.on_abort? - 客户端取消后最多还会读取多少上游数据? - 是否有取消传播耗时指标? 9.3 缓冲与背压 - 上游和下游分别有哪些缓冲层? - 上游比下游快时,数据会积累在哪里? - 缓冲区是否有上限? - 是否能暂停或降低上游读取速度? - flush 策略是逐 chunk、按字节还是按时间窗口? - 实时性目标是否有明确数值,而不是默认“越快越好”? 9.4 编解码与内存分配 - 同一请求体被 decode 了几次? - 解析结果能否在 request context 中复用? - 是否存在 .. 循环拼接导致大量临时字符串? - SSE 边界扫描是否重复遍历相同数据? - encode 是否只在最终边界发生? - 是否按请求体大小和 chunk 数量做过 benchmark? 9.5 资源上界 - 单条流是否有最大持续时间? - 是否限制最大响应字节数? - 是否限制最大事件数或未发送缓冲量? - 超限后是否取消上游并记录原因? - 超时、取消、上游错误是否可以在指标中区分? 9.6 证据与可观测性 - 是否记录精确版本和 commit? - 是否有 worker 维度的 CPU 和延迟指标? - 是否采集过 flame graph / CPU profile? - 是否记录请求体大小、响应字节数、chunk 数和流持续时间? - 是否有修复前后的同负载对照实验? - 文章中的每个“根因”是否都有对应证据? 十、最终总结 这次问题让我补上的并不只是某个 OpenResty API 的知识,而是一套运行时分析方法。 10.1 静态正确性只是起点 代码能够正确退出、正确转发数据,不代表它在高频、突发、取消或慢客户端场景下仍然高效。 10.2 不要把四类问题混成一个原因 - 取消传播决定无效工作何时停止; - yield 决定调度是否公平; - 缓存和批处理决定单位工作成本; - 背压和资源上限决定异常流能消耗多少资源。 10.3 ngx.sleep(0) 有效,但能力边界必须说清楚 它确实让出调度权,可以缓解单请求垄断 worker;它不降低总解析量,不限速,也不构成背压。 10.4 网络 API 不一定等待 cosocket receive()、flush 等 API 的运行时行为取决于缓冲区、socket readiness 和调用参数。看见 I/O 调用,不能默认已经发生 yield。 10.5 AI 负责扩大搜索空间,证据负责收敛结论 AI 可以快速生成假设、解释机制和设计实验,但必须用准确版本源码、官方文档、profile 和对照实验校验。一个听起来完整的运行时故事,不一定是真实调用链。 真正需要培养的能力,不是背诵“哪里应该加一个 ngx.sleep(0)”,而是面对任何事件驱动系统时,都主动追问:执行权何时归还?数据在哪里缓冲?重复工作发生了几次?取消如何传播?资源上界在哪里? 参考资料 - APISIX PR #13254:客户端断开时终止上游流 - APISIX PR #13255:流式循环增加调度让出 - APISIX Issue #13256:流式请求资源治理 - APISIX PR #13356:缓存 post_arg.* 请求体解析结果 - APISIX PR #13377:缓存 AI 请求 JSON decode 结果 - APISIX PR #13391:优化 SSE parser 与 streaming flush - OpenResty lua-nginx-module 文档 - lua-resty-http 源码 ### AI时代高效设计开发:Anthropic → OpenAI 协议转换插件实战 - URL: https://jiankunking.com/efficient-design-and-development-in-the-ai-era-practice-of-plugin-development-for-converting-anthropic-requests-to-an-openai-compatible-backend.html - Content type: original - Published: 2026-04-16 - Updated: 2026-04-16 - Summary: APISIX Anthropic 与 OpenAI 协议互转插件:支持流式、思考、工具调用与多模态。 - Categories: AI - Tags: APISIX, Plugin, AI Gateway, AI, Anthropic, OpenAI, Protocol Translation Article text: 文章速览 APISIX Anthropic 与 OpenAI 协议互转插件:支持流式、思考、工具调用与多模态。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:AI APISIX Anthropic 与 OpenAI 协议互转插件:支持流式、思考、工具调用与多模态 AI时代高效设计开发:Anthropic → OpenAI 协议转换插件实战 一、参考已有实现 复用 Higress、LiteLLM 等相似项目的代码实现,让 AI 直接参考。 二、准备测试用例 整理 CURL 示例等场景,让 AI 自行校验验证结果。 三、明确功能边界 阶段 转换内容 请求 | Anthropic Messages API → OpenAI Chat Completions | 响应 | OpenAI Chat Completions → Anthropic Messages API | 支持 | 非流式/流式、thinking、tool_use、多模态图片 | 四、Vibe Coding 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 | -- -- APISIX Plugin: claude2openai -- 参考实现: Higress ai-proxy provider/claude_to_openai.go -- -- 功能: -- 1. 请求阶段:将 Claude Messages API 请求转换为 OpenAI Chat Completions 请求 -- 2. 响应阶段:将 OpenAI Chat Completions 响应转换为 Claude Messages API 响应 -- 支持:非流式、流式、thinking、tool_use、多模态图片 -- local core = require("apisix.core") local cjson = require("cjson.safe") local ngx = ngx local pairs = pairs local ipairs = ipairs local type = type local table_insert = table.insert local table_concat = table.concat local string_sub = string.sub local string_find = string.find local ngx_now = ngx.now local plugin_name = "jiankunking-claude2openai" local schema = { type = "object", properties = { target_model = { type = "string", description = "Override the model name sent to OpenAI backend" }, }, } local _M = { version = 0.1, priority = 2650, -- 在 consumer-restriction(2640) 和 key-auth(2500) 之前执行 name = plugin_name, schema = schema, } function _M.check_schema(conf) return core.schema.check(schema, conf) end ------------------------------------------------------------------------ -- 工具函数 ------------------------------------------------------------------------ -- OpenAI finish_reason -> Claude stop_reason (参考 Higress openAIFinishReasonToClaude) local function openai_finish_reason_to_claude(reason) if not reason or reason == cjson.null then return "end_turn" end local mapping = { stop = "end_turn", length = "max_tokens", tool_calls = "tool_use", content_filter = "end_turn", } return mapping[reason] or reason end -- 移除 x-anthropic-billing-header 中的动态 cch 字段以支持 prompt caching -- 参考 Higress stripCchFromBillingHeader -- cch 值每次请求都会变化,如果不移除会导致缓存失效 local function strip_cch_from_billing_header(text) if type(text) ~= "string" then return text end local prefix = "x-anthropic-billing-header:" if string_sub(text, 1, #prefix) ~= prefix then return text end -- 循环移除所有"; cch=xxx" 模式 local result = text while true do local cch_start = string_find(result, "; cch=", 1, true) if not cch_start then break end local after = cch_start + 2 -- skip "; " local semi_pos = string_find(result, ";", after, true) if not semi_pos then -- cch 在末尾,直接截断 result = string_sub(result, 1, cch_start - 1) else -- cch 后面还有内容,移除 "; cch=xxx" 部分 result = string_sub(result, 1, cch_start - 1) .. string_sub(result, semi_pos) end end return result end -- Claude content blocks 中提取纯文本部分 local function extract_text_parts(content_array) local parts = {} if type(content_array) ~= "table" then return parts end for _, block in ipairs(content_array) do if block.type == "text" and block.text then table_insert(parts, block.text) end end return parts end ------------------------------------------------------------------------ -- 请求转换: Claude Messages -> OpenAI Chat Completions -- 参Higress ConvertClaudeRequestToOpenAI ------------------------------------------------------------------------ -- 转换 Claude content blocks 数组为 OpenAI 格式 -- 返回 { text_parts, tool_calls, tool_results, openai_contents } local function convert_content_array(content_array) local result = { text_parts = {}, tool_calls = {}, tool_results = {}, openai_contents = {}, } if type(content_array) ~= "table" then return result end for _, block in ipairs(content_array) do if block.type == "text" and block.text then local processed_text = strip_cch_from_billing_header(block.text) table_insert(result.text_parts, processed_text) local openai_content = { type = "text", text = processed_text } -- 透传 cache_control 以支持 prompt caching if block.cache_control then openai_content.cache_control = block.cache_control end table_insert(result.openai_contents, openai_content) elseif (block.type == "image" or block.type == "document") and block.source then -- Claude: {type:"image"/"document", source:{type:"base64", media_type:"...", data:"..."}} -- OpenAI: {type:"image_url", image_url:{url:"data:...;base64,..."}} local img_obj if block.source.type == "base64" then img_obj = { type = "image_url", image_url = { url = "data:" .. (block.source.media_type or "image/png") .. ";base64," .. block.source.data } } elseif block.source.type == "url" then img_obj = { type = "image_url", image_url = { url = block.source.url } } end if img_obj then if block.cache_control then img_obj.cache_control = block.cache_control end table_insert(result.openai_contents, img_obj) end elseif block.type == "thinking" and block.thinking th… ### 2025年终总结 - URL: https://jiankunking.com/2025-year-end-summary.html - Content type: original - Published: 2025-12-31 - Updated: 2025-12-31 - Summary: 2025年工作聚焦大模型全生命周期管理和多云高可用云原生网关;考取大数据系统开发中级职称和系统架构设计师高级证书。 - Categories: Year-End Summary - Tags: Year-End Summary, 2025 Article text: 文章速览 2025年工作聚焦大模型全生命周期管理和多云高可用云原生网关;考取大数据系统开发中级职称和系统架构设计师高级证书。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Year-End Summary 2025年工作聚焦大模型全生命周期管理和多云高可用云原生网关;考取大数据系统开发中级职称和系统架构设计师高级证书。 时光荏苒,又是一年。 工作 这一年,我的工作主要集中在下面两个方面: - 大模型全生命周期管理(模型注册–模型网关–模型令牌……) - 多云高可用场景下云原生网关 生活 个人成长 今年在工作之余考了两个证 - 大数据系统开发(山东省中级职称) - 系统架构设计师(软考高级) 家庭 四个字,“比去年好”,已是莫大的慰藉与成就。 财务 - 提前还了一些贷款。 学习感悟 备考软考的时候,看到了下面两句话,深有感触: - 架构的基本需求主要是在满足功能属性的前提下,关注软件质量属性,架构设计则是为满足架构需求(质量属性)寻找适当的“战术”(即架构策略)。 - 软件架构(及软件架构设计师)重点关注的是质量属性。因为在大量的可能结构中,可以使用不同的结构来实现同样的功能性,即功能性在很大程度上是独立于结构的,架构设计师面临着决策(对结构的选择),而功能性所关心的是它如何与其他质量属性进行交互,以及它如何限制其他质量属性。 新一年的期望 健康与家庭 - 家人健健康康、平平安安 个人提升 - 减肥成功 - 提升一下英语能力 时间不语,却见证所有选择。25年未竟的,已写在26年的序章里。 ### 多云高可用场景下云原生网关架构设计 - URL: https://jiankunking.com/cloud-native-gateway-selection-for-multi-cloud-high-availability.html - Content type: original - Published: 2025-11-01 - Updated: 2025-11-01 - Summary: 企业上云后,单一云厂商风险逐渐暴露。本文介绍如何设计云原生网关架构,实现跨云高可用,涵盖集群适配、流量隔离等关键设计。 - Categories: Architecture - Tags: Gateway, Kubernetes, Multi-Cloud, High Availability, Istio, Envoy Article text: 文章速览 企业上云后,单一云厂商风险逐渐暴露。本文介绍如何设计云原生网关架构,实现跨云高可用,涵盖集群适配、流量隔离等关键设计。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 企业上云后,单一云厂商风险逐渐暴露。本文介绍如何设计云原生网关架构,实现跨云高可用,涵盖集群适配、流量隔离等关键设计。 背景分析 数字化转型下,企业上云已成必然。但业务规模扩大后,单一云厂商的风险逐渐暴露,因此我们设计云原生网关架构,始终以多云高可用为核心。 网关作为微服务的流量核心入口,还集成了流量管理、安全防护与全链路可观测能力。那该如何设计这一网关,才能充分发挥这些能力,真正实现多云高可用呢? 基于这一核心定位,我们可从集群适配、流量隔离等多个关键维度设计网关,以此充分激活其各项能力并筑牢多云场景下的高可用底座。 - 集群适配:随云环境精准选择网关类型 - 依托多云技术布局,网关采用 “集群类型对应” 的选型逻辑,确保网关与应用服务底层集群基础设施无缝衔接,避免跨云。 - 实例专属绑定:集群级隔离,精细化流量治理 - 每个集群独立部署专属网关实例,实现资源与流量严格隔离。 - 统一界面:屏蔽底层细节降低使用门槛 - 网关提供一体化可视化操作平台,将底层复杂技术完全封装,贴合 APP 快速迭代的业务需求。 - 云原生高可用:99.95% SLA兜底,支撑千万级访问 - 高可用性全权由云厂商保障,无需额外架构投入。 - 插件化安全:简化认证降低业务复杂度 - 登录、权限校验等通用安全逻辑以插件化形式预置于网关,完全剥离于 APP 业务代码,大幅降低开发成本 为什么一个K8S集群要部署一个网关? - 服务隔离与安全边界 - 不同集群承载不同安全等级的业务,通过独立网关可实现网络层面的服务隔离,降低横向移动风险。 - 流量就近处理 - 在多云环境中,集群往往分布在不同地域或不同云厂商。为每个集群部署独立网关,可以实现流量的就近处理,减少跨地域、跨云厂商的网络延迟。 - 故障隔离 - 独立网关能有效隔离故障影响范围。如果一个集群的网关出现问题,不会影响其他集群的正常运行。 - 部署灵活性 - 为每个集群部署独立网关,可以根据集群的实际需求和业务特点,灵活配置网关参数和功能。 为什么选择直接对接云网关? 在确定了”每集群一网关”的架构模式后,我们又面临一个选择:是使用开源网关(如Kong、APISIX、Higress)自己部署,还是直接对接云厂商提供的网关服务? 经过对比分析,我们最终选择了直接对接云网关服务。而这一决策的核心依据,正是云网关服务自身具备的一系列契合我们需求的核心特性,具体体现在以下几个方面。 - 高可用性保障 - 云厂商的网关服务通常都经过了大规模的生产验证,可用性承诺一般在99.95%以上,还有专业的运维团队支持。 - 运维成本降低 - 功能丰富 - 弹性扩展能力强 - 与云原生生态集成 - 云厂商的网关服务与其他云服务深度集成,可以更好地发挥云原生的优势,提高开发效率和系统弹性。 架构分层 这是一套多云网关统一管理架构,旨在实现跨云服务商的网关资源集中编排与适配,解决多云环境下网关管理分散、运维复杂度高的问题。以下从架构分层、组件职责和价值三个维度展开介绍: 架构分层与组件解析 该架构分为业务编排层、网关聚合层、适配端层三层,各层职责明确且解耦: 1. 业务编排层:业务逻辑的“指挥中心” - 用户角色:网关用户是操作主体,负责对逻辑网关和路由执行“增删改查”操作。 - 核心组件: - 逻辑网关:抽象网关的逻辑定义,依赖SSL证书实现安全通信,是业务侧对域名的逻辑抽象。 - 路由:与逻辑网关关联,依赖插件和服务实现流量转发规则;路由的审批流程通过MQ(消息队列)异步处理,保障流程可靠性。 2. 网关聚合层:多云指令的“转换器” - 承接业务编排层的操作指令,对域名执行“创建/删除”,对路由组执行“发布/下线(Publish/Unpublish)”。 - 核心是CloudGatewayProvider(Adapter),作为“中间桥梁”,将业务层的统一指令转换为适配多云环境的操作格式,实现多云指令的标准化。 3. 适配端层:多云资源的“执行器” - 对接不同云服务商的网关服务,如阿里云-MSE、华为云-APIG、腾讯云-云原生网关等,实现多云网关的“一管多”。 - 阿里云-MSE:管理域名、服务、路由、策略、插件等资源,是阿里云生态下的微服务网关载体。 - 华为云-APIG:通过API组管理域名、响应等,是华为云的API网关服务入口。 - 腾讯云-云原生网关:适配腾讯云的网关产品,支持云原生场景下的流量管理。 架构价值 - 统一编排:业务侧通过“逻辑网关+路由”的模式,无需关注底层云差异,实现网关资源的集中化、标准化编排。 - 多云适配:通过聚合层和适配层的解耦设计,轻松对接阿里云、华为云、腾讯云等多厂商,避免“一云一套管理逻辑”的重复建设。 - 灵活扩展:插件、策略等组件支持自定义扩展,可根据业务需求灵活增强网关能力;适配端层的“…”节点也支持快速接入新的云服务商。 核心接口设计 抽象比具体更重要 在多云环境下,关键是要把不同云厂商API的差异藏起来,给业务层提供统一的网关管理能力。 核心接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | // CloudGateway 核心网关接口定义 - 包含域名、服务、路由的CRUD操作 // 产品的一步操作 对应云的多步操作 // 具体需要实现哪些接口,按需实现即可 type CloudGateway interface { // ----------------- 域名管理 ----------------- // CreateDomain 域名管理--可重入 CreateDomain(ctx context.Context, request *model.CreateDomainRequest) (*model.CreateDomainResponse, error) // GetDomain 查询域名 GetDomain(ctx context.Context, request *model.GetDomainRequest) (*model.GetDomainResponse, error) // UpdateDomain 更新域名 UpdateDomain(ctx context.Context, request *model.UpdateDomainRequest) (*model.UpdateDomainResponse, error) // DeleteDomain 删除域名 DeleteDomain(ctx context.Context, request *model.DeleteDomainRequest) (*model.DeleteDomainResponse, error) // ListDomains 查询域名列表 ListDomains(ctx context.Context, request *model.ListDomainRequest) (*model.ListDomainResponse, error) // ConflictCheckDomain 冲突检查域名 ConflictCheckDomain(ctx context.Context, request *model.ConflictCheckDomainRequest) (*model.ConflictCheckDomainResponse, error) // ----------------- 服务管理 ----------------- // CreateService 服务管理 --可重入 CreateService(ctx context.Context, request *model.CreateServiceRequest) (*model.CreateServiceResponse, error) // GetService 查询服务 GetService(ctx context.Context, request *model.GetServiceRequest) (*model.GetServiceResponse, error) // UpdateService 更新服务 UpdateService(ctx context.Context, request *model.UpdateServiceRequest) (*model.UpdateServiceResponse, error) // DeleteService 删除服务 DeleteService(ctx context.Context, request *model.DeleteServiceRequest) (*model.DeleteServiceResponse, error) // ListServices 查询服务列表 ListServices(ctx context.Context, request *model.ListServiceRequest) (*model.ListServiceResponse, error) // ServiceConflictCheck 服务冲突检查 ServiceConflictCheck(ctx context.Context, request *model.ConflictCheckServiceRequest) (*model.ConflictCheckServiceResponse, error) // CreateRoute ----------------- 路由管理 ----------------- // CreateRoute 创建路由--可重入 CreateRoute(ctx context.Context, request *model.CreateRouteRequest) (*model.CreateRouteResponse, error) // GetRoute 查询路由 GetRoute(ctx context.Context, request *model.GetRouteRequest) (*model.GetRouteResponse, error) // UpdateRoute 更新路由 UpdateRoute(ctx context.Context, request *model.UpdateRouteRequest) (*model.UpdateRouteResponse, error) // DeleteRoute 删除路由--可重入 DeleteRoute(ctx context.Context, request *model.DeleteRouteRequest) (*model.DeleteRouteResponse, error) // ListRoutes 查询路由列表 ListRoutes(ctx context.Context, request *model.ListRouteRequest) (*model.ListRouteResponse, error) // PatchRoute 部分更新路由 PatchRoute(ctx context.Context, request *model.PatchRouteRequest) (*model.PatchRouteResponse, error) } // CloudClientManager 客户端管理接口 - 用于初始化和获取云SDK客户端 type CloudClientManager[T any] interface { // InitClient 初始化云SDK客户端 InitClient(ctx context.Context, config *model.CloudClientInitConfig) error // GetClient 获取初始化好的客户端 GetClient(ctx context.Context, config model.CloudClientGetConfig) (T, error) } // CloudGatewayProvider 综合接口,组合核心功能和客户端管理 type CloudGatewayProvider interface { CloudGateway GetCloudGatewayProviderType() model.CloudGatewayProvider } // NewGatewayProvider 工厂函数,用于创建特定云厂商的网关实例 func NewGatewayProvider(provider model.CloudProvider) (CloudGatewayProvider, error) { switch provider { case model.HuaweiCloud: return huawei_apig.NewHuaweiAPIGProvider(), nil case model.AliCloud: return ali_mse.NewAliMSEProvider(), nil default: return nil, &bizerrors.GatewayError{ Code: "unsupported_provider", Message: "不支持的云厂商", } } } | 设计思路 - 统一抽象:不同云厂商的网关API差异很大,有的关注”域名”,有的主打”服务”。通过CloudGateway接口,我们把这些差异都藏在适配层下面,让业务代码不用关心具体是哪家云厂商。 - 工厂模式:用工厂模式创建不同云厂商的网关实例,后面要加新的云厂商时,直接加个case分支就行,不用改现有代码。实际落地时,这个设计帮我们省了不少对接新云平台的时间。 - 客户端管理封装:每个云厂商的SDK初始化方式都不一样,通过CloudClientManager接口,我们把这些复杂逻辑包起来,让代码更简洁,维护也更容易。 - 幂等性:我在接口中标注了”可重入”,这是从运维经验里总结的。分布式环境下网络不稳定,请求很容易重试。确保核心操作幂等,就能避免重复创建资源或状态混乱的问题。 - 按需实现:接口虽然覆盖了完整生命周期,但我们允许具体实现”按需实现”。实际落地时,确实有些云厂商的某些功能不完善,这种设计让我们能灵活应对。 - 错误处理要精细:专门定义了GatewayError类型,提供详细的错误码和信息。排查问题时,这个设计帮我们快速定位是哪个云厂商的哪个功能出了问题。 部署架构及高可用 经验总结 - 什么是架构? - 架构的基本需求主要是在满足功能属性的前提下,关注软件质量属性,架构设计则是为满足架构需求(质量属性)寻找适当的“战术”(即架构策略)。 - 软件架构(及软件架构设计师)重点关注的是质量属性。因为在大量的可能结构中,可以使用不同的结构来实现同样的功能性,即功能性在很大程度上是独立于结构的,架构设计师面临着决策(对结构的选择),而功能性所关心的是它如何与其他质量属性进行交互,以及它如何限制其他质量属性。 - 多为运维考虑 - 设计架构不能只看开发方不方便,还要想长期怎么维护。我们选的方案让运维团队从底层维护中解放出来,能更专注于业务支撑。 - 保持技术中立 - 虽然用了云厂商服务,但通过抽象层保持中立,这样将来想换云厂商时,也能平滑迁移,不会被一家绑定死。 - 别过度设计 - 简单能落地的方案才是最好的!先解决核心问题,再慢慢完善。 ### Apache APISIX 架构浅析 - URL: https://jiankunking.com/apache-apisix-architecture.html - Content type: original - Published: 2025-03-21 - Updated: 2025-03-21 - Summary: 分析 Apache APISIX 的高性能架构,重点介绍事件驱动、协程、LuaJIT、OpenResty 等核心设计及其与 Envoy 的差异。 - Categories: Architecture - Tags: Apache, APISIX, Gateway Article text: 文章速览 分析 Apache APISIX 的高性能架构,重点介绍事件驱动、协程、LuaJIT、OpenResty 等核心设计及其与 Envoy 的差异。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 大家从网上肯定看到过关于Apisix性能高的文章,那么到底是如何实现的呢? 本文是分析也是自己学习《OpenResty从入门到实战》及Apisix官方文档的一个笔记 简单分析 先看一下官网的架构图 从图中可以看到APISIX是基于OpenResty与Nginx实现的。 这里需要注意OpenResty并不是Nginx的fork,也不是在Nginx的基础上加了一些常用库重新打包,而只是把Nginx当作底层的网络库来使用。 那具体是如何使用Nginx来作为网络库的呢? 其实很简单就是在Nginx.conf中做简单的配置,让所有的流量都通过网关的Lua代码来处理。 注:Nginx默认不支持Lua的,这部分是OpenResty的能力 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | server { listen 9080; init_worker_by_lua_block { apisix.http_init_worker() } location / { access_by_lua_block { apisix.http_access_phase() } header_filter_by_lua_block { apisix.http_header_filter_phase() } body_filter_by_lua_block { apisix.http_body_filter_phase() } log_by_lua_block { apisix.http_log_phase() } } } # 这里只是一个简单的示意,完整的配置可以看到文末的nginx.conf文件 | 在这个示例中,监听了 9080 端口,并通过location /的方式,把这个端口的所有请求都拦截下来,并依次通过access、rewrite、header filter、body filter和log这几个阶段进行处理,在每个阶段中都会去调用对应的插件函数。其中,rewrite阶段便是在apisix.http_access_phase函数中合并处理的。 这里是一份完整的Nginx的配置文件:APISIX Nginx.conf 这里补充下OpenResty Phases 为了实现足够高的性能,Apache APISIX 使用 C 编写了基于前缀树的匹配路由算法,并在此基础上使用 LuaJIT 提供的 FFI 编写了适用于 Lua 的接口。而 Lua 的灵活性,也使得 Apache APISIX 的路由分发模块,可以轻易地支持通过特定的表达式等方法,对同一前缀的下级路由进行匹配。最终在替代 NGINX 原生路由分发功能的前提下,实现了兼具高性能、高灵活性的动态配置功能。有关这部分功能的详细实现,可以查看 lua-resty-radixtree 和 route.lua。 那Lua层面是如何保证高性能的呢? 协程+事件 在OpenResty层面,Lua的协程会与Nginx的事件机制相互配合。如果Lua代码中出现类似查询 MySQL 数据库这样的 I/O 操作,就会先调用Lua协程的 yield 把自己挂起,然后在Nginx中注册回调;在 I/O 操作完成(也可能是超时或者出错)后,再由Nginx回调 resume 来唤醒Lua协程。这样就完成了Lua协程和Nginx事件驱动的配合,避免在Lua代码中写回调。 LuaJIT 我们先来看下LuaJIT在OpenResty整体架构中的位置: OpenResty的worker进程都是fork master进程而得到的,其实,master进程中的LuaJIT虚拟机也会一起fork过来。在同一个worker内的所有协程,都会共享这个LuaJIT虚拟机,Lua代码的执行也是在这个虚拟机中完成的。 标准 Lua 和 LuaJIT 其实标准Lua出于性能考虑,也内置了虚拟机,所以Lua代码并不是直接被解释执行的,而是先由Lua编译器编译为字节码(Byte Code),然后再由Lua虚拟机执行。 而LuaJIT的运行时环境,除了一个汇编实现的Lua解释器外,还有一个可以直接生成机器代码的JIT编译器。开始的时候,LuaJIT和标准Lua一样,Lua代码被编译为字节码,字节码被LuaJIT的解释器解释执行。 但不同的是,LuaJIT的解释器会在执行字节码的同时,记录一些运行时的统计信息,比如每个Lua函数调用入口的实际运行次数,还有每个Lua循环的实际执行次数。当这些次数超过某个随机的阈值时,便认为对应的Lua函数入口或者对应的Lua循环足够热,这时便会触发JIT编译器开始工作。 JIT 编译器会从热函数的入口或者热循环的某个位置开始,尝试编译对应的Lua代码路径。编译的过程,是把LuaJIT字节码先转换成LuaJIT 自己定义的中间码(IR),然后再生成针对目标体系结构的机器码。 所以,所谓LuaJIT的性能优化,本质上就是让尽可能多的Lua代码可以被JIT编译器生成机器码,而不是回退到Lua解释器的解释执行模式。 注意 - LuaJIT并不完备,存在NYI(Not Yet Implemented)问题 - LuaJIT 的作者目前处于半退休状态 - OpenResty用的LuaJIT是LuaJIT自己维护的分支 语句摘录 在学习《OpenResty从入门到实战》的过程中对于一下几句话深有感触,这里摘录一下与大家共勉: 🎯 在我看来,明白一个技术为何存在,并弄清楚它和别的类似技术之间的差异和优势,远比你只会熟练调用它提供的 API 更为重要。这种技术视野,会给你带来一定程度的远见和洞察力,这也可以说是工程师和架构师的一个重要区别。 📚 任何技术课程的学习,都不能代替对官方文档的仔细研读。这些耗时的笨功夫,每个人都省不掉的。 🌍 OpenResty 现在的官方文档只有英文版本,国内工程师在阅读时,难免会因为语言问题,抓不住重点,甚至误解其中的内容。但越是这样,越没有捷径可走,你更应该仔细地把文档从头到尾读完,并在有疑问时,结合测试案例集和自己的尝试,去确定出答案。这才是辅助我们学习 OpenResty 的正确途径。 ⚖️ 对待技术的选择,我们可以有倾向,但还是不要一概而论绝对化,因为并没有一个可以适合所有缓存场景的银弹。根据实际场景的需要,构建一个最小化可用的方案,然后逐步地增加,是一个不错的法子。 OpenResty vs Envoy 底座比对 随着云原生的普及,Kubernetes技术栈的南北向流量网关也开始普及:南北入口网关选型 虽然从目前的南北向网关功能来看还不如以OpenResty为底座的Kong或者APISIX,但云原生社区明显更加活跃。 下面从Contributors、Star、Commits、Releases四方面来分析比对(数据取值时间:2025.03.22): - Contributors - OpenResty:35 - Envoy:1227 - Star - OpenResty:12.9k - Envoy:25.7k - Commits - OpenResty:1746 - Envoy:24125 - Releases - OpenResty:16 - Envoy:196 从目前功能来看Kong或者APISIX能力更全,但从长远来看,个人更看好Kubernetes Gateway API相关的网关。 现在感觉Cloudflare也放弃了OpenResty,转到了Pingora:how-we-built-pingora-the-proxy-that-connects-cloudflare-to-the-internet Pingora 并不是一个工具,而是一个库。 ### 南北入口网关选型 - URL: https://jiankunking.com/south-north-entry-gateway-selection.html - Content type: original - Published: 2025-03-07 - Updated: 2025-03-07 - Summary: 面向国内外混合云环境的南北向入口网关选型实践,从部署形态、流量治理、扩展能力和运维复杂度等方面对比Istio、Envoy等方案。 - Categories: Kubernetes - Tags: Gateway, Kubernetes, Istio, Envoy, API, Route, Priority Article text: 文章速览 面向国内外混合云环境的南北向入口网关选型实践,从部署形态、流量治理、扩展能力和运维复杂度等方面对比Istio、Envoy等方案。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes 国内外混合云环境下南北向网关选型实践,对比分析Istio、Envoy等网关方案的优缺点。 南北向网关的选型之旅 背景 我们这边的容器云是国内外混合云,这里的混合云是指 - 从提供者来分包含: - 阿里云 - 华为云 - AWS - Oracle - 自建 - 从地域来分包含: - 国内 - 海外 - 法兰克福 - 俄罗斯 - 新加坡 - …… 所以我们希望网关方案能够 - 涵盖住所有集群、所有地域 - 最好是能直接用云产品 - 对接各个集群的方案是统一的 - 能够涵盖 - Ingress Nginx的能力 - 业务开发对于网关的日常需求 我们一期的目标是先弄南北向网关,二期看情况再弄服务网格(东西向) 所以本方案主要侧重在南北向网关的选择,但如果后期要弄服务网格也要能支持 基于上面的背景,业界可以选择的方案,大概有这些: - Istio Gateway - 阿里云ASM - 华为云ASM - Kubernetes Gateway API 先捋一下业务网关的基础能力 - HTTP 路由 - HTTP 流量拆分 ◦ Canary traffic rollout ◦ Blue-green traffic rollout ◦ …… - Timeout - Rewrite - Redirect - Header Modify - Request Mirror - WebSocket - Retry - Cors - Rate Limits - Circuit Breaking 上面的这些能力也涵盖住了Ingress Nginx的能力。 对于网关部分,大家有可能会想到为啥调研阿里云的MSE? 主要是MSE只能部署在阿里云的ACK中,对于非阿里云ACK及自建集群没法纳管。 下面开始逐一分析以上网关的优劣 网关优劣势分析 华为云ASM 基于Istio Gateway实现,能支持服务网格但实现方式Sidecar模式。 优势 - ASM 完全兼容istio CRD 劣势 - ASM基础版本(支持单集群、支持最大实例数是200、不保证SLA) - UCS 服务网格商用受限(现在还没有人用过) - 注1:ASM企业版已经下线了 - 不支持纳管别的云或者自建集群 - 同样存在路由优先级问题 阿里云ASM 基于Istio Gateway实现,能支持服务网格,支持:Sidecar、Ambient两种模式,不过现阶段生产不推荐Ambient模式。 优势 - 能提供SLA保证 - 不需要自己运维相关组件 - 支持纳管别的云及自建集群 - 针对业务集群阿里云对应地域没有regin及海外网络差的情况,有针对性方案:ASM远程控制面 - 功能特性全 - 同一控制面支持纳管多个kubernetes集群 *这里需要注意:这些集群都是类似镜像集群,集群内的ASM部署的CRD及下发的路由啥的都是一毛一样的,所以这个功能也没啥用 劣势 - 因为是直接基于Istio Gateway的,所以路由的顺序有问题 - 同一个虚拟服务内部,按照声明顺序来匹配,匹配到一个满足的就停止了 - 同一个域名,不同虚拟服务之间,顺序是不确定的 - 目前支持Kubernetes Gateway API v1.1.0,不支持v1.2.0的相关特性 - 标准 - HTTPRoute 超时 - 后端协议支持WebSocket 和 HTTP/2 Istio Gateway / Kubernetes Gateway API 对于Istio Gateway这里分为两种方案: - 对接标准,也就是Kubernetes Gateway API - 标准化:Kubernetes Gateway API旨在成为一种标准,用于描述在Kubernetes集群中如何暴露服务。这意味着它不仅仅限于与某个特定的服务网格或入口控制器(如Istio)一起使用。通过采用Gateway API,您可以更容易地切换不同的实现或服务提供商,而无需重写您的配置。 - 概念一致性:跟大家之前使用的MSE、APISIX保持一致 - 更好的集成能力:由于Gateway API是Kubernetes原生的API,因此它能够更好地与其他Kubernetes原生工具和系统集成 - 支持路由优先级 - 直接对接Istio Gateway - 现阶段Istio Gateway的能力比Kubernetes Gateway API涵盖的范围更广,比如:熔断、限流 - 但会引入Istio Gateway虚拟服务(VirtualService)、目标规则(DestinationRule)等概念,增加用户的学习成本 - 路由顺序不确定 到这里可以看出来只看路由优先级这么一个点就过滤掉了云ASM及直接对接Istio Gateway。 但还有几个点需要注意: - Kubernetes Gateway API目前不支持熔断、限流 - 对于跨域,也需要等到Milestone v1.3.0 (当前版本v1.2.1,下一个版本应该就是v1.3.0,但具体发布时间不确定) Service Mesh 从官网上看,Istio对于Kubernetes Gateway API服务网格的支持也已经GA。 Kubernetes Gateway API路由优先级 从HTTPRoutes生成的代理或负载均衡器路由配置必须根据以下标准优先匹配,并在出现平局时继续比较。在适用路由上指定的所有规则中,优先级应给予具有以下条件的匹配: - “精确”路径匹配。 - 具有最多字符的“前缀”路径匹配。 - 方法匹配。 - 最大数量的头部匹配。 - 最大数量的查询参数匹配。 注:正则表达式路径匹配的优先级是具体实现相关的。 如果在多个路由之间仍然存在平局,匹配优先级必须按照以下标准依次确定,并在出现平局时继续比较: - 基于创建时间戳的最早的路由。 - 按“{命名空间}/{名称}”字母顺序排列的第一个路由。 如果在HTTPRoute内仍然存在平局,则应将匹配优先级授予符合上述条件的第一个匹配规则(按列表顺序)。 如果未成功附加与请求匹配的规则到请求来源的父级,则必须返回HTTP 404状态码。 原文地址: https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1.HTTPRouteRule 从文档注释的位置可以判断同一个HTTPRouteRule内的多个HTTPRouteMatch应该是会满足以上规则的,那么多个HTTPRouteRule之间呢? 下面逐一验证下以上两种情况: 验证结果 针对常用的 - 多个前缀匹配之间的优先级 - 前缀匹配与精确匹配的优先级 进行验证,通过验证结果可以看出istio路由是遵循了Kubernetes Gateway API规范的。 结果 从文中所列举的几种方案来看,目前最可行的方案就是 - 自己部署Istio Gateway,然后直接对接Kubernetes Gateway API的CRD。 - 这个真实要用起来也要等Milestone v1.3.0 发布及istio支持之后 - 或者等阿里云ASM支持Milestone v1.3.0之后 其它 云ASM支持Kubernetes版本的范围及云ASM Istio版本 - 阿里云 - 华为云 Sidecar vs Ambient https://istio.io/latest/zh/docs/overview/dataplane-modes/#choosing-between-sidecar-and-ambient Istio 与 Kubernetes Gateway API Istio 支持 Kubernetes Gateway API, 并计划将其作为未来流量管理的默认 API。 Istio是如何实现Kubernetes Gateway API路由优先级的? https://github.com/istio/istio/blob/master/pilot/pkg/config/kube/gateway/conversion.go#L878 buildHTTPDestination ### Go HTTP 客户端设置的调优指南 - URL: https://jiankunking.com/tuning-the-go-http-client-library.html - Content type: original - Published: 2025-01-18 - Updated: 2025-01-18 - Summary: 记录 Go HTTP Client 产生大量 TIME_WAIT 的排查过程,分析连接复用、响应体处理和 Transport 参数调优。 - Categories: Go - Tags: Go, Network, TCP, Client, HTTP, IP, Settings, TIME_WAIT Article text: 文章速览 记录 Go HTTP Client 产生大量 TIME_WAIT 的排查过程,分析连接复用、响应体处理和 Transport 参数调优。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 记录一次Go HTTP Client TIME_WAIT的优化 业务流程 分析 通过容器监控发现服务到事件总线的负载均衡之间有大量的短链接,回看一下代码 发送请求的代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | func SendToKEvent(ev *KEvent) error { data, err := json.Marshal(ev.Data) if err != nil { return err } log.Println(string(data)) if !sendEvent { log.Println("------ SEND_EVENT IS DISABLED ------") return nil } defer util.TimeCost("SendToKEvent")() body := bytes.NewReader(data) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, err := http.NewRequest(http.MethodPost, ev.Url, body) if err != nil { return err } req.WithContext(ctx) req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("......", "......") for k, v := range ev.ExtMap { req.Header.Set(k, v) } resp, err := httpc.HttpClient.Do(req) if err != nil { return err } defer resp.Body.Close() // 事件总线 2xx 均为正常 if resp.StatusCode >= 300 || resp.StatusCode < 200 { return fmt.Errorf("req failed, resp=%v", resp) } return nil } | http client的代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | var ( HttpClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: func(ctx context.Context, network, addr string) (conn net.Conn, e error) { return (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 90 * time.Second, }).DialContext(ctx, network, addr) }, ForceAttemptHTTP2: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 30 * time.Second, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, } ) | 代码看起来没啥问题,但想到了之前处理过Golang ES client的一个问题 https://jiankunking.com/tcp-state-diagram.html 看下上文中TIME_WAIT部分,发现还真是 https://pkg.go.dev/net/http#Response 1 2 3 4 5 6 | // The http Client and Transport guarantee that Body is always // non-nil, even on responses without a body or responses with // a zero-length body. It is the caller's responsibility to // close Body. The default HTTP client's Transport may not // reuse HTTP/1.x "keep-alive" TCP connections if the Body is // not read to completion and closed. | 调整代码 1 2 3 4 5 6 7 8 9 10 11 | func SendToKEvent(ev *KEvent) error { ...... resp, err := httpc.HttpClient.Do(req) if err != nil { return err } defer resp.Body.Close() io.Copy(ioutil.Discard, resp.Body) // <-- 添加这一行 ...... return nil } | 重新部署后,发现TIME_WAIT的链接少了很多,但还是有10几个 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | bash-5.0# netstat -anp |grep TIME tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:10964 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:45738 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:21178 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:37354 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:10966 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:37352 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:61524 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:61526 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:21180 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:33256 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:45736 TIME_WAIT - tcp 0 0 ::ffff:172.16.3.247:8080 ::ffff:10.200.76.64:33254 TIME_WAIT - bash-5.0# | 这里需要注意一下 - 172.16.3.247是服务POD的ip - 10.200.76.64是POD所在宿主机的ip 也就是说POD跟宿主机之间有短链接,那这几个短链接是在做啥呢? 抓包看下 着重看一下No 502这一行 1 2 3 4 5 6 7 8 9 10 11 12 13 | Frame 502: 177 bytes on wire (1416 bits), 177 bytes captured (1416 bits) Ethernet II, Src: ee:ee:ee:ee:ee:ee (ee:ee:ee:ee:ee:ee), Dst: b6:cd:6a:f8:69:5e (b6:cd:6a:f8:69:5e) Internet Protocol Version 4, Src: 10.200.76.64, Dst: 172.16.3.247 Transmission Control Protocol, Src Port: 29978, Dst Port: 8080, Seq: 1, Ack: 1, Len: 111 Hypertext Transfer Protocol GET /healthz HTTP/1.1\r\n <-- 注意这一行,这个接口是服务配置的存活检查接口 Host: 172.16.3.247:8080\r\n User-Agent: kube-probe/1.21\r\n Accept: */*\r\n Connection: close\r\n <-- 注意这一行 \r\n [Response in frame: 506] [Full request URI: http://172.16.3.247:8080/healthz] | Connection - Connection: keep-alive 当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接 - Connection: close 代表一个Request完成后,客户端和服务器之间用于传输HTTP数据的TCP连接会关闭, 当客户端再次发送Request,需要重新建立TCP连接。 从Connection的注释可以看出当请求header中带有Connection: keep-alive表明该请求是会是一个短链接。 看下服务的Deployment的配置 1 2 3 4 5 6 7 8 9 | livenessProbe: failureThreshold: 3 httpGet: path: /healthz port: 8080 scheme: HTTP periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 | 到这里问题都可以解释的通了,Kubernetes会每10秒请求一次服务的存活检查的接口,每一次都是短链接,而TIME_WAIT的默认值是120s。 那服务TIME_WAIT的链接应该会一直保持在11-13个左右。 到这里所有的问题都就可以解释了。 结论 - Go HTTP Client请求完了,即使业务不关注响应的Body,还是要在代码中read一下body。 - 只要服务配置了存活检查就会有短链接,短链接的数据取决于检查间隔时间的配置。 拓展阅读 - TCP 状态图 ### 如何优化Elasticsearch大文档查询? - URL: https://jiankunking.com/how-to-optimize-elasticsearch-large-document-query.html - Content type: original - Published: 2025-01-11 - Updated: 2025-01-11 - Summary: B端商城业务场景下,接口毛刺严重,耗时主要集中在ES查询。本文记录一次复杂的DSL优化过程,从问题定位到优化方案落地。 - Categories: Elasticsearch - Tags: Performance, Elasticsearch, Document, Size, Query, Large Article text: 文章速览 B端商城业务场景下,接口毛刺严重,耗时主要集中在ES查询。本文记录一次复杂的DSL优化过程,从问题定位到优化方案落地。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch B端商城业务场景下,接口毛刺严重,耗时主要集中在ES查询。本文记录一次复杂的DSL优化过程,从问题定位到优化方案落地。 背景 B端商城业务有一个场景就是客户可见的产品列表是需要N多闸口及各种其它逻辑组合过滤的,各种闸口数据及产品数据都是存储在ES的(有的是独立索引,有的是作为产品属性存储在产品文档上)。 在实际使用的过程中,发现接口的毛刺比较严重,而这部分毛刺请求的耗时基本都是花费在从ES中查询产品索引的时候。 开启了一下ES慢DSL的日志 1 2 3 4 5 6 7 | PUT /jiankunking_product_prod/_settings { "index.search.slowlog.threshold.query.warn": "10s", "index.search.slowlog.threshold.query.info": "5s", "index.search.slowlog.threshold.fetch.warn": "2s", "index.indexing.slowlog.source": true } | 经过分析慢DSL日志发现耗时长的部分都是在fetch阶段。 这里有个地方需要注意 1 2 3 4 5 6 7 8 9 10 11 | [root@jiankunking-search-01: /data/es/logs]# ls -lrth |grep -v .gz total 2.2G -rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_audit.json -rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_index_indexing_slowlog.log -rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_index_indexing_slowlog.json -rw-r--r-- 1 es es 53M Dec 31 2023 jiankunking_deprecation.log -rw-r--r-- 1 es es 108M Dec 31 2023 jiankunking_deprecation.json -rw-r--r-- 1 es es 55K Jul 30 10:43 jiankunking_server.json -rw-r--r-- 1 es es 52K Jul 30 10:43 jiankunking.log -rw-r--r-- 1 es es 63M Jul 30 11:32 jiankunking_index_search_slowlog.log //这里是完整的DSL -rw-r--r-- 1 es es 8.9M Jul 30 11:32 jiankunking_index_search_slowlog.json //这里的DSL会被截断 | 分析 已知问题点 - 产品文档身上有4个属性会很大 - 属性A(nested属性):可以到几万个 - 属性B(nested属性):可以到几百个 - 属性C(string数组):可以到几万个 - 属性D(大Object):可以到几万个 - ES fetch阶段慢,其实就是从相关分片请求文档内容慢(这时候id其实已经知道了) 大体就是下图这么个流程 下面简化一下请求的DSL,看下移除所有复杂的查询逻辑后,直接按照_id来terms查询效果如何? DSL 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | GET /jiankunking_product_prod/_search { "size": 10000, "_source": { "includes": [ "code", "group", "groupBrand" ], "excludes": [] }, "query": { "terms": { "_id": [ "具体文档_id" ] } } } | 不同文档大小查询时延 当前分析的DSL原本命中的文档数就是8306 下表中的文档数是直接在terms中查询的id数 文档数 文档大小(Bytes) 文档大小(KB) 响应时延(ms) 备注 8306 | <=6,766,797 | <=6,608 | 5908 | | 5908 | <500,000 | <488 | 2327 | 剔除大的 | 6929 | <200,000 | <195 | 1507 | 剔除大的 | 5731 | <100,000 | <97 | 599 | 剔除大的 | 4925 | <50,000 | <49 | 356 | 剔除大的 | 4236 | <30,000 | <29 | 214 | 剔除大的(注意这里,当文档大小比较小的时候,4000+的文档查询其实是比较快的) | —- | —- | —- | —- | —- | 4070 | >30,000 | >29 | 6261 | 剔除小的 | 3381 | >50,000 | >49 | 6050 | 剔除小的 | 2572 | >100,000 | >97 | 5388 | 剔除小的 | 1377 | >200,000 | >195 | 4973 | 剔除小的 | 669 | >500,000 | >488 | 3984 | 剔除小的 | 381 | >1,000,000 | >976 | 3169 | 剔除小的 | 217 | >2,000,000 | >1,952 | 2391 | 剔除小的 | 88 | >3,000,000 | >2,928 | 1244 | 剔除小的 | 从大文档开始删除 从小文档开始删除 分析 - 文档数与文档大小查询分析 - 剔除大文档之后,查询数据效率提升明显 - 剔除小文档之后,查询数据效率提升缓慢 到这里我们可以发现当文档size比较小的时候几千个文档的查询RT是很短的,但当随着请求命中的大文档越来越多,RT极速增加。 回看下我们的产品索引数据,可以发现大字段其实都是用来过滤的,并不是返回给页面需要的;那我们是不是可以:将索引拆分为两个或者ES只用来作为二级索引返回ids,然后去MySQL中查询具体的产品信息? 那我们将慢DSL中中查询的字段修改为只返回_id 1 2 3 4 5 6 7 8 9 10 11 | POST /jiankunking_product_prod/_search { "size": 10000, "_source": false, "query": { "terms": { "_id": [""], "boost": 1 } } } | 这时候查询耗时只需要203ms,这种情况下还能不能再优化了呢?答案是可以的 索引中文档_id就是产品的code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | POST /jiankunking_product_prod/_search { "size": 10000, "_source": false, "stored_fields": "_none_", "docvalue_fields": [ "code" ], "query": { "terms": { "code": [ "" ], "boost": 1 } } } | 这时候查询只需要76ms。 结论 到这里这次优化基本结束了,最终的方案就是 - 通过从jiankunking_product_prod索引中通过列存获取ids - 到MySQL或者新的产品主数据索引中查询具体的产品数据 思考 为啥不直接从jiankunking_product_prod索引中通过列存获取前端需要的数据呢? 因为真实业务场景中需要返回的产品属性虽然每个不大,但总数有20多个,列存在返回字段数多且命中文档大小都不大的场景下,相比原逻辑直接从_source中取会略有下降。 更多原理性解释,可以看下这里:https://jiankunking.com/elasticsearch-source-doc-values-and-store-performance.html ES适合的场景都有哪些? 目前我这边遇到的场景主要有: - 检索加速 - 数据查询的主存储 - 当文档大小不是太大的时候,索引检索完直接返回需要的数据 - 二级索引 - 针对的就是本文这种场景 - 日志 - 应用/容器日志 - 这里追求的更多是高吞吐的写入 - 业务日志 具体索引中数据大小是什么情况呢? 分位数 大小 (KB) 0.050 | 1.159 | 0.100 | 1.388 | 0.150 | 1.609 | 0.200 | 1.693 | 0.250 | 1.774 | 0.300 | 2.140 | 0.350 | 2.969 | 0.400 | 3.499 | 0.450 | 3.898 | 0.500 | 4.238 | 0.550 | 4.917 | 0.600 | 5.730 | 0.650 | 7.157 | 0.700 | 8.823 | 0.750 | 13.122 | 0.800 | 32.320 | 0.850 | 57.531 | 0.900 | 114.387 | 0.950 | 262.478 | 0.990 | 989.708 | 0.991 | 1,099.801 | 0.992 | 1,259.971 | 0.993 | 1,481.723 | 0.994 | 1,807.947 | 0.995 | 2,155.884 | 0.996 | 2,392.959 | 0.997 | 2,725.635 | 0.998 | 3,171.288 | 0.999 | 4,238.397 | 来自官方的点赞 拓展阅读 - https://jiankunking.com/elasticsearch-source-doc-values-and-store-performance.html - https://jiankunking.com/elasticsearch-scroll-and-search-after.html - https://luis-sena.medium.com/stop-using-the-id-field-in-elasticsearch-6fb650d1fbae - https://jiankunking.com/elasticsearch-avoid-the-fetch-phase-when-retrieving-only-id.html - https://jiankunking.com/elasticsearch-query-secret.html - https://www.elastic.co/guide/en/elasticsearch/reference/current/general-recommendations.html ### 又一次不接受Elasticsearch官方建议导致的事故 - URL: https://jiankunking.com/another-incident-caused-by-not-accepting-elasticsearch-official-advice.html - Content type: original - Published: 2025-01-08 - Updated: 2025-01-08 - Summary: 复盘 Elasticsearch 集群因索引和分片数量失控、mmap 与本地内存压力导致写入超时和集群变红的事故。 - Categories: Debugging - Tags: Elasticsearch, Shard, Production-Incident, mmap, Native-Memory Article text: 文章速览 复盘 Elasticsearch 集群因索引和分片数量失控、mmap 与本地内存压力导致写入超时和集群变红的事故。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Debugging 记录一下 一次Elasticsearch集群事故分析、排查、处理 背景 上午同事拉群反馈他们在用的一个ES集群出现写入超时的情况,查看ES集群状态发现已经是RED了 - 这是一个3 * 8核32G(jvm堆配置了16G)的集群 - 出问题的时候,集群内有5000个索引,每个索引shard数是6,副本数是1,也就是一个索引是12个shard - shard数已经达到了6w个,这不包含ES自带的系统索引。 - 集群的实例重启过 - 数据量大约480G 看到这里是不是有一种似曾相识的感觉? => 一次不接受Elasticsearch官方建议导致的事故 立马联系业务删除历史数据,业务这边明确可以删除22年、23年的历史数据。 处理 调速 在业务执行批量删除后,我这边先调大了集群恢复相关的参数 1 2 3 4 5 6 7 | PUT /_cluster/settings { "persistent": { "cluster.routing.allocation.node_concurrent_recoveries": 200, "indices.recovery.max_bytes_per_sec": "80mb" } } | 关于恢复速度相关参数解释:https://elastic.ac.cn/guide/en/elasticsearch/reference/8.17/recovery.html 在集群恢复的期间,ES实例一直在输出WARN日志 1 2 3 4 | Unable to acquire permit to use snapshot files during recovery, this recovery will recover index files from the source node. Ensure snapshot files can be used during recovery by setting [indices.recovery.max_concurrent_snapshot_file_downloads] to be no greater than [25] | 看了下集群设置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | GET /_cluster/settings?include_defaults=true { "recovery": { "recovery_activity_timeout": "1800000ms", "retry_delay_network": "5s", "internal_action_timeout": "15m", "max_concurrent_snapshot_file_downloads_per_node": "25", "retry_delay_state_sync": "500ms", "max_concurrent_snapshot_file_downloads": "5",// 这里并没有比25大 "internal_action_long_timeout": "1800000ms", "max_concurrent_operations": "1", "use_snapshots": "true", "max_concurrent_file_chunks": "2" } } | 所以先忽略,恢复后,该日志就不再输出了。 排查 找了一个ES节点下载最近几天的日志,排查实例退出的原因 实例退出前有大量如下异常输出 1 2 3 4 5 | this may be caused by lack of enough unfragmented virtual address space or too restrictive virtual memory limits enforced by the operating system, preventing us to map a chunk of 414627 bytes. Please review 'ulimit -v', 'ulimit -m' (both should return 'unlimited'), and 'sysctl vm.max_map_count'. More information: http://blog.thetaphi.de/2012/07/use-lucenes-mmapdirectory-on-64bit.html | 这里看了一下max_map_count没有问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | [root@prd-jiankunking-notify-es-3: /app/es/data/nodes/0]# ulimit -m unlimited [root@prd-jiankunking-notify-es-3: /app/es/data/nodes/0]# ulimit -v unlimited [root@prd-jiankunking-notify-es-3: /app/es/data/nodes/0]# [root@prd-jiankunking-notify-es-3: /app/es/data/nodes/0]# sysctl vm.max_map_count vm.max_map_count = 262144 [root@prd-jiankunking-notify-es-3: /app/es/data/nodes/0]# docker exec -it 21871c061981 /bin/bash root@prd-jiankunking-notify-es-3:/usr/share/elasticsearch# ulimit -m unlimited root@prd-jiankunking-notify-es-3:/usr/share/elasticsearch# ulimit -v unlimited root@prd-jiankunking-notify-es-3:/usr/share/elasticsearch# sysctl vm.max_map_count vm.max_map_count = 262144 | 262144是elasticsearch 7.16.2启动的时候提示的最小值 找到实例退出时间点的日志 1 2 3 4 5 6 7 | OpenJDK 64-Bit Server VM warning: INFO: os::commit_memory(0x00007fe4d805c000, 65536, 1) failed; error='Not enough space' (errno=12) # There is insufficient memory for the Java Runtime Environment to continue. # Native memory allocation (mmap) failed to map 65536 bytes for committing reserved memory. [thread 330 also had an error][thread 232 also had an error] # An error report file with more information is saved as # logs/hs_err_pid7.log | 再看下logs/hs_err_pid7.log的日志内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | # # There is insufficient memory for the Java Runtime Environment to continue. # Native memory allocation (mmap) failed to map 65536 bytes for committing reserved memory. # Possible reasons: # The system is out of physical RAM or swap space # The process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap # Possible solutions: # Reduce memory load on the system # Increase physical memory or swap space # Check if swap backing store is full # Decrease Java heap size (-Xmx/-Xms) # Decrease number of Java threads # Decrease Java thread stack sizes (-Xss) # Set larger code cache with -XX:ReservedCodeCacheSize= # JVM is running with Zero Based Compressed Oops mode in which the Java heap is # placed in the first 32GB address space. The Java Heap base address is the # maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress # to set the Java Heap base and to place the Java Heap above 32GB virtual address. # This output file may be truncated or incomplete. # # Out of Memory Error (os_linux.cpp:2728), pid=7, tid=213 # # JRE version: OpenJDK Runtime Environment Temurin-17.0.1+12 (17.0.1+12) (build 17.0.1+12) # Java VM: OpenJDK 64-Bit Server VM Temurin-17.0.1+12 (17.0.1+12, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, linux-amd64) # Core dump will be written. Default location: /usr/share/elasticsearch/core.7 # --------------- S U M M A R Y ------------ Command Line: -Xshare:auto -Des.networkaddress.cache.ttl=60 -Des.networkaddress.cache.negative.ttl=10 -XX:+AlwaysPreTouch -Xss1m -Djava.awt.headless=true -Dfile.encoding=UTF-8 -Djna.nosys=true -XX:-OmitStackTraceInFastThrow -XX:+ShowCodeDetailsInExceptionMessages -Dio.netty.noUnsafe=true -Dio.netty.noKeySetOptimization=true -Dio.netty.recycler.maxCapacityPerThread=0 -Dio.netty.allocator.numDirectArenas=0 -Dlog4j.shutdownHookEnabled=false -Dlog4j2.disable.jmx=true -Dlog4j2.formatMsgNoLookups=true -Djava.locale.providers=SPI,COMPAT --add-opens=java.base/java.io=ALL-UNNAMED -Xms16g -Xmx16g -XX:+UseG1GC -Djava.io.tmpdir=/tmp/elasticsearch-18266345477890424435 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=data -XX:ErrorFile=logs/hs_err_pid%p.log -Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m -Des.cgroups.hierarchy.override=/ -XX:MaxDirectMemorySize=8589934592 -XX:InitiatingHeapOccupancyPercent=30 -XX:G1ReservePercent=25 -Des.path.home=/usr/share/elasticsearch -Des.path.conf=/usr/share/elasticsearch/config -Des.distribution.flavor=default -Des.distribution.type=docker -Des.bundled_jdk=true org.elasticsearch.bootstrap.Elasticsearch -Ebootstrap.memory_lock=true Host: Intel(R) Xeon(R) CPU E5-2682 v4 @ 2.50GHz, 8 cores, 31G, Ubuntu 20.04.3 LTS Time: Wed Jan 8 00:44:25 2025 UTC elapsed time: 28895.244676 seconds (0d 8h 1m 35s) | 结论 到这里可以看出是ES在申请Native memory allocation (mmap) 的时候内存不足,进程退出了。 数据删除后,通过 1 | GET /_cat/health?v | 看到active_shards_percent恢复进度到100%后,但还有19个shard unassign 看下具体没有分配的原因 1 | GET /_cluster/allocation/explain | 返回内容如下 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | { "reason" : "ALLOCATION_FAILED", "at" : "2025-01-07T16:28:22.026Z", "failed_allocation_attempts" : 5, "details" : """failed shard on node [IYowNoBkTzq_WPjXgb_10Q]: failed recovery, failure RecoveryFailedException[[notification-jiankunking-2025.01][3]: Recovery failed on {es-notify-203}{IYowNoBkTzq_WPjXgb_10Q} {nzTGfgdBQvykqKacU_cyzA}{127.0.0.203}{127.0.0.203:9301}{cdfhilmrstw}{ml.machine_memory=33568223232, xpack.installed=true, transform.node=true, ml.max_open_jobs=512, ml.max_jvm_size=17179869184}]; nested: IndexShardRecoveryException[failed to recover from gateway]; nested: EngineCreationFailureException[failed to open reader on writer]; nested: IOException[Map failed: MMapIndexInput(path="/usr/share/elasticsearch/data/nodes/0/indices/n5E4PEFpTKKDgGdNmEqSPQ/3/index/_j50_Lucene80_0.dvd") [this may be caused by lack of enough unfragmented virtual address space or too restrictive virtual memory limits enforced by the operating system, preventing us to map a chunk of 4321640 bytes. Please review 'ulimit -v', 'ulimit -m' (both should return 'unlimited'), and 'sysctl vm.max_map_count'. More information: http://blog.thetaphi.de/2012/07/use-lucenes-mmapdirectory-on-64bit.html]]; """, "last_allocation_status" : "no" } | 手动重新分配下 1 | POST /_cluster/reroute?retry_failed=true | 集群状态red->yellow->green 业务清理历史数据后,集群 - shard数降为4w - 数据量降为335.3 GB ### 2024年终总结 - URL: https://jiankunking.com/2024-year-end-summary.html - Content type: original - Published: 2024-12-31 - Updated: 2024-12-31 - Summary: 回顾2024年在B端系统稳定性治理方面的工作,以及腰、背、手治疗、家庭、财务和阅读上的变化,并记录对新一年的健康与生活期望。 - Categories: Year-End Summary - Tags: Year-End Summary, 2024 Article text: 文章速览 回顾2024年在B端系统稳定性治理方面的工作,以及腰、背、手治疗、家庭、财务和阅读上的变化,并记录对新一年的健康与生活期望。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Year-End Summary 2024年工作集中在B端系统稳定性治理;生活方面持续治疗腰背手问题,家庭状况有所好转。 又是一年 工作 今年的工作内容主要集中在B端系统的稳定性治理: 生活 个人 10月份开始了针对于腰、背、手的治疗(基本上就是每个周末去一次),到年底手指已经不需要整天贴膏药了。 年底29号去的时候,大夫说,后面如果没有疼痛,就逐渐拉长治疗间隔。 也算是一个好消息。 家庭 同去年,还是略显艰辛。 财务 - 提前还了一些贷款。 读书 今年感触比较大的几段话: - 真没必要为三五年后的前途担忧,大家都乐观点,说不定到时候都物是人非,瞬间豁然开朗。当下便是最好的时光,及时行乐,与世界和解。我们不妨暂时不去想那些烦恼,去听喜欢的歌,拍想拍的照片,吃喜欢的甜点。给好运一点时间,只管做自己,好运一定会如约而至! - 你年少时曾梦想自己将来会成为一个什么样的人,会做出多么了不起的事,但现在发现,自己其实也不过如此。你可能做了很多努力,但是只能帮助你从一个小茄子长成一个大茄子,却始终变不成一个西瓜,无法达到质变。按我们的说法是,受到了阶级的桎梏。 新一年的期望 - 家人健健康康、平平安安。 - 自己减肥成功。 - 饭搭子高血压、头晕,也算是给我敲了警钟,毕竟我也没比他瘦几斤。 23年的愿望成功拖到了24年 ### ElasticSearch为什么不能在query阶段直接返回_id,从而避免fetch? - URL: https://jiankunking.com/elasticsearch-avoid-the-fetch-phase-when-retrieving-only-id.html - Content type: original - Published: 2024-11-24 - Updated: 2024-11-24 - Summary: 解释 Elasticsearch 查询为何不能在 query 阶段直接返回文档 _id,以及 fetch 阶段仍然存在的原因和可行优化。 - Categories: Elasticsearch - Tags: Elasticsearch, fetch, phase, _id Article text: 文章速览 解释 Elasticsearch 查询为何不能在 query 阶段直接返回文档 _id,以及 fetch 阶段仍然存在的原因和可行优化。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 整理自Github的一个issue,也正好解答了我的疑惑 https://github.com/elastic/elasticsearch/issues/17159 提问 是否可以避免搜索的fetch阶段并仅返回文档ID?查询阶段结束时是否有_id,这样当我只需要_id时,fetch就多余了?可以通过当前API完成此操作吗? 最终,我希望能够比目前所见的速度更快地从搜索中检索文档ID。我已经尝试了所有文档中记录的各种方法来获得更好的性能,但没有找到令人满意的结果。我所取得的最佳成果只是通过并行查询每个5个分片中的每个分片而获得了25%的速度提升。一个可接受的速度提升应该快90%。了解这是否合理以及如果不合理的原因将会很有帮助。很难理解为什么我可以快速得到a)前100个结果,b)总计数,以及c)快速排序它们,但检索结果却非常慢。 此外,通过开发插件是否有可能提高此(仅限ID)场景的性能?是否有其他选项,无论是记录在案还是未记录在案,可以减少开销? 强调一下这一点的重要性,这对我们的实施至关重要,很可能是我们决定采用Elastic以替换当前庞大的持久性层的关键因素。 回答 搜索阶段获取 Lucene的文档 ID(整数),而不是 elasticsearch 的 ID(字符串)。fetch阶段使用 Lucene 的存储字段机制查找文档 ID。存储字段以压缩块的形式存储在一起。由于 _source 是一个存储字段,因此您必须解压缩大量 _source 才能获得 ID 字段。由于它是分块的,因此您还必须解压缩未命中的文档的存储字段。 聚合速度很快,因为它们使用文档值(doc values),这是一种非分块的列式结构。它经过压缩,但使用的是数值技巧,而不是通用的压缩算法。如果能够将您的工作重新设计为一个聚合操作,通过将感兴趣的工作推送到 Elasticsearch,那么您的操作速度可以提升数个数量级。 一个推荐/优化 1 2 3 4 5 6 7 8 9 | GET test/_search { "query": { "match_all": {} }, "_source": false, "stored_fields": "_none_", "docvalue_fields": ["my_id_field"] } | - “stored_fields”: “none“:这将禁止检索所有存储字段,如 _source 和 _id。 - “docvalue_fields”: [“my_id_field”]:这样就可以只检索选定的 doc_values 字段。 来源 - https://luis-sena.medium.com/stop-using-the-id-field-in-elasticsearch-6fb650d1fbae ### [译]Elasticsearch Sequence ID实现思路及用途 - URL: https://jiankunking.com/elasticsearch-sequence-ids-6-0.html - Content type: translation - Published: 2024-11-22 - Updated: 2024-11-22 - Summary: 介绍 Elasticsearch Sequence ID 的实现原理,以及 Primary Term、Sequence Number 和 Checkpoint 在恢复与复制中的作用。 - Categories: Elasticsearch - Tags: Elasticsearch, Cluster, Sequence, PrimaryTerm, Checkpoint - Original source: https://www.elastic.co/blog/elasticsearch-sequence-ids-6-0 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 工具进阶:如何利用 MAT 找到问题发生的根本原因 - URL: https://jiankunking.com/how-to-use-mat-to-identify-the-root-cause-of-a-problem.html - Content type: original - Published: 2024-11-09 - Updated: 2024-11-09 - Summary: MAT(Memory Analyzer Tool)是Java内存分析利器。本文介绍如何利用MAT分析堆转储文件,定位OOM、内存泄漏等问题的根本原因。 - Categories: Java - Tags: Java, Heap, Dump, MAT Article text: 文章速览 MAT(Memory Analyzer Tool)是Java内存分析利器。本文介绍如何利用MAT分析堆转储文件,定位OOM、内存泄漏等问题的根本原因。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java MAT(Memory Analyzer Tool)是Java内存分析利器。本文介绍如何利用MAT分析堆转储文件,定位OOM、内存泄漏等问题的根本原因。 我们知道,在存储用户输入的密码时,会使用一些 hash 算法对密码进行加工,比如 SHA-1。这些信息同样不允许在日志输出里出现,必须做脱敏处理,但是对于一个拥有系统权限的攻击者来说,这些防护依然是不够的。攻击者可能会直接从内存中获取明文数据,尤其是对于 Java 来说,由于提供了 jmap 这一类非常方便的工具,可以把整个堆内存的数据 dump 下来。 比如,“我的世界”这一类使用 Java 开发的游戏,会比其他语言的游戏更加容易破解一些,所以我们在 JVM 中,如果把密码存储为 char 数组,其安全性会稍微高一些。 这是一把双刃剑,在保证安全的前提下,我们也可以借助一些外部的分析工具,帮助我们方便的找到问题根本。 有两种方式来获取内存的快照。我们前面提到过,通过配置一些参数,可以在发生 OOM 的时候,被动 dump 一份堆栈信息,这是一种;另一种,就是通过 jmap 主动去获取内存的快照。 jmap 命令在 Java 9 之后,使用 jhsdb 命令替代,它们在用法上,区别不大。注意,这些命令本身会占用操作系统的资源,在某些情况下会造成服务响应缓慢,所以不要频繁执行。 1 2 | jmap -dump:format=b,file=heap.bin 37340 jhsdb jmap --binaryheap --pid 37340 | 1. 工具介绍 有很多工具能够帮助我们来分析这份内存快照。在前面已多次提到 VisualVm 这个工具,它同样可以加载和分析这份 dump 数据,虽然比较“寒碜”。 专业的事情要有专业的工具来做,今天要介绍的是一款专业的开源分析工具,即 MAT。 MAT 工具是基于 Eclipse 平台开发的,本身是一个 Java 程序,所以如果你的堆快照比较大的话,则需要一台内存比较大的分析机器,并给 MAT 本身加大初始内存,这个可以修改安装目录中的 MemoryAnalyzer.ini 文件。 来看一下 MAT 工具的截图,主要的功能都体现在工具栏上了。其中,默认的启动界面,展示了占用内存最高的一些对象,并有一些常用的快捷方式。通常,发生内存泄漏的对象,会在快照中占用比较大的比重,分析这些比较大的对象,是我们切入问题的第一步。 点击对象,可以浏览对象的引用关系,这是一个非常有用的功能: - outgoing references 对象的引出 - incoming references 对象的引入 path to GC Roots 这是快速分析的一个常用功能,显示和 GC Roots 之间的路径。 另外一个比较重要的概念,就是浅堆(Shallow Heap)和深堆(Retained Heap),在 MAT 上经常看到这两个数值。 浅堆代表了对象本身的内存占用,包括对象自身的内存占用,以及“为了引用”其他对象所占用的内存。 深堆是一个统计结果,会循环计算引用的具体对象所占用的内存。但是深堆和“对象大小”有一点不同,深堆指的是一个对象被垃圾回收后,能够释放的内存大小,这些被释放的对象集合,叫做保留集(Retained Set)。 如上图所示,A 对象浅堆大小 1 KB,B 对象 2 KB,C 对象 100 KB。A 对象同时引用了 B 对象和 C 对象,但由于 C 对象也被 D 引用,所以 A 对象的深堆大小为 3 KB(1 KB + 2 KB)。 A 对象大小(1 KB + 2 KB + 100 KB)> A 对象深堆 > A 对象浅堆。 2. 代码示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.IntStream; public class Objects4MAT { static class A4MAT { B4MAT b4MAT = new B4MAT(); } static class B4MAT { C4MAT c4MAT = new C4MAT(); } static class C4MAT { List list = new ArrayList<>(); } static class DominatorTreeDemo1 { DominatorTreeDemo2 dominatorTreeDemo2; public void setValue(DominatorTreeDemo2 value) { this.dominatorTreeDemo2 = value; } } static class DominatorTreeDemo2 { DominatorTreeDemo1 dominatorTreeDemo1; public void setValue(DominatorTreeDemo1 value) { this.dominatorTreeDemo1 = value; } } static class Holder { DominatorTreeDemo1 demo1 = new DominatorTreeDemo1(); DominatorTreeDemo2 demo2 = new DominatorTreeDemo2(); Holder() { demo1.setValue(demo2); demo2.setValue(demo1); } private boolean aBoolean = false; private char aChar = '\0'; private short aShort = 1; private int anInt = 1; private long aLong = 1L; private float aFloat = 1.0F; private double aDouble = 1.0D; private Double aDouble_2 = 1.0D; private int[] ints = new int[2]; private String string = "1234"; } Runnable runnable = () -> { Map map = new HashMap<>(); IntStream.range(0, 100).forEach(i -> { byte[] bytes = new byte[1024 * 1024]; String str = new String(bytes).replace('\0', (char) i); A4MAT a4MAT = new A4MAT(); a4MAT.b4MAT.c4MAT.list.add(str); map.put(i + "", a4MAT); }); Holder holder = new Holder(); try { //sleep forever , retain the memory Thread.sleep(Integer.MAX_VALUE); } catch (InterruptedException e) { e.printStackTrace(); } }; void startHugeThread() throws Exception { new Thread(runnable, "huge-thread").start(); } public static void main(String[] args) throws Exception { Objects4MAT objects4MAT = new Objects4MAT(); objects4MAT.startHugeThread(); } } | 2.1. 代码介绍 我们以一段代码示例 Objects4MAT,来具体看一下 MAT 工具的使用。代码创建了一个新的线程 “huge-thread”,并建立了一个引用的层级关系,总的内存大约占用 100 MB。同时,demo1 和 demo2 展示了一个循环引用的关系。最后,使用 sleep 函数,让线程永久阻塞住,此时整个堆处于一个相对“静止”的状态。 如果你是在本地启动的示例代码,则可以使用 Accquire 的方式来获取堆快照。 2.2. 内存泄漏检测 如果问题特别突出,则可以通过 Find Leaks 菜单快速找出问题。 如下图所示,展示了名称叫做 huge-thread 的线程,持有了超过 96% 的对象,数据被一个 HashMap 所持有。 对于特别明显的内存泄漏,在这里能够帮助我们迅速定位,但通常内存泄漏问题会比较隐蔽,我们需要更加复杂的分析。 2.3. 支配树视图 支配树视图对数据进行了归类,体现了对象之间的依赖关系。如图,我们通常会根据“深堆”进行倒序排序,可以很容易的看到占用内存比较高的几个对象,点击前面的箭头,即可一层层展开支配关系。 图中显示的是其中的 1 MB 数据,从左侧的 inspector 视图,可以看到这 1 MB 的 byte 数组具体内容。 从支配树视图同样能够找到我们创建的两个循环依赖,但它们并没有显示这个过程。 支配树视图的概念有一点点复杂,我们只需要了解这个概念即可。 如上图,左边是引用关系,右边是支配树视图。可以看到 A、B、C 被当作是“虚拟”的根,支配关系是可传递的,因为 C 支配 E,E 支配 G,所以 C 也支配 G。 另外,到对象 C 的路径中,可以经过 A,也可以经过 B,因此对象 C 的直接支配者也是根对象。同理,对象 E 是 H 的支配者。 我们再来看看比较特殊的 D 和 F。对象 F 与对象 D 相互引用,因为到对象 F 的所有路径必然经过对象 D,因此,对象 D 是对象 F 的直接支配者。 可以看到支配树视图并不一定总是能看到对象的真实应用关系,但对我们分析问题的影响并不是很大。 这个视图是非常好用的,甚至可以根据 package 进行归类,对目标类的查找也是非常快捷的。 编译下面这段代码,可以展开视图,实际观测一下支配树,这和我们上面介绍的是一致的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | public class DorminatorTreeDemo { static class A { C c; byte[] data = new byte[1024 * 1024 * 2]; } static class B { C c; byte[] data = new byte[1024 * 1024 * 3]; } static class C { D d; E e; byte[] data = new byte[1024 * 1024 * 5]; } static class D { F f; byte[] data = new byte[1024 * 1024 * 7]; } static class E { G g; byte[] data = new byte[1024 * 1024 * 11]; } static class F { D d; H h; byte[] data = new byte[1024 * 1024 * 13]; } static class G { H h; byte[] data = new byte[1024 * 1024 * 17]; } static class H { byte[] data = new byte[1024 * 1024 * 19]; } A makeRef(A a, B b) { C c = new C(); D d = new D(); E e = new E(); F f = new F(); G g = new G(); H h = new H(); a.c = c; b.c = c; c.e = e; c.d = d; d.f = f; e.g = g; f.d = d; f.h = h; g.h = h; return a; } static A a = new A(); static B b = new B(); public static void main(String[] args) throws Exception { new DorminatorTreeDemo().makeRef(a, b); Thread.sleep(Integer.MAX_VALUE); } } | 2.4. 线程视图 想要看具体的引用关系,可以通过线程视图。我们在第 5 讲,就已经了解了线程其实是可以作为 GC Roots 的。如图展示了线程内对象的引用关系,以及方法调用关系,相对比 jstack 获取的栈 dump,我们能够更加清晰地看到内存中具体的数据。 如下图,我们找到了 huge-thread,依次展开找到 holder 对象,可以看到循环依赖已经陷入了无限循环的状态。这在查看一些 Java 对象的时候,经常发生,不要感到奇怪。 2.5. 柱状图视图 我们返回头来再看一下柱状图视图,可以看到除了对象的大小,还有类的实例个数。结合 MAT 提供的不同显示方式,往往能够直接定位问题。也可以通过正则过滤一些信息,我们在这里输入 MAT,过滤猜测的、可能出现问题的类,可以看到,创建的这些自定义对象,不多不少正好一百个。 右键点击类,然后选择 incoming,这会列出所有的引用关系。 再次选择某个引用关系,然后选择菜单“Path To GC Roots”,即可显示到 GC Roots 的全路径。通常在排查内存泄漏的时候,会选择排除虚弱软等引用。 使用这种方式,即可在引用之间进行跳转,方便的找到所需要的信息。 再介绍一个比较高级的功能。 我们对于堆的快照,其实是一个**“瞬时态”**,有时候仅仅分析这个瞬时状态,并不一定能确定问题,这就需要对两个或者多个快照进行对比,来确定一个增长趋势。 可以将代码中的 100 改成 10 或其他数字,再次 dump 一份快照进行比较。如图,通过分析某类对象的增长,即可辅助问题定位。 3. 高级功能—OQL MAT 支持一种类似于 SQL 的查询语言 OQL(Object Query Language),这个查询语言 VisualVM 工具也支持。 以下是几个例子,你可以实际实践一下。 查询 A4MAT 对象: 1 | SELECT * FROM Objects4MAT$A4MAT | 正则查询 MAT 结尾的对象: 1 | SELECT * FROM ".*MAT" | 查询 String 类的 char 数组: 1 2 | SELECT OBJECTS s.value FROM java.lang.String s SELECT OBJECTS mat.b4MAT FROM Objects4MAT$A4MAT mat | 根据内存地址查找对象: 1 | select * from 0x55a034c8 | 使用 INSTANCEOF 关键字,查找所有子类: 1 | SELECT * FROM INSTANCEOF java.util.AbstractCollection | 查询长度大于 1000 的 byte 数组: 1 | SELECT * FROM byte[] s WHERE s.@length>1000 | 查询包含 java 字样的所有字符串: 1 | SELECT * FROM java.lang.String s WHERE toString(s) LIKE ".*java.*" | 查找所有深堆大小大于 1 万的对象: 1 | SELECT * FROM INSTANCEOF java.lang.Object o WHERE o.@retainedHeapSize>10000 | 如果你忘记这些属性的名称的话,MAT 是可以自动补全的。 OQL 有比较多的语法和用法,若想深入了解,可参考这里。 一般,我们使用上面这些简单的查询语句就够用了。 OQL 还有一个好处,就是可以分享。如果你和同事同时在分析一个大堆,不用告诉他先点哪一步、再点哪一步,共享给他一个 OQL 语句就可以了。 如下图,MAT 贴心的提供了复制 OQL 的功能,但是用在其他快照上,不会起作用,因为它复制的是如下的内容。 4. 小结 这一讲我们介绍了 MAT 工具的使用,其是用来分析内存快照的;在最后,简要介绍了 OQL 查询语言。 在 Java 9 以前的版本中,有一个工具 jhat,可以以 html 的方式显示堆栈信息,但和 VisualVm 一样,都太过于简陋,推荐使用 MAT 工具。 我们把问题设定为内存泄漏,但其实 OOM 或者频繁 GC 不一定就是内存泄漏,它也可能是由于某次或者某批请求频繁而创建了大量对象,所以一些严重的、频繁的 GC 问题也能在这里找到原因。有些情况下,占用内存最多的对象,并不一定是引起内存泄漏问题的元凶,但我们也有一个比较通用的分析过程。 并不是所有的堆都值得分析的,我们在做这个耗时的分析之前,需要有个依据。比如,经过初步调优之后,GC 的停顿时间还是较长,则需要找到频繁 GC 的原因;再比如,我们发现了内存泄漏,需要找到是谁在搞鬼。 首先,我们高度关注快照载入后的初始分析,占用内存高的 topN 对象,大概率是问题产生者。 对照自己的代码,首先要分析的,就是产生这些大对象的逻辑。举几个实际发生的例子。有一个 Spring Boot 应用,由于启用了 Swagger 文档生成器,但是由于它的 API 关系非常复杂,嵌套层次又非常深(每次要产生几百 M 的文档!),结果请求几次之后产生了内存溢出,这在 MAT 上就能够一眼定位到问题;而另外一个应用,在读取数据库的时候使用了分页,但是 pageSize 并没有做一些范围检查,结果在请求一个较大分页的时候,使用 fastjson 对获取的数据进行加工,直接 OOM。 如果不能通过大对象发现问题,则需要对快照进行深入分析。使用柱状图和支配树视图,配合引入引出和各种排序,能够对内存的使用进行整体的摸底。由于我们能够看到内存中的具体数据,排查一些异常数据就容易得多。 可以在程序运行的不同时间点,获取多份内存快照,对比之后问题会更加容易发现。我们还是用一个例子来看。有一个应用,使用了 Kafka 消息队列,开了一般大小的消费缓冲区,Kafka 会复用这个缓冲区,按理说不应该有内存问题,但是应用却频繁发生 GC。通过对比请求高峰和低峰期间的内存快照,我们发现有工程师把消费数据放入了另外一个 “内存队列”,写了一些画蛇添足的代码,结果在业务高峰期一股脑把数据加载到了内存中。 上面这些问题通过分析业务代码,也不难发现其关联性。问题如果非常隐蔽,则需要使用 OQL 等语言,对问题一一排查、确认。 可以看到,上手 MAT 工具是有一定门槛的,除了其操作模式,还需要对我们前面介绍的理论知识有深入的理解,比如 GC Roots、各种引用级别等。 在很多场景,MAT 并不仅仅用于内存泄漏的排查。由于我们能够看到内存上的具体数据,在排查一些难度非常高的 bug 时,MAT 也有用武之地。比如,因为某些脏数据,引起了程序的执行异常,此时,想要找到它们,不要忘了 MAT 这个老朋友。 ### 一次线程池使用错误导致的问题 - URL: https://jiankunking.com/a-problem-caused-by-a-thread-pool-usage-error.html - Content type: original - Published: 2024-11-02 - Updated: 2024-11-02 - Summary: 复盘一次线程池使用错误导致服务线程数异常飙升的问题,记录从监控告警、线程栈与堆内存分析,到定位重复创建线程池并完成修复的全过程。 - Categories: Java - Tags: Executor, ThreadPool, Java, CPU-Saturation Article text: 文章速览 复盘一次线程池使用错误导致服务线程数异常飙升的问题,记录从监控告警、线程栈与堆内存分析,到定位重复创建线程池并完成修复的全过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 通过监控发现服务线程数异常飙升,排查发现是线程池使用方式错误导致。本文记录完整的排查过程和解决方案,帮助理解线程池的正确使用姿势。 背景 通过监控发现一个服务的线程数异常多 同期CPU 内存 网络连接都没有什么异常。 排查 第一个反应就是查看线程栈 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | "pool-2493-thread-3" #3718833 prio=5 os_prio=0 tid=0x00007f1610041000 nid=0x38bff6 waiting on condition [0x00007f18ba29e000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007a044cce0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) "pool-2493-thread-2" #3718832 prio=5 os_prio=0 tid=0x00007f161003f800 nid=0x38bff5 waiting on condition [0x00007f18bd502000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007a044cce0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) "pool-2493-thread-1" #3718823 prio=5 os_prio=0 tid=0x00007f161003e000 nid=0x38bff3 waiting on condition [0x00007f18c2725000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007a044cce0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) | 本来想从线程栈中找到线程是从哪里创建的,但从线程栈现在只能看到线程是由ThreadPoolExecutor创建的,只能看到线程运行态的东西。 那么heap中会有吗? 名词 值 Object/Stack Frame | java.lang.Thread @ 0x7a044af30 | Name | pool-2493-thread-3 | Shallow Heap | 120 | Retained Heap | 1,728 | Max. Locals’ Retained Heap | | Context Class Loader | org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader @ 0x71d665028 | Is Daemon | false | State | [alive, parked, waiting, waiting indefinitely] | State value | 0x291 | 这里只是比线程栈多了一个Context Class Loader别的没有什么有用的信息。 回看下线程栈信息,发现一个规律[pool-2493-thread-1] pool后面的数字的递增的,但thread后面数字基本都是1 2 3 从这篇文章Naming Executor Service Threads and Thread Pool in Java 可以推断出,应该在代码中有位置初始化了线程池,并且核心线程数是3,在代码中搜索了一下,还真找到了这么一段代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // import cn.hutool.core.thread.ThreadUtil; @GetMapping(value = "/jiankunkingTransfer") public Map jiankunkingTransfer(String date) { Executor executor = ThreadUtil.newExecutor(3, 6, 30000); // todo 执行一些逻辑处理 for (Object c : json.getJSONArray("data")) { executor.execute(() -> { // todo 执行一些逻辑处理 }); } // 注意这里没有用CountDownLatch来await() // 也没有shutdown() } | 一开始以为局部创建的线程会被GC 回收掉,然后通过Arthas vmtool强制GC了一把,发现线程并没有减少。 为啥没有回收呢? => Does an ExecutorService get garbage collected when out of scope? => 这里提到了线程池的shutdown(),但此时的我还没有将重点放在这里。 => 导致我认为没有被回收的线程是全局变量,一顿排查操作并没有发现全局且很大的线程池 => 再次搜索找到查找答案: https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html 在JDK的官方文档中找到这么一段话 1 2 3 4 5 | Finalization A pool that is no longer referenced in a program AND has no remaining threads will be shutdown automatically. If you would like to ensure that unreferenced pools are reclaimed even if users forget to call shutdown(), then you must arrange that unused threads eventually die, by setting appropriate keep-alive times, using a lower bound of zero core threads and/or setting allowCoreThreadTimeOut(boolean). | => 程序中不再引用且没有剩余线程的地将自动shutdown。如果您想确保即使用户忘记调用shutdown也能回收未引用的地,那么您必须通过设置适当的保持活动时间、使用零核心线程的下限和/或设置allowCoreThreadTimeOut(boolean)来安排未使用的线程最终死亡allowCoreThreadTimeOut(boolean) 那么为什么退出作用域了,线程不会回收呢? => https://stackoverflow.com/questions/7728102/why-doesnt-this-thread-pool-get-garbage-collected 结论 通过官方文档可以看出解决这个问题有下面几种方式 - 线程池 设置成 成员变量 (推荐) - 使用threadPoolExecutor.shutdown(); 方法关闭线程池(会等待任务执行结束) - 设置核心线程超时关闭 allowCoreThreadTimeOut(true); - 核心线程数设置为0, 但在使用上会带来别的麻烦(略) 问题的处理方式已经清楚了,下面再来看下shutdown()跟线程池默认的命名规则 拓展 shutdown()实现 shutdown就是将线程池状态设置为SHUTDOWN,然后中断所有空闲(空闲即阻塞在队列上)的线程,最终设置线程池状态为Terminated。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | /** * Initiates an orderly shutdown in which previously submitted * tasks are executed, but no new tasks will be accepted. * Invocation has no additional effect if already shut down. * *

This method does not wait for previously submitted tasks to * complete execution. Use {@link #awaitTermination awaitTermination} * to do that. * * @throws SecurityException {@inheritDoc} */ public void shutdown() { final ReentrantLock mainLock = this.mainLock; // 加锁(全局锁) mainLock.lock(); try { // 权限校验,安全策略相关判断 checkShutdownAccess(); // 设置线程池状态为SHUTDOWN。 advanceRunState(SHUTDOWN); // 中断所有的空闲的工作线程 interruptIdleWorkers(); // 空方法,留给子类实现 onShutdown(); // hook for ScheduledThreadPoolExecutor } finally { // 解锁 mainLock.unlock(); } tryTerminate(); } | https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html#shutdown-- 线程池默认的命名规则 java.util.concurrent.DefaultThreadFactory 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | static class DefaultThreadFactory implements ThreadFactory { private static final AtomicInteger poolNumber = new AtomicInteger(1); private final ThreadGroup group; private final AtomicInteger threadNumber = new AtomicInteger(1); private final String namePrefix; DefaultThreadFactory() { SecurityManager s = System.getSecurityManager(); group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-"; } public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); if (t.isDaemon()) t.setDaemon(false); if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); return t; } } | 从代码中可以看出默认线程的名字是按照=> pool-线程池编号(从1开始自增)-thread-线程池中线程数量(从1开始自增) 如何自定义线程名字可以参考下面这个 https://stackoverflow.com/questions/6113746/naming-threads-and-thread-pools-of-executorservice ### 一次Fegin CPU占用过高导致的事故 - URL: https://jiankunking.com/an-accident-caused-by-excessive-cpu-usage-of-fegin.html - Content type: original - Published: 2024-10-13 - Updated: 2024-10-13 - Summary: 复盘一次 Feign 调用导致 CPU 占用过高和接口响应变慢的生产事故,记录告警、定位、根因与处理过程。 - Categories: Debugging - Tags: ThreadPool, Java, CPU-Saturation, Feign Article text: 文章速览 复盘一次 Feign 调用导致 CPU 占用过高和接口响应变慢的生产事故,记录告警、定位、根因与处理过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Debugging 记录一次应用事故分析、排查、处理 收到CPU告警,业务反馈接口响应耗时过长。本文完整记录从告警发现、问题定位到根因分析的全过程,最终定位到Fegin调用导致的CPU占用过高问题。 背景介绍 9号上午收到CPU告警,同时业务反馈依赖该服务的上游服务接口响应耗时太长 1 2 3 4 5 6 7 8 | 应用告警-CPU使用率 告警变更 【WARNING】项目XXX,集群qd-aliyun,分区bbbb-prod,应用customer,实例customer-6fb6448688-m47jz, POD实例CPU请求使用率 >= 90.000000% 当前值138.4971051199925% 发生时间:2024/10/09 11:17:33 项目XXX,集群qd-aliyun,分区bbbb-prod,应用customer,实例customer-6fb6448688-28pvs, POD实例CPU请求使用率 >= 90.000000% 当前值157.7076205766934%告警已恢复 发生时间: 2024/10/09 11:06:33 恢复时间: 2024/10/09 12:24:33 | 服务访问量 单实例峰值QPS100左右 为啥要关注QPS,因为QPS100不应该消耗这么多CPU啊,而且请求、响应体都不大。 POD监控 POD配额 - CPU请求 2 Core CPU上限 3 Core - 内存请求 7GiB 内存上限 9GiB 从图中可以看出 - CPU负载一直很高 - TCP链接及线程数从11点40开始陡峭上升 Arms 看下Trace监控发现,耗时主要是customer通过fegin调用外围接口导致的。 临时方案 临时处理方案:扩实例并增加CPU配置。 根因分析 此处略过排查三方接口跟开放平台网关的过程,此处的结论是:依赖的三方接口跟开放平台网关没有问题。 为啥会先排查三方接口跟开放平台网关是因为中Trace上来看是调用三方接口响应时间过长。 从Arms图看可以看出 - CPU耗时集中在fegin调用的Decoder、Encoder - Decoder、Encoder耗时都集中在 - HttpMessageConverters#getDefaultConverters()=> - WebMvcConfigurationSupport#addDefaultHttpMessageConverters=> - ……(具体调用链看下方摘要) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | feign.ReflectiveFeign$BuildTemplateByResolvingArgs.create(Object[]) (14.37%, 1.43 minutes) feign.ReflectiveFeign$BuildEncodedTemplateFromArgs.reesolve(Object[], RequestTemplate, Map) (14.37%, 1.43minutes) org.springframework.cloud.openfeign.support.SpringEndcoder.encode(Object, Type, RequestTemplate) (14.28%,1.42 minutes) com.jiankunking.common.core.feign.FeignClientsConfig$$ambda$938.56729293.get0bject() (13.98%, 1.39 minutes com.jiankunking.common.core.feign.FeignClientsConfig.lambda$feignEncoder$2() (13.98%, 1.39 minutees) org.springframework.boot.autoconfigure.http.HttpmessaageConverters.(HttpMessageConverter[]) (12.03%,1.19 minutes) prg.springframework.boot.autoconfigure.http.Http.HttpMessageConverters.(Collection) (12.03%, 119 minutes) org.springframework.boot.autoconfigure.http.HttpmessaageConverters.(boolean, Collection) (12.03%, 1.19 minutes) prg.springframework.boot.autoconfigure.http.Http.HttpMessageConverters.getDefaultConverters()(12.02%, 1.19 minutes org.springframework.boot.autoconfigure.http.HttpmessageConverters$1.defaultMessageConverters() (12.02%, 119 minutes) org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport.getMessageConverters() (12.02%, 1.19 minutes) org.springframework.web.servlet.config.annotation. WebMvcConfigurationSupport.addDefaultHttpMessageConverters(List) (12.02%, 1 org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.build() (5.93%, 0.59 minutes) org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.configure(ObjectMapper)(5.91%, 0.59 minutes) org.springframework.http.converter.json.Jackson2Objec:tMapperBuilder.registerWellKnownModulesIfAvailable(Map)(5.89%,0.58 min org.springframework.util.ClassUtils.forName(String, CClassLoader)(5.84%, 0.58 minutes) java.lang.Class.forName(String, boolean, Classloader) (5.83%, 0.58 minutes) java.lang.Class.forName0(String, boolean, ClassLoader, Class) (5.83%, 0.58 minutes) ...... | 自定义Encoder、Decoder Encoder 看下jiankunking.common.core.feign.FeignClientsConfig中的Encoder 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public Encoder feignEncoder() { ObjectFactory objectFactory = () -> new HttpMessageConverters(new RMappingJackson2HttpMessageConverter()); return new SpringEncoder(objectFactory); } public class RMappingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter { public RMappingJackson2HttpMessageConverter(ObjectMapper objectMapper) { super(objectMapper); List mediaTypes = new ArrayList<>(); mediaTypes.add(MediaType.valueOf(MediaType.APPLICATION_JSON_UTF8_VALUE)); mediaTypes.add(MediaType.valueOf(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")); setSupportedMediaTypes(mediaTypes); } RMappingJackson2HttpMessageConverter() { List mediaTypes = new ArrayList<>(); mediaTypes.add(MediaType.valueOf(MediaType.APPLICATION_JSON_UTF8_VALUE)); mediaTypes.add(MediaType.valueOf(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")); setSupportedMediaTypes(mediaTypes); } } | Decoder 看下jiankunking.common.core.feign.FeignClientsConfig中的Decoder 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public Decoder feignDecoder() { HttpMessageConverter jacksonConverter = new MappingJackson2HttpMessageConverter(customObjectMapper()); ObjectFactory objectFactory = () -> new HttpMessageConverters(jacksonConverter); return new ResponseEntityDecoder(new RSpringDecoder(objectFactory)); } public ObjectMapper customObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new StringToDateModule()); objectMapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); objectMapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return objectMapper; } | Google了一下:‘spring feign encode jackson cpu usage high’ => https://segmentfault.com/a/1190000043037032 => https://mp.weixin.qq.com/s/RuqltkN9VdVQ1K3GKuJ-Gw => https://meantobe.github.io/2019/12/21/ClassLoader/ 源码分析 查看registerWellKnownModulesIfAvailable处的代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | @SuppressWarnings("unchecked") private void registerWellKnownModulesIfAvailable(Map modulesToRegister) { try { Class jdk8ModuleClass = (Class) ClassUtils.forName("com.fasterxml.jackson.datatype.jdk8.Jdk8Module", this.moduleClassLoader); Module jdk8Module = BeanUtils.instantiateClass(jdk8ModuleClass); modulesToRegister.put(jdk8Module.getTypeId(), jdk8Module); } catch (ClassNotFoundException ex) { // jackson-datatype-jdk8 not available } try { Class javaTimeModuleClass = (Class) ClassUtils.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", this.moduleClassLoader); Module javaTimeModule = BeanUtils.instantiateClass(javaTimeModuleClass); modulesToRegister.put(javaTimeModule.getTypeId(), javaTimeModule); } catch (ClassNotFoundException ex) { // jackson-datatype-jsr310 not available } // Joda-Time present? if (ClassUtils.isPresent("org.joda.time.LocalDate", this.moduleClassLoader)) { try { Class jodaModuleClass = (Class) ClassUtils.forName("com.fasterxml.jackson.datatype.joda.JodaModule", this.moduleClassLoader); Module jodaModule = BeanUtils.instantiateClass(jodaModuleClass); modulesToRegister.put(jodaModule.getTypeId(), jodaModule); } catch (ClassNotFoundException ex) { // jackson-datatype-joda not available } } // Kotlin present? if (KotlinDetector.isKotlinPresent()) { try { Class kotlinModuleClass = (Class) ClassUtils.forName("com.fasterxml.jackson.module.kotlin.KotlinModule", this.moduleClassLoader); Module kotlinModule = BeanUtils.instantiateClass(kotlinModuleClass); modulesToRegister.put(kotlinModule.getTypeId(), kotlinModule); } catch (ClassNotFoundException ex) { if (!kotlinWarningLogged) { kotlinWarningLogged = true; logger.warn("For Jackson Kotlin classes support please add " + "\"com.fasterxml.jackson.module:jackson-module-kotlin\" to the classpath"); } } } } | 可以看到其逻辑为若classpath中有JodaTime的LocalDate,则加载Jackson对应的JodaModule.LaunchedURLClassLoader. 为啥没有怀疑jdk8ModuleClass、javaTimeModuleClass这两个地方呢?因为common包中已经依赖了下面两个包 1 2 | compile "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${v.jacksonDatatype}" compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${v.jacksonDatatype}" | 那么解决方案就很清晰了 解决方案 避免ClassLoader反复加载 将这个依赖添加到工程中。加载一次后,再次调用可以通过findLoadedClass获得,减少加载类导致的资源消耗。 1 2 3 4 5 | com.fasterxml.jackson.datatype jackson-datatype-joda x.x.x | 避免HttpMessageConverters重复初始化 1 2 3 4 5 6 7 8 9 10 11 12 13 | public Decoder feignDecoder() { HttpMessageConverter jacksonConverter = new MappingJackson2HttpMessageConverter(customObjectMapper()); ObjectFactory objectFactory = () -> new HttpMessageConverters(false, Collections.singletonList(jacksonConverter)); return new ResponseEntityDecoder(new RSpringDecoder(objectFactory)); } public Encoder feignEncoder() { HttpMessageConverter jacksonConverter = new RMappingJackson2HttpMessageConverter(customObjectMapper()); ObjectFactory objectFactory = () -> new HttpMessageConverters(false, Collections.singletonList(jacksonConverter)); return new SpringEncoder(objectFactory); } | 注意这种处理方式,会导致feign返回值简单类型的调用异常,比如返回是String会抛出异常 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | feign.codec.DecodeException: Error while extracting response for type [class java.lang.String] and content type [application/json]; nested exception is org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `java.lang.String` out of START_OBJECT token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.lang.String` out of START_OBJECT token at [Source: (PushbackInputStream); line: 1, column: 1] at feign.SynchronousMethodHandler.decode(SynchronousMethodHandler.java:180) ~[feign-core-10.1.0.jar!/:na] at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:140) ~[feign-core-10.1.0.jar!/:na] at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:78) ~[feign-core-10.1.0.jar!/:na] at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:103) ~[feign-core-10.1.0.jar!/:na] at com.sun.proxy.$Proxy219.token(Unknown Source) ~[na:na] at com.jiankunking.gateway.controller.AuthController.getAccessTokenByCode(AuthController.java:399) [classes!/:na] at com.jiankunking.gateway.controller.AuthController.getCustomerToken(AuthController.java:86) [classes!/:na] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_242] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_242] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_242] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_242] at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:189) [spring-web-5.1.6.RELEASE.jar!/:5.1.6.RELEASE] at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138) [spring-web-5.1.6.RELEASE.jar!/:5.1.6.RELEASE] at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:102) [spring-webmvc-5.1.6.RELEASE.jar!/:5.1.6.RELEASE] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:892) [spring-webmvc-5.1.6.RELEASE.jar!/:5.1.6.REL… ### Elasticsearch不停机切换(上云)方案 - URL: https://jiankunking.com/elasticsearch-non-stop-cloud-solution.html - Content type: original - Published: 2024-09-21 - Updated: 2024-09-21 - Summary: 如何给飞行中的飞机换引擎?本文介绍Elasticsearch不停机切换上云的完整方案,解决数据迁移、双写同步、平滑切换等核心问题。 - Categories: Elasticsearch - Tags: Elasticsearch, 停机, 切换, 上云 Article text: 文章速览 如何给飞行中的飞机换引擎?本文介绍Elasticsearch不停机切换上云的完整方案,解决数据迁移、双写同步、平滑切换等核心问题。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 如何给飞行中的飞机换引擎?本文介绍Elasticsearch不停机切换上云的完整方案,解决数据迁移、双写同步、平滑切换等核心问题。 如何给飞行中的飞机换引擎? 背景 - 业务背景 - 略 - 技术背景 - 线下集群40个索引左右,总数据量不大,不到100G - 因为ES承担的业务鉴权业务,所以不能接受停机割接 - 还有就是ES中数据来自各个业务方,推送的时机不定,也没有完备的重推机制,所以不能停机割接 - 索引中基本都没有创建或者更新时间字段,即使部分有,也没有用起来 - 也就无法使用logstash的增量同步功能。 - 希望不进行业务改造,直接替换。 - 虽然服务分为了读写服务,但通过读服务还是可以调用写入的API,通过写服务也可以调用读的API。 架构方案 - 全量数据同步logstash - 脚步比对出来的差异数据,脚步补数 注意: - CLB及代理层的配置一定有冗余 - 如果个CLB支撑不了,可以考虑 - 方式一:直接申请多个CLB,并将这多个CLB的地址配置到应用中 - 方式二:先申请一个EIP,在EIP的后面配置多个CLB,这样应用只配置一个EIP的地址就可以了 - 方式三:CLB直接升配到NLB - CLB文档 - NLB文档 - 准备两套CLB及代理层的原因是:代理层是个Nginx集群,手动一台一台更新配置然后reload很慢,这时候数据写入的主ES是不确定的。 - 比对核心逻辑 - 获取线下集群所有索引(跳过系统所以及不需要迁移的索引) - 遍历第一步获取到的索引集合 - 获取线上、线下索引的文档总数,如果总数不一样,终止比对; - 如果总数一样,则通过search after(需要)分页分别从线上、线下获取数据比对。 注意:search_after的排序字段集合有几个要求 - 如果_id就是业务ID,则直接使用该字段; - 如果_id是ES自动生成的ID,则需要使用业务ID字段来排序(需要保证该业务ID索引内部不重复;如果不能保证,则需要添加其他字段来保证唯一;保证唯一的目的就是比对的两个索引在相同位置的文档就应该是一样的,不一样就是有问题); - 如果无法找到能构建复合主键的字段,则需要将索引数据完整的拉到内存中,然后根据mapping将所有字段拼接构建组合ID,然后去重,再依次比对。(索引条数不一样的,也可以通过类似的方式来查找异常的原因;采取这种简单粗暴方式的原因是:1、我们这种类型索引的数据量不大 2、这个比对程序其实就是个临时的工具,不会长期使用) 模板、mapping、index setting这些都需要比对。 比对核心代码 MapFlatUtil.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | import java.util.*; /** * @Author jiankunking * @Date 2024/9/4 17:13 * @Description: */ public class MapFlatUtil { static String PREFIX = "."; public static Map flat(Map map) { Map configMap = new LinkedHashMap<>(); map.entrySet().forEach(entry -> { if (entry.getValue() instanceof Map) { Map subMap = flat(entry.getKey(), (Map) entry.getValue()); if (!subMap.isEmpty()) { configMap.putAll(subMap); } } else if (entry.getValue() instanceof List) { configMap.put(entry.getKey(), entry.getValue()); } else { configMap.put(entry.getKey(), entry.getValue() == null ? "" : String.valueOf(entry.getValue())); } }); return configMap; } private static Map flat(String parentNode, Map source) { Map flatMap = new LinkedHashMap<>(); Set> set = source.entrySet(); set.forEach(entity -> { Object value = entity.getValue(); String key = entity.getKey(); String newKey = parentNode + PREFIX + key; if (value instanceof Map) { flatMap.putAll(flat(newKey, (Map) value)); } else if (value instanceof List) { flatMap.put(newKey, value); } else { flatMap.put(newKey, value == null ? "" : String.valueOf(value)); } }); return flatMap; } } | MapCompareUtil.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static com.jiankunking.branchcompare.es.SortUtil.mapComparator; /** * @Author jiankunking * @Date 2024/9/14 9:48 * @Description: */ @Slf4j public class MapCompareUtil { public static boolean isMapEquals(Map offlineMap, Map onlineMap) throws JsonProcessingException { offlineMap = MapFlatUtil.flat(offlineMap); onlineMap = MapFlatUtil.flat(onlineMap); if (offlineMap.size() != onlineMap.size()) { return false; } for (Map.Entry offlineEntry : offlineMap.entrySet()) { String offlineEntryKey = offlineEntry.getKey(); if (!onlineMap.containsKey(offlineEntryKey)) { return false; } Object offlineEntryValue = offlineEntry.getValue(); Object onlineEntryValue = onlineMap.get(offlineEntryKey); Class offlineEntryValueClass = offlineEntryValue.getClass(); Class onlineEntryValueClass = onlineEntryValue.getClass(); if (offlineEntryValueClass != onlineEntryValueClass) { log.warn("value type not equals,offlineEntryValue:" + offlineEntryValueClass.getName() + ",onlineEntryValue:" + onlineEntryValueClass.getName()); return false; } if (offlineEntryValue instanceof Map) { Map offlineMapValue = (Map) offlineEntryValue; Map onlineMapValue = (Map) onlineEntryValue; if (!isMapEquals(offlineMapValue, onlineMapValue)) { return false; } continue; } else if (offlineEntryValue instanceof List) { List offlineList = (List) offlineEntryValue; List onlineList = (List) onlineEntryValue; if (offlineList.size() != onlineList.size()) { log.warn("list size not equals,offlineList:" + offlineList.size() + ",onlineList:" + onlineList.size()); return false; } // List if (!offlineList.isEmpty() && offlineList.get(0) instanceof Map) { List> offlineEntryValueTmp = (List>) offlineEntryValue; List> onlineEntryValueTmp = (List>) onlineEntryValue; List sorts = new ArrayList<>(); // 按照map 的key 排序 for (Map.Entry entry : offlineEntryValueTmp.get(0).entrySet()) { sorts.add(new SortUtil.Sort(entry.getKey(), SortUtil.Order.ASC)); } List> offlineEntryValueSorted = offlineEntryValueTmp.stream() .sorted(mapComparator(sorts)) .collect(Collectors.toList()); List> onlineEntryValueSorted = onlineEntryValueTmp.stream() .sorted(mapComparator(sorts)) .collect(Collectors.toList()); for (int i = 0; i < offlineEntryValueSorted.size(); i++) { Object offlineListItem = offlineEntryValueSorted.get(i); Object onlineListItem = onlineEntryValueSorted.get(i); if (!isMapEquals((Map) offlineListItem, (Map) onlineListItem)) { return false; } } } else { // List<简单类型> offlineList.sort(Comparator.comparing(o -> o.toString())); onlineList.sort(Comparator.comparing(o -> o.toString())); for (int i = 0; i < offlineList.size(); i++) { Object offlineListItem = offlineList.get(i); Object onlineListItem = onlineList.get(i); if (!simpleObjectEquals(offlineListItem, onlineListItem)) { log.warn("list item not equals,offlineListItem:" + offlineListItem + ",onlineListItem:" + onlineListItem); return false; } } } continue; } if (!simpleObjectEquals(offlineEntryValue, onlineEntryValue)) { log.warn("map value not equals,offlineEntryValue:" + offlineEntryValue + ",onlineEntryValue:" + onlineEntryValue); return false; } } return true; } // 只能处理简单对象 不能处理Map List等复杂类型 private static boolean simpleObjectEquals(Object o1, Object o2) throws JsonProcessingException { String offlineJson = new ObjectMapper().writeValueAsString(o1); String onlineJson = new ObjectMapper().writeValueAsString(o2); if (offlineJson.equals(onlineJson)) { return true; } return false; } } | SortUtil.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; /** * @Author jiankunking * @Date 2024/9/5 14:00 * @Description: https://gist.github.com/IOsetting/25ca8d70c12c11390113d343f666cd6e */ public class SortUtil { public enum Order {ASC, DESC} /** * @param sorts keys and sort direction * @return sorted list */ public static Comparator> mapComparator(List sorts) { return (o1, o2) -> { int ret = 0; for (Sort sort : sorts) { Object v1 = o1.get(sort.field); Object v2 = o2.get(sort.field); ret = singleCompare(v1, v2, sort.order == Order.ASC); if (ret != 0) { break; } } return ret; }; } public static class Sort { public String field; public Order order; public Sort(String field, Order order) { this.field = field; this.order = order; } } private static int singleCompare(Object ao, Object bo, boolean asc) { int ret; if (ao == null && bo == null) { ret = 0; } else if (ao == null) { ret = -1; } else if (bo == null) { ret = 1; } else if (ao instanceof BigDecimal) { ret = ((BigDecimal) ao).compareTo((BigDecimal) bo); } else if (ao instanceof Number) { if (((Number) ao).doubleValue() != ((Number) bo).doubleValue()) { ret = ((Number) ao).doubleValue() > ((Number) bo).doubleValue() ? 1 : -1; } else { ret = 0; } } else if (ao instanceof Date) { ret = ((Date) ao).compareTo((Date) bo); } else { ret = String.valueOf(ao).compareTo(String.valueOf(bo)); } if (!asc) { return -ret; } return ret; } public static void main(String[] args) { List> list = new ArrayList<>(); List sorts = new ArrayList<>(); List> sorted = list.stream() .sorted(mapComparator(sorts)) .collect(Collectors.toList()); for (Map map : sorted) { System.out.println(map.get("somekey")); } } } | EsQueryUtil.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | public static SearchResponse searchAfterByMultiFields(RestHighLevelClient restHighLevelClient, String indexName, List searchAfterSortFields, List searchAfterValues, int size) throws IOException { SearchSourceBuilder builder = new SearchSourceBuilder(); builder.size(size); builder.trackTotalHits(true); builder.query(QueryBuilders.matchAllQuery()); // USING SEARCH AFTER if (searchAfterValues != null && !searchAfterValues.isEmpty()) { builder.searchAfter(searchAfterValues.toArray()); } for (String sortField : searchAfterSortFields) { builder.sort(sortField, SortOrder.ASC); } SearchRequest searchRequest = new SearchRequest(); searchRequest.indices(indexName); searchRequest.source(builder); // log.info(searchRequest.toString()); log.info(searchRequest.source().toString()); SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT); return response; } static List getSearchAfterValues(List searchAfterSortFields, SearchHit hit) { List searchAfterValues = new ArrayList<>(searchAfterSortFields.size()); Map map = hit.getSourceAsMap(); for (String field : searchAfterSortFields) { if (field.equals("_id")) { searchAfterValues.add(hit.getId()); } else { searchAfterValues.add(map.get(field)); } } return searchAfterValues; } | 反思 - 要拉通全流程及相关人员,核对每个可能出现的问题及应对方案 - 有些东西不能因为是临时的就放松警惕性 - 比如本次代理层申请的机器是有两块的盘:1、一个50G的系统盘 2、一个500G的数据盘;但最终落地的时候云厂商同学还是把nginx的访问日志落到了系统盘,导致系统盘满了,系统受到的影响。 - 这个500G的盘当时还讨论过,要用来存储访问日志,防止机器磁盘写满。 - 任务列表也梳理了代理层遇到问题要发送告警,但没有一一核实,导致系统盘满的时候,没有第一时间收到告警。 - 只要是在核心链路上的,不管是不是临时的,必须一一测试、验证。 ### OAuth 2 PKCE - URL: https://jiankunking.com/oauth-2-pkce.html - Content type: original - Published: 2024-06-15 - Updated: 2024-06-15 - Summary: OAuth 2.0 PKCE协议详解,通过codeverifier和codechallenge增强公共客户端的安全性,防止授权码拦截攻击。 - Categories: Security - Tags: Security, PKCE Article text: 文章速览 OAuth 2.0 PKCE协议详解,通过codeverifier和codechallenge增强公共客户端的安全性,防止授权码拦截攻击。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Security OAuth 2.0 PKCE协议详解,通过code_verifier和code_challenge增强公共客户端的安全性,防止授权码拦截攻击。 OAuth 2 作者: 王新栋 PKCE 协议,全称是 Proof Key for Code Exchange by OAuth Public Clients。 下面看一下PKCE 协议的交互流程 注意,为了突出第三方软件使用 PKCE 协议时与授权服务之间的通信过程,省略了受保护资源服务和资源拥有者的角色: 首先,App 自己要生成一个随机的、长度在 43~128 字符之间的、参数为 code_verifier 的字符串验证码;接着,我们再利用这个 code_verifier,来生成一个被称为”挑战码”的参数code_challenge。 那怎么生成这个 code_challenge 的值呢?OAuth 2.0 规范里面给出了两种方法,就是看code_challenge_method 这个参数的值: - 一种 code_challenge_method=plain,此时 code_verifier 的值就是 code_challenge的值; - 另外一种 code_challenge_method=S256,就是将 code_verifier 值进行 ASCII 编码之后再进行哈希,然后再将哈希之后的值进行 BASE64-URL 编码,如下代码所示: 1 | code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) | 那么如何使用呢? 在第一步获取授权码 code 的时候,我们使用 code_challenge 参数。需要注意的是,我们要同时将 code_challenge_method 参数也传过去,目的是让授权服务知道生成code_challenge 值的方法是 plain 还是 S256。 1 2 3 4 5 6 | https://authorization-server.com/auth? response_type=code& app_id=APP_ID& redirect_uri=REDIRECT_URI& code_challenge=CODE_CHALLENGE& code_challenge_method=S256 | 在第二步获取访问令牌的时候,我们使用 code_verifier 参数,授权服务此时会将code_verifier 的值进行一次运算。那怎么运算呢?就是上面code_challenge_method=S256 的这种方式。 没错,第一步请求授权码的时候,已经告诉授权服务生成 code_challenge 的方法了。所以,在第二步的过程中,授权服务将运算的值跟第一步接收到的值做比较,如果相同就颁发访问令牌。 1 2 3 4 5 6 | POST https://api.authorization-server.com/token? grant_type=authorization_code& code=AUTH_CODE_HERE& redirect_uri=REDIRECT_URI& app_id=APP_ID& code_verifier=CODE_VERIFIER | 现在,你就知道了我们是如何使用 code_verifier 和 code_challenge 这两个参数的了吧。 总结一下就是,换取授权码 code 的时候,我们使用 code_challenge 参数值;换取访问令牌的时候,我们使用 code_verifier 参数值。 由于网络不法分子这样的中间人虽然可以截获 code_challenge,但是他并不能由 code_challenge 逆推 code_verifier,只有客户端自己才知道这两个值。因此即使中间人截获了 code_challenge,Authorization Code 等,也无法换取Access Token,避免了安全问题。 如何在移动App中使用OAuth2 ### [译]Elasticsearch _source Doc_values And Store Performance - URL: https://jiankunking.com/elasticsearch-source-doc-values-and-store-performance.html - Content type: translation - Published: 2024-05-04 - Updated: 2024-05-04 - Summary: 翻译分析 Elasticsearch 中 _source、stored fields 与 doc values 的内部存储方式及字段读取性能差异。 - Categories: Elasticsearch - Tags: Elasticsearch, Storage, _source, doc_values - Original source: https://sease.io/2021/02/field-retrieval-performance-in-elasticsearch.html The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 手把手落地DDD 笔记 - URL: https://jiankunking.com/hand-in-hand-landing-ddd.html - Content type: original - Published: 2024-03-21 - Updated: 2024-03-21 - Summary: 《手把手落地 DDD》学习笔记,整理领域驱动设计在新旧系统中的建模、重构、协作和落地经验。 - Categories: DDD - Tags: Reading Notes, DDD Article text: 文章速览 《手把手落地 DDD》学习笔记,整理领域驱动设计在新旧系统中的建模、重构、协作和落地经验。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:DDD 手把手落地DDD 作者:钟敬 追风赶月莫停留,平芜尽处是春山。 如果想看改造旧系统相关可以直接查看【34|落地经验:怎样在实际项目中推广DDD?】 02|迭代一概述:怎样开启一个麻雀虽小五脏俱全的项目? 领域驱动设计主要的开发流程: 03|事件风暴(上):怎样和业务愉快地聊需求? 事件风暴是怎么一回事? 事件风暴的主要过程: 实际上,领域事件表示的是,业务流程中每个步骤引发的结果。 在 DDD 中的各种命名,一般都优先使用约定俗成的业务术语。 关于领域事件,我们还要注意下面这两点。 - 第一,不要把技术事件当成领域事件。领域事件一定要是领域专家所关注的,用的是业务术语。像数据库事务已回滚、缓存已命中之类的技术术语,不是领域事件,不在这个阶段讨论。 - 第二,查询功能不算领域事件。领域事件应该是对某样事物产生了影响,并被记录的事情。一般是某个事物的创建、修改和删除。还有一种情况是向其他人或者系统发消息,例如”通知邮件已发送”也算领域事件,因为接收方可能会通过进一步处理来影响某些事物。 所谓统一语言,英文是 Ubiquitous Language,是 DDD 中的一个核心模式。指的是业务人员和开发人员使用的语言要一致。语言是知识的载体。语言一致就意味着背后对领域知识的理解一致。统一语言贯穿了 DDD 的全过程。 04|事件风暴(下):事件风暴还有哪些诀窍? 事件风暴第二步:识别命令 所谓命令(command),就是引发领域事件的操作,我们可以通过分析领域事件得到。除了识别出命令本身以外,我们通常还要识别出谁执行的命令,以及为了执行命令我们要查询出什么数据。 事件风暴第三步:识别领域名词 这里说的领域名词,是从命令、领域事件、执行者、查询数据里找到的名词性概念。例如,对于签订合同这个命令而言,受到影响的名词性概念是”合同”;类似地,对于合同已签订这个领域事件,是由于”合同”这个名词性概念的状态变化所导致的。 再谈事件风暴的作用 首先我们看看领域事件的作用。从代码实现的角度来看,领域事件一般会对应一段代码逻辑,这段逻辑可能会最终改变数据库中的数据。另外,在事件驱动的架构中,一个领域事件可能会表现为一个向外部发送的异步消息。 那命令的作用体现在哪儿呢?领域建模时,我们可以通过对命令的走查(walkthrough),细化和验证领域模型。在实现层面,一个命令可能对应前端的一个操作,例如按下按钮;对于后端而言,一个命令可能对应一个 API。 再来说命令的执行者。在领域建模时,执行者可能本身就是一个领域对象,也可能是领域对象充当的角色,或者是权限管理中的一个角色。 其实识别领域名词的最终目的是要找到领域模型中的对象。 事件风暴的常见问题 第一个问题是,在事件风暴里是否要列出所有的领域事件和命令? 在事件风暴里只列出主要的、足以用于表达和交流领域知识的步骤,例如签订合同、生效合同等等。而像修改合同和删除合同这样的步骤是显而易见的,在讨论过程中可以提一下,但不必真的列出来,这样是为了保持简洁 第二个问题是,各个领域事件需要体现严格的时间顺序吗? 只需要按照大致的顺序,贴出领域事件就可以了。这是因为,如果要体现严格的时间顺序,需要用到更复杂的符号,例如条件判断,还有要画更多的连线,这会使事件风暴变得非常繁琐。 因此,我们应该关注点分离。如果要体现严格的时间顺序,我们可以用流程图、用顺序图等方法,但事件风暴不必关注这一点。 第三个问题是,每个步骤的颗粒度应该有多大? 这里说的步骤,指的是一对领域事件和命令。 比如说,”签订合同”这个命令,在具体操作的时候,可能分成录入合同基本信息、录入合同明细、上传附件等等更小的步骤。那么,我们需要为每一个小步骤都识别出领域事件和命令吗? 这就要考虑从业务的角度,我们是把每个小步骤都当作独立的一个事务来看待,还是把它们合起来作为同一个事务。 另外,可以设想,如果每个小步骤都向外界发出一个领域事件,对系统后续的功能是不是有意义。那么在目前的需求里,合同作为一个整体来提交就可以了,分成小的领域事件,并没有意义,所以不再分成更小的步骤了。 在实践里,有时仍然会有模棱两可的情况,这时,原则上宜粗不宜细。可以先采用比较大的颗粒度。后面必要的时候,再拆细,就可以了。 第四个问题是,事件风暴适用于所有项目吗? 事件风暴主要应用在需求不清晰,或者理解不统一的情况下,通过协作的方式理清业务、达成一致,所以通常对于新项目比较适用。 至于遗留系统改造的情况,如果这个系统的知识已经流失得很严重,那么事件风暴仍然是有意义的。但如果大家对这个系统的业务知识很清楚,只是要进行架构改造,那么事件风暴的意义就不大了。 总结 事件风暴是一种通过协作的方式捕获行为需求的方法,在这个过程里,业务人员和技术人员一起消化领域知识、形成统一语言、并为领域建模奠定基础。 事件风暴分为识别领域事件、识别命令、识别领域名词三个步骤。这一节课讲的是后面两个步骤。 “命令”是引发领域事件的操作,可以从领域事件”反推”出来。此外,还可以识别命令的两个附加信息,一个是发出命令的”执行者”,另一个是为了完成命令要查询出的数据。 “领域名词”是隐含在命令和领域事件中的名词性概念。这些名词是领域建模的素材, 而对于这些素材的深入分析可以留到领域建模进行 05|领域建模实践(上):怎样既准确又深刻地理解业务知识? 领域建模中的一些基本概念 领域建模主要有两个目的: - 将知识可视化,准确、深刻地反映领域知识,并且在业务和技术人员之间达成一致; - 指导系统的设计和编码,也就是说,领域模型应该能够比较容易地转化成数据库模式和代码实现。 而我们建立领域模型,主要是要识别领域对象(domain object),领域对象之间的关系,以及领域对象的关键属性,必要的时候还要将领域对象组织成模块。 那么,什么是领域对象呢?我们系统中要处理的各种”事物”就是领域对象。比如说项目、员工、账户等等。这些对象都反映了名词性的概念。 其中,有些名词化了的动词也是领域对象。比如说我们进行了一笔支付操作,并且想把这笔操作记录下来。这时,”支付”也是领域对象。支付本来是动词,但这里实际上是要把一笔支付的信息记录下来,在这里就把”支付”当名词用了。 领域模型是用领域模型图来表达的,通常用 UML 来画。 在领域建模过程中,我们说领域对象时,有时指类,有时指实例,一般可以通过上下文来区分。 此外,DDD 中将领域对象又分成实体(entity)和值对象(value object)。 在领域建模阶段,我们主要关注的是实体和它们之间的关系。如果实体的名字已经能清晰说明实体的含义,那我们就不需要加属性了。如果名字还不足以充分表达含义,我们可以写几个关键属性,来辅助说明。 在 UML 中,用大括号括起来的内容称为”约束”(constraint)。和一般性的注释不同,凡是约束,必须在程序中的某个地方进行实现。 “0..*” 我们也可以这样理解:一个组织最少有 0 个员工,最多可以有很多员工。 “1..1” 表示,一个员工最少要属于一个组织,最多也只能属于一个组织。 06|领域建模实践(下):领域建模还有什么其他技巧? 划分模块 操作(operation)在 UML 里也叫方法(method)。对象的属性是静态的值,而操作是动态的逻辑。在 UML 中,操作用”操作名+ 括号”的方式表示,括号中可以写参数。 人的认知能力是有限的,面对这样一张复杂的对象网络,就产生了认知过载(cognitive overload)。 解决这一问题的方法就是”模块化”。也就是说,把模型中的业务概念组织成若干高内聚的模块(module),而模块之间尽量低耦合。 在 UML 中,可以用包来表示模块。包的符号是下面这样: 建立词汇表 接着我们来建立词汇表,也就是把事件风暴和领域模型中重要的词汇列成表。为什么要建立词汇表呢?主要是有两个作用。 首先,我们需要通过词汇表来规范领域模型中的词汇。同一个词,可能会在领域模型中出现多次,时间久了,就可能不一致,因此需要进一步规范。 第二,是可以用于后续编程中的命名。按照 DDD 的要求,程序中的各种命名也需要统一,并且需要与领域模型中保持一致。我们会在词汇表中列出英文全称和缩写,以达到这个目的。 词汇表是保证统一语言的重要手段。 07|领域建模原理:DDD领域建模和传统方法有什么区别? 什么是领域模型? 在讨论什么是领域模型之前,咱们先说说什么是模型。 首先,模型是以解决特定问题为目的的。例如沙盘模型是为了卖房,而建筑图纸是为了盖楼。没有目的就谈不上模型。 第二,模型都是对现实世界或人们思维中的事物进行的模拟。例如沙盘模型和建筑图纸都是对建筑物的模拟,而玩具车是对真车的模拟。 第三,模型总是提取了被模拟事物中的部分信息,而忽略掉了其他大部分信息。例如,沙盘模型提取了楼盘的外观信息,但是忽略了内部结构和建筑材料信息。而建筑图纸反映了内部结构信息,但忽略了外观信息。到底提取哪些信息,忽略哪些信息,取决于模型的目的。 第四,模型可以有多种表现形式,例如图纸、影像、公式以及电脑中的文件等等。具体采用哪种形式,取决于要解决的问题和当前的技术水平。 最后,模型是一种人造物,大自然本身是不存在模型的。 DDD软件研发过程 DDD 的核心模式之一:模型驱动设计就是围绕领域模型展开的。它有两个要点:领域模型和业务需求要保持一致;系统实现和领域模型也要保持一致。最终的结果就是系统实现和业务需求保持一致。 除此之外,DDD 还有另一个核心模式:统一语言。要用好统一语言,一方面要建立好领域模型、词汇表等”物质基础”,另一方面要在沟通协作的过程中,不断保持模型、语言和系统实现的一致性。 08|数据库设计:怎样按领域模型设计数据库? 模型中的一个一对多关联,可以映射成一个外键字段,以及一个外键约束。但基于云的应用一般不会真的建立外键约束,而外键的逻辑关系还是存在的。我们用虚线箭头表示这种逻辑上的外键关系,称为虚拟外键。对于多对多关联,我们必须增加一个关联表,其中包括了两个实体表各自的主键。另外,关联上的多重性决定了外键字段的非空约束。 与”ER 图法”的区别 首先,采用 UML 类图描述的领域模型图是 ER 图的超集。也就是说,ER 图能表达的,领域模型图都能表达;而领域模型图能表达的,ER 图未必能表达。因此,使用领域模型图以后,我们就不必再使用 ER 图了。 其实我们前几节课进行的领域建模,大体上相当于传统意义上的”概念设计”。如果把领域模型中的属性都补全,就相当于传统意义的”逻辑设计”了。而我们今天做的,其实就是传统上的”物理设计”,所以产物叫做”物理数据模型”。 第二个区别是,ER 图只能表达静态的数据关系,只用于数据库设计,而领域模型图则可以将静态数据和动态行为绑定,不仅可以用于数据库设计,还可以用于程序设计,这一点我们在后面的课程会看到。也就是说,基于 DDD 的方法能够保证程序设计和数据库设计的高度统一。 第三个区别是,领域模型对应的主要是传统软件工程的分析模型,而 ER 图在传统软件工程里则处于设计阶段,所以两者的层次和使用场合也是不一样的。 DDD 方法是 ER 图法的”超集”,并且能够将静态数据和动态逻辑整合在一起,达到业务、数据库和代码三者的统一。 09|分层架构:怎样逃离”大泥球”? 代码中不稳定的部分,应该依赖稳定的部分。 10|代码实现(上):要”贫血”还是要”充血”? “面向对象”还是”面向过程”? 贫血模型指的是领域对象中只有数据,没有行为,这种风格违背了面向对象的原则。 “富领域模型”,也就是领域对象里既包含数据,也包含行为。 DDD 强调,在代码编写阶段,如果发现模型的问题,要及时修改模型,始终保持代码和模型的一致。 领域模型图中一定不能存在只有技术人员才懂的内容。 11|代码实现(中):怎样创建领域对象、实现领域逻辑? “表意接口”(Intention-Revealing Interfaces)模式 DDD 强调,每个类和方法的命名都应该尽量直观地反映领域知识,与统一语言保持一致。这种做法也是 DDD 的一个模式,叫做 “Intention-Revealing Interfaces”,可以译作表意接口。 “领域服务”(Domain Service)模式 如果一个逻辑需要和领域专家讨论才能确认的,就是领域逻辑;如果领域专家根本不感兴趣的,多半就是应用逻辑。 “工厂”(Factory)模式 DDD 认为,领域对象的创建逻辑也是领域层的一部分。如果创建领域对象的逻辑比较简单,可以直接用对象的构造器来实现。但是如果比较复杂,就应该把创建逻辑放到一个专门的机制里,来保证领域对象的简洁和聚焦。 这里说的专门机制可以是一个方法或者一个类,可以有很多种实现方式。不论具体方式是什么,在 DDD 里统称为工厂(Factory)模式。准确地说,工厂其实是用来创建聚合的。工厂和前面说的仓库这两个模式,其实是一种隐喻(metaphor):用工厂来创造产品,然后存到仓库。 所谓创建逻辑复杂,包括两方面:一是规则复杂,二是结构复杂。DDD 认为用于校验的领域逻辑也属于创建过程的一部分。 模块划分的”打横”与”打竖” 尽管《重构》一书中没有明确说,但是一个包下有太多的类,也是一种常见的坏味道。那么多少算”多”呢,也没有明确的标准,不过根据心理学的认知负载理论,我建议,一个包里的类和子包的数量加在一起,最好不要超过 9 个。 事实上,一个应用服务和调用它的控制器以及被它调用的领域对象之间才具有耦合性。分层架构把本来耦合的几个对象拆到了不同的包。之所以我们在分层架构中采用按性质分包的方式,是因为,将领域逻辑与其非领域逻辑分离,以及将技术相关和无关两部分分离,这两个”关注点分离”的好处实在太大,大过了破坏”松耦合高内聚”原则的代价。 但是另一方面,在每个层次内部,我比较建议尽量按照耦合性分包。这是由于排除上述两个”关注点分离”以后,”松耦合高内聚”的好处就体现出来了。如果把按层次划分叫做”打横”分,把按耦合性划分叫做”打竖”分,咱们目前采取的是就是”先横后竖”的分法。 12|代码实现(下):怎样更加”面向对象”? 通过”表意接口”提高封装性 在面向对象设计中一个常见的陷阱就是滥用继承。要防止这一倾向,要记住一个原则,不要仅仅为了复用而使用继承。你还要问自己一个问题,父类和子类的关系,在语义上,是否有分类关系,或者概念的普遍和特殊关系。只有符合这种关系的,才能采用继承,否则应该用”组合”来实现复用。 13|迭代二概述:怎样更深刻地理解领域知识? 模型的建立 模型的实现 14|聚合的概念:怎样保护业务规则? 聚合的概念 第一,具有整体与部分的关系。也就是说,逻辑上,员工信息是整体,而技能信息是员工信息的一部分。 第二,具有不变规则,而且这种不变规则在并发的时候可能被破坏。要防止规则的破坏,仅仅锁住一条技能记录是不够的,必须把员工和所有技能作为一个整体锁住才能解决。或者说,员工和他的所有技能确定了一个事务边界。 具有这样特征的一组领域对象,在 DDD 里就叫做一个聚合(Aggregate)。 聚合的表示法 让我们看看这个图是怎么表达的。 首先,在员工实体名字上方,我们加了一个 <> 的标识,中文是聚合根的意思。在一个聚合里,像员工这样代表整体的实体就是聚合根。一个聚合只有一个聚合根。 <> 外面这个像书名号一样的符号,其实不是书名号,而是两个小于号和两个大于号。这个符号在 UML 里叫做 stereotype ,中文译作 “衍型“ 。这是 UML里用来扩充符号意思的一种机制。 比如说,表示员工实体的方框在 UML 中本来用来表示”类”。加上 <>以后,就衍生出了”表示聚合根的类”的符号。所谓”衍型”就是”衍生出来的符号类型”。这种机制我们后面还会用到。 再看表示员工和技能一对多关联的那条实线。在员工一端,变成了一个空心棱形。这种符号专门表示整体部分关系,有菱形的一端是代表”整体”的对象,另一端是代表”部分”的对象。整体部分关系是关联关系的一种特例。 另外,原来这一端的 “1..1” 被删掉了。因为,对于这种整体部分关系而言,这一端必然是”1..1”。你可以思考一下为什么。虽然写上 “1..1” 也对,但由于必然是,所以出于简洁的原因,就可以不写了。 最后,我们用一个包把这个聚合中的类包起来,从而可以一眼看出这个聚合的边界。一般我们约定,聚合包的名字和聚合根的名字是一样的。 识别更多的聚合 不过,一般来说,业务人员最关心的就是当前客户经理,历史变更信息只在少数情况下才用到。所以,领域专家希望强调当前客户经理这个概念。因此,我们保留了这个关联。 这个关联是可以由客户经理推导出来的,称为”派生关联“(derived association)。仔细看一下,在”/ 当前客户经理”前面有一个斜杠。在 UML 中,凡是前面有斜杠的,就表示是派生出来的内容。 识别出聚合以后,模型图变成下面的样子。 进一步理解聚合概念 首先,作为部分的实体,只能属于一个聚合根,不可能属于多个聚合根。比如说,一条技能信息,只能属于一个员工,不能属于多个员工。又比如说,我的手只能是我一个人的手,不能同时又是其他人的手。 其次,我的手是不能”跳槽”的。不能今天是我的手,明天就变成了别人的手。也就是说,一个聚合的一部分,不能再变成其他聚合根的一部分。 再次,由前两条自然可以推出,聚合根被删除,那么聚合中的所有对象都会被删除。 最后,还有一个”标识”的问题。在业务上,为了识别每个实体,实体必然要有一个标识。例如,人的标识,可以是身份证号。如果这个人是学生,那么他的标识也可以是学号。注意,这里说的标识是一个业务概念,而不是技术概念,和数据库表中常见的没有业务概念的 ID 是不同的。 对于聚合而言,聚合根要有全局的唯一标识,而从属于聚合根的实体只需要有局部于聚合的标识。例如,员工是聚合根,员工号是全局标识。而工作经验没有必要进行全局编号,只需要在聚合内部编个号就可以了。例如,001 号员工的第 1 份工作经验、第 2 份工作经验等等。 我们再来考虑一下聚合的作用。聚合最基本的作用,是为一组具有整体部分关系的对象维护不变规则。而当我们掌握了这种建模技术以后,还可以发现其他一些层面的作用。 首先,聚合不仅是”被动地”实现不变规则,它还为我们提供了一个新的视角,可以更细致地和业务人员讨论业务规则。从这个视角去思考过去做过的系统,我们很可能会发现一些遗漏的业务规则。 其次,开发人员过去一般认为事务只是一个技术概念。现在我们可以看到,事务其实是来源于业务规则的,本质上是个业务问题。也就是说,聚合在业务规则和事务之间建立了起联系。 再次,我们在模型上为每个聚合建了一个包,可以认为,聚合是一种特殊的模块。这样,模型的层次就变得更清晰了。同时,我们也可以把聚合当作一个粗粒度的概念单位进行思考,降低了认知负载。 最后,不少开发人员编程时觉得事务范围的大小不好把握。聚合作为一个事务边界,给出了事务范围的下限,为开发时确定事务范围提供了参考。 总结 聚合是 DDD 里的一个重要模式,主要作用是维护不变规则。如果一组对象具有整体部分关系,并且需要维护整体上的不变规则,那么就可以识别为一个聚合。其中表示整体的那个实体叫做聚合根。 为了在模型中表示聚合,我们使用了叫做 <> 的衍型来表示聚合根;在关联上用空心菱形符号表示整体部分关系;并用一个包把聚合包起来,包的名字一般和聚合根的名字相同。另外,在识别客户经理等聚合的时候,我们还介绍了派生关联。 通过整体部分这一特征,我们还可以推出其他几个特征,包括:表示部分的实体只能属于一个聚合,并且不能再变成其他聚合的一部分;聚合根被删除的话,整个聚合的实体都要被删除;聚合根有全局标识,非聚合根实体只有局部标识。 聚合的作用,除了确保不变规则以外,还为我们增加了一个分析业务规则的视角,将业务规则和事务联系起来,增加了模型的清晰度,并且使开发人员更容易确定事务的范围。 18|值对象(上):到底什么是值对象? 值对象的概念 在 DDD 里,像员工这样有单独的标识,理论上可以改变的对象,就叫做实体(Entiy);像员工状态和时间段这样没有单独的标识,并且不可改变的对象,就叫值对象(Value Object)。 从直观上看,实体是一个”东西”,而值对象是一个”值”,往往用来描述一个实体的属性,这也是值对象名字的由来。 多种多样的值对象 原子值对象 vs 复合值对象 首先,我们可以把值对象分成原子的和复合的。 所谓原子值对象,是在概念上不能再拆分的值对象。比如说,整数、布尔值,日期、颜色以及状态等等,一般都建模成值对象。他们只有一个属性,不能再分了。 而复合值对象是其他对象组合起来的值对象。 举个例子,”长度”对象是由”数值”和”长度单位”两个属性组成的,比如”5 米”,”3毫米”等等。”姓名”一般也认为是值对象,由”姓”和”名”两个属性组成,如果考虑国际化,还要加上”中间名”。”地址”常常也认为是值对象,属性包括”国家”、”省”、”市”、”区”、”街道”、”门牌号”等。还有,”字符串”也是复合值对象,它是由一系列的字符组成的,这种组合方式和前面几种不太一样。 现在你知道为什么 Java 里面 String 对象是不可变的了吧?因为它是值对象。但是你可能又发现一个问题,Java 里 Date(日期)是可变的,而我们上面说日期是值对象,不可变。这是为什么呢? 其实呀,把 Date 实现成可变的,是早期 JDK 设计的一个错误,这带来了很多问题。直到JDK8 引入了新的日期和时间库,也就是 LocalDate、 LocalDatetime 这些类型,才完美地解决了这个问题。而这些新的类型都是不可变的。 你看,哪怕是发明 Java 的牛人,有时候也没搞清楚什么是值对象。 最后还有一种常见的复合值对象,就是所谓快照。 比如修改员工的时候,可能需要把修改历史留下来,也就是我们可以看到员工信息的各个版本。一种做法就是建一个员工历史表,里面的字段和员工表差不多。每次修改,都把修改前的员工数据存一份到历史表。这些信息,就是员工在某个时刻的”快照”。快照是不可变的,因为它是历史信息,历史是不可改变的。多数值对象都比较小,但快照有时会很大,但仍然是值对象。 独立的值对象 vs 依附于实体的值对象 另外,值对象还可以分成独立的和依附于实体的。比如说,”时间段”、”整数”都是独立的,它们可以用来描述任何实体的属性,所以可以不依附于任何实体而单独存在。但是,员工状态就是依附于实体的,它只能表达员工这个实体的状态,脱离了员工,员工状态也就没有单独存在的意义了。 可数值对象 vs 连续值对象 值对象也可以分成可数的和连续的。可数值对象是离散的,可以一个一个列出来。比如说整数和日期、员工状态都是可数的。而实数则是连续的值对象。像颜色这样的值对象,在自然界里本来是连续的,但由于技术的限制,在计算机里一般实现为可数的,比如说,一些老式的系统只支持 256 种颜色。 预定义值对象 vs 非预定义值对象 最后,值对象还可以分成预定义的和非预定义的。 所谓预定义的,就是需要以某种方式在系统里,把这种对象的值定义出来,常见的方式有程序里的枚举类型、数据库定义表,配置文件等。比如说,员工状态的三个对象”试用期” “正式工” “终止”,就是用枚举的方式定义在程序里的。而用于构造地址的”省” “市”则常常定义在数据库表里。 非预定义的值对象就不必预先定义在系统里了,比如说”整数”,由于是无限的,根本就没有办法预定义。我们不可能用一个数据库表把所有整数都定义进去,当然,也没这个必要。 20|值对象(下):值对象和实体的本质区别是什么? 实体是靠独立于其他属性的标识来确定同一性的,而值对象以本身的值来确定同一性,没有独立于其他属性的标识;理论上,实体是可变的,而值对象是不可变的。 值对象和实体的本质区别 现实中的事物,也就是实体,总有一个产生和消亡的过程,在这个过程里,各种属性也可能发生变化,因此是可变的。 而值对象则是纯粹的概念产物,唯一的目的就是方便人的思考和沟通。所以,这样的概念本身并没有自然的产生和消亡过程,也不需要改变。5 就是 5 , 5 如果变成 6 ,那么就已经是另一个值对象了,原来的 5 还在那里,并没有改变。换句话说,值对象并没有实体意义上的”生命周期”。因此,谈论值对象的改变,本身是没有意义的,这就是值对象不变性的本质原 因。 21|用”限定”建模:怎样简化一对多关联? 之前,员工和工作经验之间有一个一对多关联。现在,在员工那一端加了一个小方框,里面写了”: 时间段”,而另一端的多重性,由原来的”0..*”神奇地变成了”0..1”。 这种方式所表达的意思是说,对于一个员工而言,任何一个时间段,要么没有工作经验,要么有一条工作经验,但不能有多条工作经验。换句话说,总体上看,一个员工可以有多条工作经验,但限定在一个时间段的话,那么最多就只能有一条工作经验了。 所以,这种机制就叫作”限定”(qualification)。而上面那个标有”: 时间段”的小方框,叫做”限定符”(qualifier)。 我们不难发现,限定机制起到了两个作用:第一,表达了更丰富的语义,把原来用注解说明的约束变成了更严格的符号;第二,简化了关联关系的多重性,把原来的一对多,在形式上,变成了一对一。 27|迭代三概述:怎样处理规模更大的系统? 聚合 聚合(aggregate)是一组有整体部分关系,并且要满足一定不变规则的领域对象,其中只有一个实体表示整体,这个实体叫做聚合根。 聚合的整体与部分是强关联的,也就是一旦聚合根被删除,其他部分必然也被删除。由于这种强关系,所以外界只能通过聚合根来访问非根对象,因此,只有聚合根有业务意义上的全局标识,非聚合根实体只有局部标识。 对于那些单独存在,而不属于其他聚合的实体,可以认为是只有聚合根的、退化的聚合。 在模型图里,我们可以用 <> 衍型结合菱形符号表示聚合根,并且把聚合相关的实体以及专属于聚合的值对象放在一个包里。 不变规则,指的是每时每刻都不能打破的规则。如果可以暂时打破,后面再补救,就不算不变规则了。对于聚合整体上的不变规则,需要在聚合根或者和聚合配合的领域服务中维护。 此外,我们还要考虑不变规则在并发的情况下被破坏的情况,这就要用事务把聚合的操作保护起来。所以,聚合决定了事务的最小边界。这种事务常常要用乐观锁或悲观锁来实现。 在编程上,非根实体的增、删、改,一般要由聚合根或者和聚合配合使用的工厂或领域服务来负责,外部不能直接修改非聚合根。为了实现这一点,我们可以将非聚合根的构造器和 setter设成包级私有权限。此外,聚合根返回非根实体的列表时,应该转换成不可变列表。 《领域驱动设计》原书的第 6.1 节介绍了聚合,你可以去看看。 值对象 值对象(value object)通常用来表示实体的属性值。由于值对象是纯粹的概念产物,因此并不存在从创建到消亡的生命周期,在概念上也是不可变的。而另一方面,实体则是现实中的概念,存在从产生到消亡的生命周期,理论上是可变的。 由于实体的属性变了,仍然是这个实体,所以必须具有独立于其他属性的标识,通过这个标识来判断实体的同一性。也就是只要标识一样,哪怕属性变了,这个实体还是这个实体。而值对象是不变的,所以不需要独立于其他属性的标识,而是以所有的属性值作为一个整体来判断同一性。 在模型图上,可以用 <> 衍型来表示值对象。我们还要注意一点,值对象也是要封装领域逻辑的,因此不是 DTO(数据传输对象)。 值对象的主要优点是在内存和数据库布局上的灵活性,既可以采用共享的方式,也可以采用不共享的方式,这是实体所不具备的。同时,不变性也可以避免程序错误,有利于并发程序的编写和函数式编程。 《领域驱动设计》原书第 5.3 节介绍了值对象。 限定 限定(qualification)可以起到简化关联的多重性,丰富模型语义的作用。如果两个实体之间本来是一对多的关系,而某个属性固定后,就可以变成一对一的关系,那么就可以使用限定。 限定在数据库里可以表现为主键和限… ### DDD实战课 笔记 - URL: https://jiankunking.com/ddd-practical-course.html - Content type: original - Published: 2024-03-02 - Updated: 2024-03-02 - Summary: 《DDD实战课》学习笔记,涵盖领域驱动设计的战略设计、战术设计、限界上下文、聚合根、实体、值对象等核心概念。 - Categories: DDD - Tags: Reading Notes, DDD Article text: 文章速览 《DDD实战课》学习笔记,涵盖领域驱动设计的战略设计、战术设计、限界上下文、聚合根、实体、值对象等核心概念。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:DDD 《DDD实战课》学习笔记,涵盖领域驱动设计的战略设计、战术设计、限界上下文、聚合根、实体、值对象等核心概念。 DDD实战课 作者:欧创新 01 | 微服务设计为什么要选择DDD? 微服务拆分困境产生的根本原因就是不知道业务或者微服务的边界到底在什么地方。 为什么 DDD 适合微服务? DDD 是一种处理高度复杂领域的设计思想,它试图分离技术实现的复杂性,并围绕业务概念构建领域模型来控制业务的复杂性,以解决软件难以理解,难以演进的问题。DDD 不是架构,而是一种架构设计方法论,它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现架构演进。 DDD 包括战略设计和战术设计两部分 战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,限界上下文可以作为微服务设计的参考边界。 战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。 DDD 战略设计会建立领域模型,领域模型可以用于指导微服务的设计和拆分。事件风暴是建立领域模型的主要方法,它是一个从发散到收敛的过程。它通常采用用例分析、场景分析和用户旅程分析,尽可能全面不遗漏地分解业务领域,并梳理领域对象之间的关系,这是一个发散的过程。事件风暴过程会产生很多的实体、命令、事件等领域对象,我们将这些领域对象从不同的维度进行聚类,形成如聚合、限界上下文等边界,建立领域模型,这就是一个收敛的过程。 我们可以用三步来划定领域模型和微服务的边界。 第一步:在事件风暴中梳理业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出领域实体等领域对象。 第二步:根据领域实体之间的业务关联性,将业务紧密相关的实体进行组合形成聚合,同时确定聚合中的聚合根、值对象和实体。在这个图里,聚合之间的边界是第一层边界,它们在同一个微服务实例中运行,这个边界是逻辑边界,所以用虚线表示。 第三步:根据业务及语义边界等因素,将一个或者多个聚合划定在一个限界上下文内,形成领域模型。在这个图里,限界上下文之间的边界是第二层边界,这一层边界可能就是未来微服务的边界,不同限界上下文内的领域逻辑被隔离在不同的微服务实例中运行,物理上相互隔离,所以是物理边界,边界之间用实线来表示。 有了这两层边界,微服务的设计就不是什么难事了 DDD 与微服务的关系 DDD 主要关注:从业务领域视角划分领域边界,构建通用语言进行高效沟通,通过业务抽象,建立领域模型,维持业务和代码的逻辑一致性。 微服务主要关注:运行时的进程间通信、容错和故障隔离,实现去中心化数据管理和去中心化服务治理,关注微服务的独立开发、测试、构建和部署。 02 | 领域、子域、核心域、通用域和支撑域 如何理解领域和子域? 领域就是用来确定范围的,范围即边界,这也是DDD 在设计中不断强调边界的原因。 在研究和解决业务问题时,DDD 会按照一定的规则将业务领域进行细分,当领域细分到一定的程度后,DDD 会将问题范围限定在特定的边界内,在这个边界内建立领域模型,进而用代码实现该领域模型,解决相应的业务问题。简言之,DDD 的领域就是这个边界内要解决的业务问题域。 既然领域是用来限定业务边界和范围的,那么就会有大小之分,领域越大,业务范围就越大,反之则相反。 领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。 DDD 的研究方法与自然科学的研究方法类似。当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题一步一步地细分,再针对细分出来的问题域,逐个深入研究,探索和建立所有子域的知识体系。当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。 我们来看一下上面这张图。这个例子是在讲如何给桃树建立一个完整的生物学知识体系。初中生物课其实早就告诉我们研究方法了。它的研究过程是这样的。 第一步:确定研究对象,即研究领域,这里是一棵桃树。 第二步:对研究对象进行细分,将桃树细分为器官,器官又分为营养器官和生殖器官两种。其中营养器官包括根、茎和叶,生殖器官包括花、果实和种子。桃树的知识体系是我们已经确定要研究的问题域,对应 DDD 的领域。根、茎、叶、花、果实和种子等器官则是细分后的问题子域。这个过程就是 DDD 将领域细分为多个子域的过程。 第三步:对器官进行细分,将器官细分为组织。比如,叶子器官可细分为保护组织、营养组织和输导组织等。这个过程就是 DDD 将子域进一步细分为多个子域的过程。 第四步:对组织进行细分,将组织细分为细胞,细胞成为我们研究的最小单元。细胞之间的细胞壁确定了单元的边界,也确定了研究的最小边界。 不同行业的业务模型可能会不一样,但领域建模和微服务建设的过程和方法基本类似,其核心思想就是将问题域逐步分解,降低业务理解和系统实现的复杂度。 如何理解核心域、通用域和支撑域? 在领域不断划分的过程中,领域会细分为不同的子域,子域可以根据自身重要性和功能属性划分为三类子域,它们分别是:核心域、通用域和支撑域。 决定产品和公司核心竞争力的子域是核心域,它是业务成功的主要因素和公司的核心竞争力。没有太多个性化的诉求,同时被多个子域使用的通用功能子域是通用域。还有一种功能子域是必需的,但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,它就是支撑域。 那为什么要划分核心域、通用域和支撑域,主要目的是什么呢? 还是拿上图的桃树来说吧。我们将桃树细分为了根、茎、叶、花、果实和种子等六个子域,那桃树是否有核心域?有的话,到底哪个是核心域呢? 不同的人对桃树的理解是不同的。如果这棵桃树生长在公园里,在园丁的眼里,他喜欢的是”人面桃花相映红”的阳春三月,这时花就是桃树的核心域。但如果这棵桃树生长在果园里,对果农来说,他则是希望在丰收的季节收获硕果累累的桃子,这时果实就是桃树的核心域。 在不同的场景下,不同的人对桃树核心域的理解是不同的,因此对桃树的处理方式也会不一样。园丁更关注桃树花期的营养,而果农则更关注桃树落果期的营养,有时为了保证果实的营养供给,还会裁剪掉疯长的茎和叶(通用域或支撑域)。 核心域、通用域、支撑域的划分本质是公司战略方向的体现,DDD是从战略到战术角度来进行架构设计的方法。 总结 领域的核心思想就是将问题域逐级细分,来降低业务理解和系统实现的复杂度。通过领域细分,逐步缩小微服务需要解决的问题域,构建合适的领域模型,而领域模型映射成系统就是微服务了。 03 | 限界上下文:定义领域边界的利器 通用语言定义上下文含义,限界上下文则定义领域边界,以确保每个上下文含义在它特定的边界内都具有唯一的含义,领域模型则存在于这个边界之内。 什么是通用语言? 在事件风暴过程中,通过团队交流达成共识的,能够简单、清晰、准确描述业务涵义和规则的语言就是通用语言。 下面我带你看一张图,这张图描述了从事件风暴建立通用语言到领域对象设计和代码落地的完整过程。 设计过程中我们可以用一些表格,来记录事件风暴和微服务设计过程中产生的领域对象及其属性。比如,领域对象在 DDD 分层架构中的位置、属性、依赖关系以及与代码模型对象的映射关系等。 到这里,我要再强调一次。DDD 分析和设计过程中的每一个环节都需要保证限界上下文内术语的统一,在代码模型设计的时侯就要建立领域对象和代码对象的一一映射,从而保证业务模型和代码模型的一致,实现业务语言与代码语言的统一。 如果你做到了这一点,也就是建立了领域对象和代码对象的映射关系,那就可以指导软件开发人员准确无误地按照设计文档完成微服务开发了。即使是不熟悉代码的业务人员,也可以很快找到代码的位置。 什么是限界上下文? 限界上下文大概是直译过来的一个晦涩的术语,理解成本较高。英文是bounded context,应该叫上下文边界更合适。 DDD 在战略设计上提出了”限界上下文”这个概念,用来确定语义所在的领域边界。 我们可以将限界上下文拆解为两个词:限界和上下文。限界就是领域的边界,而上下文则是语义环境。通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。 限界上下文的定义就是:用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。 现在我们用一个保险领域的例子来说明下术语的边界。保险业务领域有投保单、保单、批单、赔案等保险术语,它们分别应用于保险的不同业务流程。 - 客户投保时,业务人员记录投保信息,系统对应有投保单实体对象。 - 缴费完成后,业务人员将投保单转为保单,系统对应有保单实体对象,保单实体与投保单实体关联。 - 如客户需要修改保单信息,保单变为批单,系统对应有批单实体对象,批单实体与保单实体关联。 - 如果客户发生理赔,生成赔案,系统对应有报案实体对象,报案实体对象与保单或者批单实体关联。 投保单、保单、批单、赔案等,这些术语虽然都跟保单有关,但不能将保单这个术语作用在保险全业务领域。因为术语有它的边界,超出了边界理解上就会出现问题。 正如电商领域的商品一样,商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界,这个边界就可能会成为未来微服务设计的边界。看到这,我想你应该非常清楚了,领域边界就是通过限界上下文来定义的。 限界上下文和微服务的关系 首先,领域可以拆分为多个子领域。一个领域相当于一个问题域,领域拆分为子域的过程就是大问题拆分为小问题的过程。在这个图里面保险领域被拆分为:投保、支付、保单管理和理赔四个子域。 子域还可根据需要进一步拆分为子子域,比如,支付子域可继续拆分为收款和付款子子域。拆到一定程度后,有些子子域的领域边界就可能变成限界上下文的边界了。 子域可能会包含多个限界上下文,如理赔子域就包括报案、查勘和定损等多个限界上下文(限界上下文与理赔的子子域领域边界重合)。也有可能子域本身的边界就是限界上下文边界,如投保子域。 每个领域模型都有它对应的限界上下文,团队在限界上下文内用通用语言交流。领域内所有限界上下文的领域模型构成整个领域的领域模型。 理论上限界上下文就是微服务的边界。我们将限界上下文内的领域模型映射到微服务,就完成了从问题域到软件的解决方案。 总结 通用语言确定了项目团队内部交流的统一语言,而这个语言所在的语义环境则是由限界上下文来限定的,以确保语义的唯一性。 而领域专家、架构师和开发人员的主要工作就是通过事件风暴来划分限界上下文。限界上下文确定了微服务的设计和拆分方向,是微服务设计和拆分的主要依据。如果不考虑技术异构、团队沟通等其它外部因素,一个限界上下文理论上就可以设计为一个微服务。 可以说,限界上下文在微服务设计中具有很重要的意义,如果限界上下文的方向偏离,那微服务的设计结果也就可想而知了。因此,我们只有理解了限界上下文的真正涵义以及它在微服务设计中的作用,才能真正发挥 DDD 的价值,这是基础也是前提。 04 | 实体和值对象:从领域模型的基础单元看系统设计 实体 在 DDD 中有这样一类对象,它们拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致。对这些对象而言,重要的不是其属性,而是其延续性和标识,对象的延续性和标识会跨越甚至超出软件的生命周期。我们把这样的对象称为实体。 1. 实体的业务形态 在 DDD 不同的设计过程中,实体的形态是不同的。在战略设计时,实体是领域模型的一个重要对象。领域模型中的实体是多个属性、操作或行为的载体。在事件风暴中,我们可以根据命令、操作或者事件,找出产生这些行为的业务实体对象,进而按照一定的业务规则将依存度高和业务关联紧密的多个实体对象和值对象进行聚类,形成聚合。你可以这么理解,实体和值对象是组成领域模型的基础单元。 2. 实体的代码形态 在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,通过这些方法实现实体自身的业务逻辑。在 DDD 里,这些实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现。 3. 实体的运行形态 实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。但是,由于它们拥有相同的ID,它们依然是同一个实体。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。 4. 实体的数据库形态 与传统数据模型设计优先不同,DDD 是先构建领域模型,针对实际业务场景构建实体对象和行为,再将实体对象映射到数据持久化对象。 在领域模型映射到数据模型时,一个实体可能对应 0 个、1 个或者多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。在某些场景中,有些实体只是暂驻静态内存的一个运行态实体,它不需要持久化。比如,基于多个价格配置数据计算后生成的折扣实体。 而在有些复杂场景下,实体与持久化对象则可能是一对多或者多对一的关系。比如,用户user 与角色 role 两个持久化对象可生成权限实体,一个实体对应两个持久化对象,这是一对多的场景。再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息customer 和账户信息 account 两类数据保存到同一张数据库表中,客户和账户两个实体可根据需要从一个持久化对象中生成,这就是多对一的场景。 值对象 《实现领域驱动设计》一书中对值对象的定义:通过对象属性值来识别的对象,它将多个相关属性组合为一个概念整体。 在 DDD 中用来描述领域的特定方面,并且是一个没有标识符的对象,叫作值对象。 也就说,值对象描述了领域中的一件东西,这个东西是不可变的,它将不同的相关属性组合成了一个概念整体。当度量和描述改变时,可以用另外一个值对象予以替换。它可以和其它值对象进行相等性比较,且不会对协作对象造成副作用。 简单来说,值对象本质上就是一个集合。 那这个集合里面有什么呢?若干个用于描述目的、具有整体概念和不可修改的属性。那这个集合存在的意义又是什么?在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免属性零碎。 人员实体原本包括:姓名、年龄、性别以及人员所在的省、市、县和街道等属性。这样显示地址相关的属性就很零碎了对不对?现在,我们可以将”省、市、县和街道等属性”拿出来构成一个”地址属性集合”,这个集合就是值对象了。 1. 值对象的业务形态 值对象是 DDD 领域模型中的一个基础对象,它跟实体一样都来源于事件风暴所构建的领域模型,都包含了若干个属性,它与实体一起构成聚合。 我们不妨对照实体,来看值对象的业务形态,这样更好理解。本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。值对象的属性集虽然在物理上独立出来了,但在逻辑上它仍然是实体属性的一部分,用于描述实体的特征。 在值对象中也有部分共享的标准类型的值对象,它们有自己的限界上下文,有自己的持久化对象,可以建立共享的数据类微服务,比如数据字典。 2. 值对象的代码形态 值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性;如果值对象是属性集合,则把它设计为 Class 类,Class 将具有整体概念的多个属性归集到属性集合,这样的值对象没有 ID,会被实体整体引用。 我们看一下下面这段代码,person 这个实体有若干个单一属性的值对象,比如 Id、name 等属性;同时它也包含多个属性的值对象,比如地址 address。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class person{// 实体 public String id;//值对象人员唯一主键 public String name;//单一属性值对象 public int age;//单一属性值对象 public boolean gender;//单一属性值对象 public Address address;//属性集值对象,被实体引用 //方法不列举了 } public class Address{ // 值对象无主键ID public String province;//值对象 public String city;//值对象 public String county;//值对象 public String street;//值对象 //方法不列举了 } | 3. 值对象的运行形态 实体实例化后的 DO 对象的业务属性和业务行为非常丰富,但值对象实例化的对象则相对简单和乏味。除了值对象数据初始化和整体替换的行为外,其它业务行为就很少了。 值对象嵌入到实体的话,有这样两种不同的数据格式,也可以说是两种方式,分别是属性嵌入的方式和序列化大对象的方式。 引用单一属性的值对象或只有一条记录的多属性值对象的实体,可以采用属性嵌入的方式嵌入。引用一条或多条记录的多属性值对象的实体,可以采用序列化大对象的方式嵌入。比如,人员实体可以有多个通讯地址,多个地址序列化后可以嵌入人员的地址属性。值对象创建后就不允许修改了,只能用另外一个值对象来整体替换。 如果你对这两种方式不够了解,可以看看下面的例子。 案例 1:以属性嵌入的方式形成的人员实体对象,地址值对象直接以属性值嵌入人员实体中。 案例 2:以序列化大对象的方式形成的人员实体对象,地址值对象被序列化成大对象 Json 串后,嵌入人员实体中。 4. 值对象的数据库形态 DDD 引入值对象是希望实现从”数据建模为中心”向”领域建模为中心”转变,减少数据库表的数量和表与表之间复杂的依赖关系,尽可能地简化数据库设计,提升数据库性能。 如何理解用值对象来简化数据库设计呢? 传统的数据建模大多是根据数据库范式设计的,每一个数据库表对应一个实体,每一个实体的属性值用单独的一列来存储,一个实体主表会对应 N 个实体从表。而值对象在数据库持久化方面简化了设计,它的数据库设计大多采用非数据库范式,值对象的属性值和实体对象的属性值保存在同一个数据库实体表中。 举个例子,还是基于上述人员和地址那个场景,实体和数据模型设计通常有两种解决方案:第一是把地址值对象的所有属性都放到人员实体表中,创建人员实体,创建人员数据表;第二是创建人员和地址两个实体,同时创建人员和地址两张表。 第一个方案会破坏地址的业务涵义和概念完整性,第二个方案增加了不必要的实体和表,需要处理多个实体和表的关系,从而增加了数据库设计的复杂性。 那到底应该怎样设计,才能让业务含义清楚,同时又不让数据库变得复杂呢? 我们可以综合这两个方案的优势,扬长避短。在领域建模时,我们可以把地址作为值对象,人员作为实体,这样就可以保留地址的业务涵义和概念完整性。而在数据建模时,我们可以将地址的属性值嵌入人员实体数据库表中,只创建人员数据库表。这样既可以兼顾业务含义和表达,又不增加数据库的复杂度。 值对象就是通过这种方式,简化了数据库设计,总结一下就是:在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体,减少实体表的数量,简化数据库设计。 另外,也有 DDD 专家认为,要想发挥对象的威力,就需要优先做领域建模,弱化数据库的作用,只把数据库作为一个保存数据的仓库即可。即使违反数据库设计原则,也不用大惊小怪,只要业务能够顺利运行,就没什么关系。 5. 值对象的优势和局限 值对象是一把双刃剑,它的优势是可以简化数据库设计,提升数据库性能。但如果值对象使用不当,它的优势就会很快变成劣势。”知彼知己,方能百战不殆”,你需要理解值对象真正适合的场景。 值对象采用序列化大对象的方法简化了数据库设计,减少了实体表的数量,可以简单、清晰地表达业务概念。这种设计方式虽然降低了数据库设计的复杂度,但却无法满足基于值对象的快速查询,会导致搜索值对象属性值变得异常困难。 值对象采用属性嵌入的方法提升了数据库的性能,但如果实体引用的值对象过多,则会导致实体堆积一堆缺乏概念完整性的属性,这样值对象就会失去业务涵义,操作起来也不方便。 所以,你可以对照着以上这些优劣势,结合你的业务场景,好好想一想了。那如果在你的业务场景中,值对象的这些劣势都可以避免掉,那就请放心大胆地使用值对象吧。 实体和值对象的关系 实体和值对象是微服务底层的最基础的对象,一起实现实体最基本的核心领域逻辑。 值对象和实体在某些场景下可以互换,很多 DDD 专家在这些场景下,其实也很难判断到底将领域对象设计成实体还是值对象?可以说,值对象在某些场景下有很好的价值,但是并不是所有的场景都适合值对象。 你需要根据团队的设计和开发习惯,以及上面的优势和局限分析,选择最适合的方法。 关于值对象,我还要多说几句。其实,DDD 引入值对象还有一个重要的原因,就是到底领域建模优先还是数据建模优先? DDD 提倡从领域模型设计出发,而不是先设计数据模型。 前面讲过了,传统的数据模型设计通常是一个表对应一个实体,一个主表关联多个从表,当实体表太多的时候就很容易陷入无穷无尽的复杂的数据库设计,领域模型就很容易被数据模型绑架。可以说,值对象的诞生,在一定程度上,和实体是互补的。 我们还是以前面的图示为例: 在领域模型中人员是实体,地址是值对象,地址值对象被人员实体引用。在数据模型设计时,地址值对象可以作为一个属性集整体嵌入人员实体中,组合形成上图这样的数据模型;也可以以序列化大对象的形式加入到人员的地址属性中,前面表格有展示。 从这个例子中,我们可以看出,同样的对象在不同的场景下,可能会设计出不同的结果。有些场景中,地址会被某一实体引用,它只承担描述实体的作用,并且它的值只能整体替换,这时候你就可以将地址设计为值对象,比如收货地址。而在某些业务场景中,地址会被经常修改,地址是作为一个独立对象存在的,这时候它应该设计为实体,比如行政区划中的地址信息维护。 05 | 聚合和聚合根:怎样设计聚合? 聚合(Aggregate) 聚合根(AggregateRoot) 在事件风暴中,我们会根据一些业务操作和行为找出实体(Entity)或值对象(ValueObject),进而将业务关联紧密的实体和值对象进行组合,构成聚合,再根据业务语义将多个聚合划定到同一个限界上下文(Bounded Context)中,并在限界上下文内完成领域建模。 聚合 在 DDD 中,实体和值对象是很基础的领域对象。实体一般对应业务对象,它具有业务属性和业务行为;而值对象主要是属性集合,对实体的状态和特征进行描述。但实体和值对象都只是个体化的对象,它们的行为表现出来的是个体的能力。 那聚合在其中起什么作用呢? 举个例子。社会是由一个个的个体组成的,象征着我们每一个人。随着社会的发展,慢慢出现了社团、机构、部门等组织,我们开始从个人变成了组织的一员,大家可以协同一致的工作,朝着一个最大的目标前进,发挥出更大的力量。 领域模型内的实体和值对象就好比个体,而能让实体和值对象协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。 你可以这么理解,聚合就是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元,每一个聚合对应一个仓储,实现数据的持久化。 聚合有一个聚合根和上下文边界,这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。按照这种方式设计出来的微服务很自然就是”高内聚、低耦合”的。 聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。聚合内实体以充血模型实现个体业务能力,以及业务逻辑的高内聚。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。 比如有的业务场景需要同一个聚合的 A 和 B 两个实体来共同完成,我们就可以将这段业务逻辑用领域服务来实现;而有的业务逻辑需要聚合 C 和聚合 D 中的两个服务共同完成,这时你就可以用应用服务来组合这两个服务。 聚合根 聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致性的问题。 传统数据模型中的每一个实体都是对等的,如果任由实体进行无控制地调用和数据修改,很可能会导致实体之间数据逻辑的不一致。而如果采用锁的方式则会增加软件的复杂度,也会降低系统的性能。 如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。 首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。 其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。 最后在聚合之间,它还是聚合对外的接口人,以聚合根 ID 关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根 ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。 怎样设计聚合? DDD 领域建模通常采用事件风暴,它通常采用用例分析、场景分析和用户旅程分析等方法,通过头脑风暴列出所有可能的业务行为和事件,然后找出产生这些行为的领域对象,并梳理领域对象之间的关系,找出聚合根,找出与聚合根业务紧密关联的实体和值对象,再将聚合根、实体和值对象组合,构建聚合。 下面我们以保险的投保业务场景为例,看一下聚合的构建过程主要都包括哪些步骤。 第 1 步:采用事件风暴,根据业务行为,梳理出在投保过程中发生这些行为的所有的实体和值对象,比如投保单、标的、客户、被保人等等。 第 2 步:从众多实体中选出适合作为对象管理者的根实体,也就是聚合根。判断一个实体是否是聚合根,你可以结合以下场景分析:是否有独立的生命周期?是否有全局唯一 ID?是否可以创建或修改其它对象?是否有专门的模块来管这个实体。图中的聚合根分别是投保单和客户实体。 第 3 步:根据业务单一职责和高内聚原则,找出与聚合根关联的所有紧密依赖的实体和值对象。构建出 1 个包含聚合根(唯一)、多个实体和值对象的对象集合,这个集合就是聚合。在图中我们构建了客户和投保这两个聚合。 第 4 步:在聚合内根据聚合根、实体和值对象的依赖关系,画出对象的引用和依赖模型。这里我需要说明一下:投保人和被保人的数据,是通过关联客户 ID 从客户聚合中获取的,在投保聚合里它们是投保单的值对象,这些值对象的数据是客户的冗余数据,即使未来客户聚合的数据发生了变更,也不会影响投保单的值对象数据。从图中我们还可以看出实体之间的引用关系,比如在投保聚合里投保单聚合根引用了报价单实体,报价单实体则引用了报价规则子实体。 第 5 步:多个聚合根据业务语义和上下文一起划分到同一个限界上下文内。 这就是一个聚合诞生的完整过程了。 聚合的一些设计原则 - 在一致性边界内建模真正的不变条件。 聚合用来封装真正的不变性,而不是简单地将对象组合在一起。聚合内有一套不变的业务规则,各实体和值对象按照统一的业务规则运行,实现对象数据的一致性,边界之外的任何东西都与该聚合无关,这就是聚合能实现业务高内聚的原因。 - 设计小聚合。 如果聚合设计得过大,聚合会因为包含过多的实体,导致实体之间的管理过于复杂,高频操作时会出现并发冲突或者数据库锁,最终导致系统可用性变差。而小聚合设计则可以降低由于业务过大导致聚合重构的可能性,让领域模型更能适应业务的变化。 - 通过唯一标识引用其它聚合。 聚合之间是通过关联外部聚合根 ID 的方式引用,而不是直接对象引用的方式。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。 - 在边界之外使用最终一致性。 聚合内数据强一致性,而聚合之间数据最终一致性。在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及… ### 2023年终总结 - URL: https://jiankunking.com/2023-year-end-summary.html - Content type: original - Published: 2023-12-30 - Updated: 2023-12-30 - Summary: 2023年工作总结:权限系统上线、方案设计;生活总结:解决背痛驼背问题、家庭健康挑战、贷款商转公成功。 - Categories: Year-End Summary - Tags: Year-End Summary, 2023 Article text: 文章速览 2023年工作总结:权限系统上线、方案设计;生活总结:解决背痛驼背问题、家庭健康挑战、贷款商转公成功。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Year-End Summary 2023年工作总结:权限系统上线、方案设计;生活总结:解决背痛驼背问题、家庭健康挑战、贷款商转公成功。 不”平凡”的一年,记录一下 工作 拖期很久的权限终于年初上线了,后面又参与了新版权限的方案设计、论证。权限这件事解决了我一直以来的自我怀疑,让我整个人变得更自信。 感触 如果你想让某件事变得困难,甚至完全不可能完成,那么你可以让终极目标尽可能地模糊。这是因为,你肯定无法完成一个终极目标不明确的项目,你可能会原地踏步,也可能会对它改来改去,甚至很可能会放弃它。相反,如果你想完成一个重要项目,绝对有必要让终极目标尽可能地清晰。 生活 个人 生活中今年最大的收获就是解决了多年依赖的背难受跟驼背:今年花的最值的一笔钱 家庭 家庭成员今年或受伤或累病,比较煎熬。 今年的假期基本都用来陪家人跑医院了,目之所及,这种情况还要持续至少几个月。 财务 - 贷款商转公成功。 - 提前还了一些贷款。 读书 今年感触比较大的几段话: - 只应对,不预测。 - 阅读和经验训练你的世界模型。即使你忘记了你的经历或你读到的东西,它对你的世界模型的影响仍然存在。你的大脑就像一个编译过的程序,你已经失去了它的来源。它有效,但你不知道为什么。 - 从实践到认识、从认识到实践循环往复,人的认识是螺旋式上升的,波浪式前进的。 新一年的期望 - 家人健健康康、平平安安。 - 自己减肥成功。 ### 【Elasticsearch源码】 分片恢复分析 - URL: https://jiankunking.com/elasticsearch-shard-recovery-source-code-analysis.html - Content type: original - Published: 2023-12-23 - Updated: 2023-12-23 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析分片恢复流程,包括 Segment 传输、引擎打开和 Translog 重放。 - Categories: Elasticsearch - Tags: Elasticsearch, Shard, 源码, Recovery Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析分片恢复流程,包括 Segment 传输、引擎打开和 Translog 重放。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第七篇:Elasticsearch 分片恢复分析 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于分片恢复的疑问点: - 网上对于ElasticSearch分片恢复的逻辑说法一抓一把,网上说的对不对?新版本中有没有更新? - 在分片恢复的时候,如果收到Api _forcemerge请求,这时候,会如何处理?(因为副本恢复的第一节点是复制segment文件) - 分片恢复的第二阶段是同步translog,这一步会不会加锁?不加锁的话,如何确保是同步完成了? 如果说看源码有捷径的话,那么找到网上一篇写的比较权威的源码分析文章跟着看,那不失为一种好方法。 下面源码分析部分将参考腾讯云的:Elasticsearch 底层系列之分片恢复解析,一边参考,一边印证。 源码分析 目标节点请求恢复 先找到分片恢复的入口:IndicesClusterStateService.createOrUpdateShards 在这里会判断本地节点是否在routingNodes中,如果在,说明本地节点有分片创建或更新的需求,否则跳过。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | private void createOrUpdateShards(final ClusterState state) { // 节点到索引分片的映射关系,主要用于分片分配、均衡决策 // 具体的内容可以看下:https://jiankunking.com/elasticsearch-cluster-state.html RoutingNode localRoutingNode = state.getRoutingNodes().node(state.nodes().getLocalNodeId()); if (localRoutingNode == null) { return; } DiscoveryNodes nodes = state.nodes(); RoutingTable routingTable = state.routingTable(); for (final ShardRouting shardRouting : localRoutingNode) { ShardId shardId = shardRouting.shardId(); // failedShardsCache:https://github.com/jiankunking/elasticsearch/blob/master/server/src/main/java/org/elasticsearch/indices/cluster/IndicesClusterStateService.java#L116 // 恢复过程中失败的碎片列表;我们跟踪这些碎片,以防止在每次集群状态更新时重复恢复这些碎片 if (failedShardsCache.containsKey(shardId) == false) { AllocatedIndex indexService = indicesService.indexService(shardId.getIndex()); assert indexService != null : "index " + shardId.getIndex() + " should have been created by createIndices"; Shard shard = indexService.getShardOrNull(shardId.id()); if (shard == null) { // shard不存在则需创建 assert shardRouting.initializing() : shardRouting + " should have been removed by failMissingShards"; createShard(nodes, routingTable, shardRouting, state); } else { // 存在则更新 updateShard(nodes, shardRouting, shard, routingTable, state); } } } } | 副本分片恢复走的是createShard分支,在该方法中,首先获取shardRouting的类型,如果恢复类型为PEER,说明该分片需要从远端获取,则需要找到源节点,然后调用IndicesService.createShard: RecoverySource的Type有以下几种: 1 2 3 4 5 | EMPTY_STORE, EXISTING_STORE,//主分片本地恢复 PEER,//副分片从远处主分片恢复 SNAPSHOT,//从快照恢复 LOCAL_SHARDS//从本节点其它分片恢复(shrink时) | createShard代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | private void createShard(DiscoveryNodes nodes, RoutingTable routingTable, ShardRouting shardRouting, ClusterState state) { assert shardRouting.initializing() : "only allow shard creation for initializing shard but was " + shardRouting; DiscoveryNode sourceNode = null; // 如果恢复方式是peer,则会找到shard所在的源节点进行恢复 if (shardRouting.recoverySource().getType() == Type.PEER) { sourceNode = findSourceNodeForPeerRecovery(logger, routingTable, nodes, shardRouting); if (sourceNode == null) { logger.trace("ignoring initializing shard {} - no source node can be found.", shardRouting.shardId()); return; } } try { final long primaryTerm = state.metadata().index(shardRouting.index()).primaryTerm(shardRouting.id()); logger.debug("{} creating shard with primary term [{}]", shardRouting.shardId(), primaryTerm); indicesService.createShard( shardRouting, recoveryTargetService, new RecoveryListener(shardRouting, primaryTerm), repositoriesService, failedShardHandler, this::updateGlobalCheckpointForShard, retentionLeaseSyncer, nodes.getLocalNode(), sourceNode); } catch (Exception e) { failAndRemoveShard(shardRouting, true, "failed to create shard", e, state); } } /** * Finds the routing source node for peer recovery, return null if its not found. Note, this method expects the shard * routing to *require* peer recovery, use {@link ShardRouting#recoverySource()} to check if its needed or not. */ private static DiscoveryNode findSourceNodeForPeerRecovery(Logger logger, RoutingTable routingTable, DiscoveryNodes nodes, ShardRouting shardRouting) { DiscoveryNode sourceNode = null; if (!shardRouting.primary()) { ShardRouting primary = routingTable.shardRoutingTable(shardRouting.shardId()).primaryShard(); // only recover from started primary, if we can't find one, we will do it next round if (primary.active()) { // 找到primary shard所在节点 sourceNode = nodes.get(primary.currentNodeId()); if (sourceNode == null) { logger.trace("can't find replica source node because primary shard {} is assigned to an unknown node.", primary); } } else { logger.trace("can't find replica source node because primary shard {} is not active.", primary); } } else if (shardRouting.relocatingNodeId() != null) { // 找到搬迁的源节点 sourceNode = nodes.get(shardRouting.relocatingNodeId()); if (sourceNode == null) { logger.trace("can't find relocation source node for shard {} because it is assigned to an unknown node [{}].", shardRouting.shardId(), shardRouting.relocatingNodeId()); } } else { throw new IllegalStateException("trying to find source node for peer recovery when routing state means no peer recovery: " + shardRouting); } return sourceNode; } | 源节点的确定分两种情况,如果当前shard本身不是primary shard,则源节点为primary shard所在节点,否则,如果当前shard正在搬迁中(从其他节点搬迁到本节点),则源节点为数据搬迁的源头节点。得到源节点后调用IndicesService.createShard,在该方法中调用方法IndexShard.startRecovery开始恢复。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | public void startRecovery(RecoveryState recoveryState, PeerRecoveryTargetService recoveryTargetService, PeerRecoveryTargetService.RecoveryListener recoveryListener, RepositoriesService repositoriesService, Consumer mappingUpdateConsumer, IndicesService indicesService) { // TODO: Create a proper object to encapsulate the recovery context // all of the current methods here follow a pattern of: // resolve context which isn't really dependent on the local shards and then async // call some external method with this pointer. // with a proper recovery context object we can simply change this to: // startRecovery(RecoveryState recoveryState, ShardRecoverySource source ) { // markAsRecovery("from " + source.getShortDescription(), recoveryState); // threadPool.generic().execute() { // onFailure () { listener.failure() }; // doRun() { // if (source.recover(this)) { // recoveryListener.onRecoveryDone(recoveryState); // } // } // }} // } assert recoveryState.getRecoverySource().equals(shardRouting.recoverySource()); switch (recoveryState.getRecoverySource().getType()) { case EMPTY_STORE: case EXISTING_STORE: executeRecovery("from store", recoveryState, recoveryListener, this::recoverFromStore); break; case PEER: try { markAsRecovering("from " + recoveryState.getSourceNode(), recoveryState); recoveryTargetService.startRecovery(this, recoveryState.getSourceNode(), recoveryListener); } catch (Exception e) { failShard("corrupted preexisting index", e); recoveryListener.onRecoveryFailure(recoveryState, new RecoveryFailedException(recoveryState, null, e), true); } break; case SNAPSHOT: final String repo = ((SnapshotRecoverySource) recoveryState.getRecoverySource()).snapshot().getRepository(); executeRecovery("from snapshot", recoveryState, recoveryListener, l -> restoreFromRepository(repositoriesService.repository(repo), l)); break; case LOCAL_SHARDS: final IndexMetadata indexMetadata = indexSettings().getIndexMetadata(); final Index resizeSourceIndex = indexMetadata.getResizeSourceIndex(); final List startedShards = new ArrayList<>(); final IndexService sourceIndexService = indicesService.indexService(resizeSourceIndex); final Set requiredShards; final int numShards; if (sourceIndexService != null) { requiredShards = IndexMetadata.selectRecoverFromShards(shardId().id(), sourceIndexService.getMetadata(), indexMetadata.getNumberOfShards()); for (IndexShard shard : sourceIndexService) { if (shard.state() == IndexShardState.STARTED && requiredShards.contains(shard.shardId())) { startedShards.add(shard); } } numShards = requiredShards.size(); } else { numShards = -1; requiredShards = Collections.emptySet(); } if (numShards == startedShards.size()) { assert requiredShards.isEmpty() == false; executeRecovery("from local shards", recoveryState, recoveryListener, l -> recoverFromLocalShards(mappingUpdateConsumer, startedShards.stream().filter((s) -> requiredShards.contains(s.shardId())).collect(Collectors.toList()), l)); } else { final RuntimeException e; if (numShards == -1) { e = new IndexNotFoundException(resizeSourceIndex); } else { e = new IllegalStateException("not all required shards of index " + resizeSourceIndex + " are started yet, expected " + numShards + " found " + startedShards.size() + " can't recover shard " + shardId()); } throw e; } break; default: throw new IllegalArgumentException("Unknown recovery source " + recoveryState.getRecoverySource()); } } | 对于恢复类型为PEER的任务,恢复动作的真正执行者为PeerRecoveryTargetService.doRecovery。在该方法中,首先调用getStartRecoveryRequest获取shard的metadataSnapshot,该结构中包含shard的段信息,如syncid、checksum、doc数等,然后封装为StartRecoveryRequest,通过RPC发送到源节点: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | private void doRecovery(final long recoveryId, final StartRecoveryRequest preExistingRequest) { final String actionName; final TransportRequest requestToSend; final StartRecoveryRequest startRequest; final RecoveryState.Timer timer; try (RecoveryRef recoveryRef = onGoingRecoveries.getRecovery(recoveryId)) { if (recoveryRef == null) { logger.trace("not running recovery with id [{}] - can not find it (probably finished)", recoveryId); return; } final RecoveryTarget recoveryTarget = recoveryRef.target(); timer = recoveryTarget.state().getTimer(); if (preExistingRequest == null) { try { final IndexShard indexShard = recoveryTarget.indexShard(); indexShard.preRecovery(); assert recoveryTarget.sourceNode() != null : "can not do a recovery without a source node"; logger.trace("{} preparing shard for peer recovery", recoveryTarget.shardId()); indexShard.prepareForIndexRecovery(); final long startingSeqNo = indexShard.recoverLocallyUpToGlobalCheckpoint(); assert startingSeqNo == UNASSIGNED_SEQ_NO || recoveryTarget.state().getStage() == RecoveryState.Stage.TRANSLOG : "unexpected recovery stage [" + recoveryTarget.state().getStage() + "] starting seqno [ " + startingSeqNo + "]"; // 构造recovery request startRequest = getStartRecoveryRequest(logger, clusterService.localNode(), recoveryTarget, startingSeqNo); requestToSend = startRequest; actionName = PeerRecoverySourceService.Actions.START_RECOVERY; } catch (final Exception e) { // this will be logged as warning later on... logger.trace("unexpected error while preparing shard for peer recovery, failing recovery", e); onGoingRecoveries.failRecovery(recoveryId, new RecoveryFailedException(recoveryTarget.state(), "failed to prepare shard for recovery", e), true… ### 记一次RocketMQ Client超时问题排查 - URL: https://jiankunking.com/org-apache-rocketmq-shaded-io-grpc-statusruntimeexception-deadline-exceeded-clientcall-started-after-deadline-exceeded-2-591386886s-from-now.html - Content type: original - Published: 2023-12-15 - Updated: 2023-12-15 - Summary: RocketMQ Client在云上环境报超时异常的排查过程,分析gRPC deadline exceeded问题的根因与解决方案。 - Categories: Debugging - Tags: Java, Timeout, RocketMQ, gRPC, Deadline-Exceeded Article text: 文章速览 RocketMQ Client在云上环境报超时异常的排查过程,分析gRPC deadline exceeded问题的根因与解决方案。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Debugging RocketMQ Client在云上环境报超时异常的排查过程,分析gRPC deadline exceeded问题的根因与解决方案。 问题的一种可能 昨天同事A遇到一个诡异问题,在代码中加入消费rocketmq的代码后,在本地运行好好的程序,部署到云上测试集群A1会报出以下异常: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | time,cluster,namespace,service,pod,log 2023-12-14 09:36:00,cluster-test,did-test,jiankunking-test,jiankunking-test-6b86565dbb-lzt9d,2023-12-14 09:36:00.595 INFO 1 --- [ XNIO-1 task-1] c.h.d.i.rocket.PdmRocketMqConsumer : [TID:95e19b4533204903a985c90b068cddcc_64_17025177603380033] --- 构建mq5.0消费者:proxy:10.163.240.237:8080, topic:product_material_topic_uat_center, group:product_material_group_center 2023-12-14 09:36:40,cluster-test,did-test,jiankunking-test,jiankunking-test-6b86565dbb-lzt9d,2023-12-14 09:36:39.995 ERROR 1 --- [ XNIO-1 task-1] c.h.d.i.rocket.PdmRocketMqConsumer : [TID:95e19b4533204903a985c90b068cddcc_64_17025177603380033] --- 构建mq5.0消费者异常:proxy:10.163.240.237:8080, topic:product_material_topic_uat_center, group:product_material_group_center 2023-12-14 09:36:40,cluster-test,did-test,jiankunking-test,jiankunking-test-6b86565dbb-lzt9d, 2023-12-14 09:36:40,cluster-test,did-test,jiankunking-test,jiankunking-test-6b86565dbb-lzt9d,java.lang.IllegalStateException: Expected the service PushConsumerImpl-0 [FAILED] to be RUNNING, but the service has FAILED at org.apache.rocketmq.shaded.com.google.common.util.concurrent.AbstractService.checkCurrentState(AbstractService.java:381) ~[rocketmq-client-java-5.0.5.jar!/:na] at org.apache.rocketmq.shaded.com.google.common.util.concurrent.AbstractService.awaitRunning(AbstractService.java:305) ~[rocketmq-client-java-5.0.5.jar!/:na] at org.apache.rocketmq.shaded.com.google.common.util.concurrent.AbstractIdleService.awaitRunning(AbstractIdleService.java:165) ~[rocketmq-client-java-5.0.5.jar!/:na] at org.apache.rocketmq.client.java.impl.consumer.PushConsumerBuilderImpl.build(PushConsumerBuilderImpl.java:128) ~[rocketmq-client-java-5.0.5.jar!/:na] at com.jiankunking.issuemanage.rocket.PdmRocketMqConsumer.initConsumer(PdmRocketMqConsumer.java:89) ~[classes!/:na] at com.jiankunking.issuemanage.controller.IssueTagInfoController.initRocketMq$original$l28F75DZ(IssueTagInfoController.java:103) ~[classes!/:na] at com.jiankunking.issuemanage.controller.IssueTagInfoController.initRocketMq$original$l28F75DZ$accessor$0D0TSRNs(IssueTagInfoController.java) ~[classes!/:na] at com.jiankunking.issuemanage.controller.IssueTagInfoController$auxiliary$Rnw73z4x.call(Unknown Source) ~[classes!/:na] at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:86) ~[skywalking-agent.jar:8.12.0] at com.jiankunking.issuemanage.controller.IssueTagInfoController.initRocketMq(IssueTagInfoController.java) ~[classes!/:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na] at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na] at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na] at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) ~[spring-web-5.3.24.jar!/:5.3.24] at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150) ~[spring-web-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1071) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:964) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at javax.servlet.http.HttpServlet.service(HttpServlet.java:645) ~[javax.servlet-api-4.0.1.jar!/:4.0.1] at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.24.jar!/:5.3.24] at javax.servlet.http.HttpServlet.service(HttpServlet.java:750) ~[javax.servlet-api-4.0.1.jar!/:4.0.1] at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.24.jar!/:5.3.24] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.24.jar!/:5.3.24] at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.24.jar!/:5.3.24] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.24.jar!/:5.3.24] at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.24.jar!/:5.3.24] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.24.jar!/:5.3.24] at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:275) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler.access$100(ServletInitialHandler.java:79) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:134) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:131) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:255) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler.access$000(ServletInitialHandler.java:79) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:100) ~[undertow-servlet-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.server.Connectors.executeRootHandler(Connectors.java:387) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:852) ~[undertow-core-2.2.20.Final.jar!/:2.2.20.Final] at org.apache.skywalking.apm.plugin.undertow.v2x.SWRunnable.run(SWRunnable.java:45) ~[na:na] at org.apache.skywalking.apm.plugin.undertow.v2x.SWRunnable.run(SWRunnable.java:45) ~[na:na] at org.jboss.threads.ContextClassLoaderSavingRunnable.run(ContextClassLoaderSavingRunnable.java:35) ~[jboss-threads-3.1.0.Final.jar!/:3.1.0.Final] at org.jboss.threads.EnhancedQueueExecutor.safeRun(EnhancedQueueExecutor.java:2019) ~[jboss-threads-3.1.0.Final.jar!/:3.1.0.Final] at org.jboss.threads.EnhancedQueueExecu… ### Elasticsearch 集群状态 - URL: https://jiankunking.com/elasticsearch-cluster-state.html - Content type: original - Published: 2023-11-05 - Updated: 2023-11-05 - Summary: 详解Elasticsearch集群状态API返回结果,包括节点信息、索引元数据、路由表等关键字段的含义和用途。 - Categories: Elasticsearch - Tags: Elasticsearch, Cluster, State Article text: 文章速览 详解Elasticsearch集群状态API返回结果,包括节点信息、索引元数据、路由表等关键字段的含义和用途。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 详解Elasticsearch集群状态API返回结果,包括节点信息、索引元数据、路由表等关键字段的含义和用途。 1 2 3 | GET /_cluster/state https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-state.html | 详细信息如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 | { "cluster_name": "business-log", "cluster_uuid": "ArYy-qmCTbCQTDUI8ogsBg", "version": 497261, "state_uuid": "6_u2t1A1Rnyw70hfp7eWIQ", "master_node": "1WRaRyU-SuCPn8qbz3e4hg", "blocks": { "indices": { ".kibana_1": { "8": { "description": "index write (api)", "retryable": false, "levels": [ "write" ] } } } }, "nodes": { "hYrrmhLHTx-QHoDmZ2wATg": { "__jiankunking_comment__": "节点名称、监听地址和端口等信息", "name": "es-b-220", "ephemeral_id": "Pk0I_YgZQKe0bK1EIQ8R-w", "transport_address": "127.0.220.220:9301", "attributes": { "ml.machine_memory": "135202910208", "ml.max_open_jobs": "512", "xpack.installed": "true", "ml.max_jvm_size": "33285996544", "transform.node": "true" }, "roles": [ "data", "data_cold", "data_content", "data_frozen", "data_hot", "data_warm", "ingest", "master", "ml", "remote_cluster_client", "transform" ] } }, "metadata": { "__metadata_jiankunking_comment__": "集群元数据", "cluster_uuid": "ArYy-qmCTbCQTDUI8ogsBg", "cluster_uuid_committed": true, "__cluster_uuid_committed_comment__": "Whether the current node with the given cluster state is locked into the cluster with the UUID returned by {@link #clusterUUID()}, meaning that it will not accept any cluster state with a different clusterUUID. https://github.com/jiankunking/elasticsearch/blob/692cca611db5db3365cba45320879f4e05d3d7d2/server/src/main/java/org/elasticsearch/cluster/metadata/Metadata.java#L241", "cluster_coordination": { "term": 92, "__last_committed_config_comment__": "https://www.elastic.co/guide/en/elasticsearch/reference/7.13/modules-discovery-voting.html" "last_committed_config": [ "hYrrmhLHTx-QHoDmZ2wATg", "jaNLhd1eT6SUYcLJkHpE1Q", "1VQFmt9jQ-6d7fCMjP-vnQ", "uzGdH3VeRbOlcDIWeKdgIw", "PhjlCea6TNKh4rdZPyPDkA", "ZFQdNP4HSgCtPCnMfDdopw", "1WRaRyU-SuCPn8qbz3e4hg" ], "last_accepted_config": [ "hYrrmhLHTx-QHoDmZ2wATg", "jaNLhd1eT6SUYcLJkHpE1Q", "1VQFmt9jQ-6d7fCMjP-vnQ", "uzGdH3VeRbOlcDIWeKdgIw", "PhjlCea6TNKh4rdZPyPDkA", "ZFQdNP4HSgCtPCnMfDdopw", "1WRaRyU-SuCPn8qbz3e4hg" ], "voting_config_exclusions": [] }, "templates": { "__templates_jiankunking_comment__": "全部模板的具体内容", "k8s-log-template": { "order": 0, "index_patterns": [ "logstash-*" ], "settings": { "index": { "codec": "best_compression", "refresh_interval": "30s", "number_of_shards": "3", "number_of_replicas": "1" } }, "mappings": { "_doc": { "dynamic_templates": [ { "strings_as_keywords": { "unmatch": "log", "mapping": { "type": "keyword" }, "match_mapping_type": "string" } }, { "log": { "mapping": { "norms": false, "analyzer": "standard", "type": "text" }, "match_mapping_type": "string", "match": "log" } }, { "@timestamp": { "mapping": { "type": "date" }, "match_mapping_type": "date", "match": "@timestamp" } } ] } }, "aliases": {} } }, "indices": { "__indices_jiankunking_comment__": "索引列表", "logstash-2023.11.06": { "version": 27, "mapping_version": 20, "settings_version": 1, "aliases_version": 1, "routing_num_shards": 896, "state": "open", "settings": { "index": { "codec": "best_compression", "routing": { "allocation": { "include": { "_tier_preference": "data_content" } } }, "refresh_interval": "30s", "number_of_shards": "7", "provided_name": "logstash-2023.11.06", "creation_date": "1699200016628", "number_of_replicas": "1", "uuid": "eplKvvIaSpaTzwqhjwGNYQ", "version": { "created": "7130499" } } }, "mappings": { "_doc": { "dynamic_templates": [ { "strings_as_keywords": { "unmatch": "log", "mapping": { "type": "keyword" }, "match_mapping_type": "string" } }, { "log": { "mapping": { "norms": false, "analyzer": "standard", "type": "text" }, "match_mapping_type": "string", "match": "log" } }, { "@timestamp": { "mapping": { "type": "date" }, "match_mapping_type": "date", "match": "@timestamp" } } ], "properties": { "cluster": { "type": "keyword" }, "kubernetes": { "properties": { "container_name": { "type": "keyword" }, "statefulset": { "properties": { "name": { "type": "keyword" } } }, "annotations": { "properties": { "helm_sh/namespace": { "type": "keyword" }, "helm_sh/release": { "type": "keyword" } } }, "namespace_uid": { "type": "keyword" }, "namespace_labels": { "properties": { "kubernetes_io/metadata_name": { "type": "keyword" }, "control-plane": { "type": "keyword" } } }, "namespace_name": { "type": "keyword" }, "pod_name": { "type": "keyword" } } }, "log": { "norms": false, "analyzer": "standard", "type": "text" }, "node_name": { "type": "keyword" }, "@timestamp": { "type": "date" } } } }, "aliases": [], "primary_terms": { "__primary_terms_jiankunking_comment__": "某个分片被选为主分片的次数,用于区分新旧主分片;https://www.devopsschool.com/blog/what-is-seq_no-and-primary_term-in-elasticsearch/", "0": 1, "1": 2, "2": 4, "3": 3, "4": 1, "5": 7, "6": 4 }, "in_sync_allocations": { "__in_sync_allocations_jiankunking_comment__": "同步分片列表,代表某个分片中拥有最新数据的分片列表", "5": [ "pYyuBTo0QVeuvY2PrADVJQ", "zfP06et8TOuy3NAUirRO1w" ], "6": [ "EQl7pge5ST26Wnz-lGLB6g", "J1PeYw0fSpyNdNl_PJ1PAA" ], "2": [ "BmcC-XHYR36suKmyGqA_DQ", "53scs4-9SuWB-YgZ000SLg" ], "4": [ "HWbwWFWfRKmiRQXVA7KVrA", "539ACU5IQGyjXGcbqDpZcA" ], "3": [ "o3YjH-7qQpSEX1FQKVUoDQ", "DezGojWLSiOKSvEex1vH6Q" ], "1": [ "jkBt1_D1Sni_3Uo2k8gJow", "cyKkS7K2Q1uXY_1F44w9ow" ], "0": [ "Th7wtq7RQASfrRj5csnz0w", "GVifsKOwTlqhxGpgYVOdfQ" ] }, "rollover_info": {}, "system": false, "timestamp_range": { "unknown": true } } }, "ingest": { "pipeline": [ { "id": "xpack_monitoring_6", "config": { "description": "This pipeline upgrades documents from the older version of the Monitoring API to the newer version (7) by fixing breaking changes in those older documents before they are indexed from the older version (6).", "version": 7120099, "processors": [ { "script": { "source": "ctx._type = null" } }, { "gsub": { "field": "_index", "pattern": "(.monitoring-\\w+-)6(-.+)", "replacement": "$17$2" } } ] } } ] }, "index_template": { "__index_template_jiankunking_comment__": "模板有两种类型:索引模板和组件模板。 组件模板是可重用的构建块,用于配置映射,设置和别名。 你使用组件模板来构造索引模板,但它们不会直接应用于一组索引。 索引模板可以包含组件模板的集合,也可以直接指定设置,映射和别名。https://blog.csdn.net/UbuntuTouch/article/details/113751797", "index_template": { "logs": { "index_patterns": [ "logs-*-*" ], "composed_of": [ "logs-mappings", "logs-settings" ], "priority": 100, "version": 0, "_meta": { "description": "default logs template installed by x-pack", "managed": true }, "data_stream": { "hidden": false }, "allow_auto_create": true } } }, "data_stream": { "data_stream": { "ilm-history-5": { "name": "ilm-history-5", "timestamp_field": { "name": "@timestamp" }, "indices": [ { "index_name": ".ds-ilm-history-5-2023.07.18-000024", "index_uuid": "ovhEhrqgQmq6MR4pLk2m4A" }, { "index_name": ".ds-ilm-history-5-2023.08.17-000025", "index_uuid": "5zw095VyRJyWBlAovftH3g" }, { "index_name": ".ds-ilm-history-5-2023.09.16-000026", "index_uuid": "nIRDt047TmSLIc-P7fW4Ug" }, { "index_name": ".ds-ilm-history-5-2023.10.16-000027", "index_uuid": "1F4fEU6YTnynu06PWcZ9LQ" } ], "generation": 27, "_meta": { "description": "index template for ILM history indices", "managed": true }, "hidden": true, "replicated": false, "system": false } } }, "index-graveyard": { "__index-graveyard_jiankunking_comment__": "index-graveyard:索引墓碑。记录已删除的索引,并保存一段时间。索引删除是主节点通过下发集群状态来执行的;各节点处理集群状态是异步的过程。例如,索引分片分布在5个节点上,删除索引期间,某个节点是“down”掉的,没有执行删除逻辑;当这个节点恢复的时候,其存储的已删除的索引会被当作孤立资源加入集群,索引死而复活。墓碑的作用就是防止发生这种情况。", "tombstones": [ { "__tombstones_jiankunking_comment__": "已删除的索引列表" }, { "index": { "index_name": "logstash-hic-2023.10.13", "index_uuid": "wLl2HTaDQ7-UuKuvpu_Y8w" }, "delete_date_in_millis": 1699068185273 }, { "index": { "index_name": "logstash-mplat-2023.10.13", "index_uuid": "sDM_kklIST6ym1cLZ3RKDQ" }, "delete_date_in_millis": 1699068185273 } ] }, "index_lifecycle": { "policies": { "kibana-event-log-policy": { "policy": { "phases": { "hot": { "min_age": "0ms", "actions": { "rollover": { "max_size": "50gb", "max_age": "30d" } } }, "delete": { "min_age": "90d", "actions": { "delete": { "delete_searchable_snapshot": true } } } } }, "headers": {}, "v… ### 一次不接受Elasticsearch官方建议导致的事故 - URL: https://jiankunking.com/an-accident-caused-by-not-accepting-the-official-advice-of-elasticsearch.html - Content type: original - Published: 2023-10-27 - Updated: 2023-10-27 - Summary: 复盘一次 Elasticsearch 7 节点集群磁盘与分片问题引发的生产事故,记录分析、排查和恢复过程。 - Categories: Debugging - Tags: Elasticsearch, Shard, Disk, Production-Incident Article text: 文章速览 复盘一次 Elasticsearch 7 节点集群磁盘与分片问题引发的生产事故,记录分析、排查和恢复过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Debugging 记录一下 一次Elasticsearch集群事故分析、排查、处理 背景介绍 事故发生的ElasticSearch集群共有7台机器: - 127.0.204.193 - 127.0.204.194 - 127.0.204.195 - 127.0.220.73 - 127.0.220.74 - 127.0.220.220 - 127.0.220.221 其中193、194、195的机器配置一样,具体如下: - CPU:32核 - 内存:128G - 磁盘:4T*3 系统盘单独挂载:40G 73、74、220、221的机器配置一样,具体如下: - CPU:32核 - 内存:128G - 磁盘:10T 系统盘单独挂载:50G 以上7台机器用的都是阿里云的高效云盘,https://help.aliyun.com/zh/ecs/user-guide/disks-2 也就是说最大吞吐量(读+写 上限)为140MB/s 问题 由于比较穷,所以集群的部署情况比较复杂,这7台机器上共有一个Kafka集群、一个ZooKeeper集群、两个ElasticSearch集群。 其中Kafka、ZooKeeper集群部署在193、194、195上; 两个ElasticSearch集群各有7个节点(也就是这个7台机器每个都有两个ElasticSearch集群的实例); 193、194、195上的3块盘,Kafka、ZooKeeper、ElasticSearch(2个)集群都在用。 在这次事故之前发生过一次Kafka集群写入延迟,经分析是两个ElasticSearch集群与Kafka集群公用一块磁盘导致磁盘io满,造成Kafka集群集群延迟。 所以将193、194、195上的3块盘的使用调整为: - 一块4T的盘单独给Kafka、ZooKeeper使用 - 另外两块4T的盘两个ElasticSearch集群混用(其中一个ElasticSearch集群的数据量,已经很小了,不到10G,所以可以忽略) 好了,前情回顾已经写完了,下面来说下这次出现的问题: - 23号下午5点,ElasticSearch集群挂了 - 集群没有master - 25号上午9点,ElasticSearch集群又开始rebalance shards 分析 因为23号的问题很紧急,所以当时采取的方案就是重启整个集群,先恢复。 193上当时没有master的异常日志 1 2 3 4 5 6 | {"type": "server", "timestamp": "2023-10-23T09:22:08,955Z", "level": "WARN", "component": "o.e.c.c.ClusterFormationFailureHelper", "cluster.name": "business-log", "node.name": "es-b-193", "message": "master not discovered or elected yet, an election requires at least 4 nodes with ids from [hYrrmhLHTx-QHoDmZ2wATg, jaNLhd1eT6SUYcLJkHpE1Q, 1VQFmt9jQ-6d7fCMjP-vnQ, uzGdH3VeRbOlcDIWeKdgIw, PhjlCea6TNKh4rdZPyPDkA, ZFQdNP4HSgCtPCnMfDdopw, 1WRaRyU-SuCPn8qbz3e4hg], have discovered [{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{k0x5Rog-S0CFhAmorJ3V0Q}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}, {es-b-221}{jaNLhd1eT6SUYcLJkHpE1Q}{XkD-b14XQzq1-o3EiDSuKA}{127.0.220.221}{127.0.220.221:9301}{cdfhilmrstw}, {es-b-194}{PhjlCea6TNKh4rdZPyPDkA}{6pQJDRevScad3hr_hJcnTg}{127.0.204.194}{127.0.204.194:9301}{cdfhilmrstw}, {es-b-220}{hYrrmhLHTx-QHoDmZ2wATg}{Qyz9MF2bRmKQq9uPWgTBuw}{127.0.220.220}{127.0.220.220:9301}{cdfhilmrstw}, {es-b-195}{1VQFmt9jQ-6d7fCMjP-vnQ}{e_P2NNXnTIWGzg7vE8EWkA}{127.0.204.195}{127.0.204.195:9301}{cdfhilmrstw}, {es-b-74}{uzGdH3VeRbOlcDIWeKdgIw}{fhNTddq7Syi7zPDVCejDWQ}{127.0.220.74}{127.0.220.74:9301}{cdfhilmrstw}, {es-b-73}{1WRaRyU-SuCPn8qbz3e4hg}{1iRrVMWcSNGGxsuQYxePSg}{127.0.220.73}{127.0.220.73:9301}{cdfhilmrstw}] which is a quorum; discovery will continue using [127.0.204.194:9301, 127.0.204.195:9301] from hosts providers and [{es-b-220}{hYrrmhLHTx-QHoDmZ2wATg}{Qyz9MF2bRmKQq9uPWgTBuw}{127.0.220.220}{127.0.220.220:9301}{cdfhilmrstw}, {es-b-73}{1WRaRyU-SuCPn8qbz3e4hg}{1iRrVMWcSNGGxsuQYxePSg}{127.0.220.73}{127.0.220.73:9301}{cdfhilmrstw}, {es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{k0x5Rog-S0CFhAmorJ3V0Q}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}, {es-b-74}{uzGdH3VeRbOlcDIWeKdgIw}{fhNTddq7Syi7zPDVCejDWQ}{127.0.220.74}{127.0.220.74:9301}{cdfhilmrstw}, {es-b-194}{PhjlCea6TNKh4rdZPyPDkA}{6pQJDRevScad3hr_hJcnTg}{127.0.204.194}{127.0.204.194:9301}{cdfhilmrstw}, {es-b-221}{jaNLhd1eT6SUYcLJkHpE1Q}{XkD-b14XQzq1-o3EiDSuKA}{127.0.220.221}{127.0.220.221:9301}{cdfhilmrstw}, {es-b-195}{1VQFmt9jQ-6d7fCMjP-vnQ}{e_P2NNXnTIWGzg7vE8EWkA}{127.0.204.195}{127.0.204.195:9301}{cdfhilmrstw}] from last-known cluster state; node term 43, last-accepted version 397615 in term 43", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "ZFQdNP4HSgCtPCnMfDdopw" } {"type": "server", "timestamp": "2023-10-23T09:22:11,119Z", "level": "WARN", "component": "r.suppressed", "cluster.name": "business-log", "node.name": "es-b-193", "message": "path: /_cat/nodes, params: {h=ip,name,heap.percent,heap.current,heap.max,ram.percent,ram.current,ram.max,node.role,master,cpu,load_1m,load_5m,load_15m,disk.used_percent,disk.used,disk.total}", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "ZFQdNP4HSgCtPCnMfDdopw" , org.elasticsearch.cluster.block.ClusterBlockException: blocked by: [SERVICE_UNAVAILABLE/2/no master];\",\n","stream":"stdout","time":"2023-10-23T09:21:34.490903538Z"} {"log":"\"at org.elasticsearch.cluster.block.ClusterBlocks.globalBlockedException(ClusterBlocks.java:179 | 恢复后,排查日志,主要看到的现象就是193节点频繁的added、removed 1 2 3 | added {{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{k0x5Rog-S0CFhAmorJ3V0Q}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}}, term: 43, version: 397620, reason: ApplyCommitRequest{term=43, version=397620, sourceNode={es-b-73}{1WRaRyU-SuCPn8qbz3e4hg}{1iRrVMWcSNGGxsuQYxePSg}{127.0.220.73}{127.0.220.73:9301}{cdfhilmrstw}{ml.machine_memory=133070966784, ml.max_open_jobs=512, xpack.installed=true, ml.max_jvm_size=33285996544, transform.node=true}} removed {{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{k0x5Rog-S0CFhAmorJ3V0Q}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}}, term: 43, version: 397621, reason: ApplyCommitRequest{term=43, version=397621, sourceNode={es-b-73}{1WRaRyU-SuCPn8qbz3e4hg}{1iRrVMWcSNGGxsuQYxePSg}{127.0.220.73}{127.0.220.73:9301}{cdfhilmrstw}{ml.machine_memory=133070966784, ml.max_open_jobs=512, xpack.installed=true, ml.max_jvm_size=33285996544, transform.node=true}} | 导致193上的shard一直rebalance。但193上日志,不知道什么原因丢失了,能找到的最早的日志也是23号下午5点21分之后的日志了。经过日志排查问题到这里被卡主了。 回看发生问题前的一段时间各个节点的监控,CPU、内存、磁盘、写入量等都没有异常的波动,唯一发现的一个异常: 那么generic thread pool是做啥的呢? => 用于通用操作(例如,后台节点发现)它的线程池类型是动态缩放的 到这里也只是能印证了,节点间相互发现有问题。那么到底什么原因造成的集群宕机呢? 时间来到了今天上午也就是25号9点左右的时候,发现集群又开始rebalance shards,通过 1 | GET /_cat/shards?h=index,shard,prirep,state,unassigned.reason | 可以看到shard重新迁移的原因是node-left,到master节点的机器看下日志: 1 2 3 4 5 | root@jiankunking-es-02:~# docker logs -f 1f741600dbcc |grep node-left {"type": "server", "timestamp": "2023-10-23T09:33:18,042Z", "level": "INFO", "component": "o.e.c.s.MasterService", "cluster.name": "business-log", "node.name": "es-b-194", "message": "node-left[{es-b-221}{jaNLhd1eT6SUYcLJkHpE1Q}{XkD-b14XQzq1-o3EiDSuKA}{127.0.220.221}{127.0.220.221:9301}{cdfhilmrstw} reason: disconnected], term: 52, version: 399161, delta: removed {{es-b-221}{jaNLhd1eT6SUYcLJkHpE1Q}{XkD-b14XQzq1-o3EiDSuKA}{127.0.220.221}{127.0.220.221:9301}{cdfhilmrstw}}", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:52:35,140Z", "level": "INFO", "component": "o.e.c.s.MasterService", "cluster.name": "business-log", "node.name": "es-b-194", "message": "node-left[{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw} reason: followers check retry count exceeded], term: 52, version: 404805, delta: removed {{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}}", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:53:24,564Z", "level": "INFO", "component": "o.e.c.s.MasterService", "cluster.name": "business-log", "node.name": "es-b-194", "message": "node-left[{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw} reason: disconnected], term: 52, version: 404807, delta: removed {{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}}", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:53:30,879Z", "level": "INFO", "component": "o.e.c.s.MasterService", "cluster.name": "business-log", "node.name": "es-b-194", "message": "node-left[{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw} reason: disconnected], term: 52, version: 404810, delta: removed {{es-b-193}{ZFQdNP4HSgCtPCnMfDdopw}{PPAowFAWQRiI9s5FoaDxWQ}{127.0.204.193}{127.0.204.193:9301}{cdfhilmrstw}}", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } | 按理说 193 194 195这些机器与另外4台机器相比,区别点在于挂了多块盘,那性能应该更好啊,为啥连不上的会是他们呢? 又回看了下节点日志,发现节点间互连存在大量的超时 master(194)节点日志: 1 2 3 4 5 | {"type": "server", "timestamp": "2023-10-25T01:35:18,510Z", "level": "WARN", "component": "o.e.t.OutboundHandler", "cluster.name": "business-log", "node.name": "es-b-194", "message": "sending transport message [MessageSerializer{Request{indices:data/write/bulk[s][r]}{70758574}{false}{false}{false}}] of size [21075] on [Netty4TcpChannel{localAddress=/127.0.204.194:57580, remoteAddress=127.0.204.193/127.0.204.193:9301, profile=default}] took [29403ms] which is above the warn threshold of [5000ms]", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:35:18,510Z", "level": "WARN", "component": "o.e.t.OutboundHandler", "cluster.name": "business-log", "node.name": "es-b-194", "message": "sending transport message [MessageSerializer{Request{indices:data/write/bulk[s]}{70758608}{false}{false}{false}}] of size [1992] on [Netty4TcpChannel{localAddress=/127.0.204.194:57580, remoteAddress=127.0.204.193/127.0.204.193:9301, profile=default}] took [29403ms] which is above the warn threshold of [5000ms]", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:35:18,510Z", "level": "WARN", "component": "o.e.t.OutboundHandler", "cluster.name": "business-log", "node.name": "es-b-194", "message": "sending transport message [MessageSerializer{Request{indices:data/write/bulk[s]}{70758614}{false}{false}{false}}] of size [5563] on [Netty4TcpChannel{localAddress=/127.0.204.194:57580, remoteAddress=127.0.204.193/127.0.204.193:9301, profile=default}] took [29403ms] which is above the warn threshold of [5000ms]", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "PhjlCea6TNKh4rdZPyPDkA" } {"type": "server", "timestamp": "2023-10-25T01:35:18,510Z", "level": "WARN", "component": "o.e.t.OutboundHandler", "cluster.name": "business-log", "node.name": "es-b-194", "message": "sending transport message [MessageSerializer{Request{indices:data/write/bulk[s][r]}{70758686 … ### [转]快递员的工作策略——TCP窗口 - URL: https://jiankunking.com/the-work-strategy-of-couriers-tcp-window.html - Content type: repost - Published: 2023-10-16 - Updated: 2023-10-16 - Summary: 通过快递员类比解释 TCP 窗口机制,讨论发送窗口、接收窗口、MSS、确认策略和 Window Scale。 - Categories: Network - Tags: Network, TCP, Wireshark, IP, Window - Original author: 林沛满 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]TCP之在途字节数 - URL: https://jiankunking.com/bytes-in-transit-of-tcp.html - Content type: repost - Published: 2023-10-06 - Updated: 2023-10-06 - Summary: 结合 Wireshark 抓包讲解 TCP 在途字节数、网络承载量,以及如何判断拥塞、丢包与重传。 - Categories: Network - Tags: Network, TCP, Wireshark - Original author: 林沛满 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]Wireshark的提示 - URL: https://jiankunking.com/wireshark-prompt.html - Content type: repost - Published: 2023-10-06 - Updated: 2023-10-06 - Summary: 解释 Wireshark 常见提示信息的含义和排查方法,帮助初学者理解抓包不完整、校验和等分析线索。 - Categories: Network - Tags: Network, TCP, Wireshark - Original author: 林沛满 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]架构的本质:如何打造一个有序的系统? - URL: https://jiankunking.com/the-essence-of-architecture-how-to-create-an-orderly-system.html - Content type: repost - Published: 2023-09-24 - Updated: 2023-09-24 - Summary: 架构的本质是通过合理的内部编排,保证系统高度有序,能够不断扩展,满足业务和技术的变化。本文深入分析架构的本质、分类以及如何成为一名优秀的架构师。 - Categories: Architecture - Tags: Business, Technology, Application, System-Design The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [译]Elasticsearch自定义文档路由 - URL: https://jiankunking.com/customizing-your-document-routing.html - Content type: translation - Published: 2023-08-19 - Updated: 2023-08-19 - Summary: 翻译介绍 Elasticsearch 自定义文档路由的原理、配置、查询方式、适用场景,以及热点和关联模型等注意事项。 - Categories: Elasticsearch - Tags: Elasticsearch, Document, Routing - Original source: https://www.elastic.co/cn/blog/customizing-your-document-routing The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 三难选择(不可能三角) - URL: https://jiankunking.com/mundellian-trilemma.html - Content type: original - Published: 2023-07-12 - Updated: 2023-07-12 - Summary: 三元悖论与RUM猜想:在多个维度中,同时优化两项时,需要以另一项劣化作为代价。如何在取舍中找到平衡? - Categories: Misc - Tags: 三元悖论, 不可能三角, 三难选择 Article text: 文章速览 三元悖论与RUM猜想:在多个维度中,同时优化两项时,需要以另一项劣化作为代价。如何在取舍中找到平衡? 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Misc 三元悖论与RUM猜想:在多个维度中,同时优化两项时,需要以另一项劣化作为代价。如何在取舍中找到平衡? 如何取舍、平衡? 三元悖论 三元悖论是国际金融学中的原则,指一个国家不可能同时完成下列三者: - 资本自由进出(Capital mobility) - 固定汇率(Exchange rate) - 独立自主的货币政策(Monetary policy) RUM猜想 RUM猜想来自论文“Designing Access Methods: The RUM Conjecture”(Manos Athanassoulis et al.(2016)),同时被SIGMOD和EDBT收录。它说的是,对任何数据结构来说,在Read Overhead(读)、Update Overhead(写) 和 Memory or Storage Overhead(存储) 中,同时优化两项时,需要以另一项劣化作为代价。 CAP GC - Throughput represents the amount of work that can be done in a given time unit. In terms of this discussion, a garbage collection algorithm that performs more collection work per time unit is preferable, allowing higher throughput of the Java application. - Latency gives an indication of how long a single operation of the application takes. A garbage collection algorithm focused on latency tries to minimize impacting latency. In the context of a GC, the key concerns are whether its operation induces pauses, the extent of any pauses, and how long the pauses may be. - Memory footprint in the context of a GC means how much extra memory beyond the application’s Java heap memory usage the GC needs for proper operation. Data used purely for the management of the Java heap takes away from the application; if the amount of memory the GC (or, more generally, the JVM) uses is less, more memory can be provided to the application’s Java heap. Java garbage collection_ The 10-release evolution from JDK 8 to JDK 18 ### SQL必知必会 笔记 - URL: https://jiankunking.com/sql-must-know.html - Content type: original - Published: 2023-07-02 - Updated: 2023-07-02 - Summary: 《SQL必知必会》课程资料索引与学习笔记,汇总索引、B+树、Hash索引、缓冲池、慢SQL分析、数据恢复和SQL注入等数据库主题。 - Categories: MySQL - Tags: Reading Notes, MySQL Article text: 文章速览 《SQL必知必会》课程资料索引与学习笔记,汇总索引、B+树、Hash索引、缓冲池、慢SQL分析、数据恢复和SQL注入等数据库主题。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 《SQL必知必会》学习笔记,涵盖索引原理、B+树、Hash索引、索引优化等核心知识点。 SQL必知必会 作者: 陈旸 索引的原理:我们为什么用B+树来做索引? 24丨索引的原理:我们为什么用B+树来做索引 Hash索引的底层原理是什么 25丨Hash索引的底层原理是什么 为什么没有理想的索引 29丨为什么没有理想的索引 锁:悲观锁和乐观锁是什么 30丨锁:悲观锁和乐观锁是什么 为什么大部分RDBMS都会支持MVCC 31丨为什么大部分RDBMS都会支持MVCC 查询优化器是如何工作的 32丨查询优化器是如何工作的 如何使用性能分析工具定位SQL执行慢的原因 33丨如何使用性能分析工具定位SQL执行慢的原因 答疑篇:关于索引以及缓冲池的一些解惑 34丨答疑篇:关于索引以及缓冲池的一些解惑 数据库没有备份,没有使用Binlog的情况下,如何恢复数据 36丨数据库没有备份,没有使用Binlog的情况下,如何恢复数据 SQL注入:你的SQL是如何被注入的 37丨SQL注入:你的SQL是如何被注入的 ### SQL99 和 SQL92 的区别 - URL: https://jiankunking.com/sql99-vs-sql92.html - Content type: original - Published: 2023-06-23 - Updated: 2023-06-23 - Summary: SQL99和SQL92是两种不同的SQL标准,本文主要对比两者在JOIN语法方面的区别,建议多表连接使用SQL99标准,因为层次性更强,可读性更强。 - Categories: SQL - Tags: SQL, SQL99, SQL92, Standard Article text: 文章速览 SQL99和SQL92是两种不同的SQL标准,本文主要对比两者在JOIN语法方面的区别,建议多表连接使用SQL99标准,因为层次性更强,可读性更强。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:SQL SQL99和SQL92是两种不同的SQL标准,本文主要对比两者在JOIN语法方面的区别,建议多表连接使用SQL99标准,因为层次性更强,可读性更强。 这里主要说一下两者关于JOIN方面的区别: SQL 92 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | select e.*, d.dname, c.cname from emp e, dept d, city c where ( e.deptno = d.deptno and d.loc = c.cid and sal > 2000 ) or ( e.deptno = d.deptno and d.loc = c.cid and comm is not null ) order by e.sal | SQL 99 1 2 3 4 5 6 7 8 9 10 11 | select * from emp e inner join dept d on e.deptno = d.deptno inner join city c on d.loc = c.cid where e.sal > 2000 or e.comm is not null order by e.sal | 我个人建议多表连接使用 SQL99 标准,因为层次性更强,可读性更强。 ### Redis数据分布优化:如何应对数据倾斜? - URL: https://jiankunking.com/data-distribution-optimization-how-to-deal-with-data-skewness.html - Content type: original - Published: 2023-06-18 - Updated: 2023-06-18 - Summary: Redis切片集群中数据倾斜问题分析,包括数据量倾斜和数据访问倾斜两种类型,以及相应的优化策略。 - Categories: Redis - Tags: Reading Notes, Redis, Optimization, Data, Skewness Article text: 文章速览 Redis切片集群中数据倾斜问题分析,包括数据量倾斜和数据访问倾斜两种类型,以及相应的优化策略。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Redis Redis切片集群中数据倾斜问题分析,包括数据量倾斜和数据访问倾斜两种类型,以及相应的优化策略。 在切片集群中,数据会按照一定的分布规则分散到不同的实例上保存。比如,在使用 Redis Cluster 或 Codis 时,数据都会先按照 CRC 算法的计算值对 Slot(逻辑槽)取模,同时,所有的 Slot 又会由运维管理员分配到不同的实例上。这样,数据就被保存到相应的实例上了。 虽然这种方法实现起来比较简单,但是很容易导致一个问题:数据倾斜。 数据倾斜有两类。 - 数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。 - 数据访问倾斜:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。 如果发生了数据倾斜,那么保存了大量数据,或者是保存了热点数据的实例的处理压力就会增大,速度变慢,甚至还可能会引起这个实例的内存资源耗尽,从而崩溃。这是我们在应用切片集群时要避免的。 今天这节课,我就来和你聊聊,这两种数据倾斜是怎么发生的,我们又该怎么应对。 数据量倾斜的成因和应对方法 首先,我们来看数据量倾斜的成因和应对方案。 当数据量倾斜发生时,数据在切片集群的多个实例上分布不均衡,大量数据集中到了一个或几个实例上,如下图所示: 那么,数据量倾斜是怎么产生的呢?这主要有三个原因,分别是某个实例上保存了 bigkey、Slot 分配不均衡以及 Hash Tag。接下来,我们就一个一个来分析,同时我还会给你讲解相应的解决方案。 bigkey 导致倾斜 第一个原因是,某个实例上正好保存了 bigkey。bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。 而且,bigkey 的操作一般都会造成实例 IO 线程阻塞,如果 bigkey 的访问量比较大,就会影响到这个实例上的其它请求被处理的速度。 其实,bigkey 已经是我们课程中反复提到的一个关键点了。为了避免 bigkey 造成的数据倾斜,一个根本的应对方法是,我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。 此外,如果 bigkey 正好是集合类型,我们还有一个方法,就是把 bigkey 拆分成很多个小的集合类型数据,分散保存在不同的实例上。 我给你举个例子。假设 Hash 类型集合 user:info 保存了 100 万个用户的信息,是一个 bigkey。那么,我们就可以按照用户 ID 的范围,把这个集合拆分成 10 个小集合,每个小集合只保存 10 万个用户的信息(例如小集合 1 保存的是 ID 从 1 到 10 万的用户信息,小集合 2 保存的是 ID 从 10 万零 1 到 20 万的用户)。这样一来,我们就可以把一个 bigkey 化整为零、分散保存了,避免了 bigkey 给单个切片实例带来的访问压力。 需要注意的是,当 bigkey 访问量较大时,也会造成数据访问倾斜,我一会儿再给你讲具体怎么应对。 接下来,我们再来看导致数据量倾斜的第二个原因:Slot 分配不均衡。 Slot 分配不均衡导致倾斜 如果集群运维人员没有均衡地分配 Slot,就会有大量的数据被分配到同一个 Slot 中,而同一个 Slot 只会在一个实例上分布,这就会导致,大量数据被集中到一个实例上,造成数据倾斜。 我以 Redis Cluster 为例,来介绍下 Slot 分配不均衡的情况。 Redis Cluster 一共有 16384 个 Slot,假设集群一共有 5 个实例,其中,实例 1 的硬件配置较高,运维人员在给实例分配 Slot 时,就可能会给实例 1 多分配些 Slot,把实例 1 的资源充分利用起来。 但是,我们其实并不知道数据和 Slot 的对应关系,这种做法就可能会导致大量数据正好被映射到实例 1 上的 Slot,造成数据倾斜,给实例 1 带来访问压力。 为了应对这个问题,我们可以通过运维规范,在分配之前,我们就要避免把过多的 Slot 分配到同一个实例。如果是已经分配好 Slot 的集群,我们可以先查看 Slot 和实例的具体分配关系,从而判断是否有过多的 Slot 集中到了同一个实例。如果有的话,就将部分 Slot 迁移到其它实例,从而避免数据倾斜。 不同集群上查看 Slot 分配情况的方式不同:如果是 Redis Cluster,就用 CLUSTER SLOTS 命令;如果是 Codis,就可以在 codis dashboard 上查看。 比如说,我们执行 CLUSTER SLOTS 命令查看 Slot 分配情况。命令返回结果显示,Slot 0 到 Slot 4095 被分配到了实例 192.168.10.3 上,而 Slot 12288 到 Slot 16383 被分配到了实例 192.168.10.5 上。 1 2 3 4 5 6 7 8 9 | 127.0.0.1:6379> cluster slots 1) 1) (integer) 0 2) (integer) 4095 3) 1) "192.168.10.3" 2) (integer) 6379 2) 1) (integer) 12288 2) (integer) 16383 3) 1) "192.168.10.5" 2) (integer) 6379 | 如果某一个实例上有太多的 Slot,我们就可以使用迁移命令把这些 Slot 迁移到其它实例上。在 Redis Cluster 中,我们可以使用 3 个命令完成 Slot 迁移。 - CLUSTER SETSLOT:使用不同的选项进行三种设置,分别是设置 Slot 要迁入的目标实例,Slot 要迁出的源实例,以及 Slot 所属的实例。 - CLUSTER GETKEYSINSLOT:获取某个 Slot 中一定数量的 key。 - MIGRATE:把一个 key 从源实例实际迁移到目标实例。我来借助一个例子,带你了解下这三个命令怎么用。 我来借助一个例子,带你了解下这三个命令怎么用。假设我们要把 Slot 300 从源实例(ID 为 3)迁移到目标实例(ID 为 5),那要怎么做呢? 实际上,我们可以分成 5 步。 第 1 步,我们先在目标实例 5 上执行下面的命令,将 Slot 300 的源实例设置为实例 3,表示要从实例 3 上迁入 Slot 300。 1 | CLUSTER SETSLOT 300 IMPORTING 3 | 第 2 步,在源实例 3 上,我们把 Slot 300 的目标实例设置为 5,这表示,Slot 300 要迁出到实例 5 上,如下所示: 1 | CLUSTER SETSLOT 300 MIGRATING 5 | 第 3 步,从 Slot 300 中获取 100 个 key。因为 Slot 中的 key 数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移 key。 1 | CLUSTER GETKEYSINSLOT 300 100 | 第 4 步,我们把刚才获取的 100 个 key 中的 key1 迁移到目标实例 5 上(IP 为 192.168.10.5),同时把要迁入的数据库设置为 0 号数据库,把迁移的超时时间设置为 timeout。我们重复执行 MIGRATE 命令,把 100 个 key 都迁移完。 1 | MIGRATE 192.168.10.5 6379 key1 0 timeout | 最后,我们重复执行第 3 和第 4 步,直到 Slot 中的所有 key 都迁移完成。 从 Redis 3.0.6 开始,你也可以使用 KEYS 选项,一次迁移多个 key(key1、2、3),这样可以提升迁移效率。 1 | MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3 | 对于 Codis 来说,我们可以执行下面的命令进行数据迁移。其中,我们把 dashboard 组件的连接地址设置为 ADDR,并且把 Slot 300 迁移到编号为 6 的 codis server group 上。 1 | codis-admin --dashboard=ADDR -slot-action --create --sid=300 --gid=6 | 除了 bigkey 和 Slot 分配不均衡会导致数据量倾斜,还有一个导致倾斜的原因,就是使用了 Hash Tag 进行数据切片。 Hash Tag 导致倾斜 Hash Tag 是指加在键值对 key 中的一对花括号{}。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。如果没用 Hash Tag 的话,客户端计算整个 key 的 CRC16 的值。 举个例子,假设 key 是 user:profile:3231,我们把其中的 3231 作为 Hash Tag,此时,key 就变成了 user:profile:{3231}。当客户端计算这个 key 的 CRC16 值时,就只会计算 3231 的 CRC16 值。 否则,客户端会计算整个“user:profile:3231”的 CRC16 值。使用 Hash Tag 的好处是,如果不同 key 的 Hash Tag 内容都是一样的,那么,这些 key 对应的数据会被映射到同一个 Slot 中,同时会被分配到同一个实例上。 下面这张表就显示了使用 Hash Tag 后,数据被映射到相同 Slot 的情况,你可以看下。 其中,user:profile:{3231}和 user:order:{3231}的 Hash Tag 一样,都是 3231,它们的 CRC16 计算值对 16384 取模后的值也是一样的,所以就对应映射到了相同的 Slot 1024 中。user:profile:{5328}和 user:order:{5328}也是相同的映射结果。 那么,Hash Tag 一般用在什么场景呢?其实,它主要是用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。 这样操作起来非常麻烦,所以,我们可以使用 Hash Tag 把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。 但是,使用 Hash Tag 的潜在问题,就是大量的数据可能被集中到一个实例上,导致数据倾斜,集群中的负载不均衡。那么,该怎么应对这种问题呢?我们就需要在范围查询、事务执行的需求和数据倾斜带来的访问压力之间,进行取舍了。 我的建议是,如果使用 Hash Tag 进行切片的数据会带来较大的访问压力,就优先考虑避免数据倾斜,最好不要使用 Hash Tag 进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用。 好了,到这里,我们完整地了解了数据量倾斜的原因以及应对方法。接下来,我们再来看数据访问倾斜的原因和应对方法。 数据访问倾斜的成因和应对方法 发生数据访问倾斜的根本原因,就是实例上存在热点数据(比如新闻应用中的热点新闻内容、电商促销活动中的热门商品信息,等等)。 一旦热点数据被存在了某个实例中,那么,这个实例的请求访问量就会远高于其它实例,面临巨大的访问压力,如下图所示: 那么,我们该如何应对呢? 和数据量倾斜不同,热点数据通常是一个或几个数据,所以,直接重新分配 Slot 并不能解决热点数据的问题。 通常来说,热点数据以服务读操作为主,在这种情况下,我们可以采用热点数据多副本的方法来应对。 这个方法的具体做法是,我们把热点数据复制多份,在每一个数据副本的 key 中增加一个随机前缀,让它和其它副本数据不会被映射到同一个 Slot 中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的 key 又不一样,会被映射到不同的 Slot 中。在给这些 Slot 分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。 这里,有个地方需要注意下,**热点数据多副本方法只能针对只读的热点数据。**如果热点数据是有读有写的话,就不适合采用多副本方法了,因为要保证多副本间的数据一致性,会带来额外的开销。 对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。 小结 这节课,我向你介绍了数据倾斜的两种情况:数据量倾斜和数据访问倾斜。 造成数据量倾斜的原因主要有三个: - 数据中有 bigkey,导致某个实例的数据量增加; - Slot 手工分配不均,导致某个或某些实例上有大量数据; - 使用了 Hash Tag,导致数据集中到某些实例上。 而数据访问倾斜的主要原因就是有热点数据存在,导致大量访问请求集中到了热点数据所在的实例上。 为了应对数据倾斜问题,我给你介绍了四个方法,也分别对应了造成数据倾斜的四个原因。我把它们总结在下表中,你可以看下。 当然,如果已经发生了数据倾斜,我们可以通过数据迁移来缓解数据倾斜的影响。Redis Cluster 和 Codis 集群都提供了查看 Slot 分配和手工迁移 Slot 的命令,你可以把它们应用起来。 最后,关于集群的实例资源配置,我再给你一个小建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的 Slot。 ### Win 10 ElasticSearch源码本地调试 - URL: https://jiankunking.com/win-10-elasticsearch-source-code-local-debugging.html - Content type: original - Published: 2023-06-10 - Updated: 2023-06-10 - Summary: 图文详解如何在Windows 10环境下搭建Elasticsearch源码调试环境,包括代码下载、IDE配置、断点调试等完整步骤。 - Categories: Elasticsearch - Tags: Elasticsearch, Win10, Debug Article text: 文章速览 图文详解如何在Windows 10环境下搭建Elasticsearch源码调试环境,包括代码下载、IDE配置、断点调试等完整步骤。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 图文详解如何在Windows 10环境下搭建Elasticsearch源码调试环境,包括代码下载、IDE配置、断点调试等完整步骤。 下载代码 此处以我平时学习的代码为例 1 | git clone git@github.com:jiankunking/elasticsearch.git | 导入到Idea 原文地址:https://github.com/jiankunking/elasticsearch/blob/master/CONTRIBUTING.md Idea 代码运行 Debug Elasticsearch是idea导入代码后自动有的,但要注意下图红框1、2两处配置 https://discuss.elastic.co/t/failing-to-run-on-intellij-in-debug-mode/227805/3 本地代码运行 cd到代码根目录 执行如下命令 1 | ./gradlew run --debug-jvm | 看到界面显示如下 表示代码已经启动完成 请求调用 这时候就可以在idea代码中添加断点了 1 2 | curl --location 'http://localhost:9200' \ --header 'Authorization: Basic ZWxhc3RpYzpwYXNzd29yZA==' | 返回 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | { "name": "runTask-0", "cluster_name": "runTask", "cluster_uuid": "7OO9ktkiRp29frL6VYDzGg", "version": { "number": "8.0.0-SNAPSHOT", "build_flavor": "default", "build_type": "zip", "build_hash": "886e154d5d08d33a33c734c2961c6917b528925e", "build_date": "2023-06-08T01:37:09.815840600Z", "build_snapshot": true, "lucene_version": "8.8.0", "minimum_wire_compatibility_version": "7.12.0", "minimum_index_compatibility_version": "7.0.0" }, "tagline": "You Know, for Search" } | ### Redis的使用规范小建议 - URL: https://jiankunking.com/suggestion-for-redis-usage-standards.html - Content type: original - Published: 2023-06-04 - Updated: 2023-06-04 - Summary: Redis使用规范整理,涵盖键值对命名、数据结构选择、内存优化等方面,帮助规范使用Redis,实现高性能和节省内存的目标。 - Categories: Redis - Tags: Reading Notes, Redis, Standards Article text: 文章速览 Redis使用规范整理,涵盖键值对命名、数据结构选择、内存优化等方面,帮助规范使用Redis,实现高性能和节省内存的目标。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Redis Redis使用规范整理,涵盖键值对命名、数据结构选择、内存优化等方面,帮助规范使用Redis,实现高性能和节省内存的目标。 毕竟,高性能和节省内存,是我们的两个目标,只有规范地使用Redis,才能真正实现这两个目标。如果说之前的内容教会了你怎么用,那么今天的内容,就是帮助你用好Redis,尽量不出错。 好了,话不多说,我们来看下键值对的使用规范。 键值对使用规范 关于键值对的使用规范,我主要想和你说两个方面: - key 的命名规范,只有命名规范,才能提供可读性强、可维护性好的 key,方便日常管理; - value 的设计规范,包括避免bigkey、选择高效序列化方法和压缩方法、使用整数对象共享池、数据类型选择。 规范一:key 的命名规范 一个 Redis 实例默认可以支持 16 个数据库,我们可以把不同的业务数据分散保存到不同的数据库中。 但是,在使用不同数据库时,客户端需要使用 SELECT 命令进行数据库切换,相当于增加了一个额外的操作。 其实,我们可以通过合理命名 key,减少这个操作。具体的做法是,把业务名作为前缀,然后用冒号分隔,再加上具体的业务数据名。这样一来,我们可以通过 key 的前缀区分不同的业务数据,就不用在多个数据库间来回切换了。 我给你举个简单的小例子,看看具体怎么命名 key。 比如说,如果我们要统计网页的独立访客量,就可以用下面的代码设置 key,这就表示,这个数据对应的业务是统计 unique visitor(独立访客量),而且对应的页面编号是 1024。 1 | uv:page:1024 | 这里有一个地方需要注意一下。key 本身是字符串,底层的数据结构是 SDS。SDS 结构中会包含字符串长度、分配空间大小等元数据信息。从 Redis 3.2 版本开始,当 key 字符串的长度增加时,SDS 中的元数据也会占用更多内存空间。 所以,我们在设置 key 的名称时,要注意控制 key 的长度。否则,如果 key 很长的话,就会消耗较多内存空间,而且,SDS 元数据也会额外消耗一定的内存空间。 SDS 结构中的字符串长度和元数据大小的对应关系如下表所示: 为了减少 key 占用的内存空间,我给你一个小建议:对于业务名或业务数据名,可以使用相应的英文单词的首字母表示,(比如 user 用 u 表示,message 用 m),或者是用缩写表示(例如 unique visitor 使用 uv)。 规范二:避免使用 bigkey Redis 是使用单线程读写数据,bigkey 的读写操作会阻塞线程,降低 Redis 的处理效率。所以,在应用 Redis 时,关于 value 的设计规范,非常重要的一点就是避免 bigkey。 bigkey 通常有两种情况。 - 情况一:键值对的值大小本身就很大,例如 value 为 1MB 的 String 类型数据。为了避免 String 类型的 bigkey,在业务层,我们要尽量把 String 类型的数据大小控制在 10KB 以下。 - 情况二:键值对的值是集合类型,集合元素个数非常多,例如包含 100 万个元素的 Hash 集合类型数据。为了避免集合类型的 bigkey,我给你的设计规范建议是,尽量把集合类型的元素个数控制在 1 万以下。 当然,这些建议只是为了尽量避免 bigkey,如果业务层的 String 类型数据确实很大,我们还可以通过数据压缩来减小数据大小;如果集合类型的元素的确很多,我们可以将一个大集合拆分成多个小集合来保存。 这里,还有个地方需要注意下,Redis 的 4 种集合类型 List、Hash、Set 和 Sorted Set,在集合元素个数小于一定的阈值时,会使用内存紧凑型的底层数据结构进行保存,从而节省内存。例如,假设 Hash 集合的 hash-max-ziplist-entries 配置项是 1000,如果 Hash 集合元素个数不超过 1000,就会使用 ziplist 保存数据。 紧凑型数据结构虽然可以节省内存,但是会在一定程度上导致数据的读写性能下降。所以,如果业务应用更加需要保持高性能访问,而不是节省内存的话,在不会导致 bigkey 的前提下,你就不用刻意控制集合元素个数了。 规范三:使用高效序列化方法和压缩方法 为了节省内存,除了采用紧凑型数据结构以外,我们还可以遵循两个使用规范,分别是使用高效的序列化方法和压缩方法,这样可以减少 value 的大小。 Redis 中的字符串都是使用二进制安全的字节数组来保存的,所以,我们可以把业务数据序列化成二进制数据写入到 Redis 中。 但是,不同的序列化方法,在序列化速度和数据序列化后的占用内存空间这两个方面,效果是不一样的。比如说,protostuff 和 kryo 这两种序列化方法,就要比 Java 内置的序列化方法(java-build-in-serializer)效率更高。 此外,业务应用有时会使用字符串形式的 XML 和 JSON 格式保存数据。 这样做的好处是,这两种格式的可读性好,便于调试,不同的开发语言都支持这两种格式的解析。 缺点在于,XML 和 JSON 格式的数据占用的内存空间比较大。为了避免数据占用过大的内存空间,我建议使用压缩工具(例如 snappy 或 gzip),把数据压缩后再写入 Redis,这样就可以节省内存空间了。 规范四:使用整数对象共享池 整数是常用的数据类型,Redis 内部维护了 0 到 9999 这 1 万个整数对象,并把这些整数作为一个共享池使用。 换句话说,如果一个键值对中有 0 到 9999 范围的整数,Redis 就不会为这个键值对专门创建整数对象了,而是会复用共享池中的整数对象。 这样一来,即使大量键值对保存了 0 到 9999 范围内的整数,在 Redis 实例中,其实只保存了一份整数对象,可以节省内存空间。 基于这个特点,我建议你,在满足业务数据需求的前提下,能用整数时就尽量用整数,这样可以节省实例内存。 那什么时候不能用整数对象共享池呢?主要有两种情况。 第一种情况是,如果 Redis 中设置了 maxmemory,而且启用了 LRU 策略(allkeys-lru 或 volatile-lru 策略),那么,整数对象共享池就无法使用了。 这是因为,LRU 策略需要统计每个键值对的使用时间,如果不同的键值对都共享使用一个整数对象,LRU 策略就无法进行统计了。 第二种情况是,如果集合类型数据采用 ziplist 编码,而集合元素是整数,这个时候,也不能使用共享池。因为 ziplist 使用了紧凑型内存结构,判断整数对象的共享情况效率低。 好了,到这里,我们了解了和键值对使用相关的四种规范,遵循这四种规范,最直接的好处就是可以节省内存空间。接下来,我们再来了解下,在实际保存数据时,该遵循哪些规范。 数据保存规范 规范一:使用 Redis 保存热数据 为了提供高性能访问,Redis 是把所有数据保存到内存中的。 虽然 Redis 支持使用 RDB 快照和 AOF 日志持久化保存数据,但是,这两个机制都是用来提供数据可靠性保证的,并不是用来扩充数据容量的。而且,内存成本本身就比较高,如果把业务数据都保存在 Redis 中,会带来较大的内存成本压力。 所以,一般来说,在实际应用 Redis 时,我们会更多地把它作为缓存保存热数据,这样既可以充分利用 Redis 的高性能特性,还可以把宝贵的内存资源用在服务热数据上,就是俗话说的“好钢用在刀刃上”。 规范二:不同的业务数据分实例存储 虽然我们可以使用 key 的前缀把不同业务的数据区分开,但是,如果所有业务的数据量都很大,而且访问特征也不一样,我们把这些数据保存在同一个实例上时,这些数据的操作就会相互干扰。 你可以想象这样一个场景:假如数据采集业务使用 Redis 保存数据时,以写操作为主,而用户统计业务使用 Redis 时,是以读查询为主,如果这两个业务数据混在一起保存,读写操作相互干扰,肯定会导致业务响应变慢。 那么,我建议你把不同的业务数据放到不同的 Redis 实例中。这样一来,既可以避免单实例的内存使用量过大,也可以避免不同业务的操作相互干扰。 规范三:在数据保存时,要设置过期时间 对于 Redis 来说,内存是非常宝贵的资源,而且,Redis 通常用于保存热数据。热数据一般都有使用的时效性。所以,在数据保存时,我建议你根据业务使用数据的时长,设置数据的过期时间。 不然的话,写入 Redis 的数据会一直占用内存,如果数据持续增多,就可能达到机器的内存上限,造成内存溢出,导致服务崩溃。 规范四:控制 Redis 实例的容量 Redis 单实例的内存大小都不要太大,根据我自己的经验值,建议你设置在 2~6GB 。这样一来,无论是 RDB 快照,还是主从集群进行数据同步,都能很快完成,不会阻塞正常请求的处理。 命令使用规范 最后,我们再来看下在使用 Redis 命令时要遵守什么规范。 规范一:线上禁用部分命令 Redis 是单线程处理请求操作,如果我们执行一些涉及大量操作、耗时长的命令,就会严重阻塞主线程,导致其它请求无法得到正常处理,这类命令主要有 3 种。 - KEYS,按照键值对的 key 内容进行匹配,返回符合匹配条件的键值对,该命令需要对 Redis 的全局哈希表进行全表扫描,严重阻塞 Redis 主线程; - FLUSHALL,删除 Redis 实例上的所有数据,如果数据量很大,会严重阻塞 Redis 主线程; - FLUSHDB,删除当前数据库中的数据,如果数据量很大,同样会阻塞 Redis 主线程。 所以,我们在线上应用 Redis 时,就需要禁用这些命令。具体的做法是,管理员用 rename-command 命令在配置文件中对这些命令进行重命名,让客户端无法使用这些命令。 当然,你还可以使用其它命令替代这 3 个命令。 - 对于 KEYS 命令来说,你可以用 SCAN 命令代替 KEYS 命令,分批返回符合条件的键值对,避免造成主线程阻塞; - 对于 FLUSHALL、FLUSHDB 命令来说,你可以加上 ASYNC 选项,让这两个命令使用后台线程异步删除数据,可以避免阻塞主线程。 规范二:慎用 MONITOR 命令 Redis 的 MONITOR 命令在执行后,会持续输出监测到的各个命令操作,所以,我们通常会用 MONITOR 命令返回的结果,检查命令的执行情况。 但是,MONITOR 命令会把监控到的内容持续写入输出缓冲区。如果线上命令的操作很多,输出缓冲区很快就会溢出了,这就会对 Redis 性能造成影响,甚至引起服务崩溃。 所以,除非十分需要监测某些命令的执行(例如,Redis 性能突然变慢,我们想查看下客户端执行了哪些命令),你可以偶尔在短时间内使用下 MONITOR 命令,否则,我建议你不要使用 MONITOR 命令。 规范三:慎用全量操作命令 对于集合类型的数据来说,如果想要获得集合中的所有元素,一般不建议使用全量操作的命令(例如 Hash 类型的 HGETALL、Set 类型的 SMEMBERS)。这些操作会对 Hash 和 Set 类型的底层数据结构进行全量扫描,如果集合类型数据较多的话,就会阻塞 Redis 主线程。 如果想要获得集合类型的全量数据,我给你三个小建议。 - 第一个建议是,你可以使用 SSCAN、HSCAN 命令分批返回集合中的数据,减少对主线程的阻塞。 - 第二个建议是,你可以化整为零,把一个大的 Hash 集合拆分成多个小的 Hash 集合。这个操作对应到业务层,就是对业务数据进行拆分,按照时间、地域、用户 ID 等属性把一个大集合的业务数据拆分成多个小集合数据。例如,当你统计用户的访问情况时,就可以按照天的粒度,把每天的数据作为一个 Hash 集合。 - 最后一个建议是,如果集合类型保存的是业务数据的多个属性,而每次查询时,也需要返回这些属性,那么,你可以使用 String 类型,将这些属性序列化后保存,每次直接返回 String 数据就行,不用再对集合类型做全量扫描了。 小结 这节课,我围绕 Redis 应用时的高性能访问和节省内存空间这两个目标,分别在键值对使用、命令使用和数据保存三方面向你介绍了 11 个规范。 我按照强制、推荐、建议这三个类别,把这些规范分了下类,如下表所示: 我来解释一下这 3 个类别的规范。 - 强制类别的规范:这表示,如果不按照规范内容来执行,就会给 Redis 的应用带来极大的负面影响,例如性能受损。 - 推荐类别的规范:这个规范的内容能有效提升性能、节省内存空间,或者是增加开发和运维的便捷性,你可以直接应用到实践中。 - 建议类别的规范:这类规范内容和实际业务应用相关,我只是从我的经历或经验给你一个建议,你需要结合自己的业务场景参考使用。 我再多说一句,你一定要熟练掌握这些使用规范,并且真正地把它们应用到你的 Redis 使用场景中,提高 Redis 的使用效率。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | 我总结的 Redis 使用规范分为两大方面,主要包括业务层面和运维层面。 业务层面主要面向的业务开发人员: 1、key 的长度尽量短,节省内存空间 2、避免 bigkey,防止阻塞主线程 3、4.0+版本建议开启 lazy-free 4、把 Redis 当作缓存使用,设置过期时间 5、不使用复杂度过高的命令,例如SORT、SINTER、SINTERSTORE、ZUNIONSTORE、ZINTERSTORE 6、查询数据尽量不一次性查询全量,写入大量数据建议分多批写入 7、批量操作建议 MGET/MSET 替代 GET/SET,HMGET/HMSET 替代 HGET/HSET 8、禁止使用 KEYS/FLUSHALL/FLUSHDB 命令 9、避免集中过期 key 10、根据业务场景选择合适的淘汰策略 11、使用连接池操作 Redis,并设置合理的参数,避免短连接 12、只使用 db0,减少 SELECT 命令的消耗 13、读请求量很大时,建议读写分离,写请求量很大,建议使用切片集群 运维层面主要面向的是 DBA 运维人员: 1、按业务线部署实例,避免多个业务线混合部署,出问题影响其他业务 2、保证机器有足够的 CPU、内存、带宽、磁盘资源 3、建议部署主从集群,并分布在不同机器上,slave 设置为 readonly 4、主从节点所部署的机器各自独立,尽量避免交叉部署,对从节点做维护时,不会影响到主节点 5、推荐部署哨兵集群实现故障自动切换,哨兵节点分布在不同机器上 6、提前做好容量规划,防止主从全量同步时,实例使用内存突增导致内存不足 7、做好机器 CPU、内存、带宽、磁盘监控,资源不足时及时报警,任意资源不足都会影响 Redis 性能 8、实例设置最大连接数,防止过多客户端连接导致实例负载过高,影响性能 9、单个实例内存建议控制在 10G 以下,大实例在主从全量同步、备份时有阻塞风险 10、设置合理的 slowlog 阈值,并对其进行监控,slowlog 过多需及时报警 11、设置合理的 repl-backlog,降低主从全量同步的概率 12、设置合理的 slave client-output-buffer-limit,避免主从复制中断情况发生 13、推荐在从节点上备份,不影响主节点性能 14、不开启 AOF 或开启 AOF 配置为每秒刷盘,避免磁盘 IO 拖慢 Redis 性能 15、调整 maxmemory 时,注意主从节点的调整顺序,顺序错误会导致主从数据不一致 16、对实例部署监控,采集 INFO 信息时采用长连接,避免频繁的短连接 17、做好实例运行时监控,重点关注 expired_keys、evicted_keys、latest_fork_usec,这些指标短时突增可能会有阻塞风险 18、扫描线上实例时,记得设置休眠时间,避免过高 OPS 产生性能抖动 | ### Elasticsearch文件存储 - URL: https://jiankunking.com/elasticsearch-file-storage.html - Content type: original - Published: 2023-06-03 - Updated: 2023-06-03 - Summary: 分析Elasticsearch Index文件是如何存储的? 主要是想看一下FST文件是以什么粒度创建的? - Categories: Elasticsearch - Tags: Elasticsearch, Storage, Lucene Article text: 文章速览 分析Elasticsearch Index文件是如何存储的? 主要是想看一下FST文件是以什么粒度创建的? 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 分析Elasticsearch Index文件是如何存储的? 主要是想看一下FST文件是以什么粒度创建的? 首先通过kibana找一个索引的shard,此处咱们就以logstash-2023.05.30索引为例 查看下shard分布情况 1 2 3 4 5 6 7 8 9 10 11 | GET /_cat/shards/logstash-2023.05.30?v index shard prirep state docs store ip node logstash-2023.05.30 3 p STARTED 1520736 408.1mb 10.138.40.73 10.138.40.73-node1 logstash-2023.05.30 5 p STARTED 1520888 409.9mb 10.138.40.74 10.138.40.74-node1 logstash-2023.05.30 6 p STARTED 1518331 408.2mb 10.138.40.221 10.138.40.221-node1 logstash-2023.05.30 4 p STARTED 1518186 409.3mb 10.138.204.194 10.138.204.194-node1 logstash-2023.05.30 1 p STARTED 1519231 408.8mb 10.138.40.220 10.138.40.220-node1 logstash-2023.05.30 2 p STARTED 1519970 409.9mb 10.138.204.195 10.138.204.195-node1 logstash-2023.05.30 0 p STARTED 1520024 410.6mb 10.138.204.193 10.138.204.193-node1 | 这里以位于10.138.204.193上的shard 0为例分析。 要找到存储目录先要找到index的id 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | GET /logstash-2023.05.30/_settings { "logstash-2023.05.30" : { "settings" : { "index" : { "codec" : "best_compression", "routing" : { "allocation" : { "include" : { "_tier_preference" : "data_content" } } }, "refresh_interval" : "60s", "number_of_shards" : "7", "provided_name" : "logstash-2023.05.30", "creation_date" : "1685376005206", "number_of_replicas" : "0", "uuid" : "FYWtFGTIS2CLB8yJhFXG9g",//这里就是索引的id "version" : { "created" : "7130499" } } } } } | 登录机器,找到存储索引文件的对应目录 1 | /data3/10.138.204.193-node1/nodes/0/indices/FYWtFGTIS2CLB8yJhFXG9g | 展开一下该目录下的文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | root@prd-paas-es-01:/data3/10.138.204.193-node1/nodes/0/indices/FYWtFGTIS2CLB8yJhFXG9g# tree -C -s . ├── [ 4096] 0 │ ├── [ 20480] index │ │ ├── [ 158] _17f.fdm │ │ ├── [ 25578562] _17f.fdt │ │ ├── [ 1939] _17f.fdx │ │ ├── [ 4636] _17f.fnm │ │ ├── [ 7981735] _17f.kdd │ │ ├── [ 20898] _17f.kdi │ │ ├── [ 716] _17f.kdm │ │ ├── [ 7945983] _17f_Lucene80_0.dvd │ │ ├── [ 3916] _17f_Lucene80_0.dvm │ │ ├── [ 6230127] _17f_Lucene84_0.doc │ │ ├── [ 3875001] _17f_Lucene84_0.pos │ │ ├── [ 7448815] _17f_Lucene84_0.tim │ │ ├── [ 108786] _17f_Lucene84_0.tip │ │ ├── [ 1637] _17f_Lucene84_0.tmd │ │ ├── [ 593] _17f.si │ │ ├── [ 158] _3uv.fdm │ │ ├── [ 33652243] _3uv.fdt │ │ ├── [ 2555] _3uv.fdx │ │ ├── [ 4636] _3uv.fnm │ │ ├── [ 10520395] _3uv.kdd │ │ ├── [ 27689] _3uv.kdi │ │ ├── [ 716] _3uv.kdm │ │ ├── [ 10573208] _3uv_Lucene80_0.dvd │ │ ├── [ 3916] _3uv_Lucene80_0.dvm │ │ ├── [ 8298061] _3uv_Lucene84_0.doc │ │ ├── [ 5154427] _3uv_Lucene84_0.pos │ │ ├── [ 9716222] _3uv_Lucene84_0.tim │ │ ├── [ 142063] _3uv_Lucene84_0.tip │ │ ├── [ 1620] _3uv_Lucene84_0.tmd │ │ ├── [ 593] _3uv.si │ │ ├── [ 158] _5bg.fdm │ │ ├── [ 16433011] _5bg.fdt │ │ ├── [ 1259] _5bg.fdx │ │ ├── [ 4636] _5bg.fnm │ │ ├── [ 5158094] _5bg.kdd │ │ ├── [ 13396] _5bg.kdi │ │ ├── [ 716] _5bg.kdm │ │ ├── [ 5140762] _5bg_Lucene80_0.dvd │ │ ├── [ 3916] _5bg_Lucene80_0.dvm │ │ ├── [ 4005897] _5bg_Lucene84_0.doc │ │ ├── [ 2583880] _5bg_Lucene84_0.pos │ │ ├── [ 4873082] _5bg_Lucene84_0.tim │ │ ├── [ 70979] _5bg_Lucene84_0.tip │ │ ├── [ 1593] _5bg_Lucene84_0.tmd │ │ ├── [ 593] _5bg.si │ │ ├── [ 158] _60h.fdm │ │ ├── [ 24664753] _60h.fdt │ │ ├── [ 1886] _60h.fdx │ │ ├── [ 4636] _60h.fnm │ │ ├── [ 7640438] _60h.kdd │ │ ├── [ 19996] _60h.kdi │ │ ├── [ 716] _60h.kdm │ │ ├── [ 7754954] _60h_Lucene80_0.dvd │ │ ├── [ 3916] _60h_Lucene80_0.dvm │ │ ├── [ 6147241] _60h_Lucene84_0.doc │ │ ├── [ 3998559] _60h_Lucene84_0.pos │ │ ├── [ 7254035] _60h_Lucene84_0.tim │ │ ├── [ 105673] _60h_Lucene84_0.tip │ │ ├── [ 1719] _60h_Lucene84_0.tmd │ │ ├── [ 593] _60h.si │ │ ├── [ 200] _7jq.fdm │ │ ├── [ 63208093] _7jq.fdt │ │ ├── [ 4692] _7jq.fdx │ │ ├── [ 4636] _7jq.fnm │ │ ├── [ 19306117] _7jq.kdd │ │ ├── [ 51562] _7jq.kdi │ │ ├── [ 716] _7jq.kdm │ │ ├── [ 20228561] _7jq_Lucene80_0.dvd │ │ ├── [ 3916] _7jq_Lucene80_0.dvm │ │ ├── [ 15606568] _7jq_Lucene84_0.doc │ │ ├── [ 9581341] _7jq_Lucene84_0.pos │ │ ├── [ 17383473] _7jq_Lucene84_0.tim │ │ ├── [ 272615] _7jq_Lucene84_0.tip │ │ ├── [ 1592] _7jq_Lucene84_0.tmd │ │ ├── [ 593] _7jq.si │ │ ├── [ 437] _82w.cfe │ │ ├── [ 4489379] _82w.cfs │ │ ├── [ 408] _82w.si │ │ ├── [ 437] _87w.cfe │ │ ├── [ 4932636] _87w.cfs │ │ ├── [ 408] _87w.si │ │ ├── [ 437] _8ao.cfe │ │ ├── [ 13905317] _8ao.cfs │ │ ├── [ 408] _8ao.si │ │ ├── [ 437] _8ls.cfe │ │ ├── [ 20181047] _8ls.cfs │ │ ├── [ 408] _8ls.si │ │ ├── [ 437] _8nq.cfe │ │ ├── [ 1234712] _8nq.cfs │ │ ├── [ 408] _8nq.si │ │ ├── [ 437] _8oa.cfe │ │ ├── [ 872798] _8oa.cfs │ │ ├── [ 408] _8oa.si │ │ ├── [ 437] _8pp.cfe │ │ ├── [ 1593677] _8pp.cfs │ │ ├── [ 408] _8pp.si │ │ ├── [ 437] _8r5.cfe │ │ ├── [ 914008] _8r5.cfs │ │ ├── [ 408] _8r5.si │ │ ├── [ 437] _8rf.cfe │ │ ├── [ 940473] _8rf.cfs │ │ ├── [ 408] _8rf.si │ │ ├── [ 437] _8rz.cfe │ │ ├── [ 1315312] _8rz.cfs │ │ ├── [ 408] _8rz.si │ │ ├── [ 437] _8s9.cfe │ │ ├── [ 1121692] _8s9.cfs │ │ ├── [ 408] _8s9.si │ │ ├── [ 437] _8sk.cfe │ │ ├── [ 243476] _8sk.cfs │ │ ├── [ 408] _8sk.si │ │ ├── [ 1678] segments_6 │ │ └── [ 0] write.lock │ ├── [ 4096] _state │ │ ├── [ 186] retention-leases-2865.st │ │ └── [ 125] state-0.st │ └── [ 4096] translog │ ├── [ 55] translog-29.tlog │ └── [ 88] translog.ckp └── [ 4096] _state └── [ 1230] state-2.st 5 directories, 118 files | 有了文件信息,我们再来看下,segment信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | GET /logstash-2023.05.30/_segments // 这里为了直观 只展示shard 0对应的segment { "_shards": { "total": 7, "successful": 7, "failed": 0 }, "indices": { "logstash-2023.05.30": { "shards": { "0": [ { "routing": { "state": "STARTED", "primary": true, "node": "4hEWcF8hRFWTEkQxlKQmqg" }, "num_committed_segments": 17, "num_search_segments": 17, "segments": { "_17f": { "generation": 1563, "num_docs": 210331, "deleted_docs": 0, "size_in_bytes": 59203502, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": false, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_3uv": { "generation": 4999, "num_docs": 278411, "deleted_docs": 0, "size_in_bytes": 78098502, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": false, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_5bg": { "generation": 6892, "num_docs": 132645, "deleted_docs": 0, "size_in_bytes": 38291972, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": false, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_60h": { "generation": 7793, "num_docs": 199809, "deleted_docs": 0, "size_in_bytes": 57599273, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": false, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_7jq": { "generation": 9782, "num_docs": 520420, "deleted_docs": 0, "size_in_bytes": 145654675, "memory_in_bytes": 5204, "committed": true, "search": true, "version": "8.8.2", "compound": false, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_82w": { "generation": 10472, "num_docs": 15416, "deleted_docs": 0, "size_in_bytes": 4490224, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_87w": { "generation": 10652, "num_docs": 16837, "deleted_docs": 0, "size_in_bytes": 4933481, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8ao": { "generation": 10752, "num_docs": 48855, "deleted_docs": 0, "size_in_bytes": 13906162, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8ls": { "generation": 11152, "num_docs": 70903, "deleted_docs": 0, "size_in_bytes": 20181892, "memory_in_bytes": 5140, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8nq": { "generation": 11222, "num_docs": 3954, "deleted_docs": 0, "size_in_bytes": 1235557, "memory_in_bytes": 6924, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8oa": { "generation": 11242, "num_docs": 2785, "deleted_docs": 0, "size_in_bytes": 873643, "memory_in_bytes": 6820, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8pp": { "generation": 11293, "num_docs": 5194, "deleted_docs": 0, "size_in_bytes": 1594522, "memory_in_bytes": 7060, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8r5": { "generation": 11345, "num_docs": 2936, "deleted_docs": 0, "size_in_bytes": 914853, "memory_in_bytes": 6748, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8rf": { "generation": 11355, "num_docs": 2920, "deleted_docs": 0, "size_in_bytes": 941318, "memory_in_bytes": 6836, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8rz": { "generation": 11375, "num_docs": 4304, "deleted_docs": 0, "size_in_bytes": 1316157, "memory_in_bytes": 6820, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87StoredFieldsFormat.mode": "BEST_COMPRESSION" } }, "_8s9": { "generation": 11385, "num_docs": 3647, "deleted_docs": 0, "size_in_bytes": 1122537, "memory_in_bytes": 6892, "committed": true, "search": true, "version": "8.8.2", "compound": true, "attributes": { "Lucene87Stored… ### [转]腾讯万亿级 Elasticsearch 内存效率提升解密 - URL: https://jiankunking.com/tencent-trillion-level-elasticsearch-memory-efficiency-improves-decryption.html - Content type: repost - Published: 2023-06-03 - Updated: 2023-06-03 - Summary: 本文介绍腾讯ES团队如何通过FST OffHeap优化,将堆内存使用率降低80%,单节点存储量从5TB提升至50TB。重点分析FST内存占用问题及零拷贝内存OffHeap的实现方案。 - Categories: Elasticsearch - Tags: Performance, Java, Elasticsearch, 源码 - Original source: https://cloud.tencent.com/developer/article/1636527 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 分布式技术原理与算法解析 笔记 - URL: https://jiankunking.com/principles-and-algorithm-analysis-of-distributed-technology.html - Content type: original - Published: 2023-05-20 - Updated: 2023-05-20 - Summary: 分布式技术原理与算法课程笔记,系统整理选举、共识、分布式事务、分布式锁和数据一致性等核心概念、典型算法与工程实现思路。 - Categories: Distributed - Tags: Distributed, 算法, 笔记 Article text: 文章速览 分布式技术原理与算法课程笔记,系统整理选举、共识、分布式事务、分布式锁和数据一致性等核心概念、典型算法与工程实现思路。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Distributed 分布式技术原理与算法解析课程笔记,涵盖分布式选举、共识、事务、锁等核心概念和算法实现。 04 | 分布式选举:国不可一日无君 长者为大:Bully 算法 Bully 算法是一种霸道的集群选主算法,为什么说是霸道呢?因为它的选举原则是“长者”为大,即在所有活着的节点中,选取 ID 最大的节点作为主节点。 在 Bully 算法中,节点的角色有两种:普通节点和主节点。初始化时,所有节点都是平等的,都是普通节点,并且都有成为主的权利。但是,当选主成功后,有且仅有一个节点成为主节点,其他所有节点都是普通节点。当且仅当主节点故障或与其他节点失去联系后,才会重新选主。 Bully 算法在选举过程中,需要用到以下 3 种消息: - Election 消息,用于发起选举; - Alive 消息,对 Election 消息的应答; - Victory 消息,竞选成功的主节点向其他节点发送的宣誓主权的消息。 Bully 算法选举的原则是“长者为大”,意味着它的假设条件是,集群中每个节点均知道其他节点的 ID。在此前提下,其具体的选举过程是: - 集群中每个节点判断自己的 ID 是否为当前活着的节点中 ID 最大的,如果是,则直接向其他节点发送 Victory 消息,宣誓自己的主权; - 如果自己不是当前活着的节点中 ID 最大的,则向比自己 ID 大的所有节点发送Election 消息,并等待其他节点的回复; - 若在给定的时间范围内,本节点没有收到其他节点回复的 Alive 消息,则认为自己成为主节点,并向其他节点发送 Victory 消息,宣誓自己成为主节点;若接收到来自比自己ID 大的节点的 Alive 消息,则等待其他节点发送 Victory 消息; - 若本节点收到比自己 ID 小的节点发送的 Election 消息,则回复一个 Alive 消息,告知其他节点,我比你大,重新选举。 目前已经有很多开源软件采用了 Bully 算法进行选主,比如 MongoDB 的副本集故障转移功能。MongoDB 的分布式选举中,采用节点的最后操作时间戳来表示 ID,时间戳最新的 节点其 ID 最大,也就是说时间戳最新的、活着的节点是主节点。 小结一下。Bully 算法的选择特别霸道和简单,谁活着且谁的 ID 最大谁就是主节点,其他节点必须无条件服从。这种算法的优点是,选举速度快、算法复杂度低、简单易实现。 但这种算法的缺点在于,需要每个节点有全局的节点信息,因此额外信息存储较多;其次,任意一个比当前主节点 ID 大的新节点或节点故障后恢复加入集群的时候,都可能会触发重新选举,成为新的主节点,如果该节点频繁退出、加入集群,就会导致频繁切主。 民主投票:Raft 算法 采用 Raft 算法选举,集群节点的角色有 3 种: - Leader,即主节点,同一时刻只有一个 Leader,负责协调和管理其他节点; - Candidate,即候选者,每一个节点都可以成为 Candidate,节点在该角色下才可以被选为新的 Leader; - Follower,Leader 的跟随者,不可以发起选举。 Raft 选举的流程,可以分为以下几步: - 初始化时,所有节点均为 Follower 状态。 - 开始选主时,所有节点的状态由 Follower 转化为 Candidate,并向其他节点发送选举请求。 - 其他节点根据接收到的选举请求的先后顺序,回复是否同意成为主。这里需要注意的是,在每一轮选举中,一个节点只能投出一张票。 - 若发起选举请求的节点获得超过一半的投票,则成为主节点,其状态转化为 Leader,其他节点的状态则由 Candidate 降为 Follower。Leader 节点与 Follower 节点之间会定期发送心跳包,以检测主节点是否活着。 - 当 Leader 节点的任期到了,即发现其他服务器开始下一轮选主周期时,Leader 节点的状态由 Leader 降级为 Follower,进入新一轮选主。 节点的状态迁移如下所示(图中的 term 指的是选举周期): 请注意,每一轮选举,每个节点只能投一次票。这种选举就类似人大代表选举,正常情况下每个人大代表都有一定的任期,任期到后会触发重新选举,且投票者只能将自己手里唯一的票投给其中一个候选者。对应到 Raft 算法中,选主是周期进行的,包括选主和任值两个时间段,选主阶段对应投票阶段,任值阶段对应节点成为主之后的任期。但也有例外的时候,如果主节点故障,会立马发起选举,重新选出一个主节点。 小结一下。Raft 算法具有选举速度快、算法复杂度低、易于实现的优点;缺点是,它要求系统内每个节点都可以相互通信,且需要获得过半的投票数才能选主成功,因此通信量大。该算法选举稳定性比 Bully 算法好,这是因为当有新节点加入或节点故障恢复后,会触发选主,但不一定会真正切主,除非新节点或故障后恢复的节点获得投票数过半,才会导致切主。 具有优先级的民主投票:ZAB 算法 ZAB(ZooKeeper Atomic Broadcast)选举算法是为 ZooKeeper 实现分布式协调功能而设计的。相较于 Raft 算法的投票机制,ZAB 算法增加了通过节点 ID 和数据 ID 作为参考进行选主,节点 ID 和数据 ID 越大,表示数据越新,优先成为主。相比较于 Raft 算法,ZAB 算法尽可能保证数据的最新性。所以,ZAB 算法可以说是对 Raft 算法的改进。 使用 ZAB 算法选举时,集群中每个节点拥有 3 种角色: - Leader,主节点; - Follower,跟随者节点; - Observer,观察者,无投票权。 选举过程中,集群中的节点拥有 4 个状态: - Looking 状态,即选举状态。当节点处于该状态时,它会认为当前集群中没有 Leader,因此自己进入选举状态。 - Leading 状态,即领导者状态,表示已经选出主,且当前节点为 Leader。 - Following 状态,即跟随者状态,集群中已经选出主后,其他非主节点状态更新为Following,表示对 Leader 的追随。 - Observing 状态,即观察者状态,表示当前节点为 Observer,持观望态度,没有投票权和选举权。 投票过程中,每个节点都有一个唯一的三元组 (server_id, server_zxID, epoch),其中server_id 表示本节点的唯一 ID;server_zxID 表示本节点存放的数据 ID,数据 ID 越大表 示数据越新,选举权重越大;epoch 表示当前选取轮数,一般用逻辑时钟表示。 ZAB 选举算法的核心是“少数服从多数,ID 大的节点优先成为主”,因此选举过程中通过(vote_id, vote_zxID) 来表明投票给哪个节点,其中 vote_id 表示被投票节点的 ID,vote_zxID 表示被投票节点的服务器 zxID。ZAB 算法选主的原则是:server_zxID 最大者成为 Leader;若 server_zxID 相同,则 server_id 最大者成为 Leader。 接下来,我以 3 个 Server 的集群为例,此处每个 Server 代表一个节点,与你介绍 ZAB 选主的过程。 第一步:当系统刚启动时,3 个服务器当前投票均为第一轮投票,即 epoch=1,且 zxID 均为 0。此时每个服务器都推选自己,并将选票信息 广播出去。 第二步:根据判断规则,由于 3 个 Server 的 epoch、zxID 都相同,因此比较 server_id,较大者即为推选对象,因此 Server 1 和 Server 2 将 vote_id 改为 3,更新自己的投票箱并重新广播自己的投票。 第三步:此时系统内所有服务器都推选了 Server 3,因此 Server 3 当选 Leader,处于Leading 状态,向其他服务器发送心跳包并维护连接;Server1 和 Server2 处于 Following 状态。 小结一下。ZAB 算法性能高,对系统无特殊要求,采用广播方式发送信息,若节点中有 n 个节点,每个节点同时广播,则集群中信息量为 n*(n-1) 个消息,容易出现广播风暴;且除了投票,还增加了对比节点 ID 和数据 ID,这就意味着还需要知道所有节点的 ID 和数据ID,所以选举时间相对较长。但该算法选举稳定性比较好,当有新节点加入或节点故障恢复后,会触发选主,但不一定会真正切主,除非新节点或故障后恢复的节点数据 ID 和节点ID 最大,且获得投票数过半,才会导致切主。 三种选举算法的对比分析 05 | 分布式共识:存异求同 分布式共识就是在多个节点均可独自操作或记录的情况下,使得所有节点针对某个状态达成一致的过程。 分布式共识:存异求同 06 | 分布式事务:All or nothing 07 | 分布式锁:关键重地,非请勿入 特别放送 那些你不能错过的分布式系统论文 ### MySQL 必知必会 笔记 - URL: https://jiankunking.com/mysql-must-know-and-must-know.html - Content type: original - Published: 2023-05-20 - Updated: 2023-05-20 - Summary: 《MySQL必知必会》学习笔记,整理字段类型、浮点数与定点数、索引设计、查询优化及常见数据库使用误区,便于系统复习核心知识。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 《MySQL必知必会》学习笔记,整理字段类型、浮点数与定点数、索引设计、查询优化及常见数据库使用误区,便于系统复习核心知识。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 《MySQL必知必会》学习笔记,涵盖字段类型定义、浮点数与定点数、索引优化等核心知识点。 MySQL 必知必会 作者: 朱晓峰 02 | 字段:这么多字段类型,该怎么定义? 浮点数类型和定点数类型 MySQL 用 4 个字节存储 FLOAT 类型数据,用 8 个字节来存储 DOUBLE 类型数据。无论哪个,都是采用二进制的方式来进行存储的。比如 9.625,用二进制来表达,就是1001.101,或者表达成 1.001101×2^3。看到了吗?如果尾数不是 0 或 5(比如9.624),你就无法用一个二进制数来精确表达。怎么办呢?就只好在取值允许的范围内进行近似(四舍五入)。 现在你一定明白了,为什么数据类型是 DOUBLE 的时候,我们得到的结果误差更小一些,而数据类型是 FLOAT 的时候,误差会更大一下。原因就是,DOUBLE 有 8 位字节,精度更高。 说到这里,我想你已经彻底理解了浮点数据类型不精准的原因了。 那么,MySQL 有没有精准的数据类型呢?当然有,这就是定点数类型:DECIMAL。 就像浮点数类型的存储方式,决定了它不可能精准一样,DECIMAL 的存储方式决定了它一定是精准的。 浮点数类型是把十进制数转换成二进制数存储,DECIMAL 则不同,它是把十进制数的整数部分和小数部分拆开,分别转换成十六进制数,进行存储。这样,所有的数值,就都可以精准表达了,不会存在因为无法表达而损失精度的问题。 MySQL 用 DECIMAL(M,D)的方式表示高精度小数。其中,M 表示整数部分加小数部分,一共有多少位,M<=65。D 表示小数部分位数,D IK Analyzer 扩展配置 custom_user_name.dic | 验证 模版 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | POST /_template/jiankunking-attr { "order": 0, "index_patterns": [ "jiankunking-attrs", "jiankunking-attrs-dev" ], "settings": { "index": { "number_of_shards": "6", "number_of_replicas": "1", "refresh_interval": "200ms" } }, "mappings": { "dynamic_templates": [ { "strings": { "mapping": { "type": "keyword" }, "match_mapping_type": "string" } } ], "properties": { "id": { "type": "keyword" }, "attrs.user_name_ik.attrValue": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "creator": { "type": "keyword" }, "updater": { "type": "keyword" }, "createdAt": { "format": "epoch_second", "type": "date" }, "updatedAt": { "format": "epoch_second", "type": "date" } } } } | 查询验证过程略 结论 1、需要自定义词库 使用IK插件但不自定义词库的话,无法正确的分词姓名; 使用词库的话,需要穷举可能得搜索项, 假如,词典中只加载姓名,那么对于搜索后半段,比如:孙新伟,搜索:新伟,会搜索不到,这时候搜索结果就会不符合预期。 2、对于英文姓名分词有一定限制 比如搜索:dam,Adam Dean不会被检索到 3、需要安装IK插件,需要重启集群 Ngram方案 验证 模版 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | POST /_template/jiankunking-attr-ngram { "order": 0, "index_patterns": [ "jiankunking-attrs-v3-*" ], "settings": { "index": { "max_ngram_diff": "9", "refresh_interval": "200ms", "analysis": { "analyzer": { "ngram_analyzer": { "tokenizer": "ngram" } }, "tokenizer": { "ngram": { "token_chars": [ "letter", "digit" ], "min_gram": "1", "type": "ngram", "max_gram": "10" } } }, "number_of_shards": "10", "number_of_replicas": "1" } }, "mappings": { "dynamic_templates": [ { "strings": { "mapping": { "type": "keyword" }, "match_mapping_type": "string" } } ], "properties": { "attrs.pinyin.attrValue": { "search_analyzer": "ngram_analyzer", "analyzer": "ngram_analyzer", "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "createdAt": { "format": "epoch_second", "type": "date" }, "creator": { "type": "keyword" }, "attrs.nickname.attrValue": { "search_analyzer": "ngram_analyzer", "analyzer": "ngram_analyzer", "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "attrs.username.attrValue": { "search_analyzer": "ngram_analyzer", "analyzer": "ngram_analyzer", "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "attrs.user_id.attrValue": { "type": "keyword", "fields": { "text": { "search_analyzer": "ngram_analyzer", "analyzer": "ngram_analyzer", "type": "text" } } }, "id": { "type": "keyword" }, "attrs": { "type": "object" }, "updater": { "type": "keyword" }, "updatedAt": { "format": "epoch_second", "type": "date" } } }, "aliases": {} } | 查询验证过程略 结论 搜索返回的数据中,会有相似数据 比如搜索:”土豆儿”,会匹配到:”王豆豆”、”田豆豆”等 Ngram分词会消耗大量资源(尤其是磁盘),reindex有可能会超时 结论 由于用户数据量不到100万,所以即使资源效果的多一些,也在可以接受的范围内,所以最终采取了Ngram方案 最优的查询语句如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | { "size": 10, "_source": [ "attrs.username.attrValue", "attrs.pinyin.attrValue", "attrs.user_id.attrValue", "attrs.nickname.attrValue" ], "query": { "multi_match": { "query": "jiankunking", "type": "best_fields", "fields": [ "attrs.user_id.attrValue.text", "attrs.username.attrValue", "attrs.nickname.attrValue", "attrs.pinyin.attrValue" ] } } } | ### 构建高效业务网关:基于Gin与ReverseProxy的实现方案 - URL: https://jiankunking.com/building-an-efficient-business-gateway-implementation-solutions-based-on-gin-and-reverseproxy.html - Content type: original - Published: 2023-05-12 - Updated: 2023-05-12 - Summary: 基于Golang、Gin和ReverseProxy构建高效业务网关,实现路由热加载、反向代理、权限控制等功能。 - Categories: Architecture - Tags: Gateway, Gin, Go, ReverseProxy Article text: 文章速览 基于Golang、Gin和ReverseProxy构建高效业务网关,实现路由热加载、反向代理、权限控制等功能。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 基于Golang、Gin和ReverseProxy构建高效业务网关,实现路由热加载、反向代理、权限控制等功能。 动手开发自己的API网关 基于: - Golang - Gin - ReverseProxy Gin 路由热加载 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | type ProxyServer struct { App *gin.Engine upstreamProxy map[string]*httputil.ReverseProxy reverseProxyGroup singleflight.Group bindingHttpServer *http.Server apiConfig *model.ApiConfigV1 } func NewProxyServer(conf *model.ApiConfigV1) *ProxyServer { obj := &ProxyServer{ App: gin.New(), apiConfig: conf, upstreamProxy: make(map[string]*httputil.ReverseProxy), } obj.initApp() obj.initRoute() return obj } ps := NewProxyServer(conf) ps.bindingHttpServer = obj.GetBindingHttpServer() ps.bindingHttpServer.Handler = ps.App obj.proxyServer = ps // https://jiankunking.com/golang-copy-on-write-and-atomic.html | ### [译]自上而下认识Elasticsearch - URL: https://jiankunking.com/elasticsearch-from-the-top-down.html - Content type: translation - Published: 2023-03-19 - Updated: 2023-03-19 - Summary: 从集群、节点、请求协调、索引路由、主分片和副本等上层视角,系统介绍 Elasticsearch 的工作方式。 - Categories: Elasticsearch - Tags: Elasticsearch, Lucene, Architecture - Original source: https://www.elastic.co/cn/blog/found-elasticsearch-top-down The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [译]自下而上认识Elasticsearch - URL: https://jiankunking.com/elasticsearch-from-the-bottom-up.html - Content type: translation - Published: 2023-03-19 - Updated: 2023-03-19 - Summary: 从倒排索引、索引词、Segment 和事务等底层概念出发,逐步解释 Elasticsearch 的索引与数据组织机制。 - Categories: Elasticsearch - Tags: Elasticsearch, Lucene, Architecture - Original source: https://www.elastic.co/cn/blog/found-elasticsearch-from-the-bottom-up The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Gin获取Response Body引发的OOM - URL: https://jiankunking.com/golang-gin-get-response-body-oom.html - Content type: original - Published: 2023-02-24 - Updated: 2023-02-24 - Summary: 复盘 Gin 中读取和缓存 Response Body 导致的 OOM,分析内存增长原因并给出更安全的实现方式。 - Categories: Go - Tags: Gin, Go, OOM, Response, Body Article text: 文章速览 复盘 Gin 中读取和缓存 Response Body 导致的 OOM,分析内存增长原因并给出更安全的实现方式。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 有轮子尽量用轮子 :sob: :sob: :sob: :sob: :sob: :sob: 我们在开发中基于Gin开发了一个Api网关,但上线后发现内存会在短时间内暴涨,然后被OOM kill掉。具体内存走势如下图: 放大其中一次 在图二中可以看到内存的增长是很快的,在一分半的时间内,内存增长了近2G。 对于这种内存短时间暴涨的问题,pprof不太分析,除非写个脚本定时去pprof 经过再次review代码,找到了原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | package server import ( "bytes" "fmt" "github.com/gin-gonic/gin" jsoniter "github.com/json-iterator/go" ) var json = jsoniter.ConfigCompatibleWithStandardLibrary type BodyDumpResponseWriter struct { gin.ResponseWriter body *bytes.Buffer } func (w *BodyDumpResponseWriter) Write(b []byte) (int, error) { w.body.Write(b) // 注意这一行 return w.ResponseWriter.Write(b) } func ReadResponseBody(ctx *gin.Context) { rbw := &BodyDumpResponseWriter{body: &bytes.Buffer{}, ResponseWriter: ctx.Writer} ctx.Writer = rbw ctx.Next() rawResp := rbw.body.String() if len(rawResp) == 0 { AbnormalPrint(ctx, "resp-empty", rawResp) return } ctx.Set(ctx_raw_response_body, rawResp) // 序列化Body,并放到ctx中 // 读取响应Body的目的是记录审计日志用 } // AbnormalPrint 异常情况,打印信息到日志 func AbnormalPrint(ctx *gin.Context, typ string, rawResp string) { // 具体代码忽略 } | 简单一看,这不就是Gin获取响应体一种标准的方式吗?毕竟GitHub及Stack Overflow上都是这么写的 https://github.com/gin-gonic/gin/issues/1363 https://stackoverflow.com/questions/38501325/how-to-log-response-body-in-gin 那么问题出在哪呢? 再看下代码,可以看到这个代码的逻辑是每一个请求都会将响应的Body完整的缓存在内存一份,对于响应体很大的请求,在这里就会造成内存暴涨,比如:像日志下载。 找到了原因修改起来就比较简单了,根据请求响应的Header跳过文件下载类的请求;同时根据请求的Header跳过SSE及Websocket请求,因为这两类流的请求记录到审计日志中意义不大,而且在json序列化的时候也会有问题。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | package server import ( "bytes" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" jsoniter "github.com/json-iterator/go" ) var json = jsoniter.ConfigCompatibleWithStandardLibrary type BodyDumpResponseWriter struct { gin.ResponseWriter body *bytes.Buffer } func (w *BodyDumpResponseWriter) Write(b []byte) (int, error) { // 文件下载类请求,不再缓存相应结果 if !isFileDownLoad(w.Header()) { w.body.Write(b) } return w.ResponseWriter.Write(b) } func isNoNeedToReadResponse(req *http.Request) bool { if isSSE(req) || isWebsocket(req) { return true } return false } func isSSE(req *http.Request) bool { contentType := req.Header.Get("Accept") if contentType == "" { contentType = req.Header.Get("accept") } contentType = strings.ToLower(contentType) // sse if !strings.Contains(contentType, "text/event-stream") { return false } return true } // https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Basics_of_HTTP/MIME_types // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition func isFileDownLoad(responseHeader http.Header) bool { contentType := strings.ToLower(responseHeader.Get("Content-Type")) if strings.Contains(contentType, "application/octet-stream") { return true } contentDisposition := responseHeader.Get("Content-Disposition") if contentDisposition != "" { return true } return false } func isWebsocket(req *http.Request) bool { conntype := strings.ToLower(req.Header.Get("Connection")) upgrade := strings.ToLower(req.Header.Get("Upgrade")) if conntype == "upgrade" && upgrade == "websocket" { return true } return false } func ReadResponseBody(ctx *gin.Context) { if isNoNeedToReadResponse(ctx.Request) { return } rbw := &BodyDumpResponseWriter{body: &bytes.Buffer{}, ResponseWriter: ctx.Writer} ctx.Writer = rbw ctx.Next() contentType := ctx.Writer.Header().Get("content-type") if !strings.Contains(contentType, "application/json") { return } rawResp := rbw.body.String() if len(rawResp) == 0 { AbnormalPrint(ctx, "resp-empty", rawResp) return } ctx.Set(ctx_raw_response_body, rawResp) // 序列化Body,并放到ctx中 // 读取响应Body的目的是记录审计日志用 } // AbnormalPrint 异常情况,打印信息到日志 func AbnormalPrint(ctx *gin.Context, typ string, rawResp string) { // 具体代码忽略 } | 其实,写这篇文章的目的并不是为了阐述这个问题如何解决,而是想说: - Copy 代码的时候需要留意下自己的场景 - 尽量用轮子,而不是自己去造轮子 在我们手写API网关的时候,还遇到过以下问题 - 第一版的网络处理也是手写的,导致对于各种Content-Type处理不好; - 因为要解析Body,也没有精力去适配各种压缩协议,所以在网关这里会强制关闭压缩; - 手写网络处理,会出现一些诡异的问题 - 比如:我们支持页面终端连接到K8S集群,而这个终端连接走的是Websocket,假设支持该连接操作的服务是A(就是:页面< - - - - - - >网关< - - - - - - >服务A< - - - - - - >K8S集群),那么后面过网关的请求部分请求会直接请求到服务A上(此时根本没有走网关的API router,直接就复用Websocket这个连接了),即使这些API不是服务A的。 第一版手写网络请求处理的代码示意如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | func proxyHttp(ctx context.Context, proxy_req *http.Request, domain string) { // origin request req := ctx.Request() response, err := HttpClient.Do(proxy_req) if err != nil { // 打印异常 return } defer response.Body.Close() //copy response header if response != nil && response.Header != nil { for k, values := range response.Header { for _, value := range values { ctx.ResponseWriter().Header().Set(k, value) } } } // status code ctx.StatusCode(response.StatusCode) buf := make([]byte, 1024) for { len, err := response.Body.Read(buf) if err != nil && err != io.EOF { // 打印异常 break } if len == 0 { break } ctx.ResponseWriter().Write(buf[:len]) ctx.ResponseWriter().Flush() continue } ctx.Next() } func proxyWebSocket(ctx context.Context, request *http.Request, target string) { var logger = ctx.Application().Logger() responseWriter := http.ResponseWriter(ctx.ResponseWriter()) conn, err := net.Dial("tcp", target) if err != nil { // 打印异常 return } hijacker, ok := responseWriter.(http.Hijacker) if !ok { http.Error(responseWriter, "Not a hijacker?", 500) return } nc, _, err := hijacker.Hijack() if err != nil { // 打印异常 return } defer nc.Close() defer conn.Close() err = request.Write(conn) if err != nil { // 打印异常 return } errc := make(chan error, 2) cp := func(dst io.Writer, src io.Reader) { _, err := io.Copy(dst, src) errc <- err } go cp(conn, nc) go cp(nc, conn) // wait over <-errc ctx.Application().Logger().Infof("websocket proxy to %s over", target) } | 后来换成了基础类库的httputil.ReverseProxy来处理网络连接,问题解决。 ### 权限的一种实现方式 - URL: https://jiankunking.com/a-way-to-implement-permissions.html - Content type: original - Published: 2023-02-17 - Updated: 2023-02-17 - Summary: 动手开发自己的鉴权服务,支持动态新增资源类型、资源属性,实现RBAC与ABAC相结合的权限控制能力。 - Categories: Architecture - Tags: RBAC, ABAC, Security, Permission Article text: 文章速览 动手开发自己的鉴权服务,支持动态新增资源类型、资源属性,实现RBAC与ABAC相结合的权限控制能力。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 动手开发自己的鉴权服务,支持动态新增资源类型、资源属性,实现RBAC与ABAC相结合的权限控制能力。 动手开发自己的鉴权服务 摘出两个功能点简单说一下 - 资源类型 - 支持动态新增资源类型 - 资源类型新增后,支持新增资源类型权限配置(资源数据的获取支持SQL、RPC等) - 资源类型支持属性 - 通过资源类型的属性,实现ABAC的能力 - 比如: - 资源类型APP上添加属性env,并指定env的取值,那在配置权限的时候就通过env来配置权限 1 2 3 4 5 6 7 8 9 10 | // 环境在 stage、test、dev中 { "StringIn": { "env": [ "stage", "test", "dev" ] } } | - 比如 添加时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // 周一到周五 9点到18点 { "WeekDayIn": { "time": [//周一到周五 "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" ] }, "TimeLessThanEquals": { "time": 36000 // 18点 }, "TimeGreaterThanEquals": { "time": 3600 //9点 } } | ### 身份的焦虑笔记 - URL: https://jiankunking.com/status-anxiety-notes.html - Content type: original - Published: 2023-01-31 - Updated: 2023-01-31 - Summary: 《身份的焦虑》读书笔记,整理社会地位焦虑的来源、表现,以及哲学、艺术和生活方式提供的缓解路径。 - Categories: Reading Notes - Tags: Reading Notes, 身份的焦虑 Article text: 文章速览 《身份的焦虑》读书笔记,整理社会地位焦虑的来源、表现,以及哲学、艺术和生活方式提供的缓解路径。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Reading Notes 本文整理自:《身份的焦虑》 作者:【英】阿兰·德波顿 生活就是用一种焦虑代替另一种焦虑,用一种欲望代替另一种欲望的过程。 - 我们身上那些更加隐秘的侧面——诸如我们的困惑、我们的愠怒,我们的罪恶感——有时竟然在某一书页上跟我们撞个正着,一种自我认同感于是油然而生。那位作者用确切的文字描述了一种我们原以为只有我们自己才有所会心的情境,一时间,我们就像两个早早地去赴约吃饭的爱人,兴奋不已地发现两人间竟有这么多的共同点(陶醉之下,只能嚼几口眼前的开胃小食,哪有心思再去吃什么正餐),我们也会把书暂时放下,带点乖张地微笑着盯着书脊不放,仿佛在说,”何等幸运,邂逅此君。” 马塞尔·普鲁斯特曾表达过类似的意思,他说,”事实上,每个读者只能读到已然存在于他内心的东西。书籍只不过是一种光学仪器,作者将其提供给读者,以便于他发现如果没有这本书的帮助他就发现不了的东西。” 我们的个性并非如我们乐于想象的那般密不透风,我们自以为只归我们独有的很多东西其实根本没那么私密——当然并不是说它们就是客观超然的,像你在快餐店里招呼侍应生那么不带感情色彩,而是说它们其实都是人类所共有的东西。我们在发现自己并非如此孤立的同时也要付点代价:我们也并非如我们想象的那般与众不同。 - 新的经济自由使数亿中国人过上了富裕的生活。然而,在繁荣的经济大潮中,一个已经困扰西方世界长达数世纪的问题也东渡到了中国:那就是身份的焦虑。 身份的焦虑是我们对自己在世界中地位的担忧。不管我们是一帆风顺、步步高升,还是举步维艰、江河日下,都难以摆脱这种烦恼。为何身份的问题会令我们寝食难安呢?原因甚为简单,身份的高低决定了人情冷暖:当我们平步青云时,他人都笑颜逢迎;而一旦被扫地出门,就只落得人走茶凉了。其结果是,我们每个人都惟恐失去身份地位,如果察觉到别人并不怎么喜爱或尊敬我们时,就很难对自己保持信心。我们的”自我”或自我形象就像一只漏气的气球,需要不断充入他人的爱戴才能保持形状,而他人对我们的忽略则会轻而易举地把它扎破。因此,惟有外界对我们表示尊敬的种种迹象才能帮助我们获得对自己的良好感觉。 - 身份的焦虑是何时产生的呢?生活的基本需求总应该首先得到满足吧。在饿殍遍地的饥馑年月里,很少有人会因为身份而焦虑。历史证明,社会保障了生活的基本需求之际,就是身份的焦虑滋生之时。在现代社会里,我们总爱拿自己的成就与被我们认为是同一层面的人相比较,身份的焦虑便缘此而生了。翻开报纸,发现上面有熟人光彩照人的相片(这足可以毁掉你整个早晨的心情);你的好友兴冲冲地告诉你一个消息(他们升了职、他们即将结婚、他们的书上了畅销书排行榜),因为他们幼稚地、甚至带点施虐性地认为这是一个好消息。在晚会上,有人用力地握着我们的手,问我们在”干”什么,而他自己筹集资金刚刚开张了一家新公司:每当这一切发生时,我们便为自己的身份地位而担心了。 - 现今,身份的焦虑比以往任何时候都强烈,因为每个人获取成功(性爱的成功、经济的成功和职业的成功)的可能性似乎比以往任何时候都大。要想觉得自己不是一个”失败者”,我们必须期望更多的东西。我们每时每刻都被成功人士的故事所包围。然而回顾历史,我们可以发现,在绝大多数时代里,人们的主导思想与之完全相反:对生活抱以很低的期待不仅正常,而且明智。仅有极少数的人立志追求财富与成就。就大多数人而言,他们知道自己活在世上就是为人奴役、逆来顺受。即使是今天,我们攀上社会顶层的可能性也微乎其微。我们很难获得能与比尔·盖茨一较高下的成功,就如同一个17世纪的人想获得路易十四那样的权力是痴人说梦一样。然而不幸的是,现在的人们觉得这一切并非没有实现的可能——这种想法来自于每个人阅读的杂志。事实上,如果谁没有为了实现这一切而全力以赴,那才是世间最荒唐无稽的事情。 - 富豪们是否也会受到身份焦虑的困扰?答案是肯定的,因为他们攀比的对象是与他们同等地位的人。我们所有人的做法都并无二致,故而即使我们比历史上的任何时期的人都富裕,到头来还是觉得一无所有、两手空空。并非我们不知好歹,而是因为我们并不以古人为参照来判断自己。与古人或与其他地域的人相比而显现出的富裕,并不能长时间地使我们开心。只有同那些一起长大的同伴、一起工作的同事、熟识的朋友,或是在公共场合与那些有认同感的新知相比较时,如果我们拥有和他们一样多或更多的东西的时候,我们才认为自己是幸运的。因此,要想获得成功的感觉,最佳途径莫过于选择一个稍逊于己的人作为朋友…… - 毋庸置疑,对身份地位的渴望,同人类的任何欲望一样,都具有积极的作用:激发潜能、力臻完美、阻止离经叛道的有害行径,并增强社会共同价值产生的凝聚力。如同那些事业成功的失眠症患者历来所强调的那样,惟焦虑者方能成功,这或许具有一定的道理。但承认焦虑的价值,并不妨碍我们同时对此进行质疑。我们渴望得到地位和财富,但其实一旦如愿以偿,我们的生活反而会变得更加糟糕。我们的很多欲望总是与自己真正的需求毫无关系。过多地关注他人(那些在我们的葬礼上不会露面的人)对我们的看法,使我们把自己短暂一生之中最美好的时光破坏殆尽。假如我们不能停止忧虑,我们将会用生命中大量的光阴为错误的东西而担心,这才是最令人痛心疾首的事情。 - 身份的焦虑是一种担忧。担忧我们处在无法与社会设定的成功典范保持一致的危险中,从而被夺去尊严和尊重,这种担忧的破坏力足以摧毁我们生活的松紧度;以及担忧我们当下所处的社会等级过于平庸,或者会堕至更低的等级。 - 这种焦虑由同事间的聊天中提到的辞职、裁员、晋升、退休等消息引起,由报纸上刊登的知名人士简介及友人更巨大的成功引起。如同承认自己嫉妒别人一样(这与焦虑情绪相联系),表现出自己焦虑的程度在社交中是一种轻率之举,因此,反映内心变化的外部迹象并不显著,通常仅限于在听到别人的成就之后露出关切的眼神、尴尬的微笑或者过长的沉默。 第一部分 焦虑起因 - 威廉·詹姆斯在《心理学原理》(1890)中写道:”如果可行,对一个人最残忍的惩罚莫过如此:给他自由,让他在社会上逍游,却又视之如无物,完全不给他丝毫的关注。当他出现时,其他的人甚至都不愿稍稍侧身示意;当他讲话时,无人回应,也无人在意他的任何举止。如果我们周围每一个人见到我们时都视若无睹,根本就忽略我们的存在,要不了多久,我们心里就会充满愤怒,我们就能感觉到一种强烈而又莫名的绝望,相对于这种折磨,残酷的体罚将变成一种解脱。“ - 亚当·斯密在他的《道德情操论》(1759)中说:”我们在这个世界上辛苦劳作、来回奔波到底为了什么呢?所有这些贪婪和欲望,所有这些对财富、权力和名声的追求,其目的到底何在呢?难道是为了满足自然的需求?如果是这样,最底层的劳动者的收入也足以满足人的自然需求。那么人类的一切被称为‘改善生存状况’的伟大目的的价值何在?” - “被他人注意、被他人关怀,得到他人的同情、赞美和支持,这就是我们想要从一切行为中得到的价值。富有的人忘情于财富,是因为财富能够自然而然地为他吸引世界的目光。穷人则完全相反,他们以贫穷为耻。他们感觉到自己生活在世界的目光之外。一旦感到自己被世界所忽略,人类天性中最强烈的欲望将必然难以得到满足。穷人进出家门都不为人所注意,即使在闹市,他也会像独处在家一样默默无闻。而名流显贵们则不然,他们一直为世界所瞩目。所有的人都渴望能够一睹尊颜。他们的行为成为公众关心的对象。他们的片言只语、举手投足都不会被人忽略。” - 人为什么要追求显耀的身份?对此问题的回答几成共识:要言之,无非是祈财、求名和扩大影响。 然而,有一个显然不为权势规则所关注的字眼却能更准确地表述我们心中的渴慕,那就是”爱”。衣食一旦无忧,累积的财物、掌控的权力就不再是我们在社会等级中追求成功的关键要素,我们开始在意的其实是显耀的身份为我们赢得的”爱”。金钱、名声和影响只能视为”爱”的表征——或者是获取爱的途径——而非终极目标。 第一章 - 只要不觉得羞辱,人完全可以长期过着艰苦的生活而毫无怨言,如士兵和探险家们,他们愿意过着一种极其艰苦简陋的生活,其物质之匮乏远甚于现今社会上那些最窘困的群体,然而,他们能熬过一切的苦难。为什么会是这样呢?因为他们清楚自己受到他人的尊重。 同样,由显耀的身份所带来的东西也不仅仅局限在财富上。一些非常富足的人仍孜孜以求地聚敛财富,尽管他们所拥有的已足够供其后五代人挥霍之用。如果我们坚持以理性的财务视点来分析他们,也许会对他们的狂热感到难以理解,但是,如果我们看到在积累财富的同时,他们其实也在赢取他人的尊重,我们就不会奇怪了。很少有人只是一味地追求高雅情趣,也很少有人只是耽溺于奢华享乐,但我们每个人都渴求一种生存的尊严。因此我们可以大胆假设,如果未来社会是凭着积攒小小的塑料圆片(而非金钱)来获取他人的爱,那么,要不了多久,这种我们现在看来毫无价值的小玩意就会成为所有人追求和渴望的焦点。 - 他人对我们的关注之所以如此重要,主要原因便在于人类对自身价值的判断有一种与生俱来的不确定性——我们对自己的认识在很大程度上取决于他人对我们的看法。我们的自我感觉和自我认同完全受制于周围的人对我们的评价。如果我们讲出的笑话让他们开怀,我们就对自己逗笑的能力充满自信;如果我们受到他人的赞扬,我们就会对自己的优点开始留意。反之,如果我们进了一间屋子,人们甚至不屑于瞥上我们一眼,或者当我们告诉他们我们的职业时,他们马上表现出不耐烦,我们很可能会对自己产生怀疑,觉得自己一无是处。 - 当然,在一个理想世界中,我们可能更坚强一些。我们会固守自己的底线,不管别人是否在意我们,也不会顾虑别人的臧否。可能有人曲意奉承我们,但我们并不因此而自鸣得意;同样,只要我们对自身有清醒的认识,清楚自身价值之所在,他人不公允的看待也不会伤及我们,因为我们清楚自己的地位和境遇。然而,我们对自己特性和品质的认识总是在一些相互矛盾的评价中飘忽不定。一会儿觉得自己聪明机巧、幽默风趣、一言九鼎,一会儿又觉得自己蠢笨如牛、了无情趣、一钱不值,在这种摇摆无定的情状下,我们对自身价值的判断完全受制于社会的态度——若得褒扬,我们就会感觉良好;反之,则痛不欲生。仿佛我们对他人的情感负有亏欠似的。 - 我们的”自我”或自我认知可以用一只漏气的气球来作比方——任何时候,我们都需要他人的爱(对于气球而言,便是源源不断的氦气)来填充自己的内心,而经不起哪怕是针尖麦芒大的刺伤。我们的情绪变得难以理喻,一会儿因他人的褒扬而开心,一会儿为他人的漠视而伤怀。同事的一句心不在焉的问候,几通没有应答的电话就可能使我们闷闷不乐;而如果有人记住了我们的名字,或送来一只果篮,我们又会觉得生活满洒阳光,人生何等惬意! 第二章 - 未成年时,没人在意我们的所作所为,我们可以无条件地受人宠爱。我们可以吃得打饱嗝而毋须顾忌,可以狂喊大叫而不顾他人感受,也可以不挣一分钱,不交一位有权有势的朋友,但是,我们还是周围的人关注的中心。 - 一旦成年了,就意味着我们得在这满是势利鬼和冰冷面孔的世间争取一个位置,这些人的影响是使我们产生身份焦虑的关键所在。尽管也有朋友和爱人承诺说永不抛弃我们——即便在我们破产和名誉扫地之时,他们也会和我们共同度过(有时候我们会真的相信他们)——现实却相当残酷:我们身边多的是势利小人,我们无时无刻不在他们势利的眼神下生活。 - 势利者关注的只是他人的声望和成就。一旦他相熟的人的声望和成就有所改变,这些势利者很可能闻风而动,重新排定他所谓最亲近的朋友,从而上演一出出悲喜剧。 - 深藏在我们内心的害怕其实才是势利产生的惟一根源,看清了这一点,我们也就能对势利有清楚的认识。对那些对自己的地位非常有把握的人来说,他们没有心思去把成心矮化他人当作某种消遣。傲慢的背后藏着的无非就是恐惧。由于总是感觉自己不如别人,因此才要想方设法让别人觉得他不如自己。 - 这种害怕还能世代相传。同人类所有的陋习一样,势利者也是代代相承。上一辈的人定会向下一代灌输低下的社会地位就是一种悲剧的观念,使下一辈不可能在感情上轻易摆脱低下的身份就意味着平庸,高尚的身份就意味着卓越的思维定势。 - 然而,单凭个体的力量很难挣脱势利的桎梏,因为势利的病征是群体性的。年轻一代开始也许会对势利反感,但这还不足以将人类从势利的桎梏中解救出来。因为这很可能使他们渴望博得那些轻看他们的上层阶级的好感,因而也变得势利起来(我们可以不喜欢某些人,但这并不意味着我们不想讨得他们的欢心)。由此可见,杰出阶层的势利观念足以影响整个社会,使所有的人为了赢取别人的爱和认可而开始热衷于那些他们原本毫无兴趣的所谓追求。 - 我们也许会对买下这样一件家具的人极尽揶揄之能事,然而在我们嘲笑他们之前,我们其实应该设身处地,以更宽广的视野思考这样的问题:为什么有厂商要生产这样的家具?又为什么有人要买这样的家具?这样,我们也许不再拿这些买主打趣,因为该责备的正是我们置身其中的社会——是我们的社会预设了这样一种规范,让我们每个人都从心理上相信买下这样的橱柜是必要且值得的,因为这种过分雕琢、近乎怪诞的摆设能赢来别人的敬意。人们追求奢华,与其说是出于贪欲,倒不如说是源于一种情感上挥之不去的心结。往往是那些担心被人看不起的人,为了使自己不会显得太过寒碜,才会添置这样一件特别的家具,藉此传出一种信息:我也应该得到尊重! - 在势利社会里,如果一个身份低贱的人所遭受的痛苦,在物质层面表现为贫困的话,那么被人忽略、受人白眼则是这些缺乏重要身份标志的人们在精神层面上所遭受的痛苦。 - 我们所期待的远超出我们祖先们的想象,但我们付出的代价则是永远都挥之不去的焦虑——我们永远都不能安于现状,永远都有尚未企及的梦想。 - 卢梭的主要论点基于对财富的阐释。他认为,财富并不代表占有物的多少,而是拥有多少我们渴望得到的东西。它是相对的,相对于人们的欲望。任何时候,不管我们占有的财物多么丰富,只要我们还在追求某种我们不可能得到的东西,我们就谈不上富有;相反,如果我们总是满足于我们现时的拥有,不管我们实际占有的东西多么匮乏,我们是富有的。 - 几个世纪以来,人们坚信苦难是人生的一部分。这一信念也就成为了人类最为重要的精神资产,成为人们面对苦难时的精神支柱。然而现代的社会观念使得人们充满渴望和期盼,也无情地改变了先前人们所固守的人生来就是受苦的理念。 一旦人们觉得来世不过是一种臆想,或者从科学的角度把来世视为一种并不存在的精神鸦片,这时,追求现世成功和实现切近人生理想的压力就会无时无处不在,这使他们躁动不已,因为他们清楚人生苦短,任何的机会都可能稍纵即逝。现世的一切不再视为永恒世界的一段序曲,相反,现世的成功就是人生的一切。 一个人若对来世的可能丧失信念,在希望受挫后他遭受的打击可能会更大。那些相信现世的一切只是永生世界的短暂序曲的人可能觉得他人在现世的成功不过是永恒世界中一现的昙花,因而不易心生妒嫉。 - “减少对自身的期望会使人有如释重负的快意,这同实现自己的期望一样,是件值得高兴的事情。倘若一个人在某个方面一无是处,而自己仍处之泰然,这将是一种难以言喻的轻松。如果有一天我们决定不必费心去减肥,也不再为青春难驻而烦忧,我们的生活该有多愉快呀!我们会说:‘谢天谢地,那些不切实际的念头终于都见鬼去了!’我们给自己增多一份期望,就是增多一份负担,虽然这也可能给自己增多一份自豪。” - 直到18世纪,几乎在所有西方国家,实施的还是森严的等级制度。在这种社会制度之下,除极少数例外情况,社会个体几无改变自己身份和地位的可能。这种索尔兹伯里的约翰和约翰·福蒂斯丘所高度颂扬的社会制度,从各个方面来说,都显然是极端不公正的,但它却让那些社会底层的人们有了一种自在和自由:他们不必将自己同社会中其他的人所取得的成就进行比照,因而,在心理上他们并没有感到自己严重缺乏社会身份,也没有如今底层人们那种强烈的一无所有和一无是处的焦虑。 当然,托克维尔非常清楚贵族统治制度的局限性,他并非想要回复到1776或1789年前的社会里。他看到了西方现代社会里广大民众的生活远胜于中世纪生活在社会底层的广大民众,然而,他也指出中世纪底层的民众却享有一种精神的宁静,这是现代的人们永远无法得到的。 - 1651年,托马斯·霍布斯在其著作《利维坦》中指出,个体的存在先于社会的出现。个体是为了自己的利益才加入社会组织中,他们放弃自己的一些自由和权利,来换取社会的保护。 - 18、19世纪政治和消费生产的巨大进步尽管极大程度上改善了人类的物质生活,但同时也为人类心理造成了难言苦痛,因为同社会体制和生产进步一起伴生的还有一种全新的理想——每个人都深信人生而平等,每个人都深信自己有足够的实力去实现自己的任何理想。 在人类历史上长期存在的主导观念却同这种新的人人平等的思想完全相左:人与人之间的不平等才是正常;随遇而安,知足常乐才算明智。绝大多数的人深知在现实生活中他们只能接受剥削,而且逆来顺受,只有极少数的人渴望财富和实现自己的抱负。 - 在《人性论》(1739)中,戴维·休谟这样写道:”产生这种妒忌的不是自己与他人之间的远远不成比例,反而是我们的互相接近。一个普通的士兵对他的将领不如对军曹或班长那样妒忌,一个卓越的作家遭不到一般平庸的小文人的多大妒忌,而却遭到和他地位相近的作家的妒忌。的确,人们也许会以为越是不成比例,则在比较之下所感到的不快必然越大。但是我们可以在另一方面考虑,远远的不成比例,就切断了关系,或者使我们根本不与我们距离很远的人物比较,或者就减弱了比较的效果。” 我们每天都会经验到许多不平等的对待,但我们并不会因此而妒恨每一个比我们优越的人,这就是嫉妒的特别之处。有些人的生活胜过我们千倍万倍,但我们能心安无事;而另一些人一丁点的成功却能让我们耿耿于怀,寝食不安。我们妒嫉的只是和我们处在同一层次的人,即我们的比照群体。世上最难忍受的大概就是我们最亲近的朋友比我们成功。 第三章 - 乔治·奥威尔在他的《狮子和独角兽》(1941)一书中也记下了物质进步的各个方面:”几乎所有西方国家的公民现在都一样能使用便捷平坦的道路,享用清洁无菌的水,受到警察的保护,免费使用图书馆,还很有可能得到免费的教育。在很大程度上,穷人能和富人阅读同样的书籍,观看同样的电影,收听同样的节目。因为是大批量的生产,服装不再昂贵,再加上居住条件的改善,穷人和富人在生活方面的差异日渐缩小。要想寻找英国未来的雏形,你只需在那些轻工业区和公路沿线遛达一圈就可以了。不管是在斯劳、达格纳姆、巴尼特,还是在莱奇沃思、海斯,事实上,在任何大城市的郊区,你都能发现旧的生活模式已经日渐为新的模式所替代。在那些由玻璃和砖石砌成的新型城区里,人们的生活已然缺乏文化上的情趣。人们变得浮躁不定,和他们生活密切相关的无非是听装食品、宣传海报、收音机和内燃机等等。” - 令人奇怪的是,人类物质方面的实际拥有极大地丰富了,随之而来的竟然是一种挥之不去且愈显强烈的”一无所有”的感觉,以及对这种感觉的恐惧!同那些在中世纪的欧洲大地上辛勤耕作却对岁尾收成毫无把握的祖先比起来,现在的生活富有且充满机遇的这些欧洲后裔们对身份的焦虑、对所有之物的担忧远甚于他们的祖先。 - 如果考虑到人们对”怎样才算足够的”判断标准中隐含的心理情愫,他们这种对”一无所有”的忧虑就并不奇怪了。我们从来就不会孤立地形成我们对事物(如财富和社会尊重)的相应期待,我们的判断必然有一个参照群体——那些我们认为和自己差不多的人。只有同他们比较,我们才能确定我们合适的期待视野。我们不可能孤立地欣赏自己拥有的东西,也不可能通过与中世纪祖先进行比较来衡量我们现在的拥有。同样,我们也不可能仅仅因为自己身处一个繁荣富足的历史时期而沾沾自喜。只有当我们所拥有的同儿时的朋友、现在的同事、我们看作朋友的人,以及在公众领域与我们身份相当的人一样多,甚至还要略多一些时,我们才会觉得自己是幸运的。 即使我们所拥有的只是一间透风漏雨、周围环境极差的小屋子,但只要我们清楚和自己层次相当的人的生活情形也大致如此,这时,虽然我们也了解另外一个残酷的事实,即社会中的贵族住在规模宏大的庄园里,房间还有暖气供应,我们还是能认命并接受这一现实,对自身的处境亦无太多的哀怨。我们当然会觉得自己是不幸的,但这还不足以成为滋生妒嫉的温床。如果我们有了一个融乐的家庭,一份舒适的工作,但我们在一次同学聚会上发现一些老同学(再也没有任何群体比旧时的同学更堪为比照群体了)住的房子比我们大,工作更优裕,我们回家后反倒更容易生发强烈的不幸感。如果我们的比照群体比我们更加优越,更有成就,我们感到自己原本应该取得更大的成就,从而,焦虑,甚至愤恨会接踵而来。 我们每天都会经验到许多不平等的对待,但我们并不会因此而妒恨每一个比我们优越的人,这就是嫉妒的特别之处。有些人的生活胜过我们千倍万倍,但我们能心安无事;而另一些人一丁点的成功却能让我们耿耿于怀,寝食不安。我们妒嫉的只是和我们处在同一层次的人,即我们的比照群体。世上最难忍受的大概就是我们最亲近的朋友比我们成功。 在人类历史上长期存在的主导观念却同这种新的人人平等的思想完全相左:人与人之间的不平等才是正常;随遇而安,知足常乐才算明智。绝大多数的人深知在现实生活中他们只能接受剥削,而且逆来顺受,只有极少数的人渴望财富和实现自己的抱负。 - 尽管基督教教义也宣扬平等的观念,但基督教政治理论家几乎都回避了这样一个问题:为了使上帝的信徒能公平地享用世间的财富,我们是否可以对世间的社会等级结构做些改革和调整?是的,在上帝面前我们每个人都是平等的,但这并不表明我们在尘世就可以追求人与人之间的平等。 - “由皇室和贵族统治的国家尽管有其缺点,但在那样的社会里也有一些乐趣是现代人很难想见的。由于从没有构想过另一种社会形式,每个人仅仅了解自己的身份,而从来没有想过还会有可能改变自己的身份,所以他们绝不会产生和自己的上级或主人平起平坐的期望,因而那时的人们不会对自己的权利有任何怀疑。对他们的艰苦境遇,既无敌对反感之情绪,也无堕落蒙羞之心态,因为他们相信一切都是天定,他们只能接受。农奴的地位非常低下,但他们把自己的命运视为自然的法则。正因为如此,尽管不同阶层人民之间的命运如此迥异,但各个阶层之间并无恶意。你可以在这个社会看到很多的不平等,但你不会看到人们的心灵会因此而蒙羞。” - 但是,一个民主的社会拆去了所有束缚人们梦想的樊篱。一个人也许生活拮据,在物质方面远不如人,但这并不妨碍他们从理论上觉得他和任何人都是平等的。托克维尔写道:”在美国,我遇到的每一个人,不管多么穷困潦倒,他们眼里都写满希望,同时心里无不对富人们的安逸舒适心生妒嫉。”贫穷的国民打量着邻近街区里的富人们,坚信有一天自己也会过上富人们的生活。当然,他们的梦想并非完全是空穴来风,因为确有不少出身寒门的美国人取得了相当的成就。尽管如此,例外并不代表普遍情况,美国仍然有生活在底层的人们。同先前的贵族统治制度下的穷人不同,美国底层的人把他们差强人意的生活现状归咎于期望的泡汤或理想的受阻。 - 以仆人对主人的态度为基点,托克维尔认为,民主社会与贵族社会对贫穷的看法大相径庭。在贵族社会里,底层的仆人能泰然地接受他们的命运,用托克维尔的话来说,他们能”愉快地生活,对自己的工作感到自豪,同时也不失自尊”。然而,在一个民主社会里,有的只是报刊和社会舆论没完没了的鼓噪,让每个生活在底层的人都相信他们总有机会攀上社会金字塔的塔尖,有机会成为实业家、大法官、科学家,甚至是总统。这种无限机遇的论调在一开始也许能给人一种盲目的乐观,对那些底层的年轻人尤甚。但在他们之中,只有极少数最优秀的幸运儿才有机会脱颖而出,实现他们的梦想;而多数的人,随着时间一天天过去,他们并不能改变自己的身份,正如托克维尔所言,他们会转而变得意志消沉,内心极度痛楚,并轻贱自己,同时也憎恶自己的顶头上司们。 - 直到18世纪,几乎在所有西方国家,实施的还是森严的等级制度。在这种社会制度之下,除极少数例外情况,社会个体几无改变自己身份和地位的可能。这种索尔兹伯里的约翰和约翰·福蒂斯丘所高度颂扬的社会制度,从各个方面来说,都显然是极端不公正的,但它却让那些社会底层的人们有了一种自在和自由:他们不必将自己同社会中其他的人所取得的成就进行比照,因而,在心理上他们并没有感到自己严重缺乏社会身份,也没有如今底层人们那种强烈的一无所有和一无是处的焦虑。 - 在托克维尔对美国进行考察数十年后,对这个问题予以关注的是一个美国人——威廉·詹姆斯。他从心理学的角度探讨了这种因社会使每一成员产生无限期望而带来的困扰。 詹姆斯认为,对自己感到满意,并不要求我们在任何领域都取得成功。失败并非任何时候都会给我们带来羞辱,只有某件事情我们不仅尽力而为了,而且在一开始就觉得此事关涉我们的自尊和成就感,结果却还是做砸了,这时我们才会觉得羞愧。因此我们对自己设定的目标决定了我们对成功和失败的解读。詹姆斯,这位哈佛的心理学教授,曾经期望自己成为一位杰出的心理学家。如果不能实现这一理想,他会觉得自己颜面顿失。因此,他承认自己必定会妒嫉那些在心理学方面比自己更有研究的人,并感到羞愧。然而,如果有人能翻译整部《会饮篇》,而他费尽心思甚至还弄不明白该书的起首几行文句,对此,他倒不会有什么不自在,因为他从未立志学习古希腊文字。 詹姆斯认为:对于我们未曾想去尝试的事情,就不可用成败来衡量;既无失败,何来羞辱? - 大众传媒的迅猛发展使得人们对自身的期望变得更高了。阿尔弗雷德·哈姆斯沃思,英国《每日邮报》的创办者,在1896年报纸首发仪式上坦率地告诉民众,他心目中的理想读者就是那些虽然现在”年收入只有100英镑”,心里却梦想着”来年能有1000英镑进账的人”。与此同时,在美国则有《妇女家庭杂志》(1883年创刊)、《大都市》(1886年创刊)、《芒赛》(1889年创刊)以及《时尚》(1892年创刊)等刊物杂志将各种奢华的生活推至读者眼前。例如,19世纪末的美国《时尚》杂志告诉读者的信息不外乎如下:美洲杯赛过后,有哪些人上了约翰·雅各布·阿斯特的诺马哈游艇狂欢,寄宿学校里最时尚的女孩正如何穿戴打扮,纽波特和南安普敦谁在筹办最棒的舞会,还有正餐的鱼子酱应该和什么配在一起(土豆和酸奶油)。 此外,电台广播和影视资讯也使人们越来越有可能了解上流社会的生活情况,并和上流阶层攀上一定的关系。到20世纪30年代,美国人每周耗在电影院的时间高达1.5亿小时,至于收听广播的时间更是高达10亿小时。1946年还只有0.02%的美国家庭拥有电视机,而这一比例到2000年已经攀至98%。 这些新的媒介所传达的内容和信息助长了人们对生活的渴望,而穿插在节目之间的广告更是推波助澜。 - 诚然,现代社会前所未有地提高了我们的收入,至少使我们看起来更为富有。实际上,现代社会给人们真实的感受却是使我们愈来愈感觉到贫穷。现代社会激发了人们无限的期望,在我们想要得到的和能够得到的东西之间、在我们实际的地位和我们理想的地位之间造成了永远无法填补的鸿沟。我们可能比原始社会里的野人更觉得一无所有——诚如卢梭所言(他的论断在此达到了最令人难以信服的地步),原始的野人只要有一块屋顶遮住他们头顶的天,几只苹果和坚果填饱了他们的肚子,在晚上拨弄”某些粗糙的乐器”来聊以自慰,或”用石斧做渔船”来消磨时光,他们心中的满足就无以复加了。 卢梭对现代人和野蛮人之间幸福程度的比较使我们很自然联想到詹姆斯所说的期望对幸福的决定作用。也许我们拥有的不多,但由于期望的减少我们能知足常乐;反之,现代社会鼓励人们追求一切,尽管我们已经非常富有,我们却终日焦虑多愁。 - 卢梭所言的野人可谓是衣不遮体,一无所有。但同现代社会中住在”泰姬陵”里的人相比,他们至少能够享受因渴求很少而导致的极大的富裕。 第四章 - 就物质层面看,在社会等级中位居低层,此般境遇少有快乐可言。但从精神层面看就不尽然,低层的人不一定总得无时无地苦不堪言。在很大程度上,贫困对自尊的影响取决于周围的人对贫穷的理解和看法。 - “有一次我和这样一个资产者在曼彻斯特街上走,和他谈到工人区的恶劣的不合卫生的建筑体系,谈到这些地区的可怕的居住条件,我说我还没有看到过比曼彻斯特建筑得更坏的城市。他静静地听完这一切,在走到拐角上和我告别的时候,他说:‘但是在这里到底可以赚很多钱。再见,先生!’英国资产者对自己的工人是否挨饿,是毫不在乎的,只要他自己能赚钱就行。一切生活关系都以能否赚钱来衡量,凡是不赚钱的都是蠢事,都不切实际,都是幻想。” - 19世纪40年代曼彻斯特贫民窟的生活也许谈不上愉快,但如果生活在这里的穷困工人阶级了解到是他们雇主贪婪和残忍,是资本主义制度无处不在的贪污和腐化造成了他们眼下的困境(而且这种社会制度不可能凭他们个人的力量得到改变),那么,这… ### [译]Effective Elasticsearch Plugin Management with Docker - URL: https://jiankunking.com/effective-elasticsearch-plugin-management-with-docker.html - Content type: translation - Published: 2022-12-25 - Updated: 2022-12-25 - Summary: 翻译介绍如何在 Docker 环境中管理 Elasticsearch 插件,涵盖持久化、基础插件、复杂插件和 TLS 配置。 - Categories: Elasticsearch - Tags: Elasticsearch, Plugin, Docker - Original source: https://www.elastic.co/cn/blog/elasticsearch-docker-plugin-management The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Golang PutUvarint Uvarint - URL: https://jiankunking.com/golang-putuvarint-uvarint.html - Content type: original - Published: 2022-12-23 - Updated: 2022-12-23 - Summary: 结合 bigcache 的使用场景,介绍 Go binary.PutUvarint 与 Uvarint 的变长整数编码原理和用法。 - Categories: Go - Tags: Go, PutUvarint, Uvarint Article text: 文章速览 结合 bigcache 的使用场景,介绍 Go binary.PutUvarint 与 Uvarint 的变长整数编码原理和用法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 看bigcache库的时候,注意到存放数据用的是PutUvarint、Uvarint,那这两个方法是做什么的呢? PutUvarint 源码及注释 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // PutUvarint encodes a uint64 into buf and returns the number of bytes written. // If the buffer is too small, PutUvarint will panic. // PutVarint 将 int64 编码为 buf 并返回写入的字节数。如果缓冲区太小,PutVarint 会panic。 func PutUvarint(buf []byte, x uint64) int { i := 0 // 0x80 128 for x >= 0x80 { buf[i] = byte(x) | 0x80 // 右移7位,然后将右移后的值,赋值给x x >>= 7 i++ } buf[i] = byte(x) return i + 1 } | 代码示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | package main import ( "encoding/binary" "fmt" ) func main() { buf := make([]byte, binary.MaxVarintLen64) for _, x := range []int64{-65, -64, -2, -1, 0, 1, 2, 63, 64} { n := binary.PutVarint(buf, x) fmt.Printf("%x\n", buf[:n]) } } | 输出结果 1 2 3 4 5 6 7 8 9 10 11 | 8101 7f 03 01 00 02 04 7e 8001 Program exited. | https://go.dev/play/p/yRUoUooVrHm Uvarint 源码及注释 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | // Uvarint decodes a uint64 from buf and returns that value and the // number of bytes read (> 0). If an error occurred, the value is 0 // and the number of bytes n is <= 0 meaning: // // n == 0: buf too small // n < 0: value larger than 64 bits (overflow) // and -n is the number of bytes read // Uvarint 从 buf 解码 uint64 并返回该值和读取的字节数(> 0)。如果发生错误,则该值为0,并且字节数n <= 0意味着: // n == 0:buf太小了 // n <0:大于64位的值(溢出) // 和-n是读取的字节数 func Uvarint(buf []byte) (uint64, int) { var x uint64 var s uint for i, b := range buf { // MaxVarintLen64 10 if i == MaxVarintLen64 { // Catch byte reads past MaxVarintLen64. // See issue https://golang.org/issues/41185 return 0, -(i + 1) // overflow } // 0x80 128 if b < 0x80 { if i == MaxVarintLen64-1 && b > 1 { return 0, -(i + 1) // overflow } return x | uint64(b)< max { tempDelay = max } srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay) time.Sleep(tempDelay) continue } return err } connCtx := ctx if cc := srv.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic("ConnContext returned nil") } } tempDelay = 0 c := srv.newConn(rw) c.setState(c.rwc, StateNew, runHooks) // before Serve can return go c.serve(connCtx) // <--- 注意这里 } } | 注意一下,在serve(l net.Listener)函数中,对于每一个请求都会开一个协程来处理。 协程内部都做了啥呢? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | // Serve a new connection. func (c *conn) serve(ctx context.Context) { c.remoteAddr = c.rwc.RemoteAddr().String() ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr()) var inFlightResponse *response defer func() { if err := recover(); err != nil && err != ErrAbortHandler { const size = 64 << 10 buf := make([]byte, size) buf = buf[:runtime.Stack(buf, false)] c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf) } if inFlightResponse != nil { inFlightResponse.cancelCtx() } if !c.hijacked() { if inFlightResponse != nil { inFlightResponse.conn.r.abortPendingRead() inFlightResponse.reqBody.Close() } c.close() c.setState(c.rwc, StateClosed, runHooks) } }() if tlsConn, ok := c.rwc.(*tls.Conn); ok { tlsTO := c.server.tlsHandshakeTimeout() if tlsTO > 0 { dl := time.Now().Add(tlsTO) c.rwc.SetReadDeadline(dl) c.rwc.SetWriteDeadline(dl) } if err := tlsConn.HandshakeContext(ctx); err != nil { // If the handshake failed due to the client not speaking // TLS, assume they're speaking plaintext HTTP and write a // 400 response on the TLS conn's underlying net.Conn. if re, ok := err.(tls.RecordHeaderError); ok && re.Conn != nil && tlsRecordHeaderLooksLikeHTTP(re.RecordHeader) { io.WriteString(re.Conn, "HTTP/1.0 400 Bad Request\r\n\r\nClient sent an HTTP request to an HTTPS server.\n") re.Conn.Close() return } c.server.logf("http: TLS handshake error from %s: %v", c.rwc.RemoteAddr(), err) return } // Restore Conn-level deadlines. if tlsTO > 0 { c.rwc.SetReadDeadline(time.Time{}) c.rwc.SetWriteDeadline(time.Time{}) } c.tlsState = new(tls.ConnectionState) *c.tlsState = tlsConn.ConnectionState() if proto := c.tlsState.NegotiatedProtocol; validNextProto(proto) { if fn := c.server.TLSNextProto[proto]; fn != nil { h := initALPNRequest{ctx, tlsConn, serverHandler{c.server}} // Mark freshly created HTTP/2 as active and prevent any server state hooks // from being run on these connections. This prevents closeIdleConns from // closing such connections. See issue https://golang.org/issue/39776. c.setState(c.rwc, StateActive, skipHooks) fn(c.server, tlsConn, h) } return } } // HTTP/1.x from here on. ctx, cancelCtx := context.WithCancel(ctx) c.cancelCtx = cancelCtx defer cancelCtx() c.r = &connReader{conn: c} c.bufr = newBufioReader(c.r) c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10) for { w, err := c.readRequest(ctx) if c.r.remain != c.server.initialReadLimitSize() { // If we read any bytes off the wire, we're active. c.setState(c.rwc, StateActive, runHooks) } if err != nil { const errorHeaders = "\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\n" switch { case err == errTooLarge: // Their HTTP client may or may not be // able to read this if we're // responding to them and hanging up // while they're still writing their // request. Undefined behavior. const publicErr = "431 Request Header Fields Too Large" fmt.Fprintf(c.rwc, "HTTP/1.1 "+publicErr+errorHeaders+publicErr) c.closeWriteAndWait() return case isUnsupportedTEError(err): // Respond as per RFC 7230 Section 3.3.1 which says, // A server that receives a request message with a // transfer coding it does not understand SHOULD // respond with 501 (Unimplemented). code := StatusNotImplemented // We purposefully aren't echoing back the transfer-encoding's value, // so as to mitigate the risk of cross side scripting by an attacker. fmt.Fprintf(c.rwc, "HTTP/1.1 %d %s%sUnsupported transfer encoding", code, StatusText(code), errorHeaders) return case isCommonNetReadError(err): return // don't reply default: if v, ok := err.(statusError); ok { fmt.Fprintf(c.rwc, "HTTP/1.1 %d %s: %s%s%d %s: %s", v.code, StatusText(v.code), v.text, errorHeaders, v.code, StatusText(v.code), v.text) return } publicErr := "400 Bad Request" fmt.Fprintf(c.rwc, "HTTP/1.1 "+publicErr+errorHeaders+publicErr) return } } // Expect 100 Continue support req := w.req if req.expectsContinue() { if req.ProtoAtLeast(1, 1) && req.ContentLength != 0 { // Wrap the Body reader with one that replies on the connection req.Body = &expectContinueReader{readCloser: req.Body, resp: w} w.canWriteContinue.setTrue() } } else if req.Header.get("Expect") != "" { w.sendExpectationFailed() return } c.curReq.Store(w) if requestBodyRemains(req.Body) { registerOnHitEOF(req.Body, w.conn.r.startBackgroundRead) } else { w.conn.r.startBackgroundRead() } // HTTP cannot have multiple simultaneous active requests.[*] // Until the server replies to this request, it can't read another, // so we might as well run the handler in this goroutine. // [*] Not strictly true: HTTP pipelining. We could let them all process // in parallel even if their responses need to be serialized. // But we're not going to implement HTTP pipelining because it // was never deployed in the wild and the answer is HTTP/2. inFlightResponse = w serverHandler{c.server}.ServeHTTP(w, w.req) // <--- 注意这里 inFlightResponse = nil w.cancelCtx() if c.hijacked() { return } w.finishRequest() if !w.shouldReuseConnection() { if w.requestBodyLimitHit || w.closedRequestBodyEarly() { c.closeWriteAndWait() } return } c.setState(c.rwc, StateIdle, runHooks) c.curReq.Store((*response)(nil)) if !w.conn.server.doKeepAlives() { // We're in shutdown mode. We might've replied // to the user without "Connection: close" and // they might think they can send another // request, but such is life with HTTP/1.1. return } if d := c.server.idleTimeout(); d != 0 { c.rwc.SetReadDeadline(time.Now().Add(d)) if _, err := c.bufr.Peek(4); err != nil { return } } c.rwc.SetReadDeadline(time.Time{}) } } | 注意这行代码 1 | serverHandler{c.server}.ServeHTTP(w, w.req) | 其中ServeHTTP是一个接口,绝大多数Web框架都是通过实现该接口,从而替换掉Golang默认的路由。 具体的接口定义: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // A Handler responds to an HTTP request. // // ServeHTTP should write reply headers and data to the ResponseWriter // and then return. Returning signals that the request is finished; it // is not valid to use the ResponseWriter or read from the // Request.Body after or concurrently with the completion of the // ServeHTTP call. // // Depending on the HTTP client software, HTTP protocol version, and // any intermediaries between the client and the Go server, it may not // be possible to read from the Request.Body after writing to the // ResponseWriter. Cautious handlers should read the Request.Body // first, and then reply. // // Except for reading the body, handlers should not modify the // provided Request. // // If ServeHTTP panics, the server (the caller of ServeHTTP) assumes // that the effect of the panic was isolated to the active request. // It recovers the panic, logs a stack trace to the server error log, // and either closes the network connection or sends an HTTP/2 // RST_STREAM, depending on the HTTP protocol. To abort a handler so // the client sees an interrupted response but the server doesn't log // an error, panic with the value ErrAbortHandler. type Handler interface { ServeHTTP(ResponseWriter, *Request) } | Golang默认实现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72… ### Golang写时复制是否是原子性的? - URL: https://jiankunking.com/golang-copy-on-write-and-atomic.html - Content type: original - Published: 2022-07-07 - Updated: 2022-07-07 - Summary: 结合 Go 汇编分析 Copy-On-Write 赋值操作是否具备原子性,以及并发读写时需要注意的问题。 - Categories: Go - Tags: Go, Atomic, Copy-On-Write, COW Article text: 文章速览 结合 Go 汇编分析 Copy-On-Write 赋值操作是否具备原子性,以及并发读写时需要注意的问题。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go Golang在生成汇编语句的时候,赋值是一句话,所以:COW Copy-On-Write是原子性的 先说一下我这边的一个简化场景吧,有一个定时任务定时从数据库获取数据,也就是对应实例代码中的getNewProject(),获取完数据后,会直接赋给变量projectMap(projectMap其实就是作为一个缓存来用的);还会有程序从projectMap获取对应的信息。 这个场景其实就是一个简单的写时复制。对于获取在赋值过程中,获取到旧值,也是允许的。 有个疑问点就是在赋值的这个操作是不是原子的呢? 比如示例代码中的第8行。 验证代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | package main var ( projectMap = make(map[string]*Project) ) func main() { projectMap = getNewProject() } func getNewProject() map[string]*Project { items := make(map[string]*Project) item := new(Project) item.ID = "project_id" item.Name = "project_name" items[item.ID] = item return items } type Project struct { ID string `json:"id"` Name string `json:"name"` } | 在网上也找到一些资料,说是原子性的: - 看看开源项目都是如何做的 - https://github.com/fagongzi/manba/blob/master/pkg/proxy/dispatcher_meta.go#L174 - 看看一些对于Copy-On-Write的讨论 - https://chunlife.top/2019/09/03/copy-on-write%E6%8A%80%E6%9C%AF/ - https://github.com/Terry-Mao/gopush-cluster/issues/44 但终究还是自己探索一下的比较好对吧,哈哈。 要想看这个赋值操作是不是原子性的,那咱们看下汇编代码吧。 查看汇编,咱们分为两步: - 编译golang代码为.o文件 - 反编译.o文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | PS D:\Code\Golang\github\jiankunking\cow-test> go tool compile -N -l main\main.go PS D:\Code\Golang\github\jiankunking\cow-test> go tool objdump .\main.o TEXT "".main(SB) gofile..D:/Code/Golang/github/jiankunking/cow-test/main/main.go main.go:7 0x22f1 493b6610 CMPQ 0x10(R14), SP main.go:7 0x22f5 763e JBE 0x2335 main.go:7 0x22f7 4883ec08 SUBQ $0x8, SP main.go:7 0x22fb 48892c24 MOVQ BP, 0(SP) main.go:7 0x22ff 488d2c24 LEAQ 0(SP), BP main.go:8 0x2303 e800000000 CALL 0x2308 [1:5]R_CALL:"".getNewProject main.go:8 0x2308 833d0000000000 CMPL $0x0, 0(IP) [2:6]R_PCREL:runtime.writeBarrier+-1 main.go:8 0x230f 6690 NOPW main.go:8 0x2311 7402 JE 0x2315 main.go:8 0x2313 eb09 JMP 0x231e main.go:8 0x2315 48890500000000 MOVQ AX, 0(IP) [3:7]R_PCREL:"".projectMap main.go:8 0x231c eb0e JMP 0x232c main.go:8 0x231e 488d3d00000000 LEAQ 0(IP), DI [3:7]R_PCREL:"".projectMap main.go:8 0x2325 e800000000 CALL 0x232a [1:5]R_CALL:runtime.gcWriteBarrier<1> main.go:8 0x232a eb00 JMP 0x232c main.go:9 0x232c 488b2c24 MOVQ 0(SP), BP main.go:9 0x2330 4883c408 ADDQ $0x8, SP main.go:9 0x2334 c3 RET main.go:7 0x2335 e800000000 CALL 0x233a [1:5]R_CALL:runtime.morestack_noctxt main.go:7 0x233a ebb5 JMP "".main(SB) TEXT "".getNewProject(SB) gofile..D:/Code/Golang/github/jiankunking/cow-test/main/main.go main.go:11 0x233c 493b6610 CMPQ 0x10(R14), SP main.go:11 0x2340 0f8600010000 JBE 0x2446 main.go:11 0x2346 4883ec58 SUBQ $0x58, SP main.go:11 0x234a 48896c2450 MOVQ BP, 0x50(SP) main.go:11 0x234f 488d6c2450 LEAQ 0x50(SP), BP main.go:11 0x2354 48c744242000000000 MOVQ $0x0, 0x20(SP) main.go:12 0x235d e800000000 CALL 0x2362 [1:5]R_CALL:runtime.makemap_small<1> main.go:12 0x2362 4889442428 MOVQ AX, 0x28(SP) main.go:14 0x2367 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:type."".Project main.go:14 0x236e e800000000 CALL 0x2373 [1:5]R_CALL:runtime.newobject<1> main.go:14 0x2373 4889442430 MOVQ AX, 0x30(SP) main.go:15 0x2378 48c740080a000000 MOVQ $0xa, 0x8(AX) main.go:15 0x2380 833d0000000000 CMPL $0x0, 0(IP) [2:6]R_PCREL:runtime.writeBarrier+-1 main.go:15 0x2387 7402 JE 0x238b main.go:15 0x2389 eb0c JMP 0x2397 main.go:15 0x238b 488d1500000000 LEAQ 0(IP), DX [3:7]R_PCREL:go.string."project_id" main.go:15 0x2392 488910 MOVQ DX, 0(AX) main.go:15 0x2395 eb11 JMP 0x23a8 main.go:15 0x2397 4889c7 MOVQ AX, DI main.go:15 0x239a 488d1500000000 LEAQ 0(IP), DX [3:7]R_PCREL:go.string."project_id" main.go:15 0x23a1 e800000000 CALL 0x23a6 [1:5]R_CALL:runtime.gcWriteBarrierDX main.go:15 0x23a6 eb00 JMP 0x23a8 main.go:16 0x23a8 488b542430 MOVQ 0x30(SP), DX main.go:16 0x23ad 8402 TESTB AL, 0(DX) main.go:16 0x23af 48c742180c000000 MOVQ $0xc, 0x18(DX) main.go:16 0x23b7 488d7a10 LEAQ 0x10(DX), DI main.go:16 0x23bb 833d0000000000 CMPL $0x0, 0(IP) [2:6]R_PCREL:runtime.writeBarrier+-1 main.go:16 0x23c2 7402 JE 0x23c6 main.go:16 0x23c4 eb0d JMP 0x23d3 main.go:16 0x23c6 488d3500000000 LEAQ 0(IP), SI [3:7]R_PCREL:go.string."project_name" main.go:16 0x23cd 48897210 MOVQ SI, 0x10(DX) main.go:16 0x23d1 eb10 JMP 0x23e3 main.go:16 0x23d3 488d1500000000 LEAQ 0(IP), DX [3:7]R_PCREL:go.string."project_name" main.go:16 0x23da 6690 NOPW main.go:16 0x23dc e800000000 CALL 0x23e1 [1:5]R_CALL:runtime.gcWriteBarrierDX main.go:16 0x23e1 eb00 JMP 0x23e3 main.go:18 0x23e3 488b542430 MOVQ 0x30(SP), DX main.go:18 0x23e8 8402 TESTB AL, 0(DX) main.go:18 0x23ea 488b0a MOVQ 0(DX), CX main.go:18 0x23ed 488b7a08 MOVQ 0x8(DX), DI main.go:18 0x23f1 48894c2440 MOVQ CX, 0x40(SP) main.go:18 0x23f6 48897c2448 MOVQ DI, 0x48(SP) main.go:18 0x23fb 488b5c2428 MOVQ 0x28(SP), BX main.go:18 0x2400 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:type.map[string]*"".Project main.go:18 0x2407 e800000000 CALL 0x240c [1:5]R_CALL:runtime.mapassign_faststr<1> main.go:18 0x240c 4889442438 MOVQ AX, 0x38(SP) main.go:18 0x2411 8400 TESTB AL, 0(AX) main.go:18 0x2413 488b542430 MOVQ 0x30(SP), DX main.go:18 0x2418 833d0000000000 CMPL $0x0, 0(IP) [2:6]R_PCREL:runtime.writeBarrier+-1 main.go:18 0x241f 7402 JE 0x2423 main.go:18 0x2421 eb05 JMP 0x2428 main.go:18 0x2423 488910 MOVQ DX, 0(AX) main.go:18 0x2426 eb0a JMP 0x2432 main.go:18 0x2428 4889c7 MOVQ AX, DI main.go:18 0x242b e800000000 CALL 0x2430 [1:5]R_CALL:runtime.gcWriteBarrierDX main.go:18 0x2430 eb00 JMP 0x2432 main.go:19 0x2432 488b442428 MOVQ 0x28(SP), AX main.go:19 0x2437 4889442420 MOVQ AX, 0x20(SP) main.go:19 0x243c 488b6c2450 MOVQ 0x50(SP), BP main.go:19 0x2441 4883c458 ADDQ $0x58, SP main.go:19 0x2445 c3 RET main.go:11 0x2446 e800000000 CALL 0x244b [1:5]R_CALL:runtime.morestack_noctxt main.go:11 0x244b e9ecfeffff JMP "".getNewProject(SB) TEXT "".init(SB) gofile..D:/Code/Golang/github/jiankunking/cow-test/main/main.go main.go:4 0x2450 493b6610 CMPQ 0x10(R14), SP main.go:4 0x2454 763e JBE 0x2494 main.go:4 0x2456 4883ec08 SUBQ $0x8, SP main.go:4 0x245a 48892c24 MOVQ BP, 0(SP) main.go:4 0x245e 488d2c24 LEAQ 0(SP), BP main.go:4 0x2462 e800000000 CALL 0x2467 [1:5]R_CALL:runtime.makemap_small<1> main.go:4 0x2467 833d0000000000 CMPL $0x0, 0(IP) [2:6]R_PCREL:runtime.writeBarrier+-1 main.go:4 0x246e 6690 NOPW main.go:4 0x2470 7402 JE 0x2474 main.go:4 0x2472 eb09 JMP 0x247d main.go:4 0x2474 48890500000000 MOVQ AX, 0(IP) [3:7]R_PCREL:"".projectMap main.go:4 0x247b eb0e JMP 0x248b main.go:4 0x247d 488d3d00000000 LEAQ 0(IP), DI [3:7]R_PCREL:"".projectMap main.go:4 0x2484 e800000000 CALL 0x2489 [1:5]R_CALL:runtime.gcWriteBarrier<1> main.go:4 0x2489 eb00 JMP 0x248b main.go:4 0x248b 488b2c24 MOVQ 0(SP), BP main.go:4 0x248f 4883c408 ADDQ $0x8, SP main.go:4 0x2493 c3 RET main.go:4 0x2494 e800000000 CALL 0x2499 [1:5]R_CALL:runtime.morestack_noctxt main.go:4 0x2499 ebb5 JMP "".init(SB) TEXT type..eq."".Project(SB) gofile.. gofile..:1 0x2713 493b6610 CMPQ 0x10(R14), SP gofile..:1 0x2717 0f8610010000 JBE 0x282d gofile..:1 0x271d 4883ec58 SUBQ $0x58, SP gofile..:1 0x2721 48896c2450 MOVQ BP, 0x50(SP) gofile..:1 0x2726 488d6c2450 LEAQ 0x50(SP), BP gofile..:1 0x272b 4889442460 MOVQ AX, 0x60(SP) gofile..:1 0x2730 48895c2468 MOVQ BX, 0x68(SP) gofile..:1 0x2735 c644241e00 MOVB $0x0, 0x1e(SP) gofile..:1 0x273a 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x273f 488b5208 MOVQ 0x8(DX), DX gofile..:1 0x2743 4889542428 MOVQ DX, 0x28(SP) gofile..:1 0x2748 488b542468 MOVQ 0x68(SP), DX gofile..:1 0x274d 488b5208 MOVQ 0x8(DX), DX gofile..:1 0x2751 4889542420 MOVQ DX, 0x20(SP) gofile..:1 0x2756 4839542428 CMPQ DX, 0x28(SP) gofile..:1 0x275b 7405 JE 0x2762 gofile..:1 0x275d e9b3000000 JMP 0x2815 gofile..:1 0x2762 eb00 JMP 0x2764 gofile..:1 0x2764 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x2769 488b5218 MOVQ 0x18(DX), DX gofile..:1 0x276d 4889542420 MOVQ DX, 0x20(SP) gofile..:1 0x2772 488b542468 MOVQ 0x68(SP), DX gofile..:1 0x2777 488b5218 MOVQ 0x18(DX), DX gofile..:1 0x277b 4889542428 MOVQ DX, 0x28(SP) gofile..:1 0x2780 4839542420 CMPQ DX, 0x20(SP) gofile..:1 0x2785 7405 JE 0x278c gofile..:1 0x2787 e987000000 JMP 0x2813 gofile..:1 0x278c eb00 JMP 0x278e gofile..:1 0x278e 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x2793 488b5208 MOVQ 0x8(DX), DX gofile..:1 0x2797 4889542428 MOVQ DX, 0x28(SP) gofile..:1 0x279c 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x27a1 488b12 MOVQ 0(DX), DX gofile..:1 0x27a4 4889542448 MOVQ DX, 0x48(SP) gofile..:1 0x27a9 488b542468 MOVQ 0x68(SP), DX gofile..:1 0x27ae 488b1a MOVQ 0(DX), BX gofile..:1 0x27b1 48895c2440 MOVQ BX, 0x40(SP) gofile..:1 0x27b6 488b4c2428 MOVQ 0x28(SP), CX gofile..:1 0x27bb 488b442448 MOVQ 0x48(SP), AX gofile..:1 0x27c0 e800000000 CALL 0x27c5 [1:5]R_CALL:runtime.memequal<1> gofile..:1 0x27c5 8844241f MOVB AL, 0x1f(SP) gofile..:1 0x27c9 84c0 TESTL AL, AL gofile..:1 0x27cb 7502 JNE 0x27cf gofile..:1 0x27cd eb41 JMP 0x2810 gofile..:1 0x27cf eb00 JMP 0x27d1 gofile..:1 0x27d1 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x27d6 488b5218 MOVQ 0x18(DX), DX gofile..:1 0x27da 4889542428 MOVQ DX, 0x28(SP) gofile..:1 0x27df 488b542460 MOVQ 0x60(SP), DX gofile..:1 0x27e4 488b5210 MOVQ 0x10(DX), DX gofile..:1 0x27e8 4889542438 MOVQ DX, 0x38(SP) gofile..:1 0x27ed 488b542468 MOVQ 0x68(SP), DX gofile..:1 0x27f2 488b5a10 MOVQ 0x10(DX), BX gofile..:1 0x27f6 48895c2430 MOVQ BX, 0x30(SP) gofile..:1 0x27fb 488b4c2428 MOVQ 0x28(SP), CX gofile..:1 0x2800 488b442438 MOVQ 0x38(SP), AX gofile..:1 0x2805 e800000000 CALL 0x280a [1:5]R_CALL:runtime.memequal<1> gofile..:1 0x280a 8844241e MOVB AL, 0x1e(SP) gofile..:1 0x280e eb0e JMP 0x281e gofile..:1 0x2810 eb05 JMP 0x2817 gofile..:1 0x2812 90 NOPL gofile..:1 0x2813 eb02 JMP 0x2817 gofile..:1 0x2815 eb00 JMP 0x2817 gofile..:1 0x2817 c644241e00 MOVB $0x0, 0x1e(SP) gofile..:1 0x281c eb00 JMP 0x281e gofile..:1 0x281e 0fb644241e MOVZX 0x1e(SP), AX gofile..:1 0x2823 488b6c2450 MOVQ 0x50(SP), BP gofile..:1 0x2828 4883c458 ADDQ $0x58, SP gofile..:1 0x282c c3 RET gofile..:1 0x282d 4889442408 MOVQ AX, 0x8(SP) gofile..:1 0x2832 48895c2410 MOVQ BX, 0x10(SP) gofile..:1 0x2837 e800000000 CALL 0x283c [1:5]R_CALL:runtime.morestack_noctxt gofile.. 0 { cmdArr = append(cmdArr, "-C", destDir) } //remote shell. req := client.CoreV1().RESTClient(). Post(). Namespace(i.Namespace). Resource("pods"). Name(i.Name). SubResource("exec"). VersionedParams(&corev1.PodExecOptions{ Container: i.ContainerName, Command: cmdArr, Stdin: true, Stdout: true, Stderr: true, TTY: false, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { return err } err = exec.Stream(remotecommand.StreamOptions{ Stdin: reader, Stdout: os.Stdout, Stderr: os.Stderr, Tty: false, }) if err != nil { return err } return nil } func checkDestinationIsDir(ctx context.Context, client *kubernetes.Clientset, config *rest.Config, i *Pod, destPath string) error { return i.Exec(ctx, client, config, []string{"test", "-d", destPath}) } func makeTar(srcPath, destPath string, writer io.Writer) error { // TODO: use compression here? tarWriter := tar.NewWriter(writer) defer tarWriter.Close() srcPath = path.Clean(srcPath) destPath = path.Clean(destPath) return recursiveTar(path.Dir(srcPath), path.Base(srcPath), path.Dir(destPath), path.Base(destPath), tarWriter) } func recursiveTar(srcBase, srcFile, destBase, destFile string, tarWriter *tar.Writer) error { // defer func() { // fmt.Println("d") // if err := recover(); err != nil { // fmt.Println(err) // 这里的err其实就是panic传入的内容 // } // fmt.Println("e") // }() filepath := path.Join(srcBase, srcFile) stat, err := os.Lstat(filepath) if err != nil { return err } if stat.IsDir() { files, err := ioutil.ReadDir(filepath) if err != nil { return err } if len(files) == 0 { //case empty directory hdr, _ := tar.FileInfoHeader(stat, filepath) hdr.Name = destFile if err := tarWriter.WriteHeader(hdr); err != nil { return err } } for _, f := range files { if err := recursiveTar(srcBase, path.Join(srcFile, f.Name()), destBase, path.Join(destFile, f.Name()), tarWriter); err != nil { return err } } return nil } else if stat.Mode()&os.ModeSymlink != 0 { //case soft link hdr, _ := tar.FileInfoHeader(stat, filepath) target, err := os.Readlink(filepath) if err != nil { return err } hdr.Linkname = target hdr.Name = destFile if err := tarWriter.WriteHeader(hdr); err != nil { return err } } else { //case regular file or other file type like pipe hdr, err := tar.FileInfoHeader(stat, filepath) if err != nil { return err } hdr.Name = destFile err = tarWriter.WriteHeader(hdr) if err != nil { log.Println(err) return err } f, err := os.Open(filepath) if err != nil { return err } defer f.Close() if _, err := io.Copy(tarWriter, f); err != nil { return err } return f.Close() } return nil } func (i *Pod) Exec(ctx context.Context, client *kubernetes.Clientset, config *rest.Config, cmd []string) error { req := client.CoreV1().RESTClient(). Post(). Namespace(i.Namespace). Resource("pods"). Name(i.Name). SubResource("exec"). VersionedParams(&corev1.PodExecOptions{ Container: i.ContainerName, Command: cmd, Stdin: true, Stdout: true, Stderr: true, TTY: false, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { return err } err = exec.Stream(remotecommand.StreamOptions{ Stdin: strings.NewReader(""), Stdout: os.Stdout, Stderr: os.Stderr, Tty: false, }) if err != nil { return err } return nil } | ### MySQL 可重复读隔离级别与幻读(能很大程度上避免幻读,但不能完全避免) - URL: https://jiankunking.com/mysql-isolation-level-repeatable-read-and-phantom-read.html - Content type: original - Published: 2022-03-14 - Updated: 2022-03-14 - Summary: 基于MySQL 5.7事务实验分析可重复读隔离级别下的幻读现象,说明一致性读、当前读及不同操作顺序为何能大幅避免但不能完全消除幻读。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 基于MySQL 5.7事务实验分析可重复读隔离级别下的幻读现象,说明一致性读、当前读及不同操作顺序为何能大幅避免但不能完全消除幻读。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 在MySQL可重复读的隔离级别下,能很大程度上避免幻读,但不能完全避免。 场景复现 环境信息: 1 2 | MySQL版本:5.7.23-log 隔离级别:REPEATABLE-READ | 测试数据: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for app_record_lock_test -- ---------------------------- DROP TABLE IF EXISTS `app_record_lock_test`; CREATE TABLE `app_record_lock_test` ( `id` int(11) NOT NULL AUTO_INCREMENT, `hash` bigint(20) NOT NULL DEFAULT 0, `cluster` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `namespace` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '', `service` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '', `pod` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '', `created_at` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), `updated_at` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `cluster_hash`(`cluster`, `hash`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 120236026 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of app_record_lock_test -- ---------------------------- INSERT INTO `app_record_lock_test` VALUES (120236012, 1, 'cluster', 'namespace', 'service', 'pod', '2022-02-18 14:14:59', '2022-02-09 10:00:00'); INSERT INTO `app_record_lock_test` VALUES (120236013, 2, 'cluster', 'namespace', 'service', 'pod', '2022-02-18 14:14:59', '2022-02-09 10:00:00'); INSERT INTO `app_record_lock_test` VALUES (120236014, 3, 'cluster', 'namespace', 'service', 'pod', '2022-02-18 14:14:59', '2022-02-09 10:00:00'); SET FOREIGN_KEY_CHECKS = 1; | 复现流程: 时间 会话A(T1) 会话B(T2) 1 | START TRANSACTION; | | 2 | | START TRANSACTION; | 3 | SELECT * FROM app_record_lock_test WHERE cluster = ‘cluster1’; | | 4 | | INSERT INTO app_record_lock_test (HASH, cluster, namespace, service, pod, updated_at)VALUES(1, ‘cluster1’, ‘namespace’, ‘service’, ‘pod’, ‘2022-02-09 10:00:00’) ; | 5 | | COMMIT; | 6 | update app_record_lock_test set namespace= ‘namespace2’ where cluster = ‘cluster1’; | | 7 | SELECT * FROM app_record_lock_test WHERE cluster = ‘cluster1’; | | 8 | COMMIT; | | 在第3步执行查询cluster = ‘cluster1’时,查询到的结果是空。 但会话B执行完第4 5之后,再在会话A中执行第6步,就不会等待或者报错了,也就是可以正常执行,在第7步的查询中,也能查到该数据了。 注意:会话A能正常更新、查询的前提是:会话B事务已提交。 注意:会话A如果不执行更新(也就是第6步),那么在查询的时候,还是查询不到记录的。 原因分析 对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含下面这两个必要的隐藏列(row_id并不是必要的:在创建的表中有主键时,或者有不允许为NULL的UNIQUE键时,都不会包含row_id列). • trx_id:一个事务每次对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列. • roll_pointer:每次对某条聚族索引记录进行改动时,都会把旧的版本写入到undo日志中.这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息. 在REPEATABLE READ隔离级别下,T1第一次执行普通的SELECT语句时生成了一个ReadView,之后T2向app_record_lock_test表中新插入一条记录并提交.ReadView并不能阻止T1执行UPDATE或者DELETE语句来改动这个新插入的记录(由于T2已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的trx_id隐藏列的值就变成了T1的事务id,之后T1再使用普通的SELECT语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端.因为这个特殊现象的存在,我们也可以认为InnoDB中的MVCC并不能完全禁止幻读。 也就是说,T1将新插入数据的事务号修改的小于等于ReadView对应的事务版本号了,相当于扩充了ReadView的范围,从而导致在下一次查询的时候能查询出该记录。 ### 编程高手必学的内存知识 笔记 - URL: https://jiankunking.com/memory-knowledge-that-programming-masters-must-learn.html - Content type: original - Published: 2022-01-22 - Updated: 2022-01-22 - Summary: 《编程高手必学的内存知识》学习笔记,涵盖虚拟内存、页表、进程地址空间、内存映射等核心概念。 - Categories: Java - Tags: Reading Notes, Java Article text: 文章速览 《编程高手必学的内存知识》学习笔记,涵盖虚拟内存、页表、进程地址空间、内存映射等核心概念。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 《编程高手必学的内存知识》学习笔记,涵盖虚拟内存、页表、进程地址空间、内存映射等核心概念。 编程高手必学的内存知识 作者: 海纳 为什么可用内存会远超物理内存? 虚拟内存的出现,是为了解决直接操作物理内存的系统无法支持多进程的问题。这里的难点主要是进程的地址空间非常小,而且多个进程的地址很容易发生冲突。所以在局部性原理的基础上,CPU设计者提出虚拟内存的方案将多个进程的地址空间隔离开,并且提供了巨大的内存空间。 我们可以总结一下,虚拟内存主要有下面两个特点: 第一,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的。这就解决了多进程之间地址冲突的问题。 第二,PTE中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。 另外,虚拟内存可以充分使用CPU提供的机制来完成很多重要的任务。例如,fork借用写保护来实现写时复制,JVM中借用改变某一个页的读权限来实现safepoint查询等等。 由于CPU对内存提供了更多保护的能力,所以X86架构的CPU把这种工作模式称为保护模式,与可以直接访问物理内存的实模式形成了对比。 为什么可用内存会远超物理内存? X86体系结构中的实模式和保护模式 8086是16位的CPU,我们称8086的工作模式为实模式,它的特点是直接操作物理内存,内存管理容易出错,要十分小心,代码编写和调试都很困难。 之后出现的i386,则采用了和实模式不同的保护模式。相比实模式,i386中的保护模式,采用了页式管理,但它没有彻底放弃8086的段式管理,而是将段寄存器中的值由段基址变成了段选择子。段选择子本质是GDT表的下标值,段基址都转移到GDT中去了。 段式管理负责将逻辑地址转换为线性地址,或者称为虚拟地址,页式管理负责将线性地址映射到物理地址。i386的保护模式采用了段页式混合管理的模式,兼具了段式管理和页式管理的优点。 除了段页式内存管理这个不同之外,保护模式和实模式的区别还体现在中断描述符表(IDT)上。IDT是保护模式的一个重要组成部分,它保存着 i386 中断服务程序的入口地址。 8086和i386对X86架构的CPU影响巨大。直到今天,X86架构的CPU在上电以后,为了与8086保持兼容,还是运行在16位实模式下,也就是说所有访存指令访问的都是物理内存地址。在启动操作系统后,才会切换到保护模式下进行工作。 X86体系结构中的实模式和保护模式 内存布局:应用程序是如何安排数据的? IA-32机器上的Linux进程内存布局 在32位机器上,每个进程都具有4GB的寻址能力。Linux系统会默认将高地址的1GB空间分配给内核,剩余的低3GB是用户可以使用的用户空间。下图是32位机器上Linux进程的一个典型的内存布局。在实践中,我们可以通过cat /proc/pid/maps来查看某个进程的实际虚拟内存布局。 BSS 段这个缩写名字是Block Started by Symbol,但很多人可能更喜欢把它记作Better Save Space的缩写。 我们上述的布局分析都是基于Linux系统下关闭了进程地址随机化的选项。如果打开进程地址随机化的模式,其中的堆空间、栈空间和共享库映射的地址,在每次程序运行下都会不一样。这是因为内核在加载的过程中,会对这些区域的起始地址增加一些随机的偏移值,这能增加缓冲区溢出的难度。 Intel 64机器上的Linux进程内存布局 从图中你可以看到,在用户空间和内核空间之间有一个巨大的内存空洞。这块空间之所以用更深颜色来区分,是因为这块空间的不可访问是由CPU来保证的(这里的地址都不满足Intel 64的Canonical form)。 内存布局:应用程序是如何安排数据的? 深入理解栈:从CPU和函数的视角看栈的管理 深入理解栈:从CPU和函数的视角看栈的管理 栈的魔法:从栈切换的角度理解进程和协程 栈切换的核心就是栈指针rsp寄存器的切换,只要我们想办法把rsp切换了就相当于换了执行单元的上下文环境。 栈的魔法:从栈切换的角度理解进程和协程 静态链接:变量与内存地址是如何映射的? 在GNU/linux 下,GNU的binutils提供了一系列编程语言的工具程序,用来查看不同格式下的目标文件。今天我要给你重点介绍两个工具:readelf和objdump,这两个工具可以用来解析和读取上一节编译阶段生成的目标文件信息。 一般情况下,我在对二进制文件进行反汇编时会使用objdump工具,因为readelf工具没有提供反汇编的能力,它更多是用来解析二进制文件信息。 从源文件生成二进制可执行文件,这一过程主要包含了编译和链接两个步骤。其中,编译的作用是生成性能优越的机器码。对于编译单元内部的静态函数,可以在编译时通过相对地址的办法,生成call指令,因为无论将来调用者和被调用者被安置到什么地方,它们之间的相对距离不会发生变化。 而其他类型的变量和函数在编译时,编译器并不知道它们的最终地址,所以只能使用占位符(比如 0)来临时代替目标地址。 而链接器的任务是为所有变量和函数分配地址,并把被分配到的地址回写到调用者处。链接的过程主要分为两步,第一步是多文件合并,同时为符号分配地址,第二步则是将符号的地址回写到引用它的地方。其中,地址回写有一个专门的名字叫做重定位。重定位的过程依赖目标文件中的重定位表。 静态链接:变量与内存地址是如何映射的? 动态链接(上):地址无关代码是如何生成的? 动态链接(上):地址无关代码是如何生成的? 动态链接(下):延迟绑定与动态链接器是什么? 动态链接(下):延迟绑定与动态链接器是什么? 深入理解堆:malloc和内存池是怎么回事? 深入理解堆:malloc和内存池是怎么回事?? 页中断:fork、mmap背后的保护神 页中断:fork、mmap背后的保护神 即时编译:高性能JVM的核心秘密 即时编译:高性能JVM的核心秘密 内存虚拟化:云原生时代的奠基者 内存虚拟化:云原生时代的奠基者 存储电路:计算机存储芯片的电路结构是怎样的? 存储电路:计算机存储芯片的电路结构是怎样的? CPUCache:访存速度是如何大幅提升的? CPUCache:访存速度是如何大幅提升的? MESI协议:多核CPU是如何同步高速缓存的? MESI协议:多核CPU是如何同步高速缓存的? 内存模型:有了MESI为什么还需要内存屏障? 内存模型:有了MESI为什么还需要内存屏障? NUMA:非均匀访存带来了哪些提升与挑战? NUMA:非均匀访存带来了哪些提升与挑战? Java内存模型:Java中的volatile有什么用? Java内存模型:Java中的volatile有什么用? Scavenge:基于copy的垃圾回收算法 Scavenge:基于copy的垃圾回收算法 分代算法:基于生命周期的内存管理 分代算法:基于生命周期的内存管理 G1GC:分区回收算法说的是什么? G1GC:分区回收算法说的是什么? PauselessGC:挑战无暂停的垃圾回收 PauselessGC:挑战无暂停的垃圾回收 GC实例:Python和Go的内存管理机制是怎样的? GC实例:Python和Go的内存管理机制是怎样的? ### 系统性能调优必知必会 笔记 - URL: https://jiankunking.com/system-performance-tuning-must-know-and-must-know.html - Content type: original - Published: 2022-01-22 - Updated: 2022-01-22 - Summary: 《系统性能调优必知必会》学习笔记,涵盖CPU缓存、内存管理、磁盘I/O、网络性能等核心知识点。 - Categories: Performance - Tags: Performance, Reading Notes Article text: 文章速览 《系统性能调优必知必会》学习笔记,涵盖CPU缓存、内存管理、磁盘I/O、网络性能等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Performance 《系统性能调优必知必会》学习笔记,涵盖CPU缓存、内存管理、磁盘I/O、网络性能等核心知识点。 系统性能调优必知必会 作者: 陶辉 CPU缓存 CPU 的多级缓存 比如,Linux系统上,离CPU最近的一级缓存是32KB,二级缓存是256KB,最大的三级缓存则是20MB。 你可能注意到,三级缓存要比一、二级缓存大许多倍,这是因为当下的CPU都是多核心的,每个核心都有自己的一、二级缓存,但三级缓存却是一颗CPU上所有核心共享的。 程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被CPU使用,就像下面这张图。 缓存要比内存快很多。CPU访问一次内存通常需要100个时钟周期以上,而访问一级缓存只需要4~5个时钟周期,二级缓存大约12个时钟周期,三级缓存大约30个时钟周期(对于2GHZ主频的CPU来说,一个时钟周期是0.5纳秒。 如果CPU所要操作的数据在缓存中,则直接读取,这称为缓存命中。命中缓存会带来很大的性能提升,因此,我们的代码优化目标是提升 CPU 缓存的命中率。 当然,缓存命中率是很笼统的,具体优化时还得一分为二。比如,你在查看CPU缓存时会发现有2个一级缓存(比如Linux上就是上图中的index0 和 index1),这是因为,CPU会区别对待指令与数据。比如,“1+1=2”这个运算,“+”就是指令,会放在一级指令缓存中,而“1”这个输入数字,则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中,代码指令与数据是放在一起的,但执行时却是分开进入指令缓存与数据缓存的,因此我们要分开来看二者的缓存命中率。 提升数据缓存的命中率 缓存一次性会载入多少元素呢?CPU Cache Line相关,它定义了缓存一次载入数据的大小,Linux上你可以通过coherency_line_size配置查看它,通常是64字节。 遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。 关于CPU Cache Line的应用其实非常广泛,如果你用 Nginx,会发现它是用哈希表来存放域名、HTTP头部等数据的,这样访问速度非常快,而哈希表里桶的大小如server_names_hash_bucket_size,它默认就等于 CPU Cache Line 的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但Nginx 官网上明确建议它应该是CPU Cache Line的整数倍。 为什么要做这样的要求呢?就是因为缓存是按照64字节的整数倍来访问内存的,哈希表的桶按此大小排列布局,就可以尽量减少访问内存的次数。比如,若桶大小为64字节,那么根据地址获取字符串时只需要访问一次内存,而桶大小为50字节,会导致最坏2次访问内存,而70字节最坏会有3次访问内存。 提升指令缓存的命中率 当代码中出现if、switch等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如 if 还是 else 中的指令),就可以提前把这些指令放在缓存中,CPU执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当array数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高。 提升多核 CPU 下的缓存命中率 操作系统提供了将进程或者线程绑定到某一颗CPU上运行的能力。如Linux上提供了sched_setaffinity方法实现这一功能。 Perf工具也提供了cpu-migrations事件,它可以显示进程从不同的CPU核心上迁移的次数。 如果你在用Linux操作系统,可以通过一个名叫Perf的工具直观地验证缓存命中的情况(可以用yum install perf或者apt-get install perf安装这个工具)。 执行perf stat可以统计出进程运行时的系统信息(通过-e选项指定要统计的事件,如果要查看三级缓存总的命中率,可以指定缓存未命中 cache-misses 事件,以及读取缓存次数cache-references事件,两者相除就是缓存的未命中率,用1相减就是命中率。类似的,通过L1-dcache-load-misses和L1-dcache-loads可以得到L1缓存的命中率)。 内存池:如何提升内存分配的效率? 在Linux系统中,用Xmx设置JVM的最大堆内存为8GB,但在近百个并发线程下,观察到Java进程占用了14GB的内存。为什么会这样呢? 这是因为,绝大部分高级语言都是用C语言编写的,包括Java,申请内存必须经过C库,而C库通过预分配更大的空间作为内存池,来加快后续申请内存的速度。这样,预分配的6GB的C库内存池就与JVM中预分配的8G内存池叠加在一起,造成了Java进程的内存占用超出了预期。 隐藏的内存池 实际上,在你的业务代码与系统内核间,往往有两层内存池容易被忽略,尤其是其中的C库内存池。 当代码申请内存时,首先会到达应用层内存池,如果应用层内存池有足够的可用内存,就会直接返回给业务代码,否则,它会向更底层的C库内存池申请内存。比如,如果你在Apache、Nginx 等服务之上做模块开发,这些服务中就有独立的内存池。当然,Java中也有内存池,当通过启动参数Xmx指定JVM的堆内存为8GB时,就设定了JVM堆内存池的大小。 你可能听说过Google的TCMalloc和FaceBook的JEMalloc,它们也是C库内存池。当C库内存池无法满足内存申请时,才会向操作系统内核申请分配内存。如下图所示: 回到文章开头的问题,Java已经有了应用层内存池,为什么还会受到C库内存池的影响呢?这是因为,除了JVM负责管理的堆内存外,Java还拥有一些堆外内存,由于它不使用JVM的垃圾回收机制,所以更稳定、持久,处理IO的速度也更快。这些堆外内存就会由C库内存池负责分配,这是Java受到C库内存池影响的原因。 索引:如何用哈希表管理亿级对象? 内存结构与序列化方案 事实上对于动态(元素是变化的)哈希表,我们无法避免哈希冲突。有两种方法解决哈希冲突: - 链接法,落到数组同一个位置中的多个数据,通过链表串在一起。使用哈希函数查找到这个位置后,再使用链表遍历的方式查找数据。Java标准库中的哈希表就使用链接法解决冲突。 - 开放寻址法,插入时若发现对应的位置已经占用,或者查询时发现该位置上的数据与查询关键字不同,开放寻址法会按既定规则变换哈希函数(例如哈希函数设为 H(key,i),顺序地把参数i加1),计算出下一个数组下标,继续在哈希表中探查正确的位置。 我们该选择哪种方法呢? 链接法虽然实现简单,还允许存放元素个数大于数组的大小(也叫装载因子大于1),但链接法序列化数据的代价很大,因为使用了指针后,内存是不连续的。 开放寻址法确保所有对象都在数组里,就可以把数组用到的这段连续内存原地映射到文件中(参考Linux中的mmap,Java等语言都有类似的封装),再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件,但备份前还是需要主动刷新内存 (参考Linux中的msync,它可以按地址及长度来分段刷新,以减少msync的耗时),以确定备份数据的精确时间点。而新的进程启动时,可以通过映射磁盘中的文件到内存,快速重建哈希表提供服务。 使用哈希表,你要注意几个关键问题。 - 生产环境一定要考虑容灾,而把哈希表原地序列化为文件是一个解决方案,它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法,而后者更擅长序列化数据,因此成为我们的首选。 - 亿级数据下,我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突,提升速度。 - 优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。 零拷贝:如何高效地传输文件? 零拷贝:如何高效地传输文件? 协程:如何快速地实现高并发服务? 事实上,无论基于多进程还是多线程,都难以实现高并发,这由两个原因所致。 首先,单个线程消耗的内存过多,比如,64位的Linux为每个线程的栈分配了8MB的内存,还预分配了64MB的内存作为堆内存池。所以,我们没有足够的内存去开启几万个线程实现并发。 其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了让CPU充分工作,也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。 实际上,用户态的代码切换协程,与内核切换线程的原理是一样的。内核通过管理CPU的寄存器来切换线程,我们以最重要的栈寄存器和指令寄存器为例,看看协程切换时如何切换程序指令与内存。 每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前线程的栈,而指令寄存器IP保存着下一条要执行的指令地址。因此,从线程1切换到线程2时,首先要把SP、IP 寄存器的值为线程1保存下来,再从内存中找出线程2上一次切换前保存好的寄存器值,写入CPU的寄存器,这样就完成了线程切换。(其他寄存器也需要管理、替换,原理与此相同,不再赘述。) 协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态: 从协程1切换到协程2后的状态如下图所示: 创建协程时,会从进程的堆中分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。当然,栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。 由此可见,协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的sleep函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep只会让当前协程休眠,由协程框架在指定时间后唤醒协程。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。 所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。 锁:如何根据业务场景选择合适的锁? 互斥锁与自旋锁:休眠还是“忙等待”? 当你无法判断锁住的代码会执行多久时,应该首选互斥锁,互斥锁是一种独占锁。 如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。自旋锁比互斥锁快得多,因为它通过CPU提供的CAS函数(全称Compare And Swap),在用户态代码中完成加锁与解锁操作。 我们知道,加锁流程包括2个步骤:第1步查看锁的状态,如果锁是空闲的,第2步将锁设置为当前线程持有。 在没有CAS操作前,多个线程同时执行这2个步骤是会出错的。比如线程A执行第1步发现锁是空闲的,但它在执行第2步前,线程B也执行了第1步,B也发现锁是空闲的,于是线程A、B会同时认为它们获得了锁。 CAS函数把这2个步骤合并为一条硬件级指令。这样,第1步比较锁状态和第2步锁变量赋值,将变为不可分割的原子指令。于是,设锁为变量lock,整数0表示锁是空闲状态,整数pid表示线程ID,那么CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid,0)则表示解锁操作。 多线程竞争锁的时候,加锁失败的线程会“忙等待”,直到它拿到锁。什么叫“忙等待”呢?它并不意味着一直执行CAS函数,生产级的自旋锁在“忙等待”时,会与CPU紧密配合,它通过CPU提供的PAUSE指令,减少循环等待时的耗电量;对于单核CPU,忙等待并没有意义,此时它会主动把线程休眠。 如果你对此感兴趣,可以阅读下面这段生产级的自旋锁,看看它是怎么执行“忙等待”的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | while (true) { //因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高 if (lock == 0 && CAS(lock, 0, pid) == 1) return; 45 if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义 for (n = 1; n < 2048; n <<= 1) { //pause的时间,应当越来越长 for (i = 0; i < n; i++) pause(); //CPU专为自旋锁设计了pause指令 if (lock == 0 && CAS(lock, 0, pid)) return; //pause后再尝试获取锁 } } sched_yield(); //单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU 12 } | 如果你能够明确区分出读和写两种场景,可以选择读写锁。 用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。Java中的ReentrantReadWriteLock读写锁,就支持这种排队的公平读写锁。 如何提升TCP三次握手的性能? 当客户端通过发送SYN发起握手时,可以通过tcp_syn_retries控制重发次数。当服务器的SYN半连接队列溢出后,SYN报文会丢失从而导致连接建立失败。我们可以通过netstat -s给出的统计结果判断队列长度是否合适,进而通过tcp_max_syn_backlog参数调整队列的长度。服务器回复SYN+ACK报文的重试次数由tcp_synack_retries参数控制,网络稳定时可以调小它。为了应对 SYN 泛洪攻击,应将 tcp_syncookies 参数设置为1,它仅在 SYN 队列满后开启 syncookie 功能,保证连接成功建立。 服务器收到客户端返回的ACK后,会把连接移入accept队列,等待进程调用accept函数取出连接。如果accept队列溢出,默认系统会丢弃 ACK,也可以通过tcp_abort_on_overflow参数用RST通知客户端连接建立失败。如果netstat统计信息显示,大量的ACK被丢弃后,可以通过listen函数的backlog参数和somaxconn系统参数提高队列上限。 TFO技术绕过三次握手,使得HTTP请求减少了1个RTT的时间。Linux下可以通过tcp_fastopen参数开启该功能。 如何提升TCP三次握手的性能? 如何提升TCP四次挥手的性能? 我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。 如何提升TCP四次挥手的性能? 如何修改TCP缓冲区才能兼顾并发数量与传输速度? 如果你在Linux系统中用free命令查看内存占用情况,会发现一栏叫做buff/cache,它是系统内存,似乎与应用进程无关。但每当进程新建一个TCP连接,buff/cache中的内存都会上升4K左右。而且,当连接传输数据时,就远不止增加4K内存了。 这是因为TCP连接是由内核维护的,内核为每个连接建立的内存缓冲区,既要为网络传输服务,也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小,就无法充分使用网络带宽,TCP传输速度就会很慢;如果连接的内存配置过大,那么服务器内存会很快用尽,新连接就无法建立成功。因此,只有深入理解Linux下TCP内存的用途,才能正确地配置内存大小。 实现高并发服务时,由于必须把大部分内存用在网络传输上,所以除了关注应用内存的使用,还必须关注TCP内核缓冲区的内存使用情况。 TCP使用ACK确认报文实现了可靠性,又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而,默认的滑动窗口最大只能到65KB,要想提升发送速度必须提升滑动窗口的上限,在Linux下是通过设置tcp_window_scaling为1做到的。 滑动窗口定义了飞行报文的最大字节数,当它超过带宽时延积时,就会发生丢包。而当它小于带宽时延积时,就无法让TCP的传输速度达到网络允许的最大值。因此,滑动窗口的设计,必须参考带宽时延积。 内核缓冲区决定了滑动窗口的上限,但我们不能通过socket的SO_SNFBUF等选项直接把缓冲区大小设置为带宽时延积,因为TCP不会一直维持在最高速上,过大的缓冲区会减少并发连接数。Linux带来的缓冲区自动调节功能非常有效,我们应当把缓冲区的上限设置为带宽时延积。其中,发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把tcp_moderate_rcvbuf设置为1来开启,其中调节的依据根据tcp_mem而定。 如何修改TCP缓冲区才能兼顾并发数量与传输速度? 如何调整TCP拥塞控制的性能? 当TCP连接建立成功后,拥塞控制算法就会发生作用,首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口,Linux上可以通过route ip change命令修改它。通常,在带宽时延积较大的网络中,应当调高初始拥塞窗口。 丢包以及重复的ACK都是明确的拥塞信号,此时,发送方就会调低拥塞窗口减速,同时修正慢启动阈值。这样,将来再次到达这个速度时,就会自动进入拥塞避免阶段,用线性速度代替慢启动阶段的指数速度提升窗口大小。 当然,重复ACK意味着发送方可以提前重发丢失报文,快速重传算法定义了这一行为。同时,为了使得重发报文的过程中,发送速度不至于出现断崖式下降,TCP又定义了快速恢复算法,发送方在报文重新变得有序后,结束快速恢复进入拥塞避免阶段。但以丢包作为网络拥塞的信号往往为时已晚,于是以BBR算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变,而网络时延升高时,就说明网络中的缓冲队列出现了积压,这是进行拥塞控制的最好时机。Linux高版本支持BBR算法,你可以通过tcp_congestion_control配置更改拥塞控制算法。 如何调整TCP拥塞控制的性能? 实战:单机如何实现管理百万主机的心跳服务? 实战:单机如何实现管理百万主机的心跳服务? 优化TLS=SSL性能该从何下手? 优化TLS=SSL性能该从何下手? 如何提升HTTP-1.1性能? 如何提升HTTP-1.1性能? HTTP-2是怎样提升性能的? HTTP-2是怎样提升性能的? 一致性哈希:如何高效地均衡负载? 一致性哈希:如何高效地均衡负载? 与程序员相关的SSD性能知识 与程序员相关的SSD性能知识 加餐与分享 留言 鲤鲤鱼:我们集群有一个问题,某一台物理机的CPU会被Hadoop yarn的查询任务打满,并且占用最多的pid在不停的变化,我查看了TIME_WAIT的个数好像也不是很多,在顶峰的时候还没达到一万,能够持续一两个小时。这个问题您有没有什么思路呢? 作者:解决性能问题,一般有两种方法:经验派和“理论”派。前者就是基于自己的经验概率,将能想到的优化方法都试一遍,这种方式通常又有效又快速,但无法解决复杂的问题。而所谓理论派,就是沿着固定的思路,使用二分法,从高至低慢慢下沉到细节。具体到你的问题,我建议你先看看,CPU占用的是用户态还是系统态,用户态的话就要分析代码了,系统态还要进一步分析。火焰图通常是个很好的办法,虽然搭能画火焰图的环境很麻烦,但这种底层方法很有效。 杨文宇:链表的内存地址不连续是如何影响序列化的?老师能具体说一下吗? 作者:当数组外还有链表中的元素时,序列化就必须遍历所有元素,比如,至少要做1次循环,把每1个遍历到的元素的值,序列化写入至另一段内存中。而使用闭散列时,可以直接将这个数组占用的内存作为序列化后的数据。 helloworld:“第二,读取磁盘数据时,需要先找到数据所在的位置,对于机械磁盘来说,就是旋转磁头到数据所在的扇区,再开始顺序读取数据。其中,旋转磁头耗时很长,为了降低它的影响,PageCache使用了预读功能。”那是不是使用SSD这类固态硬盘(不用旋转磁头),PageCache就没有很大的影响? 作者:对的!其实,当下的操作系统对SSD磁盘的支持还不够,当SSD广泛应用时,文件系统还需要跟上,得获得很大的性能提升才可以。 Robust:“然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。”这里的出错应该表示一些特殊的错误吧,或者是说和共享内存有关的错误,比如申请不到内存等。老师,我理解得没错吧? 作者:这里指无法恢复的错误,不仅是内存申请错误,比如访问已经释放的资源,且没有捕获异常或者无法捕获异常,进而操作系统只能杀死线程时,进程里的其它线程也会被杀死。 范闲:用户态的协程不能用互斥或者自旋,会进入内核态与其设计初衷相悖,Python里面用的yield。 作者:是的,用户态协程需要用户态的代码将锁重新实现一遍,其中实现时不能用到内核提供的系统调用。 Geek_007:看评论区,很多同学都说是长连接,普通的HTTP keep-alive会不会有坑,三大运营商或者中间网络设备都会将超过一定时间的链接drop掉。如果没有H2这种ping保活的机制,有可能客户端长链接莫名其妙的就被drop掉,客户端只能依赖超时来感知异常,反倒是影响性能了。 作者:是的,不只网络设备,一些代理服务器为了减轻自己的负担,也会把长连接断掉,比如Nginx默认关闭75秒没有数据交互的keep-alive长连接。 大咖助场 场景1:失败引发轮询 案例 在使用Apache HttpClient发送HTTP请求时,稍有经验的程序员都知道去控制下超时时间,这样,在连接不上服务器或者服务器无响应时,响应延时都会得到有效的控制,例如我们会使用下面的代码来配置HttpClient: 1 2 3 4 5 | RequestConfig requestConfig = RequestConfig.custom(). setConnectTimeout(2 * 1000). //控制连接建立时间 setConnectionRequestTimeout(1 * 1000). //控制获取连接时间 setSocketTimeout(3 * 1000). //控制“读取”数据等待时间 build(); | 以上面的代码为例,你能估算出响应时间最大是多少么?上面的代码实际设置了三个参数,是否直接相加就能计算出最大延时时间?即所有请求100%控制在6秒。 先不说结论,通过实际的生产线观察,我们确实发现大多符合我们的预期:可以说99.9%的响应都控制在6秒以内,但是总有一些“某年某月某天”,发现有一些零星的请求甚至超过了10秒,这又是为什么? 解析 经过问题跟踪,我们发现我们访问的URL是一个下游服务的域名(大多如此,并不稀奇),而这个域名本身有点特殊,由于负载均衡等因素的考虑,我们将它绑定到了多个IP地址。所以假设这些IP地址中,一些IP地址指向的服务临时不服务时,则会引发轮询,即轮询其它IP地址直到最终成功或失败,而每一次轮询中的失败都会额外增加一倍ConnectTimeout,所以我们发现超过6秒甚至10秒的请求也不稀奇了。我们可以通过HttpClient的源码来验证下这个逻辑(参考org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect方法): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | public void connect( final ManagedHttpClientConnection conn, final HttpHost host, final InetSocketAddress localAddress, final int connectTimeout, final SocketConfig socketConfig, final HttpContext context) throws IOException { final Lookup registry = getSocketFactoryRegistry( final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName()); //域名解析,可能解析出多个地址 final InetAddress[] addresses = host.getAddress() != null ? new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve final int port = this.schemePortResolver.resolve(host); //对于解析出的地址,进行连接,如果中途有失败,尝试下一个 for (int i = 0; i < addresses.length; i++) { final InetAddress address = addresses[i]; final Boolean last = i == addresses.length - 1; Socket sock = sf.createSocket(context); conn.bind(sock); final InetSocketAddress remoteAddress = new InetSocketAddress(address, if (this.log.isDebugEnabled()) { this.log.debug("Connecting to " + remoteAddress); } try { //使用解析出的地址执行连接 sock = sf.connectSocket( connectTimeout, sock, host, remoteAddress, localAddress, c conn.bind(sock); if (this.log.isDebugEnabled()) { this.log.debug("Connection established " + conn); } //如果连接成功,则直接退出,不继续尝试其它地址 return; } catch (final SocketTimeoutException ex) { if (last) { throw new ConnectTimeoutException(ex, host, addresses); } } catch (final ConnectException ex) { if (last) { //如果连接到最后一个地址,还是失败,则抛出异常。如果不是最后一个 final String msg = ex.getMessage(); if ("Connection timed out".equals(msg)) { throw new ConnectTimeoutException(ex, host, addresses); } else { throw new HttpHostConnectException(ex, host, addresses); } } &#… ### ElasticSearch的一些限制及推荐配置 - URL: https://jiankunking.com/elasticsearch-limits-and-recommended-configurations.html - Content type: original - Published: 2021-12-22 - Updated: 2021-12-22 - Summary: 整理Elasticsearch的使用限制和推荐配置,包括数组大小、文档限制、分片数量等关键参数的最佳实践。 - Categories: Elasticsearch - Tags: Elasticsearch, 限制, 推荐, 配置 Article text: 文章速览 整理Elasticsearch的使用限制和推荐配置,包括数组大小、文档限制、分片数量等关键参数的最佳实践。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 整理Elasticsearch的使用限制和推荐配置,包括数组大小、文档限制、分片数量等关键参数的最佳实践。 限制 数组字段,数组大小无限制。 There is no hard limit but it’s definitely recommended to keep those arrays “reasonable”. When performing an update, Elasticsearch needs to fetch the entire doc, apply the update, then index the updated document and replicate the entire updated document to the replica, so very large arrays would come with a performance penalty indeed. 文档大小限制:2GB Given that the default http.max_content_length is set to 100MB, Elasticsearch will refuse to index any document that is larger than that. You might decide to increase that particular setting, but Lucene still has a limit of about 2GB. https://stackoverflow.com/questions/28841221/what-is-the-maximum-elasticsearch-document-size/28841656#28841656 https://www.elastic.co/guide/en/elasticsearch/reference/current/general-recommendations.html 单字段大小限制 未找到资料,感觉整个文档不超就好 推荐配置 模板参考配置 从ES 5.x开始,索引级设置需要写在模板中,或者在创建索引时指定,我们把各个索引通用的配置写到了模板中,这个模板匹配全部的索引,并且具有最低的优先级,让用户定义的模板有更高的优先级,以覆盖这个模板中的配置。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | { "template": "*", "order": 0, "settings": { "index.merge.policy.max_merged_segment": "2gb", "index.merge.policy.segments_per_tier": "24", "index.number_of_replicas": "1", "index.number_of_shards": "24", "index.optimize_auto_generated_id": "true", "index.refresh_interval": "120s", "index.translog.durability": "async", "index.translog.flush_threshold_size": "1000mb", "index.translog.sync_interval": "120s", "index.unassigned.node_left.delayed_timeout": "5d" } } | 避免热索引分片不均 默认情况下,ES的分片均衡策略是尽量保持各个节点分片数量大致相同。但是当集群扩容时,新加入集群的节点没有分片,此时新创建的索引分片会集中在新节点上,这导致新节点拥有太多热点数据,该节点可能会面临巨大的写入压力。因此,对于一个索引的全部分片,我们需要控制单个节点上存储的该索引的分片总数,使索引分片在节点上分布得更均匀一些。 例如,10个节点的集群,索引主分片数为5,副本数量为1,那么平均下来每个节点应该有(5×2)/10=1个分片,考虑到节点故障、分片迁移的情况,可以设置节点分片总数为2: 1 2 3 4 5 6 7 | curl --location --request PUT 'http://127.0.0.1:9200/myindex/_settings' \ --header 'Content-Type: application/json' \ --data '{ "index": { "routing.allocation.total_shards_per_node": "2" } }' | 延时分片分配策略(节点离开延迟分片分配) 当节点出于任何原因(人为原因或系统异常)离开集群时,主节点会做出以下反应(如下称为步骤 X 是方便后续的解读): - 步骤1:将副本分片提升为主分片以替换节点上的任何主分片。 - 步骤2:分配副本分片以替换丢失的副本(在有足够的节点的前提下)。 - 步骤3:在其余节点之间均匀地重新平衡分片。 以上操作的好处是:避免集群数据丢失,确保集群高可用。 但可能带来的副作用也非常明显:其一,会给集群带来额外的负载(分片分配非常耗费系统资源);其二,若离开集群的节点很快返回,上述机制的必要性就有待商榷。 此时,延迟分片分配就显得非常必要,设置如下: 1 2 3 4 5 6 | PUT _all/_settings { "settings": { "index.unassigned.node_left.delayed_timeout": "5m" } } | 延时分片分配策略的本质: 当节点离开集群并确认几分钟(自己设定)可以快速上线的情况下,离开的过程中只触发步骤1的将离开节点上的对应的副本分片提升为主分片。此时集群至少不是red 状态,而是yellow状态。步骤2、步骤3不会发生,此时集群是可用的,待设定的几分钟内下线集群确保重新上线后,分片再重新转为副本分片,此时集群恢复绿色状态。 这个过程有效避免了步骤2、步骤3的分片分配,整体上以最短的时间确保了集群的高可用性。 https://www.elastic.co/guide/en/elasticsearch/reference/current/delayed-allocation.html 每个节点将被恢复的并发分片数 1 2 3 4 5 6 | PUT _cluster/settings { "transient": { "cluster.routing.allocation.node_concurrent_recoveries": 3 } } | 以上配置:node_concurrent_incoming_recoveries和 node_concurrent_outgoing_recoveries同时生效。 incoming_recoverie:通常是其他节点上的副本 shard 恢复到该节点上outgoing_recoveries:通常是当前节点上的主分片 shard 恢复副本分片到其他节点上。 https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cluster.html ### [转]寻找一种易于理解的一致性算法(扩展版) - URL: https://jiankunking.com/raft-zh_cn.html - Content type: translation - Published: 2021-12-17 - Updated: 2021-12-17 - Summary: 转载 Raft 一致性算法扩展论文中文译文,介绍领导者选举、日志复制、安全性和成员变更机制。 - Categories: Distributed - Tags: Raft, Consensus, Leader-Election, Log-Replication - Original source: https://github.com/maemual/raft-zh_cn/blob/master/raft-zh_cn.md The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]Git规范的必要性 - URL: https://jiankunking.com/the-need-for-git-specification.html - Content type: repost - Published: 2021-12-12 - Updated: 2021-12-12 - Summary: 介绍建立 Git 规范的必要性,涵盖分支管理、提交记录、语义化版本和 Git-Flow 工作流程。 - Categories: Git - Tags: Git - Original source: https://github.com/rfyiamcool/notes/blob/main/git.md The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [译]ElasticSearch中如何处理关联数据? - URL: https://jiankunking.com/managing-relations-inside-elasticsearch.html - Content type: translation - Published: 2021-12-11 - Updated: 2021-12-11 - Summary: 翻译介绍 Elasticsearch 处理关联数据的多种方式,对比对象、Nested、Parent/Child 和反规范化模型。 - Categories: Elasticsearch - Tags: Elasticsearch, Nested, Parent/Child, Denormalization - Original source: https://www.elastic.co/cn/blog/managing-relations-inside-elasticsearch The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 友爱的Sentry - URL: https://jiankunking.com/fraternity-sentry.html - Content type: original - Published: 2021-11-17 - Updated: 2021-11-17 - Summary: 记录 Sentry 使用 Redis 时出现 OOM 的现象、社区讨论,以及 Sentry 在 Redis 中存储的数据类型。 - Categories: Sentry - Tags: Redis, Sentry, OOM Article text: 文章速览 记录 Sentry 使用 Redis 时出现 OOM 的现象、社区讨论,以及 Sentry 在 Redis 中存储的数据类型。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Sentry What do you want Sentry to do in this case? Turn itself off? 最近Sentry的Redis一直报内存占用高,看Sentry日志能看到: 1 | OOM command not allowed when used memory > 'maxmemory' | 然后上网看了一下,发现了几个有意思的回复: 感觉Sentry社区很“友爱”,就是硬钢。 那么,Sentry Redis中存储的都是什么内容呢? 截图来源: https://github.com/getsentry/sentry/issues/1183 https://github.com/getsentry/sentry/issues/13785 https://forum.sentry.io/t/redis-hitting-oom/2810 ### [转]7.7 版本中的新改进:显著降低 Elasticsearch 堆内存使用量 - URL: https://jiankunking.com/significantly-decrease-your-elasticsearch-heap-memory-usage.html - Content type: repost - Published: 2021-09-04 - Updated: 2021-09-04 - Summary: 介绍 Elasticsearch 7.7 降低堆内存占用的改进,以及索引规模、字段数量和集群容量之间的关系。 - Categories: Elasticsearch - Tags: Elasticsearch, Index, Heap - Original source: https://www.elastic.co/cn/blog/significantly-decrease-your-elasticsearch-heap-memory-usage The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 怎样在焦虑的情况下表现更好 - URL: https://jiankunking.com/how-to-perform-better-in-anxious-situations.html - Content type: repost - Published: 2021-08-10 - Updated: 2021-08-10 - Summary: 每个人都想在生活中做到最好。但是,一个人越是想表现好,压力就会越大,焦虑情绪也越严重。本文介绍了几个策略,帮你借助焦虑来提高自己的表现。 - Categories: Life - Tags: 焦虑 - Original source: https://www.psychologytoday.com/us/blog/hack-your-anxiety/201812/7-ways-to-use-anxiety-to-improve-performance - Original author: Alicia H. Clark, Psy.D. The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 【Elasticsearch源码】 节点关闭分析 - URL: https://jiankunking.com/elasticsearch-node-stop-source-code-analysis.html - Content type: original - Published: 2021-06-13 - Updated: 2021-06-13 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析节点关闭流程、资源释放顺序和相关生命周期管理。 - Categories: Elasticsearch - Tags: Elasticsearch, Node, 源码 Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析节点关闭流程、资源释放顺序和相关生命周期管理。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第六篇:Elasticsearch 节点关闭分析 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于节点关闭流程疑惑的点: - 节点关闭都做了哪些检查? - kill ES进程来关闭节点是否安全? - 普通节点关闭与Master节点关闭有什么区别? - 正在写入数据的节点,在关闭的时候,会发生什么? 源码分析 在节点启动过程中,Bootstrap#setup方法中添加了shutdown hook,当进程收到系统SIGTERM(kill命令默认信号 15)或SIGINT(2)信号时,调用Node#close方法,执行节点关闭流程。 https://stackoverflow.com/questions/4042201/how-does-sigint-relate-to-the-other-termination-signals-such-as-sigterm-sigquit 每个模块的Service中都实现了doStop和doClose,用于处理这个模块的正常关闭流程。节点总的关闭流程位于Node#close,在close方法的实现中,先调用一遍各个模块的doStop,然后再次遍历各个模块执行doClose。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | // During concurrent close() calls we want to make sure that all of them return after the node has completed it's shutdown cycle. // If not, the hook that is added in Bootstrap#setup() will be useless: // close() might not be executed, in case another (for example api) call to close() has already set some lifecycles to stopped. // In this case the process will be terminated even if the first call to close() has not finished yet. @Override public synchronized void close() throws IOException { synchronized (lifecycle) { if (lifecycle.started()) { stop(); } if (!lifecycle.moveToClosed()) { return; } } logger.info("closing ..."); // 关闭各种服务 List toClose = new ArrayList<>(); StopWatch stopWatch = new StopWatch("node_close"); toClose.add(() -> stopWatch.start("node_service")); toClose.add(nodeService); toClose.add(() -> stopWatch.stop().start("http")); toClose.add(injector.getInstance(HttpServerTransport.class)); toClose.add(() -> stopWatch.stop().start("snapshot_service")); toClose.add(injector.getInstance(SnapshotsService.class)); toClose.add(injector.getInstance(SnapshotShardsService.class)); toClose.add(injector.getInstance(RepositoriesService.class)); toClose.add(() -> stopWatch.stop().start("client")); Releasables.close(injector.getInstance(Client.class)); toClose.add(() -> stopWatch.stop().start("indices_cluster")); toClose.add(injector.getInstance(IndicesClusterStateService.class)); toClose.add(() -> stopWatch.stop().start("indices")); toClose.add(injector.getInstance(IndicesService.class)); // close filter/fielddata caches after indices toClose.add(injector.getInstance(IndicesStore.class)); toClose.add(injector.getInstance(PeerRecoverySourceService.class)); toClose.add(() -> stopWatch.stop().start("cluster")); toClose.add(injector.getInstance(ClusterService.class)); toClose.add(() -> stopWatch.stop().start("node_connections_service")); toClose.add(injector.getInstance(NodeConnectionsService.class)); toClose.add(() -> stopWatch.stop().start("discovery")); toClose.add(injector.getInstance(Discovery.class)); toClose.add(() -> stopWatch.stop().start("monitor")); toClose.add(nodeService.getMonitorService()); toClose.add(() -> stopWatch.stop().start("fsHealth")); toClose.add(injector.getInstance(FsHealthService.class)); toClose.add(() -> stopWatch.stop().start("gateway")); toClose.add(injector.getInstance(GatewayService.class)); toClose.add(() -> stopWatch.stop().start("search")); toClose.add(injector.getInstance(SearchService.class)); toClose.add(() -> stopWatch.stop().start("transport")); toClose.add(injector.getInstance(TransportService.class)); for (LifecycleComponent plugin : pluginLifecycleComponents) { toClose.add(() -> stopWatch.stop().start("plugin(" + plugin.getClass().getName() + ")")); toClose.add(plugin); } toClose.addAll(pluginsService.filterPlugins(Plugin.class)); toClose.add(() -> stopWatch.stop().start("script")); toClose.add(injector.getInstance(ScriptService.class)); toClose.add(() -> stopWatch.stop().start("thread_pool")); toClose.add(() -> injector.getInstance(ThreadPool.class).shutdown()); // Don't call shutdownNow here, it might break ongoing operations on Lucene indices. // See https://issues.apache.org/jira/browse/LUCENE-7248. We call shutdownNow in // awaitClose if the node doesn't finish closing within the specified time. toClose.add(() -> stopWatch.stop().start("gateway_meta_state")); toClose.add(injector.getInstance(GatewayMetaState.class)); toClose.add(() -> stopWatch.stop().start("node_environment")); toClose.add(injector.getInstance(NodeEnvironment.class)); toClose.add(stopWatch::stop); if (logger.isTraceEnabled()) { toClose.add(() -> logger.trace("Close times for each service:\n{}", stopWatch.prettyPrint())); } IOUtils.close(toClose); logger.info("closed"); } private Node stop() { if (!lifecycle.moveToStopped()) { return this; } logger.info("stopping ..."); injector.getInstance(ResourceWatcherService.class).close(); injector.getInstance(HttpServerTransport.class).stop(); injector.getInstance(SnapshotsService.class).stop(); injector.getInstance(SnapshotShardsService.class).stop(); injector.getInstance(RepositoriesService.class).stop(); // stop any changes happening as a result of cluster state changes injector.getInstance(IndicesClusterStateService.class).stop(); // close discovery early to not react to pings anymore. // This can confuse other nodes and delay things - mostly if we're the master and we're running tests. injector.getInstance(Discovery.class).stop(); // we close indices first, so operations won't be allowed on it injector.getInstance(ClusterService.class).stop(); injector.getInstance(NodeConnectionsService.class).stop(); injector.getInstance(FsHealthService.class).stop(); nodeService.getMonitorService().stop(); injector.getInstance(GatewayService.class).stop(); injector.getInstance(SearchService.class).stop(); injector.getInstance(TransportService.class).stop(); pluginLifecycleComponents.forEach(LifecycleComponent::stop); // we should stop this last since it waits for resources to get released // if we had scroll searchers etc or recovery going on we wait for to finish. injector.getInstance(IndicesService.class).stop(); logger.info("stopped"); return this; } | 各模块的关闭有一定的顺序关系,以 doStop 为例,按下表所示的 顺序调用各模块 doStop方法。 服务 简介 ResourceWatcherService | 通用资源监视服务 | HttpServerTransport | HTTP 传输服务,提供REST接口服务 | SnapshotsService | 快照服务 | SnapshotShardsService | 负责启动和停止shard级快照 | RepositoriesService | Service responsible for maintaining and providing access to snapshot repositories on nodes | IndicesClusterStateService | 收到集群状态信息后,处理其中索引相关操作 | Discovery | 集群拓扑管理 | ClusterService | 集群管理服务,主要处理集群任务,发布集群状态 | NodeConnectionsService | 节点连接管理服务 | FsHealthService | Runs periodically and attempts to create a temp file to see if the filesystem is writable. If not then it marks the path as unhealthy | MonitorService | 提供进程级、系统级、文件系统和 JVM 的监控服务 | GatewayService | 负责集群元数据持久化与恢复 | SearchService | 处理搜索请求 | TransportService | 底层传输服务 | plugins | 当前的所有插件 | IndicesService | 负责创建、删除索引等索引操作 | 综合来看,关闭顺序大致如下∶ - 关闭快照和HTTPServer,不再响应用户REST请求。 - 关闭集群拓扑管理,不再响应ping请求。 - 关闭网络模块,让节点离线。 - 执行各个插件的关闭流程。 - 关闭IndicesService。 最后才关闭IndicesService,是因为这期间需要等待释放的资源最多,时间最长。 下面着重看一下IndicesService的doStop: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | @Override protected void doStop() { clusterService.removeApplier(timestampFieldMapperService); timestampFieldMapperService.doStop(); ThreadPool.terminate(danglingIndicesThreadPoolExecutor, 10, TimeUnit.SECONDS); ExecutorService indicesStopExecutor = Executors.newFixedThreadPool(5, daemonThreadFactory(settings, "indices_shutdown")); // Copy indices because we modify it asynchronously in the body of the loop final Set indices = this.indices.values().stream().map(s -> s.index()).collect(Collectors.toSet()); final CountDownLatch latch = new CountDownLatch(indices.size()); for (final Index index : indices) { indicesStopExecutor.execute(() -> { try { removeIndex(index, IndexRemovalReason.SHUTDOWN, "shutdown"); } finally { latch.countDown(); } }); } try { // 注意shardsClosedTimeout 这个值是在IndicesService的构造函数中初始化的 // this.shardsClosedTimeout = settings.getAsTime(INDICES_SHARDS_CLOSED_TIMEOUT, new TimeValue(1, TimeUnit.DAYS)); // 也就是说 CountDownLatch.await默认1天才会继续后面的流程 if (latch.await(shardsClosedTimeout.seconds(), TimeUnit.SECONDS) == false) { logger.warn("Not all shards are closed yet, waited {}sec - stopping service", shardsClosedTimeout.seconds()); } } catch (InterruptedException e) { // ignore } finally { indicesStopExecutor.shutdown(); } } | 那什么时候会导致removeIndex执行一直无法返回呢? 1 2 3 4 5 6 | IndicesService removeIndex(final Index index, final IndexRemovalReason reason, final String extraInfo)=> IndexService close(final String reason, boolean delete)=> IndexService removeShard(int shardId, String reason)=> IndexService removeShard(String reason, ShardId sId, IndexShard indexShard, Store store, IndexEventListener listener)=> IndexShard close(String reason, boolean flushEngine)=> Engine flushAndClose()=> | 下面具体看一下flushAndClose(): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /** * Flush the engine (committing segments to disk and truncating the * translog) and close it. */ public void flushAndClose() throws IOException { if (isClosed.get() == false) { logger.trace("flushAndClose now acquire writeLock"); // 可以看一下: // https://github.com/jiankunking/elasticsearch/blob/master/server/src/main/java/org/elasticsearch/index/engine/InternalEngine.java#L857 // 由于写入操作已经加了读锁,此时写锁会等待,直到写入执行完毕。 // 因此数据写入过程不会被中断。但是由于网络模块被关闭,客户端的连接会被断开。 // 客户端应当作为失败处理,虽然ES服务端的写流程还在继续。 try (ReleasableLock lock = writeLock.acquire()) { logger.trace("flushAndClose now acquired writeLock"); try { logger.debug("flushing shard on close - this might take some time to sync files to disk"); try { // TODO we might force a flush in the future since we have the write lock already even though recoveries // are running. flush(); } catch (AlreadyClosedException ex) { logger.debug("engine already closed - skipping flushAndClose"); } } finally { close(); // double close is not a problem } } } awaitPendingClose(); } | 总结 kill -15/-2 ElasticSearch是可以正常退出的。 正在写入数据的节点,在关闭的时候,需要等待数据写入完成或者超时。 主节点被关闭时,没有想象中的特殊处理,节点正常执行关闭流程,当TransportService模块被关闭后,集群重新选举新Master。因此,滚动重启期间会有一段时间处于无主状态。 该部分等看集群选举部分的时候,再细看一下。 ### 【Elasticsearch源码】 节点启动分析 - URL: https://jiankunking.com/elasticsearch-node-start-source-code-analysis.html - Content type: original - Published: 2021-06-12 - Updated: 2021-06-12 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析节点启动流程、核心组件初始化和服务启动顺序。 - Categories: Elasticsearch - Tags: Elasticsearch, Node, 源码 Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析节点启动流程、核心组件初始化和服务启动顺序。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第五篇:Elasticsearch 节点启动分析 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于节点启动流程疑惑的点: - 节点启动都做了哪些检查? - 节点启动都初始化了哪些内容? - 当节点启动后,数据迁移是在哪里处理? 源码分析 先从启动脚本中找到启动类的入口:org.elasticsearch.bootstrap.Elasticsearch。 下面看一下org.elasticsearch.bootstrap.Elasticsearch,先看一下主入口函数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | /** * Main entry point for starting elasticsearch */ public static void main(final String[] args) throws Exception { // 根据jvm.options中读取:es.networkaddress.cache.ttl和es.networkaddress.cache.negative.ttl // 并覆盖JVM Security中的networkaddress.cache.ttl与networkaddress.cache.negative.ttl overrideDnsCachePolicyProperties(); /* * We want the JVM to think there is a security manager installed so that if internal policy decisions that would be based on the * presence of a security manager or lack thereof act as if there is a security manager present (e.g., DNS cache policy). This * forces such policies to take effect immediately. */ System.setSecurityManager(new SecurityManager() { @Override public void checkPermission(Permission perm) { // grant all permissions so that we can later set the security manager to the one that we want } }); LogConfigurator.registerErrorListener(); final Elasticsearch elasticsearch = new Elasticsearch(); // 核心检查处理都在main(final String[] args, final Elasticsearch elasticsearch, final Terminal terminal)方法中 int status = main(args, elasticsearch, Terminal.DEFAULT); if (status != ExitCodes.OK) { final String basePath = System.getProperty("es.logs.base_path"); // It's possible to fail before logging has been configured, in which case there's no point // suggesting that the user look in the log file. if (basePath != null) { Terminal.DEFAULT.errorPrintln( "ERROR: Elasticsearch did not exit normally - check the logs at " + basePath + System.getProperty("file.separator") + System.getProperty("es.logs.cluster_name") + ".log" ); } exit(status); } } | main的处理逻辑如下: 1 2 3 4 5 6 7 8 9 10 | Elasticsearch main(final String[] args)=> Elasticsearch main(final String[] args, final Elasticsearch elasticsearch, final Terminal terminal)=> Command main(String[] args, Terminal terminal)=> EnvironmentAwareCommand execute(Terminal terminal, OptionSet options)=> Elasticsearch execute(Terminal terminal, OptionSet options, Environment env)=> Bootstrap static void init( final boolean foreground, final Path pidFile, final boolean quiet, final Environment initialEnv)=> | 下面看一下Bootstrap.init 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | /** * This method is invoked by {@link Elasticsearch#main(String[])} to startup elasticsearch. */ static void init( final boolean foreground, final Path pidFile, final boolean quiet, final Environment initialEnv) throws BootstrapException, NodeValidationException, UserException { // force the class initializer for BootstrapInfo to run before // the security manager is installed BootstrapInfo.init(); INSTANCE = new Bootstrap(); final SecureSettings keystore = loadSecureSettings(initialEnv); final Environment environment = createEnvironment(pidFile, keystore, initialEnv.settings(), initialEnv.configFile()); // the LogConfigurator will replace System.out and System.err with redirects to our logfile, so we need to capture // the stream objects before calling LogConfigurator to be able to close them when appropriate final Runnable sysOutCloser = getSysOutCloser(); final Runnable sysErrorCloser = getSysErrorCloser(); LogConfigurator.setNodeName(Node.NODE_NAME_SETTING.get(environment.settings())); try { LogConfigurator.configure(environment); } catch (IOException e) { throw new BootstrapException(e); } if (environment.pidFile() != null) { try { PidFile.create(environment.pidFile(), true); } catch (IOException e) { throw new BootstrapException(e); } } try { final boolean closeStandardStreams = (foreground == false) || quiet; if (closeStandardStreams) { final Logger rootLogger = LogManager.getRootLogger(); final Appender maybeConsoleAppender = Loggers.findAppender(rootLogger, ConsoleAppender.class); if (maybeConsoleAppender != null) { Loggers.removeAppender(rootLogger, maybeConsoleAppender); } sysOutCloser.run(); } // fail if somebody replaced the lucene jars // 检查 Lucene 版本,ES 各个版本对使用的 Lucene 版本是有要求的 // 在这里检查Lucene版本以防止有人替换不兼容的jar包。 checkLucene(); // install the default uncaught exception handler; must be done before security is // initialized as we do not want to grant the runtime permission // setDefaultUncaughtExceptionHandler // 会根据不同的异常,设置不同的exit code // InternalError 128 // OutOfMemoryError 127 // StackOverflowError 126 // UnknownError 125 // IOError 124 // 其它 1 Thread.setDefaultUncaughtExceptionHandler(new ElasticsearchUncaughtExceptionHandler()); // 检查启动es的用户 // 检查JNA(系统调用) // 检查MEMORY_LOCK // 检查MaxNumberOfThreads // 检查MaxSizeVirtualMemory // 检查MaxFileSize // init lucene random seed // 注册JVM addShutdownHook(Node退出的时候,会用到) // 检查jar冲突 // 初始化JVM Security // Node实例添加validateNodeBeforeAcceptingRequests,并初始化Node实例。 INSTANCE.setup(true, environment); try { // any secure settings must be read during node construction IOUtils.close(keystore); } catch (IOException e) { throw new BootstrapException(e); } // 1、开始启动各子模块。 // 子模块在Node类中创建、启动 // 子模块的start方法基本就是初始化内部数据、创建线程池、启动线程池等操作。 // 2、调用keepAliveThread.start()方法启动keepalive线程,线程本身不做具体的工作。 // 主线程执行完启动流程后会退出,keepalive线程是唯一的用户线程, // 作用是保持进程运行。在Java程序中,至少要有一个用户线程。当用户线程数为零时退出进程。 INSTANCE.start(); // We don't close stderr if `--quiet` is passed, because that // hides fatal startup errors. For example, if Elasticsearch is // running via systemd, the init script only specifies // `--quiet`, not `-d`, so we want users to be able to see // startup errors via journalctl. if (foreground == false) { sysErrorCloser.run(); } } catch (NodeValidationException | RuntimeException e) { // disable console logging, so user does not see the exception twice (jvm will show it already) final Logger rootLogger = LogManager.getRootLogger(); final Appender maybeConsoleAppender = Loggers.findAppender(rootLogger, ConsoleAppender.class); if (foreground && maybeConsoleAppender != null) { Loggers.removeAppender(rootLogger, maybeConsoleAppender); } Logger logger = LogManager.getLogger(Bootstrap.class); // HACK, it sucks to do this, but we will run users out of disk space otherwise if (e instanceof CreationException) { // guice: log the shortened exc to the log file ByteArrayOutputStream os = new ByteArrayOutputStream(); PrintStream ps = null; try { ps = new PrintStream(os, false, "UTF-8"); } catch (UnsupportedEncodingException uee) { assert false; e.addSuppressed(uee); } new StartupException(e).printStackTrace(ps); ps.flush(); try { logger.error("Guice Exception: {}", os.toString("UTF-8")); } catch (UnsupportedEncodingException uee) { assert false; e.addSuppressed(uee); } } else if (e instanceof NodeValidationException) { logger.error("node validation exception\n{}", e.getMessage()); } else { // full exception logger.error("Exception", e); } // re-enable it if appropriate, so they can see any logging during the shutdown process if (foreground && maybeConsoleAppender != null) { Loggers.addAppender(rootLogger, maybeConsoleAppender); } throw e; } } | 下面看一下Node实例初始化及启动部分: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952… ### 【Elasticsearch源码】 GET分析 - URL: https://jiankunking.com/elasticsearch-get-source-code-analysis.html - Content type: original - Published: 2021-04-11 - Updated: 2021-04-11 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析 GET 请求的处理链路、节点路由与文档读取过程。 - Categories: Elasticsearch - Tags: Elasticsearch, 源码, GET Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析 GET 请求的处理链路、节点路由与文档读取过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第四篇:Elasticsearch GET 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 通过前3篇的学习,可以稍微总结一下Elasticsearch: - ES是一个集群,所以每个Node都需要和其他的Nodes 进行交互,这些交互是通过NodeClient来完成。 - ES中RPC、HTTP请求都是基于Netty自行封装的: - NettyTransport 对应RPC协议支持 - NettyHttpServerTransport 则对应HTTP协议支持 - Transport*Action 是比较核心的类集合: - Action -> Transport*Action - TransportAction -> TransportHandler(即使是本地Node也会通过发请求的方式,将处理转发到TransportHandler处理) - 真实干活的Transport*Action类(或者其父类)中doExecute(……) 目的 在看源码之前先梳理一下,自己对于GET流程疑惑的点: - 是不是根据Document _id通过hash找到对应的Shard? - 根据Document _id查询如何做到实时可见的? - 单个shard查询失败会不会查询副本? 源码分析 第二部分是代码分析的过程,不想看的朋友可以跳过直接看第三部分总结。 通过搜索/{index}/_doc/{id}可以找到RestGetAction,找到RestGetAction再加上前面的总结,其实就知道真实干活的是TransportGetAction。 在TransportGetAction的父类TransportSingleShardAction中找到了doExecute: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | @Override protected void doExecute(Task task, Request request, ActionListener listener) { new AsyncSingleAction(request, listener).start(); } // TransportSingleShardAction的AsyncSingleAction中 private AsyncSingleAction(Request request, ActionListener listener) { this.listener = listener; ClusterState clusterState = clusterService.state(); if (logger.isTraceEnabled()) { logger.trace("executing [{}] based on cluster state version [{}]", request, clusterState.version()); } // 集群nodes列表 nodes = clusterState.nodes(); ClusterBlockException blockException = checkGlobalBlock(clusterState); if (blockException != null) { throw blockException; } String concreteSingleIndex; if (resolveIndex(request)) { concreteSingleIndex = indexNameExpressionResolver.concreteSingleIndex(clusterState, request).getName(); } else { concreteSingleIndex = request.index(); } this.internalRequest = new InternalRequest(request, concreteSingleIndex); // TransportGetAction中resolveRequest // 解析请求,更新指定routing resolveRequest(clusterState, internalRequest); blockException = checkRequestBlock(clusterState, internalRequest); if (blockException != null) { throw blockException; } // 根据路由算法获取目标shard的迭代器或者根据优先级获选择目标节点 this.shardIt = shards(clusterState, internalRequest); } // TransportGetAction中 @Override protected ShardIterator shards(ClusterState state, InternalRequest request) { return clusterService.operationRouting() .getShards(clusterService.state(), request.concreteIndex(), request.request().id(), request.request().routing(), request.request().preference()); } | 下面看一下OperationRouting中的getShards(……)看一下是如何获取到具体的shardId的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | public ShardIterator getShards(ClusterState clusterState, String index, String id, @Nullable String routing, @Nullable String preference) { return preferenceActiveShardIterator(shards(clusterState, index, id, routing), clusterState.nodes().getLocalNodeId(), clusterState.nodes(), preference, null, null); } protected IndexShardRoutingTable shards(ClusterState clusterState, String index, String id, String routing) { int shardId = generateShardId(indexMetadata(clusterState, index), id, routing); return clusterState.getRoutingTable().shardRoutingTable(index, shardId); } public static int generateShardId(IndexMetadata indexMetadata, @Nullable String id, @Nullable String routing) { final String effectiveRouting; final int partitionOffset; // routing参数解析可以参考具体的文档 // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html if (routing == null) { assert(indexMetadata.isRoutingPartitionedIndex() == false) : "A routing value is required for gets from a partitioned index"; effectiveRouting = id; } else { effectiveRouting = routing; } if (indexMetadata.isRoutingPartitionedIndex()) { partitionOffset = Math.floorMod(Murmur3HashFunction.hash(id), indexMetadata.getRoutingPartitionSize()); } else { // we would have still got 0 above but this check just saves us an unnecessary hash calculation partitionOffset = 0; } return calculateScaledShardId(indexMetadata, effectiveRouting, partitionOffset); } private static int calculateScaledShardId(IndexMetadata indexMetadata, String effectiveRouting, int partitionOffset) { final int hash = Murmur3HashFunction.hash(effectiveRouting) + partitionOffset; // we don't use IMD#getNumberOfShards since the index might have been shrunk such that we need to use the size // of original index to hash documents return Math.floorMod(hash, indexMetadata.getRoutingNumShards()) / indexMetadata.getRoutingFactor(); } | 到这里可以知道ES就是通过Document _id hash找到对应的shard。 下面看一下是如何做到实时可见的? 数据节点接收协调节点请求的入口为:TransportSingleShardAction.ShardTransportHandler# messageReceived: 1 2 3 4 5 6 7 | @Override public void messageReceived(final Request request, final TransportChannel channel, Task task) throws Exception { if (logger.isTraceEnabled()) { logger.trace("executing [{}] on shard [{}]", request, request.internalShardId); } asyncShardOperation(request, request.internalShardId, new ChannelActionListener<>(channel, transportShardAction, request)); } | 具体执行是在子类TransportGetAction#asyncShardOperation中: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @Override protected void asyncShardOperation(GetRequest request, ShardId shardId, ActionListener listener) throws IOException { IndexService indexService = indicesService.indexServiceSafe(shardId.getIndex()); IndexShard indexShard = indexService.getShard(shardId.id()); // 关于realtime可以看一下官方文档 // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html if (request.realtime()) { // we are not tied to a refresh cycle here anyway super.asyncShardOperation(request, shardId, listener); } else { indexShard.awaitShardSearchActive(b -> { try { super.asyncShardOperation(request, shardId, listener); } catch (Exception ex) { listener.onFailure(ex); } }); } } | TransportGetAction#asyncShardOperation获取文档最终调用的是: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @Override protected GetResponse shardOperation(GetRequest request, ShardId shardId) { IndexService indexService = indicesService.indexServiceSafe(shardId.getIndex()); IndexShard indexShard = indexService.getShard(shardId.id()); // 关于realtime、refresh可以看一下官方文档 // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html if (request.refresh() && !request.realtime()) { indexShard.refresh("refresh_flag_get"); } GetResult result = indexShard.getService().get(request.id(), request.storedFields(), request.realtime(), request.version(), request.versionType(), request.fetchSourceContext()); return new GetResponse(result); } | shardOperation先检查是否需要refresh,然后调用indexShard.getService().get()读取数据并存储到GetResult中。读取及过滤 在ShardGetService#get()函数中,调用: GetResult getResult = innerGet(……); 获取结果。GetResult类用于存储读取的真实数据内容。核心的数据读取实现在ShardGetService#innerGet(……)函数中: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | private GetResult innerGet(String id, String[] gFields, boolean realtime, long version, VersionType versionType, long ifSeqNo, long ifPrimaryTerm, FetchSourceContext fetchSourceContext) { fetchSourceContext = normalizeFetchSourceContent(fetchSourceContext, gFields); // 调用Engine获取数据 Engine.GetResult get = indexShard.get(new Engine.Get(realtime, realtime, id) .version(version).versionType(versionType).setIfSeqNo(ifSeqNo).setIfPrimaryTerm(ifPrimaryTerm)); assert get.isFromTranslog() == false || realtime : "should only read from translog if realtime enabled"; if (get.exists() == false) { get.close(); } if (get == null || get.exists() == false) { return new GetResult(shardId.getIndexName(), id, UNASSIGNED_SEQ_NO, UNASSIGNED_PRIMARY_TERM, -1, false, null, null, null); } try { // 获取返回结果 // break between having loaded it from translog (so we only have _source), and having a document to load return innerGetLoadFromStoredFields(id, gFields, fetchSourceContext, get, mapperService); } finally { get.close(); } } //对指定的field、source进行过滤(source过滤只支持对字段), //把结果存于GetResult对象中 private GetResult innerGetLoadFromStoredFields(String id, String[] storedFields, FetchSourceContext fetchSourceContext, Engine.GetResult get, MapperService mapperService) { assert get.exists() : "method should only be called if document could be retrieved"; // check first if stored fields to be loaded don't contain an object field DocumentMapper docMapper = mapperService.documentMapper(); if (storedFields != null) { for (String field : storedFields) { Mapper fieldMapper = docMapper.mappers().getMapper(field); if (fieldMapper == null) { if (docMapper.mappers().objectMappers().get(field) != null) { // Only fail if we know it is a object field, missing paths / fields shouldn't fail. throw new IllegalArgumentException("field [" + field + "] isn't a leaf field"); } } } } Map documentFields = null; Map metadataFields = null; BytesReference source = null; DocIdAndVersion docIdAndVersion = get.docIdAndVersion(); // force fetching source if we read from translog and need to recreate stored fields boolean forceSourceForComputingTranslogStoredFields = get.isFromTranslog() && storedFields != null && Stream.of(storedFields).anyMatch(f -> TranslogLeafReader.ALL_FIELD_NAMES.contains(f) == false); FieldsVisitor fieldVisitor = buildFieldsVisitors(storedFields, forceSourceForComputingTranslogStoredFields ? FetchSourceContext.FETCH_SOURCE : fetchSourceContext); if (fieldVisitor != null) { try { docIdAndVersion.reader.document(docIdAndVersion.docId, fieldVisitor); } catch (IOException e) { throw new ElasticsearchException("Failed to get id [" + id + "]", e); } source = fieldVisitor.source(); // in case we read from translog, some extra steps are needed to make _source consistent and to load stored fields if (get.isFromTranslog()) { // Fast path: if only asked for the source or stored fields that have been already provided by TranslogLeafReader, // just make source consistent by reapplying source filters from mapping (possibly also nulling the source) if (forceSourceForComputingTranslogStoredFields == false) { try { source = indexShard.mapperService().documentMapper().sourceMapper().applyFilters(source, null); } catch (IOException e) { throw new ElasticsearchException("Failed to reapply filters for [" + id + "] after reading from translog", e); } } else { // Slow path: recreate stored fields from original source assert source != null : "original source in translog must exist"; SourceToParse sourceToParse = new SourceToParse(shardId.getIndexName(), id, source, XContentHelper.xContentType(source), fieldVisitor.routing()); ParsedDocument doc = indexShard.mapperService().documentMapper().parse(sourceToParse); assert doc.dynamicMappingsUpdate() == null : "mapping updates should not be re… ### 【Elasticsearch源码】 更新性能分析 - URL: https://jiankunking.com/why-does-elasticsearch-have-poor-update-performance.html - Content type: original - Published: 2021-02-28 - Updated: 2021-02-28 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析文档更新流程,并解释更新性能低于直接写入的原因。 - Categories: Elasticsearch - Tags: Performance, Elasticsearch, 源码, Update Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析文档更新流程,并解释更新性能低于直接写入的原因。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第三篇:Elasticsearch 更新性能 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于更新疑惑的点: 为什么Elasticsearch更新与写入的性能会有比较大的差异? 源码分析 建议先看一下:【Elasticsearch源码】 写入分析 在【Elasticsearch源码】 写入分析中可以看到bulk请求最终在TransportShardBulkAction doRun()中执行的时候,还是通过一个循环,一个一个处理的,并没有什么神奇之处。 下面看一下具体执行的代码executeBulkItemRequest doRun(): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | /** * Executes bulk item requests and handles request execution exceptions. * @return {@code true} if request completed on this thread and the listener was invoked, {@code false} if the request triggered * a mapping update that will finish and invoke the listener on a different thread */ static boolean executeBulkItemRequest(BulkPrimaryExecutionContext context, UpdateHelper updateHelper, LongSupplier nowInMillisSupplier, MappingUpdatePerformer mappingUpdater, Consumer> waitForMappingUpdate, ActionListener itemDoneListener) throws Exception { final DocWriteRequest.OpType opType = context.getCurrent().opType(); final UpdateHelper.Result updateResult; if (opType == DocWriteRequest.OpType.UPDATE) { final UpdateRequest updateRequest = (UpdateRequest) context.getCurrent(); try { // updateResult = updateHelper.prepare(updateRequest, context.getPrimary(), nowInMillisSupplier); } catch (Exception failure) { // we may fail translating a update to index or delete operation // we use index result to communicate failure while translating update request final Engine.Result result = new Engine.IndexResult(failure, updateRequest.version()); context.setRequestToExecute(updateRequest); context.markOperationAsExecuted(result); context.markAsCompleted(context.getExecutionResult()); return true; } // execute translated update request switch (updateResult.getResponseResult()) { case CREATED: case UPDATED: IndexRequest indexRequest = updateResult.action(); IndexMetadata metadata = context.getPrimary().indexSettings().getIndexMetadata(); MappingMetadata mappingMd = metadata.mapping(); indexRequest.process(metadata.getCreationVersion(), mappingMd, updateRequest.concreteIndex()); context.setRequestToExecute(indexRequest); break; case DELETED: context.setRequestToExecute(updateResult.action()); break; case NOOP: context.markOperationAsNoOp(updateResult.action()); context.markAsCompleted(context.getExecutionResult()); return true; default: throw new IllegalStateException("Illegal update operation " + updateResult.getResponseResult()); } } else { context.setRequestToExecute(context.getCurrent()); updateResult = null; } assert context.getRequestToExecute() != null; // also checks that we're in TRANSLATED state final IndexShard primary = context.getPrimary(); final long version = context.getRequestToExecute().version(); final boolean isDelete = context.getRequestToExecute().opType() == DocWriteRequest.OpType.DELETE; final Engine.Result result; if (isDelete) { final DeleteRequest request = context.getRequestToExecute(); result = primary.applyDeleteOperationOnPrimary(version, request.id(), request.versionType(), request.ifSeqNo(), request.ifPrimaryTerm()); } else { final IndexRequest request = context.getRequestToExecute(); result = primary.applyIndexOperationOnPrimary(version, request.versionType(), new SourceToParse( request.index(), request.id(), request.source(), request.getContentType(), request.routing()), request.ifSeqNo(), request.ifPrimaryTerm(), request.getAutoGeneratedTimestamp(), request.isRetry()); } if (result.getResultType() == Engine.Result.Type.MAPPING_UPDATE_REQUIRED) { try { primary.mapperService().merge(MapperService.SINGLE_MAPPING_NAME, new CompressedXContent(result.getRequiredMappingUpdate(), XContentType.JSON, ToXContent.EMPTY_PARAMS), MapperService.MergeReason.MAPPING_UPDATE_PREFLIGHT); } catch (Exception e) { logger.info(() -> new ParameterizedMessage("{} mapping update rejected by primary", primary.shardId()), e); onComplete(exceptionToResult(e, primary, isDelete, version), context, updateResult); return true; } mappingUpdater.updateMappings(result.getRequiredMappingUpdate(), primary.shardId(), new ActionListener<>() { @Override public void onResponse(Void v) { context.markAsRequiringMappingUpdate(); waitForMappingUpdate.accept( ActionListener.runAfter(new ActionListener<>() { @Override public void onResponse(Void v) { assert context.requiresWaitingForMappingUpdate(); context.resetForExecutionForRetry(); } @Override public void onFailure(Exception e) { context.failOnMappingUpdate(e); } }, () -> itemDoneListener.onResponse(null)) ); } @Override public void onFailure(Exception e) { onComplete(exceptionToResult(e, primary, isDelete, version), context, updateResult); // Requesting mapping update failed, so we don't have to wait for a cluster state update assert context.isInitial(); itemDoneListener.onResponse(null); } }); return false; } else { onComplete(result, context, updateResult); } return true; } /** * Prepares an update request by converting it into an index or delete request or an update response (no action). */ public Result prepare(UpdateRequest request, IndexShard indexShard, LongSupplier nowInMillis) { // 这里是实时获取 // 获取结果最终会到InternalEngine // get(Get get, DocumentMapper mapper, Function searcherWrapper) // 后面会附上 代码 final GetResult getResult = indexShard.getService().getForUpdate( request.id(), request.ifSeqNo(), request.ifPrimaryTerm()); return prepare(indexShard.shardId(), request, getResult, nowInMillis); } public GetResult getForUpdate(String id, long ifSeqNo, long ifPrimaryTerm) { // realtime是true return get(id, new String[]{RoutingFieldMapper.NAME}, true, Versions.MATCH_ANY, VersionType.INTERNAL, ifSeqNo, ifPrimaryTerm, FetchSourceContext.FETCH_SOURCE); } private GetResult get(String id, String[] gFields, boolean realtime, long version, VersionType versionType, long ifSeqNo, long ifPrimaryTerm, FetchSourceContext fetchSourceContext) { currentMetric.inc(); try { long now = System.nanoTime(); GetResult getResult = innerGet(id, gFields, realtime, version, versionType, ifSeqNo, ifPrimaryTerm, fetchSourceContext); if (getResult.isExists()) { existsMetric.inc(System.nanoTime() - now); } else { missingMetric.inc(System.nanoTime() - now); } return getResult; } finally { currentMetric.dec(); } } private GetResult innerGet(String id, String[] gFields, boolean realtime, long version, VersionType versionType, long ifSeqNo, long ifPrimaryTerm, FetchSourceContext fetchSourceContext) { fetchSourceContext = normalizeFetchSourceContent(fetchSourceContext, gFields); Engine.GetResult get = indexShard.get(new Engine.Get(realtime, realtime, id) .version(version).versionType(versionType).setIfSeqNo(ifSeqNo).setIfPrimaryTerm(ifPrimaryTerm)); assert get.isFromTranslog() == false || realtime : "should only read from translog if realtime enabled"; if (get.exists() == false) { get.close(); } if (get == null || get.exists() == false) { return new GetResult(shardId.getIndexName(), id, UNASSIGNED_SEQ_NO, UNASSIGNED_PRIMARY_TERM, -1, false, null, null, null); } try { // break between having loaded it from translog (so we only have _source), and having a document to load return innerGetLoadFromStoredFields(id, gFields, fetchSourceContext, get, mapperService); } finally { get.close(); } } public Engine.GetResult get(Engine.Get get) { readAllowed(); DocumentMapper mapper = mapperService.documentMapper(); if (mapper == null) { return GetResult.NOT_EXISTS; } return getEngine().get(get, mapper, this::wrapSearcher); } /** * Prepares an update request by converting it into an index or delete request or an update response (no action, in the event of a * noop). */ protected Result prepare(ShardId shardId, UpdateRequest request, final GetResult getResult, LongSupplier nowInMillis) { if (getResult.isExists() == false) { // If the document didn't exist, execute the update request as an upsert return prepareUpsert(shardId, request, getResult, nowInMillis); } else if (getResult.internalSourceRef() == null) { // no source, we can't do anything, throw a failure... throw new DocumentSourceMissingException(shardId, request.id()); } else if (request.script() == null && request.doc() != null) { // The request has no script, it is a new doc that should be merged with the old document return prepareUpdateIndexRequest(shardId, request, getResult, request.detectNoop()); } else { // The request has a script (or empty script), execute the script and prepare a new index request return prepareUpdateScriptRequest(shardId, request, getResult, nowInMillis); } } | 其中,prepare在org/elasticsearch/action/update/UpdateHelper.java 中。 从代码中可以看到更新逻辑分两步: - 获取待更新文档的数据 - 执行更新文档的操作 第1步最终会调用InternalEngine中的get方法。代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | @Override public GetResult get(Get get, DocumentMapper mapper, Function searcherWrapper) { assert Objects.equals(get.uid().field(), IdFieldMapper.NAME) : get.uid().field(); try (ReleasableLock ignored = readLock.acquire()) { ensureOpen(); // 是否实时获取 if (get.realtime()) { final VersionValue versionValue; try (Releasable ignore = versionMap.acquireLock(get.uid().bytes())) { // we need to lock here to access the version map to do this truly in RT versionValue = getVersionFromMap(get.uid().bytes()); } if (versionValue != null) { if (versionValue.isDelete()) { return GetResult.NOT_EXISTS; } if (get.versionType().isVersionConflictForReads(versionValue.version, get.version())) { throw new VersionConflictEngineException(shardId, get.id(), get.versionType().explainConflictForReads(versionValue.version, get.version())); } if (get.getIfSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO && ( get.getIfSeqNo() != versionValue.seqNo || get.getIfPrimaryTerm() != versionValue.term )) { throw new VersionConflictEngineException(shardId, get.id(), get.getIfSeqNo(), get.getIfPrimaryTerm(), versionValue.seqNo, versionValue.term); } // 是否从Translog获取 if (get.isReadFromTranslog()) { // this is only used for updates - API _GET calls will always read form a reader for consistency // the update call doesn't need the consistency since it's source only + _parent but parent can go away in 7.0 if (versionValue.getLocation() != null) { try { final Translog.Operation operation = translog.readOperation(versionValue.getLocation()); if (operation != null) { return getFromTranslog(get, (Translog.Index) o… ### [译]eBay Elasticsearch性能调优实践 - URL: https://jiankunking.com/elasticsearch-performance-tuning-practice-at-ebay.html - Content type: translation - Published: 2021-02-21 - Updated: 2021-02-21 - Summary: 翻译 eBay 的 Elasticsearch 性能调优实践,涵盖索引设计、分片规模、写入优化、搜索优化和性能测试。 - Categories: Elasticsearch - Tags: Performance, Elasticsearch, Cluster, Tuning - Original source: https://tech.ebayinc.com/engineering/elasticsearch-performance-tuning-practice-at-ebay The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 【Elasticsearch源码】 检索分析 - URL: https://jiankunking.com/elasticsearch-search-source-code-analysis.html - Content type: original - Published: 2021-01-29 - Updated: 2021-01-29 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析搜索请求的完整流程,包括协调、Query 和 Fetch 阶段。 - Categories: Elasticsearch - Tags: Elasticsearch, 源码, Search Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析搜索请求的完整流程,包括协调、Query 和 Fetch 阶段。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第二篇:Elasticsearch 搜索 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于检索流程疑惑的点: - 当索引是按照日期拆分之后,在使用-* 检索,会不会通过索引层面的时间配置直接跳过无关索引?使用*会对性能造成多大的影响? - 增加shard的副本数,能不能优化检索,比如:让副本数与节点数相同?也就是想知道,正确的副本数应该如何确定? - 聚合是如何实现的?先在shard层面调用Lucene来聚合,然后汇聚到协调节点再进行全局聚合?类似全局排序:https://jiankunking.com/elasticsearch-scroll-and-search-after.html 完整流程 图片截取自:Elasticsearch源码解析与优化实战 源码分析 第二部分是代码分析的过程,不想看的朋友可以跳过直接看第三部分总结。 分析的话,咱们就以_search操作为主线。 在RestSearchAction可以看到: - 路由注册 - 请求参数转换 真正执行的是TransportSearchAction,类图如下: 1 2 3 4 5 6 7 8 9 | TransportSearchAction doExecute => // executeRequest中会判断是local请求还是remote请求 // local请求会执行executeLocalSearch,在executeLocalSearch中会将remote相关参数置空,然后在调用executeSearch // remote请求会执行executeSearch TransportSearchAction executeRequest => TransportSearchAction executeLocalSearch|executeSearch => // executeSearch会合并remoteShardIterators(跨集群访问)与localShardIterators得到shardIterators // 校验shard数是否超限 TransportSearchAction executeSearch => | 下面先看一下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | private void executeRequest(Task task, SearchRequest searchRequest, SearchAsyncActionProvider searchAsyncActionProvider, ActionListener listener) { final long relativeStartNanos = System.nanoTime(); final SearchTimeProvider timeProvider = new SearchTimeProvider(searchRequest.getOrCreateAbsoluteStartMillis(), relativeStartNanos, System::nanoTime); ActionListener rewriteListener = ActionListener.wrap(source -> { if (source != searchRequest.source()) { // only set it if it changed - we don't allow null values to be set but it might be already null. this way we catch // situations when source is rewritten to null due to a bug searchRequest.source(source); } final SearchContextId searchContext; final Map remoteClusterIndices; if (searchRequest.pointInTimeBuilder() != null) { searchContext = searchRequest.pointInTimeBuilder().getSearchContextId(namedWriteableRegistry); remoteClusterIndices = getIndicesFromSearchContexts(searchContext, searchRequest.indicesOptions()); } else { searchContext = null; remoteClusterIndices = remoteClusterService.groupIndices(searchRequest.indicesOptions(), searchRequest.indices()); } OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); final ClusterState clusterState = clusterService.state(); if (remoteClusterIndices.isEmpty()) { executeLocalSearch( task, timeProvider, searchRequest, localIndices, clusterState, listener, searchContext, searchAsyncActionProvider); } else { // 对应 ccs_minimize_roundtrips // https://www.elastic.co/guide/en/elasticsearch/reference/7.9/modules-cross-cluster-search.html if (shouldMinimizeRoundtrips(searchRequest)) { final TaskId parentTaskId = task.taskInfo(clusterService.localNode().getId(), false).getTaskId(); ccsRemoteReduce(parentTaskId, searchRequest, localIndices, remoteClusterIndices, timeProvider, searchService.aggReduceContextBuilder(searchRequest), remoteClusterService, threadPool, listener, (r, l) -> executeLocalSearch( task, timeProvider, r, localIndices, clusterState, l, searchContext, searchAsyncActionProvider)); } else { AtomicInteger skippedClusters = new AtomicInteger(0); // 针对每个集群将搜索请求发送出去, // 目标集群TransportSearchAction收到请求调用doExecute方法处理 collectSearchShards(searchRequest.indicesOptions(), searchRequest.preference(), searchRequest.routing(), skippedClusters, remoteClusterIndices, remoteClusterService, threadPool, ActionListener.wrap( searchShardsResponses -> { final BiFunction clusterNodeLookup = getRemoteClusterNodeLookup(searchShardsResponses); final Map remoteAliasFilters; final List remoteShardIterators; if (searchContext != null) { remoteAliasFilters = searchContext.aliasFilter(); remoteShardIterators = getRemoteShardsIteratorFromPointInTime(searchShardsResponses, searchContext, searchRequest.pointInTimeBuilder().getKeepAlive(), remoteClusterIndices); } else { remoteAliasFilters = getRemoteAliasFilters(searchShardsResponses); remoteShardIterators = getRemoteShardsIterator(searchShardsResponses, remoteClusterIndices, remoteAliasFilters); } int localClusters = localIndices == null ? 0 : 1; int totalClusters = remoteClusterIndices.size() + localClusters; int successfulClusters = searchShardsResponses.size() + localClusters; executeSearch((SearchTask) task, timeProvider, searchRequest, localIndices, remoteShardIterators, clusterNodeLookup, clusterState, remoteAliasFilters, listener, new SearchResponse.Clusters(totalClusters, successfulClusters, skippedClusters.get()), searchContext, searchAsyncActionProvider); }, listener::onFailure)); } } }, listener::onFailure); if (searchRequest.source() == null) { rewriteListener.onResponse(searchRequest.source()); } else { Rewriteable.rewriteAndFetch(searchRequest.source(), searchService.getRewriteContext(timeProvider::getAbsoluteStartMillis), rewriteListener); } } | 下面再看一下executeSearch: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | private void executeSearch(SearchTask task, SearchTimeProvider timeProvider, SearchRequest searchRequest, OriginalIndices localIndices, List remoteShardIterators, BiFunction remoteConnections, ClusterState clusterState, Map remoteAliasMap, ActionListener listener, SearchResponse.Clusters clusters, @Nullable SearchContextId searchContext, SearchAsyncActionProvider searchAsyncActionProvider) { // red状态也可以查询 clusterState.blocks().globalBlockedRaiseException(ClusterBlockLevel.READ); // TODO: I think startTime() should become part of ActionRequest and that should be used both for index name // date math expressions and $now in scripts. This way all apis will deal with now in the same way instead // of just for the _search api final List localShardIterators; final Map aliasFilter; final String[] concreteLocalIndices; if (searchContext != null) { assert searchRequest.pointInTimeBuilder() != null; aliasFilter = searchContext.aliasFilter(); concreteLocalIndices = localIndices == null ? new String[0] : localIndices.indices(); localShardIterators = getLocalLocalShardsIteratorFromPointInTime(clusterState, localIndices, searchRequest.getLocalClusterAlias(), searchContext, searchRequest.pointInTimeBuilder().getKeepAlive()); } else { final Index[] indices = resolveLocalIndices(localIndices, clusterState, timeProvider); Map> routingMap = indexNameExpressionResolver.resolveSearchRouting(clusterState, searchRequest.routing(), searchRequest.indices()); routingMap = routingMap == null ? Collections.emptyMap() : Collections.unmodifiableMap(routingMap); concreteLocalIndices = new String[indices.length]; for (int i = 0; i < indices.length; i++) { concreteLocalIndices[i] = indices[i].getName(); } Map nodeSearchCounts = searchTransportService.getPendingSearchRequests(); // searchShards=>computeTargetedShards=>calculateScaledShardId // https://github.com/jiankunking/elasticsearch/blob/master/server/src/main/java/org/elasticsearch/cluster/routing/OperationRouting.java#L251 // clusterService.operationRouting().searchShards代码可以=> // 1、7.X及之后默认情况下preference是Adaptive replica selection,具体讲解参见:https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-search.html#search-preference // 2、副本数跟节点数相同并不能让一次搜索都请求本机的shard,https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-shard-routing.html#search-adaptive-replica // 3、如果想强制从本机中shard中获取数据,可以指定preference=_local;https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-shard-routing.html#shard-and-node-preference GroupShardsIterator localShardRoutings = clusterService.operationRouting().searchShards(clusterState, concreteLocalIndices, routingMap, searchRequest.preference(), searchService.getResponseCollectorService(), nodeSearchCounts); localShardIterators = StreamSupport.stream(localShardRoutings.spliterator(), false) .map(it -> new SearchShardIterator( searchRequest.getLocalClusterAlias(), it.shardId(), it.getShardRoutings(), localIndices)) .collect(Collectors.toList()); aliasFilter = buildPerIndexAliasFilter(searchRequest, clusterState, indices, remoteAliasMap); } final GroupShardsIterator shardIterators = mergeShardsIterators(localShardIterators, remoteShardIterators); failIfOverShardCountLimit(clusterService, shardIterators.size()); Map concreteIndexBoosts = resolveIndexBoosts(searchRequest, clusterState); // optimize search type for cases where there is only one shard group to search on if (shardIterators.size() == 1) { // if we only have one group, then we always want Q_T_F, no need for DFS, and no need to do THEN since we hit one shard searchRequest.searchType(QUERY_THEN_FETCH); } if (searchRequest.allowPartialSearchResults() == null) { // No user preference defined in search request - apply cluster service default searchRequest.allowPartialSearchResults(searchService.defaultAllowPartialSearchResults()); } if (searchRequest.isSuggestOnly()) { // disable request cache if we have only suggest searchRequest.requestCache(false); switch (searchRequest.searchType()) { case DFS_QUERY_THEN_FETCH: // convert to Q_T_F if we have only suggest searchRequest.searchType(QUERY_THEN_FETCH); break; } } final DiscoveryNodes nodes = clusterState.nodes(); BiFunction connectionLookup = buildConnectionLookup(searchRequest.getLocalClusterAlias(), nodes::get, remoteConnections, searchTransportService::getConnection); final Executor asyncSearchExecutor = asyncSearchExecutor(concreteLocalIndices, clusterState); // 判断是否需要在查询前做目标分片过滤 // pre_filter_shard_size // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html // shouldPreFilterSearchShards,判断为true需要同时满足3个条件: // 1、查询类型为QUERY_THEN_FETCH // 2、是否能通过查询重写预判出查询结果为空或者有字段排序 // 3、实际的查询分片数量> preFilterShardSize(默认128) // 需要注意的是: // pre-filter 最主要的作用不是降低查询延迟,而是 pre-filter 阶段可以不占用search theadpool,减少了这个线程池的占用情况。 final boolean preFilterSearchShards = shouldPreFilterSearchShards(clusterState, searchRequest, concreteLocalIndices, localShardIterators.size() + remoteShardIterators.size()); // 调用searchAsyncAction进行异步搜索,search操作是由action的start方法来处理的。 searchAsyncActionProvider.asyncSearchAction( task, searchRequest, asyncSearchExecutor, shardIterators, timeProvider, connectionLookup, clusterState, Collections.unmodifiableMap(aliasFilter), concreteIndexBoosts, listener, preFilterSearchShards, threadPool, clusters).start(); } | 在看searchAsyncAction之前先看一下AbstractSearchAsyncAction的继承及实现类: searchAsyncAction主要是生成查询的请求,也就是AbstractSearchAsyncAction的实例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | private AbstractSearchAsyncAction searchAsyncAction( SearchTask task, SearchRequest searchRequest, Ex… ### Elasticsearch Refresh vs Flush - URL: https://jiankunking.com/elasticsearch-refresh-vs-flush.html - Content type: original - Published: 2021-01-23 - Updated: 2021-01-23 - Summary: 详解Elasticsearch中Refresh和Flush两个操作的区别,包括触发时机、作用范围和对搜索可见性的影响。 - Categories: Elasticsearch - Tags: Elasticsearch, Refresh, Flush Article text: 文章速览 详解Elasticsearch中Refresh和Flush两个操作的区别,包括触发时机、作用范围和对搜索可见性的影响。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 详解Elasticsearch中Refresh和Flush两个操作的区别,包括触发时机、作用范围和对搜索可见性的影响。 图片来自https://lakshyabansal.hashnode.dev/write-operation-in-elasticsearch Refresh 使用refresh API显式刷新一个或多个索引。 如果请求以数据流为目标,则刷新该流的后台索引。刷新使自上次刷新以来对索引执行的所有操作都可用于搜索。 默认情况下,Elasticsearch会定期每秒刷新一次索引,但仅在最近30秒内收到搜索请求的索引上刷新。也可以使用index.refresh_interval设置更改此默认间隔。 刷新请求是同步的,并且在刷新操作完成之前不会返回响应。 举例 比如设置索引的refresh_interval为-1,这时候会导致/_search?track_total_hits=true返回的数据总条数不准,比如数据在一直写入但查询返回的总条数一直不变。 Flush 通过刷新data stream或者index将当前仅存储在事务日志中的数据永久存储到Lucene索引中。当Elasticsearch重启时,会重放事务日志中未刷新到Lucene索引的数据,从而将Elasticsearch恢复到重启前的状态。 默认情况下,Elasticsearch使用内存启发式,以便根据需要自动触发刷新操作,以便清除内存。 Elasticsearch automatically triggers flushes as needed, using heuristics that trade off the size of the unflushed transaction log against the cost of performing each flush. 一旦一个操作被刷新,它就会永久存储在Lucene索引中。这意味着不需要在事务日志中维护它的额外副本,除非出于某些其他原因而保留它。事务日志由多个文件组成,称为generations,一旦不再需要生成文件,Elasticsearch将删除它们,释放磁盘空间。 使用flush API也可以在一个或多个索引上触发刷新,尽管用户很少需要直接调用这个API。如果在对一些文档建立索引之后调用flush API,那么成功的响应表明Elasticsearch已经flush了所有在调用flush API之前建立索引的文档。 translog的Flush是Elasticsearch在后台自动运行的。默认情况下Elasticsearch每隔5s会去检测要不要Flush translog,默认条件是:每30分钟主动进行一次Flush或者当translog文件大小大于512MB主动进行一次Flush。默认配置下,每次index、bulk、delete、update完成的时候,会触发Flush translog到磁盘上,然后才返回200 OK。这个提高了数据安全性,但是会对写入的性能造成不小的影响。 在写入效率优先的情况下,可以在index template里设置如下参数: 1 2 | "index.translog.durability":"async"(默认是request) "index.translog.sync_interval":30s (默认是5s) | 小结 简而言之,_refresh用于使新文档在搜索时可见。 反过来,_flush用于在硬盘上持久化内存段。 _flush不会影响Elasticsearch中文档的可见性,因为搜索是在内存段中进行的,而_refresh会影响它们的可见性。 ### 【Elasticsearch源码】 写入分析 - URL: https://jiankunking.com/elasticsearch-write-source-code-analysis.html - Content type: original - Published: 2021-01-16 - Updated: 2021-01-16 - Summary: 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析写入请求、主副本复制、批量写入和活跃分片等待机制。 - Categories: Elasticsearch - Tags: Elasticsearch, 源码, Write, Index Article text: 文章速览 基于 Elasticsearch 8.0.0-SNAPSHOT 源码分析写入请求、主副本复制、批量写入和活跃分片等待机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch 带着疑问学源码,第一篇:Elasticsearch写入 代码分析基于:https://github.com/jiankunking/elasticsearch Elasticsearch 8.0.0-SNAPSHOT 目的 在看源码之前先梳理一下,自己对于写入流程疑惑的点: - Elasticsearch写入是等待所有副本都写入完成了才返回还是只要主副本写入了就返回? - 副本写入成功的标准是什么? - wait_for_active_shard参数的作用是啥? - Elasticsearch为什么bulk写入会比单条写入更快? - 都说Elasticsearch数据复制使用了PacificA,那到底是在哪里用的? - https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-replication.html#basic-write-model 源码分析 第二部分是代码分析的过程,不想看的朋友可以跳过直接看第三部分总结。 分析的话,咱们就以_bulk操作为主线。 通过搜索_bulk API找到RestBulkAction。在RestBulkAction可以看到: 1、路由注册 1 2 3 4 5 6 7 8 | @Override public List routes() { return List.of( new Route(POST, "/_bulk"), new Route(PUT, "/_bulk"), new Route(POST, "/{index}/_bulk"), new Route(PUT, "/{index}/_bulk")); } | 2、请求参数转换 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | @Override public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { BulkRequest bulkRequest = Requests.bulkRequest(); String defaultIndex = request.param("index"); String defaultRouting = request.param("routing"); FetchSourceContext defaultFetchSourceContext = FetchSourceContext.parseFromRestRequest(request); String defaultPipeline = request.param("pipeline"); String waitForActiveShards = request.param("wait_for_active_shards"); if (waitForActiveShards != null) { bulkRequest.waitForActiveShards(ActiveShardCount.parseString(waitForActiveShards)); } Boolean defaultRequireAlias = request.paramAsBoolean(DocWriteRequest.REQUIRE_ALIAS, null); bulkRequest.timeout(request.paramAsTime("timeout", BulkShardRequest.DEFAULT_TIMEOUT)); bulkRequest.setRefreshPolicy(request.param("refresh")); bulkRequest.add(request.requiredContent(), defaultIndex, defaultRouting, defaultFetchSourceContext, defaultPipeline, defaultRequireAlias, allowExplicitIndex, request.getXContentType()); return channel -> client.bulk(bulkRequest, new RestStatusToXContentListener<>(channel)); } | 从代码中可以看到RestRequest解析并转化为BulkRequest,再通过NodeClient对bulkRequest进行处理。 bulk方法是在AbstractClient,但实际执行的方法是NodeClient中的doExecute。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | @Override public void doExecute(ActionType action, Request request, ActionListener listener) { // Discard the task because the Client interface doesn't use it. try { executeLocally(action, request, listener); } catch (TaskCancelledException | IllegalArgumentException | IllegalStateException e) { // #executeLocally returns the task and throws TaskCancelledException if it fails to register the task because the parent // task has been cancelled, IllegalStateException if the client was not in a state to execute the request because it was not // yet properly initialized or IllegalArgumentException if header validation fails we forward them to listener since this API // does not concern itself with the specifics of the task handling listener.onFailure(e); } } /** * Execute an {@link ActionType} locally, returning that {@link Task} used to track it, and linking an {@link ActionListener}. * Prefer this method if you don't need access to the task when listening for the response. This is the method used to * implement the {@link Client} interface. * * @throws TaskCancelledException if the request's parent task has been cancelled already */ public < Request extends ActionRequest, Response extends ActionResponse > Task executeLocally(ActionType action, Request request, ActionListener listener) { return taskManager.registerAndExecute("transport", transportAction(action), request, localConnection, (t, r) -> { try { listener.onResponse(r); } catch (Exception e) { assert false : new AssertionError("callback must handle its own exceptions", e); throw e; } }, (t, e) -> { try { listener.onFailure(e); } catch (Exception ex) { ex.addSuppressed(e); assert false : new AssertionError("callback must handle its own exceptions", ex); throw ex; } }); } | NodeClient在处理BulkRequest请求时,会将请求的action转化为对应Transport层的action,再由TaskManager的registerAndExecute将请求转发到TransportAction中的execute来进行处理。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | public Task registerAndExecute(String type, TransportAction action, Request request, Transport.Connection localConnection, BiConsumer onResponse, BiConsumer onFailure) { final Releasable unregisterChildNode; if (request.getParentTask().isSet()) { unregisterChildNode = registerChildConnection(request.getParentTask().getId(), localConnection); } else { unregisterChildNode = () -> {}; } final Task task; try { task = register(type, action.actionName, request); } catch (TaskCancelledException e) { unregisterChildNode.close(); throw e; } // NOTE: ActionListener cannot infer Response, see https://bugs.openjdk.java.net/browse/JDK-8203195 action.execute(task, request, new ActionListener() { @Override public void onResponse(Response response) { try { Releasables.close(unregisterChildNode, () -> unregister(task)); } finally { onResponse.accept(task, response); } } @Override public void onFailure(Exception e) { try { Releasables.close(unregisterChildNode, () -> unregister(task)); } finally { onFailure.accept(task, e); } } }); return task; } | TransportAction中的execute代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | /** * Use this method when the transport action should continue to run in the context of the current task */ public final void execute(Task task, Request request, ActionListener listener) { ActionRequestValidationException validationException = request.validate(); if (validationException != null) { listener.onFailure(validationException); return; } if (task != null && request.getShouldStoreResult()) { listener = new TaskResultStoringActionListener<>(taskManager, task, listener); } RequestFilterChain requestFilterChain = new RequestFilterChain<>(this, logger); requestFilterChain.proceed(task, actionName, request, listener); } /** * Continue processing the request. Should only be called if a response has not been sent through * the given {@link ActionListener listener} * 摘自父类ActionFilterChain的注释 */ @Override public void proceed(Task task, String actionName, Request request, ActionListener listener) { int i = index.getAndIncrement(); try { if (i < this.action.filters.length) { // 先执行filter逻辑 this.action.filters[i].apply(task, actionName, request, listener, this); } else if (i == this.action.filters.length) { // 执行TransportAction逻辑 this.action.doExecute(task, request, listener); } else { listener.onFailure(new IllegalStateException("proceed was called too many times")); } } catch(Exception e) { logger.trace("Error during transport action execution.", e); listener.onFailure(e); } } | 下面看一下到TransportBulkAction中看一下doExecute具体做了啥? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | /** * Use this method when the transport action should continue to run in the context of the current task * 摘自父类TransportAction的注释 */ @Override protected void doExecute(Task task, BulkRequest bulkRequest, ActionListener listener) { final int indexingOps = bulkRequest.numberOfActions(); final long indexingBytes = bulkRequest.ramBytesUsed(); final boolean isOnlySystem = isOnlySystem(bulkRequest, clusterService.state().metadata().getIndicesLookup(), systemIndices); final Releasable releasable = indexingPressure.markCoordinatingOperationStarted(indexingOps, indexingBytes, isOnlySystem); final ActionListener releasingListener = ActionListener.runBefore(listener, releasable::close); final String executorName = isOnlySystem ? Names.SYSTEM_WRITE : Names.WRITE; try { doInternalExecute(task, bulkRequest, executorName, releasingListener); } catch (Exception e) { releasingListener.onFailure(e); } } protected void doInternalExecute(Task task, BulkRequest bulkRequest, String executorName, ActionListener listener) { final long startTime = relativeTime(); final AtomicArray responses = new AtomicArray<>(bulkRequest.requests.size()); boolean hasIndexRequestsWithPipelines = false; final Metadata metadata = clusterService.state().getMetadata(); final Version minNodeVersion = clusterService.state().getNodes().getMinNodeVersion(); for (DocWriteRequest actionRequest : bulkRequest.requests) { IndexRequest indexRequest = getIndexWriteRequest(actionRequest); if (indexRequest != null) { // Each index request needs to be evaluated, because this method also modifies the IndexRequest boolean indexRequestHasPipeline = IngestService.resolvePipelines(actionRequest, indexRequest, metadata); hasIndexRequestsWithPipelines |= indexRequestHasPipeline; } if (actionRequest instanceof IndexRequest) { IndexRequest ir = (IndexRequest) actionRequest; ir.checkAutoIdWithOpTypeCreateSupportedByVersion(minNodeVersion); if (ir.getAutoGeneratedTimestamp() != IndexRequest.UNSET_AUTO_GENERATED_TIMESTAMP) { throw new IllegalArgumentException("autoGeneratedTimestamp should not be set externally"); } } } if (hasIndexRequestsWithPipelines) { // this method (doExecute) will be called again, but with the bulk requests updated from the ingest node processing but // also with IngestService.NOOP_PIPELINE_NAME on each request. This ensures that this on the second time through this method, // this path is never taken. try { if (Assertions.ENABLED) { final boolean arePipelinesResolved = bulkRequest.requests() .stream() .map(TransportBulkAction::getIndexWriteRequest) .filter(Objects::nonNull) .allMatch(IndexRequest::isPipelineResolved); assert arePipelinesResolved : bulkRequest; } if (clusterService.localNode().isIngestNode()) { processBulkIndexIngestRequest(task, bulkRequest, executorName, listener); } else { ingestForwarder.forwardIngestRequest(BulkAction.INSTANCE, bulkRequest, listener); } } catch (Exception e) { listener.onFailure(e); } return; } // Attempt to create all the indices that we're going to need during the bulk before we start. // Step 1: collect all the indices in the request final Map indices = bulkRequest.requests.stream() // delete requests should not attempt to create the index (if the index does not // exists), unless an external versioning is used .filter(request -> request.opType() != DocWriteRequest.OpType.DELETE || request.versionType() == VersionType.EXTERNAL || request.versionType() == Versi… ### RocketMQ 设计 - URL: https://jiankunking.com/rocketmq-design.html - Content type: original - Published: 2020-12-13 - Updated: 2020-12-13 - Summary: 整理 RocketMQ 的消息存储、通信机制、过滤、负载均衡、事务消息和消息查询等核心设计。 - Categories: Message Queue - Tags: RocketMQ, Broker, NameServer, CommitLog, ConsumeQueue Article text: 文章速览 整理 RocketMQ 的消息存储、通信机制、过滤、负载均衡、事务消息和消息查询等核心设计。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Message Queue 消息存储、通信机制、消息过滤、负载均衡、事务消息、消息查询 转载自RocketMQ官方文档 1 消息存储 消息存储是RocketMQ中最为复杂和最为重要的一部分,本节将分别从RocketMQ的消息存储整体架构、PageCache与Mmap内存映射以及RocketMQ中两种不同的刷盘方式三方面来分别展开叙述。 1.1 消息存储整体架构 消息存储架构图中主要有下面三个跟消息存储相关的文件构成。 (1) CommitLog:消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件; (2) ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能,由于RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件中根据topic检索消息是非常低效的。Consumer即可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。consumequeue文件可以看成是基于topic的commitlog索引文件,故consumequeue文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样consumequeue文件采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M; (3) IndexFile:IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。Index文件的存储位置是:$HOME \store\index${fileName},文件名fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故rocketmq的索引文件其底层实现为hash索引。 在上面的RocketMQ的消息存储整体架构图中可以看出,RocketMQ采用的是混合型的存储结构,即为Broker单个实例下所有的队列共用一个日志数据文件(即为CommitLog)来存储。RocketMQ的混合型存储结构(多个Topic的消息实体内容都存储于一个CommitLog中)针对Producer和Consumer分别采用了数据和索引部分相分离的存储结构,Producer发送消息至Broker端,然后Broker端使用同步或者异步的方式对消息刷盘持久化,保存至CommitLog中。只要消息被刷盘持久化至磁盘文件CommitLog中,那么Producer发送的消息就不会丢失。正因为如此,Consumer也就肯定有机会去消费这条消息。当无法拉取到消息后,可以等下一次消息拉取,同时服务端也支持长轮询模式,如果一个消息拉取请求未拉取到消息,Broker允许等待30s的时间,只要这段时间内有新消息到达,将直接返回给消费端。这里,RocketMQ的具体做法是,使用Broker端的后台服务线程—ReputMessageService不停地分发请求并异步构建ConsumeQueue(逻辑消费队列)和IndexFile(索引文件)数据。 1.2 页缓存与内存映射 页缓存(PageCache)是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。 在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Deadline”(此时块存储采用SSD的话),随机读的性能也会有所提升。 另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)。 1.3 消息刷盘 (1) 同步刷盘:如上图所示,只有在消息真正持久化至磁盘后RocketMQ的Broker端才会真正返回给Producer端一个成功的ACK响应。同步刷盘对MQ消息可靠性来说是一种不错的保障,但是性能上会有较大影响,一般适用于金融业务应用该模式较多。 (2) 异步刷盘:能够充分利用OS的PageCache的优势,只要消息写入PageCache即可将成功的ACK返回给Producer端。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了MQ的性能和吞吐量。 2 通信机制 RocketMQ消息队列集群主要包括NameServer、Broker(Master/Slave)、Producer、Consumer4个角色,基本通讯流程如下: (1) Broker启动后需要完成一次将自己注册至NameServer的操作;随后每隔30s时间定时向NameServer上报Topic路由信息。 (2) 消息生产者Producer作为客户端发送消息时候,需要根据消息的Topic从本地缓存的TopicPublishInfoTable获取路由信息。如果没有则更新路由信息会从NameServer上重新拉取,同时Producer会默认每隔30s向NameServer拉取一次路由信息。 (3) 消息生产者Producer根据2)中获取的路由信息选择一个队列(MessageQueue)进行消息发送;Broker作为消息的接收者接收消息并落盘存储。 (4) 消息消费者Consumer根据2)中获取的路由信息,并再完成客户端的负载均衡后,选择其中的某一个或者某几个消息队列来拉取消息并进行消费。 从上面1)~3)中可以看出在消息生产者, Broker和NameServer之间都会发生通信(这里只说了MQ的部分通信),因此如何设计一个良好的网络通信模块在MQ中至关重要,它将决定RocketMQ集群整体的消息传输能力与最终的性能。 rocketmq-remoting 模块是 RocketMQ消息队列中负责网络通信的模块,它几乎被其他所有需要网络通信的模块(诸如rocketmq-client、rocketmq-broker、rocketmq-namesrv)所依赖和引用。为了实现客户端与服务器之间高效的数据请求与接收,RocketMQ消息队列自定义了通信协议并在Netty的基础之上扩展了通信模块。 2.1 Remoting通信类结构 2.2 协议设计与编解码 在Client和Server之间完成一次消息发送时,需要对发送的消息进行一个协议约定,因此就有必要自定义RocketMQ的消息协议。同时,为了高效地在网络中传输消息和对收到的消息读取,就需要对消息进行编解码。在RocketMQ中,RemotingCommand这个类在消息传输过程中对所有数据内容的封装,不但包含了所有的数据结构,还包含了编码解码操作。 Header字段 类型 Request说明 Response说明 code | int | 请求操作码,应答方根据不同的请求码进行不同的业务处理 | 应答响应码。0表示成功,非0则表示各种错误 | language | LanguageCode | 请求方实现的语言 | 应答方实现的语言 | version | int | 请求方程序的版本 | 应答方程序的版本 | opaque | int | 相当于requestId,在同一个连接上的不同请求标识码,与响应消息中的相对应 | 应答不做修改直接返回 | flag | int | 区分是普通RPC还是onewayRPC得标志 | 区分是普通RPC还是onewayRPC得标志 | remark | String | 传输自定义文本信息 | 传输自定义文本信息 | extFields | HashMap | 请求自定义扩展信息 | 响应自定义扩展信息 | 可见传输内容主要可以分为以下4部分: (1) 消息长度:总长度,四个字节存储,占用一个int类型; (2) 序列化类型&消息头长度:同样占用一个int类型,第一个字节表示序列化类型,后面三个字节表示消息头长度; (3) 消息头数据:经过序列化后的消息头数据; (4) 消息主体数据:消息主体的二进制字节数据内容; 2.3 消息的通信方式和流程 在RocketMQ消息队列中支持通信的方式主要有同步(sync)、异步(async)、单向(oneway) 三种。其中“单向”通信模式相对简单,一般用在发送心跳包场景下,无需关注其Response。这里,主要介绍RocketMQ的异步通信流程。 2.4 Reactor多线程设计 RocketMQ的RPC通信采用Netty组件作为底层通信库,同样也遵循了Reactor多线程模型,同时又在这之上做了一些扩展和优化。 上面的框图中可以大致了解RocketMQ中NettyRemotingServer的Reactor 多线程模型。一个 Reactor 主线程(eventLoopGroupBoss,即为上面的1)负责监听 TCP网络连接请求,建立好连接,创建SocketChannel,并注册到selector上。RocketMQ的源码中会自动根据OS的类型选择NIO和Epoll,也可以通过参数配置),然后监听真正的网络数据。拿到网络数据后,再丢给Worker线程池(eventLoopGroupSelector,即为上面的“N”,源码中默认设置为3),在真正执行业务逻辑之前需要进行SSL验证、编解码、空闲检查、网络连接管理,这些工作交给defaultEventExecutorGroup(即为上面的“M1”,源码中默认设置为8)去做。而处理业务操作放在业务线程池中执行,根据 RomotingCommand 的业务请求码code去processorTable这个本地缓存变量中找到对应的 processor,然后封装成task任务后,提交给对应的业务processor处理线程池来执行(sendMessageExecutor,以发送消息为例,即为上面的 “M2”)。从入口到业务逻辑的几个步骤中线程池一直再增加,这跟每一步逻辑复杂性相关,越复杂,需要的并发通道越宽。 线程数 线程名 线程具体说明 1 | NettyBoss_%d | Reactor 主线程 | N | NettyServerEPOLLSelector_%d_%d | Reactor 线程池 | M1 | NettyServerCodecThread_%d | Worker线程池 | M2 | RemotingExecutorThread_%d | 业务processor处理线程池 | 3 消息过滤 RocketMQ分布式消息队列的消息过滤方式有别于其它MQ中间件,是在Consumer端订阅消息时再做消息过滤的。RocketMQ这么做是在于其Producer端写入消息和Consumer端订阅消息采用分离存储的机制来实现的,Consumer端订阅消息是需要通过ConsumeQueue这个消息消费的逻辑队列拿到一个索引,然后再从CommitLog里面读取真正的消息实体内容,所以说到底也是还绕不开其存储结构。其ConsumeQueue的存储结构如下,可以看到其中有8个字节存储的Message Tag的哈希值,基于Tag的消息过滤正式基于这个字段值的。 主要支持如下2种的过滤方式 (1) Tag过滤方式:Consumer端在订阅消息时除了指定Topic还可以指定TAG,如果一个消息有多个TAG,可以用||分隔。其中,Consumer端会将这个订阅请求构建成一个 SubscriptionData,发送一个Pull消息的请求给Broker端。Broker端从RocketMQ的文件存储层—Store读取数据之前,会用这些数据先构建一个MessageFilter,然后传给Store。Store从 ConsumeQueue读取到一条记录后,会用它记录的消息tag hash值去做过滤,由于在服务端只是根据hashcode进行判断,无法精确对tag原始字符串进行过滤,故在消息消费端拉取到消息后,还需要对消息的原始tag字符串进行比对,如果不同,则丢弃该消息,不进行消息消费。 (2) SQL92的过滤方式:这种方式的大致做法和上面的Tag过滤方式一样,只是在Store层的具体过滤过程不太一样,真正的 SQL expression 的构建和执行由rocketmq-filter模块负责的。每次过滤都去执行SQL表达式会影响效率,所以RocketMQ使用了BloomFilter避免了每次都去执行。SQL92的表达式上下文为消息的属性。 4 负载均衡 RocketMQ中的负载均衡都在Client端完成,具体来说的话,主要可以分为Producer端发送消息时候的负载均衡和Consumer端订阅消息的负载均衡。 4.1 Producer的负载均衡 Producer端在发送消息的时候,会先根据Topic找到指定的TopicPublishInfo,在获取了TopicPublishInfo路由信息后,RocketMQ的客户端在默认方式下selectOneMessageQueue()方法会从TopicPublishInfo中的messageQueueList中选择一个队列(MessageQueue)进行发送消息。具体的容错策略均在MQFaultStrategy这个类中定义。这里有一个sendLatencyFaultEnable开关变量,如果开启,在随机递增取模的基础上,再过滤掉not available的Broker代理。所谓的”latencyFaultTolerance”,是指对之前失败的,按一定的时间做退避。例如,如果上次请求的latency超过550Lms,就退避3000Lms;超过1000L,就退避60000L;如果关闭,采用随机递增取模的方式选择一个队列(MessageQueue)来发送消息,latencyFaultTolerance机制是实现消息发送高可用的核心关键所在。 4.2 Consumer的负载均衡 在RocketMQ中,Consumer端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在Push模式只是对pull模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息后,然后提交到消息消费线程池后,又“马不停蹄”的继续向服务器再次尝试拉取消息。如果未拉取到消息,则延迟一下又继续拉取。在两种基于拉模式的消费方式(Push/Pull)中,均需要Consumer端在知道从Broker端的哪一个消息队列—队列中去获取消息。因此,有必要在Consumer端来做负载均衡,即Broker端中多个MessageQueue分配给同一个ConsumerGroup中的哪些Consumer消费。 1、Consumer端的心跳包发送 在Consumer启动后,它就会通过定时任务不断地向RocketMQ集群中的所有Broker实例发送心跳包(其中包含了,消息消费分组名称、订阅关系集合、消息通信模式和客户端id的值等信息)。Broker端在收到Consumer的心跳消息后,会将它维护在ConsumerManager的本地缓存变量—consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量—channelInfoTable中,为之后做Consumer端的负载均衡提供可以依据的元数据信息。 2、Consumer端实现负载均衡的核心类—RebalanceImpl 在Consumer实例的启动流程中的启动MQClientInstance实例部分,会完成负载均衡服务线程—RebalanceService的启动(每隔20s执行一次)。通过查看源码可以发现,RebalanceService线程的run()方法最终调用的是RebalanceImpl类的rebalanceByTopic()方法,该方法是实现Consumer端负载均衡的核心。这里,rebalanceByTopic()方法会根据消费者通信类型为“广播模式”还是“集群模式”做不同的逻辑处理。这里主要来看下集群模式下的主要处理流程: (1) 从rebalanceImpl实例的本地缓存变量—topicSubscribeInfoTable中,获取该Topic主题下的消息消费队列集合(mqSet); (2) 根据topic和consumerGroup为参数调用mQClientFactory.findConsumerIdList()方法向Broker端发送获取该消费组下消费者Id列表的RPC通信请求(Broker端基于前面Consumer端上报的心跳包数据而构建的consumerTable做出响应返回,业务请求码:GET_CONSUMER_LIST_BY_GROUP); (3) 先对Topic下的消息消费队列、消费者Id排序,然后用消息队列分配策略算法(默认为:消息队列的平均分配算法),计算出待拉取的消息队列。这里的平均分配算法,类似于分页的算法,将所有MessageQueue排好序类似于记录,将所有消费端Consumer排好序类似页数,并求出每一页需要包含的平均size和每个页面记录的范围range,最后遍历整个range而计算出当前Consumer端应该分配到的记录(这里即为:MessageQueue)。 (4) 然后,调用updateProcessQueueTableInRebalance()方法,具体的做法是,先将分配到的消息队列集合(mqSet)与processQueueTable做一个过滤比对。 - 上图中processQueueTable标注的红色部分,表示与分配到的消息队列集合mqSet互不包含。将这些队列设置Dropped属性为true,然后查看这些队列是否可以移除出processQueueTable缓存变量,这里具体执行removeUnnecessaryMessageQueue()方法,即每隔1s 查看是否可以获取当前消费处理队列的锁,拿到的话返回true。如果等待1s后,仍然拿不到当前消费处理队列的锁则返回false。如果返回true,则从processQueueTable缓存变量中移除对应的Entry; - 上图中processQueueTable的绿色部分,表示与分配到的消息队列集合mqSet的交集。判断该ProcessQueue是否已经过期了,在Pull模式的不用管,如果是Push模式的,设置Dropped属性为true,并且调用removeUnnecessaryMessageQueue()方法,像上面一样尝试移除Entry; 最后,为过滤后的消息队列集合(mqSet)中的每个MessageQueue创建一个ProcessQueue对象并存入RebalanceImpl的processQueueTable队列中(其中调用RebalanceImpl实例的computePullFromWhere(MessageQueue mq)方法获取该MessageQueue对象的下一个进度消费值offset,随后填充至接下来要创建的pullRequest对象属性中),并创建拉取请求对象—pullRequest添加到拉取列表—pullRequestList中,最后执行dispatchPullRequest()方法,将Pull消息的请求对象PullRequest依次放入PullMessageService服务线程的阻塞队列pullRequestQueue中,待该服务线程取出后向Broker端发起Pull消息的请求。其中,可以重点对比下,RebalancePushImpl和RebalancePullImpl两个实现类的dispatchPullRequest()方法不同,RebalancePullImpl类里面的该方法为空,这样子也就回答了上一篇中最后的那道思考题了。 消息消费队列在同一消费组不同消费者之间的负载均衡,其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列。 5 事务消息 Apache RocketMQ在4.3.0版中已经支持分布式事务消息,这里RocketMQ采用了2PC的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息,如下图所示。 5.1 RocketMQ事务消息流程概要 上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。 1.事务消息发送及提交: (1) 发送消息(half消息)。 (2) 服务端响应消息写入结果。 (3) 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。 (4) 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见) 2.补偿流程: (1) 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查” (2) Producer收到回查消息,检查回查消息对应的本地事务的状态 (3) 根据本地事务状态,重新Commit或者Rollback 其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。 5.2 RocketMQ事务消息设计 1.事务消息在一阶段对用户不可见 在RocketMQ事务消息的主要流程中,一阶段的消息如何对用户不可见。其中,事务消息相对普通消息最大的特点就是一阶段发送的消息对用户是不可见的。那么,如何做到写入消息但是对用户不可见呢?RocketMQ事务消息的做法是:如果消息是half消息,将备份原消息的主题与消息消费队列,然后改变主题为RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费half类型的消息,然后RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。 在RocketMQ中,消息在服务端的存储结构如下,每条消息都会有对应的索引信息,Consumer通过ConsumeQueue这个二级索引来读取消息实体内容,其流程如下: RocketMQ的具体实现策略是:写入的如果事务消息,对消息的Topic和Queue等属性进行替换,同时将原来的Topic和Queue信息存储到消息的属性中,正因为消息主题被替换,故消息并不会转发到该原主题的消息消费队列,消费者无法感知消息的存在,不会消费。其实改变消息主题是RocketMQ的常用“套路”,回想一下延时消息的实现机制。 2.Commit和Rollback操作以及Op消息的引入 在完成一阶段写入一条对用户不可见的消息后,二阶段如果是Commit操作,则需要让消息对用户可见;如果是Rollback则需要撤销一阶段的消息。先说Rollback的情况。对于Rollback,本身一阶段的消息对用户是不可见的,其实不需要真正撤销消息(实际上RocketMQ也无法去真正的删除一条消息,因为是顺序写文件的)。但是区别于这条消息没有确定状态(Pending状态,事务悬而未决),需要一个操作来标识这条消息的最终状态。RocketMQ事务消息方案中引入了Op消息的概念,用Op消息标识事务消息已经确定的状态(Commit或者Rollback)。如果一条事务消息没有对应的Op消息,说明这个事务的状态还无法确定(可能是二阶段失败了)。引入Op消息后,事务消息无论是Commit或者Rollback都会记录一个Op操作。Commit相对于Rollback只是在写入Op消息前创建Half消息的索引。 3.Op消息的存储和对应关系 RocketMQ将Op消息写入到全局一个特定的Topic中通过源码中的方法—TransactionalMessageUtil.buildOpTopic();这个Topic是一个内部的Topic(像Half消息的Topic一样),不会被用户消费。Op消息的内容为对应的Half消息的存储的Offset,这样通过Op消息能索引到Half消息进行后续的回查操作。 4.Half消息的索引构建 在执行二阶段Commit操作时,需要构建出Half消息的索引。一阶段的Half消息由于是写到一个特殊的Topic,所以二阶段构建索引时需要读取出Half消息,并将Topic和Queue替换成真正的目标的Topic和Queue,之后通过一次普通消息的写入操作来生成一条对用户可见的消息。所以RocketMQ事务消息二阶段其实是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消息,然后走一遍消息写入流程。 5.如何处理二阶段失败的消息? 如果在RocketMQ事务消息的二阶段过程中失败了,例如在做Commit操作时,出现网络问题导致Commit失败,那么需要通过一定的策略使这条消息最终被Commit。RocketMQ采用了一种补偿机制,称为“回查”。Broker端对未确定状态的消息发起回查,将消息发送到对应的Producer端(同一个Group的Producer),由Producer根据消息来检查本地事务的状态,进而执行Commit或者Rollback。Broker端通过对比Half消息和Op消息进行事务消息的回查并且推进CheckPoint(记录那些事务消息的状态是确定的)。 值得注意的是,rocketmq并不会无休止的的信息事务状态回查,默认回查15次,如果15次回查还是无法得知事务状态,rocketmq默认回滚该消息。 6 消息查询 RocketMQ支持按照下面两种维度(“按照Message Id查询消息”、“按照Message Key查询消息”)进行消息查询。 6.1 按照MessageId查询消息 RocketMQ中的MessageId的长度总共有16字节,其中包含了消息存储主机地址(IP地址和端口),消息Commit Log offset。“按照MessageId查询消息”在RocketMQ中具体做法是:Client端从MessageId中解析出Broker… ### 后端存储实战课 笔记 - URL: https://jiankunking.com/back-end-storage-practical-lesson.html - Content type: original - Published: 2020-12-12 - Updated: 2020-12-12 - Summary: 《后端存储实战课》学习笔记,围绕海量数据分片、数据库与存储系统选型、数据组织方式及查询需求反推存储设计等核心观点展开。 - Categories: Storage - Tags: Reading Notes, Storage Article text: 文章速览 《后端存储实战课》学习笔记,围绕海量数据分片、数据库与存储系统选型、数据组织方式及查询需求反推存储设计等核心观点展开。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Storage 《后端存储实战课》学习笔记,重点摘录关于海量数据分片、存储选型、数据组织方式等核心观点。 后端存储实战课 作者: 李玥 最近在读《后端存储实战课》,其中有几条对于我个人而言,感触很深,特此摘录一下: - 解决海量数据导致存储系统慢的问题,思想非常简单,就是一个“拆”字,把一大坨数据拆分成 N 个小坨,学名叫“分片(Shard)”。拆开之后,每个分片里的数据就没那么多了,然后让查找尽量落在某一个分片上,这样来提升查找性能。所有分布式存储系统解决海量数据查找问题都是遵循的这个思想。 - 同样一份商品数据,如果我们是按照关键字搜索,放在 ES 里就比放在 MySQL 快了几个数量级。原因是,数据组织方式、物理存储结构和查询方式,对查询性能的影响是巨大的,而且海量数据还会指数级地放大这个性能差距。 所以,在大厂中,对于海量数据的处理原则,都是根据业务对数据查询的需求,反过来确定选择什么数据库、如何组织数据结构、如何分片数据,这样才能达到最优的查询性能。同样一份订单数据,除了在订单库保存一份用于在线交易以外,还会在各种数据库中,以各种各样的组织方式存储,用于满足不同业务系统的查询需求。像 BAT 这种大厂,它的核心业务数据,存个几十上百份是非常正常的。 ### 对于Java系高级特性的个人看法 - URL: https://jiankunking.com/personal-views-on-the-advanced-features-of-java.html - Content type: original - Published: 2020-11-29 - Updated: 2020-11-29 - Summary: 讨论 CompletableFuture、Lambda 和 Reactive 等 Java 高级特性的价值、适用场景与使用取舍。 - Categories: Java - Tags: Java, Lambda, CompletableFuture Article text: 文章速览 讨论 CompletableFuture、Lambda 和 Reactive 等 Java 高级特性的价值、适用场景与使用取舍。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java CompletableFuture、Lambda、Reactive,欢迎留言讨论。 说一下我个人的观点吧: Lambda 优点 - 代码简洁 缺点 - 不方便调试. - 会拉升代码维护的成本。对于写代码/维护代码的人,有一定的要求(需要学习语法糖)。 - 对于非 CPU密集型的程序,性能方面没有什么提升。 拓展:Java Lambda表达式 实现原理分析 CompletableFuture CompletableFuture这种回调底层还是Fork/Join框架,Fork/Join对于io这种操作还是会阻塞线程,而且CompletableFuture默认线程数是与CPU核数一样的。在现在容器化的场景下,CPU核数都不会很多(一般都是个位数),那么使用CompletableFuture是执行io操作是不是会更早的无响应?因为个位数的线程很快就都被阻塞了。 这里说一下我的理解: CompletableFuture还不能等完全同于ForkJoin。 可以简单的理解为 CompletableFuture.then() 等于 Fork CompletableFuture.get() 等于 Join 但不是所有场景下,CompletableFuture都需要用get()结束的。也就是说,有时候是不需要调用阻塞的get()方法的。 另外,虽然CompletableFuture 默认使用 ForkJoinPool,但你完全可以给它提供一个自定义的执行器。 – 李玥 Spring Reactive Spring Reactive基于Netty,用更少的线程可以满足更多的并发请求,可以减少微服务部署的实例,但对于使用者来说Reactor不是很友好,而且排查问题麻烦。 拓展:Spring WebFlux vs Spring MVC 总结 目前来看CompletableFuture、Lambda、Reactive,对于目前Web开发而言可以说是意义不大,尤其是CompletableFuture、Spring Reactive,终将被Loom项目代替或者将基于Loom再次开发。 Java目前的问题在于语言层面没有协程这种东西,所以io操作仍然会阻塞线程(除非基于Netty开发)。 Java在云原生的路上,内存归还OS问题,已在G1、ZGC中解决了,剩下的就是协程跟AOT了。 协程 - 2023/09/19 JDK21 发布 - JDK 21 推出了协程 - 2025/03/18 JDK24 发布 - JDK 24 解决了协程在synchronized场景下pinning的问题 - 到此Java 协程基本可用了 ### Elasticsearch Breaker CircuitBreakingException Parent Data Too Large Real Usage - URL: https://jiankunking.com/elasticsearch-breaker-circuitbreakingexception-parent-data-too-large-transport-request-real-usage.html - Content type: original - Published: 2020-11-24 - Updated: 2020-11-24 - Summary: 排查 Elasticsearch 7.6.2 的 Parent Data Too Large 熔断异常,分析真实内存断路器配置、触发条件和处理方式。 - Categories: Elasticsearch - Tags: Elasticsearch Article text: 文章速览 排查 Elasticsearch 7.6.2 的 Parent Data Too Large 熔断异常,分析真实内存断路器配置、触发条件和处理方式。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch indices.breaker.total.use_real_memory 引发的问题 最近业务日志es(7.6.2)集群,写入时经常返回以下异常: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | 2020-11-24T02:59:05.557085524Z {"type": "server", "timestamp": "2020-11-24T02:59:05,556Z", "level": "DEBUG", "component": "o.e.a.a.c.n.i.TransportNodesInfoAction", "cluster.name": "business-log", "node.name": "es-b-193", "message": "failed to execute on node [EKFLTB1jTrSbTHi80t8lDw]", "cluster.uuid": "ArYy-qmCTbCQTDUI8ogsBg", "node.id": "w8mHCBNORpa8P73ML3c1zg" , 2020-11-24T02:59:05.557111874Z "stacktrace": ["org.elasticsearch.transport.RemoteTransportException: [es-b-194][127.0.0.1:9300][cluster:monitor/nodes/info[n]]", 2020-11-24T02:59:05.557116050Z "Caused by: org.elasticsearch.common.breaker.CircuitBreakingException: [parent] Data too large, data for [] would be [27684654388/25.7gb], which is larger than the limit of [26521423052/24.6gb], real usage: [27684652880/25.7gb], new bytes reserved: [1508/1.4kb], usages [request=0/0b, fielddata=19858/19.3kb, in_flight_requests=2805740/2.6mb, accounting=86606929/82.5mb]", 2020-11-24T02:59:05.557120478Z "at org.elasticsearch.indices.breaker.HierarchyCircuitBreakerService.checkParentLimit(HierarchyCircuitBreakerService.java:343) ~[elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557123616Z "at org.elasticsearch.common.breaker.ChildMemoryCircuitBreaker.addEstimateBytesAndMaybeBreak(ChildMemoryCircuitBreaker.java:128) ~[elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557126392Z "at org.elasticsearch.transport.InboundHandler.handleRequest(InboundHandler.java:171) [elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557129043Z "at org.elasticsearch.transport.InboundHandler.messageReceived(InboundHandler.java:119) [elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557131767Z "at org.elasticsearch.transport.InboundHandler.inboundMessage(InboundHandler.java:103) [elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557134387Z "at org.elasticsearch.transport.TcpTransport.inboundMessage(TcpTransport.java:667) [elasticsearch-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557137050Z "at org.elasticsearch.transport.netty4.Netty4MessageChannelHandler.channelRead(Netty4MessageChannelHandler.java:62) [transport-netty4-client-7.6.2.jar:7.6.2]", 2020-11-24T02:59:05.557139775Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:374) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557143378Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:360) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557146200Z "at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:352) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557157569Z "at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:326) [netty-codec-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557160772Z "at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:300) [netty-codec-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557163614Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:374) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557166366Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:360) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557169060Z "at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:352) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557171799Z "at io.netty.handler.logging.LoggingHandler.channelRead(LoggingHandler.java:241) [netty-handler-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557174627Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:374) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557177412Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:360) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557180299Z "at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:352) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557182991Z "at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1422) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557185677Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:374) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557188907Z "at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:360) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557191750Z "at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:931) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557194464Z "at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:163) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557197166Z "at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:700) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557199910Z "at io.netty.channel.nio.NioEventLoop.processSelectedKeysPlain(NioEventLoop.java:600) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557202599Z "at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:554) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557205278Z "at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:514) [netty-transport-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557207911Z "at io.netty.util.concurrent.SingleThreadEventExecutor$6.run(SingleThreadEventExecutor.java:1050) [netty-common-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557214146Z "at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) [netty-common-4.1.43.Final.jar:4.1.43.Final]", 2020-11-24T02:59:05.557216961Z "at java.lang.Thread.run(Thread.java:830) [?:?]"] } | 出现异常时,jvm.options配置如下: 1 2 3 4 5 6 7 8 9 10 11 12 | ## GC configuration #-XX:+UseConcMarkSweepGC #-XX:CMSInitiatingOccupancyFraction=75 #-XX:+UseCMSInitiatingOccupancyOnly ## G1GC Configuration # NOTE: G1GC is only supported on JDK version 10 or later. # To use G1GC uncomment the lines below. #-XX:-UseConcMarkSweepGC #-XX:-UseCMSInitiatingOccupancyOnly -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=75 | 经过一顿Google之后,发现该问题是由于: es 7.x之后引入了indices.breaker.total.use_real_memory造成的 ,从文档来看indices.breaker.total.use_real_memory控制的是jvm实际使用的内存。 那么jvm内存用到多少的时候,会触发该熔断呢? 从集群系统配置中可以看到indices.breaker.total.limit默认是95。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | curl --location --request GET 'http://127.0.0.1:9200/_cluster/settings?include_defaults&flat_settings&local&filter_path=defaults.indices*' { "defaults": { "indices.analysis.hunspell.dictionary.ignore_case": "false", "indices.analysis.hunspell.dictionary.lazy": "false", "indices.breaker.accounting.limit": "100%", "indices.breaker.accounting.overhead": "1.0", "indices.breaker.fielddata.limit": "40%", "indices.breaker.fielddata.overhead": "1.03", "indices.breaker.fielddata.type": "memory", "indices.breaker.request.limit": "60%", "indices.breaker.request.overhead": "1.0", "indices.breaker.request.type": "memory", "indices.breaker.total.limit": "95%", "indices.breaker.total.use_real_memory": "true", "indices.breaker.type": "hierarchy", "indices.cache.cleanup_interval": "1m", "indices.fielddata.cache.size": "-1b", "indices.id_field_data.enabled": "true", "indices.lifecycle.history_index_enabled": "true", "indices.lifecycle.poll_interval": "10m", "indices.mapping.dynamic_timeout": "30s", "indices.memory.index_buffer_size": "10%", "indices.memory.interval": "5s", "indices.memory.max_index_buffer_size": "-1", "indices.memory.min_index_buffer_size": "48mb", "indices.memory.shard_inactive_time": "5m", "indices.queries.cache.all_segments": "false", "indices.queries.cache.count": "10000", "indices.queries.cache.size": "10%", "indices.query.bool.max_clause_count": "1024", "indices.query.query_string.allowLeadingWildcard": "true", "indices.query.query_string.analyze_wildcard": "false", "indices.recovery.internal_action_long_timeout": "1800000ms", "indices.recovery.internal_action_timeout": "15m", "indices.recovery.max_bytes_per_sec": "40mb", "indices.recovery.max_concurrent_file_chunks": "2", "indices.recovery.recovery_activity_timeout": "1800000ms", "indices.recovery.retry_delay_network": "5s", "indices.recovery.retry_delay_state_sync": "500ms", "indices.requests.cache.expire": "0ms", "indices.requests.cache.size": "1%", "indices.store.delete.shard.timeout": "30s" } } | 关于为什么在默认开启indices.breaker.total.use_real_memory之后,如果GC算法是G1的话,会频繁触发熔断呢? 先解释一下G1的几个参数: - InitiatingHeapOccupancyPercent:表示G1 GC并行循环初始设置的堆大小值,这个值决定了一个并行循环是不是要开始执行。它的逻辑是在一次GC完成后,比较老年代占用的空间和整个Java堆之间的比例。如果大于这个值,则预约下一次GC开始一个并行循环回收垃圾,从初始标记阶段开始。这个值越小,GC越频繁,反之,值越大,可以让应用程序执行时间更长。不过在内存消耗很快的情况下,我认为早运行并行循环比晚运行要好,看病要趁早。 - G1NewSizePercent:年轻代初始化值,默认是 5% - G1MaxNewSizePercent:年轻代占用最大值,最大值默认是整个Java堆大小的60% 关于该问题具体分析可以看:https://github.com/elastic/elasticsearch/pull/46169 简单来说就是:es jvm.options之前的默认配置会导致老年代+年轻代的内存占用超过95%(理论上内存阈值会达到60+75=135),从而导致频繁的熔断。 修复该问题最有效的方式是根据不同版本JDK调整GC算法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # GC configuration 8-13:-XX:+UseConcMarkSweepGC 8-13:-XX:CMSInitiatingOccupancyFraction=75 8-13:-XX:+UseCMSInitiatingOccupancyOnly ## G1GC Configuration # NOTE: G1 GC is only supported on JDK version 10 or later # to use G1GC, uncomment the next two lines and update the version on the # following three lines to your version of the JDK # 10-13:-XX:-UseConcMarkSweepGC # 10-13:-XX:-UseCMSInitiatingOccupancyOnly 14-:-XX:+UseG1GC 14-:-XX:G1ReservePercent=25 14-:-XX:InitiatingHeapOccupancyPercent=30 | 这也是最新版es jvm.options中的默认配置。 ### Apache Lucene 评分简介 - URL: https://jiankunking.com/apache-lucene-scoring-introduction.html - Content type: repost - Published: 2020-11-21 - Updated: 2020-11-21 - Summary: 介绍 Apache Lucene 的文档相关性评分机制,对比 TF-IDF 与 BM25 的计算思路和主要差异。 - Categories: Elasticsearch - Tags: Elasticsearch, Lucene, Score The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 集成容器云 - URL: https://jiankunking.com/integrated-container-cloud.html - Content type: original - Published: 2020-11-10 - Updated: 2020-11-10 - Summary: 将容器管理平台(Compass)的能力集成到内部控制台,实现日志、打包、权限等功能的统一管理,提升开发者体验。 - Categories: Kubernetes - Tags: Kubernetes, Design, Architecture Article text: 文章速览 将容器管理平台(Compass)的能力集成到内部控制台,实现日志、打包、权限等功能的统一管理,提升开发者体验。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes 将容器管理平台(Compass)的能力集成到内部控制台,实现日志、打包、权限等功能的统一管理,提升开发者体验。 功能分析 图中虚线框是直接对接Compass的api,其余的都是对接的Kubernetes,但兼容了Compass。 集群规模 Kubernetes 集群数 10+,单集群机器30+。 ### To Be a Better Man - URL: https://jiankunking.com/to-be-a-better-man.html - Content type: original - Published: 2020-11-06 - Updated: 2020-11-06 - Summary: 记录关于主动沟通、直接表达、保持开放心态、通过更多经历积累判断力,以及从不同视角提升认知层次的个人成长思考。 - Categories: Life - Tags: Life Article text: 文章速览 记录关于主动沟通、直接表达、保持开放心态、通过更多经历积累判断力,以及从不同视角提升认知层次的个人成长思考。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Life 昨天跟磊哥聊天的时候,有几个很深的感触:要Open一点、主动一点,不要那么在意别人的感受;要多经历一些事,才能体会的更多;要提高Level,通过不同的视角,看到不一样的东西。 昨天跟磊哥聊天的时候,有几个很深的感触,记录一下: 1、Open一点,主动一点,不要那么在意别人的感受(因为很多时候,你所想的别人的感受与别人真实的感受是不一样的),直接、简单的表达自己。表达出自己的观点。 2、要多经历一些事,才能体会的更多。 3、要提高Level,通过不同的视角,看不到不一样的东西。 ### [转]Elasticsearch 集群内应该设置多少个分片(shard)? - URL: https://jiankunking.com/how-many-shards-should-i-have-in-elasticsearch-cluster.html - Content type: repost - Published: 2020-10-13 - Updated: 2020-10-13 - Summary: 讨论 Elasticsearch 集群应该设置多少分片及多大的分片,并说明容量、恢复速度和查询性能之间的权衡。 - Categories: Elasticsearch - Tags: Elasticsearch, Shard, Cluster - Original source: https://www.elastic.co/blog/how-many-shards-should-i-have-in-my-elasticsearch-cluster The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 控制台 - URL: https://jiankunking.com/console-features.html - Content type: original - Published: 2020-09-25 - Updated: 2020-09-25 - Summary: 梳理内部开发控制台已经实现和计划建设的功能,包括容器云集成、日志查询、构建发布、应用管理与权限治理,为后续平台迭代提供功能清单。 - Categories: Kubernetes - Tags: Kubernetes, Design Article text: 文章速览 梳理内部开发控制台已经实现和计划建设的功能,包括容器云集成、日志查询、构建发布、应用管理与权限治理,为后续平台迭代提供功能清单。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes 今天思考Q4要做啥的时候,顺便梳理一下目前已经或者准备做的功能。 集成容器云部分 ### Kubernetes中Java应用Heap Dump - URL: https://jiankunking.com/how-to-do-java-jvm-heap-dump-in-kubernetes.html - Content type: original - Published: 2020-09-12 - Updated: 2020-09-12 - Summary: 在Kubernetes环境中,开发人员没有机器权限,无法直接获取Java Heap Dump文件。本文介绍一种将Heap Dump自动上传到OSS的解决方案。 - Categories: Kubernetes - Tags: Java, Kubernetes, Heap, Dump Article text: 文章速览 在Kubernetes环境中,开发人员没有机器权限,无法直接获取Java Heap Dump文件。本文介绍一种将Heap Dump自动上传到OSS的解决方案。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes 在Kubernetes环境中,开发人员没有机器权限,无法直接获取Java Heap Dump文件。本文介绍一种将Heap Dump自动上传到OSS的解决方案。 伴随着微服务及容器化的发展,越来越多的应用运行在kubernetes集群中,运维、调试的问题也随之而来。以Java为例,当线上环境出现内存问题,比如OOM,这时候需要Dump内存进行分析的时候,就会发现对于普通开发人员来说他们没有操作kubernetes集群机器的权限,从而导致,Dump出来的文件无法回传到开发手中进行MAT之类的分析。 本文的解决办法是这样的,当用户需要Dump某个应用实例的时候,只需要在实例终端界面点击一下按钮,后台会自动Dump Heap到OSS上,上传完成后,会将下载的信息展示在列表页,这时候开发人员就可以进行下载了。 具体的操作流程是这样的: 1、检测Pod中是否有JDK TOOLS 2、拷贝Dump工具到对应的Pod中 3、赋予Dump工具可执行权限 4、运行Dump工具 Dump工具会识别Java进程,如果有多个Java进程会Dump进程号小的那一个。 核心代码主要是拷贝Dump工具到对应的Pod中 jmap.go 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | package dump import ( "bytes" "context" "errors" "fmt" "log" "os" "strings" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" ) var jmapDumpTool = "jmap-dump-tool" func init() { // jmap-dump-tool 文件名 if os.Getenv("JMAP_DUMP_TOOL_NAME") != "" { jmapDumpTool = os.Getenv("JMAP_DUMP_TOOL_NAME") } log.Println("JMAP_DUMP_TOOL_NAME:" + jmapDumpTool) } func DumpJavaHeap(ctx context.Context, project, app, env, cluster, namespace, podID, container string) error { // todo 获取k8s信息部分 需要替换成自己的 ctxName := meta.GetContextName(cluster) kclient, err := k8s.GetClient(ctxName) if err != nil { log.Println(err) return errors.New("获取kubernetes client失败!") } // 获取kube config配置 config, err := k8s.GetClientConfig(ctxName) if err != nil { log.Println(err) return errors.New("获取kube config失败!") } pod := new(pod) pod.Namespace = namespace pod.Name = podID pod.ContainerName = container log.Println("开始检测目标容器中是否有JDK Tool") //检测是否 容器中是否有jps命令 cmds := []string{"sh", "-c", "jps"} req := kclient.CoreV1().RESTClient(). Post(). Namespace(pod.Namespace). Resource("pods"). Name(pod.Name). SubResource("exec"). VersionedParams(&corev1.PodExecOptions{ Container: pod.ContainerName, Command: cmds, Stdin: true, Stdout: true, Stderr: true, TTY: false, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { log.Println(err) return &customerr.JavaHeapDumpError{Msg: "检查对应容器中是否有JDK Tools时发生异常!"} } bufErr := new(bytes.Buffer) err = exec.Stream(remotecommand.StreamOptions{ Stdin: strings.NewReader(""), Stdout: os.Stdout, Stderr: bufErr, Tty: false, }) if bufErr.Len() > 0 { e := string(bufErr.Bytes()) if e != "" { log.Println(e) return &customerr.JavaHeapDumpError{Msg: "请检查对应容器中是否有JDK Tools," + e} } } log.Println("检测完成,目标容器中有JDK Tool") srcPath := "/" + jmapDumpTool log.Println("srcPath:" + srcPath) destPath := "/" + jmapDumpTool log.Println("destPath:" + destPath) err = pod.copyToPod(ctx, kclient, config, srcPath, destPath) if err != nil { log.Println(err) return &customerr.JavaHeapDumpError{Msg: "dump工具拷贝失败!"} } log.Println("jmap-dump-tool copy to pod end") // 赋予可执行权限 cmds = []string{"sh", "-c", "chmod +x /" + jmapDumpTool} err = pod.Exec(ctx, kclient, config, cmds) if err != nil { log.Println(err) return &customerr.JavaHeapDumpError{Msg: "dump工具赋予可执行权限失败!"} } log.Println("jmap-dump-tool 已赋予可执行权限") go execDump(*pod, jmapDumpTool, project, app, env, kclient, config) return nil } func execDump(p pod, jmapDumpTool, project, app, env string, client *kubernetes.Clientset, config *rest.Config) { // 执行 c := fmt.Sprintf("/%s %s %s %s", jmapDumpTool, project, app, env) cmds := []string{"sh", "-c", c} err := p.Exec(context.Background(), client, config, cmds) if err != nil { log.Println(err) } } | cp.go(参考kubectl cp的实现) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | package dump import ( "archive/tar" "context" "fmt" "io" "io/ioutil" "log" "os" "path" "strings" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" ) type pod struct { Name string Namespace string ContainerName string } func (i *pod) copyToPod(ctx context.Context, client *kubernetes.Clientset, config *rest.Config, srcPath string, destPath string) error { reader, writer := io.Pipe() if destPath != "/" && strings.HasSuffix(string(destPath[len(destPath)-1]), "/") { destPath = destPath[:len(destPath)-1] } if err := checkDestinationIsDir(ctx, client, config, i, destPath); err == nil { destPath = destPath + "/" + path.Base(srcPath) } go func() { defer writer.Close() err := makeTar(srcPath, destPath, writer) if err != nil { fmt.Println(err) } }() var cmdArr []string cmdArr = []string{"tar", "-xf", "-"} destDir := path.Dir(destPath) if len(destDir) > 0 { cmdArr = append(cmdArr, "-C", destDir) } //remote shell. req := client.CoreV1().RESTClient(). Post(). Namespace(i.Namespace). Resource("pods"). Name(i.Name). SubResource("exec"). VersionedParams(&corev1.PodExecOptions{ Container: i.ContainerName, Command: cmdArr, Stdin: true, Stdout: true, Stderr: true, TTY: false, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { return err } err = exec.Stream(remotecommand.StreamOptions{ Stdin: reader, Stdout: os.Stdout, Stderr: os.Stderr, Tty: false, }) if err != nil { return err } return nil } func checkDestinationIsDir(ctx context.Context, client *kubernetes.Clientset, config *rest.Config, i *pod, destPath string) error { return i.Exec(ctx, client, config, []string{"test", "-d", destPath}) } func makeTar(srcPath, destPath string, writer io.Writer) error { // TODO: use compression here? tarWriter := tar.NewWriter(writer) defer tarWriter.Close() srcPath = path.Clean(srcPath) destPath = path.Clean(destPath) return recursiveTar(path.Dir(srcPath), path.Base(srcPath), path.Dir(destPath), path.Base(destPath), tarWriter) } func recursiveTar(srcBase, srcFile, destBase, destFile string, tarWriter *tar.Writer) error { filepath := path.Join(srcBase, srcFile) stat, err := os.Lstat(filepath) if err != nil { return err } if stat.IsDir() { files, err := ioutil.ReadDir(filepath) if err != nil { return err } if len(files) == 0 { //case empty directory hdr, _ := tar.FileInfoHeader(stat, filepath) hdr.Name = destFile if err := tarWriter.WriteHeader(hdr); err != nil { return err } } for _, f := range files { if err := recursiveTar(srcBase, path.Join(srcFile, f.Name()), destBase, path.Join(destFile, f.Name()), tarWriter); err != nil { return err } } return nil } else if stat.Mode()&os.ModeSymlink != 0 { //case soft link hdr, _ := tar.FileInfoHeader(stat, filepath) target, err := os.Readlink(filepath) if err != nil { return err } hdr.Linkname = target hdr.Name = destFile if err := tarWriter.WriteHeader(hdr); err != nil { return err } } else { //case regular file or other file type like pipe hdr, err := tar.FileInfoHeader(stat, filepath) if err != nil { return err } hdr.Name = destFile err = tarWriter.WriteHeader(hdr) if err != nil { log.Println(err) return err } f, err := os.Open(filepath) if err != nil { return err } defer f.Close() if _, err := io.Copy(tarWriter, f); err != nil { return err } return f.Close() } return nil } func (i *pod) Exec(ctx context.Context, client *kubernetes.Clientset, config *rest.Config, cmd []string) error { req := client.CoreV1().RESTClient(). Post(). Namespace(i.Namespace). Resource("pods"). Name(i.Name). SubResource("exec"). VersionedParams(&corev1.PodExecOptions{ Container: i.ContainerName, Command: cmd, Stdin: true, Stdout: true, Stderr: true, TTY: false, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { return err } err = exec.Stream(remotecommand.StreamOptions{ Stdin: strings.NewReader(""), Stdout: os.Stdout, Stderr: os.Stderr, Tty: false, }) if err != nil { return err } return nil } | ### 一个Kubernetes Web终端连接工具 - URL: https://jiankunking.com/kubernetes-client-go-how-to-make-a-web-terminal.html - Content type: original - Published: 2020-09-12 - Updated: 2020-09-12 - Summary: 介绍如何使用 Kubernetes client-go 实现 Web 终端,让开发人员通过浏览器进入 Pod 进行调试。 - Categories: Kubernetes - Tags: Kubernetes, client-go, exec, terminal Article text: 文章速览 介绍如何使用 Kubernetes client-go 实现 Web 终端,让开发人员通过浏览器进入 Pod 进行调试。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes 当应用部署到Kubernetes集群中之后,如何提供Web终端的功能,以便开发人员调试? 方案一 该功能的核心就是实现kubernetes executor接口 exec.go 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | package pod import ( "context" "errors" "log" "net/http" "sync" "github.com/gorilla/websocket" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/remotecommand" ) // 封装websocket连接 type WsConnection struct { wsSocket *websocket.Conn // 底层websocket inChan chan *WsMessage // 读取队列 outChan chan *WsMessage // 发送队列 mutex sync.Mutex // 避免重复关闭管道 isClosed bool closeChan chan byte // 关闭通知 } // web终端发来的包 type xtermMessage struct { MsgType string `json:"type"` // 类型:resize客户端调整终端, input客户端输入 Input string `json:"input"` // msgtype=input情况下使用 Rows uint16 `json:"rows"` // msgtype=resize情况下使用 Cols uint16 `json:"cols"` // msgtype=resize情况下使用 } // websocket消息 type WsMessage struct { MessageType int Data []byte } // 关闭连接 func (wsConn *WsConnection) WsClose() { wsConn.wsSocket.Close() wsConn.mutex.Lock() defer wsConn.mutex.Unlock() if !wsConn.isClosed { wsConn.isClosed = true close(wsConn.closeChan) } } // ssh流式处理器 type streamHandler struct { wsConn *WsConnection resizeEvent chan remotecommand.TerminalSize } // executor回调获取web是否resize func (handler *streamHandler) Next() (size *remotecommand.TerminalSize) { ret := <-handler.resizeEvent size = &ret return } // 发送返回消息到协程 func (wsConn *WsConnection) WsWrite(messageType int, data []byte) (err error) { select { case wsConn.outChan <- &WsMessage{messageType, data}: case <-wsConn.closeChan: err = errors.New("WsWrite websocket closed") break } return } // 读取协程 func (wsConn *WsConnection) wsReadLoop() { for { // 读一条message // ReadMessage返回的messageType只可能是:TextMessage BinaryMessage msgType, data, err := wsConn.wsSocket.ReadMessage() if err != nil { log.Println(err) break } //log.Print(string(data)) // 放入请求队列 wsConn.inChan <- &WsMessage{ msgType, data, } } } // 发送协程 func (wsConn *WsConnection) wsWriteLoop() { // 服务端返回给页面的数据 for { select { // 取一个应答 case msg := <-wsConn.outChan: //log.Print(string(msg.Data)) // 写给web websocket if err := wsConn.wsSocket.WriteMessage(msg.MessageType, msg.Data); err != nil { log.Println(err) break } case <-wsConn.closeChan: wsConn.WsClose() return } } } func (wsConn *WsConnection) onContextCancel(ctx context.Context) { for { select { case <-ctx.Done(): log.Println("web cancel context or time out..........") wsConn.WsClose() return } } } // 读取 页面消息到协程 func (wsConn *WsConnection) WsRead() (msg *WsMessage, err error) { select { case msg = <-wsConn.inChan: return case <-wsConn.closeChan: err = errors.New("WsRead websocket closed") break } return } // executor回调读取web端的输入 func (handler *streamHandler) Read(p []byte) (size int, err error) { // 读web发来的输入 msg, err := handler.wsConn.WsRead() if err != nil { handler.wsConn.WsClose() return } xtermMsg := &xtermMessage{ //MsgType: string(msg.MessageType), Input: string(msg.Data), } // 放到channel里,等remotecommand executor调用我们的Next取走 handler.resizeEvent <- remotecommand.TerminalSize{Width: xtermMsg.Cols, Height: xtermMsg.Rows} size = len(xtermMsg.Input) copy(p, xtermMsg.Input) return } // executor回调向web端输出 func (handler *streamHandler) Write(p []byte) (size int, err error) { // 产生副本 copyData := make([]byte, len(p)) copy(copyData, p) size = len(p) err = handler.wsConn.WsWrite(websocket.TextMessage, copyData) return } func ContainerExec(ctx context.Context, r *http.Request, w http.ResponseWriter, cluster, namespace, podID, container string) error { // todo 获取k8s信息部分 需要替换成自己的 ctxName := meta.GetContextName(cluster) kclient, err := k8s.GetClient(ctxName) if err != nil { log.Println(err) return err } cmds := []string{"sh", "-c", "test -f /bin/bash && bash || sh"} option := &corev1.PodExecOptions{ Command: cmds, Stdin: true, Stdout: true, Stderr: true, TTY: true, Container: container, } subCtx, cancel := context.WithTimeout(ctx, models.READ_LOG_TIMEOUT) defer cancel() req := kclient.CoreV1().RESTClient(). Post(). Resource("pods"). Name(podID). Namespace(namespace). SubResource("exec"). VersionedParams(option, scheme.ParameterCodec).Timeout(models.READ_LOG_TIMEOUT) wsSocket, err := upGrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return err } wsConn := &WsConnection{ wsSocket: wsSocket, inChan: make(chan *WsMessage, 1000), outChan: make(chan *WsMessage, 1000), closeChan: make(chan byte), isClosed: false, } // 获取kube config配置 config, err := k8s.GetClientConfig(ctxName) if err != nil { wsConn.WsClose() log.Println(err) return err } // 创建到容器的连接 executor, err := remotecommand.NewSPDYExecutor(config, http.MethodPost, req.URL()) if err != nil { wsConn.WsClose() log.Println(err) return err } // 页面读入输入 协程 go wsConn.wsReadLoop() // 服务端返回数据 协程 go wsConn.wsWriteLoop() // 监听前端请求 go wsConn.onContextCancel(subCtx) // 配置与容器之间的数据流处理回调 handler := &streamHandler{wsConn: wsConn, resizeEvent: make(chan remotecommand.TerminalSize)} if err = executor.Stream(remotecommand.StreamOptions{ Stdin: handler, Stdout: handler, Stderr: handler, TerminalSizeQueue: handler, Tty: true, }); err != nil { log.Println("handler", err) return err } return err } | 参考资料: https://github.com/jiankunking/k8-web-terminal 方案一存在内存泄漏问题,https://github.com/kubernetes/client-go/issues/884 方案二 exec.go 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "time" "github.com/gorilla/websocket" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/remotecommand" "git.jiankunking.net/console/k8s-ext/k8s" "git.jiankunking.net/console/k8s-ext/pkg/models" ) // https://github.com/kubernetes/dashboard/blob/master/src/app/backend/handler/terminal.go type PtyHandler interface { io.Reader io.Writer remotecommand.TerminalSizeQueue } const END_OF_TRANSMISSION = "\u0004" // TerminalMessage is the messaging protocol between ShellController and TerminalSession. // // OP DIRECTION FIELD(S) USED DESCRIPTION // --------------------------------------------------------------------- // bind fe->be SessionID Id sent back from TerminalResponse // stdin fe->be Data Keystrokes/paste buffer // resize fe->be Rows, Cols New terminal size // stdout be->fe Data Output from the process // toast be->fe Data OOB message to be shown to the user type TerminalMessage struct { Op, Data string //SessionID string `json:",omitempty"` Rows, Cols uint16 `json:",omitempty"` } // TerminalSession type TerminalSession struct { ID string wsConn *websocket.Conn sizeChan chan remotecommand.TerminalSize doneChan chan struct{} } // TerminalSize handles pty->process resize events // Called in a loop from remotecommand as long as the process is running func (t *TerminalSession) Next() *remotecommand.TerminalSize { select { case size := <-t.sizeChan: return &size case <-t.doneChan: return nil } } // Read handles pty->process messages (stdin, resize) // Called in a loop from remotecommand as long as the process is running func (t *TerminalSession) Read(p []byte) (int, error) { _, message, err := t.wsConn.ReadMessage() if err != nil { log.Printf("%s: read ws message failed: %v", t.ID, err) return copy(p, END_OF_TRANSMISSION), err } log.Printf("%s: read", t.ID) var msg TerminalMessage if err := json.Unmarshal(message, &msg); err != nil { // TODO: temp workaround for non-json input return 0, nil //log.Printf("%s: json decoded failed: %v", t.ID, err) //return copy(p, END_OF_TRANSMISSION), err } switch msg.Op { case "stdin": return copy(p, msg.Data), nil case "resize": t.sizeChan <- remotecommand.TerminalSize{Width: msg.Cols, Height: msg.Rows} return 0, nil default: log.Printf("%s: unknown message type '%s'", t.ID, msg.Op) return copy(p, END_OF_TRANSMISSION), fmt.Errorf("unknown message type '%s'", msg.Op) } } // Write handles process->pty stdout // Called from remotecommand whenever there is any output func (t *TerminalSession) Write(p []byte) (int, error) { //msg, err := json.Marshal(TerminalMessage{ // Op: "stdout", // Data: string(p), //}) //if err != nil { // log.Printf("json encode failed: %v", err) // return 0, err //} if err := t.wsConn.WriteMessage(websocket.TextMessage, p); err != nil { log.Printf("%s: write ws message failed: %v", t.ID, err) return 0, err } log.Printf("%s: write", t.ID) return len(p), nil } func (t *TerminalSession) Close() error { close(t.doneChan) return t.wsConn.Close() } func newTerminalSession(id string, r *http.Request, w http.ResponseWriter) (*TerminalSession, error) { conn, err := upGrader.Upgrade(w, r, nil) if err != nil { return nil, err } return &TerminalSession{ ID: id, wsConn: conn, sizeChan: make(chan remotecommand.TerminalSize), doneChan: make(chan struct{}), }, nil } func ContainerExec(ctx context.Context, r *http.Request, w http.ResponseWriter, cluster, namespace, podName, container string) error { kclient, err := k8s.GetKubeClient(cluster) if err != nil { log.Println(err) return err } // 获取kube config配置 config, err := k8s.GetClientConfig(cluster) if err != nil { log.Println(err) return err } cmd := []string{"sh", "-c", "test -f /bin/bash && bash || sh"} option := &corev1.PodExecOptions{ Command: cmd, Stdin: true, Stdout: true, Stderr: true, TTY: true, Container: container, } //ctx, cancel := context.WithTimeout(ctx, models.READ_LOG_TIMEOUT) //defer cancel() req := kclient.CoreV1().RESTClient(). Post(). Resource("pods"). Name(podName). Namespace(namespace). SubResource("exec"). VersionedParams(option, scheme.ParameterCodec). Timeout(models.READ_LOG_TIMEOUT) executor, err := remotecommand.NewSPDYExecutor(config,… ### [译]Java Volatile Keyword - URL: https://jiankunking.com/java-volatile-keyword.html - Content type: translation - Published: 2020-08-14 - Updated: 2020-08-14 - Summary: 翻译介绍 Java volatile 关键字的可见性、禁止指令重排序、适用场景及其与锁的区别。 - Categories: Java - Tags: Java, AQS, Volatile - Original source: http://tutorials.jenkov.com/java-concurrency/volatile.html The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### ReentrantLock 重入与重试 - URL: https://jiankunking.com/java-reentrantlock-reentrant-and-retryry.html - Content type: original - Published: 2020-08-12 - Updated: 2020-08-12 - Summary: ReentrantLock获取锁时会自旋吗?可重入次数有上限吗?本文基于JDK 12源码分析ReentrantLock的重入机制和自旋策略。 - Categories: Java - Tags: Java, AQS, ReentrantLock Article text: 文章速览 ReentrantLock获取锁时会自旋吗?可重入次数有上限吗?本文基于JDK 12源码分析ReentrantLock的重入机制和自旋策略。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java ReentrantLock获取锁时会自旋吗?可重入次数有上限吗?本文基于JDK 12源码分析ReentrantLock的重入机制和自旋策略。 今天突然想起了个问题: 1、ReentrantLock在获取锁的时候,会不会自旋?如果自旋的话,会自旋多少次? 2、ReentrantLock可重入次数会有上限控制吗? 1、看一下ReentrantLock中锁的实现: 非公平锁: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /** * Performs non-fair tryLock. tryAcquire is implemented in * subclasses, but both need nonfair try for trylock method. */ @ReservedStackAccess final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } | 公平锁: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | /** * Fair version of tryAcquire. Don't grant access unless * recursive call or no waiters or is first. */ @ReservedStackAccess protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } } | 从代码中可以看到不管是公平锁还是非公平锁再获取不到锁的时候,都没有自旋。区别在于非公平锁在获取锁的时候,会先获取一下锁,而公平锁会先判断队列中有没有。 同时在代码中也没有看到获取不到锁就入队,难道会在外面自旋重试?看下AbstractQueuedSynchronizer中代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | /** * Acquires in exclusive mode, ignoring interrupts. Implemented * by invoking at least once {@link #tryAcquire}, * returning on success. Otherwise the thread is queued, possibly * repeatedly blocking and unblocking, invoking {@link * #tryAcquire} until success. This method can be used * to implement method {@link Lock#lock}. * * @param arg the acquire argument. This value is conveyed to * {@link #tryAcquire} but is otherwise uninterpreted and * can represent anything you like. */ public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } | 从这里可以看到获取不到锁,就直接入队了。 2、因为AQS中的state使用int类型存储,最大到2^31-1。 ### [译]Sentry:如何从数据存储中获得更强的一致性 - URL: https://jiankunking.com/sentry-how-to-get-stronger-consistency-out-of-a-datastore.html - Content type: translation - Published: 2020-07-03 - Updated: 2020-07-03 - Summary: 翻译介绍 Sentry 如何协调 ClickHouse 写入、消费和副本读取,在分布式数据存储中获得更强的一致性。 - Categories: Sentry - Tags: Sentry, Stronger, Consistency, Datastore - Original source: https://blog.sentry.io/2019/09/17/how-to-get-stronger-consistency-out-of-a-datastore The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 云原生时代的Spring Boot - URL: https://jiankunking.com/spring-boot-clound-native-by-graalvm.html - Content type: original - Published: 2020-06-20 - Updated: 2020-06-20 - Summary: 云原生时代,Spring Boot应用面临启动慢、内存占用大的挑战。本文探讨如何利用GraalVM实现原生编译,提升Java应用的云原生适配能力。 - Categories: Java - Tags: Java, Cloud, Native Article text: 文章速览 云原生时代,Spring Boot应用面临启动慢、内存占用大的挑战。本文探讨如何利用GraalVM实现原生编译,提升Java应用的云原生适配能力。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 云原生时代,Spring Boot应用面临启动慢、内存占用大的挑战。本文探讨如何利用GraalVM实现原生编译,提升Java应用的云原生适配能力。 Spring Boot毫无疑问是Java后端开发的第一大框架,基于Spring Boot有着一套完整的工具链,各种各样的starter。对于日常业务开发而言,可以说是轮子很全。 但随着云原生时代的到来,Spring Boot应用或者说是Java应用却暴露出了一些问题,其中比较突出的有: - 启动慢 - 应用内存占用多 其中启动慢的主要原因:代码编译。 当然对于Spring Boot来说,Bean实例注入也会花费一定的时间,但花费时间相比编译会小的多。大家可以通过开启延迟初始化试试。 1 2 3 | spring: main: lazy-initialization: true | Spring Boot 2.2开始支持。 个人本地开启延迟初始化之后,启动能快了1~2秒,整个启动时间10秒左右。 测试机配置:i7-6500U 2.50@GHz 内存:16G 内存占用多主要是内存占用后不会归还操作系统,这个正在逐步改善: - G1 JDK12及之后 已支持 - ZGC JDK13及之后 已支持 由于Java语言的特性及Spring Boot的一些实现方式,决定了即便是开启了G1/ZGC的未使用内存及时归还操作系统,Spring Boot的内存占用,仍然远大于Golang这种编译型语言。 2017年9月,Java 9发布,在Java 9中引入了AOT(Ahead-of-Time Compilation)。 AOT在内部使用是通过GraalVM来生成代码的。 但对于普通用户而言通过Java的AOT去编译Spring程序还是不可行的。 那么有没有一种比较优雅的解决方案呢?既能使用Spring Boot又能像Golang一样启动快、内存占用低? 有朋友可能想到了Quarkus、Micronaut,但这两个框架如果是从头开始开发,可以考虑一下,但还是要注意两点: - 需要去学习使用 - 某些库有可能不支持 其实,Java想要解决云原生时代的问题,目前的方案基本都是基于GraalVM来的,不管是Quarkus还是Micronaut都是。 那么,Spring Boot有没有类似的方案呢? 答案是有的。 在spring-projects-experimental Organizations 下有这么一个项目:spring-graalvm-native 目前已发布到0.7.0 release,不过从github的文档中可以看到这个项目的状态仍然是alpha,也就是说目前用到生产中还是为时过早。 希望能早日spring-graalvm-native能早日发布生产可用版本吧。 graalvm+AOT如此美好? 其实,GraalVM目前来看还是有一些局限的: Not Supported - Dynamic Class Loading/Unloading - Runtime Bytecode Generation * - InvokeDynamic Bytecode and Method Handles - …… Require Configuration - Resource Access - Reflection - Dynamic Proxy(JDK,not CGLIB) - JNI (Java Native Interface) - …… 更详细限制可以看: https://github.com/oracle/graal/blob/master/substratevm/LIMITATIONS.md 同时,由于提前编译无法像JIT那样获取到运行时的信息,所以在做Profile-Guided Optimization,PGO时,会更麻烦。 具体做法:https://www.graalvm.org/docs/release-notes/19_2/ JIT会做的典型的PGO1: - type-feedback optimization:主要针对多态的面向对象程序来做优化。根据profile收集到的receiver type信息来把原本多态的虚方法调用点(virtual method call site)或属性访问点(property access site)根据类型来去虚化(devirtualize)。 - single-value profiling:这个相对少见一些。它的思路是有些参数、函数返回值可能在一次运行中只会遇到一个具体值。如果是这样的话可以把那个具体值给记录下来,然后在JIT编译时把它当作常量来做优化,于是常见的常量相关优化(常量折叠、条件常量传播等)就可以针对一个静态意义上本来不是常量的值来做了。branch-profile-based code scheduling:主要目的是把“热”的(频繁执行的)代码路径集中放在一起,而把“冷”的(不频繁执行的)代码路径放到别的地方。AOT编译的话常常会利用一些静态的启发条件来猜测哪些路径比较热,或者让用户指定哪些路径比较热(例如likely()/unlikely()宏),而JIT搭配PGO的话可以有比较准确的路径热度信息,对应可以做的优化也就更吻合实际执行情况,于是效果会更好。 - profile-guided inlining heuristics:根据profile信息得知函数调用点的热度,从而影响内联决策——对某个调用点,到底值不值得把目标函数内联进来。 - implicit exception:隐式异常,例如Java/C#的空指针异常检查,又例如Java/C#的除以零检查。这些异常如果在某块代码里从来没有发生过,就可以用更快的方式来实现,而不必生成显式检查代码。但如果在某块代码经常发生这种异常,则显式检查会更快。 附录: 1. JIT会做的典型的PGO https://www.zhihu.com/question/52572852↩︎ 个人思考: 其实,通过openjdk jeps及spring boot的一些实验性的项目可以看出,Java正在实现一些新的特性:比如本文提到的AOT,Loom来解决Java的一些痛点。 但这些新的特性具体什么时候能用于生产还是一个未知数。 相对于Golang,在使用Java的过程中,我个人感觉有以下几个痛点: - 没有协程,无法轻量的异步。 - 也正是没有协程,IO请求的阻塞,会导致线程上下文的切换,成本太高。 - 内存占用过高 - 没有Context,调用方请求取消,感知不到;调用别人的时候,也没有办法很好的传递调用状态及请求取消。 ### [转]Go Select 实现分析 - URL: https://jiankunking.com/go-select.html - Content type: repost - Published: 2020-06-13 - Updated: 2020-06-13 - Summary: 深入分析Go语言select关键字的实现原理,介绍scase与hselect等核心数据结构,以及无case、单case、非阻塞和多case场景下的运行机制。 - Categories: Go - Tags: Go, Select - Original source: https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-select/ The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [译]Go 并发 : Context - URL: https://jiankunking.com/go-concurrency-patterns-context.html - Content type: translation - Published: 2020-06-13 - Updated: 2020-06-13 - Summary: 翻译介绍 Go Context 的设计与使用方式,包括请求取消、超时控制和跨 Goroutine 传递请求范围数据。 - Categories: Go - Tags: Go, Concurrency, Context - Original source: https://blog.golang.org/context The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 学习开源代码该如何入手? - URL: https://jiankunking.com/how-to-start-learning-open-source-code.html - Content type: original - Published: 2020-06-12 - Updated: 2020-06-12 - Summary: 《消息队列高手课》学习笔记:通过文档了解项目、带着问题阅读源码、以点带面的方式学习开源代码。 - Categories: Learning - Tags: Open-Source, Reading-Method Article text: 文章速览 《消息队列高手课》学习笔记:通过文档了解项目、带着问题阅读源码、以点带面的方式学习开源代码。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Learning 《消息队列高手课》学习笔记:通过文档了解项目、带着问题阅读源码、以点带面的方式学习开源代码。 消息队列高手课 作者:李玥 - 通过文档了解项目 - 这个项目是干什么的? - 能解决哪些问题? - 适合在哪些场景使用? - 有哪些功能? - 如何使用? - 用以点带面的方式来阅读源码 - 最好是带着问题阅读源码,最好是带着问题答案去阅读源码 - RocketMQ的消息是怎么写到文件里的? - Kafka的Coordinator是怎么维护消费位置的? 学习开源代码该如何入手? ### [译]关于Go net/http 超时完全指南 - URL: https://jiankunking.com/go-net-http-timeout-sequence-diagram.html - Content type: translation - Published: 2020-05-23 - Updated: 2020-05-23 - Summary: 翻译介绍 Go net/http 的各类超时设置,通过时序分析区分服务器端和客户端不同阶段的超时行为。 - Categories: Go - Tags: Go, net/http, HTTP, Timeout - Original source: https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/ The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 重构:改善既有代码的设计 笔记 - URL: https://jiankunking.com/refactoring-improving-the-design-of-existing-code.html - Content type: original - Published: 2020-05-16 - Updated: 2020-05-16 - Summary: 《重构:改善既有代码的设计》学习笔记,整理重构原则、代码坏味道、常用重构手法、测试保护和选择重构时机等实践要点。 - Categories: Refactoring - Tags: Reading Notes, Design, Refactoring, Code Article text: 文章速览 《重构:改善既有代码的设计》学习笔记,整理重构原则、代码坏味道、常用重构手法、测试保护和选择重构时机等实践要点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Refactoring 本文整理自:《重构改善既有代码的设计》 作者:MartinFowler 出版时间:2015-08 重构,第一个案例 如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构那个程序,使特性添加比较容易进行,然后再添加特性。 每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。这些测试是必要的,因为尽管遵循重构手法可以使我避免绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。所以我需要可靠的测试。 测试过程中很重要的一部分,就是测试程序对于结果的报告方式,他们要么说”OK”,表示所有新字符串都和参考字符串一样,要么就列出失败清单,显示问题字符串的出现行号。这些测试都能够自我检验。是的,你必须让测试有能力自我检验,否则就得耗费大把时间来回比对,这会降低你的开发速度。 进行重构的时候, 我们需要依赖测试, 让它告诉我们是引入Bug。好的测试是重构的根本。花时间建立一个优良的测试机制是完全值得的,因为当你修改程序时,好测试会给你必要的安全保障。 重构原则 何谓重构 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 首先,重构的目的是使软件更容易被理解和修改。你可以在软件内部做很多修改,但必须对软件可观察的外部行为只造成很小变化,或甚至不造成变化。与之形成对比的是性能优化。和重构一样,性能优化通常不会改变组件的行为(除了执行速度),只会改变其内部结构。但是两者出发点不同:性能优化往往使代码较难理解,但为了得到所需的性能你不得不那么做。 我要强调的第二点是:重构不会改变软件可观察的行为——重构之后软件功能一如以往。任何用户,不论最终用户或其他程序员,都不知道已经有东西发生了变化。 何时重构 三次法则 Don Roberts 给了我一条准则:第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。 事不过三,三则重构。 复审代码时重构 如果是比较大的设计复审工作,那么在一个较大团队内保留多种观点通常会更好一些。此时直接展示代码往往不是最佳办法。我喜欢运用 UML 示意图展现设计,并以 CRC 卡展示软件情节。换句话说,我会和某个团队进行设计复审,而和单个复审者进行代码复审。 极限编程[ Beck , XP ]中的”结对编程”形式,把代码复审的积极性发挥到了极致。一旦采用这种形式,所有正式开发任务都由两名开发者在同一台机器上进行。这样便在开发过程中形成随时进行的代码复审工作,而重构也就被包含在开发过程内了。 重构的难题 修改接口 如今的问题是:该如何面对那些必须修改”已发布接口”的重构手法? 简言之,如果重构手法改变了已发布接口,你必须同时维护新旧两个接口,直到所有用户都有时间对这个变化做出反应。幸运的是,这不太困难。你通常都有办法把事情组织好,让旧接口继续工作。请尽量这么做:让旧接口调用新接口。当你要修改某个函数名称时,请留下旧函数,让它调用新函数。千万不要复制函数实现,那会让你陷入重复代码的泥悼中难以自拔。你还应该使用 Java 提供的 deprecation (不建议使用)设施,将旧接口标记为 deprecated 。这么一来你的调用者就会注意到它了。 这个过程的一个好例子就是 Java 容器类(集合类 , collection classes)。 Java 2的新容器取代了原先一些容器。当 Java 2容器发布时, JavaSoft 花了很大力气来为开发者提供一条顺利迁徙之路。 “保留旧接口”的办法通常可行,但很烦人。起码在一段时间里你必须构造并维护一些额外的函数。它们会使接口变得复杂,使接口难以使用。还好我们有另一个选择:不要发布接口。当然我不是说要完全禁止,因为很明显你总得发布一些接口。如果你正在建造供外部使用的 API (就像 Sim 公司所做的那样),就必须发布接口。之所以说尽量不要发布,是因为我常常看到一些开发团队公开了太多接口。我曾经看到一支三人团队这么工作:每个人都向另外两人公开发布接口。这使他们不得不经常来回维护接口,而其实他们原本可以直換进入程序库,径行修改自己管理的那一部分,那会轻松许多。过度强调代码所有权的团队常常会犯这种错误。发布接口很有用,但也有代价。所以除非真有必要,不要发布接口。这可能意味需要改变你的代码所有权观念,让每个人都可以修改别人的代码,以适应接口的改动。以结对编程的方式完成这一切通常是个好主意。 不要过早发布接口。请修改你的代码所有权政策,使重构更顺畅。 代码的坏味道 Duplicated Code 重复代码 LongMethod 过长方法 我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。 Large Class 过长类 Long Parameter List 过长参数列表 Divergent Change 发散式变化 我们希望软件能够更容易被修改——毕竟软件再怎么说本来就该是”软”的。**一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。**如果不能做到这点,你就嗅出两种紧密相关的刺鼻味道中的一种了。 如果某个类经常因为不同的原因在不同的方向上发生变化 ,Divergent Change就出现了。当你看着一个类说:”呃,如果新加入一个数据库,我必须修改这三个函数;如果新出现一种金融工具,我必须修改这四个函数。”那么此时也许将这个对象分成两个会更好,这么一来每个对象就可以只因一种变化而需要修改。当然,往往只有在加入新数据库或新金融工具后,你才能发现这一点。针对某一外界变化的所有相应修改,都只应该发生在单一类中,而这个新类内的所有内容都应该反应此变化。为此,你应该找出某特定原因而造成的所有变化,然后运用Extract Class将它们提炼到另一个类中。 Shotgun Surgery 霰弹式修改 Shotgun Surgery 类似 Divergent Change ,但恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是Shotgun Surgery。如果需要修改的代码散布四处,你不但很难找到它们,也很容易忘记某个重要的修改。 这种情况下你应该使用Move Method和 Move Filed把所有需要修改的代码放进同一个类。如果眼下没有合适的类可以安置这些代码,就创造一个。通常可以运用Inline Class把一系列相关行为放进同一个类 。 这可能会造成少量Divergent Change ,但你可以轻易处理它。 Divergent Change是指”一个类受多种变化的影响”,Shotgun Surgery则是指”一种变化引发多个类相应修改”。这两种情况下你都会希望整理代码使”外界变化”与”需要修改的类”趋于一一对应。 Feature Envy 特性依恋 对象技术的全部要点在于:这是一种”将数据和对数据的操作行为包装在一起”的技术。有一种经典气味是:函数对某个类的兴趣高过对自己所处类的兴趣。这种孺慕之情最通常的焦点便是数据。无数次经验里,我们看到某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法显而易见:把这个函数移至另一个地点。你应该使用Move Method把它移到它该去的地方。有时候函数中只有一部分受这种依恋之苦,这时候你应该使用Extract Method把这一部分提炼到独立函数中,再使用Move Method带它去它的梦中家园。 当然,并非所有情况都这么简单 。一个函数往往会用到几个类的功能,那么它究竟该被置于何处呢?我们的原则是:判断哪个类拥有最多被此函数使用的数据,然后就把这个函数和那些数据摆在一起。如果先以Extract Method将这个函数分解为数个较小函数并分别置放于不同地点,上述步骤也就比较容易完成了。 有几个复杂精巧的模式破坏了这个规则。说起这个话题, GoF的Strategy和Visitor立刻跳入我的脑海,Kent Beck的Self Delegation [Beck]也在此列。使用这些模式是为了对抗坏味道Divergent Change 。最根本的原则是:将总是一起变化的东西放在一块儿。数据和引用这些数据的行为总是一起变化的,但也有例外。如果例外出现,我们就搬移那些行为,保持变化只在一地发生。Strategy和Visitor使你得以轻松修改函数行为,因为它们将少量需被扭写的行为隔离开来―当然也付出了”多一层间接性”的代价。 Data Clumps 数据泥团 数据项就像小孩子,喜欢成群结队地待在一块儿。你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。首先请找出这些数据以字段形式出现的地方, 运用Extract Class将它们提炼到一个独立对象中。然后将注意力转移到函数签名上,运用Introduce Parameter Object(或Preserve Whole Object为它减肥。这么做的直接好处是可以将很多参数列缩短,简化函数调用。是的,不必在意Data Clumps只用上新对象的一部分字段,只要以新对象取代两个(或更多)字段,你就值回票价了。 一个好的评判办法是:删掉众多数据中的一项。这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是个明确信号:你应该为它们产生一个新对象。 减少字段和参数的个数,当然可以去除一些坏味道,但更重要的是:一旦拥有新对象,你就有机会让程序散发出一种芳香。得到新对象后,你就可以着手寻找Feature Envy,这可以帮你指出能够移至新类中的种种程序行为。不必太久, 所有的类都将在它们的小小社会中充分发挥价值。 Primitive Obsession 基本类型偏执 如果你有一组应该总是被放在一起的字段,可以运用Extract Class。如果你在参数列表中看到基本型数据,不妨试试Introduce Parameter Object。如果你发现自己正从数组中挑选数据,可运用Replace Array with Object。 Switch Statements switch 语句 面向对象程序的一个最明显特征就是:少用switch(或case) 语句。从本质上说,switch语句的问题在于重复。你常会发现同样的switch语句散布于不同地点。如果要为它添加一个新的case子句, 就必须找到所有switch语句并修改它们。面向对象中的多态概念可为此带来优雅的解决办法。 大多数时候,一看到switch语句,你就应该考虑以多态来替换它。问题是多态该出现在哪儿?switch语句常常根据类型码进行选择,你要的是”与该类型码相关的函数或类”,所以应该使用Extract Method将switch语句提炼到一个独立函数中,再以Move Method将它搬移到需要多态性的那个类里。此时你必须决定是否使用Replace Type Code with Subclasses或Replace Type Code with State/Strategy 。一旦这样完成继承结构之后,你就可以运用Replace Conditional with Polymorphism了。 如果你只是在单一函数中有些选择事例,且并不想改动它们,那么多态就有点杀鸡用牛刀了。这种情况下Replace Parameter with Explicit Methods是个不错的选择。如果你的选择条件之一是null,可以试试Introduce Null Object 。 ParallelInheritanceHierarchies 平行继承体系 Parallel Inheritance hierarchies其实是Shotgun Surgery的特殊情况。在这种情况下,每当你为某个类增加一个子类,必须也为另一个类相应增加一个子类。如果你发现某个继承体系的类名称前缀和另一个继承体系的类名称前缀完全相同,便是闻到了这种坏味道。 消除这种重复性的一般策略是:让一个继承体系的实例引用另一个继承体系的实例。如果再接再励运用Move method和Move Field,就可以将引用端的继承体系消弭于无形。 LazyClass 冗余类 SpeculativeGenerality 夸夸其谈未来性 这个令我们十分敏感的坏味道,命名者是 Brian Foote。当有人说”噢,我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。那么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,那就值得那么做;如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。 如果你的某个抽象类其实没有太大作用,请运用Collapse Hierarchy。不必要的委托可运用Inline class除掉。如果函数的某些参数未被用上,可对它实施Remove Parameter。如果函数名称带有多余的抽象意味,应该对它实施Rename Method,让它现实一些。 Temporary Field 令人迷惑的临时字段 Message Chains (过度耦合的消息链) 如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。实际代码中你看到的可能是一长串getThis()或一长串临时变量。采取这种方式,意味客户代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。 这时候你应该使用Hide Delegate。你可以在消息链的不同位置进行这种重构手法。理论上可以重构消息链上的任何一个对象,但这么做往往会把一系列对象(intermediate object)都变成Middle Man。通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以Extract Method把使用该对象的代码提炼到一个独立函数中,再运用Move Method把这个函数推入消息链。如果这条链上的某个对象有多位客户打算航行此航线的剩余部分,就加一个函数来做这件事。 有些人把任何函数链都视为坏东西,我们不这样想。呵呵,我们的冷静镇定是出了名的,起码在这件事上是这样 Middle Man 中间人 对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随委托。比如说你问主管是否有时间参加一个会议,他就把这个消息”委托”给他的记事簿,然后才能回答你。很好,你没必要知道这位主管到底使用传统记事簿或电子记事簿亦或秘书来记录自己的约会。 但是人们可能过度运用委托。你也许会看到某个类接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用Remove Middle Man,直接和真正负责的对象打交道。如果这样”不干实事”的函数只有少数几个,可以运用Inline Method把它们放进调用端。如果这些Middle Man还有其他行为,可以运用Replace Delegation with Inheritance把它变成实责对象的子类,这样你既可以扩展原对象的行为,又不必负担那么多的委托动作。 Inappropriate Intimacy 过度亲密 过分狎昵的类必须拆散。你可以采用Move Method和Move Field帮它们划清界线,从而减少狎昵行径。你也可以看看是否可以运用Change Bidirectional Association to Unidirectional让其中一个类对另一个斩断情丝。如果两个类实在是情投意合,可以运用Extract Class把两者共同点提炼到一个安全地点,让它们坦荡地使用这个新类。或者也可以尝试运用Hide Delegate让另一个类来为它们传递相思情。 继承往往造成过度亲密,因为子类对父类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独自生活了,请运用Replace Inheritance with Delegation让它离开继承体系。 Alternative Classes with Different Interfaces 异曲同工的类 如果两个函数做同一件事,却有着不同的签名,请运用Rename Method根据它们的用途重新命名。但这往往不够,请反复运用Move Method将某些行为移入类,直到两者的协议一致为止。如果你必须重复而赘余地移入代码才能完成这些,或许可运用Extract Superclass为自己赎点罪。 Incomplete Library Class 不完整的库类 如果你只想修改库类的一两个函数,可以运用Introduce Foreign Method;如果想要添加一大堆额外行为,就得运用Introduce Local Extension。 Data Class 数据类 所谓Data Class是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分细琐地操控着。这些类早期可能拥有public字段,果真如此你应该在别人注意到它们之前,立刻运用Encapsulate Field将它们封装起来。如果这些类内含容器类的字段,你应该检查它们是不是得到了恰当的封装:如果没有,就运用Encapsulate Collection把它们封装起来。对于那些不该被其他类修改的字段,请运用Remove Setting Method。 然后,找出这些取值设值函数被其他类运用的地点。尝试以Move Method把那些调用行为搬移到Data Class。如果无法搬移整个函数,就运用Extract Method产生一个可被搬移的函数。不久之后你就可以运用Hide Method把这些取值/设值函数隐藏起来了。 Refused Bequest 拒绝继承 子类应该继承父类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩! 按传统说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用Push Down Method和Push Down Field把所有用不到的函数下推给那个兄弟。这样一来,父类就只持有所有子类共享的东西。你常常会听到这样的建议:所有父类都应该是抽象(abstract)的。 既然使用”传统说法”这个略带贬义的词,你就可以猜到,我们不建议你这么做,起码不建议你每次都这么做。我们经常利用继承来复用一些行为,并发现这可以很好地应用于日常工作。这也是一种坏味道,我们不否认,但气味通常并不强烈。所以我们说:如果Refused Bequest引起困惑和问题,请遵循传统忠告。但不必认为你每次都得那么做。十有八九这种坏味道很淡,不值得理睬。 如果子类复用了父类的行为(实现),却又不愿意支持父类的接口, Refused Bequest的坏味道就会变得浓烈。拒绝继承父类的实现,这一点我们不介意;但如果拒绝继承父类的接口,我们不以为然。不过即使你不愿意继承接口,也不要胡乱修改继承体系,应该运用Replace Inheritance with Delegation来达到目的 Comments 注释过多 别担心,我们并不是说你不该写注释。从嗅觉上说, Comments不是一种坏味道,事实上它们还是一种香味呢。我们之所以要在这里提到 Comments,是因为人们常把它当作除臭剂来使用。常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。这种情况的发生次数之多,实在令人吃惊。 Comments可以带我们找到本章先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚说明了一切。 如果你需要注释来解释一块代码做了什么,试试Extract Method;如果函数已经提炼出来,但还是需要注释来解释其行为,试试Rename Method;如果你需要注释说明某些系统的需求规格,试试Introduce Assertion。 当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。 如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己”为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。 重构列表 重构的记录格式 介绍重构时,我采用一种标准格式。每个重构手法都有如下五个部分。 - 首先是名称(name)。建造一个重构词汇表,名称是很重要的。这个名称也就是我将在本书其他地方使用的名称。 - 名称之后是一个简短概要(summary)。简单介绍此一重构手法的适用情景以及它所做的事情。这部分可以帮助你更快找到你所需要的重构手法。 - 动机(motivation)为你介绍”为什么需要这个重构”和”什么情况下不该使用这个重构”。 - 做法(mechanics)简明扼要地一步一步介绍如何进行此一重构。 - 范例(examples)以一个十分简单的例子说明此重构手法如何运作。 概要”包括三个部分:(1)一句话,介绍这个重构能够帮助解决的问题;(2)段简短陈述,介绍你应该做的事:(3)一幅速写图,简单展现重构前后示例:有时候我展示代码,有时候我展示UML图。总之,哪种形式能更好呈现该重构的本质,我就使用哪种形式(本书所有UML图都根据实现观点而画[Fowler,,UML]。)如果你以前见过这一重构手法,那么速写图能够让你迅速了解这一重构的概况;如果你不曾见过这个重构,可能就需要浏览整个范例,才能得到较好的认识。 “做法”出自我自己的笔记。这些笔记是为了让我在一段时间不做某项重构之后还能记得怎么做。它们也颇为简洁,通常不会解释”为什么要这么做那么做”。我会在”范例”中给出更多解释。这么一来,”做法”就成了简短的笔记。如果你知道该使用哪个重构,但记不清具体步骤,可以参考”做法”部分(至少我是这么使用它们的);如果你初次使用某个重构,可能只参考”做法”还不够,你还需要阅读”范例”。 撰写”做法”的时候,我尽量将重构的每个步骤都写得简短。我强调安全的重构方式,所以应该采用非常小的步骤,并且在每个步骤之后进行测试。真正工作时,我通常会采用比这里介绍的”婴儿学步”稍大些的步骤,然而一旦出问题,我就会撤销上一步,换用比较小的步骤。这些步骤还包含一些特定状况的参考,所以它们也有检验表的作用。我自己经常忘掉这些该做的事情。 “范例”像是简单而有趣的教科书。我使用这些范例是为了帮助解释重构的基本要素,最大限度地避免其他枝节,所以我希望你能原谅其中的简化工作(它们当然不是优秀商用对象设计的适当例子)。不过我敢肯定,你一定能在你手上那些更复杂的情况中使用它们。某些十分简单的重构干脆没有范例,因为我觉得为它们加上个范例不会有多大意义。 更明确地说,加上范例仅仅是为了阐释当时讨论的重构手法。通常那些代码最终仍有其他问题,但修正那些问题需要用到其他重构手法。某些情况下数个重构经常被一并运用,这时候我会把某些范例拿到另一个重构中继续使用。大部分时候,个范例只为一项重构而设计,这么做是为了让每一项重构手法自成一体,因为这份重构列表的首要目的还是作为参考工具。 这些重构的成熟度如何? 重构的基本技巧—小步前进、频繁测试——已经得到多年的实践检验。所以,我敢保证,重构的这些基础思想是非常可靠的。 重新组织方法 Extract Method 提炼函数 动机 有几个原因造成我喜欢简短而命名良好的函数。首先,如果每个函数的粒度都很小,那么函数被复用的机会就更大;其次,这会使高层函数读起来就像一系列注释:再次,如果函数都是细粒度,那么函数的覆写也会更容易些。 人们有时会问我,一个函数多长才算合适?在我看来,长度不是问题,关键在于函数名称和函数本体之间的语义距离。如果提炼可以强化代码的清晰度,那就去做,就算函数名称比提炼出来的代码还长也无所谓。 做法 - 创造一个新函数,根据这个函数的意图来对它命名(以它”做什么”来命名,而不是以它”怎样做”命名) - →即使你想要提炼的代码非常简单,例如只是一条消息或一个函数调用,只要新函数的名称能够以更好的方式昭示代码意图,你也应该提炼它。但如果你想不出一个更有意义的名称,就别动。 - 将提炼出的代码从源函数复制到新建的目标函数中。 - 仔细检查提炼出的代码,看看其中是否引用了”作用域限于源函数”的变量(包括局部变量和源函数参数)。 - 检查是否有”仅用于被提炼代码段”的临时变量。如果有,在目标函数中将它们声明为临时变量。 - 检査被提炼代码段,看看是否有任何局部变量的值被它改变。如果一个临时变量值被修改了,看看是否可以将被提炼代码段处理为一个查询,并将结果赋值给相关变量。如果很难这样做,或如果被修改的变量不止一个,你就不能仅仅将这段代码原封不动地提炼出来。你可能需要先使用Split Temporary Ariable,然后再尝试提炼。也可以使用Replace Temp with Query将临时变量消灭掉。 - 将被提炼代码段中需要读取的局部变量,当作参数传给目标函数。 - 处理完所有局部变量之后,进行编译。 - 在源函数中,将被提炼代码段替换为对目标函数的调用。 - 如果你将任何临时变量移到目标函数中,请检查它们原本的声明式是否在被提炼代码段的外围。如果是,现在你可以删除这些声明式了 - 编译,测试。 临时变量往往为数众多,甚至会使提炼工作举步维艰。这种情况下,我会尝试先运用Replace Temp with Query(减少临时变量。如果即使这么做了提炼依旧困难重重,我就会动用Replace Method with Method Object,这个重构手法不在乎 代码中有多少临时变量,也不在乎你如何使用它们。 Inline Method 内联方法 一个函数的本体与名称同样清楚易懂。 在函数调用点插入函数本体,然后移除该函数。 1 2 3 4 5 6 7 8 9 10 11 12 | int getRating(){ return (moreThanFiveLateDeliveries())? 2: 1; } boolean moreThanFiveLateDeliveries(){ return numberofLateDeliveries >5: } || || \/ int getRating(){ return(numberofLateDeliveries >5)?2: 1 } | Inline Temp 内联临时变量 你有一个临时变量,只被一个简单表达式赋值一次,而它妨碍了其他重构手法。 将所有对该变量的引用动作,替换为对它赋值的那个表达式自身。 1 2 3 4 5 6 | double basePrice= anorder.basePrice(); return (basePrice>1000) || || \/ return (anorder. basePrice()> 1000) | Replace Temp with Query 用查询方法代替临时变量 你的程序以一个临时变量保存某一表达式的运算结果。 将这个表达式提炼到一个独立函数中。将这个临时变量的所有引用点替换为对新函数的调用。此后,新函数就可被其他函数使用。 1 2 3 4 5 | double basePrice =quantity *_itemPrice: if(baseprice>1000) return baseprice *0.95 else return baseprice *0.98; | 替换为: 1 2 3 4 5 6 7 8 | if(basePrice())> 1000 return baseprice() *0. 95: else return basePrice()*0.98 double basePrice(){ return _quantity*_itemPrice… ### [译]Elasticsearch集群规模和性能调优 - URL: https://jiankunking.com/elasticsearch-cluster-sizing-and-performance-tuning.html - Content type: translation - Published: 2020-04-07 - Updated: 2020-04-07 - Summary: 翻译介绍 Elasticsearch 集群容量规划与性能调优,涵盖数据规模、分片大小、副本数量及读写负载均衡。 - Categories: Elasticsearch - Tags: Performance, Elasticsearch, Cluster, Size, Tuning - Original source: https://www.elastic.co/cn/blog/found-sizing-elasticsearch The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 架构设计三原则 - URL: https://jiankunking.com/three-principles-of-architecture.html - Content type: original - Published: 2020-03-11 - Updated: 2020-03-11 - Summary: 架构设计需要遵循三个核心原则:合适原则(合适优于业界领先)、简单原则(简单优于复杂)、演化原则(演化优于一步到位)。 - Categories: Architecture - Tags: Simple, Evolvable, Appropriate, Design-Principle Article text: 文章速览 架构设计需要遵循三个核心原则:合适原则(合适优于业界领先)、简单原则(简单优于复杂)、演化原则(演化优于一步到位)。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 架构设计需要遵循三个核心原则:合适原则(合适优于业界领先)、简单原则(简单优于复杂)、演化原则(演化优于一步到位)。 - 合适原则 合适原则宣言:“合适优于业界领先” - 简单原则 简单原则宣言:“简单优于复杂”。 - 演化原则 演化原则宣言:“演化优于一步到位”。 ### 网络分层模型 - URL: https://jiankunking.com/layered-network-model.html - Content type: original - Published: 2020-03-11 - Updated: 2020-03-11 - Summary: 对比 TCP/IP 四层模型与 OSI 七层模型,解释各层职责、协议对应关系和数据传输过程。 - Categories: Network - Tags: Network Article text: 文章速览 对比 TCP/IP 四层模型与 OSI 七层模型,解释各层职责、协议对应关系和数据传输过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 两个网络分层模型:TCP/IP和OSI,一个是四层模型,一个是七层模型,这两者应该如何互相映射或者说互相解释呢? - 第一层:物理层, TCP/IP里无对应; - 第二层:数据链路层, 对应TCP/IP的链接层; - 第三层:网络层, 对应TCP/IP的网际层; - 第四层:传输层, 对应TCP/IP的传输层; - 第五、六、七层:统一对应到TCP/IP的应用层。 ### 趣谈网络协议 笔记 - URL: https://jiankunking.com/fun-talk-about-network-protocols.html - Content type: original - Published: 2020-03-06 - Updated: 2020-03-06 - Summary: 《趣谈网络协议》学习笔记,涵盖网络分层、IP地址分类、CIDR无类型域间选路、子网掩码等核心知识点。 - Categories: Network - Tags: Reading Notes, Network Article text: 文章速览 《趣谈网络协议》学习笔记,涵盖网络分层、IP地址分类、CIDR无类型域间选路、子网掩码等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 《趣谈网络协议》学习笔记,涵盖网络分层、IP地址分类、CIDR无类型域间选路、子网掩码等核心知识点。 趣谈网络协议 作者: 刘超 网络协议 网络分层 无类型域间选路(CIDR) 这种方式打破了原来设计的几类地址的做法,将 32 位的 IP 地址一分为二,前面是网络号,后面是主机号。从哪里分呢?你如果注意观察的话可以看到,10.100.122.2/24,这个 IP 地址中有一个斜杠,斜杠后面有个数字 24。这种地址表示形式,就是 CIDR。后面 24 的意思是,32 位中,前 24 位是网络号,后 8 位是主机号。 伴随着 CIDR 存在的,一个是广播地址,10.100.122.255。如果发送这个地址,所有 10.100.122 网络里面的机器都可以收到。另一个是子网掩码,255.255.255.0。 “无类型”的意思是选路决策是基于整个32位IP地址的掩码操作。而不管其IP地址是A类、B类或是C类。 DHCP:IP是怎么来的,又是怎么没的? 如何配置 IP 地址? net-tools: 1 2 | $ sudo ifconfig eth1 10.0.0.1/24 $ sudo ifconfig eth1 up | iproute2: 1 2 | $ sudo ip addr add 10.0.0.1/24 dev eth1 $ sudo ip link set up eth1 | 你可能会问了,自己配置这个自由度太大了吧,我是不是配置什么都可以?如果配置一个和谁都不搭边的地址呢?例如,旁边的机器都是 192.168.1.x,我非得配置一个 16.158.23.6,会出现什么现象呢? 不会出现任何现象,就是包发不出去呗。为什么发不出去呢?我来举例说明。 192.168.1.6 就在你这台机器的旁边,甚至是在同一个交换机上,而你把机器的地址设为了16.158.23.6。在这台机器上,你企图去 ping 192.168.1.6,你觉得只要将包发出去,同一个交换机的另一台机器马上就能收到,对不对? 可是 Linux 系统不是这样的,它没你想得那么智能。你用肉眼看到那台机器就在旁边,它则需要根据自己的逻辑进行处理。 要知道只要是在网络上跑的包,都是完整的,可以有下层没上层,绝对不可能有上层没下层。 所以,你看着它有自己的源 IP 地址 16.158.23.6,也有目标 IP 地址 192.168.1.6,但是包发不出去,这是因为 MAC 层还没填。 自己的 MAC 地址自己知道,这个容易。但是目标 MAC 填什么呢?是不是填 192.168.1.6 这台机器的MAC 地址呢? 当然不是。Linux 首先会判断,要去的这个地址和我是一个网段的吗,或者和我的一个网卡是同一网段的吗?只有是一个网段的,它才会发送 ARP 请求,获取 MAC 地址。如果发现不是呢? Linux 默认的逻辑是,如果这是一个跨网段的调用,它便不会直接将包发送到网络上,而是企图将包发送到网关。 如果你配置了网关的话,Linux 会获取网关的 MAC 地址,然后将包发出去。对于 192.168.1.6 这台机器来讲,虽然路过它家门的这个包,目标 IP 是它,但是无奈 MAC 地址不是它的,所以它的网卡是不会把包收进去的。 如果没有配置网关呢?那包压根就发不出去。 如果将网关配置为 192.168.1.6 呢?不可能,Linux 不会让你配置成功的,因为网关要和当前的网络至少一个网卡是同一个网段的,怎么可能 16.158.23.6 的网关是 192.168.1.6 呢? 所以,当你需要手动配置一台机器的网络 IP 时,一定要好好问问你的网络管理员。如果在机房里面,要去网络管理员那里申请,让他给你分配一段正确的 IP 地址。当然,真正配置的时候,一定不是直接用命令配置的,而是放在一个配置文件里面。不同系统的配置文件格式不同,但是无非就是 CIDR、子网掩码、广播地址和网关地址。 动态主机配置协议(DHCP) 动态主机配置协议(Dynamic Host Configuration Protocol),简称DHCP。 解析 DHCP 的工作方式 当一台机器新加入一个网络的时候,肯定一脸懵,啥情况都不知道,只知道自己的 MAC 地址。怎么办?先吼一句,我来啦,有人吗?这时候的沟通基本靠“吼”。这一步,我们称为DHCP Discover。 新来的机器使用 IP 地址 0.0.0.0 发送了一个广播包,目的 IP 地址为 255.255.255.255。广播包封装在UDP 里面,UDP 封装在 BOOTP 里面。其实 DHCP 是 BOOTP 的增强版,但是如果你去抓包的话,很可能看到的名称还是 BOOTP 协议。 在这个广播包里面,新人大声喊:我是新来的(Boot request),我的 MAC 地址是这个,我还没有IP,谁能给租给我个 IP 地址! 格式就像这样: 请求 响应 新来的机器很开心,它的“吼”得到了回复,并且有人愿意租给它一个 IP 地址了,这意味着它可以在网络上立足了。当然更令人开心的是,如果有多个 DHCP Server,这台新机器会收到多个 IP 地址,简直受宠若惊。 它会选择其中一个 DHCP Offer,一般是最先到达的那个,并且会向网络发送一个 DHCP Request 广播数据包,包中包含客户端的 MAC 地址、接受的租约中的 IP 地址、提供此租约的 DHCP 服务器地址等,并告诉所有 DHCP Server 它将接受哪一台服务器提供的 IP 地址,告诉其他 DHCP 服务器,谢谢你们的接纳,并请求撤销它们提供的 IP 地址,以便提供给下一个 IP 租用请求者。 当 DHCP Server 接收到客户机的 DHCP request 之后,会广播返回给客户机一个 DHCP ACK 消息包,表明已经接受客户机的选择,并将这一 IP 地址的合法租用信息和其他的配置信息都放入该广播包,发给客户机,欢迎它加入网络大家庭。 IP 地址的收回和续租 既然是租房子,就是有租期的。租期到了,管理员就要将 IP 收回。 如果不用的话,收回就收回了。就像你租房子一样,如果还要续租的话,不能到了时间再续租,而是要提前一段时间给房东说。DHCP 也是这样。 客户机会在租期过去 50% 的时候,直接向为其提供 IP 地址的 DHCP Server 发送 DHCP request 消息包。客户机接收到该服务器回应的 DHCP ACK 消息包,会根据包中所提供的新的租期以及其他已经更新的 TCP/IP 参数,更新自己的配置。这样,IP 租用更新就完成了。 交换机与VLAN 拓扑结构是怎么形成的? 我们常见到的办公室大多是一排排的桌子,每个桌子都有网口,一排十几个座位就有十几个网口,一个楼层就会有几十个甚至上百个网口。如果算上所有楼层,这个场景自然比你宿舍里的复杂多了。具体哪里复杂呢?我来给你具体讲解。 首先,这个时候,一个交换机肯定不够用,需要多台交换机,交换机之间连接起来,就形成一个稍微复杂的拓扑结构。 我们先来看两台交换机的情形。两台交换机连接着三个局域网,每个局域网上都有多台机器。如果机器1 只知道机器 4 的 IP 地址,当它想要访问机器 4,把包发出去的时候,它必须要知道机器 4 的 MAC 地址。 于是机器 1 发起广播,机器 2 收到这个广播,但是这不是找它的,所以没它什么事。**交换机 A 一开始是不知道任何拓扑信息的,在它收到这个广播后,采取的策略是,除了广播包来的方向外,它还要转发给其他所有的网口。**于是机器 3 也收到广播信息了,但是这和它也没什么关系。 当然,交换机 B 也是能够收到广播信息的,但是这时候它也是不知道任何拓扑信息的,因而也是进行广播的策略,将包转发到局域网三。这个时候,机器 4 和机器 5 都收到了广播信息。机器 4 主动响应说,这是找我的,这是我的 MAC 地址。于是一个 ARP 请求就成功完成了。 在上面的过程中,交换机 A 和交换机 B 都是能够学习到这样的信息:机器 1 是在左边这个网口的。当了解到这些拓扑信息之后,情况就好转起来。当机器 2 要访问机器 1 的时候,机器 2 并不知道机器 1 的MAC 地址,所以机器 2 会发起一个 ARP 请求。这个广播消息会到达机器 1,也同时会到达交换机 A。这个时候交换机 A 已经知道机器 1 是不可能在右边的网口的,所以这个广播信息就不会广播到局域网二和局域网三。 当机器 3 要访问机器 1 的时候,也需要发起一个广播的 ARP 请求。这个时候交换机 A 和交换机 B 都能够收到这个广播请求。交换机 A 当然知道主机 A 是在左边这个网口的,所以会把广播消息转发到局域网一。同时,交换机 B 收到这个广播消息之后,由于它知道机器 1 是不在右边这个网口的,所以不会将消息广播到局域网三。 ICMP与ping ICMP 协议的格式 ping 是基于 ICMP 协议工作的。ICMP全称Internet Control Message Protocol,就是互联网控制报文协议。这里面的关键词是“控制”,那具体是怎么控制的呢? 网络包在异常复杂的网络环境中传输时,常常会遇到各种各样的问题。当遇到问题的时候,总不能“死个不明不白”,要传出消息来,报告情况,这样才可以调整传输策略。 ICMP 报文是封装在 IP 包里面的。因为传输指令的时候,肯定需要源地址和目标地址。它本身非常简单。因为作为侦查兵,要轻装上阵,不能携带大量的包袱。 ping:查询报文类型的使用 接下来,我们重点来看 ping 的发送和接收过程。 假定主机 A 的 IP 地址是 192.168.1.1,主机 B 的 IP 地址是 192.168.1.2,它们都在同一个子网。那当你在主机 A 上运行“ping 192.168.1.2”后,会发生什么呢? ping 命令执行的时候,源主机首先会构建一个 ICMP 请求数据包,ICMP 数据包内包含多个字段。最重要的是两个,第一个是类型字段,对于请求数据包而言该字段为8;另外一个是顺序号,主要用于区分连续 ping 的时候发出的多个数据包。每发出一个请求数据包,顺序号会自动加 1。为了能够计算往返时间 RTT,它会在报文的数据部分插入发送时间。 然后,由 ICMP 协议将这个数据包连同地址 192.168.1.2 一起交给 IP 层。IP 层将以 192.168.1.2 作为目的地址,本机 IP 地址作为源地址,加上一些其他控制信息,构建一个 IP 数据包。 接下来,需要加入 MAC 头。如果在ARP 映射表中查找出 IP 地址 192.168.1.2 所对应的 MAC 地址,则可以直接使用;如果没有,则需要发送 ARP 协议查询 MAC 地址,获得 MAC 地址后,由数据链路层构建一个数据帧,目的地址是 IP 层传过来的 MAC 地址,源地址则是本机的 MAC 地址;还要附加上一些控制信息,依据以太网的介质访问规则,将它们传送出去。 主机 B 收到这个数据帧后,先检查它的目的 MAC 地址,并和本机的 MAC 地址对比,如符合,则接收,否则就丢弃。接收后检查该数据帧,将 IP 数据包从帧中提取出来,交给本机的 IP 层。同样,IP 层检查后,将有用的信息提取后交给 ICMP 协议。 主机 B 会构建一个 ICMP 应答包,应答数据包的类型字段为0,顺序号为接收到的请求数据包中的顺序号,然后再发送出去给主机 A。 在规定的时候间内,源主机如果没有接到 ICMP 的应答包,则说明目标主机不可达;如果接收到了ICMP 应答包,则说明目标主机可达。此时,源主机会检查,用当前时刻减去该数据包最初从源主机上发出的时刻,就是 ICMP 数据包的时间延迟。 当然这只是最简单的,同一个局域网里面的情况。如果跨网段的话,还会涉及网关的转发、路由器的转发等等。但是对于 ICMP 的头来讲,是没什么影响的。会影响的是根据目标 IP 地址,选择路由的下一跳,还有每经过一个路由器到达一个新的局域网,需要换 MAC 头里面的 MAC 地址。 网关(Gateway) 你了解 MAC 头和 IP 头的细节吗? 一旦配置了 IP 地址和网关,往往就能够指定目标地址进行访问了。由于在跨网关访问的时候,牵扯到MAC 地址和 IP 地址的变化,这里有必要详细描述一下 MAC 头和 IP 头的细节。 在任何一台机器上,当要访问另一个 IP 地址的时候,都会先判断,这个目标 IP 地址,和当前机器的IP地址,是否在同一个网段。怎么判断同一个网段呢?需要 CIDR 和子网掩码。 如果不是同一网段,例如,你要访问你们校园网里面的 BBS,该怎么办?这就需要发往默认网关Gateway。Gateway 的地址一定是和源 IP 地址是一个网段的。往往不是第一个,就是第二个。例如192.168.1.0/24 这个网段,Gateway 往往会是 192.168.1.1/24 或者 192.168.1.2/24。 如何发往默认网关呢?网关不是和源 IP 地址是一个网段的么?这个过程就和发往同一个网段的其他机器是一样的:将源地址和目标 IP 地址放入 IP 头中,通过 ARP 获得网关的 MAC 地址,将源 MAC 和网关的 MAC 放入 MAC 头中,发送出去。网关所在的端口,例如 192.168.1.1/24 将网络包收进来,然后接下来怎么做,就完全看网关的了。 网关往往是一个路由器,是一个三层转发的设备。啥叫三层设备?就是把 MAC 头和 IP头都取下来,然后根据里面的内容,看看接下来把包往哪里转发的设备。 很多情况下,人们把网关就叫作路由器。其实不完全准确,而另一种比喻更加恰当:路由器是一台设备,它有五个网口或者网卡,相当于有五只手,分别连着五个局域网。每只手的 IP 地址都和局域网的 IP地址相同的网段,每只手都是它握住的那个局域网的网关。 任何一个想发往其他局域网的包,都会到达其中一只手,被拿进来,拿下 MAC 头和 IP 头,看看,根据自己的路由算法,选择另一只手,加上 IP 头和 MAC 头,然后扔出去。 IP 头和 MAC 头哪些变、哪些不变? MAC 地址是一个局域网内才有效的地址。因而,MAC 地址只要过网关,就必定会改变,因为已经换了局域网。两者主要的区别在于 IP 地址是否改变。不改变 IP 地址的网关,我们称为转发网关;改变 IP 地址的网关,我们称为NAT 网关。 TCP 协议 - 顺序问题 ,稳重不乱; - 丢包问题,承诺靠谱; - 连接维护,有始有终; - 流量控制,把握分寸; - 拥塞控制,知进知退。 SYN 是发起一个连接,ACK 是回复,RST 是重新连接,FIN 是结束连接 TCP 的三次握手 TCP 四次挥手 断开的时候,我们可以看到,当 A 说“不玩了”,就进入 FIN_WAIT_1 的状态,B 收到“A 不玩”的消息后,发送知道了,就进入 CLOSE_WAIT 的状态。 A 收到“B 说知道了”,就进入 FIN_WAIT_2 的状态,如果这个时候 B 直接跑路,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。 如果 B 没有跑路,发送了“B 也不玩了”的请求到达 A 时,A 发送“知道 B 也不玩了”的 ACK 后,从FIN_WAIT_2 状态结束,按说 A 可以跑路了,但是最后的这个 ACK 万一 B 收不到呢?则 B 会重新发一个“B 不玩了”,这个时候 A 已经跑路了的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,“B 说不玩了”会重发的,A 会重新发一个 ACK 并且足够时间到达 B。 A 直接跑路还有一个问题是,A 的端口就直接空出来了,但是 B 不知道,B 原来发过的很多包很可能还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是这里要上一个双保险,防止产生混乱,因而也需要等足够长的时间,等到原来B 发送的所有的包都死翘翘,再空出端口来。 等待的时间设为 2MSL,MSL是Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30秒,1 分钟和 2 分钟等。 还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,A 就表示,我已经在这里等了这么长时间了,已经仁至义尽了,之后的我就都不认了,于是就直接发送 RST,B 就知道 A 早就跑了。 HTTPS 协议 HTTPS协议:点外卖的过程原来这么复杂 HTTPDNS HTTPDNS:网络世界的地址簿也会指错路 数据中心 数据中心:我是开发商,自己拿地盖别墅 VPN VPN通过隧道技术在公众网络上仿真一条点到点的专线,是通过利用一种协议来传输另外一种协议的技术。 VPN:朝中有人好做官 移动网络 移动网络:去巴塞罗那,手机也上不了脸书 云中网络 主要讲虚拟机、虚拟网卡 云中网络:自己拿地成本高,购买公寓更灵活 软件定义网络(SDN) 软件定义网络:共享基础设施的小区物业管理办法 云中的网络安全 云中的网络安全:虽然不是土豪,也需要基本安全和保障 云中的网络QoS(Quality of Service) 云中的网络QoS:邻居疯狂下电影,我该怎么办? 云中网络的隔离GRE、VXLAN 云中网络的隔离GRE、VXLAN:虽然住一个小区,也要保护隐私 容器网络 容器网络:来去自由的日子,不买公寓去合租 容器网络之Flannel 容器网络之Flannel:每人一亩三分地 容器网络之Calico 容器网络之Calico:为高效说出善意的谎言 RPC RPC协议综述:远在天边,近在眼前 基于XML的SOAP协议:不要说NBA,请说美国职业篮球联赛 基于JSON的RESTful接口协议:我不关心过程,请给我结果 二进制类RPC协议:还是叫NBA吧,总说全称多费劲 跨语言类RPC协议:交流之前,双方先来个专业术语表 ### Linux 磁盘分区、挂载 - URL: https://jiankunking.com/linux-mount-partition.html - Content type: original - Published: 2020-02-26 - Updated: 2020-02-26 - Summary: Linux磁盘分区与挂载操作指南,覆盖磁盘识别、创建分区、文件系统格式化、临时挂载和开机自动挂载等常用命令与操作步骤。 - Categories: Linux - Tags: Linux, Mount, Partition Article text: 文章速览 Linux磁盘分区与挂载操作指南,覆盖磁盘识别、创建分区、文件系统格式化、临时挂载和开机自动挂载等常用命令与操作步骤。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Linux Linux磁盘分区与挂载操作指南,包括查看磁盘、分区、格式化、挂载等常用命令。 Linux 磁盘分区、挂载 查看已挂账的磁盘 1 | df -hl /* | 查看分区 1 | fdisk -l | 分区指定文件系统(会格式化) 1 | mkfs.xfs -f /dev/vdb | 挂载 1 | mount /dev/vdb /data | 以上挂载重启后失效 查看挂载结果 1 | df -TH | blkid 磁盘分区,查询磁盘分区的UUID。 1 | blkid /dev/vdb | vim编辑/etc/fstab 1 | UUID=37aeb018-9dfd-412f-81c1-583f1eb1189f /data xfs defaults 0 2 | lsblk 查看分区和磁盘 ### Sentry 高可用部署 - URL: https://jiankunking.com/sentry-high-availability-deploy.html - Content type: original - Published: 2020-02-16 - Updated: 2020-02-16 - Summary: 基于Sentry 10.1.0.dev梳理生产级高可用部署方案,分别处理Kafka、Redis等依赖中间件,以及Web、Worker、Snuba等Sentry组件的高可用。 - Categories: Sentry - Tags: Sentry, Deploy Article text: 文章速览 基于Sentry 10.1.0.dev梳理生产级高可用部署方案,分别处理Kafka、Redis等依赖中间件,以及Web、Worker、Snuba等Sentry组件的高可用。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Sentry Sentry高可用部署方案,包括依赖中间件高可用和Sentry组件高可用的完整部署实践。 Sentry 高可用部署,部署分析基于Sentry 10.1.0.dev 05e720a7 Sentry官方对于自托管推荐的部署方式是Docker Compose,但这种方式有以下几个缺点: - 所有服务都部署在一台机器上 - 所有的组件都不是高可用的 对于生产环境来说,组件高可用是一个必备的条件,所以有了下面的高可用部署,高可用部署分为两部分: - Sentry依赖中间件的高可用 - Sentry本身组件的高可用 Sentry 服务 Sentry具体的服务关系及依赖,具体见下图: 需要注意以下几点: - symbolicator、symbolicator-cleanup 依赖挂载卷sentry-symbolicator:/data - web、cron、worker、post-process-forwarder、sentry-cleanup 也依赖挂载卷,具体可以可见:https://docs.sentry.io/server/filestore/ 为什么需要关注挂载卷? 因为挂载卷依赖存储服务,如果没用高可用的存储服务,Sentry自身组件就难以做到全部高可用。 部署 Sentry依赖中间件 - Sentry依赖中间件的高可用,可以通过购买云服务商的服务来实现或者自己搭建。 - 通过修改sentry onpremise将Sentry依赖的服务替换为相关的ip或者域名,具体代码可以参考:jiankunking/onpremise。 Sentry自身服务 将Sentry服务拆分,部署到kubernetes集群中,具体设置参考onpremise中docker-compose.yml中的启动命令、端口、环境变量来设置。 其中有以下几点需要注意: - Sentry各个服务的启动命令,相比docker-compose.yml中command,不太一致,简单列一下: - snuba api - snuba consumer –auto-offset-reset=latest –max-batch-time-ms 750 - snuba replacer –auto-offset-reset=latest –max-batch-size 3 - symbolicator run - sentry run web –loglevel DEBUG - sentry run cron - sentry run worker - …… - snuba api默认监听的是127.0.0.1,修改为0.0.0.0,具体修改位置参见: https://github.com/jiankunking/snuba/commit/69fee6253c6a78e7c2668bf6c86692e4df8fe012 - sentry sentry/conf/server.py中KAFKA_CLUSTERS默认是localhost:9092,修改方式参见: https://github.com/jiankunking/onpremise/blob/master/sentry/cover/server.py#L1640 - 构建sentry镜像,当启动命令为post-process-forwarder时,需要将自定义后的config.yml、sentry.conf.py拷贝到镜像/etc/sentry目录下,具体参见: https://github.com/jiankunking/onpremise/blob/master/sentry/Dockerfile#L10 - sentry 环境变量中添加C_FORCE_ROOT=true,可以强制以root身份运行 - install.sh脚本 - 初始化clickhouse数据库结构 - 添加初始用户 - sentry worker依赖于sentry cron,所以不能只部署worker,否则会有下面的错误提示:Background workers haven’t checked in recently. This is likely an issue with your configuration or the workers aren’t running. 小结 总的来说,将sentry部署到kubernetes中,需要注意的点还是挺多的,很多细节需要看代码来排查。 2020-06-17 更新 Sentry 社区版不支持高可用的ClickHouse分布式表。 1 2 3 4 5 6 | Distributed tables are not officially supported. DATASET_MODE would switch to distributed table names, but bootstrap won't work. You would have to manage your tables (create and all DDL operations) manually. This is not a support process, I can give you some hints, but you would be doing it at your own risk. On the other hand, we are working on this support though we cannot commit to a timeline at this time. | The environment variable DATASET_MODE does not work 二期 具体分析 关于本文的一些交流 https://forum.sentry.io/t/sentry-high-availability-deploy/11838/4 https://github.com/getsentry/onpremise/issues/747#issuecomment-729850059 ### MySQL实战45讲 笔记 - URL: https://jiankunking.com/45-lectures-on-mysql-in-practice.html - Content type: original - Published: 2020-02-06 - Updated: 2020-02-06 - Summary: 《MySQL实战45讲》学习笔记,涵盖MySQL基础架构、索引优化、事务机制、锁机制、主从复制等核心知识点。 - Categories: MySQL - Tags: Reading Notes, MySQL Article text: 文章速览 《MySQL实战45讲》学习笔记,涵盖MySQL基础架构、索引优化、事务机制、锁机制、主从复制等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 《MySQL实战45讲》学习笔记,涵盖MySQL基础架构、索引优化、事务机制、锁机制、主从复制等核心知识点。 MySQL实战45讲 作者: 林晓斌 基础架构:一条SQL查询语句是如何执行的? 大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。 查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。 好在MySQL也提供了这种”按需使用”的方式。你可以将参数query_cache_type设置成DEMAND,这样对于默认的SQL语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用SQL_CACHE显式指定,像下面这个语句一样: 1 | select SQL_CACHE * from T where ID=10; | 需要注意的是,MySQL 8.0版本直接将查询缓存的整块功能删掉了,也就是说8.0开始彻底没有这个功能了。 日志系统:一条SQL更新语句是如何执行的? 重要的日志模块:redo log 当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。 InnoDB的redo log是固定大小的,比如可以配置为一组4个文件,每个文件的大小是1GB,总共就可以记录4GB的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。 write pos是当前记录的位置,一边写一边后移,写到第3号文件末尾后就回到0号文件开头。checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。 如果write pos追上checkpoint,这时候不能再执行新的更新,得停下来先擦掉一些记录,把checkpoint推进一下。 有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe。 重要的日志模块:binlog MySQL整体来看,其实就有两块:一块是Server层,它主要做的是MySQL功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的redo log是InnoDB引擎特有的日志,而Server层也有自己的日志,称为binlog(归档日志)。 我想你肯定会问,为什么会有两份日志呢? 因为最开始MySQL里并没有InnoDB引擎。MySQL自带的引擎是MyISAM,但是MyISAM没有crash-safe的能力,binlog日志只能用于归档。而InnoDB是另一个公司以插件形式引入MySQL的,既然只依靠binlog是没有crash-safe能力的,所以InnoDB使用另外一套日志系统-也就是redo log来实现crash-safe能力。 这两种日志有以下三点不同。 - redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以使用。 - redo log是物理日志,记录的是”在某个数据上做了什么修改”;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如”给ID=2这一行的c字段加1 “。 - redo log是循环写的,空间固定会用完;binlog是可以追加写入的。”追加写”是指binlog文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。 有了对这两个日志的概念性理解,我们再来看执行器和InnoDB引擎在执行这个简单的update语句时的内部流程。 - 执行器先找引擎取ID=2这一行。ID是主键,引擎直接用树搜索找到这一行。如果ID=2这一行所在的数据本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。 - 执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。 - 引擎将这行新数据更新到内存中,同时将这个更新操作记录到redo log里面,此时redo log处于prepare状态。然后告知执行器执行完成了,随时可以提交事务。 - 执行器生成这个操作的binlog,并把binlog写入磁盘。 - 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。 最后三步看上去有点”绕”,将redo log的写入拆成了两个步骤:prepare和commit,这就是”两阶段提交”。 两阶段提交 为什么必须有”两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:怎样让数据库恢复到半个月内任意一秒的状态? 前面我们说过了,binlog会记录所有的逻辑操作,并且是采用”追加写”的形式。如果你的DBA承诺说半个月内可以恢复,那么备份系统中一定会保存最近半个月的所有binlog,同时系统会定期做整库备份。这里的”定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。 当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做: - 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库; - 然后,从备份的时间点开始,将备份的binlog依次取出来,重放到中午误删表之前的那个时刻。 这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。 好了,说完了数据恢复过程,我们回来说说,为什么日志需要”两阶段提交”。这里不妨用反证法来进行解释。 由于redo log和binlog是两个独立的逻辑,如果不用两阶段提交,要么就是先写完redo log再写binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。 仍然用前面的update语句来做例子。假设当前ID=2的行,字段c的值是0,再假设执行update语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了crash,会出现什么情况呢? - 先写redo log后写binlog。假设在redo log写完,binlog还没有写完的时候,MySQL进程异常重启。由于我们前面说过的,redo log写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行c的值是1。 但是由于binlog没写完就crash了,这时候binlog里面就没有记录这个语句。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句。 然后你会发现,如果需要用这个binlog来恢复临时库的话,由于这个语句的binlog丢失,这个临时库就会少了这一次更新,恢复出来的这一行c的值就是0,与原库的值不同。 - 先写binlog后写redo log。如果在binlog写完之后crash,由于redo log还没写,崩溃恢复以后这个事务⽆效,所以这一行c的值是0。但是binlog里面已经记录了”把c从0改成1”这个日志。所以,在之后用binlog来恢复的时候就多了一个事务出来,恢复出来的这一行c的值就是1,与原库的值不同。 可以看到,如果不使用”两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。 你可能会说,这个概率是不是很低,平时也没有什么动不动就需要恢复临时库的场景呀? 其实不是的,不只是误操作后需要用这个过程来恢复数据。当你需要扩容的时候,也就是需要再多搭建一些备库来增加系统的读能力的时候,现在常见的做法也是用全量备份加上应用binlog来实现的,这个”不一致”就会导致你的线上出现主从数据库不一致的情况。 简单说,redo log和binlog都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。 事务隔离:为什么你改了我还看不见? SQL标准的事务隔离级别包括: - 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。 - 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。 - 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 - 串行化,顾名思义是对于同一行记录,”写”会加”写锁”,”读”会加”读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在”可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在”读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的。这里需要注意的是,”读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而”串行化”隔离级别下直接用加锁的方式来避免并行访问。 总结来说,存在即合理,哪个隔离级别都有它自己的使用场景,你要根据自己的业务情况来定。我想你可能会问那什么时候需要”可重复读”的场景呢?我们来看一个数据校对逻辑的案例。 假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。 这时候使用”可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。 事务隔离的实现 理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明”可重复读”。 在MySQL中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。 假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。 当前值是4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的read-view。如图中看到的,在视图A、B、C里面,这一个记录的值分别是1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于read-view A,要得到1,就必须将当前值依次执行图中所有的回滚操作得到。 同时你会发现,即使现在有另外一个事务正在将4改成5,这个事务跟read-view A、B、C对应的事务是不会冲突的。 深入浅出索引(上) 为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用”N叉”树。这里,”N叉”树中的”N”取决于数据块的大小。 以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。 N叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。 不管是哈希还是有序数组,或者N叉树,它们都是不断迭代、不断优化的产物或者解决方案。数据库技术发展到今天,跳表、LSM树等数据结构也被用于引擎设计中,这里我就不再一一展开了。 你心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。 在MySQL中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。 InnoDB 的索引模型 主键索引的叶子节点存的是整行数据。 在InnoDB里,主键索引也被称为聚簇索引(clustered index)。 非主键索引的叶子节点内容是主键的值。 在InnoDB里,非主键索引也被称为二级索引(secondary index)。 根据上面的索引结构说明,我们来讨论一个问题:基于主键索引和普通索引的查询有什么区别? 1 2 3 4 5 6 | // 主键列为ID的表,表中有字段k,并且在k上有索引。 create table T( id int primary key, k int not null, name varchar(16), index (k))engine=InnoDB; | - 如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树; - 如果语句是select * from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。 也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。 索引维护 B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。 你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事⽆绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。 插入新记录的时候可以不指定ID的值,系统会获取当前ID最大值加1作为下一条记录的ID值。 也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。 除了考虑性能外,我们还可以从存储空间的⻆度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢? 由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型(bigint)则是8个字节。 显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。 所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。 有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的: - 只有一个索引; - 该索引必须是唯一索引。 你一定看出来了,这就是典型的KV场景。 由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。 这时候我们就要优先考虑上一段提到的”尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。 深入浅出索引(下) 在下面这个表T中,如果我执行 select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行? 下面是这个表的初始化语句。 1 2 3 4 5 6 7 | create table T ( ID int primary key, k int NOT NULL DEFAULT 0, s varchar(16) NOT NULL DEFAULT '', index k(k)) engine=InnoDB; insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg'); | 现在,我们一起来看看这条SQL查询语句的执行流程: - 在k索引树上找到k=3的记录,取得 ID = 300; - 再到ID索引树查到ID=300对应的R3; - 在k索引树取下一个值k=5,取得ID=500; - 再回到ID索引树查到ID=500对应的R4; - 在k索引树取下一个值k=6,不满足条件,循环结束。 在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了k索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。 覆盖索引 如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引k已经”覆盖了”我们的查询需求,我们称为覆盖索引。 索引下推 而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。 全局锁和表锁 根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。 全局锁 顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock(FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。 全局锁的典型使用场景是,做全库逻辑备份。 也就是把整库每个表都select出来存成文本。 官方自带的逻辑备份工具是MySQLdump。当MySQLdump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。 你一定在疑惑,有了这个功能,为什么还需要FTWRL呢?一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。 所以,single-transaction方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。 你也许会问,既然要全库只读,为什么不使用set global readonly=true的方式呢?确实readonly方式也可以让全库进入只读状态,但我还是会建议你用FTWRL方式,主要有两个原因: - 一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改global变量的方式影响面更大,我不建议你使用。 - 二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。 表级锁 MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。 表锁的语法是 lock tables … read/write。与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。 举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。 在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。 另一类表级的锁是MDL(metadata lock)。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。 因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。 - 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。 - 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。 你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们来看一下下面的操作序列,假设表t是一个小表。 备注:这里的实验环境是MySQL 5.6。 我们可以看到session A先启动,这时候会对表t加一个MDL读锁。由于session B需要的也是MDL读锁,因此可以正常执行。之后session C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。 如果只有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。前面我们说了,所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。 如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session再请求的话,这个库的线程很快就会爆满。 你现在应该知道了,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。 行锁 在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。 知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。我给你举个例子。 假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个业务需要涉及到以下操作: - 从顾客A账户余额中扣除电影票价; - 给影院B的账户余额增加这张电影票价; - 记录一条交易日志。 也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的顺序呢? 试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。 根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。 事务到底是隔离的还是不隔离的? begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句(第一个快照读语句),事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。 在MySQL里,有两个”视图”的概念: - 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view… ,而它的查询方法与表一样。 - 另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。 它没有物理结构,作用是事务执行期间用来定义”我能看到什么数据”。 “快照”在MVCC里是怎么工作的? 在可重复读隔离级别下,事务在启动的时候就”拍了个快照”。注意,这个快照是基于整库的。 一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况: - 版本未提交,不可见; - 版本已提交,但是是在视图创建后提交的,不可见; - 版本已提交,而且是在视图创建前提交的,可见。 更新数据都是先读后写的,而这个读,只能读当前的值,称为”当前读”(current read)。 当前读的规则,就是要能读到所有已经提交的记录的最新值。 这里我们提到了一个概念,叫作当前读。其实,除了update语句外,select语句如果加锁,也是当前读。 事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。 事务到底是隔离的还是不隔离的? 普通索引和唯一索引,应该怎么选择? 1 2 3 4 5 | MySQL> create table T( id int primary key, k int not null, name varchar(16), index (k))engine=InnoDB; | 假设字段 k 上的值都不重复。 查询过程 假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程,先是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下⻆的这个数据页,然后可以认为数据页内部通过二分法来定位记录。 - 对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰到第一个不满足k=5条件的记录。 - 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。 那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。 你知道的,InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB。 因为引擎是按页读写的,所以说,当找到k=5的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次”查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。 当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。 但是,我们之前计算过,对于整型字段,一个数据页可以放近千个key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。 更新过程 当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。 需要说明的是,虽然名字叫作change buf… ### TCP 状态图 - URL: https://jiankunking.com/tcp-state-diagram.html - Content type: original - Published: 2020-01-18 - Updated: 2020-01-18 - Summary: 本文详细介绍TCP的三次握手、四次挥手过程以及TCP状态机,包括CLOSEWAIT堆积的危害、TIMEWAIT问题的排查与解决方法。 - Categories: Network - Tags: Network, TCP, IP Article text: 文章速览 本文详细介绍TCP的三次握手、四次挥手过程以及TCP状态机,包括CLOSEWAIT堆积的危害、TIMEWAIT问题的排查与解决方法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 本文详细介绍TCP的三次握手、四次挥手过程以及TCP状态机,包括CLOSE_WAIT堆积的危害、TIME_WAIT问题的排查与解决方法。 TCP的三次握手、TCP四次挥手、TCP状态机 TCP的三次握手 TCP四次挥手 CLOSE_WAIT 堆积的危害 每个 CLOSE_WAIT 连接会占据一个文件描述,堆积大量的 CLOSE_WAIT 可能造成文件描述符不够用,导致建连或打开文件失败,报错 too many open files: 1 | dial udp 9.215.0.48:9073: socket: too many open files | 如何判断? 检查系统 CLOSE_WAIT 连接数: 1 | lsof | grep CLOSE_WAIT | wc -l | 检查指定进程 CLOSE_WAIT 连接数: 1 | lsof -p $PID | grep CLOSE_WAIT | wc -l | 主动关闭的一方发出 FIN 包,被动关闭的一方响应 ACK 包,此时,被动关闭的一方就进入了 CLOSE_WAIT 状态。如果一切正常,稍后被动关闭的一方也会发出 FIN 包,然后迁移到 LAST_ACK 状态。 通常,CLOSE_WAIT 状态在服务器停留时间很短,如果你发现大量的 CLOSE_WAIT 状态,那么就意味着被动关闭的一方没有及时发出 FIN 包,一般来说都是被动关闭的一方应用程序有问题。 应用没有 Close 如果 CLOSE_WAIT 堆积的量特别大(比如 10w+),甚至导致文件描述符不够用了,一般就是应用没有 Close 连接导致。 当连接被关闭时,被动关闭方在代码层面没有 close 掉相应的 socket 连接,那么自然不会发出 FIN 包,从而会导致 CLOSE_WAIT 堆积。可能是代码里根本没写 Close,也可能是代码不严谨,出现死循环之类的问题,导致即便后面写了 close 也永远执行不到。 应用迟迟不 accept 连接 如果 CLOSE_WAIT 堆积的量不是很大,可能是全连接队列 (accept queue) 堆积了。我们先看下 TCP 连接建立的过程: 连接建立好之后会被放入 accept queue,等待应用 accept,如果应用迟迟没有从队列里面去 accept 连接,等到 client 超时时间,主动关闭了连接,这时连接在 server 端仍在全连接队列中,状态变为 CLOSE_WAIT。 如果连接一直不被应用 accept 出来,内核也不会自动响应 ACK 去关闭连接的。不过这种情况的堆积量一般也不高,取决于 accept queue 的大小。 TIME_WAIT 补充我一个之前遇到的场景,有次上线服务,发现连接ES的连接有很多TIME_WAIT TIME_WAIT很明显就是本地主动关闭连接,但在等2MSL。 按理说连接ES都是长连接(Keep-Alive),不会有这么多需要关闭的连接。 看了下代码及issue,找到了原因 https://github.com/elastic/go-elasticsearch/issues/123 发现其实是Golang的一个问题 https://pkg.go.dev/net/http#Response 1 2 3 4 5 6 | // The http Client and Transport guarantee that Body is always // non-nil, even on responses without a body or responses with // a zero-length body. It is the caller's responsibility to // close Body. The default HTTP client's Transport may not // reuse HTTP/1.x "keep-alive" TCP connections if the Body is // not read to completion and closed. | 在代码中加上 1 | _ = res.String() | 问题解决 如果直接使用net/http来发送请求,除了公用一个client之外,发送完数据后,也需要 1 2 3 4 5 6 7 8 9 | resp, err := httpc.HttpClient.Do(req) if err != nil { return err } defer resp.Body.Close() // 除了.Close()之后,还需要读取一下body,否则链接的状态还是TIME_WAIT // 以下两种读取方式,任选其一即可 // _, err = ioutil.ReadAll(resp.Body) // io.Copy(ioutil.Discard, resp.Body) | 关于TIME_WAIT一些其它资料推荐: - 10-TIME_WAIT:隐藏在细节下的魔鬼 - 左耳朵耗子:从一次经历谈 TIME_WAIT 的那些事 - Tuning the Go HTTP Client Settings TCP状态机 将连接建立和连接断开的两个时序状态图综合起来,就是这个著名的TCP的状态机。学习的时候比较建议将这个状态机和时序状态机对照着看,不然容易晕。 在这个图中,加黑加粗的部分,是上面说到的主要流程,其中阿拉伯数字的序号,是连接过程中的顺序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端A的状态变迁,加粗的虚线是服务端B的状态变迁。 TCP Flags TCP中Flags字段 - SYN 表示建立连接,在TCP监听中为: Flags [S]; - FIN 表示关闭连接,在TCP监听中为: Flags [F]; - ACK 表示收到请求,返回响应,在TCP监听中为: Flags [.]; - PSH 表示数据传输,在TCP监听中为: Flags [P]; - RST 表示连接重置,在TCP监听中为: Flags [R]。 Flags字段组合使用 - 收到并建立连接为: Flags [S.] - 收到并关闭连接为: Flags [F.] - 收到并传输数据为: Flags [P.] - 收到并连接重置为: Flags [R.] 简称解释 - SYN - The synchronization flag is used to establish a three-way handshake between two hosts. Only the first packet from both the sender and receiver should have this flag set. - ACK - The acknowledgment flag is used to acknowledge the successful receipt of a packet. As we can see from the diagram above, the receiver sends an ACK as well as a SYN in the second step of the three-way handshake process to tell the sender that it received its initial packet. - FIN - The finished flag means there is no more data from the sender. Therefore, it is used in the last packet sent from the sender. It frees the reserved resources and gracefully terminates the connection. - URG - The urgent flag is used to notify the receiver to process the urgent packets before processing all other packets. The receiver will be notified when all known urgent data has been received. See RFC 6093 for more details. - PSH - The push flag is similar to the URG flag and tells the receiver to process these packets as they are received instead of buffering them. Usually, by default, the transport layer waits some time for the application layer to send enough data according to the maximum segment size so that the number of packets transmitted over the network is minimized. However, this is not desirable for certain applications, such as interactive applications (chatting). By using Push, this problem is solved. - RST - The reset flag gets sent from the receiver to the sender when a packet is sent to a particular host that was not expecting it. - ECE - This flag is responsible for indicating if the TCP peer is ECN capable. See RFC 3168 for more details. - CWR - The congestion window reduced flag is used by the sending host to indicate it received a packet with the ECE flag set. See RFC 3168 for more details. - NS (experimental) - The nonce sum flag is still an experimental flag used to help protect against accidental, malicious concealment of packets from the sender. See RFC 3540 for more details. Is it allowed to send FIN, PSH and ACK in a single packet? 是的,这是允许的,也是正常的。如果这是要发送的最后一个数据,那么 - 确认先前的数据 - 指示没有新数据到来以及 - 指示应将数据推送到应用程序而不对即将到来的数据进行任何延迟的最有效方法是设置这三个标志。 推荐阅读 如何提升TCP三次握手的性能? 如何提升TCP四次挥手的性能? ### Linux性能优化实战 笔记 - URL: https://jiankunking.com/linux-performance-optimization-practices.html - Content type: original - Published: 2020-01-09 - Updated: 2020-01-09 - Summary: 《Linux性能优化实战》学习笔记,涵盖平均负载、CPU使用率、内存管理、I/O性能、网络性能等核心知识点。 - Categories: Linux - Tags: Reading Notes, Linux Article text: 文章速览 《Linux性能优化实战》学习笔记,涵盖平均负载、CPU使用率、内存管理、I/O性能、网络性能等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Linux 《Linux性能优化实战》学习笔记,涵盖平均负载、CPU使用率、内存管理、I/O性能、网络性能等核心知识点。 Linux性能优化实战 作者: 倪朋飞 平均负载 概念 简单来说,平均负载是指单位时间内,系统处于可运行状态和不可中断状态的平均进程数,也就是平均活跃进程数,它和CPU使用率并没有直接关系。这里我先解释下,可运行状态和不可中断状态这俩词儿。 所谓可运行状态的进程,是指正在使用CPU或者正在等待CPU的进程,也就是我们常用ps命令看到的,处于R状态(Running或Runnable)的进程。 不可中断状态的进程则是正处于内核态关键流程中的进程,并且这些流程是不可打断的,比如最常见的是等待硬件设备的I/O响应,也就是我们在ps命令中看到的D状态(Uninterruptible Sleep,也称为 Disk leep)的进程。 比如,当一个进程向磁盘读写数据时,为了保证数据的一致性,在得到磁盘回复前,它是不能被其他进程或者中断打断的,这个时候的进程就处于不可中断状态。如果此时的进程被打断了,就容易出现磁盘数据与进程数据不一致的问题。 所以,不可中断状态实际上是系统对进程和硬件设备的一种保护机制。因此,你可以简单理解为,平均负载其实就是平均活跃进程数。平均活跃进程数,直观上的理解就是单位时间内的活跃进程数,但它实际上是活跃进程数的指数衰减平均值。 获取CPU核数 1 | grep 'model name' /proc/cpuinfo | wc -l | 小结 平均负载提供了一个快速查看系统整体性能的手段,反映了整体的负载情况。但只看平均负载本身,我们并不能直接发现,到底是哪里出现了瓶颈。所以,在理解平均负载时,也要注意: - 平均负载高有可能是CPU密集型进程导致的; - 平均负载高并不一定代表CPU使用率高,还有可能是I/O更繁忙了; - 当发现负载高的时候,你可以使用mpstat、pidstat等工具,辅助分析负载的来源。 CPU上下文切换 线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。 根据 Tsuna 的测试报告,每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。这个时间还是相当可观的,特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大缩短了真正运行进程的时间。 实践 经常说的CPU上下文切换是什么意思? CPU 使用率 GDB 并不适合在性能分析的早期应用。 为什么呢?因为 GDB 调试程序的过程会中断程序运行,这在线上环境往往是不允许的。所以,GDB 只适合用在性能分析的后期,当你找到了出问题的大致函数后,线下再借助它来进一步调试函数内部的问题。 那么哪种工具适合在第一时间分析进程的 CPU 问题呢?我的推荐是 perf。perf 是 Linux 2.6.31 以后内置的性能分析工具。它以性能事件采样为基础,不仅可以分析系统的各种事件和内核性能,还可以用来分析指定应用程序的性能问题。 使用 perf 分析 CPU 性能问题,我来说两种最常见、也是我最喜欢的用法。 第一种常见用法是 perf top,类似于 top,它能够实时显示占用 CPU 时钟最多的函数或者指令,因此可以用来查找热点函数,使用界面如下所示: 1 2 3 4 5 6 7 8 | $ perf top Samples: 833 of event 'cpu-clock', Event count (approx.): 97742399 Overhead Shared Object Symbol 7.28% perf [.] 0x00000000001f78a4 4.72% [kernel] [k] vsnprintf 4.32% [kernel] [k] module_get_kallsym 3.65% [kernel] [k] _raw_spin_unlock_irqrestore ... | 输出结果中,第一行包含三个数据,分别是采样数(Samples)、事件类型(event)和事件总数量(Event count)。比如这个例子中,perf 总共采集了 833 个 CPU 时钟事件,而总事件数则为 97742399。 另外,采样数需要我们特别注意。如果采样数过少(比如只有十几个),那下面的排序和百分比就没什么实际参考价值了。 再往下看是一个表格式样的数据,每一行包含四列,分别是: 第一列 Overhead ,是该符号的性能事件在所有采样中的比例,用百分比来表示。 第二列 Shared ,是该函数或指令所在的动态共享对象(Dynamic Shared Object),如内核、进程名、动态链接库名、内核模块名等。 第三列 Object ,是动态共享对象的类型。比如 [.] 表示用户空间的可执行程序、或者动态链接库,而 [k] 则表示内核空间。 最后一列 Symbol 是符号名,也就是函数名。当函数名未知时,用十六进制的地址来表示。 还是以上面的输出为例,我们可以看到,占用 CPU 时钟最多的是 perf 工具自身,不过它的比例也只有 7.28%,说明系统并没有 CPU 性能问题。 perf top 的使用你应该很清楚了吧。 接着再来看第二种常见用法,也就是 perf record 和 perf report。 perf top 虽然实时展示了系统的性能信息,但它的缺点是并不保存数据,也就无法用于离线或者后续的分析。而 perf record 则提供了保存数据的功能,保存后的数据,需要你用 perf report 解析展示。 1 2 3 4 | $ perf record # 按 Ctrl+C 终止采样 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.452 MB perf.data (6093 samples) ] $ perf report # 展示类似于 perf top 的报告 | 在实际使用中,我们还经常为 perf top 和 perf record 加上 -g 参数,开启调用关系的采样,方便我们根据调用链来分析性能问题。 小结 - 用户 CPU 和 Nice CPU 高,说明用户态进程占用了较多的 CPU,所以应该着重排查进程的性能问题。 - 系统 CPU 高,说明内核态占用了较多的 CPU,所以应该着重排查内核线程或者系统调用的性能问题。 - I/O 等待 CPU 高,说明等待 I/O 的时间比较长,所以应该着重排查系统存储是不是出现了 I/O 问题。 - 软中断和硬中断高,说明软中断或硬中断的处理程序占用了较多的 CPU,所以应该着重排查内核中的中断服务程序。 碰到 CPU 使用率升高的问题,你可以借助 top、pidstat 等工具,确认引发 CPU 性能问题的来源;再使用 perf 等工具,排查出引起性能问题的具体函数。 短时应用的运行时间比较短,很难在 top 或者 ps 这类展示系统概要和进程快照的工具中发现,你需要使用记录事件的工具来配合诊断,比如 execsnoop 或者 perf top。 实践 系统的CPU使用率很高但为啥却找不到高CPU? 系统中出现大量不可中断进程和僵尸进程怎么办? Top 输出分析 下面是一个 top 命令输出的示例,S 列(也就是 Status 列)表示进程的状态。从这个示例里,你可以看到 R、D、Z、S、I 等几个状态,它们分别是什么意思呢? 1 2 3 4 5 6 7 8 9 10 | $ top PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 28961 root 20 0 43816 3148 4040 R 3.2 0.0 0:00.01 top 620 root 20 0 37280 33676 908 D 0.3 0.4 0:00.01 app 1 root 20 0 160072 9416 6752 S 0.0 0.1 0:37.64 systemd 1896 root 20 0 0 0 0 Z 0.0 0.0 0:00.00 devapp 2 root 20 0 0 0 0 S 0.0 0.0 0:00.10 kthreadd 4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H 6 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 mm_percpu_wq 7 root 20 0 0 0 0 S 0.0 0.0 0:06.37 ksoftirqd/0 | 我们挨个来看一下: - R 是 Running 或 Runnable 的缩写,表示进程在 CPU 的就绪队列中,正在运行或者正在等待运行。 - D 是 Disk Sleep 的缩写,也就是不可中断状态睡眠(Uninterruptible Sleep),一般表示进程正在跟硬件交互,并且交互过程不允许被其他进程或中断打断。 - Z 是 Zombie 的缩写,如果你玩过“植物大战僵尸”这款游戏,应该知道它的意思。它表示僵尸进程,也就是进程实际上已经结束了,但是父进程还没有回收它的资源(比如进程的描述符、PID 等)。 - S 是 Interruptible Sleep 的缩写,也就是可中断状态睡眠,表示进程因为等待某个事件而被系统挂起。当进程等待的事件发生时,它会被唤醒并进入 R 状态。 - I 是 Idle 的缩写,也就是空闲状态,用在不可中断睡眠的内核线程上。前面说了,硬件交互导致的不可中断进程用 D 表示,但对某些内核线程来说,它们有可能实际上并没有任何负载,用 Idle 正是为了区分这种情况。要注意,D 状态的进程会导致平均负载升高, I 状态的进程却不会。 当然了,上面的示例并没有包括进程的所有状态。除了以上 5 个状态,进程还包括下面这2个状态。 第一个是 T 或者 t,也就是 Stopped 或 Traced 的缩写,表示进程处于暂停或者跟踪状态。 向一个进程发送 SIGSTOP 信号,它就会因响应这个信号变成暂停状态(Stopped);再向它发送 SIGCONT 信号,进程又会恢复运行(如果进程是终端里直接启动的,则需要你用 fg 命令,恢复到前台运行)。 而当你用调试器(如 gdb)调试一个进程时,在使用断点中断进程后,进程就会变成跟踪状态,这其实也是一种特殊的暂停状态,只不过你可以用调试器来跟踪并按需要控制进程的运行。 另一个是 X,也就是 Dead 的缩写,表示进程已经消亡,所以你不会在 top 或者 ps 命令中看到它。 僵尸进程,这是多进程应用很容易碰到的问题。正常情况下,当一个进程创建了子进程后,它应该通过系统调用 wait() 或者 waitpid() 等待子进程结束,回收子进程的资源而子进程在结束时,会向它的父进程发送 SIGCHLD 信号,所以,父进程还可以注册SIGCHLD 信号的处理函数,异步回收资源。 如果父进程没这么做,或是子进程执行太快,父进程还没来得及处理子进程状态,子进程就已经提前退出,那这时的子进程就会变成僵尸进程。换句话说,父亲应该一直对儿子负责,善始善终,如果不作为或者跟不上,都会导致“问题少年”的出现。 通常,僵尸进程持续的时间都比较短,在父进程回收它的资源后就会消亡;或者在父进程退出后,由 init 进程回收后也会消亡。 一旦父进程没有处理子进程的终止,还一直保持运行状态,那么子进程就会一直处于僵尸状态。大量的僵尸进程会用尽 PID 进程号,导致新进程不能创建,所以这种情况一定要避免。 不可中断状态,表示进程正在跟硬件交互,为了保护进程数据和硬件的一致性,系统不允许其他进程或中断打断这个进程。进程长时间处于不可中断状态,通常表示系统有 I/O 性能问题。 实践 dstat命令 是一个用来替换vmstat、iostat、netstat、nfsstat和ifstat这些命令的工具,是一个全能系统信息统计工具。与sysstat相比,dstat拥有一个彩色的界面,在手动观察性能状况时,数据比较显眼容易观察;而且dstat支持即时刷新,譬如输入dstat 3即每三秒收集一次,但最新的数据都会每秒刷新显示。和sysstat相同的是,dstat也可以收集指定的性能资源,譬如dstat -c即显示CPU的使用情况。 系统中出现大量不可中断进程和僵尸进程怎么办? 怎么理解Linux软中断? 怎么理解Linux软中断? 如何迅速分析出系统CPU的瓶颈在哪里? 如何迅速分析出系统CPU的瓶颈在哪里? pidstat 中, %wait 表示进程等待 CPU 的时间百分比。 top 中 ,iowait% 则表示等待 I/O 的 CPU 时间百分比。 Linux性能优化答疑 关注一下:【问题 2:如何用 perf 工具分析 Java 程序】 像是 Java 这种通过 JVM 来运行的应用程序,运行堆栈用的都是 JVM 内置的函数和堆栈管理。所以,从系统层面你只能看到JVM的函数堆栈,而不能直接得到 Java 应用程序的堆栈。 perf_events 实际上已经支持了 JIT,但还需要一个 /tmp/perf-PID.map 文件,来进行符号翻译。当然,开源项目 perf-map-agent 可以帮你生成这个符号表。 Linux性能优化答疑 怎么理解内存中的Buffer和Cache? 1 2 3 4 5 | # 注意不同版本的 free 输出可能会有所不同 $ free total used free shared buff/cache available Mem: 8169348 263524 6875352 668 1030472 7611064 Swap: 0 0 0 | 这里的大部分指标都比较容易理解,但 Buffer 和 Cache 可能不太好区分。从字面上来说,Buffer 是缓冲区,而 Cache 是缓存,两者都是数据在内存中的临时存储。那么,你知道这两种“临时存储”有什么区别吗? free 数据的来源 Buffers 是内核缓冲区用到的内存,对应的是 /proc/meminfo 中的 Buffers 值。 Cache 是内核页缓存和 Slab 用到的内存,对应的是 /proc/meminfo 中的 Cached 与 SReclaimable 之和。 proc 文件系统 /proc 是 Linux 内核提供的一种特殊文件系统,是用户跟内核交互的接口。比方说,用户可以从 /proc 中查询内核的运行状态和配置选项,查询进程的运行状态、统计数据等,当然,你也可以通过 /proc 来修改内核的配置。 proc 文件系统同时也是很多性能工具的最终数据来源。比如我们刚才看到的 free ,就是通过读取 /proc/meminfo ,得到内存的使用情况。 Buffers 是对原始磁盘块的临时存储,也就是用来缓存磁盘的数据,通常不会特别大(20MB 左右)。这样,内核就可以把分散的写集中起来,统一优化磁盘的写入,比如可以把多次小的写合并成单次大的写等等。 Cached 是从磁盘读取文件的页缓存,也就是用来缓存从文件读取的数据。这样,下次访问这些文件数据时,就可以直接从内存中快速获取,而不需要再次访问缓慢的磁盘。SReclaimable 是 Slab 的一部分。Slab 包括两部分,其中的可回收部分,用SReclaimable 记录;而不可回收部分,用 SUnreclaim 记录。 Buffer 是对磁盘数据的缓存,而 Cache 是文件数据的缓存,它们既会用在读请求中,也会用在写请求中。 磁盘与文件 磁盘是一个存储设备(确切地说是块设备),可以被划分为不同的磁盘分区。而在磁盘或者磁盘分区上,还可以再创建文件系统,并挂载到系统的某个目录中。这样,系统就可以通过这个挂载目录,来读写文件。 换句话说,磁盘是存储数据的块设备,也是文件系统的载体。所以,文件系统确实还是要通过磁盘,来保证数据的持久化存储。 你在很多地方都会看到这句话, Linux 中一切皆文件。换句话说,你可以通过相同的文件接口,来访问磁盘和文件(比如 open、read、write、close 等)。 在读写普通文件时,I/O 请求会首先经过文件系统,然后由文件系统负责,来与磁盘进行交互。而在读写块设备文件时,会跳过文件系统,直接与磁盘交互,也就是所谓的“裸I/O”。 这两种读写方式使用的缓存自然不同。文件系统管理的缓存,其实就是 Cache 的一部分。而裸磁盘的缓存,用的正是 Buffer。 cache是针对文件系统的缓存,而 buffers是对磁盘数据的缓存,是直接跟硬件那一层相关的,那一般来说, cache会比 buffers 的数量大了很多。 案例 如何利用系统缓存优化程序的运行效率? 为什么系统的Swap变高了? NUMA 与 Swap 19为什么系统的Swap变高了? 在内存资源紧张时,Linux 会通过 Swap ,把不常访问的匿名页换出到磁盘中,下次访问的时候再从磁盘换入到内存中来。你可以设置 /proc/sys/vm/min_free_kbytes,来调整系统定期回收内存的阈值;也可以设置 /proc/sys/vm/swappiness,来调整文件页和匿名页的回收倾向。 当 Swap 变高时,你可以用 sar、/proc/zoneinfo、/proc/pid/status 等方法,查看系统和进程的内存使用情况,进而找出 Swap 升高的根源和受影响的进程。 如何“快准狠”找到系统内存的问题? 为了迅速定位内存问题,我通常会先运行几个覆盖面比较大的性能工具,比如free、top、vmstat、pidstat 等。 具体的分析思路主要有这几步。 - 先用 free 和 top,查看系统整体的内存使用情况。 - 再用 vmstat 和 pidstat,查看一段时间的趋势,从而判断出内存问题的类型。 - 最后进行详细分析,比如内存分配分析、缓存 / 缓冲区分析、具体进程的内存使用分析等。 根据指标查找工具 根据工具查找指标 Linux 磁盘I/O是怎么工作的? 磁盘性能指标 说到磁盘性能的衡量标准,必须要提到五个常见指标,也就是我们经常用到的,使用率、饱和度、IOPS、吞吐量以及响应时间等。这五个指标,是衡量磁盘性能的基本指标。 - 使用率,是指磁盘处理 I/O 的时间百分比。过高的使用率(比如超过 80%),通常意味着磁盘 I/O 存在性能瓶颈。 - 饱和度,是指磁盘处理 I/O 的繁忙程度。过高的饱和度,意味着磁盘存在严重的性能瓶颈。当饱和度为 100% 时,磁盘无法接受新的 I/O 请求。 - IOPS(Input/Output Per Second),是指每秒的 I/O 请求数。 - 吞吐量,是指每秒的 I/O 请求大小。 - 响应时间,是指 I/O 请求从发出到收到响应的间隔时间。 这里要注意的是,使用率只考虑有没有 I/O,而不考虑 I/O 的大小。换句话说,当使用率是 100% 的时候,磁盘依然有可能接受新的 I/O 请求。 这些指标,很可能是你经常挂在嘴边的,一讨论磁盘性能必定提起的对象。不过我还是要强调一点,不要孤立地去比较某一指标,而要结合读写比例、I/O 类型(随机还是连续)以及 I/O 的大小,综合来分析。 磁盘 I/O 观测 这些指标中,你要注意: - %util ,就是我们前面提到的磁盘 I/O 使用率; - r/s+ w/s ,就是 IOPS; - rkB/s+wkB/s ,就是吞吐量; - r_await+w_await ,就是响应时间。 在观测指标时,也别忘了结合请求的大小( rareq-sz 和 wareq-sz)一起分析。 进程 I/O 观测 要观察进程的 I/O 情况,你还可以使用 pidstat 和 iotop 这两个工具。 根据指标查找工具 根据工具查找指标 关于 Linux 网络,你必须知道这些 网络模型 为了解决网络互联中异构设备的兼容性问题,并解耦复杂的网络包处理流程,OSI 模型把网络互联的框架分为应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层等七层,每个层负责不同的功能。其中: - 应用层,负责为应用程序提供统一的接口。 - 表示层,负责把数据转换成兼容接收系统的格式。 - 会话层,负责维护计算机之间的通信连接。 - 传输层,负责为数据加上传输表头,形成数据包。 - 网络层,负责数据的路由和转发。 - 数据链路层,负责 MAC 寻址、错误侦测和改错。 - 物理层,负责在物理网络中传输数据帧。 但是 OSI 模型还是太复杂了,也没能提供一个可实现的方法。所以,在 Linux 中,我们实际上使用的是另一个更实用的四层模型,即 TCP/IP 网络模型。 TCP/IP 模型,把网络互联的框架分为应用层、传输层、网络层、网络接口层等四层,其中: - 应用层,负责向用户提供一组应用程序,比如 HTTP、FTP、DNS 等。 - 传输层,负责端到端的通信,比如 TCP、UDP 等。 - 网络层,负责网络包的封装、寻址和路由,比如 IP、ICMP 等。 - 网络接口层,负责网络包在物理网络中的传输,比如 MAC 寻址、错误侦测以及通过网卡传输网络帧等。 网络配置 ifconfig 和 ip 分别属于软件包 net-tools 和 iproute2,iproute2 是 net-tools 的下一代。 我个人更推荐使用 ip 工具,因为它提供了更丰富的功能和更易用的接口。 以网络接口 ens160 为例,你可以运行下面的两个命令,查看它的配置和状态: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | [root@jiankunking ~]# ip -s addr show dev ens160 2: ens160: mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:50:56:b1:ee:91 brd ff:ff:ff:ff:ff:ff inet 10.138.40.223/24 brd 10.138.40.255 scope global noprefixroute ens160 valid_lft forever preferred_lft forever RX: bytes packets errors dropped overrun mcast 105694689249 460305272 0 139334 0 78892 TX: bytes packets errors dropped carrier collsns 501968646454 337674507 0 0 0 0 [root@jiankunking ~]# ifconfig ens160 ens160: flags=4163 mtu 1500 inet 10.138.40.223 netmask 255.255.255.0 broadcast 10.138.40.255 ether 00:50:56:b1:ee:91 txqueuelen 1000 (Ethernet) RX packets 460306226 bytes 105694760042 (98.4 GiB) RX errors 0 dropped 139334 overruns 0 frame 0 TX packets 337674596 bytes 501968659316 (467.4 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 [root@jiankunking ~]# | 你可以看到,ifconfig 和 ip 命令输出的指标基本相同,只是显示格式略微不同。比如,它们都包括了网络接口的状态标志、MTU 大小、IP、子网、MAC 地址以及网络包收发的统计信息。 这里有几个跟网络性能密切相关的指标,需要你特别关注一下。 第一,网络接口的状态标志。ifconfig 输出中的 RUNNING ,或 ip 输出中的LOWER_UP ,都表示物理网络是连通的,即网卡已经连接到了交换机或者路由器中。如果你看不到它们,通常表示网线被拔掉了。 第二,MTU 的大小。MTU 默认大小是 1500,根据网络架构的不同(比如是否使用了VXLAN 等叠加网络),你可能需要调大或者调小 MTU 的数值。 第三,网络接口的 IP 地址、子网以及 MAC 地址。这些都是保障网络功能正常工作所必需的,你需要确保配置正确。 第四,网络收发的字节数、包数、错误数以及丢包情况,特别是 TX 和 RX 部分的errors、dropped、overruns、carrier 以及 collisions 等指标不为 0 时,通常表示出现了网络 I/O 问题。其中: - errors 表示发生错误的数据包数,比如校验错误、帧同步错误等; - dropped 表示丢弃的数据包数,即数据包已经收到了 Ring Buffer,但因为内存不足等原因丢包; - overruns 表示超限数据包数,即网络 I/O 速度过快,导致 Ring Buffer 中的数据包来不及处理(队列满)而导致的丢包; - carrier 表示发生 carrirer 错误的数据包数,比如双工模式不匹配、物理电缆出现问题等; - collisions 表示碰撞数据包数。 套接字信息 可以用 netstat 或者 ss ,来查看套接字、网络栈、网络接口以及路由表的信息。 我个人更推荐,使用 ss 来查询网络的连接信息,因为它比 netstat 提供了更好的性能(速度更快)。 比如,你可以执行下面的命令,查询套接字信息: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # head -n 3 表示只显示前面 3 行 # -l 表示只显示监听套接字 # -n 表示显示数字地址和端口 (而不是名字) # -p 表示显示进程信息 [root@jiankunking ~]# netstat -nlp | head -n 3 Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:18022 0.0.0.0:* LISTEN 68726/sshd # -l 表示只显示监听套接字 # -t 表示只显示 TCP 套接字 # -n 表示显示数字地址和端口 (而不是名字) # -p 表示显示进程信息 [root@jiankunking ~]# ss -ltnp | head -n 3… ### 数据结构与算法之美:排序部分 - URL: https://jiankunking.com/time-geekbang-sort-algorithm-recommend.html - Content type: original - Published: 2019-12-30 - Updated: 2019-12-30 - Summary: 本文整理了极客时间《数据结构与算法之美》专栏中排序相关的内容,包括插入排序、快速排序、线性排序以及排序优化等核心知识点。 - Categories: Algorithm - Tags: Algorithm, Sort Article text: 文章速览 本文整理了极客时间《数据结构与算法之美》专栏中排序相关的内容,包括插入排序、快速排序、线性排序以及排序优化等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Algorithm 本文整理了极客时间《数据结构与算法之美》专栏中排序相关的内容,包括插入排序、快速排序、线性排序以及排序优化等核心知识点。 在线: 为什么插入排序比冒泡排序更受欢迎 如何用快排思想在O(n)内查找第K大元素 如何根据年龄给100万用户数据排序 如何实现一个通用的、高性能的排序函数 以上PDF整理自网络 ### 2的幂表 - URL: https://jiankunking.com/2-power-table.html - Content type: original - Published: 2019-12-22 - Updated: 2019-12-22 - Summary: 2的幂次方与字节、KB、MB、GB、TB的换算关系速查表,可用于快速估算内存占用和存储空间。 - Categories: Basic - Tags: Math, Performance, Bitwise, Reading Notes Article text: 文章速览 2的幂次方与字节、KB、MB、GB、TB的换算关系速查表,可用于快速估算内存占用和存储空间。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Basic 2的幂次方与字节、KB、MB、GB、TB的换算关系速查表,可用于快速估算内存占用和存储空间。 2的指数字节转与MB、GB换算关系 2的幂 准确值(X) 近似值 X字节转换成MB、GB等 7 | 128 | | | 8 | 256 | | | 10 | 1 024 | 一千 | 1K | 16 | 65 536 | | 64K | 20 | 1 048 576 | 一百万 | 1MB | 30 | 1 073 741 824 | 十亿 | 1GB | 32 | 4 294 967 296 | | 4GB | 40 | 1 099 511 627 776 | 一万亿(trillion) | 1TB | 这张表可以拿来做速算。例如,一个将每个32位整数映射成布尔值的向量表可以在一台普通计算机内存中放下。那样的整数有2^32个。因为每个整数只占位向量表中的一位,共需要232位(或者229 字节)来存储该映射表,大约是千兆字节的一半,普通机器很容易满足。 计算机存储单位换算 ### Java 伪共享 - URL: https://jiankunking.com/java-false-sharing.html - Content type: original - Published: 2019-11-22 - Updated: 2019-11-22 - Summary: 伪共享(False Sharing)是多处理器系统中的性能杀手,当多个线程修改同一缓存行中的不同变量时,会导致缓存行频繁失效,严重影响性能。本文详解伪共享的原理、影响及解决方案。 - Categories: Java - Tags: Java, Sharing Article text: 文章速览 伪共享(False Sharing)是多处理器系统中的性能杀手,当多个线程修改同一缓存行中的不同变量时,会导致缓存行频繁失效,严重影响性能。本文详解伪共享的原理、影响及解决方案。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 伪共享(False Sharing)是多处理器系统中的性能杀手,当多个线程修改同一缓存行中的不同变量时,会导致缓存行频繁失效,严重影响性能。本文详解伪共享的原理、影响及解决方案。 伪共享 先看一下wiki中对于伪共享的解释: In computer science, false sharing is a performance-degrading usage pattern that can arise in systems with distributed, coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts to periodically access data that will never be altered by another party, but those data share a cache block with data that are altered, the caching protocol may force the first participant to reload the whole unit despite a lack of logical necessity. The caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead required by true shared access of a resource. By far the most common usage of this term is in modern multiprocessor CPU caches, where memory is cached in lines of some small power of two word size (e.g., 64 aligned, contiguous bytes). If two processors operate on independent data in the same memory address region storable in a single line, the cache coherency mechanisms in the system may force the whole line across the bus or interconnect with every data write, forcing memory stalls in addition to wasting system bandwidth. False sharing is an inherent artifact of automatically synchronized cache protocols and can also exist in environments such as distributed file systems or databases, but current prevalence is limited to RAM caches. 在计算机科学中,错误共享是会导致性能下降,它可能出现在使用由缓存机制管理的分布式、一致的缓存的系统中,系统中最小粒度是一个缓存块。当系统参与者试图周期性地访问部分数据,这部分数据只会被自己修改,但是这些数据可能与别的数据存储在同一个缓存块,当别的数据被修改的时候,缓存协议可能会强制第一个参与者重新加载整个单元,尽管缺乏逻辑上的必要性。 到目前为止,该术语最常见的用法是在现代多处理器CPU高速缓存中,在该高速缓存中,内存以两个字长(例如64个对齐的连续字节)的行高速缓存。如果两个处理器在同一内存地址区域中的独立数据上操作,而该内存地址区域可存储在一行中,则系统中的缓存一致性机制可能会强制每次数据写入都通过总线刷新整个行,除了浪费系统带宽外,还会导致内存暂停。错误共享是自动同步的缓存协议的固有产物,也可以存在于诸如分布式文件系统或数据库之类的环境中,但是当前的流行仅限于RAM缓存。 多份数据共同存储于一个缓存行(缓存的最小单位),当其中一份数据发生更改的时候,内存系统强制更新整个缓存行。这么做的目的就是避免内存中同一地址的数据在不同缓存中的副本出现不一致。 更多信息可以参见:《垃圾回收算法手册:自动内存管理的艺术 笔记》中的 【高速缓存一致性】 Java中的伪共享 Hotspot为了优化内存占用会将字段自由地重新安排,以满足对齐要求,从而使间隙更小。也正是这种优化,导致出现了在同一缓存行上,有可能有多个数据,从而导致伪共享。 伪共享说的是缓存,而缓存的目的就是加快读取速度,也就意味了被缓存的数据不应该频繁更改。 换个角度考虑伪共享就是硬件层面高速缓存的失效,导致性能的下降。 以下图为例,当不同处理器上的线程修改驻留在同一高速缓存行上的变量时,会发生错误共享。这将使高速缓存行失效,并强制进行内存更新以保持高速缓存的一致性。 Java中的解决方案 使用@Contended注解,使用该注解,我们可以将热的频繁写入的共享字段与其他主要为只读或冷的字段隔离开来。简单的规则是读共享很便宜,写共享很昂贵。我们还可以将经常由同一线程同时写入的字段打包。 更一般地说,我们试图影响相关字段的位置,以最小化一致性缺失。在一个简单的单线程环境中,在时间上紧密地一起访问的字段应该放在相邻的空间,以促进缓存局部性。也就是说,时间局部性应该制约空间局部性。在时间上一起访问的字段应该在空间上相邻。也就是说,当线程同时访问我们的字段时,我们必须小心避免错误共享和一致性通信的过度失效。因此,我们试图集群或以其他方式隔离在同一线程上大约在同一时间写入相同缓存行的字段。请注意,这里有一个竞争的因素:如果我们过于努力地将单线程容量丢失最小化,那么我们最终可能会在并行环境中运行过多的一致性丢失。在本机C/C++代码中,程序员通常使用通知并发的结构布局。@Contended应该在Java中提供相同的功能,尽管在本地代码中,字段与偏移量的绑定在编译时发生,而在Java的加载时发生。值得指出的是,在一般情况下,对于单线程和多线程环境都没有单一的最佳布局。而理想的布局问题本身就是NP-hard。 理想情况下,JVM将使用硬件监控设施来检测共享行为并动态更改布局。这有点困难,因为我们还没有合适的方式来向JVM提供高效和方便的信息。提示:我们需要取消OS和hypervisor的中间层。另一个挑战是,在不安全的设施中使用原始字段偏移,因此我们需要解决这个问题,可能需要额外的间接级别。 最后,我也希望能够将最终字段打包在一起,因为这些字段是只读的。 小结 伪共享本质是就是在缓存中最小的颗粒度仍然大于某个对象的属性的内存占用,导致该缓存最小单元中存储了多个不相关的对象属性,多个不相关属性的(各自)修改会导致缓存状态的频繁变化。由于硬件一般支持高速缓存一致性协议,缓存状态的变化会导致多核CPU频繁更新缓存状态,导致性能下降。 图片来自: https://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads 参考资料: https://en.wikipedia.org/wiki/False_sharing http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html https://blogs.oracle.com/dave/java-contended-annotation-to-help-reduce-false-sharing 拓展阅读: https://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads https://software.intel.com/en-us/articles/intel-guide-for-developing-multithreaded-applications ### Kubernetes权威指南:从Docker到Kubernetes实践全接触 笔记 - URL: https://jiankunking.com/kubernetes-authoritative-guide.html - Content type: original - Published: 2019-11-20 - Updated: 2019-11-20 - Summary: 《Kubernetes权威指南》学习笔记,整理容器编排、资源对象、服务发现、调度、存储和集群运维。 - Categories: Kubernetes - Tags: Reading Notes, Kubernetes, Docker Article text: 文章速览 《Kubernetes权威指南》学习笔记,整理容器编排、资源对象、服务发现、调度、存储和集群运维。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Kubernetes Kubernetes权威指南:从Docker到Kubernetes实践全接触(第2版) 作者: 龚正 吴治辉 王伟 崔秀龙 闫健勇 崔晓宁 刘晓红 Kubernetes 入门 Kubernetes 是什么 在Kubernetes中, Service(服务)是分布式集群架构的核心,一个Service对象拥有如下关键特征。 - 拥有一个唯一指定的名字(比如MySQL-server) - 拥有一个虚拟IP(Cluster、 Service IP或VIP)和端口号。 - 能够提供某种远程服务能力。 - 被映射到了提供这种服务能力的一组容器应用上 Service的服务进程目前都基于Socket通信方式对外提供服务,比如Redis、Memcache、MySQL、Web Server,或者是实现了某个具体业务的一个特定的TCP Server进程。虽然一个Service通常由多个相关的服务进程来提供服务,每个服务进程都有一个独立的 Endpoint(IP+Port)访问点,但Kubernetes能够让我们通过Service(虚拟Cluster IP+Service Port)连接到指定的Service上。有了 Kubernetes内建的透明负载均衡和故障恢复机制,不管后端有多少服务进程,也不管某个服务进程是否会由于发生故障而重新部署到其他机器,都不会影响到我们对服务的正常调用。更重要的是这个Service本身一旦创建就不再变化,这意味着,在Kubernetes集群中,我们再也不用为了服务的IP地址变来变去的问题而头疼了 在通常情况下,Cluster IP是在Service创建后由Kubernetes系统自动分配的,其他Pod无法预先知道某个 Service的Cluster IP地址,因此需要一个服务发现机制来找到这个服务。为此,最初的时候, Kubernetes巧妙地使用了 Linux环境变量(Environment Variable)来解决这个问题,后面会详细说明其机制。现在我们只需知道,根据 Service的唯一名字,容器可以从环境变量中获取到Service对应的Cluster IP地址和端口,从而发起TCP/IP连接请求了。 Kubernetes 基本概念与术语 Master Kubernetes里的Master指的是集群控制节点,每个Kubernetes集群里需要有一个Master节点来负责整个集群的管理和控制,基本上Kubernetes所有的控制命令都是发给它,它来负责具体的执行过程,我们后面所有执行的命令基本都是在Master节点上运行的。Master节点通常会占据一个独立的X86服务器(或者一个虚拟机),一个主要的原因是它太重要了,它是整个集群的“首脑”,如果它宕机或者不可用,那么我们所有的控制命令都将失效。 Master节点上运行着以下一组关键进程。 - Kubernetes API Server(kube-apipserver),提供了HTTP Rest接口的关键服务进程,是Kubernetes里所有资源的增、删、改、查等操作的唯一入口,也是集群控制的入口进程。 - Kubernetes Controller Manager(kube-controller-manager), Kubernetes里所有资源对象的自动化控制中心,可以理解为资源对象的“大总管”。 - Kubernetes Scheduler(kube-scheduler),负责资源调度(Pod调度)的进程,相当于公交公司的“调度室”。 其实Master节点上往往还启动了一个etcd Server进程,因为Kubernetes里的所有资源对象的数据全部是保存在etcd中的。 Node 除了Master, Kubernetes集群中的其他机器被称为Node节点,在较早的版本中也被称为Minion。与Master一样,Node节点可以是一台物理主机,也可以是一台虚拟机。Node节点才是Kubernetes集群中的工作负载节点,每个Node都会被Master分配一些工作负载(Docker容器),当某个Node宕机时,其上的工作负载会被Master自动转移到其他节点上去。 每个Node节点上都运行着以下一组关键进程。 - kubelet:负责Pod对应的容器的创建、启停等任务,同时与Master节点密切协作,实现集群管理的基本功能。 - kube-proxy:实现Kubernetes service的通信与负载均衡机制的重要组件。 - Docker Engine(docker): Docker引擎,负责本机的容器创建和管理工作。 Node节点可以在运行期间动态增加到Kubernetes集群中,前提是这个节点上已经正确安装、配置和启动了上述关键进程,在默认情况下kubelet会向Master注册自己,这也是Kubernetes推荐的Node管理方式。一旦Node被纳入集群管理范围,kubelet进程就会定时向 Master节点汇报自身的情报,例如操作系统、Docker版本、机器的CPU和内存情况,以及之前有哪些Pod在运行等,这样 Master可以获知每个Node的资源使用情况,并实现高效均衡的资源调度策略。而某个Node超过指定时间不上报信息时,会被 Master判定为“失联”,Noe的状态被标记为不可用(Not Ready),随后 Master会触发“工作负载大转移”的自动流程。 Pod Pod是Kubernetes的最重要也最基本的概念,如图1.6所示是Pod的组成示意图,我们看到每个Pod都有一个特殊的被称为“根容器”的Pause容器。Pause容器对应的镜像属于Kubernetes平台的一部分,除了Pause容器,每个Pod还包含一个或多个紧密相关的用户业务容器。 为什么Kubernetes会设计出一个全新的Pod的概念并且Pod有这样特殊的组成结构? 原因之一:在一组容器作为一个单元的情况下,我们难以对“整体”简单地进行判断及有效地进行行动。比如,一个容器死亡了,此时算是整体死亡么?是N/M的死亡率么?引入业务无关并且不易死亡的Pause容器作为Pod的根容器,以它的状态代表整个容器组的状态,就简单、巧妙地解决了这个难题。 原因之二:Pod里的多个业务容器共享Pause容器的IP,共享Pause容器挂接的Volume,这样既简化了密切关联的业务容器之间的通信问题,也很好地解决了它们之间的文件共享问题。 Kubernetes为每个Pod都分配了唯一的IP地址,称之为Pod IP,一个Pod里的多个容器共享Pod IP地址。Kubernetes要求底层网络支持集群内任意两个Pod之间的TCP/IP直接通信,这通常采用虚拟二层网络技术来实现,例如Flannel、Openvswitch等,因此我们需要牢记一点:在Kubernetes里,一个Pod里的容器与另外主机上的Pod容器能够直接通信。 Pod其实有两种类型:普通的Pod及静态Pod(static Pod),后者比较特殊,它并不存放在Kubernetes的etcd存储里,而是存放在某个具体的Node上的一个具体文件中,并且只在此Node上启动运行。而普通的Pod一旦被创建,就会被放入到etcd中存储,随后会被Kubernetes master调度到某个具体的Node上并进行绑定(Binding),随后该Pod被对应的Node上的kubelet进程实例化成一组相关的 Docker容器并启动起来。在默认情况下,当Pod里的某个容器停止时Kubernetes会自动检测到这个问题并且重新启动这个Pod(重启Pod里的所有容器),如果Pod所在的Node宕机,则会将这个Node上的所有Pod重新调度到其他节点上。 每个Pod都可以对其能使用的服务器上的计算资源设置限额,当前可以设置限额的计算资源有CPU与 Memory两种,其中CPU的资源单位为CPU(Core)的数量,是一个绝对值而非相对值。 一个CPU的配额对于绝大多数容器来说是相当大的一个资源配额了,所以,在Kubernetes里,通常以千分之一的CPU配额为最小单位,用m来表示。通常一个容器的CPU配额被定义为100~300m,即占用0.1~0.3个CPU。由于CPU配额是一个绝对值,所以无论在拥有一个Core的机器上,还是在拥有48个Core的机器上,100m这个配额所代表的CPU的使用量都是样的。与CPU配额类似,Memory配额也是一个绝对值,它的单位是内存字节数。 在Kubernetes里,一个计算资源进行配额限定需要设定以下两个参数: - Requests:该资源的最小申请量,系统必须满足要求。 - Limits:该资源最大允许使用的量,不能被突破,当容器试图使用超过这个量的资源时可能会被Kubernetes kill并重启。 通常我们会把Request设置为一个比较小的数值,符合容器平时的工作负载情况下的资源需求,而把 Limit设置为峰值负载情况下资源占用的最大量。 Label(标签) Label是Kubernetes系统中另外一个核心概念。一个Label是一个key=value的键值对,其中key与 value由用户自己指定。 Label可以附加到各种资源对象上,例如Node、Pod、Service、RC等,一个资源对象可以定义任意数量的Label,同一个Label也可以被添加到任意数量的资源对象上去,Label通常在资源对象定义时确定,也可以在对象创建后动态添加或者删除。 我们可以通过给指定的资源对象捆绑一个或多个不同的Label来实现多维度的资源分组管理功能,以便于灵活、方便地进行资源分配、调度、配置、部署等管理工作。例如:部署不同版本的应用到不同的环境中;或者监控和分析应用(日志记录、监控、告警)等。一些常用的Label示例如下。 - 版本标签:”release”:”stable”,”release”:”canary - 环境标签:”environment”:”dev”,”environment”:”qa”,”environment”:”production” - 架构标签:”tier”:”frontend”,”tier”:”backend”,”tier”:”middleware” - 质量管控标签:”rack”:”daily”,”rack”:”week” Label相当于我们熟悉的“标签”,给某个资源对象定义一个Label,就相当于给它打了一个标签,随后可以通过Label Selector(标签选择器)查询和筛选拥有某些 Label的资源对象,Kubernetes通过这种方式实现了类似SQL的简单又通用的对象查询机制Label Selector可以被类比为SQL语句中的where查询条件,例如,name=redis-slave这个Label selector作用于Pod时,可以被类比为’select* from pod where pods name= redis-slave’这样的语句。当前有两种Label Selector的表达式:基于等式的(Equality-based)和基于集合的(Set-based),前者采用“等式类”的表达式匹配标签,下面是一些具体的例子。 - name=redis-slave:匹配所有具有标签name=redis-slave的资源对象。 - env!= production:匹配所有不具有标签env= production的资源对象,比如env=test就是满足此条件的标签之一。 而后者则使用集合操作的表达式匹配标签,下面是一些具体的例子。 - name in(redis-master, redis-save):匹配所有具有标签name=redis-master或者name=redis-slave的资源对象。 - name not in(php-frontend):匹配所有不具有标签name=php-frontend的资源对象。 可以通过多个Label Selector表达式的组合实现复杂的条件选择,多个表达式之间用”,”进行分隔即可,几个条件之间是“AND”的关系,即同时满足多个条件,比如下面的例子: - name=redis-slave, env!=production - name not in (php-frontend), env!=production Label Selector在Kubernetes中的重要使用场景有以下几处。 - kube-controller进程通过资源对象RC上定义的Label Selector来筛选要监控的Pod副本的数量,从而实现Pod副本的数量始终符合预期设定的全自动控制流程。 - kube-proxy进程通过Service的Label selector来选择对应的Pod,自动建立起每个Service到对应Pod的请求转发路由表,从而实现 Service的智能负载均衡机制。 - 通过对某些Node定义特定的Label,并且在Pod定义文件中使用NodeSelector这种标签调度策略,kube-scheduler进程可以实现Pod“定向调度”的特性。 Replication Controller (RC) RC是Kubernetes系统中的核心概念之一,简单来说,它其实是定义了一个期望的场景,即声明某种Pod的副本数量在任意时刻都符合某个预期值,所以RC的定义包括如下几个部分。 - Pod期待的副本数(replicas)。 - 用于筛选目标Pod的Label Selector - 当Pod的副本数量小于预期数量的时候,用于创建新Pod的Pod模板(template)。 当我们定义了一个RC并提交到Kubernetes集群中以后, Master节点上的Controller Manager组件就得到通知,定期巡检系统中当前存活的目标Pod,并确保目标Pod实例的数量刚好等于此RC的期望值,如果有过多的Pod副本在运行,系统就会停掉一些Pod,否则系统就会再自动创建一些Pod。可以说,通过RC,Kubernetes实现了用户应用集群的高可用性,并且大大减少了系统管理员在传统T环境中需要完成的许多手工运维工作(如主机监控脚本、应用监控脚本、故障恢复脚本等)。 Replica Set与Deployment这两个重要资源对象逐步替换了之前的RC的作用,是Kubernetes 1.3里Pod自动扩容(伸缩)这个告警功能实现的基础,也将继续在 Kubernetes未来的版本中发挥重要的作用。 最后我们总结一下关于RC(Replica Set)的一些特性与作用。 - 在大多数情况下,我们通过定义一个RC实现Pod的创建过程及副本数量的自动控制。 - RC里包括完整的Pod定义模板。 - RC通过Label Selector机制实现对Pod副本的自动控制。 - 通过改变RC里的Pod副本数量,可以实现Pod的扩容或缩容功能。 - 通过改变RC里Pod模板中的镜像版本,可以实现Pod的滚动升级功能。 Deployment Deployment是Kubernetes12引入的新概念,引入的目的是为了更好地解决Pod的编排问题。为此, Deployment在内部使用了 Replica Set来实现目的,无论从Deployment的作用与目的、它的YAM定义,还是从它的具体命令行操作来看,我们都可以把它看作RC的一次升级两者的相似度超过90%。 Deployment相对于RC的一个最大升级是我们可以随时知道当前Pod“部署”的进度。实际上由于一个Pod的创建、调度、绑定节点及在目标Node上启动对应的容器这一完整过程需要一定的时间,所以我们期待系统启动N个Pod副本的目标状态,实际上是一个连续变化的“部署过程”导致的最终状态。 Deployment的典型使用场景有以下几个。 - 创建一个Deployment对象来生成对应的Replica Set并完成Pod副本的创建过程。 - 检查Deployment的状态来看部署动作是否完成(Pod副本的数量是否达到预期的值)。 - 更新Deployment以创建新的Pod(比如镜像升级)。 - 如果当前Deployment不稳定,则回滚到一个早先的Deployment版本。 - 挂起或者恢复一个Deployment。 Horizontal Pod Autoscaler(HPA) Horizontal Pod Autoscaling简称HPA,意思是Pod横向自动扩容,与之前的RC、Deployment样,也属于一种Kubernetes资源对象。通过追踪分析RC控制的所有目标Pod的负载变化情况,来确定是否需要针对性地调整目标Pod的副本数,这是HPA的实现原理。当前,HPA可以有以下两种方式作为Pod负载的度量指标。 - CPUUtilization Percentage - 应用程序自定义的度量指标,比如服务在每秒内的相应的请求数(TPS或QPS)。 CPUUtilization Percentage是一个算术平均值,即目标Pod所有副本自身的CPU利用率的平均值。一个Pod自身的CPU利用率是该Pod当前CPU的使用量除以它的Pod Request的值,比如我们定义一个Pod的Pod Request为0.4,而当前Pod的CPU使用量为0.2,则它的CPU使用率为50%,如此一来,我们就可以就算出来一个RC控制的所有Pod副本的CPU利用率的算术平均值了。如果某一时刻CPUUtilization Percentage的值超过80%,则意味着当前的Pod副本数很可能不足以支撑接下来更多的请求,需要进行动态扩容,而当请求高峰时段过去后,Pod的CPU利用率又会降下来,此时对应的Pod副本数应该自动减少到一个合理的水平。 CPUUtilization Percentage计算过程中使用到的Pod的CPU使用量通常是1分钟内的平均值,目前通过查询Heapster扩展组件来得到这个值,所以需要安装部署Heapster,这样一来便增加了系统的复杂度和实施HPA特性的复杂度,因此,未来的计划是Kubernetes自身实现一个基础性能数据采集模块,从而更好地支持HPA和其他需要用到基础性能数据的功能模块。此外,我们也看到,如果目标Pod没有定义Pod Request的值,则无法使用CPUUtilization Percentage来实现Pod横向自动扩容的能力。除了使用CPUUtiliationPercentage, Kubernetes从1.2版本开始,尝试支持应用程序自定义的度量指标,目前仍然为实验特性,不建议在生产环境中使用。 Service(服务) 概述 Service也是Kubernetes里的最核心的资源对象之一,Kubernetes里的每个Service其实就是我们经常提起的微服务架构中的一个“微服务”,之前我们所说的Pod、RC等资源对象其实都是为这节所说的“服务”—Kubernetes Service做“嫁衣”的。图1.14显示了Pod、RC与 Service的逻辑关系。 从图1.14中我们看到,Kubernetes的 Service定义了一个服务的访问入口地址,前端的应用(Pod)通过这个入口地址访问其背后的一组由Pod副本组成的集群实例,Service与其后端Pod副本集群之间则是通过Label selector来实现“无缝对接”的。而RC的作用实际上是保证Service的服务能力和服务质量始终处于预期的标准。 运行在每个Node上的kube-proxy进程其实就是一个智能的软件负载均衡器,它负责把对Service的请求转发到后端的某个Pod实例上,并在内部实现服务的负载均衡与会话保持机制。但 Kubernetes发明了一种很巧妙又影响深远的设计:Service不是共用一个负载均衡器的IP地址,而是每个Service分配了一个全局唯一的虚拟IP地址,这个虚拟IP被称为Cluster IP。这样一来,每个服务就变成了具备唯一IP地址的“通信节点”,服务调用就变成了最基础的TCP网络通信问题。 我们知道,Pod的Endpoint地址会随着Pod的销毁和重新创建而发生改变,因为新Pod的IP地址与之前旧Pod的不同。而 Service一旦创建, Kubernetes就会自动为它分配一个可用的Cluster IP,而且在Service的整个生命周期内,它的Cluster IP不会发生改变。于是,服务发现这个棘手的问题在Kubernetes的架构里也得以轻松解决:只要用Service的Name与Service的Cluster IP地址做一个DNS域名映射即可完美解决问题。现在想想,这真是一个很棒的设计。 Kubernetes的服务发现机制 任何分布式系统都会涉及“服务发现”这个基础问题,大部分分布式系统通过提供特定的API接口来实现服务发现的功能,但这样做会导致平台的侵入性比较强,也增加了开发测试的困难。Kubernetes则采用了直观朴素的思路去解决这个棘手的问题。 首先,每个Kubernetes中的Service都有一个唯一的Cluster IP以及唯一的名字,而名字是由开发者自己定义的,部署的时候也没必要改变,所以完全可以固定在配置中。接下来的问题就是如何通过Service的名字找到对应的Cluster IP? Kubernetes通过Add-On增值包的方式引入了DNS系统,把服务名作为DNs域名,这样一来,程序就可以直接使用服务名来建立通信连接了。 外部系统访问Service的问题 为了更加深入地理解和掌握Kubernetes,我们需要弄明白Kubernetes里的“三种IP”这个关键问题,这三种IP分别如下。 - Node IP:Node节点的IP地址。 - Pod IP:Pod的IP地址 - Cluster IP: Service的IP地址。 首先,Node IP是Kubernetes集群中每个节点的物理网卡的IP地址,这是一个真实存在的物理网络,所有属于这个网络的服务器之间都能通过这个网络直接通信,不管它们中是否有部分节点不属于这个Kubernetes集群。这也表明了Kubernetes集群之外的节点访问Kubernetes集群之内的某个节点或者TCP/IP服务的时候,必须要通过Node IP进行通信。 其次,Pod IP是每个Pod的IP地址,它是Docker Engine根据docker0网桥的IP地址段进行分配的,通常是一个虚拟的二层网络,前面我们说过,Kubernetes要求位于不同Node上的Pod能够彼此直接通信,所以Kubernetes里一个Pod里的容器访问另外一个Pod里的容器,就是通过Pod IP所在的虚拟二层网络进行通信的,而真实的TCP/IP流量则是通过Node IP所在的物理网卡流出的。 最后,我们说说Service的Cluster IP,它也是一个虚拟的IP,但更像是一个“伪造”的IP网络,原因有以下几点。 - Cluster IP仅仅作用于Kubernetes Service这个对象,并由Kubernetes管理和分配IP地址(来源于Cluster IP地址池)。 - Cluster IP无法被Ping,因为没有一个“实体网络对象”来响应。 - Cluster IP只能结合Service Port组成一个具体的通信端口,单独的Cluster IP不具备TCP/IP通信的基础,并且它们属于Kubernetes集群这样一个封闭的空间,集群之外的节点如果要访问这个通信端口,则需要做一些额外的工作。 - 在Kubernetes集群之内,Node IP网、Pod IP网与Cluster IP网之间的通信,采用的是Kubernetes自己设计的一种编程方式的特殊的路由规则,与我们所熟知的IP路由有很大的不同。 NodePort的实现方式是在Kubernetes集群里的每个Node上为需要外部访问的Service开启个对应的TCP监听端口,外部系统只要用任意一个Node的IP地址+具体的NodePort端口号即可访问此服务。 但NodePort还没有完全解决外部访问Service的所有问题,比如负载均衡问题,假如我们的集群中有10个Node,则此时最好有一个负载均衡器,外部的请求只需访问此负载均衡器的IP地址,由负载均衡器负责转发流量到后面某个Node的NodePort上。如图1.17所示。 图1.17中的Load balancer组件独立于Kubernetes集群之外,通常是一个硬件的负载均衡器,或者是以软件方式实现的,例如HAProxy或者Nginx。对于每个Service,我们通常需要配置个对应的Load balancer实例来转发流量到后端的Node上,这的确增加了工作量及出错的概率。于是Kubernetes提供了自动化的解决方案,如果我们的集群运行在谷歌的GCE公有云上,那么只要我们把Service的type=Node Port改为type=LoadBalancer,此时Kubernetes会自动创建个对应的Load balancer实例并返回它的IP地址供外部客户端使用。其他公有云提供商只要实现了支持此特性的驱动,则也可以达到上述目的。此外,裸机上的类似机制(Bare Metal Service Load Balancers)也正在被开发。 Volume(存储卷) Volume是Pod中能够被多个容器访问的共享目录Kubernetes Volume概念、用途和目的与Docker的Volume比较类似,但两者不能等价。首先,Kubernetes中的Volume定义在Pod上,然后被一个Pod里的多个容器挂载到具体的文件目录下;其次,Kubernetes中的Volume与Pod的生命周期相同,但与容器的生命周期不相关,当容器终止或者重启时,Volume中的数据也不会丢失。最后,Kubernetes支持多种类型的 Volume,例如GlusterFS、ceph等先进的分布式文件系统。 除了可以让一个Pod里的多个容器共享文件、让容器的数据写到宿主机的磁盘上或者写文件到网络存储中, Kubernetes的Volume还扩展出了一种非常有实用价值的功能,即容器配置文件集中化定义与管理,这是通过ConfigMap这个新的资源对象来实现的,后面我们会详细说明。 Kubernetes提供了非常丰富的Volume类型,下面逐一进行说明。 empty Dir 一个empty Dir Volume是在Pod分配到Node时创建的。从它的名称就可以看出,它的初始内容为空,并且无须指定宿主机上对应的目录文件,因为这是 Kubernetes自动分配的一个目录当Pod从Node上移除时,empty Dir中的数据也会被永久删除。empty Dir的一些用途如下。 - 临时空间,例如用于某些应用程序运行时所需的临时目录,且无须永久保留。 - 长时间任务的中间过程 Check Point的临时保存目录。 - 一个容器需要从另一个容器中获取数据的目录(多容器共享目录)。 目前,用户无法控制emptyDir使用的介质种类。如果kubelet的配置是使用硬盘,那么所有empty都将创建在该硬盘上。Pod在将来可以设置empty Dir是位于硬盘、固态硬盘上还是基于内存的tmpfs上,上面的例子便采用了emptyDir类的 Volume。 hostPath hostPath为在Pod上挂载宿主机上的文件或目录,它通常可以用于以下几方面: - 容器应用程序生成的日志文件需要永久保存时,可以使用宿主机的高速文件系统进行存储。 - 需要访问宿主机上Docker引擎内部数据结构的容器应用时,可以通过定义hostPath为宿主机/var/lib/docker目录,使容器内部应用可以直接访问Docker的文件系统。 在使用这种类型的Volume时,需要注意以下几点。 - 在不同的Node上具有相同配置的Pod可能会因为宿主机上的目录和文件不同而导致对Volume上目录和文件的访问结果… ### MySQL 查询语句执行顺序 - URL: https://jiankunking.com/order-of-execution-of-the-sql-query.html - Content type: original - Published: 2019-11-15 - Updated: 2019-11-15 - Summary: 整理 MySQL 查询语句的实际执行顺序,并对比 FROM、WHERE、GROUP BY、HAVING、SELECT 与 ORDER BY 的书写顺序。 - Categories: MySQL - Tags: MySQL, Query, Execution Article text: 文章速览 整理 MySQL 查询语句的实际执行顺序,并对比 FROM、WHERE、GROUP BY、HAVING、SELECT 与 ORDER BY 的书写顺序。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL MySQL查询语句的执行顺序与SQL语句的书写顺序不同,本文整理了MySQL查询语句的实际执行顺序:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY。 - FROM clause - WHERE clause - GROUP BY clause - HAVING clause - SELECT clause - ORDER BY clause 该执行顺序整理自网络,MySQL-query-execution 一直想找一个权威的解释出处,比如官网,但没有找到,如果有人知道,欢迎留言。 ### [转]Uber Go 语言编码规范 - URL: https://jiankunking.com/uber-go-guide.html - Content type: repost - Published: 2019-11-15 - Updated: 2019-11-15 - Summary: 转载 Uber Go 语言编码规范,整理接口、并发、错误处理、性能、命名和代码风格等工程实践。 - Categories: Coding Standards - Tags: Coding Standards, Go, Uber, Style, Guide - Original source: https://github.com/xxjwxc/uber_go_guide_cn The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [译]Spring WebFlux vs Spring MVC - URL: https://jiankunking.com/spring-webflux-vs-spring-mvc.html - Content type: translation - Published: 2019-11-14 - Updated: 2019-11-14 - Summary: Spring MVC还是WebFlux?本文对比两种框架的适用场景,帮助你根据实际需求做出正确选择。 - Categories: Java - Tags: Java, WebFlux, MVC - Original source: https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web-reactive.html The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Elasticsearch From/Size vs Scroll vs Search After - URL: https://jiankunking.com/elasticsearch-scroll-and-search-after.html - Content type: original - Published: 2019-10-23 - Updated: 2019-10-23 - Summary: Elasticsearch From/Size、Scroll、Search After对比。 - Categories: Elasticsearch - Tags: Elasticsearch, Query Article text: 文章速览 Elasticsearch From/Size、Scroll、Search After对比。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch Elasticsearch From/Size、Scroll、Search After对比 From/Size 可以使用from和size参数对结果进行分页。from参数定义要获取的第一个结果的偏移量。 size 参数允许您配置要返回的最大匹配数。 简单来说,需要查询from + size 的条数时,coordinate node就向该index的其余的shards 发送同样的请求,等汇总到(shards * (from + size))条数时在coordinate node再做一次排序,最终抽取出真正的 from 后的 size 条结果。 注意from + size 不能超过 index.max_result_window 索引设置,默认为 10,000。 有关深入滚动的更有效方法,请参阅 Scroll 或 Search After API。 示例 举个例子,一个索引,有10亿数据,分10个shards,然后,一个搜索请求,from=1,000,000,size=100,这时候,会带来严重的性能问题: - CPU - 内存 - IO - 网络带宽 CPU、内存和IO消耗容易理解,网络带宽问题稍难理解一点。在query阶段,每个shards需要返回1,000,100条数据给coordinating node,而coordinating node需要接收10*1,000,100条数据,即使每条数据只有_doc _id 和_score,这数据量也很大了,而且,这才一个查询请求,那如果再乘以100呢? 在另一方面,我们意识到,这种深度分页的请求并不合理,因为我们是很少人为的看很后面的请求的,在很多的业务场景中,都直接限制分页,比如只能看前100页。 Search Type 在执行分布式搜索时可以执行不同的执行路径。分布式搜索操作需要分散到所有相关的shard,然后收集所有的结果。当使用分散/集中类型执行时,有几种方法可以做到这一点,特别是使用搜索引擎。 执行分布式搜索时的一个问题是从每个shard检索多少结果。例如,如果我们有 10 个shard,则第一个shard可能保存从 0 到 10 的最相关的结果,而其他shard的结果排在后面。因此,在执行请求时,我们需要从所有shard中获取从0到10的结果,对它们进行排序,然后返回结果(如果我们希望确保得到正确的结果)。 与搜索引擎相关的另一个问题是每个shard独立存在的事实。当在特定shard上执行查询时,它不会考虑来自其他shard上term频率及(其他shard上)搜索引擎的信息。如果我们想要支持准确的排名,我们需要首先收集所有shard中的term频率,以计算全局term频率,然后使用这些term频率对每个shard执行查询。 此外,由于需要对结果进行排序,在维护正确的排序行为的同时,获取大型文档集,甚至是滚动它,可能是一个非常昂贵的操作。对于大型结果集滚动,如果返回文档的顺序不重要,则最好按_doc排序。 Elasticsearch非常灵活,可以根据每个搜索请求控制执行的搜索类型。可以通过设置查询字符串中的search_type参数来配置类型。类型是: Query Then Fetch 参数值: query_then_fetch。 请求分两个阶段处理。 在第一阶段,查询被转发到所有涉及的分片。 每个分片执行搜索并生成对该分片本地的结果的排序列表。 每个分片只向协调节点返回足够的信息,以允许其合并并将分片级结果重新排序为全局排序的最大长度大小的结果集。 在第二阶段期间,协调节点仅从相关分片请求文档内容(以及高亮显示的片段,如果有的话)。 如果您未在请求中指定 search_type,那么这是默认设置。 Dfs, Query Then Fetch 参数值:dfs_query_then_fetch 与 “Query Then Fetch” 相同,除了初始分散阶段,该阶段计算分布式term频率以获得更准确的评分。 Scroll与Search After 都依赖于Search Type Search After 在日志服务架构设计中日志搜索后翻页、日志上下文的功能就是通过search_after实现的。 在官网文档中可以看出Search After有以下特点: - 实时 - 可以深度分页(使用前一页的结果来帮助检索下一页) - 不支持跳页 注意:每个文档具有一个唯一值的字段应用作排序规范的仲裁。 否则,具有相同排序值的文档的排序顺序将是未定义的。建议的方法是使用字段 _uid(elasticsearch 6.x _uid 废弃 替换为_id),它确保每个文档包含一个唯一值。 The _id field is restricted from use in aggregations, sorting, and scripting. In case sorting or aggregating on the _id field is required, it is advised to duplicate the content of the _id field into another field that has doc_values enabled. _id 字段被限制在聚合、排序和脚本中使用。 如果需要对 _id 字段进行排序或聚合,建议将 _id 字段的内容复制到另一个启用了 doc_values 的字段中。 https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-id-field.html Scroll 虽然搜索请求返回单个 “page” 的结果,但是滚动 API 可以用于从单个搜索请求中检索大量结果(甚至是所有结果),与在传统数据库上使用游标的方式大致相同。 滚动不是用于实时用户请求,而是用于处理大量数据,例如, 以便将一个索引的内容重新索引到具有不同配置的新索引中。 从滚动请求返回的结果反映了进行初始搜索请求时索引的状态,如时间快照。 对文档(索引,更新或删除)的后续更改只会影响以后的搜索请求。 可以把 scroll 分为初始化和遍历两步,初始化时将所有符合搜索条件的搜索结果缓存起来,可以想象成快照,在遍历时,从这个快照里取数据,也就是说,在初始化后对索引插入、删除、更新数据都不会影响遍历结果。 滚动请求具有优化,使排序顺序为_doc时更快。 如果你想迭代所有文档,无论顺序如何,这是最有效的选择: 1 2 3 4 5 6 | GET /_search?scroll=1m { "sort": [ "_doc" ] } | Keeping the search context alive 滚动参数(传递到搜索请求和每个滚动请求)告诉Elasticsearch应该保持搜索上下文活动的时间。其值(例如,1m,请参阅 “Time unit” 一节)不需要足够长以处理所有数据 - 它只需要足够长的时间来处理前一批结果。每个滚动请求(具有滚动参数)设置新的到期时间。 通常,后台合并过程通过将较小的段合并在一起以创建新的较大段来优化索引,此时较小的段被删除。此过程在滚动期间继续,但是打开的搜索上下文防止旧段在它们仍在使用时被删除。这就是Elasticsearch如何能够返回初始搜索请求的结果,而不考虑对文档的后续更改。 小结 如果要做非常多页的查询时,search after是一个常量查询延迟和开销,并无什么副作用,可是,就像要查询结果全量导出那样,要在短时间内不断重复同一查询成百甚至上千次,效率就显得非常低了。 2023-03-18现在不用纠结了 :sob: :sob: :sob: :sob: :sob: :sob: https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#scroll-search-results search after是lucene原生支持的: https://lucene.apache.org/core/8_0_0/core/org/apache/lucene/search/IndexSearcher.html https://lucene.apache.org/core/8_0_0/core/org/apache/lucene/search/IndexSearcher.html#searchAfter-org.apache.lucene.search.ScoreDoc-org.apache.lucene.search.Query-int- 猜测一下search after的实现原理,应该是通过doc_values来实现的 doc_values已经排好序了,所以通过每次search_after指定的值,往后查找即可 本文内容来自官方文档,主要是做了翻译及汇总。 ### 微服务理想国 - URL: https://jiankunking.com/microservice-ideal-country.html - Content type: original - Published: 2019-10-23 - Updated: 2019-10-23 - Summary: 微服务技术栈实践:Jenkins实现CI/CD、Kubernetes实现调度与高可用、Istio实现监控熔断限流。 - Categories: Architecture - Tags: Microservice, Service-Mesh Article text: 文章速览 微服务技术栈实践:Jenkins实现CI/CD、Kubernetes实现调度与高可用、Istio实现监控熔断限流。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 微服务技术栈实践:Jenkins实现CI/CD、Kubernetes实现调度与高可用、Istio实现监控熔断限流。 微服务的现在、未来 微服务 - Jenkins CI&CD - Kubernetes 调度、负载、高可用 - 自动化容器的部署和复制 - 随时扩展或收缩容器规模 - 将容器组织成组,并且提供容器间的负载均衡 - 很容易地升级应用程序容器的新版本 - 提供容器弹性,如果容器失效就替换它,等等… - Istio 监控、熔断、限流 - 流量管理(Connect):智能控制服务之间的调用流量,能够实现灰度升级、AB 测试和红黑部署等功能 - 安全加固(Secure):自动为服务之间的调用提供认证、授权和加密。 - 控制(Control):应用用户定义的 policy,保证资源在消费者中公平分配。 - 观察(Observe):查看服务运行期间的各种数据,比如日志、监控和 tracing,了解服务的运行情况。 - SkyWalking 监控 - 分布式系统的应用程序性能监视工具 - 收集Kubernetes Pod日志到ElasticSearch进行日志检索 - 通过Prometheus监控机器、实例的运行情况 - 通过Alertmanager将监控信息进行告警 其中最重要的Kubernetes服务,腾讯云、阿里云都已经支持。 微服务的未来应该是开发人员只需要写Sping Boot或者Gin/Iris应用就可以了,剩下的就交给Kubernetes。 Spring Cloud/Kubernetes Istio Knowledge Map 下图转自:istio-knowledge-map ### MySQL Explain - URL: https://jiankunking.com/mysql-explain.html - Content type: original - Published: 2019-10-21 - Updated: 2019-10-21 - Summary: 详解 MySQL EXPLAIN 执行计划,说明 type、possible_keys、key、rows 等字段含义及常见优化方法。 - Categories: MySQL - Tags: MySQL, Explain, SQL Article text: 文章速览 详解 MySQL EXPLAIN 执行计划,说明 type、possible_keys、key、rows 等字段含义及常见优化方法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL MySQL Explain命令详解,分析SQL执行计划,包括type、possible_keys、rows等关键字段的含义与优化建议。 Explain SQL 语法:explain + SQL 参数: 名称 解释 type | system > const > eq_ref > ref > range > index > all(至少达到 range 级别,最好达到 ref 级别) | possible_keys | 显示可能应用在这张表中的索引 | rows | 扫描行数,越小越好 | 说明: - consts 单表中最多只有一个匹配行(主键或者唯一索引),在优化阶段即可读取到数据。 - ref 指的是使用普通的索引(normal index)。 - range 对索引进行范围检索。 反例:explain 表的结果,type=index,索引物理文件全扫描,速度非常慢,这个 index 级别比 range还低,与全表扫描是小巫见大巫。 Extra字段显示Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。 推荐阅读阿里巴巴开发手册MySQL部分:阿里巴巴Java开发手册(华山版).pdf ### Java Final - URL: https://jiankunking.com/java-final.html - Content type: original - Published: 2019-10-21 - Updated: 2019-10-21 - Summary: 域的内存语义:保证在对象引用可见之前,final域已在构造函数中被正确初始化,避免对象引用在构造函数中"逸出"。 - Categories: Java - Tags: Final, Keyword, JMM Article text: 文章速览 域的内存语义:保证在对象引用可见之前,final域已在构造函数中被正确初始化,避免对象引用在构造函数中"逸出"。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java Java Final域的内存语义:保证在对象引用可见之前,final域已在构造函数中被正确初始化,避免对象引用在构造函数中”逸出”。 Java Final 内存语义 读 初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器的。 写 在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。 拓展 final-keyword-and-jvm-memory-impact 在线JSR:JSR 133:3.2 Final Fields(第九页) ### 计算机存储单位换算 - URL: https://jiankunking.com/unit-conversion.html - Content type: original - Published: 2019-10-03 - Updated: 2019-10-03 - Summary: 计算机存储单位换算速查,整理bit、Byte、KB、MB、GB之间的二进制与十进制换算关系,并给出整数占用空间等常用估算示例。 - Categories: Basic - Tags: Basic Article text: 文章速览 计算机存储单位换算速查,整理bit、Byte、KB、MB、GB之间的二进制与十进制换算关系,并给出整数占用空间等常用估算示例。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Basic 计算机存储单位换算速查表:bit、byte、KB、MB、GB之间的换算关系。 bit、byte、KB、MB、GB 常见的单位换算如下: - 1 byte = 8 bit - 1 KB = 210 byte = 1024 byte ≈ 103 byte - 1 MB = 220 byte ≈ 10 6 byte - 1 GB = 230 byte ≈ 10 9 byte - 1 亿 = 108 1 个整数占 4 byte,1 亿个整数占 4*108 byte ≈ 400 MB。 ### 客户中心架构设计 - URL: https://jiankunking.com/customer-center-design.html - Content type: original - Published: 2019-09-29 - Updated: 2019-09-29 - Summary: 介绍客户中心的架构设计,通过统一账户模型、OAuth 和账户合并解决多系统客户数据分散与同步问题。 - Categories: Architecture - Tags: Security, Architecture, Login Article text: 文章速览 介绍客户中心的架构设计,通过统一账户模型、OAuth 和账户合并解决多系统客户数据分散与同步问题。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 客户中心架构设计:实现客户维度账户数据统一,解决多系统账户数据分散、同步困难的问题。 客户中心梳理 现状 客户数据分散在多个系统之间,而这多个系统中又有三个最为主要的系统:365、Y、E。 - 365的客户账号登陆用的是CAS - E、Y系统定时同步CAS数据,自己维护了一份数据 客户中心的目的是实现客户维度的账户数据统一,以替换掉目前客户多套账户数据,系统之间通过数据表同步实现账户数据同步的现状。 从客户中心的名称中就可以知道,该部分对应的主体是客户,而非平常To C的用户。 这里的客户账号主要是公司的经销商、各级门店的员工。 客户与To C用户两者之间最大的区别在于:账号注册、账户管理的不同。客户账号的注册主要是通过分配,而非自己注册。 服务层面 服务主要拆分为以下几部分: - OpenApi:客户中心网关、OAuth部分 - 账户服务:与账户相关的服务 - 短信服务: - 将集团Web Services短信服务转换成HTTP REST服务(下面简称SMS服务) - Nginx 代理(组内服务发送短信是调用SMS服务,SMS服务再通过Nginx代理调用集团短信服务) - 后台管理服务:公告通知 更新细致的描述如下图: OAuth OAuth部分主要是熟悉下面这个图,只是有时扮演OAuth Server、有时扮演OAuth Client而已。 比如微信登陆,OpenApi就是微信OAuth Server的client。 OAuth涉及的点有: - Authorization Code:有效期内只能使用一次 - Access Token是采用JWT还是一个随机字符串? - JWT不依赖存储,但一旦颁发无法取消其有效性 - 随机字符串一般都是一个字符ID,具体的数据是存储在Redis中 - OAuth 框架选择 - Java - Spring Security - Shiro - Golang - OpenShift OSIN - …… OAuth Server OAuth Server需要做以下事情: - /authorize接口,负责校验是否登录(比如校验Header中bear令牌)及登陆账号是否需要别的操作(比如账号合并时的登陆账号id是一个临时id,这时需要强制跳转到账号合并、选择的页面,通过用户选择将多个账号合并为一个账号) - 已登录,设置Cookie,跳转请求authorize接口参数中的redirect_uri并携带code及state - 未登录,跳转鉴权中心的登陆页 - 用户在鉴权中心的登录页输入用户名密码,校验通过,跳转请求authorize接口参数中的redirect_uri并携带code及state - /token接口,负责校验code,颁发access_token及refresh_token - /refresh_token接口,负责通过refresh_token刷新access_token - /logout接口,清理Cookie code分为两种情况:一种是通过浏览器传递给接入方后端;一种是通过移动端获取到code后,通过调用接入方后端接口传递给接入方后端。 redirect_uri、post_logout_redirect_uri 需要校验是否与配置的一样。 OAuth Client OAuth Client对接OAuth Server需要做以下事情: - 拦截请求,校验是否已登录 - 已登录,放行 - 未登录,跳转OAuth Server的/authorize接口接口 - 根据code获取用户信息,设置Cookie、颁发自己的token(access_token、refresh_token) - /token接口,调用鉴权中心的密码模式校验密码,获取用户信息,颁发access_token、refresh_token - /refresh_token接口,负责通过refresh_token刷新access_token - 对于Web端而言,打开首页的时候,先调用me或者profile等接口获取用户信息 - 如果能获取到,则意味着登录成功 - 如果获取不到,则前端调用后端/login接口 - 后端/login接口(接口需要传递参数redirect_uri)校验是否登录 - 未登录,跳转鉴权中心的登陆页 - 已登录,跳转参数redirect_uri - 对于移动端而言,调用/token接口 - /logout接口,清理自己设置的Cookie,再调用鉴权中心的登出接口 - /callback接口,供OAuth Server回调 实现 一期 从服务架构图中可以看出,业务逻辑最复杂的是账户服务。 账户服务的复杂的地方主要在: - 数据清理逻辑服务(多系统账户合并) - 多系统迭代替换时间不一致 账户合并 下面简单说一下多系统账户合并的逻辑: - 多账户合并在账户首次登陆的时候进行(账户登录,需要区分出是否是首次登陆) - 检验账户是否需要合并的逻辑是:当前登陆账户名、手机号在多个系统之间重复 - 账户名登陆不仅仅需要校验账户名,还需要校验账户名对应的手机号 - 手机号登陆不仅仅需要校验手机号,还需要校验手机号对应的账户名 - 重复账户数据选择、修改、补充合并 这部分开发的时候,有个坑就是业务人员本身不熟悉自己的业务,合并的细节基本都是开发人员通过数据库数据自己梳理,再与业务人员确认。 切换 系统很多,但登陆用到的主要有两部分: - CAS登陆(大部分系统客户用的都是CAS登陆) - Y、E系统,该系统自己维护了一份客户账号数据 初始的计划是在一期将所有系统客户登陆统一替换掉,后来由于Y、E系统自身业务优先级的原因,无法参与这次切换,又将替换范围调整为替换CAS。 业务范围调整造成了两个影响: - 不需要多系统账号合并 - 业务范围调整的时候,账户服务根据之前的逻辑已经开发完成 这就引入了另一个问题,虽然最复杂的多系统账户合并不要了,但很多的功能点在CAS替换与多系统统一替换中基本都是一样的(由于需要合并多个系统间的账户数据,而多个系统目前的账户数据又是千奇百怪,所以根据账户数据来源的不同,分别存储在不同的表中,所以对于账户数据的操作这部分需要再次开发)。简单的CTRL+C CTRL+V再复制一份,再在公共部分加一些if else?还是通过别的方式实现? 主要的重复点在: - 账户绑定手机号 - 账户登录 - 账户密码通过手机号重置 - 发送短信验证码 基本上都是输入一样,但最终处理的时候校验、操作的表都有所不同,这个可以通过命令模式来处理。 将统一账户的处理逻辑、CAS账户的处理逻辑分别放置到Receiver中,通过不同的Command来起到隔离而又不会重复代码的作用。 命令模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。这样两者之间通过命令对象进行沟通,这样方便将命令对象进行储存、传递、调用、增加与管理。 二期 对接之前未对接的Y、E系统,这时E系统已经合并到Y系统了,所以这时只需要对接Y系统就好。 对接Y系统主要是根据各种业务规则进行账号清洗、合并。 其它 短信发送 1、线程池发送(以防短信服务不稳,造成OOM,设定BlockingQueue长度) 2、抽象短信服务基类BaseVerificationCodeSender,因为有可能对接多个短信服务,但线程池及需要做的事是一样的。 验证码校验 由于集团短信服务不太稳定,所以每类(登陆、忘记密码、修改手机号等)验证码缓存最多三个未过期的短信验证码。 只要验证了一个,该手机号缓存某类的验证码,均失效。 其它 略 附录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | +--------+ +---------------+ | |--(A)- Authorization Request ->| Resource | | | | Owner | | |<-(B)-- Authorization Grant ---| | | | +---------------+ | | | | +---------------+ | |--(C)-- Authorization Grant -->| Authorization | | Client | | Server | | |<-(D)----- Access Token -------| | | | +---------------+ | | | | +---------------+ | |--(E)----- Access Token ------>| Resource | | | | Server | | |<-(F)--- Protected Resource ---| | +--------+ +---------------+ | ### MySQL 知识点 概要 - URL: https://jiankunking.com/mysql-schema.html - Content type: original - Published: 2019-09-25 - Updated: 2019-09-25 - Summary: 整理 MySQL 的 B+Tree、索引、EXPLAIN、InnoDB、MyISAM 和分库分表等核心知识点。 - Categories: MySQL - Tags: MySQL Article text: 文章速览 整理 MySQL 的 B+Tree、索引、EXPLAIN、InnoDB、MyISAM 和分库分表等核心知识点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL B+ Tree、索引、Explain、InnoDB、MyISAM、Sharding 一、索引 B+ Tree 原理 1. 数据结构 B Tree 指的是 Balance Tree,也就是平衡树。平衡树是一颗查找树,并且所有叶子节点位于同一层。 B+ Tree 是基于 B Tree 和叶子节点顺序访问指针进行实现,它具有 B Tree 的平衡性,并且通过顺序访问指针来提高区间查询的性能。 在 B+ Tree 中,一个节点中的 key 从左到右非递减排列,如果某个指针的左右相邻 key 分别是 keyi 和 keyi+1,且不为 null,则该指针指向节点的所有 key 大于等于 keyi 且小于等于 keyi+1。 2. 操作 进行查找操作时,首先在根节点进行二分查找,找到一个 key 所在的指针,然后递归地在指针所指向的节点进行查找。直到查找到叶子节点,然后在叶子节点上进行二分查找,找出 key 所对应的 data。 插入删除操作会破坏平衡树的平衡性,因此在插入删除操作之后,需要对树进行一个分裂、合并、旋转等操作来维护平衡性。 3. 与红黑树的比较 红黑树等平衡树也可以用来实现索引,但是文件系统及数据库系统普遍采用 B+ Tree 作为索引结构,主要有以下两个原因: (一)更少的查找次数 平衡树查找操作的时间复杂度和树高 h 相关,O(h)=O(logdN),其中 d 为每个节点的出度。 红黑树的出度为 2,而 B+ Tree 的出度一般都非常大,所以红黑树的树高 h 很明显比 B+ Tree 大非常多,查找的次数也就更多。 (二)利用磁盘预读特性 为了减少磁盘 I/O 操作,磁盘往往不是严格按需读取,而是每次都会预读。预读过程中,磁盘进行顺序读取,顺序读取不需要进行磁盘寻道,并且只需要很短的磁盘旋转时间,速度会非常快。 操作系统一般将内存和磁盘分割成固定大小的块,每一块称为一页,内存与磁盘以页为单位交换数据。数据库系统将索引的一个节点的大小设置为页的大小,使得一次 I/O 就能完全载入一个节点。并且可以利用预读特性,相邻的节点也能够被预先载入。 MySQL 索引 索引是在存储引擎层实现的,而不是在服务器层实现的,所以不同存储引擎具有不同的索引类型和实现。 1. B+Tree 索引 是大多数 MySQL 存储引擎的默认索引类型。 因为不再需要进行全表扫描,只需要对树进行搜索即可,所以查找速度快很多。 因为 B+ Tree 的有序性,所以除了用于查找,还可以用于排序和分组。 可以指定多个列作为索引列,多个索引列共同组成键。 适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找。如果不是按照索引列的顺序进行查找,则无法使用索引。 InnoDB 的 B+Tree 索引分为主索引和辅助索引。主索引的叶子节点 data 域记录着完整的数据记录,这种索引方式被称为聚簇索引。因为无法把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。 辅助索引的叶子节点的 data 域记录着主键的值,因此在使用辅助索引进行查找时,需要先查找到主键值,然后再到主索引中进行查找。 2. 哈希索引 哈希索引能以 O(1) 时间进行查找,但是失去了有序性: - 无法用于排序与分组; - 只支持精确查找,无法用于部分查找和范围查找。 InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如快速的哈希查找。 3. 全文索引 MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。 查找条件使用 MATCH AGAINST,而不是普通的 WHERE。 全文索引使用倒排索引实现,它记录着关键词到其所在文档的映射。 InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。 4. 空间数据索引 MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。 必须使用 GIS 相关的函数来维护数据。 索引优化 1. 独立的列 在进行查询时,索引列不能是表达式的一部分,也不能是函数的参数,否则无法使用索引。 例如下面的查询不能使用 actor_id 列的索引: 1 | SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5; | 2. 多列索引 在需要使用多个列作为条件进行查询时,使用多列索引比使用多个单列索引性能更好。例如下面的语句中,最好把 actor_id 和 film_id 设置为多列索引。 1 2 | SELECT film_id, actor_ id FROM sakila.film_actor WHERE actor_id = 1 AND film_id = 1; | 3. 索引列的顺序 让选择性最强的索引列放在前面。 索引的选择性是指:不重复的索引值和记录总数的比值。最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。 例如下面显示的结果中 customer_id 的选择性比 staff_id 更高,因此最好把 customer_id 列放在多列索引的前面。 1 2 3 4 | SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity, COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity, COUNT(*) FROM payment; | 1 2 3 | staff_id_selectivity: 0.0001 customer_id_selectivity: 0.0373 COUNT(*): 16049 | 4. 前缀索引 对于 BLOB、TEXT 和 VARCHAR 类型的列,必须使用前缀索引,只索引开始的部分字符。 前缀长度的选取需要根据索引选择性来确定。 5. 覆盖索引 索引包含所有需要查询的字段的值。 具有以下优点: - 索引通常远小于数据行的大小,只读取索引能大大减少数据访问量。 - 一些存储引擎(例如 MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。 - 对于 InnoDB 引擎,若辅助索引能够覆盖查询,则无需访问主索引。 索引的优点 - 大大减少了服务器需要扫描的数据行数。 - 帮助服务器避免进行排序和分组,以及避免创建临时表(B+Tree 索引是有序的,可以用于 ORDER BY 和 GROUP BY 操作。临时表主要是在排序和分组过程中创建,不需要排序和分组,也就不需要创建临时表)。 - 将随机 I/O 变为顺序 I/O(B+Tree 索引是有序的,会将相邻的数据都存储在一起)。 索引的使用条件 - 对于非常小的表、大部分情况下简单的全表扫描比建立索引更高效; - 对于中到大型的表,索引就非常有效; - 但是对于特大型的表,建立和维护索引的代价将会随之增长。这种情况下,需要用到一种技术可以直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配,例如可以使用分区技术。 二、查询性能优化 使用 Explain 进行分析 Explain 用来分析 SELECT 查询语句,开发人员可以通过分析 Explain 结果来优化查询语句。 比较重要的字段有: - select_type : 查询类型,有简单查询、联合查询、子查询等 - key : 使用的索引 - rows : 扫描的行数 优化数据访问 1. 减少请求的数据量 - 只返回必要的列:最好不要使用 SELECT * 语句。 - 只返回必要的行:使用 LIMIT 语句来限制返回的数据。 - 缓存重复查询的数据:使用缓存可以避免在数据库中进行查询,特别在要查询的数据经常被重复查询时,缓存带来的查询性能提升将会是非常明显的。 2. 减少服务器端扫描的行数 最有效的方式是使用索引来覆盖查询。 重构查询方式 1. 切分大查询 一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。 1 | DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH); | 1 2 3 4 5 | rows_affected = 0 do { rows_affected = do_query( "DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 10000") } while rows_affected > 0 | 2. 分解大连接查询 将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有: - 让缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表发生变化,对其它表的查询缓存依然可以使用。 - 分解成多个单表查询,这些单表查询的缓存结果更可能被其它查询使用到,从而减少冗余记录的查询。 - 减少锁竞争; - 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可伸缩。 - 查询本身效率也可能会有所提升。例如下面的例子中,使用 IN() 代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。 1 2 3 4 | SELECT * FROM tag JOIN tag_post ON tag_post.tag_id=tag.id JOIN post ON tag_post.post_id=post.id WHERE tag.tag='MySQL'; | 1 2 3 | SELECT * FROM tag WHERE tag='MySQL'; SELECT * FROM tag_post WHERE tag_id=1234; SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904); | 三、存储引擎 InnoDB 是 MySQL 默认的事务型存储引擎,只有在需要它不支持的特性时,才考虑使用其它存储引擎。 实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locking 防止幻影读。 主索引是聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对查询性能有很大的提升。 内部做了很多优化,包括从磁盘读取数据时采用的可预测性读、能够加快读操作并且自动创建的自适应哈希索引、能够加速插入操作的插入缓冲区等。 支持真正的在线热备份。其它存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。 MyISAM 设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。 提供了大量的特性,包括压缩表、空间数据索引等。 不支持事务。 不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。 可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。 如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。 比较 - 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。 - 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。 - 外键:InnoDB 支持外键。 - 备份:InnoDB 支持在线热备份。 - 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。 - 其它特性:MyISAM 支持压缩表和空间数据索引。 四、数据类型 整型 TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT 分别使用 8, 16, 24, 32, 64 位存储空间,一般情况下越小的列越好。 INT(11) 中的数字只是规定了交互工具显示字符的个数,对于存储和计算来说是没有意义的。 浮点数 FLOAT 和 DOUBLE 为浮点类型,DECIMAL 为高精度小数类型。CPU 原生支持浮点运算,但是不支持 DECIMAl 类型的计算,因此 DECIMAL 的计算比浮点类型需要更高的代价。 FLOAT、DOUBLE 和 DECIMAL 都可以指定列宽,例如 DECIMAL(18, 9) 表示总共 18 位,取 9 位存储小数部分,剩下 9 位存储整数部分。 字符串 主要有 CHAR 和 VARCHAR 两种类型,一种是定长的,一种是变长的。 VARCHAR 这种变长类型能够节省空间,因为只需要存储必要的内容。但是在执行 UPDATE 时可能会使行变得比原来长,当超出一个页所能容纳的大小时,就要执行额外的操作。MyISAM 会将行拆成不同的片段存储,而 InnoDB 则需要分裂页来使行放进页内。 在进行存储和检索时,会保留 VARCHAR 末尾的空格,而会删除 CHAR 末尾的空格。 时间和日期 MySQL 提供了两种相似的日期时间类型:DATETIME 和 TIMESTAMP。 1. DATETIME 能够保存从 1000 年到 9999 年的日期和时间,精度为秒,使用 8 字节的存储空间。 它与时区无关。 默认情况下,MySQL 以一种可排序的、无歧义的格式显示 DATETIME 值,例如“2008-01-16 22:37:08”,这是 ANSI 标准定义的日期和时间表示方法。 2. TIMESTAMP 和 UNIX 时间戳相同,保存从 1970 年 1 月 1 日午夜(格林威治时间)以来的秒数,使用 4 个字节,只能表示从 1970 年到 2038 年。 它和时区有关,也就是说一个时间戳在不同的时区所代表的具体时间是不同的。 MySQL 提供了 FROM_UNIXTIME() 函数把 UNIX 时间戳转换为日期,并提供了 UNIX_TIMESTAMP() 函数把日期转换为 UNIX 时间戳。 默认情况下,如果插入时没有指定 TIMESTAMP 列的值,会将这个值设置为当前时间。 应该尽量使用 TIMESTAMP,因为它比 DATETIME 空间效率更高。 五、切分 水平切分 水平切分又称为 Sharding,它是将同一个表中的记录拆分到多个结构相同的表中。 当一个表的数据不断增多时,Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。 垂直切分 垂直切分是将一张表按列切分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直切分将经常被使用的列和不经常被使用的列切分到不同的表中。 在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不同的库中,例如将原来的电商数据库垂直切分成商品数据库、用户数据库等。 Sharding 策略 - 哈希取模:hash(key) % N; - 范围:可以是 ID 范围也可以是时间范围; - 映射表:使用单独的一个数据库来存储映射关系。 Sharding 存在的问题 1. 事务问题 使用分布式事务来解决,比如 XA 接口。 2. 连接 可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。 3. ID 唯一性 - 使用全局唯一 ID(GUID) - 为每个分片指定一个 ID 范围 - 分布式 ID 生成器 (如 Twitter 的 Snowflake 算法) 六、复制 主从复制 主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。 - binlog 线程 :负责将主服务器上的数据更改写入二进制日志(Binary log)中。 - I/O 线程 :负责从主服务器上读取二进制日志,并写入从服务器的中继日志(Relay log)。 - SQL 线程 :负责读取中继日志,解析出主服务器已经执行的数据更改并在从服务器中重放(Replay)。 读写分离 主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。 读写分离能提高性能的原因在于: - 主从服务器负责各自的读和写,极大程度缓解了锁的争用; - 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销; - 增加冗余,提高可用性。 读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。 参考资料 - BaronScbwartz, PeterZaitsev, VadimTkacbenko, 等. 高性能 MySQL[M]. 电子工业出版社, 2013. - 姜承尧. MySQL 技术内幕: InnoDB 存储引擎 [M]. 机械工业出版社, 2011. - 20+ 条 MySQL 性能优化的最佳经验 - 服务端指南 数据存储篇 | MySQL(09) 分库与分表带来的分布式困境与应对之策 - How to create unique row ID in sharded databases? - SQL Azure Federation – Introduction - MySQL 索引背后的数据结构及算法原理 - MySQL 性能优化神器 Explain 使用分析 - How Sharding Works - 大众点评订单系统分库分表实践 - B + 树 原文 https://github.com/CyC2018/CS-Notes/blob/master/notes/MySQL.md#innodb ### SQL UNION vs OR 性能 - URL: https://jiankunking.com/sql-performance-union-vs-or.html - Content type: original - Published: 2019-09-23 - Updated: 2019-09-23 - Summary: 对比 SQL 查询中 UNION 与 OR 的执行方式和性能差异,说明索引、执行计划与数据分布对结果的影响。 - Categories: MySQL - Tags: MySQL, UNION, OR Article text: 文章速览 对比 SQL 查询中 UNION 与 OR 的执行方式和性能差异,说明索引、执行计划与数据分布对结果的影响。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:stackoverflow 地址: https://stackoverflow.com/questions/13750475/sql-performance-union-vs-or 翻译自Bill Karwin回答: 要么你读的那篇文章用了一个不好的例子,要么你误解了他们的观点。 1 | select username from users where company = 'bbc' or company = 'itv'; | 等价于: 1 | select username from users where company IN ('bbc', 'itv'); | 在这个查询中MySQL会使用company上的索引。不需要改成UNION。 更棘手的情况是,OR条件涉及两个不同的列。 1 | select username from users where company = 'bbc' or city = 'London'; | 假设company列和city列都有一个独立的索引。MySQL通常在一个给定的查询中每个表只使用一个索引,那么应该使用哪个索引呢?如果它使用company上的索引,它仍然必须执行表扫描,以找到伦敦所在的行。如果它使用city上的索引,则必须对company为bbc的行进行表扫描。 UNION 解决方案适用于这种情况。 1 2 3 | select username from users where company = 'bbc' union select username from users where city = 'London'; | 这样的话每个子查询都可以使用索引进行搜索,然后再将子查询的结果合并在一起。 union ? union all ? 取决于需求及where过滤后的数据量 对于索引列来最好使用union all,因复杂的查询【包含运算等】将使or、in放弃索引而全表扫描,除非你能确定or、in会使用索引。 对于只有非索引字段来说你就老老实实的用or或者in,因为 非索引字段本来要全表扫描而union all 只成倍增加表扫描的次数。 ### 时间量级 - URL: https://jiankunking.com/time-scales.html - Content type: original - Published: 2019-09-21 - Updated: 2019-09-21 - Summary: 整理计算机系统中常见操作的时间量级,帮助理解 CPU、内存、磁盘和网络访问之间的性能差距。 - Categories: Network - Tags: Performance, Reading Notes Article text: 文章速览 整理计算机系统中常见操作的时间量级,帮助理解 CPU、内存、磁盘和网络访问之间的性能差距。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 本文整理自:《性能之巅》 作者:【美】 Brendan Gregg 英文版出版时间:2014年 我们可以用数字来作为时间的比较方法,同时可以用时间的长短经验来判断延时的源头。系统各组件的操作所处的时间量级差别巨大,大到了难以体会的地步。表2.2提供的延时示例,从访问3.3GHz的CPU寄存器的延时开始,阐释了我们所打交道的时间量级的差别,表中是发生单次操作的时间均值,等比放大成为想象的系统,一次寄存器访问0.3ns(十亿分之一秒的三分之一)相当于现实生活中的1秒。 表2.2 系统的各种延时 事件 延时 相对时间比例 1个CPU周期 | 0.3ns | 1s | L1缓存访问 | 0.9ns | 3s | L2缓存访问 | 2.8ns | 9s | L3缓存访问 | 12.9ns | 43s | 主存访问(从CPU访问DRAM) | 120ns | 6分钟 | 固态硬盘I/O(闪存) | 50-150us | 2-6天 | 旋转磁盘I/O | 1-10 ms | 1-12月 | 互联网:从旧金山到纽约 | 40 ms | 4年 | 互联网:从旧金山到英国 | 81 ms | 8年 | 互联网:从旧金山到澳大利亚 | 183ms | 19年 | TCP包重传 | 1-3s | 105-317年 | OS虚拟化系统重启 | 4s | 423年 | SCSI命令超时 | 30s | 3千年 | 硬件虚拟化系统重启 | 40s | 4千年 | 物理系统重启 | 5m | 32千年 | 这个表需要时刻记在心中。 ### [译]ZGC: 未使用堆内存归还操作系统 - URL: https://jiankunking.com/zgc-uncommit-unused-memory.html - Content type: translation - Published: 2019-09-18 - Updated: 2019-09-18 - Summary: 翻译并解读JEP 351,介绍ZGC把长期未使用的堆内存归还操作系统的设计动机、ZPage缓存机制、相关配置、触发条件和适用环境。 - Categories: Java - Tags: JVM, ZGC, Memory-Management - Original source: https://openjdk.java.net/jeps/351 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 垃圾回收算法手册:自动内存管理的艺术 笔记 - URL: https://jiankunking.com/the-garbage-collection-handbook-the-art-of-automatic-memory-management.html - Content type: original - Published: 2019-09-16 - Updated: 2019-09-16 - Summary: 《垃圾回收算法手册》学习笔记,系统整理引用计数、标记清扫、复制、分代、并发与增量式垃圾回收算法的原理、权衡和实现思路。 - Categories: Java - Tags: Reading Notes, Java, JVM Article text: 文章速览 《垃圾回收算法手册》学习笔记,系统整理引用计数、标记清扫、复制、分代、并发与增量式垃圾回收算法的原理、权衡和实现思路。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自:《垃圾回收算法手册:自动内存管理的艺术》 作者:Richard Jones、Antony Hosking、Eliot Moss 引言 几乎所有的现代编程语言都使用动态内存分配(allocation),即允许进程在运行时分配或者释放无法在编译期确定大小的对象,且允许对象的存活时间超出创建这些对象的子程序时间。动态分配的对象存在于堆(heap)中而非栈(stack)或者静态区(statically)中。所谓栈,即程序的活动记录(activation record)或者栈帧(stack frame);静态区则是指在编译期或者链接期就可以确定范围的存储区域。堆分配是十分重要的功能,它允许开发者: - 在运行时动态地确定新创建对象的大小(从而避免程序在运行时遭遇硬编码数组长度不足产生的失败)。 - 定义和使用具有递归特征的数据结构,例如链表(list)、树(tree)和映射(map)。 - 向父过程返回新创建的对象,例如工厂方法。 - 将一个函数作为另一个函数的返回值,例如函数式语言中的闭包(closure)或者悬挂(suspension)。 在不支持自动化动态内存管理的语言中,众多研究者已经付出了相当大的努力来解决这一难题,其方法主要是管理对象的所有权(ownership)[Belotsky,2003; Cline, Lomo,1995]。Belotsky[2003]等人针对C++提出了几个可行策略: - 第一,开发者在任何情况下都应当避免堆分配,例如可以将对象分配在栈上,当创建对象的函数返回之后,栈的弹出(pop)操作会自动将对象释放。 - 第二,在传递参数与返回值时,应尽量传值而非引用。尽管这些方法避免了分配、释放错误,但其不仅会造成内存方面的压力,而且失去了对象共享的能力。 另外,开发者也可以在一些特殊场景下使用自定义内存分配器,例如对象池(pool of object),在程序的一个阶段完成之后,池中的对象将作为一个整体全部释放。 垃圾算法之间的比较 安全性 垃圾回收器首先要考虑的因素是安全性(safety),即在任何时候都不能回收存活对象。但安全性是需要付出一定代价的,特别是在并发回收器中。 吞吐量 对程序的最终用户而言,程序当然是运行得越快越好,但这是由几方面因素决定的。其中的一方面便是花费在垃圾回收上的时间应当越少越好,文献中通常用标记/构造率(mark/cons ratio)衡量这一指标。这一概念是在早期的Lisp语言中最先提出的,它表示回收器(对存活对象进行标记)与赋值器(mutator)(创建或者构造新的链表单元)活跃度的比值。然而在大多数设计良好的架构中,赋值器会比回收器占用更多的CPU时间,因此在适当牺牲回收器效率的基础上提升赋值器的吞吐量,并进一步提升整个程序(赋值器+回收器)的执行速度,一般来说是值得的。例如,使用标记一清扫回收的系统偶尔会执行存活对象整理以减少内存碎片,虽然这一操作开销较大,但它可以提升赋值器的分配性能。 完整性与及时性 理想情况下,垃圾回收过程应当是完整的,即堆中的所有垃圾最终都应当得到回收,但这通常是不现实的,甚至是不可取的,例如纯粹的引用计数回收器便无法回收环状引用垃圾(自引用结构)。从性能方面考虑,在一次回收过程(collection cycle)中只处理堆中部分对象或许更加合理,例如分代回收器会依照堆中对象的年龄将其划分为两代或者更多代,并把回收的主要精力集中在年轻代,这样不仅可以提高回收效率,而且可以减少单次回收的平均停顿时间(pause time)。 在并发垃圾回收器中,赋值器与回收器同时工作,其目的在于避免或者尽量减少用户程序的停顿。此类回收器会遇到浮动垃圾(floating garbage)问题,即如果某个对象在回收过程启动之后才变成垃圾,那么该对象只能在下一个回收周期内得到回收。因此在并发回收器中,衡量完整性更好的方法是统计所有垃圾的最终回收情况,而不是单个回收周期的回收情况。不同的回收算法在回收及时性(promptness)方面存在较大差异,进而需要在时间和空间上进行权衡。 停顿时间 许多回收器在进行垃圾回收时需要中断赋值器线程,因此会导致在程序执行过程中出现停顿。回收器应当尽量减少对程序主要执行过程的影响,因此要求停顿时间越短越好,这点对于交互式程序或者事务处理服务器(超时将引发事务的重试,进而导致事务的积压)尤为重要。但正如我们在后面章节中将要看到的,限制停顿时间会带来一些副作用。例如,分代式回收器通过频繁且快速地回收较小的、较为年轻的对象来缩短停顿时间,而对较大的、较为年老对象的回收则只是偶尔进行。显然,在对分代回收器进行调优时,需要平衡不同分代的大小,进而才能平衡不同分代之间的停顿时间与回收频率。但由于分代回收器必须记录些分代间指针的来源,因此赋值器的指针写操作会存在少量的额外开销。 并行回收器(parallel collector)虽然也需要停顿整个程序,但它可以通过多线程回收的策略缩短停顿时间。为进一步减少停顿时间,并发回收器与增量回收器(incremental collector)偶尔会将部分回收工作与赋值器动作交替进行或者同时进行,但这一过程需要确保赋值器与回收器之间的同步,因而增大了赋值器的额外开销。回收机制的选择会影响程序在空间和时间两个方面的开销,也会影响垃圾回收周期的结束。赋值器在时间方面的额外开销取决于需要记录的赋值器操作类型(读或者写)及其如何记录。回收器在空间方面的开销以及回收周期的结束取决于系统可以容忍的浮动垃圾数量。多线程赋值器与回收器会增大设计复杂度。不论如何,缩短停顿时间的措施通常会增大整体处理时间(即降低整体处理速度)。 仅对最大或者平均停顿时间进行度量是不够的,必须要同时考虑赋值器的性能,因此停顿时间的分布也值得关注。 空间开销 内存管理的目的是安全且高效地使用内存空间。不论是显式内存管理还是自动内存管理,不同的管理策略均会产生不同程度的空间开销(space overhead)。某些垃圾回收器需要在每个对象内部占用一定的空间(例如保存引用计数),还有一些回收器会复用对象现有布局上已经存在的域(例如将标记位放在对象头部的某个字中,或者将转发指针(forwarding pointer)记录在用户数据上)。回收器也可能会引入堆级别的空间开销,例如复制式回收器需要将堆分为两个半区,任何时候赋值器只能使用一个半区,另一个半区会被回收器保留,并在回收过程中将存活对象复制到其中。回收器也可能需要一些辅助的数据结构,例如追踪式回收器需要通过标记栈来引导堆中指针图表的遍历,回收器在标记对象时也可以使用额外的位图(bitmap)而非对象中的域;对于并发回收器或者其他需要将堆划分为数个独立区域的回收器,其需要额外的记忆集(remembered set)来保存赋值器所修改的指针值或者跨区域指针的位置。 针对特定语言的优化 垃圾回收算法可以根据它们所服务的不同语言范式来归类。在函数式语言中,内存管理有着很大的优化空间。某些语言(例如ML)将可变数据与不可变数据进行区分,纯函数式语言(例如Haskell)则更为极端,它不允许用户改变任何数据,即程序是透明引用(referentially transparent)的。然而,在函数式语言内部,数据结构的更新一般不超过一次,即从待计算值(thunk)到一个弱头部范式(weak head normal form,WHNF),分代垃圾回收器可以据此尽快提升已经完成计算的数据结构。研究者们还提出了基于引用计数来处理环状数据结构的完整机制。声明式语言(declarative language)或许还可以使用其他策略来提升堆空间管理的效率,即如果某一对象创建于一个“选择点”(choice point)之后,那么当程序再次回到该选择点时,该对象将不可达,如果对象在堆中的布局是按照其分配时间排布的,那么某个选择点之后分配的内存可以在一个固定的时间内全部回收。不同种类的语言可能对回收器具有不同的要求,最显著的差异是语言中指针功能的不同,以及回收器调用对象终结的需求不同。 所谓透明引用,即以相同的参数调用同一个函数两次,所得到的结果总是相同的,也可理解为函数没有副作用。 thunk和WHNF均为函数式语言中的与懒情计算相关的概念。 可扩展性与可移植性 可扩展性(scalability)与可移植性(portability)是我们定义的最后两个指标。随着PC,甚至笔记本计算机中(且不说大型服务器)多核硬件的普及,借助硬件的并行优势来提升垃圾回收的性能将变得越来越重要。我们期待并行硬件在规模上(内核与套接字数量上)能有进一步发展,也希望异构处理器(heterogeneous processer)越来越普遍。在服务器方面,堆的大小可以达到数十甚至数百吉字节,事务型负载也越来越多,这些都给垃圾回收带来更多的要求。很多垃圾回收算法需要操作系统或者硬件的支持(例如需要依赖页保护机制,需要对虚拟内存空间进行二次映射,或者要求处理器能够提供特定的原子操作),但这些技术并不需要很强的可移植性。 性能上的劣势 与显式内存管理相比,自动内存管理是否存在性能上的劣势?我们将通过对这一问题的分析,来对两者的优劣进行总结。一般来说,自动内存管理的运行开销很大程度上取决于程序的行为,甚至硬件条件,因而很难对其进行简单评估。一个长期以来的观点是,垃圾回收通常会在总内存吞吐量以及垃圾回收停顿时间方面引人一些不可接受的开销,从而导致应用程序的执行速度慢于显式内存管理策略。自动内存管理确实会牺牲程序的部分性能,但是远不如想象中那样严重。诸如ma1loc和free等显式内存操作也会带来一些显著开销。Herts, Feng, Herger[2005测量了多种Java基准测试程序和回收算法花费在垃圾回收上的真正开销。他们构建了一个Java虚拟机并用其精确地观察到对象何时不可达,同时使用可达追踪的方法驱动模拟器来测量回收周期与高速缓存不命中(cache miss)的情况。他们将许多不同种类的垃圾回收器配置与各种不同的ma1lo/free实现进行比较,比较的方法是:如果追踪发现某一对象变成垃圾,则调用free将其释放。 Herts等人发现,尽管用这两种方式的测量结果差异较大,但是如果堆足够大(达到所需最小空间的5倍),那么垃圾回收器的执行时间性能将可以与显式分配相匹敌,但对于一般大小的堆,垃圾回收的开销会平均增大17%。 实验方法 内存管理涉及时间和空间两方面的权衡。大多数环境下,降低回收停顿时间的一种方法是增大堆的空间(增大到一定程度后将达到最优,但如果再增大,则由于局部性原理,执行时间将会变长)。 术语和符号 首先需要说明的是存储的单位。我们遵循一个字节包含八个位这一惯例。我们简单地使用KB(kilobyte)、MB(megabyte)、GB(gigabyte)、TB(terabyte)来描述对应的2的整数次幂内存单元(分别是20、20、20、20),而不使用SI数字前缀的标准定义。 赋值器与回收器 对于使用垃圾回收的程序, Dijkstra等[1976、1978]将其执行过程划分为两个半独立的部分: - 赋值器执行应用代码。这一过程会分配新的对象,并且修改对象之间的引用关系,进而改变堆中对象图的拓扑结构,引用域可能是堆中对象,也可能是根,例如静态变量、线程栈等。随着引用关系的不断变更,部分对象会失去与根的联系,即从根出发沿着对象图的任何一条边进行遍历都无法到达该对象。 - 回收器(collector)执行垃圾回收代码,即找到不可达对象并将其回收。 一个程序可能拥有多个赋值器线程,但是它们共用同一个堆。相应的,也可能存在多个回收器线程。 分配器 分配器(allocator)与回收器在功能上是正交关系。分配器支持两种操作:分配(allocate)和释放(free)。分配是为某一对象保留底层的内存存储,释放是将内存归还给分配器以便复用。分配存储空间的大小是由一个可选参数来控制的,如果我们在伪代码中忽略这一参数,意味着分配器将返回一个固定大小的对象,或者对象大小对于算法的理解并非必要。分配操作也可能支持更多参数,例如将数组的分配与单个对象的分配进行区分,或者将指针数组的分配和不包含指针的数组进行区分,或者包含其他一些必要信息以便初始化对象头部。 标记-清扫回收 标记一清扫算法是一种间接回收(indirect collection)算法,它并非直接检测垃圾本身而是先确定所有存活对象,然后反过来判定其他对象都是垃圾。需要注意的是,该算法的每次调用都需要重新计算存活对象集合,但并非所有的垃圾回收算法都需要如此。 三色抽象 三色抽象(tricolour abstraction)[Dijkstra等,1976,1978]可以简洁地描述回收过程中对象状态的变化(是否已被标记、是否在工作列表中等)。三色抽象是描述追踪式回收器的种十分有用的方法,利用它可以推演回收器的正确性,这正是回收器必须保证的。在三色抽象中,回收器将对象图划分为黑色对象(确定存活)和白色对象(可能死亡)。任意对象在初始状态下均为白色,当回收器初次扫描到某一对象时将其着为灰色,当完成该对象的扫描并找到其所有子节点之后,回收器会将其着为黑色。从概念上讲,黑色意味着已经被回收器处理过,灰色意味着已经被回收器遍历但尚未完成处理(或者需要再次进行处理)。三色抽象也可以推广到对象的域中:灰色表示正在处理的域,黑色表示已经处理过的域。如果把赋值器也当作一个对象,则三色抽象也可用于推演赋值器根集合的状态变化[Pirinen,1998]灰色赋值器表示回收器尚未完成对其根集合的扫描,黑色赋值器表示回收器已经完成对其根集合的扫描(并且不需要再次扫描)。一次堆遍历过程可以形象地看作是回收器以灰色对象作为“波面”(wavefront),将黑色对象和白色对象分离,不断向前推进波面,直到所有可达对象都变成黑色的过程。 上述算法中存在一个重要的不变式:在标记过程完成后,对象图中将不可能存在从黑色对象指向白色对象的引用,因此在标记过程中,所有白色可达对象都只能是从灰色对象可达。如果这一不变式被打破,那么回收器将不会进一步处理黑色对象,从而可能导致某个黑色对象的后代可达但未被标记(进而被错误地释放)。 改进的标记-清扫算法 程序的性能通常与其高速缓存的相关行为有很大关系。从内存中加载一个值可能要花费上百个时钟周期,但从L1高速缓存(L1 cache)中加载可能只需要花费三到四个时钟周期。高速缓存之所以能够提升程序的性能,主要是因为程序在运行时表现出了良好的时间局部性」(temporal locality),即一旦程序访问了某个内存地址,则很可能在不久之后再次访问该地址,因此值得将它的值缓存。程序也可能表现出良好的空间局部性(space locality),即一旦程序访问了某个内存地址,则很有可能在不久之后访问该地址附近的数据。现代硬件可以从两个方面利用程序的局部性特征:一方面,高速缓存与更低级别内存之间不会进行单个字节的数据传输,而是以一个固定的字节数为最小传输单元(即高速缓存行或者高速缓存块的大小),通常是32~128字节;另一方面,处理器可能会使用硬件预取(prefetch)技术,例如Intel Core微处理器架构可以探测到有规律的步进内存访问操作,进而提前读取数据流。开发者也可以利用显式预取指令引导预取过程。 位图标记 回收器可以将对象的标记位保存在其头部的某个字中,除此之外也可以使用一个独立的位图来维护标记位,即:位图中的每个位关联堆中每个可能分配对象的地址。位图所需的空间取决于虚拟机的字节对齐要求。位图可以只有一个,也可以存在多个,例如在块结构的堆中,回收器可以为每个内存块维护独立的位图,这一方式可以避免由于堆不连续导致的内存浪费。回收器可以将每个内存块的位图置于其自身内部,但如果所有内存块中位图的相对位置全部相同,则可能导致性能的下降,因为不同内存块的位图之间可能会争用相同的组相关高速缓存(set- associative cache)。对位图的访问同时也意味着对位图所在页的访问(即可能导致缺页异常——译者注),因此基于换页和高速缓存相关性的考虑,在访问位图时花费更多的指令以保持程序的局部性通常来说是值得的。为避免高速缓存的相关问题,可以将内存块中位图的位置增加一个简单偏移量,例如内存块地址的简单哈希值。还可以将位图存放在个额外的区域[Boehm and Weiser,1988年]中,并以其所对应内存块的哈希值等作为索引,这样既避免了换页问题,也避免了高速缓存冲突。 位图标记通常仅适用于单线程环境,因为多线程同时修改位图可能存在较大的写冲突风险。设置对象头部中的标记位通常是安全的,因为该操作是幂等的,即最多只会将标记位设置多次。相对于位图,实践中更常用的是字节图(byte-map),虽然它占用的空间是前者的8倍,但却解决了写冲突问题。另外还可以使用同步操作来设置位图中的位。在实际应用中,如果将标记位保存在对象头部通常会带来额外的复杂度,因为头部通常会存放一些赋值器共享数据,例如锁或者哈希值,那么当标记线程与赋值器线程并发执行时可能会产生冲突。因此,为了确保安全,标记位通常会占用头部中一个额外的字,以便与赋值器共享数据区分,当然也可以使用原子操作来设置头部中的标记位。 相对于将标记位放置在对象头部这一策略,位图可以使得标记位更加密集;对于使用位图的标记一清扫回收器,标记过程只需读取存活对象的指针域而不会修改任何对象;对于不包含引用的对象,回收器只需要读取其类型信息描述域;清扫器不会对存活对象进行任何读写操作,它只会在释放垃圾对象的过程中覆盖其某些域(例如将它们链接到空闲链表上)。因此,位图标记不仅可以减少内存中需要修改的字节数,而且减少了对高速缓存行的写入,进而减少需要写回内存的数据量。 位图标记最初应用在保守式回收器(conservative collector)中。保守式回收器的设计初衷是为C和C++等“不合作”的语言提供自动内存管理功能[Boehm and Weiser,1988]型精确(type-accurate)系统可以精确地识别每一个包含指针的槽,不论其位于对象中,是位于线程栈或者其他根集合中,而保守式回收器则无法得到编译器和运行时系统的支持因而其在识别指针时必须采用保守的判定方式,即:如果槽中某个值看起来像是指针引用,那么就必须假定它是一个指针。保守式回收器可能错误地将一个槽当作指针,这带来了两个安全上的要求:第一,回收器不能修改任何赋值器可能访问到的内存地址的值(包括对象和根集合)。这一要求导致保守式回收器不能使F任何可能移动对象的算法,因为对象被移动之后需要更新指向该对象的所有引用。这同时导致在头域中保存标记位的方案不可行,因为错误的指针会指向一个实际并不存在的象”,因此设置或者清理标记位可能会破坏用户数据。第二,应当尽可能减少赋值器破坏回收器数据的可能性。与将标记位等回收器元数据存放在一个单独区域的方案相比,为每个对象增加一个回收器专用头部数据会存在更高的风险。 使用位图标记的另一个重要目的是减少回收过程中的换页次数[Boehm,2000在现代系统中,任何由回收器导致的换页行为通常都是不可接受的,因此位图标记是否可以提升高速缓存性能便成为一个值得关注的问题。许多证据表明,对象往往成簇诞生并成批死亡[Hayes,1991; Jones, Ryder,2008],而许多分配器往往也会将这些对象分配在相邻的空间。使用位图来引导清扫可以带来两个好处:第一,在位图/字节图中,一个字内部的每个位/字节全部都被设置/清空的情况会经常出现,因此回收器可以批量读取/清空一批对象的标记位;第二,通过位图标记可以更简单地判定某一内存块中的所有对象是否都是垃圾,进而可能一次性回收整个内存块。 许多内存管理器都使用块结构堆(如Boehm and Weiser[1988])。最直接的位图标记实现策略可能是在每个内存块的前端保留一块内存以用作位图。但正如我们前面所提到的,这策略可能会导致不必要的高速缓存冲突或者换页,因此回收器通常将位图与用户数据块分开并单独存放。 Garner等[2007]提出了一种混合标记策略,即将分区适应分配器(segregated fitsallocator)所管理的每个数据块与字节图中的一个字节相关联,同时依然保留对象头部的标记位。当且仅当内存块中至少存在一个存活对象时,该内存块所对应的标记字节才会被设置。清扫器可以根据字节图快速地判定某一内存块是否完全为空(即不包含存活对象),进而可以将其整体回收。这一策略有两个优点:第一,在并发情况下,无需使用同步操作来设置字节图中的标记字节以及对象头部的标记位;第二,写操作没有数据依赖(这可能导致高速缓存延迟),且对字节图中标记字节的写操作也是无条件的。 标记过程中的高速缓存不命中问题 Bohm[2000]发现标记过程的开销决定着回收时间:在 Intel PentiumⅢ系统中,预取对象第一个指针域的开销通常会占到标记该对象总开销的三分之一。为此 Boehm提出一种灰色预取( prefetching on gray)技术,即当对象为灰色时,预取其第一个高速缓存行中的数据,如果被扫描的对象很大,则预取适当数量的高速缓存行。然而,这种技术依赖于预取时间,如果过早地进行高速缓存行预取,则数据很可能在得到使用之前就被换出,而过晚地预取则会导致高速缓存不命中。 需要考虑的问题 空间利用率 将标记位放在对象头部基本不会产生额外的空间开销,而如果使用位图来保存标记位,则额外空间开销的大小取决于对象的字节对齐要求,但其总大小不会超过堆的字节对齐要求的倒数(即堆空间的1/64或1/32,具体的值取决于堆的组织架构)。 引用计数 环状引用计数 对于环状数据结构而言,其内部对象的引用计数至少为1,因此仅靠引用计数本身无法回收环状垃圾。不论是在应用程序还是在运行时系统中,环状数据结构都十分普遍,如双向链表或者环状缓冲区。对象一关系映射(object-relations mapping)系统可能要求数据库和其中的表互相引用对方的一些信息。真实世界中的某些结构天然就是环状的,例如地理信息系统中的道路。懒惰函数式语言(lazy functional language)通常使用环来表示递归[urner 1979,Y组合子(Combinator)]。研究者们提出了多种解决环状引用计数问题的策略,我们介绍其中的几种。 最简单的策略是在引用计数之外偶尔使用追踪式回收作为补充。该方法假定大多数对象不会被环状数据结构所引用,因此可以通过引用计数方法实现快速回收,而追踪式回收则负责处理剩余的环状数据结构。这一方案简单地减少了追踪式回收的发起频率。在语言层面上, Friedman和wise[1979]发现,纯函数式语言中只有递归定义才会产生环,因此只要遵从一定的规则便可对这种情况下的环状引用计数进行特殊处理。在 Bobrow1980]的方法中,开发者可以把一组对象作为整体进行引用计数操作,当整体引用计数为零时便可将其集体回收。 许多学者建议将导致闭环出现的指针与其他指针进行区分[Friedman and Wise,1979;Brownbridge,1985; Salkeld,1987; Repels等,1988; Axford,19901。他们将普通引用称为强引用(strong reference),将导致闭环出现的引用称为弱引用(weak reference)。如果不允许强引用组成环,则强引用图可以使用标准引用计数算法处理。Brownbridge的算法得到了广泛应用,简而言之,每个对象需要包含一个强引用计数以及一个弱引用计数,在进行写操作时,写屏障会检测指针以及目标对象的强弱,并将所有可能产生环的引用设置为弱引用。为维护“所有可达对象均为强可达,且强引用不产生环”这一不变式,赋值器在删除引用时可能需要改变指针的强弱属性。但是,这一算法并不安全,且可能导致对象提前被回收,具体可以参见Salkeld的引用计数示例[Jones,1996,6.5节]。Salkeld[1987]对该算法进行修正并提升了它的安全性,但代价是在某些情况下算法将无法结束。Repels等[1988]提出了一种非常复杂的解决方案,但该算法在空间以及性能方面的开销却更加明显:与普通的引用计数相比,其所需的空间开销翻倍,在大多数情况下,其性能开销是标准引用计数的两倍,在极端情况下,甚至会呈现指数级增长。 在所有能够处理环状数据结构的引用计数算法中,得到了最广泛认可的是试验删除(trial deletion)算法。该算法无须使用后备的追踪式回收器来进行整个存活对象图的扫描,相反,它将注意力集中在可能会因删除引用而产生环状垃圾的局部对象图上。在引用计数算法中: - 在环状垃圾指针结构内部,所有对象的引用计数均由其内部对象之间的指针产生。 - 只有在删除某一对象的某个引用后该对象的引用计数仍大于零时,才有可能出现环状垃圾。 部分追踪(partial tracing)算法充分利用上述两个结论,该算法从一个可能是垃圾的对象开始进行子图追踪。对于遍历到的每个引用,算法将对其目标对象进行试验删除,即临时性地减少目标对象的引用计数,从而移除由内部指针产生的引用计数。追踪完成后,如果某个对象的引用计数仍然不是零,则必然是因为子图之外的其他对象引用了该对象,进而可以判定该对象及其传递闭包都不是垃圾。 Recycler算法[Bacon等,2001; Bacon and Rajan,2001;Paz等,2007]支持环状引用计数的并发回收。环状数据结构的回收分为3个阶段: - 首先,回收器从某个可能是环状垃圾成员的对象出发进行子图追踪,同时减少由内部指针产生的引用计数。算法将遍历到的对象着为灰色。 - 其次,对子图中的所有对象进行检测,如果某一对象的引用计数不是零,则该对象必然被子图外的其他对象引用。此时需要对第一阶段的试验删除操作进行修正,算法将存活的灰色对象重新着为黑色,同时将其他灰色对象着为白色。 - 最后,子图中所有依然为白色的对象必然是垃圾,算法可以将其回收。 内存分配 对于首次适应或循环首次适应分配而言,将空闲内存单元依照地址进行排序的另一种有效策略是位图适应分配(bitmapped- fits allocation)。该算法使用额外的位图来记录堆中每个可分配内存颗粒的状态,因此在进行内存分配时,分配器可以基于位图而非堆本身进行搜索。借助一张预先计算好的映射表,分配器仅需要对位图中一个字节进行计算,便可得知其所对应的8个连续内存颗粒所能组成的最长连续可用空间。也可以使用额外的长度信息记录较大的空闲内存单元或者已分配内存单元,从而快速将其跳过以提升分配性能。位图适应分配具有如下一些优点: - 位图本身与对象相互隔离,因此不容易遭到破坏。这一特性不仅对于诸如C和C++等安全性稍低的语言十分重要,而且对于安全性更高的语言也十分有用,因为这可以提升回收器的可靠性以及可调试性。 - 引入位图之后,无论是对于空闲内存单元还是已分配内存单元,回收器都不需要占据其中的任何空间来记录回收相关信息,从而最大限度地降低了对内存单元大小的要求。如果以一个32位的字作为最小内存分配单元,该策略会引入大约3%的空间开销,但其所带来收益却远大于这一开销。不过,基于其他一些方面的考虑,对象可能依然需要一个头部,因而这一优点并非始终能够得到体现。 - 相对于堆中的内存单元,位图更加紧凑,因此基于位图进行扫描可以提升高速缓存命中率,从而提升分配器的局部性。 分区适应分配 空间大小分级的填充 伙伴系统(buddy system)其空间大小分级均为2的整数次幂[Knowlton,1965; Peterson and Norman,1977]。我们可以将一个大小为2^(i+1)的空闲内存单元分裂为两个大小为2^i的空闲内存单元,同时也可以将两个相邻的大小为2^i的空闲内存单元合并成大小为2^(i+1)的一个,但进行合并的前提是两个相邻空闲内存单元原本就是由同一个较大的空闲内存单元分裂得到的。在该算法中,大小为2^i的空闲内存单元两两成对,因而称之为伙伴。由于伙伴系统的内部碎片通常较为严重(对于任意的内存分配需求,其平均空间浪费率会达到25%),因此该算法基本已经成为历史,在实践中较少使用。 斐波那契伙伴系统(Fibonacci buddy system)[Hirsc… ### IDEA 个人常用插件 - URL: https://jiankunking.com/idea-plugin.html - Content type: original - Published: 2019-09-11 - Updated: 2019-09-11 - Summary: 整理个人在IDEA中常用的插件,包括代码生成、日志还原、Mapper跳转等实用工具,持续更新中。 - Categories: Tools - Tags: Plugin, IDE, IDEA, Tools Article text: 文章速览 整理个人在IDEA中常用的插件,包括代码生成、日志还原、Mapper跳转等实用工具,持续更新中。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Tools 整理个人在IDEA中常用的插件,包括代码生成、日志还原、Mapper跳转等实用工具,持续更新中。 mybatis-lite 根据数据库表自动生成代码 git地址:https://github.com/mustfun/mybatis-lite MyBatis Log Plugin 把mybatis输出的日志还原成完整的sql语句 git地址:https://github.com/kookob/mybatis-log-plugin MyBatisX mapper和xml互相跳转 https://baomidou.com/pages/ba5b24/ git地址:https://github.com/baomidou/MybatisX ### 码出高效:Java 开发手册 - URL: https://jiankunking.com/alibaba-p3c.html - Content type: original - Published: 2019-09-10 - Updated: 2019-09-10 - Summary: 阿里巴巴Java开发手册(华山版),涵盖编程规约、异常日志、单元测试、安全规约、MySQL数据库、工程结构等开发规范。 - Categories: Coding Standards - Tags: Coding Standards Article text: 文章速览 阿里巴巴Java开发手册(华山版),涵盖编程规约、异常日志、单元测试、安全规约、MySQL数据库、工程结构等开发规范。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Coding Standards 阿里巴巴Java开发手册(华山版),涵盖编程规约、异常日志、单元测试、安全规约、MySQL数据库、工程结构等开发规范。 开发规范 在线PDF版本:阿里巴巴Java开发手册(华山版).pdf https://github.com/alibaba/p3c ### TCP/IP详解 卷1:协议 笔记 - URL: https://jiankunking.com/tcp-ip-illustrated-volume-1-the-protocols.html - Content type: original - Published: 2019-09-05 - Updated: 2019-09-05 - Summary: 《TCP/IP详解 卷1》学习笔记,系统整理链路层、IP、ARP、ICMP、TCP、UDP 和常见应用协议。 - Categories: Network - Tags: Reading Notes, Network, TCP, IP, Protocol Article text: 文章速览 《TCP/IP详解 卷1》学习笔记,系统整理链路层、IP、ARP、ICMP、TCP、UDP 和常见应用协议。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 本文整理自:《TCP/IP详解 卷1:协议》 作者:W. Richard Stevens 一个bit是二进制中的最小单位,代表一个0或1的位置。 bit是位。 Byte是字节。 1Byte=8bit。 概述 分层 网络协议通常分不同层次进行开发,每一层分别负责不同的通信功能。一个协议族,比如TCP/IP,是一组不同层次上的多个协议的组合。TCP/IP通常被认为是一个四层协议系统,如图1-1所示。 每一层负责不同的功能: 1)链路层,有时也称作数据链路层或网络接口层,通常包括操作系统中的设备驱动程序和计算机中对应的网络接口卡。它们一起处理与电缆(或其他任何传输媒介)的物理接口细节。 2)网络层,有时也称作互联网层,处理分组在网络中的活动,例如分组的选路。在TCP/IP协议族中,网络层协议包括IP协议(网际协议),ICMP协议(Internet互联网控制报文协议),以及IGMP协议(Internet组管理协议)。 3)运输层主要为两台主机上的应用程序提供端到端的通信。在TCP/IP协议族中,有两个互不相同的传输协议:TCP(传输控制协议)和UDP(用户数据报协议)。TCP为两台主机提供高可靠性的数据通信。它所做的工作包括把应用程序交给它的数据分成合适的小块交给下面的网络层,确认接收到的分组,设置发送最后确认分组的超时时钟等。由于运输层提供了高可靠性的端到端的通信,因此应用层可以忽略所有这些细节。而另一方面,UDP则为应用层提供一种非常简单的服务。它只是把称作数据报的分组从一台主机发送到另一台主机,但并不保证该数据报能到达另一端。任何必需的可靠性必须由应用层来提供。这两种运输层协议分别在不同的应用程序中有不同的用途,这一点将在后面看到。 4)应用层负责处理特定的应用程序细节。几乎各种不同的TCP/IP实现都会提供下面这些通用的应用程序: - Telnet 远程登录。 - FTP 文件传输协议。 - SMTP 简单邮件传送协议。 - SNMP 简单网络管理协议。 在TCP/IP协议族中,网络层IP提供的是一种不可靠的服务。也就是说,它只是尽可能快地把分组从源结点送到目的结点,但是并不提供任何可靠性保证。而另一方面,TCP在不可靠的IP层上提供了一个可靠的运输层。为了提供这种可靠的服务,TCP采用了超时重传、发送和接收端到端的确认分组等机制。由此可见,运输层和网络层分别负责不同的功能。 连接网络的另一个途径是使用网桥。网桥是在链路层上对网络进行互连,而路由器则是在网络层上对网络进行互连。网桥使得多个局域网(LAN)组合在一起,这样对上层来说就好像是一个局域网。 TCP/IP的分层 在TCP/IP协议族中,有很多种协议。图1-4给出了本书将要讨论的其他协议。 IP是网络层上的主要协议,同时被TCP和UDP使用。TCP和UDP的每组数据都通过端系统和每个中间路由器中的IP层在互联网中进行传输。 ICMP是IP协议的附属协议。IP层用它来与其他主机或路由器交换错误报文和其他重要信息。 IGMP是Internet组管理协议。它用来把一个UDP数据报多播到多个主机。 ARP(地址解析协议)和RARP(逆地址解析协议)是某些网络接口(如以太网和令牌环网)使用的特殊协议,用来转换IP层和网络接口层使用的地址。 互联网地址 互联网上的每个接口必须有一个唯一的Internet地址(也称作IP地址)。IP地址长32bit。 这些32位的地址通常写成四个十进制的数,其中每个整数对应一个字节。这种表示方法称作“点分十进制表示法(Dotted decimal notation)”。 区分各类地址的最简单方法是看它的第一个十进制整数。图1-6列出了各类地址的起止范围,其中第一个十进制整数用加黑字体表示。 需要再次指出的是,多接口主机具有多个IP地址,其中每个接口都对应一个IP地址。由于互联网上的每个接口必须有一个唯一的IP地址,因此必须要有一个管理机构为接入互联网的网络分配IP地址。这个管理机构就是互联网络信息中心(Internet Network Information Centre),称作InterNIC。InterNIC只分配网络号。主机号的分配由系统管理员来负责。 有三类IP地址:单播地址(目的为单个主机)、广播地址(目的端为给定网络上的所有主机)以及多播地址(目的端为同一组内的所有主机)。 封装 当应用程序用TCP传送数据时,数据被送入协议栈中,然后逐个通过每一层直到被当作一串比特流送入网络。其中每一层对收到的数据都要增加一些首部信息(有时还要增加尾部信息),该过程如图1-7所示。TCP传给IP的数据单元称作TCP报文段或简称为TCP段(TCP segment)。IP传给网络接口层的数据单元称作IP数据报(IP datagram)。通过以太网传输的比特流称作帧(Frame)。 图1-7中帧头和帧尾下面所标注的数字是典型以太网帧首部的字节长度。 以太网数据帧的物理特性是其长度必须在46~1500字节之间。 更准确地说,图1-7中IP和网络接口层之间传送的数据单元应该是分组(packet)。分组既可以是一个IP数据报,也可以是IP数据报的一个片(fragment)。 UDP数据与TCP数据基本一致。唯一的不同是UDP传给IP的信息单元称作UDP数据报(UDP datagram),而且UDP的首部长为8字节。 由于TCP、UDP、ICMP和IGMP都要向IP传送数据,因此IP必须在生成的IP首部中加入某种标识,以表明数据属于哪一层。为此,IP在首部中存入一个长度为8 bit的数值,称作协议域。1表示为ICMP协议,2表示为IGMP协议,6表示为TCP协议,17表示为UDP协议。 类似地,许多应用程序都可以使用TCP或UDP来传送数据。运输层协议在生成报文首部时要存入一个应用程序的标识符。TCP和UDP都用一个16 bit的端口号来表示不同的应用程序。TCP和UDP把源端口号和目的端口号分别存入报文首部中。 网络接口分别要发送和接收IP、ARP和RARP数据,因此也必须在以太网的帧首部中加入某种形式的标识,以指明生成数据的网络层协议。为此,以太网的帧首部也有一个16 bit的帧类型域。 分用 当目的主机收到一个以太网数据帧时,数据就开始从协议栈中由底向上升,同时去掉各层协议加上的报文首部。每层协议盒都要去检查报文首部中的协议标识,以确定接收数据的上层协议。这个过程称作分用(Demultiplexing),图1-8显示了该过程是如何发生的。 小结 TCP/IP协议族分为四层:链路层、网络层、运输层和应用层,每一层各有不同的责任。在TCP/IP中,网络层和运输层之间的区别是最为关键的:网络层(IP)提供点到点的服务,而运输层(TCP和UDP)提供端到端的服务。 链路层 从图1-4中可以看出,在TCP/IP协议族中,链路层主要有三个目的:(1)为IP模块发送和接收IP数据报;(2)为ARP模块发送ARP请求和接收ARP应答;(3)为RARP发送RARP请求和接收RARP应答。 环回接口 大多数的产品都支持环回接口(Loopback Interface),以允许运行在同一台主机上的客户程序和服务器程序通过TCP/IP进行通信。A类网络号127就是为环回接口预留的。根据惯例,大多数系统把IP地址127.0.0.1分配给这个接口,并命名为localhost。一个传给环回接口的IP数据报不能在任何网络上出现。 IP:网际协议 引言 IP是TCP/IP协议族中最为核心的协议。所有的TCP、UDP、ICMP及IGMP数据都以IP数据报格式传输(见图1-4)。 不可靠(unreliable)的意思是它不能保证IP数据报能成功地到达目的地。IP仅提供最好的传输服务。如果发生某种错误时,如某个路由器暂时用完了缓冲区,IP有一个简单的错误处理算法:丢弃该数据报,然后发送ICMP消息报给信源端。任何要求的可靠性必须由上层来提供(如TCP)。 无连接(connectionless)这个术语的意思是IP并不维护任何关于后续数据报的状态信息。每个数据报的处理是相互独立的。这也说明,IP数据报可以不按发送顺序接收。如果一信源向相同的信宿发送两个连续的数据报(先是A,然后是B),每个数据报都是独立地进行路由选择,可能选择不同的路线,因此B可能在A到达之前先到达。 IP首部 IP数据报的格式如图3-1所示。普通的IP首部长为20个字节,除非含有选项字段。 分析图3-1中的首部。最高位在左边,记为0 bit;最低位在右边,记为31 bit。 4个字节的32 bit值以下面的次序传输:首先是0~7 bit,其次8~15 bit,然后16~23 bit,最后是24~31 bit。这种传输次序称作bigendian字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。以其他形式存储二进制整数的机器,如little endian格式,则必须在传输数据之前把首部转换成网络字节序。 目前的协议版本号是4,因此IP有时也称作IPv4。 首部长度指的是首部占32 bit字的数目,包括任何选项。由于它是一个4比特字段,因此首部最长为60个字节。普通IP数据报(没有任何选择项)字段的值是5。 总长度字段是指整个IP数据报的长度,以字节为单位。利用首部长度字段和总长度字段,就可以知道IP数据报中数据内容的起始位置和长度。 总长度字段是IP首部中必要的内容,因为一些数据链路(如以太网)需要填充一些数据以达到最小长度。尽管以太网的最小帧长为46字节,但是I P数据可能会更短。如果没有总长度字段,那么IP层就不知道46字节中有多少是IP数据报的内容。 标识字段唯一地标识主机发送的每一份数据报。通常每发送一份报文它的值就会加 1。 TTL(time-to-live)生存时间字段设置了数据报可以经过的最多路由器数。它指定了数据报的生存时间。TTL的初始值由源主机设置(通常为32或64),一旦经过一个处理它的路由器,它的值就减去1。当该字段的值为0时,数据报就被丢弃,并发送ICMP报文通知源主机。 首部检验和字段是根据IP首部计算的检验和码。它不对首部后面的数据进行计算。ICMP、IGMP、UDP和TCP在它们各自的首部中均含有同时覆盖首部和数据检验和码。 为了计算一份数据报的IP检验和,首先把检验和字段置为 0。然后,对首部中每个16 bit进行二进制反码求和(整个首部看成是由一串 16 bit的字组成),结果存在检验和字段中。当收到一份IP数据报后,同样对首部中每个 16 bit进行二进制反码的求和。由于接收方在计算过程中包含了发送方存在首部中的检验和,因此,如果首部在传输过程中没有发生任何差错,那么接收方计算的结果应该为全 1。如果结果不是全1(即检验和错误),那么IP就丢弃收到的数据报。但是不生成差错报文,由上层去发现丢失的数据报并进行重传。 选项字段一直都是以 32 bit作为界限,在必要的时候插入值为 0的填充字节。这样就保证IP首部始终是32 bit的整数倍(这是首部长度字段所要求的)。 IP路由选择 从概念上说,IP路由选择是简单的,特别对于主机来说。如果目的主机与源主机直接相连(如点对点链路)或都在一个共享网络上(以太网或令牌环网),那么IP数据报就直接送到目的主机上。否则,主机把数据报发往一默认的路由器上,由路由器来转发该数据报。大多数的主机都是采用这种简单机制。 在一般的体制中,IP可以从TCP、UDP、ICMP和IGMP接收数据报(即在本地生成的数据报)并进行发送,或者从一个网络接口接收数据报(待转发的数据报)并进行发送。IP层在内存中有一个路由表。当收到一份数据报并进行发送时,它都要对该表搜索一次。当数据报来自某个网络接口时,IP首先检查目的IP地址是否为本机的IP地址之一或者IP广播地址。如果确实是这样,数据报就被送到由IP首部协议字段所指定的协议模块进行处理。如果数据报的目的不是这些地址,那么(1)如果IP层被设置为路由器的功能,那么就对数据报进行转发(也就是说,像下面对待发出的数据报一样处理);否则(2)数据报被丢弃。 路由表中的每一项都包含下面这些信息: - 目的IP地址。它既可以是一个完整的主机地址,也可以是一个网络地址,由该表目中的标志字段来指定(如下所述)。主机地址有一个非0的主机号(见图1-5),以指定某一特定的主机,而网络地址中的主机号为0,以指定网络中的所有主机(如以太网,令牌环网)。 - 下一站(或下一跳)路由器(next-hoprouter)的IP地址,或者有直接连接的网络IP地址。下一站路由器是指一个在直接相连网络上的路由器,通过它可以转发数据报。下一站路由器不是最终的目的,但是它可以把传送给它的数据报转发到最终目的。 - 标志。其中一个标志指明目的IP地址是网络地址还是主机地址,另一个标志指明下一站路由器是否为真正的下一站路由器,还是一个直接相连的接口。 - 为数据报的传输指定一个网络接口。 IP路由选择是逐跳地(hop-by-hop)进行的。从这个路由表信息可以看出,IP并不知道到达任何目的的完整路径(当然,除了那些与主机直接相连的目的)。所有的IP路由选择只为数据报传输提供下一站路由器的IP地址。它假定下一站路由器比发送数据报的主机更接近目的,而且下一站路由器与该主机是直接相连的。 IP路由选择主要完成以下这些功能: - 搜索路由表,寻找能与目的IP地址完全匹配的表目(网络号和主机号都要匹配)。如果找到,则把报文发送给该表目指定的下一站路由器或直接连接的网络接口(取决于标志字段的值)。 - 搜索路由表,寻找能与目的网络号相匹配的表目。如果找到,则把报文发送给该表目指定的下一站路由器或直接连接的网络接口(取决于标志字段的值)。目的网络上的所有主机都可以通过这个表目来处置。例如,一个以太网上的所有主机都是通过这种表目进行寻径的。这种搜索网络的匹配方法必须考虑可能的子网掩码。 - 搜索路由表,寻找标为“默认(default)”的表目。如果找到,则把报文发送给该表目指定的下一站路由器。 如果上面这些步骤都没有成功,那么该数据报就不能被传送。如果不能传送的数据报来自本机,那么一般会向生成数据报的应用程序返回一个“主机不可达”或“网络不可达”的错误。完整主机地址匹配在网络号匹配之前执行。只有当它们都失败后才选择默认路由。默认路由,以及下一站路由器发送的ICMP间接报文(如果我们为数据报选择了错误的默认路由),是IP路由选择机制中功能强大的特性。 为一个网络指定一个路由器,而不必为每个主机指定一个路由器,这是IP路由选择机制的另一个基本特性。这样做可以极大地缩小路由表的规模,比如Internet上的路由器有只有几千个表目,而不会是超过100万个表目。 子网寻址 现在所有的主机都要求支持子网编址(RFC 950 [Mogul and Postel 1985])。不是把IP地址看成由单纯的一个网络号和一个主机号组成,而是把主机号再分成一个子网号和一个主机号。 这样做的原因是因为A类和B类地址为主机号分配了太多的空间,可分别容纳的主机数为2^24 -2和2^16 -2。事实上,在一个网络中人们并不安排这么多的主机(各类IP地址的格式如图1-5所示)。由于全0或全1的主机号都是无效的,因此我们把总数减去 2。 子网掩码 任何主机在引导时进行的部分配置是指定主机IP地址。大多数系统把IP地址存在一个磁盘文件里供引导时读用。 除了IP地址以外,主机还需要知道有多少比特用于子网号及多少比特用于主机号。这是在引导过程中通过子网掩码来确定的。这个掩码是一个32 bit的值,其中值为1的比特留给网络号和子网号,为0的比特留给主机号。图3-7是一个B类地址的两种不同的子网掩码格式。第一个例子是noao.edu网络采用的子网划分方法,子网号和主机号都是8 bit宽。第二个例子是一个B类地址划分成10 bit的子网号和6 bit的主机号。 给定IP地址和子网掩码以后,主机就可以确定IP数据报的目的是:(1)本子网上的主机;(2)本网络中其他子网中的主机;(3)其他网络上的主机。如果知道本机的IP地址,那么就知道它是否为A类、B类或C类地址(从IP地址的高位可以得知),也就知道网络号和子网号之间的分界线。而根据子网掩码就可知道子网号与主机号之间的分界线。 将来或许会有更多的主机和网络,但是为了不让主机跨越不同的网络就得使用不同的子网号。我们的解决方法是把子网号从8 bit扩充到11 bit,把主机号从8 bit减为5 bit。这就叫作变长子网,因为140.252网络中的大多数子网都采用8 bit子网掩码,而我们的子网却采用11 bit的子网掩码。 作者子网中的IP地址结构如图3-11所示,11位子网号中的前8bit始终是13。在剩下的3 bit中,我们用二进制001表示以太网,010表示点对点SLIP链路。这个变长子网掩码在140.252网络中不会给其他主机和路由器带来问题—只要目的是子网140.252.13的所有数据报都传给路由器sun(IP地址是140.252.1.29),如图3-11所示。如果sun知道子网13中的主机有11bit子网号,那么一切都好办了。 140.252.13子网中的所有接口的子网掩码是255.255.255.224,或0xffffffe0。这表明最右边的5bit留给主机号,左边的27bit留给网络号和子网号。 ifconfig命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | [jiankunking@VM_0_3_centos ~]# ifconfig -a docker0: flags=4163 mtu 1500 inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255 ether 02:42:a5:fc:a8:b0 txqueuelen 0 (Ethernet) RX packets 1803610 bytes 751152220 (716.3 MiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 1715508 bytes 1981302829 (1.8 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 eth0: flags=4163 mtu 1500 inet 172.21.0.3 netmask 255.255.240.0 broadcast 172.21.15.255 ether 52:54:00:9f:9a:52 txqueuelen 1000 (Ethernet) RX packets 25626090 bytes 6434619435 (5.9 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 27765770 bytes 6026731597 (5.6 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 lo: flags=73 mtu 65536 inet 127.0.0.1 netmask 255.0.0.0 loop txqueuelen 1000 (Local Loopback) RX packets 2 bytes 272 (272.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 2 bytes 272 (272.0 B) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 veth7423cb0: flags=4163 mtu 1500 ether 0a:19:63:74:aa:32 txqueuelen 0 (Ethernet) RX packets 1939 bytes 158399 (154.6 KiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 1810 bytes 2114402 (2.0 MiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 | 环回接口被认为是一个网络接口。它是一个 A类地址,没有进行子网划分。 小结 在进行路由选择决策时,主机和路由器都使用路由表。在表中有三种类型的路由:特定主机型、特定网络型和默认路由型。路由表中的表目具有一定的优先级。在选择路由时,主机路由优先于网络路由,最后在没有其他可选路由存在时才选择默认路由。 IP路由选择是通过逐跳来实现的。数据报在各站的传输过程中目的 IP地址始终不变,但是封装和目的链路层地址在每一站都可以改变。大多数的主机和许多路由器对于非本地网络的数据报都使用默认的下一站路由器。 ARP:地址解析协议 引言 当一台主机把以太网数据帧发送到位于同一局域网上的另一台主机时,是根据 48 bit的以太网地址来确定目的接口的。设备驱动程序从不检查 IP数据报中的目的IP地址。 地址解析为这两种不同的地址形式提供映射: 32 bit的IP地址和数据链路层使用的任何类型的地址。 ARP为IP地址到对应的硬件地址之间提供动态映射。我们之所以用动态这个词是因为这个过程是自动完成的,一般应用程序用户或系统管理员不必关心。 RARP是被那些没有磁盘驱动器的系统使用(一般是无盘工作站或X终端),它需要系统管理员进行手工设置。 一个例子 任何时候我们敲入下面这个形式的命令: 1 | %ftp bsdi | 都会进行以下这些步骤。这些步骤的序号如图4-2所示。 - 应用程序FTP客户端调用函数gethostbyname(3)把主机名(bsdi)转换成32bit的IP地址。这个函数在DNS(域名系统)中称作解析器,我们将在第14章对它进行介绍。这个转换过程或者使用DNS,或者在较小网络中使用一个静态的主机文件(/etc/hosts)。 - FTP客户端请求TCP用得到的IP地址建立连接。 - TCP发送一个连接请求分段到远端的主机,即用上述IP地址发送一份IP数据报。 - 如果目的主机在本地网络上(如以太网、令牌环网或点对点链接的另一端),那么IP数据报可以直接送到目的主机上。如果目的主机在一个远程网络上,那么就通过IP选路函数来确定位于本地网络上的下一站路由器地址,并让它转发IP数据报。在这两种情况下,IP数据报都是被送到位于本地网络上的一台主机或路由器。 - 假定是一个以太网,那么发送端主机必须把32bit的IP地址变换成48bit的以太网地址。从逻辑Internet地址到对应的物理硬件地址需要进行翻译。这就是ARP的功能。ARP本来是用于广播网络的,有许多主机或路由器连在同一个网络上。 - ARP发送一份称作ARP请求的以太网数据帧给以太网上的每个主机。这个过程称作广播,如图4-2中的虚线所示。ARP请求数据帧中包含目的主机的IP地址(主机名为bsdi),其意思是“如果你是这个IP地址的拥有者,请回答你的硬件地址。” - 目的主机的ARP层收到这份广播报文后,识别出这是发送端在寻问它的IP地址,于是发送一个ARP应答。这个ARP应答包含IP地址及对应的硬件地址。 - 收到ARP应答后,使ARP进行请求—应答交换的IP数据报现在就可以传送了。 - 发送IP数据报到目的主机。 在ARP背后有一个基本概念,那就是网络接口有一个硬件地址(一个48bit的值,标识不同的以太网或令牌环网络接口)。在硬件层次上进行的数据帧交换必须有正确的接口地址。但是,TCP/IP有自己的地址:32bit的IP地址。知道主机的IP地址并不能让内核发送一帧数据给主机。内核(如以太网驱动程序)必须知道目的端的硬件地址才能发送数据。ARP的功能是在32bit的IP地址和采用不同网络技术的硬件地址之间提供动态映射。 ARP高速缓存 ARP高效运行的关键是由于每个主机上都有一个ARP高速缓存。这个高速缓存存放了最近Internet地址到硬件地址之间的映射记录。高速缓存中每一项的生存时间一般为20分钟,起始时间从被创建时开始算起。 我们可以用arp命令来检查ARP高速缓存。参数-a的意思是显示高速缓存中所有的内容。 1 2 3 4 5 6 7 8 9 10 11 12 13 | [jiankunking@VM_0_3_centos ~]# arp -a ? (169.254.0.15) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (169.254.0.3) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (172.17.0.4) at 02:42:ac:11:00:04 [ether] on docker0 ? (169.254.0.2) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (169.254.0.4) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (172.17.0.3) at 02:42:ac:11:00:03 [ether] on docker0 ? (172.17.0.2) at 02:42:ac:11:00:02 [ether] on docker0 ? (169.254.0.23) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (169.254.128.3) at fe:ee:0b:ca:e5:69 [ether] on eth0 ? (169.254.128.5) at fe:ee:0b:ca:e5:69 [ether] on eth0 gateway (172.21.0.1) at fe:ee:0b:ca:e5:69 [ether] on eth0 [jiankunking@VM_0_3_centos ~]# | 48 bit的以太网地址用6个十六进制的数来表示,中间以冒号隔开。 ARP的分组格式 在以太网上解析IP地址时,ARP请求和应答分组的格式如图4-3所示(ARP可以用于其他类型的网络,可以解析IP地址以外的地址。紧跟着帧类型字段的前四个字段指定了最后四个字段的类型和长度)。 以太网报头中的前两个字段是以太网的源地址和目的地址。目的地址为全 1的特殊地址是广播地址。电缆上的所有以太网接口都要接收广播的数据帧。 两个字节长的以太网帧类型表示后面数据的类型。对于ARP请求或应答来说,该字段的值为0x0806。 形容词hardware(硬件)和protocol(协议)用来描述ARP分组中的各个字段。例如,一个ARP请求分组询问协议地址(这里是IP地址)对应的硬件地址(这里是以太网地址)。 硬件类型字段表示硬件地址的类型。它的值为1即表示以太网地址。协议类型字段表示要映射的协议地址类型。它的值为0x0800即表示IP地址。它的值与包含IP数据报的以太网数据帧中的类型字段的值相同,这是有意设计的。 接下来的两个1字节的字段,硬件地址长度和协议地址长度分别指出硬件地址和协议地址的长度,以字节为单位。对于以太网上IP地址的ARP请求或应答来说,它们的值分别为6和4。 操作字段指出四种操作类型,它们是ARP请求(值为1)、ARP应答(值为2)、RARP请求(值为3)和RARP应答(值为4)(我们在第5章讨论RARP)… ### Golang 初始化顺序 - URL: https://jiankunking.com/golang-package-init-order.html - Content type: original - Published: 2019-09-01 - Updated: 2019-09-01 - Summary: 详解Go程序初始化顺序,包括包导入、常量变量初始化、init函数执行的完整流程和注意事项。 - Categories: Go - Tags: Go, Init, Order, Package Article text: 文章速览 详解Go程序初始化顺序,包括包导入、常量变量初始化、init函数执行的完整流程和注意事项。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 详解Go程序初始化顺序,包括包导入、常量变量初始化、init函数执行的完整流程和注意事项。 Go程序的初始化和执行总是从 main.main 函数开始的。但是如果 main 包里导入了其它的包,则会按照顺序将它们包含进 main 包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量。然后就是调用包里的 init 函数,如果一个包有多个 init 函数的话,实现可能是以文件名的顺序调用,同一个文件内的多个 init 则是以出现的顺序依次调用( init 不是普通函数,可以定义有多个,所以不能被其它函数调用)。最终,在 main 包的所有包常量、包变量被创建和初始化,并且 init 函数被执行后,才会进入 main.main 函数,程序开始正常执行。下图是Go程序函数启动顺序的示意图: 要注意的是,在 main.main 函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个 init 函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和 main.main 函数是并发执行的。 因为所有的 init 函数和 main 函数都是在主线程完成,它们也是满足顺序一致性模型的。 整理自:Go语言高级编程 作者:柴树杉、曹春晖 图片来自:beego官网router部分 ### Goroutine和系统线程 - URL: https://jiankunking.com/goroutine-and-system-threads.html - Content type: original - Published: 2019-09-01 - Updated: 2019-09-01 - Summary: 对比Goroutine与系统线程的区别,包括栈空间管理、调度机制、切换代价等核心差异,理解Go并发编程的优势。 - Categories: Go - Tags: Go, Goroutine, Thread Article text: 文章速览 对比Goroutine与系统线程的区别,包括栈空间管理、调度机制、切换代价等核心差异,理解Go并发编程的优势。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 对比Goroutine与系统线程的区别,包括栈空间管理、调度机制、切换代价等核心差异,理解Go并发编程的优势。 Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了Go语言并发编程质的飞跃。 首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。 Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。 在Go语言中启动一个Goroutine不仅和调用函数一样简单,而且Goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。 整理自:Go语言高级编程 作者:柴树杉、曹春晖 ### [转]给 Go 库作者的建议 - URL: https://jiankunking.com/golang-practice-advice-for-go-library-authors.html - Content type: repost - Published: 2019-08-29 - Updated: 2019-08-29 - Summary: 整理 GopherCon 2016 关于 Go 库设计的建议,涵盖 API 设计、错误处理、兼容性和可维护性。 - Categories: Go - Tags: Go, Practice, Advice - Original source: http://go-talks.appspot.com/github.com/cep21/go-talks/practical-advice-for-go-library-authors.slide#1 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]Golang 如何正确使用 Context - URL: https://jiankunking.com/how-to-correctly-use-package-context.html - Content type: repost - Published: 2019-08-29 - Updated: 2019-08-29 - Summary: Go语言Context包的正确使用方式,包括超时控制、请求取消、跨API传递请求范围的值等核心用法。 - Categories: Go - Tags: Go, Context - Original source: https://blog.lab99.org/post/golang-2017-10-27-video-how-to-correctly-use-package-context.html The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### encrypted communication elasticsearch java rest client - URL: https://jiankunking.com/encrypted-communication-elasticsearch-java-rest-client.html - Content type: original - Published: 2019-08-28 - Updated: 2019-08-28 - Summary: 介绍 Elasticsearch 7.3.1 Java REST Client 使用 HTTPS 建立加密连接的配置和实现方式。 - Categories: Elasticsearch - Tags: Java, Elasticsearch, Rest, Client, Encrypted, Communication Article text: 文章速览 介绍 Elasticsearch 7.3.1 Java REST Client 使用 HTTPS 建立加密连接的配置和实现方式。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Elasticsearch ElasticSearch 7.3.1 Java Rest Client HTTPS连接操作 ElasticSearch版本7.3.1,elasticsearch.yml配置如下: 1 2 3 4 5 6 7 8 9 10 | xpack.security.enabled: true xpack.security.transport.ssl.enabled: true xpack.security.transport.ssl.verification_mode: certificate xpack.security.transport.ssl.key: /home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com.key xpack.security.transport.ssl.certificate: /home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com.cer xpack.security.transport.ssl.certificate_authorities: [ "/home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com_ca.crt" ] xpack.security.http.ssl.enabled: true xpack.security.http.ssl.key: /home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com.key xpack.security.http.ssl.certificate: /home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com.cer xpack.security.http.ssl.certificate_authorities: [ "/home/jiankunking/elasticsearch-7.3.1/config/certs/_.jiankunking.com_ca.crt" ] | 由于ElasticSearch Java client中的KeyStore Types只支持以下几种: Type Description jceks | The proprietary keystore implementation provided by the SunJCE provider. | jks | The proprietary keystore implementation provided by the SUN provider. | dks | A domain keystore is a collection of keystores presented as a single logical keystore. It is specified by configuration data whose syntax is described in DomainLoadStoreParameter. | pkcs11 | A keystore backed by a PKCS #11 token. | pkcs12 | The transfer syntax for personal identity information as defined in PKCS #12. | 而我这边证书格式为cer,所以通过keytool进行转换: 1 | keytool -import -v -trustcacerts -file _.jiankunking.com.cer -keystore my_keystore.jks -keypass password -storepass password | 证书转换完成后,操作代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | package ssl; import org.apache.http.HttpHost; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.CredentialsProvider; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.ssl.SSLContexts; import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestClientBuilder; import org.elasticsearch.client.RestHighLevelClient; import org.elasticsearch.client.indices.CreateIndexRequest; import org.elasticsearch.client.indices.CreateIndexResponse; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.XContentType; import javax.net.ssl.SSLContext; import java.io.File; import java.io.IOException; import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.util.HashMap; import java.util.Map; /** * @Author: jiankunking * @Date: 2019/8/27 15:32 * @Description: */ public class es { public static void main(String[] args) throws KeyStoreException, IOException, NoSuchAlgorithmException, KeyManagementException, CertificateException { CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "jiankunking")); SSLContext sslContext = SSLContexts.custom() .loadTrustMaterial(new File("I:\\certs\\my_keystore.jks")) .build(); String host = "es.jiankunking.com"; int port = 9200; String scheme = "https"; String indexName = "twitter2"; RestClientBuilder restClientBuilder = RestClient.builder(new HttpHost(host, port, scheme)).setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder .setDefaultCredentialsProvider(credentialsProvider) .setSSLContext(sslContext) ); // 到这里RestHighLevelClient已经初始化完成,下面的创建索引是测试 RestHighLevelClient restHighLevelClient = new RestHighLevelClient(restClientBuilder); // 创建索引请求 CreateIndexRequest request = new CreateIndexRequest(indexName); request.settings(Settings.builder() .put("index.number_of_shards", 3) .put("index.number_of_replicas", 2) ); request.mapping( "{\n" + " \"properties\": {\n" + " \"message\": {\n" + " \"type\": \"text\"\n" + " }\n" + " }\n" + "}", XContentType.JSON); Map message = new HashMap<>(); message.put("type", "text"); Map properties = new HashMap<>(); properties.put("message", message); Map mapping = new HashMap<>(); mapping.put("properties", properties); request.mapping(mapping); CreateIndexResponse createIndexResponse; try { createIndexResponse = restHighLevelClient.indices().create(request, RequestOptions.DEFAULT); System.out.println(createIndexResponse); } catch (IOException e) { e.printStackTrace(); } } } | ### Web性能权威指南 笔记 - URL: https://jiankunking.com/high-performance-browser-networking.html - Content type: original - Published: 2019-08-27 - Updated: 2019-08-27 - Summary: 《Web性能权威指南》学习笔记,系统整理 TCP、TLS、HTTP、浏览器网络机制和 Web 性能优化方法。 - Categories: Network - Tags: Reading Notes, Network, TCP, HTTP, UDP, SSL Article text: 文章速览 《Web性能权威指南》学习笔记,系统整理 TCP、TLS、HTTP、浏览器网络机制和 Web 性能优化方法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 本文整理自:《Web性能权威指南》 作者:Ilya Grigorik 出版时间:2014-04 网络技术概览 带宽与延迟 延迟的最后一公里 traceroute (Windows 系统下是tracert) 命令利用ICMP协议定位您的计算机和目标计算机之间的所有路由器。 TCP的构成 因特网有两个核心协议:IP和TCP。IP,即 Internet Protocol(因特网协议),负责联网主机之间的路由选择和寻址;TCP,即 Transmission Control Protocol(传输控制协议),负责在不可靠的传输信道之上提供可靠的抽象层。 三次握手 所有TCP连接一开始都要经过三次握手(见图 2-1)。客户端与服务器在交换应用数据之前,必须就起始分组序列号,以及其他一些连接相关的细节达成一致。出于安全考虑,序列号由两端随机生成。 - SYN 客户端选择一个随机序列号x,并发送一个SYN分组,其中可能还包括其他TCP标志和选项。 - SYN ACK 服务器给x加1,并选择自己的一个随机序列号y,追加自己的标志和选项,然后返回响应。 - ACK 客户端给x和y加1并发送握手期间的最后一个ACK分组。 SYN:同步序列编号(Synchronize Sequence Numbers) ACK (Acknowledge character)即是确认字符 三次握手完成后,客户端与服务器之间就可以通信了。客户端可以在发送ACK分组之后立即发送数据,而服务器必须等接收到ACK分组之后才能发送数据。这个启动通信的过程适用于所有TCP连接,因此对所有使用TCP的应用具有非常大的性能影响,因为每次传输应用数据之前,都必须经历一次完整的往返。 队首阻塞 丢包就丢包 事实上,丢包是让TCP达到最佳性能的关键。被删除的包恰恰是一种反馈机制,能够让接收端和发送端各自调整速度,以避免网络拥堵,同时保持延迟最短。另外,有些应用程序可以容忍丢失一定数量的包,比如语音和游戏状态通信,就不需要可靠传输或按序交付。 就算有个包丢了,音频编解码器只要在音频中插入一个小小的间歇,就可以继续处理后来的包。只要间歇够小,用户就注意不到,而等待丢失的包则可能导致音频输出产生无法预料的暂停。相对来说,后者的用户体验更糟糕。 类似地,更新3D游戏中角色的状态也一样:收到T时刻的包而等待T-1时刻的包通常毫无必要。理想情况下,应该可以接收所有状态更新,但为避免游戏延迟,间歇性的丢包也是可以接受的。 针对TCP的优化建议 TCP是一个自适应的、对所有网络节点一视同仁的、最大限制利用底层网络的协议。因此,优化TCP的最佳途径就是调整它感知当前网络状况的方式,根据它之上或之下的抽象层的类型和需求来改变它的行为。 服务器配置调优 在着手调整TCP的缓冲区、超时等数十个变量之前,最好先把主机操作系统升级到最新版本。TCP 的最佳实践以及影响其性能的底层算法一直在与时俱进,而且大多数变化都只在最新内核中才有实现。一句话,让你的服务器跟上时代是优化发送端和接收端TCP栈的首要措施。 有了最新的内核,我们推荐你遵循如下最佳实践来配置自己的服务器。 - 增大TCP的初始拥塞窗口 加大起始拥塞窗口可以让TCP在第一次往返就传输较多数据,而随后的速度提升也会很明显。对于突发性的短暂连接,这也是特别关键的一个优化。 - 慢启动重启 在连接空闲时禁用慢启动可以改善瞬时发送数据的长TCP连接的性能。 - 窗口缩放 启用窗口缩放可以增大最大接收窗口大小,可以让高延迟的连接达到更好吞吐量。 - TCP快速打开 在某些条件下,允许在第一个SYN分组中发送应用程序数据。TFO(TCP Fast Open,TCP 快速打开)是一种新的优化选项,需要客户端和服务器共同支持。为此,首先要搞清楚你的应用程序是否可以利用这个特性。 Linux用户可以使用ss来查看当前打开的套接字的各种统计信息。在命令行里运行ss –options –extended –memory –processes –info ,可以看到当前通信节点以及它们相应的连接设置。 UDP的构成 关于UDP的应用,最广为人知同时也是所有浏览器和因特网应用都赖以运作的,就是DNS(Domain Name System,域名系统)。 IETF和W3C工作组共同制定了一套新API WebRTC(Web Real-Time Communication,Web 实时通信)。WebRTC着眼于在浏览器中通过UDP实现原生的语音和视频实时通信,以及其他形式的 P2P(Peer-to-Peer,端到端)通信。 无协议服务 要理解为什么UDP被人称作“无协议”,必须从作为TCP和UDP下一层的IP协议说起。IP层的主要任务就是按照地址从源主机向目标主机发送数据报。为此,消息会被封装在一个IP分组内(图3-1),其中载明了源地址和目标地址,以及其他一些路由参数。注意,数据报这个词暗示了一个重要的信息:IP层不保证消息可靠的交付,也不发送失败通知,实际上是把底层网络的不可靠性直接暴露给了上一层。如果某个路由节点因为网络拥塞、负载过高或其他原因而删除了IP分组,那么在必要的情况下,IP 的上一层协议要负责检测、恢复和重发数据。 UDP协议会用自己的分组结构(图3-2)封装用户消息,它只增加 4个字段:源端口、目标端口、分组长度和校验和。这样,当IP把分组送达目标主机时,该主机能够拆开UDP分组,根据目标端口找到目标应用程序,然后再把消息发送过去。仅此而已。 事实上,UDP数据报中的源端口和校验和字段都是可选的。IP分组的首部也有校验和,应用程序可以忽略UDP校验和。也就是说,所有错误检测和错误纠正工作都可以委托给上层的应用程序。说到底,UDP仅仅是在IP层之上通过嵌入应用程序的源端口和目标端口,提供了一个“应用程序多路复用”机制。明白了这一点,就可以总结一下UDP的无服务是怎么回事了。 - 不保证消息交付 不确认,不重传,无超时。 - 不保证交付顺序 不设置包序号,不重排,不会发生队首阻塞。 - 不跟踪连接状态 不必建立连接或重启状态机。 - 不需要拥塞控制 不内置客户端或网络反馈机制。 TCP是一个面向字节流的协议,能够以多个分组形式发送应用程序消息,且对分组中的消息范围没有任何明确限制。因此,连接的两端存在一个连接状态,每个分组都有序号,丢失还要重发,并且要按顺序交付。相对来说,UDP数据报有明确的限制:数据报必须封装在IP分组中,应用程序必须读取完整的消息。换句话说,数据报不能分片。 UDP与网络地址转换器 作为监管全球IP地址分配的机构,IANA(Internet Assigned Numbers Authority,因特网号码分配机构)为私有网络保留了三段IP地址,这些IP地址经常可以在NAT设备后面的内网中看到。 保留的IP地址范围: IP地址范围 地址数量 10.0.0.0~10.255.255.255 | 16 777 216 | 172.16.0.0~172.31.255.255 | 1 048 576 | 192.168.0.0~192.168.255.255 | 65 536 | 为防止路由错误和引起不必要的麻烦,不允许给外网计算机分配这些保留的私有IP地址。 传输层安全(TLS) SSL(Secure Sockets Layer,安全套接字层)协议最初是网景公司为了保障网上交易安全而开发的,该协议通过加密来保护客户个人资料,通过认证和完整性检查来确保交易安全。为达到这个目标,SSL协议在直接位于TCP上一层的应用层被实现(图 4-1)。SSL不会影响上层协议(如HTTP、电子邮件、即时通讯),但能够保证上层协议的网络通信安全。 在正确使用SSL的情况下,第三方监听者只能推断出连接的端点、加密类型,以及发送数据的频率和大致数量,不能实际读取或修改任何数据。 加密、身份验证与完整性 Web 代理、中间设备、TLS 与新协议 HTTP 良好的扩展能力和获得的巨大成功,使得 Web 上出现了大量代理和中间设备:缓存服务器、安全网关、Web 加速器、内容过滤器,等等。有时候,我们知道这些设备的存在(显式代理),而有时候,这些设备对终端用户则完全不可见。 然而,这些服务器的存在及成功也给那些试图脱离 HTTP 协议的人带了一些不便。比如,有的代理服务器只会简单地转发自己无法解释的 HTTP 扩展或其他在线格式(wire format),而有的则不管是否必要都会对所有数据执行自己设定的逻辑,还有一些安全设备可能会把本来正常的数据误判成恶意通信。 换句话说,现实当中如果想脱离 HTTP 和 80 端口的语义行事,经常会遭遇各种部署上的麻烦。比如,某些客户端表现正常,另一些可能就会异常,甚至在某个网段表现正常的客户端到了另一个网段又会变得异常。 为解决这些问题,出现了一些新协议和对 HTTP 的扩展,比如 WebSocket、SPDY等。这些新协议一般要依赖于建立 HTTPS 信道,以绕过中间代理,从而实现可靠的部署,因为加密的传输信道会对所有中间设备都混淆数据。这样虽然解决了中间设备的问题,但却导致通信两端不能再利用这些中间设备,从而与这些设备提供的身份验证、缓存、安全扫描等功能失之交臂。 信任链与证书颁发机构 Web以及浏览器中的身份验证需要回答以下几个问题:我的浏览器信任谁?我在使用浏览器的时候信任谁?这个问题至少有三个答案。 - 手工指定证书 所有浏览器和操作系统都提供了一种手工导入信任证书的机制。至于如何获得证书和验证完整性则完全由你自己来定。 - 证书颁发机构 CA(Certificate Authority,证书颁发机构)是被证书接受者(拥有者)和依赖证书的一方共同信任的第三方。 - 浏览器和操作系统 每个操作系统和大多数浏览器都会内置一个知名证书颁发机构的名单。因此,你也会信任操作系统及浏览器提供商提供和维护的可信任机构。 实践中,保存并手工验证每个网站的密钥是不可行的(当然,如果你愿意,也可以)。现实中最常见的方案就是让证书颁发机构替我们做这件事(图 4-5):浏览器指定可信任的证书颁发机构(根CA),然后验证他们签署的每个站点的责任就转移到了他们头上,他们会审计和验证这些站点的证书没有被滥用或冒充。持有CA证书的站点的安全性如果遭到破坏,那撤销该证书也是证书颁发机构的责任。 所有浏览器都允许用户检视自己安全连接的信任链,常见的访问入口就是地址栏头儿上的锁图标,点击即可查看。 证书撤销 证书撤销名单(CRL) CRL(Certificate Revocation List,证书撤销名单)是RFC 5280规定的一种检查所有证书状态的简单机制:每个证书颁发机构维护并定期发布已撤销证书的序列号名 单。这样,任何想验证证书的人都可以下载撤销名单,检查相应证书是否榜上有名。如果有,说明证书已经被撤销了。 CRL文件本身可以定期发布、每次更新时发布,或通过HTTP或其他文件传输协议来提供访问。这个名单同样由证书颁发机构签名,通常允许被缓存一定时间。实践中,这种机制效果很好,但也存在一些问题: - CRL名单会随着要撤销的证书增多而变长,每个客户端都必须取得包含所有序列号的完整名单; - 没有办法立即更新刚刚被撤销的证书序列号,比如客户端先缓存了CRL,之后某证书被撤销,那到缓存过期之前,该证书将一直被视为有效。 在线证书状态协议(OCSP) 为解决CRL机制的上述问题,RFC 2560定义了OCSP(Online Certificate Status Protocol,在线证书状态协议),提供了一种实时检查证书状态的机制。与CRL包含被撤销证书的序列号不同,OCSP 支持验证端直接查询证书数据库中的序列号,从而验证证书链是否有效。总之,OCSP 占用带宽更少,支持实时验证。 然而,没有什么机制是完美无缺的!实时OCSP查询也带了一些问题: - 证书颁发机构必须处理实时查询; - 证书颁发机构必须确保随时随地可以访问; - 客户端在进一步协商之前阻塞OCSP请求; - 由于证书颁发机构知道客户端要访问哪个站点,因此实时OCSP请求可能会泄露客户端的隐私。 实践中,CRL和OCSP机制是互补存在的,大多数证书既提供指令也支持查询。 更重要的倒是客户端的支持和行为。有的浏览器会分发自己的CRL名单,有的浏览器从证书颁发机构取得并缓存CRL文件。类似地,有的浏览器会进行实时OCSP检查,但在OCSP请求失败的情况下行为又会有所不同。要了解具体的情况,可以检查浏览器和操作系统的证书撤销网络设置。 针对TLS的优化建议 TLS记录大小 不过对于在浏览器中运行的Web应用来说,倒是有一个值得推荐的做法:每个TCP分组恰好封装一个TLS记录,而TLS记录大小恰好占满TCP分配的MSS(Maximum Segment Size,最大段大小)。换句话说,一方面不要让TLS记录分成多个TCP分组,另一方面又要尽量在一条记录中多发送数据。以下数据可作为确定最优TLS记录大小的参考: - IPv4 帧需要 20 字节,IPv6 需要 40 字节; - TCP 帧需要 20 字节; - TCP 选项需要 40 字节(时间戳、SACK 等)。 假设常见的MTU为1500字节,则TLS记录大小在IPv4下是1420字节,在IPv6下是1400字节。为确保向前兼容,建议使用IPv6下的大小:1400字节。当然,如果MTU更小,这个值也要相应调小。 可惜的是,我们不能在应用层控制TLS记录大小。TLS记录大小通常是一个设置,甚至是TLS服务器上的编译时常量或标志。要了解具体如何设置这个值,请参考服务器文档。 如果服务器要处理大量TLS连接,那么关键的优化是把每个连接占用的内存量控制在最小。默认情况下,OpenSSL等常用的库会给每个连接分配50KB空间,但正像设置记录大小一样,有必要查一查文档或者源代码,然后再决定如何调整这个值。谷歌的服务器把OpenSSL缓冲区的大小减少到了大约5KB。 TLS压缩 TLS还有一个内置的小功能,就是支持对记录协议传输的数据进行无损压缩。压缩算法在TLS握手期间商定,压缩操作在对记录加密之前执行。然而,出于如下原因,实践中往往需要禁用服务器上的TLS压缩功能: - 2012 年公布的“CRIME”攻击会利用TLS压缩恢复加密认证cookie,让攻击者实施会话劫持; - 传输级的TLS压缩不关心内容,可能会再次压缩已经压缩过的数据(图像、视频等等)。 双重压缩会浪费服务器和客户端的CPU时间,而且暴露的安全漏洞也很严重,因此请禁用TLS压缩。实践中,大多数浏览器会禁用TLS压缩,但即便如此你也应该在服务器的配置中明确禁用它,以保护用户的利益。 虽然不能使用TLS压缩,但应该使用服务器的Gzip设置压缩所有文本资源,同时对图像、视频、音频等媒体采用最合适的压缩格式。 无线网络性能 移动网络的优化建议 消除周期性及无效的数据传输 对推送而言,原生应用可以访问平台专有的推送服务,因此应该尽可能使用。对 Web 应用来说,可以使用SSE(Server Sent Events,服务器发送事件)和WebSocket以降低延迟时间和协议消耗,尽可能不使用轮询和更耗资源的XHR技术。 消除不必要的长连接 TCP或UDP连接的连接状态及生命期与设备的无线状态是相互独立的。换句话说,即便与运营商网络仍维持着(两端间)连接不中断,无线模块也可以处于低耗电状态。外部网络的分组到来时,运营商无线网络会通知设备,使其无线模块切换到连接状态,从而恢复数据传输。 明白了吗,应用不必让无线模块“活动”也可以保持连接不被断开。但不必要的长连接也有可能极大地消耗电量,而且由于人们对移动网络无线通信的误解,这种情况经常发生。 预测网络延迟上限 在移动网络中,一个HTTP请求很可能会导致一连串长达几百甚至上几千ms的网络延迟。这一方面是因为有往返延迟,另一方面也不能忘记DNS、TCP、TLS及控制面的延迟(图8-2)。 HTTP HTTP 简史 HTTP 1.0:迅速发展及参考性RFC 今天,几乎所有Web服务器都支持,而且以后还会继续支持HTTP 1.0。除此之外,剩下的你都知道了。但HTTP 1.0对每个请求都打开一个新TCP连接严重影响性能。 HTTP 1.1:互联网标准 HTTP 1.1 标准厘清了之前版本中很多有歧义的地方,而且还加入了很多重要的性能优化:持久连接、分块编码传输、字节范围请求、增强的缓存机制、传输编码及请求管道。 HTTP 1.1 改变了HTTP协议的语义,默认使用持久连接。换句话说,除非明确告知(通过Connection: close 首部),否则服务器默认会保持连接打开。 不过,这个功能也反向移植到了HTTP 1.0,可以通过Connection: Keep-Alive 首部来启用。实际上,如果你使用的是HTTP 1.1,从技术上说不需要Connection: Keep-Alive首部,但很多客户端还是选择加上它。 此外,HTTP 1.1 协议添加了内容、编码、字符集,甚至语言的协商机制,还添加了传输编码、缓存指令、客户端cookie 等十几个可以每次请求都协商的字段。 HTTP 2.0:改进传输性能 HTTP(Hypertext Transfer Protocol)是一个应用层协议,可用于分布协作式的超媒体系统。它是一个通用、无状态的协议。除了超文本,通过扩展它的请求方式、错误编码及首部,还可以将它用于很多其他领域,比如域名服务器和分布式对象管理系统。HTTP的一个功能就是允许数据的类型变化和协商,从而允许系统独立于被传输的数据构建。——RFC 2616:HTTP/1.1(1999 年 6 月) 当前,出现了一种保持HTTP语义,但脱离HTTP/1.x消息分帧及语法的协议用法。这种用法被证明有碍于性能,并且是在鼓励滥用底层传输协议。本工作组将制定一个新规范,从有序、半双工流的角度重新表达当前HTTP的语义。与HTTP/1.x一样,主要将使用TCP作为传输层,不过也应该支持其他传输协议。——HTTP 2.0 纲领 (2012 年 1 月) HTTP 2.0 的主要目标是改进传输性能,实现低延迟和高吞吐量。主版本号的增加听起来像是要做大的改进,从性能角度说的确如此。但从另一方面看,HTTP的高层协议语义并不会因为这次版本升级而受影响。所有 HTTP 首部、值,以及它们的使用场景都不会变。 Web性能要点 剖析现代Web应用 速度、性能与用户期望 时间和用户感觉 时间 感觉 0 ~100 ms | 很快 | 100~300 ms | 有一点点慢 | 300~1000 ms | 机器在工作呢 | > 1000 ms | 先干点别的吧 | > 10000 ms | 不能用了 | 这个表格解释了Web性能社区总结的经验法则:必须250 ms内渲染页面,或者至少提供视觉反馈,才能保证用户不走开! HTTP 1.x HTTP 1.0的优化策略非常简单,就一句话:升级到HTTP 1.1。完了! 改进 HTTP 的性能是 HTTP 1.1 工作组的一个重要目标,后来这个版本也引入了大量增强性能的重要特性,其中一些大家比较熟知的有: - 持久化连接以支持连接重用; - 分块传输编码以支持流式响应; - 请求管道以支持并行请求处理; - 字节服务以支持基于范围的资源请求;  - 改进的更好的缓存机制。 HTTP管道 HTTP 1.x 只能严格串行地返回响应。特别是,HTTP 1.x 不允许一个连接上的多个响应数据交错到达(多路复用),因而一个响应必须完全返回后,下一个响应才会开始传输。为说明这一点,我们可以看看服务器并行处理请求的情况(图 11-4)。 图 11-4 演示了如下几个方面: - HTML 和 CSS 请求同时到达,但先处理的是 HTML 请求; - 服务器并行处理两个请求,其中处理 HTML 用时 40 ms,处理 CSS 用时 20 ms; - CSS 请求先处理完成,但被缓冲起来以等候发送 HTML 响应; - 发送完 HTML 响应后,再发送服务器缓冲中的 CSS 响应。 HTTP 管道会导致 HTTP 服务器、代理和客户端出现很多微妙的,不见文档记载的问题: - 一个慢响应就会阻塞所有后续请求; - 并行处理请求时,服务器必须缓冲管道中的响应,从而占用服务器资源,如果有个响应非常大,则很容易形成服务器的受攻击面; - 响应失败可能终止 TCP 连接,从页强迫客户端重新发送对所有后续资源的请求,导致重复处理; - 由于可能存在中间代理,因此检测管道兼容性,确保可靠性很重要; - 如果中间代理不支持管道,那它可能会中断连接,也可能会把所有请求串联起来。 今天,一些支持管道的浏览器,通常都将其作为一个高级配置选项,但大多数浏览器都会禁用它。换句话说,如果浏览器是 Web 应用的主要交付工具,那还是很难指望通过 HTTP 管道来提升性能。 要在你自己的应用中启用管道,要注意如下事项: - 确保 HTTP 客户端支持管道; - 确保 HTTP 服务器支持管道; - 应用必须处理中断的连接并恢复; - 应用必须处理中断请求的幂等问题; - 应用必须保护自身不受出问题的代理的影响。 实践中部署 HTTP 管道的最佳途径,就是在客户端和服务器间使用安全通道(HTTPS)。这样,就能可靠地避免那些不理解或不支持管道的中间代理的干扰。 使用多个TCP连接 由于 HTTP 1.x 不支持多路复用,浏览器可以不假思索地在客户端排队所有 HTTP请求,然后通过一个持久连接,一个接一个地发送这些请求。然而,这种方式在实践中太慢。实际上,浏览器开发商没有别的办法,只能允许我们并行打开多个 TCP会话。多少个?现实中,大多数现代浏览器,包括桌面和移动浏览器,都支持每个主机打开 6 个连接。 消耗客户端和服务器资源 限制每个主机最多 6 个连接,可以让浏览器检测出无意(或有意)的 DoS(Denial of Service)攻击。如果没有这个限制,客户端有可能消耗掉服务器的所有资源。讽刺的是,同样的安全检测在某些浏览器上却会招致反向攻击:如果客户端超过 了最大连接数,那么所有后来的客户端请求都将被阻塞。大家可以做个试验,在一个主机上同时打开 6 个并行下载,然后再打开第 7 个下载请求,这个请求会挂起,直到前面的请求完成才会执行。 用足客户端连接的限制似乎是一个可以接受的安全问题,但对于需要实时交付数据的应用而言,这样做越来越容易造成部署上的问题。比如 WebSocket、ServerSent Event 和挂起 XHR,这些会话都会占用整整一个 TCP 流,而不管有无数据传输——记住,没有多路复用一说!实际上,如果你不注意,那很可能自己对自己的应用施加 DoS 攻击。 域名分区 根据 HTTP Archive 的统计,目前平均每个页面都包含 90 多个独立的资源,如果这些资源都来自同一个主机,那么仍然会导致明显的排队等待。实际上,何必把自己只限制在一个主机上呢?我们不必只通过一个主机(例如 www.example.com)提供所有资源,而是可以手工将所有资源分散到多个子域名:{shard1,shardn}.example.com。由于主机名称不一样了,就可以突破浏览器的连接限制,实现更高的并行能力。域名分区使用得越多,并行能力就越强! 当然,天下没有免费的午餐,域名分区也不例外:每个新主机名都要求有一次额外的 DNS 查询,每多一个套接字都会多消耗两端的一些资源,而更糟糕的是,站点作者必须手工分离这些资源,并分别把它们托管到多个主机上。 实践中,把多个域名(如 shard1.example.com、shard2.example.com)解析到同一个 IP 地址是很常见的做法。所有分区都通过 CNAME DNS 记录指向同一个服务器,而浏览器连接限制针对的是主机名,不是 IP 地址。另外,每个分区也可以指向一个 CDN 或其他可以访问到的服务器。 DNS 查询和 TCP 慢启动导致的额外消耗对高延迟客户端的影响最大。换句话说,移动(3G、4G)客户端经常是受过度域名分区影响最大的! Cookie 在很多应用中都是常见的性能瓶颈,很多开发者都会忽略它给每次请求增加的额外负担。 计算图片对内存的需求 所有编码的图片经浏览器解析后都会以 RGBA 位图的形式保存于内存当中。每个RGBA 图片的像素需要占用 4 字节:红、绿、蓝通道各占 1 字节,Alpha(透明)通道占 1 字节。这样算下来,一张图片占用的内存量就是图片像素宽度 × 像素高度 ×4 字节。 举个例子,800×600 像素的位图会占多大内存呢? 800 × 600 × 4 B = 1 920 000 B ≈ 1.83 MB 在资源受限的设备,比如手机上,内存占用很快就会成为瓶颈。对于游戏等严重依赖图片的应用来说,这个问题就会更明显。 打包文件到底多大合适呢?可惜的是,没有理想的大小。然而,谷歌 PageSpeed团队的测试表明,30~50 KB(压缩后)是每个 JavaScript 文件大小的合适范围:既大到了能够减少小文件带来的网络延迟,还能确保递增及分层式的执行。具体的结果可能会由于应用类型和脚本数量而有所不同。 嵌入资源 嵌入资源是另一种非常流行的优化方法,把资源嵌入文档可以减少请求的次数。比如,JavaScript 和 CSS 代码,通过适当的 script 和 style 块可以直接放在页面中,而图片甚至音频或 PDF 文件,都可以通过数据 URI(data:[mediatype][;base64],data )的方式嵌入到页面中: 1 2 3 | 1x1 transparent (GIF) pixel | 前面的例子是在文档中嵌入了一个 1×1 的透明 GIF 像素。而任何 MIME类型,只要浏览器能理解,都可以通过类似方式嵌入到页面中,包括PDF、音频、视频。不过,有些浏览器会限制数据 URI 的大小,比如 IE8最大只允许 32 KB。 数据 URI 适合特别小的,理想情况下,最好是只用一次的资源。以嵌入方式放到页面中的资源,应该算是页面的一部分,不能被浏览器、CDN 或其他缓存代理作为单独的资源缓存。换句话说,如果在多个页面中都嵌入同样的资源,那么这个资源将 会随着每个页面的加载而被加载,从而增大每个页面的总体大小。另外,如果嵌入资源被更新,那么所有以前出现过它的页面都将被宣告无效,而由客户端重新从服务器获取。 最后,虽然 CSS 和 JavaScript 等基于文本的资源很容易直接嵌入页面,也不会带来多余的开销,但非文本性资源则必须通过 base64 编码,而这会导致开销明显增大:编码后的资源大小比原大小增大 33% ! base64 编码使用 64 个 ASCII 符号和空白符将任意字节流编码为 ASCII字符串。编码过程中,base64 会导致被编码的流变成原来的 4/3,即增大33% 的字节开销。 实践中,常见的一个经验规则是只考虑嵌入 1~2 KB 以下的资源,因为小于这个标准的资源经常会导致比它自身更高的 HTTP 开销。然而,如果嵌入的资源频繁变更,又会导致宿主文档的无效缓存率升高。嵌入资源也不是完美的方法。如果你的应用要使用很小的、个别的文件,在考虑是否嵌入时,可以参照如下建议: - 如果文件很小,而且只有个别页面使用,可以考虑嵌入; - 如果文件很小,但需要在多个页面中重用,应该考虑集中打包; - 如果小文件经常需要更新,就不要嵌入了; - 通过减少 HTTP cookie 的大小将协议开销最小化。 HTTP 2.0 HTTP 2.0 的目的就是通过支持请求与响应的多路复用来减少延迟,通过压缩 HTTP首部字段将协议开销降至最低,同时增加对请求优先级和服务器端推送的支持。 HTTP 2.0 不会改动 HTTP 的语义。… ### Java ForkJoin 解析 - URL: https://jiankunking.com/java-forkjoin.html - Content type: original - Published: 2019-08-10 - Updated: 2019-08-10 - Summary: 基于 OpenJDK 12 源码分析 ForkJoinPool 的任务窃取机制,以及 ForkJoinTask.join 的等待和协作执行过程。 - Categories: Java - Tags: Java, ForkJoin Article text: 文章速览 基于 OpenJDK 12 源码分析 ForkJoinPool 的任务窃取机制,以及 ForkJoinTask.join 的等待和协作执行过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文主要想了解两个地方:如何窃取任务、task如何等待(join) 代码基于 OpenJDK 12 窃取算法(work-stealing) 从ForkJoin-Paper-DougLea中可以看出: - 每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。 - 队列使用的是双端队列,支持LIFO、FIFO。 - 子任务会被放到线程(不一定是当前线程)的队列中。 - 工作线程按照LIFO的顺序处理自己队列中数据。 - 当一个工作线程处理完自己队列中数据的时候,会随机挑选一个工作线程,并“窃取”的该工作线程队列队尾的task。 到了这里就可以知道,窃取任务从其他线程队列的尾部窃取的了。 窃取算法优缺点 工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。 工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列。 Task 等待(join) Join方法的主要作用是阻塞当前线程并等待获取结果。具体代码如下: 1 2 3 4 5 6 | public final V join() { int s; if (((s = doJoin()) & ABNORMAL) != 0) reportException(s); return getRawResult(); } | 首先,它调用了doJoin()方法,通过doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有4种:已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)。 - 如果任务状态是已完成,则直接返回任务结果。 - 如果任务状态是被取消,则直接抛出CancellationException。 - 如果任务状态是抛出异常,则直接抛出对应的异常。 让我们再来分析一下doJoin()方法的实现代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | /** * Implementation for join, get, quietlyJoin. Directly handles * only cases of already-completed, external wait, and * unfork+exec. Others are relayed to ForkJoinPool.awaitJoin. * * @return status upon completion */ private int doJoin() { int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w; return //已完成,返回status (s = status) < 0 ? s : //未完成,如果当前线程是ForkJoinWorkerThread,从该线程中取出workQueue,并尝试将 //当前task出队然后执行,执行的结果是完成则返回状态,否则使用当线程池所在的ForkJoinPool的awaitJoin方法等待 ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ? (w = (wt = (ForkJoinWorkerThread)t).workQueue).tryUnpush(this) && (s = doExec()) < 0 ? s : wt.pool.awaitJoin(w, this, 0L) : //当前线程不是ForkJoinWorkerThread,调用externalAwaitDone方法 //externalAwaitDone: Blocks a non-worker-thread until completion. externalAwaitDone(); } /** * Pops the given task only if it is at the current top. */ final boolean tryUnpush(ForkJoinTask task) { boolean popped = false; int s, cap; ForkJoinTask[] a; if ((a = array) != null && (cap = a.length) > 0 && (s = top) != base && (popped = QA.compareAndSet(a, (cap - 1) & --s, task, null))) TOP.setOpaque(this, s); return popped; } /** * Primary execution method for stolen tasks. Unless done, calls * exec and records status if completed, but doesn't wait for * completion otherwise. * * @return status on exit from this method */ final int doExec() { int s; boolean completed; // 仅未完成的任务会运行,其他情况会忽略. if ((s = status) >= 0) { try { //exec是abstract方法 //调用ForkJoinTask子类中exec completed = exec(); } catch (Throwable rex) { completed = false; s = setExceptionalCompletion(rex); } if (completed) s = setDone(); } return s; } | 在doJoin()方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;如果没有执行完,则从任务队列中取出任务并执行。如果任务顺利执行完成,则设置任务状态为NORMAL,如果出现异常,则记录异常,并将任务状态设置为EXCEPTIONAL。 ### Java ThreadLocal - URL: https://jiankunking.com/java-threadlocal.html - Content type: original - Published: 2019-08-08 - Updated: 2019-08-08 - Summary: 基于OpenJDK 12分析ThreadLocal的实现原理,包括线程隔离机制、内存结构、OOM问题及正确使用方式。 - Categories: Java - Tags: Java, ThreadLocal Article text: 文章速览 基于OpenJDK 12分析ThreadLocal的实现原理,包括线程隔离机制、内存结构、OOM问题及正确使用方式。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 基于OpenJDK 12分析ThreadLocal的实现原理,包括线程隔离机制、内存结构、OOM问题及正确使用方式。 基于OpenJDK 12 引 本文主要想了解两个地方: - ThreadLocal实例看起来是在多个线程共享,但实际上是彼此独立的,这个是怎么实现的? - ThreadLocal使用不当真的会OOM吗?如果会,那么原因是啥? 先看一下ThreadLocal的官方API解释为: 该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本[原文:These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.]。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。 大概的意思有两点: - ThreadLocal提供了一种访问某个变量的特殊方式:访问到的变量属于当前线程,即保证每个线程的变量不一样,而同一个线程在任何地方拿到的变量都是一致的,这就是所谓的线程隔离。 - 如果要使用ThreadLocal,通常定义为private static类型,在我看来最好是定义为private static final类型。 看一段代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | // 代码来自: // http://tutorials.jenkov.com/java-concurrency/threadlocal.html public class ThreadLocalExample { public static class MyRunnable implements Runnable { private ThreadLocal threadLocal = new ThreadLocal(); @Override public void run() { //注意这里 set的值是run函数的内部变量,如果是MyRunnable的全局变量 //则无法起到线程隔离的作用 threadLocal.set((int) (Math.random() * 100D)); try { //sleep两秒的作用是让thread2 set操作在thread1的输出之前执行 //如果线程之间是共用threadLocal,则thread2 set操作会覆盖掉thread1的set操作 //从而两者的输出都是thread2 set的值 Thread.sleep(2000); } catch (InterruptedException e) { System.out.println(e); } System.out.println(threadLocal.get()); } } public static void main(String[] args) throws InterruptedException { MyRunnable sharedRunnableInstance = new MyRunnable(); Thread thread1 = new Thread(sharedRunnableInstance); Thread thread2 = new Thread(sharedRunnableInstance); thread1.start(); thread2.start(); thread1.join(); //wait for thread 1 to terminate thread2.join(); //wait for thread 2 to terminate } } | 输出结果: 1 2 3 4 5 6 | thread1 start thread2 start 38 thread1 join 78 thread2 join | MyRunnable run中sleep两秒的作用是让thread2 set操作在thread1的输出之前执行,如果线程之间是共用threadLocal,则thread2 set操作会覆盖掉thread1的set操作,两者的输出都是thread2 set的值,从而输出的应该是同一个值。 但从代码执行结果来看,thread1、thread2的threadLocal是不同的,也就是实现了线程隔离。 ThreadLocal实例在线程间是如何独立的? 看一眼ThreadLocal set方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public void set(T value) { //currentThread是个native方法,会返回对当前执行线程对象的引用。 Thread t = Thread.currentThread(); //getMap 返回线程自身的threadLocals ThreadLocalMap map = getMap(t); if (map != null) { //把value set到线程自身的ThreadLocalMap中了 map.set(this, value); } else { //线程自身的ThreadLocalMap未初始化,则先初始化,再set createMap(t, value); } } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } //Thread类中 //ThreadLocalMapset的set方法未执行深拷贝,需要注意传递值的类型 ThreadLocal.ThreadLocalMap threadLocals = null; | 从代码中可以看到,在set的时候,会根据Thread对象的引用来将值添加到各自线程中。但set的值value还是同一个对象,既然传递的是同一个对象,那就涉及到另一个问题:参数值传递、引用传递的问题了。 基本类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | public class ThreadLocalExample { public static class MyRunnable implements Runnable { private ThreadLocal threadLocal = new ThreadLocal<>(); // MyRunnable 全局变量 int random; @Override public void run() { random = (int) (Math.random() * 100D); threadLocal.set(random); try { Thread.sleep(2000); } catch (InterruptedException e) { System.out.println(e); } System.out.println(threadLocal.get()); } } public static void main(String[] args) throws InterruptedException { MyRunnable sharedRunnableInstance = new MyRunnable(); Thread thread1 = new Thread(sharedRunnableInstance); Thread thread2 = new Thread(sharedRunnableInstance); thread1.start(); System.out.println("thread1 start"); thread2.start(); System.out.println("thread2 start"); thread1.join(); //wait for thread 1 to terminate System.out.println("thread1 join"); thread2.join(); //wait for thread 2 to terminate System.out.println("thread2 join"); } } | 输出结果: 1 2 3 4 5 6 7 | thread1 start thread2 start //两个值不同 16 thread1 join 75 thread2 join | 从输出可以看出两者隔离了。 引用类型 全局引用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | public class ThreadLocalExample { public static class MyRunnable implements Runnable { private ThreadLocal threadLocal = new ThreadLocal<>(); // MyRunnable 全局变量 Obj obj = new Obj(); @Override public void run() { obj.value = (int) (Math.random() * 100D); threadLocal.set(obj); try { Thread.sleep(2000); } catch (InterruptedException e) { System.out.println(e); } System.out.println(((Obj) threadLocal.get()).value); } class Obj { int value; } } public static void main(String[] args) throws InterruptedException { MyRunnable sharedRunnableInstance = new MyRunnable(); Thread thread1 = new Thread(sharedRunnableInstance); Thread thread2 = new Thread(sharedRunnableInstance); thread1.start(); System.out.println("thread1 start"); thread2.start(); System.out.println("thread2 start"); thread1.join(); //wait for thread 1 to terminate System.out.println("thread1 join"); thread2.join(); //wait for thread 2 to terminate System.out.println("thread2 join"); } } | 输出结果: 1 2 3 4 5 6 7 | thread1 start thread2 start //两个值相同 36 36 thread1 join thread2 join | 从输出结果来看,当set操作的值是MyRunnable的全局变量,并且是引用类型的时候,无法起到隔离的作用。 局部引用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | public class ThreadLocalExample { public static class MyRunnable implements Runnable { private ThreadLocal threadLocal = new ThreadLocal<>(); //Obj obj = new Obj(); @Override public void run() { Obj obj = new Obj(); obj.value = (int) (Math.random() * 100D); threadLocal.set(obj); try { Thread.sleep(2000); } catch (InterruptedException e) { System.out.println(e); } System.out.println(((Obj) threadLocal.get()).value); } class Obj { int value; } } public static void main(String[] args) throws InterruptedException { MyRunnable sharedRunnableInstance = new MyRunnable(); Thread thread1 = new Thread(sharedRunnableInstance); Thread thread2 = new Thread(sharedRunnableInstance); thread1.start(); System.out.println("thread1 start"); thread2.start(); System.out.println("thread2 start"); thread1.join(); //wait for thread 1 to terminate System.out.println("thread1 join"); thread2.join(); //wait for thread 2 to terminate System.out.println("thread2 join"); } } | 输出结果: 1 2 3 4 5 6 7 | thread1 start thread2 start //两个值不同 12 19 thread1 join thread2 join | 从输出结果看,局部引用,可以相互隔离。 到这里可以看出ThreadLocal,只是把set值或引用绑定到了当前线程,但却没有进行相应的深拷贝,所以ThreadLocal要想做的线程隔离,必须是基本类型或者run的局部变量。 ThreadLocal OOM ? 看一下ThreadLocalMap内部Entry: 1 2 3 4 5 6 7 8 9 | static class Entry extends WeakReference> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal k, Object v) { super(k); value = v; } } | 从代码中看到,Entry继承了WeakReference,并将ThreadLocal设置为了WeakReference,value设置为强引用。也就是:当没有强引用指向ThreadLocal变量时,它可被回收。 但是,还有一个问题:ThreadLocalMap维护ThreadLocal变量与具体实例的映射,当ThreadLocal变量被回收后,该映射的key变为 null,而该Entry还是在ThreadLocalMap中,从而这些无法清理的Entry,会造成内存泄漏。 ThreadLocal自带的remove、set方法,都无法处理ThreadLocal自身为null的情况,因为代码中都直接取ThreadLocal的threadLocalHashCode属性了,所以如果ThreadLocal自身已经是null,这时调用remove、set会报空指针异常(java.lang.NullPointerException)的。 所以,在使用ThreadLocal的时候,在使用完毕记得remove(remove方法会将Entry的value及Entry自身设置为null并进行清理)。 JDK 12 ThreadLocal代码地址: https://github.com/jiankunking/openjdk12/blob/master/src/java.base/share/classes/java/lang/ThreadLocal.java ### [译]Java Concurrent Atomic Package详解 - URL: https://jiankunking.com/java-concurrent-atomic-package.html - Content type: translation - Published: 2019-08-04 - Updated: 2019-08-04 - Summary: 翻译介绍 java.util.concurrent.atomic 包中的原子类、CAS 操作及其在线程安全编程中的使用方式。 - Categories: Java - Tags: Java, Atomic, Concurrent - Original source: https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/package-summary.html#package.description The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Java JUC Atomic AtomicLong - URL: https://jiankunking.com/java-juc-atomic-atomiclong.html - Content type: original - Published: 2019-08-04 - Updated: 2019-08-04 - Summary: 基于 OpenJDK 12 源码分析 AtomicLong 的 CAS 累加机制,为理解 LongAdder 的分段设计建立对比基础。 - Categories: Java - Tags: Java, Atomic, Concurrent, AtomicLong Article text: 文章速览 基于 OpenJDK 12 源码分析 AtomicLong 的 CAS 累加机制,为理解 LongAdder 的分段设计建立对比基础。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 基于OpenJDK 12 本文的目的是为后续文章解析LongAdder做一个引子,以便两者对比。 Atomic Package解析参考(比如lazySet原理解析) AtomicLong的常用方法如下: - long addAndGet(long delta):以原子方式将输入的数值与实例中的值(AtomicLong里的 value)相加,并返回结果。 - compareAndSet(long expectedValue, long newValue):如果输入的数值等于预期值,则以原子方 式将该值设置为输入的值。 - long getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。 - void lazySet(long newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 - long getAndSet(long newValue):以原子方式设置为newValue的值,并返回旧值。 AtomicLong示例代码如下: 1 2 3 4 5 6 7 | public class AtomicLongTest { static AtomicLong ai = new AtomicLong(1); public static void main(String[] args) { System.out.println(ai.getAndIncrement()); System.out.println(ai.get()); } } | 输出结果如下: 1 2 | 1 2 | 那么getAndIncrement是如何实现原子操作的呢?让我们一起分析其实现原理,getAndIncrement的源码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | public final long getAndIncrement() { return U.getAndAddLong(this, VALUE, 1L); } @HotSpotIntrinsicCandidate public final long getAndAddLong(Object o, long offset, long delta) { long v; do { v = getLongVolatile(o, offset); } while (!weakCompareAndSetLong(o, offset, v, v + delta)); return v; } /** Volatile version of {@link #getLong(Object, long)} */ @HotSpotIntrinsicCandidate public native long getLongVolatile(Object o, long offset); @HotSpotIntrinsicCandidate public final boolean weakCompareAndSetLong(Object o, long offset, long expected, ong x) { return compareAndSetLong(o, offset, expected, x); } /** * Atomically updates Java variable to {@code x} if it is currently * holding {@code expected}. * *

This operation has memory semantics of a {@code volatile} read * and write. Corresponds to C11 atomic_compare_exchange_strong. * * @return {@code true} if successful */ @HotSpotIntrinsicCandidate public final native boolean compareAndSetLong(Object o, long offset, long expected, long x); | @HotSpotIntrinsicCandidate JDK的源码中,被@HotSpotIntrinsicCandidate标注的方法,在HotSpot中都有一套高效的实现,该高效实现基于CPU指令,运行时,HotSpot维护的高效实现会替代JDK的源码实现,从而获得更高的效率。 源码getAndAddLong(Object o, long offset, long delta)中do while循环体是实现的关键所在,其逻辑是: 第一步先取得AtomicLong里存储的数值, 第二步对AtomicLong的当前数值进行加1操作, 第三步调用weakCompareAndSetLong方法来进行原子更新操作,该方法先检查当前数值是否等于v,等于意味着AtomicLong的值没有被其他线程修改过,则将weakCompareAndSetLong的当前数值更新成v+delta的值,如果不等于v,weakCompareAndSetLong方法会返回false,程序会进入do while循环重新进行weakCompareAndSetLong操作。 这里隐含了一个问题,当对于共享变量(假设变量名字是a)的竞争非常激烈的时候,在当前线程读取a、改变a之间,a的值会被别的线程改变,从而导致当前线程一直重试(自旋),一直占用CPU。 这就引出另一个问题,对于锁抢占很激烈的时候,串行是最好的解决办法。比如使用synchronized。 java.util.concurrent.atomic中的原子操作基本是基于Unsafe或者VarHandle实现的。 AtomicLong源码 本文参考 《Java并发编程的艺术》 作者:方腾飞 魏鹏 程晓明 ### Java JUC Atomic LongAdder - URL: https://jiankunking.com/java-juc-atomic-longadder.html - Content type: original - Published: 2019-08-04 - Updated: 2019-08-04 - Summary: 基于OpenJDK 12分析LongAdder的实现原理,高并发场景下比AtomicLong性能更好,通过分段累加减少竞争。 - Categories: Java - Tags: Java, Atomic, Concurrent, LongAdder Article text: 文章速览 基于OpenJDK 12分析LongAdder的实现原理,高并发场景下比AtomicLong性能更好,通过分段累加减少竞争。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 基于OpenJDK 12分析LongAdder的实现原理,高并发场景下比AtomicLong性能更好,通过分段累加减少竞争。 基于OpenJDK 12 阅读本文前,推荐先阅读以下两篇文章,以便能更好的对比理解: - 译-Java-Concurrent-Atomic-Package-详解 - Java-JUC-Atomic-AtomicLong LongAdder是JDK 1.8 新增的原子类,基于Striped64实现。 从官方文档看,LongAdder在高并发的场景下会比AtomicLong 具有更好的性能,代价是消耗更多的内存空间: This class is usually preferable to AtomicLong when multiple threads update a common sum that is used for purposes such as collecting statistics, not for fine-grained synchronization control. Under low update contention, the two classes have similar characteristics. But under high contention, expected throughput of this class is significantly higher, at the expense of higher space consumption. 那么LongAdder是怎么实现的? 先看一下LongAdder的类图: 基类Number,Number是一个抽象类其中没有任何逻辑,该类是byte、double、float、int、long、short的基类。 Striped64 设计思想 该部分翻译自Striped64源码注释,可以略过,概括起来就是: 分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。 从Striped64类注释可以看到: Striped64是package内使用的,对于在64位元素上动态分片提供统一实现(感觉有点像:AbstractQueuedSynchronizer) Striped64继承了Number类,这也就是说具体实现的子类也必须实现相关的内容 该类维护一个原子更新变量的延迟初始化表,以及一个额外的“base”字段。表的大小是2的幂。索引使用掩码下的每个线程的hash code。这个类中的几乎所有声明都是package私有的,由子类直接访问。 表格内的元素是Cell类,Cell类是一个为了减少缓存争用而填充的AtomicLong的变种。填充对于大多数原子来说是多余的,因为它们通常不规则地分散在内存中,因此彼此之间不会有太多的干扰。但是,驻留在数组中的原子对象往往是彼此相邻的,因此在没有这种预防措施的情况下,最常见的情况是共享高速缓存线(这对性能有很大的负面影响)。 在某种程度上,因为Cell类相对较大,只有他们真正被需要的时候,我们才创建。如果没有竞争,那么所有的更新操作将对base字段实现。当发生第一次争用(也就是说如果第一次对base字段的CAS操作失败),初始化为大小是2的表格。当进一步的争用发生的时候,表的大小会加倍,直到达到等于大于cpu的数量。表在未使用之前一直为null。 利用一个自旋锁(cellsBusy)来初始化和调整表的大小,以及用新Cells填充slots。这个地方没有必要使用阻塞锁,如果锁不可达,线程可以尝试其他的slots,或者尝试base字段。在这些重试期间,竞争加剧,但是降低了局部性,这仍然比阻塞锁来得好。 通过ThreadLocalRandom维护的Thread.probe字段用作每个线程的哈希码。我们让它们保持未初始化(为零)(如果它们以这种方式出现),直到它们在插槽0竞争。出现竞争后初始化为通常不会和其他的的值冲突的值,比如线程的哈希码。在执行更新时发生CAS操作失败意味着出现了争用或者表碰撞,或两者都有。在发生冲突时,如果表的大小小于容量,那么它的大小将加倍,除非其他线程持有锁。如果哈希后的slot为空,并且锁可用,则创建一个新单元格。如果存在了那么会进行CAS尝试。通过双重哈希进行重试,利用一个辅助哈希(Marsaglia XorShift随机数算法)来尝试寻找一个空闲的slot。 表的大小是有上限的,因为当线程多于CPU时,假设每个线程都绑定到一个CPU,就会有一个完美的散列函数将线程映射到插槽,从而消除冲突。当我们达到容量时,我们通过随机改变冲突线程的哈希代码来搜索这个映射。因为搜索是随机的,冲突只有通过CAS失败才知道,收敛可能会很慢,而且因为线程通常不会永远绑定到CPU,所以根本不会发生。然而,尽管有这些限制,在这些情况下观察到的竞争率通常很低。 Cell可能会出现不可用的情况,包括进行哈希的线程终止,或者由于table扩容导致线程哈希不正确。我们不尝试检测或删除这样的单元格,假设对于长时间运行的实例,争用会再次发生,因此最终将再次需要这些单元格;而对于短时间运行的实例,花费时间去销毁又没有什么必要。 Cell类 Atomiclong的变体,仅支持原始访问和CAS。 Cell类被注解@jdk.internal.vm.annotation.Contended修饰。 Contended的作用(详细信息参见:JEP 142): Define a way to specify that one or more fields in an object are likely to be highly contended across processor cores so that the VM can arrange for them not to share cache lines with other fields, or other objects, that are likely to be independently accessed. 示意图 具体实现 Striped64的核心方法是longAccumulate、doubleAccumulate,两者类似,下面主要看一下longAccumulate,对于这种代码,个人建议是理解思路即可,毕竟咱们又不是过来修改JDK的,如果真的要修改了或者有类似的需求了,再回来细看即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | //x 元素 //fn 更新函数,如果是add可以为null(这个约定避免了longadder中定义额外的变量或者函数) //wasUncontended 如果CAS在调用之前失败了,这个值为false final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) { int h; //获取当前线程的probe值,如果为0,则需要初始化该线程的probe值 if ((h = getProbe()) == 0) { ThreadLocalRandom.current(); // force initialization h = getProbe(); wasUncontended = true; } boolean collide = false; // True if last slot nonempty done: for (;;) { Cell[] cs; Cell c; int n; long v; //Cells不为空,进行操作 if ((cs = cells) != null && (n = cs.length) > 0) { //通过(hashCode & (length - 1))这种算法来实现取模 有种看到HashMap代码的感觉 //如果当前位置为null说明需要初始化 if ((c = cs[(n - 1) & h]) == null) { //判断锁状态 if (cellsBusy == 0) { // Try to attach new Cell Cell r = new Cell(x); // Optimistically create //再次判断锁状态,同时获取锁 if (cellsBusy == 0 && casCellsBusy()) { try { // Recheck under lock Cell[] rs; int m, j; if ((rs = cells) != null && (m = rs.length) > 0 && rs[j = (m - 1) & h] == null) { rs[j] = r; //创建成功跳出 break done; } } finally { //释放锁 cellsBusy = 0; } continue; // Slot is now non-empty } } collide = false; } //运行到此说明cell的对应位置上已经有相应的Cell了, //不需要初始化了 //CAS操作已经失败了,出现了竞争 else if (!wasUncontended) // CAS already known to fail wasUncontended = true; // Continue after rehash //这里尝试将x值加到a的value上 else if (c.cas(v = c.value, (fn == null) ? v + x : fn.applyAsLong(v, x))) //如果尝试成功,跳出循环,方法退出 break; //cell数组最大为cpu的数量, //cells != as表明cells数组已经被更新了 //标记为最大状态或者说是过期状态 else if (n >= NCPU || cells != cs) collide = false; // At max size or stale else if (!collide) collide = true; //扩容 当前容量 * 2 else if (cellsBusy == 0 && casCellsBusy()) { try { if (cells == cs) // Expand table unless stale cells = Arrays.copyOf(cs, n << 1); } finally { cellsBusy = 0; } collide = false; continue; // Retry with expanded table } h = advanceProbe(h); } //尝试获取锁之后扩大Cells else if (cellsBusy == 0 && cells == cs && casCellsBusy()) { try { // Initialize table if (cells == cs) { //初始化cell表,初始容量为2。 Cell[] rs = new Cell[2]; rs[h & 1] = new Cell(x); cells = rs; break done; } } finally { //释放cellsBusy锁 cellsBusy = 0; } } //如果创建cell表由于竞争导致失败,尝试将x累加到base上 // Fall back on using base else if (casBase(v = base, (fn == null) ? v + x : fn.applyAsLong(v, x))) break done; } } /** * CASes the cellsBusy field from 0 to 1 to acquire lock. */ final boolean casCellsBusy() { return CELLSBUSY.compareAndSet(this, 0, 1); } /** * CASes the base field. */ final boolean casBase(long cmp, long val) { return BASE.compareAndSet(this, cmp, val); } | 这一段的核心是这样的: - longAccumulate会根据当前线程来计算一个哈希值,然后根据(hashCode & (length - 1))取模,以定位到该线程被分散到的Cell数组中的位置 - 如果Cell数组还没有被创建,那么就去获取cellBusy这个锁(相当于锁,但是更为轻量级),如果获取成功,则初始化Cell数组,初始容量为2,初始化完成之后将x包装成一个Cell,哈希计算之后分散到相应的index上。如果获取cellBusy失败,那么会试图将x累计到base上,更新失败会重新尝试直到成功。 - 如果Cell数组已经被初始化过了,那么就根据线程的哈希值分散到一个Cell数组元素上,获取这个位置上的Cell并且赋值给变量a,如果a为null,说明该位置还没有被初始化,那么就初始化,当然在初始化之前需要竞争cellBusy变量。 - 如果Cell数组的大小已经最大了(大于等于CPU的数量),那么就需要重新计算哈希,来重新分散当前线程到另外一个Cell位置上再走一遍该方法的逻辑,否则就需要对Cell数组进行扩容,然后将原来的计数内容迁移过去。由于Cell里面保存的是计数值,所以扩容后没有必要做其他处理,直接根据index将旧的Cell数组内容复制到新的Cell数组中。 LongAdder LongAdder的基本思路就是分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。 说明 保持一个或者多个变量,初始值设置为零用于求和。当更新出现多个线程竞争时,变量集合动态增长以减少争用。最后当需要求和的时候或者说需要这个Long型的值时,可以通过把当前这些变量求和,合并后得出最终的和。 LongAdder在一些高并发场景下表现要比AtomicLong好,比如多个线程同时更新一个求和的变量,比如统计集合的数量,但是不能用于细粒度同步控制,换句话说这个是可能有误差的(因为更新与读取是并行的)。在低并发场景场景下LongAdder和AtomicLong的性能表现没什么差别,但是当高并发竞争的时候,这个类将具备更好的吞吐性能,但是相应的也会耗费相当的空间。 LongAdder继承了Number抽象类,但是并没有实现一些方法例如: equals、hashCode、compareTo,因为LongAdder实例的预期用途是进行一些比较频繁的变化,所以也不适合作为集合的key。 具体实现 看一下LongAdder有哪些方法: 下面主要解析LongAdder increment、sum方法,先看一下源码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | /** * Equivalent to {@code add(1)}. */ public void increment() { add(1L); } /** * Adds the given value. * * @param x the value to add */ public void add(long x) { Cell[] cs; long b, v; int m; Cell c; if ((cs = cells) != null || !casBase(b = base, b + x)) { //到了这里 表明cs不为null or 线程有并发冲突,导致caseBase失败 boolean uncontended = true; if (cs == null || // cells 为null (m = cs.length - 1) < 0 || // cells 不为null 但只有一个元素 (c = cs[getProbe() & m]) == null || //哈希取模 对应位置元素为null !(uncontended = c.cas(v = c.value, v + x))) //cas 替换失败(并发竞争) longAccumulate(x, null, uncontended); } } /** * CASes the base field (Striped64类中的方法) */ final boolean casBase(long cmp, long val) { return BASE.compareAndSet(this, cmp, val); } //当在sum的过程中,有可能别的线程正在操作cells(因为没有加锁) //sum取的值,不一定准确 public long sum() { Cell[] cs = cells; long sum = base; if (cs != null) { for (Cell c : cs) if (c != null) sum += c.value; } return sum; } | LongAdder vs AtomicLong Performance Java 8 Performance Improvements: LongAdder vs AtomicLong 对比LongAccumulator LongAdder类可以看做是LongAccumulator的一个特例,LongAccumulator提供了比LongAdder更强大、灵活的功能。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /** * Creates a new instance using the given accumulator function * and identity element. * @param accumulatorFunction a side-effect-free function of two arguments * @param identity identity (initial value) for the accumulator function */ public LongAccumulator(LongBinaryOperator accumulatorFunction, long identity) { this.function = accumulatorFunction; base = this.identity = identity; } @FunctionalInterface public interface LongBinaryOperator { /** * Applies this operator to the given operands. * * @param left the first operand * @param right the second operand * @return the operator result */ long applyAsLong(long left, long right); } | 构造函数其中accumulatorFunction一个双目运算接口,根据输入的两个参数返回一个计算值,identity则是LongAccumulator累加器的初始值。 accumulatorFunction主要用于Striped64 longAccumulate中使用,如果fn==null,则默认是相加,否则会调用fn.applyAsLong(v, x) LongAccumulator相比于LongAdder,可以为累加器提供非0的初始值,而LongAdder只能提供默认的0值。 另外,LongAccumulator还可以指定累加规则,比如累加或者相乘,只需要在构造LongAccumulator时,传入自定义的双目运算器即可,后者则内置累加规则。 Reference https://github.com/jiankunking/openjdk12/blob/master/src/java.base/share/classes/java/util/concurrent/atomic/LongAdder.java https://github.com/jiankunking/openjdk12/blob/master/src/java.base/share/classes/jdk/internal/vm/annotation/Contended.java https://www.jianshu.com/p/9a7de5644dd4 http://openjdk.java.net/jeps/142 http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html ### JRockit权威指南深入理解JVM 笔记 - URL: https://jiankunking.com/java-jrockit.html - Content type: original - Published: 2019-08-03 - Updated: 2019-08-03 - Summary: 《JRockit权威指南》学习笔记,整理 JVM 内存管理、垃圾回收、运行时机制和性能诊断等核心内容。 - Categories: Java - Tags: Reading Notes, Java, JVM, JRockit Article text: 文章速览 《JRockit权威指南》学习笔记,整理 JVM 内存管理、垃圾回收、运行时机制和性能诊断等核心内容。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自:《JRockit权威指南深入理解JVM》 作者:Marcus Hirt , Marcus Lagergren 出版时间:2018-12-10 起步 将应用程序迁移到JRockit 命令行选项 在JRockit JVM中,主要有3类命令行选项,分别是系统属性、标准选项(以-X开头)和非标准选项(以-XX开头)。 1、系统属性 设置JVM启动参数的方式有多种。以-D开头的参数会作为系统属性使用,这些属性可以为Java类库(如RMI等)提供相关的配置信息。例如,在启动的时候,如果设置了-Dcom.Rockin.mc.debug=true参数,则JRockit Mission Control会打印出调试信息。不过,R28之后的JRockit JVM版本废弃了很多之前使用过的系统属性,转而采用非标准选项和类似 HotSpot中虚拟机标志(VM flag)的方式设置相关选项。 2、标准选项 以-X开头的选项是大部分JVM厂商都支持的通用设置。例如,用于设置堆大小最大值的选项-Xmx在包括 JRockit在内的大部分JVM中都是相同的。当然,也存在例外,如JRockit中的选项-Xverbose会打印出可选的子模块日志信息,而在 HotSpot中,类似的(但实际上有更多的限制)选项是-verbose。 3、非标准选项 以-XX开头的命令行选项是各个JVM厂商自己定制的。这些选项可能会在将来的某个版本中被废弃或修改。如果JVM的参数配置中包含了以-XX开头的命令行选项,则在将Java应用程序从一种JVM迁移到另一种时,应该在启动M之前去除这些非标准选项确定了新的VM选项后才可以启动Java应用程序。 自适应代码生成 Java虚拟机 字节码格式 Opcodes for the Java Virtual Machine 常量池 程序,包含数据和代码两部分,其中数据作为操作数使用。对于字节码程序来说,如果操作数非常小或者很常用(如常量0),则这些操作数是直接内嵌在字节码指令中的。 较大块的数据,例如常量字符串或比较大的数字,是存储在class文件开始部分的常量池(constant pool)中的。当使用这类数据作为操作数时,使用的是常量池中数据的索引位置,而不是实际数据本身。 此外,Java程序中的方法、属性和类的元数据等也作为clas文件的组成部分,存储在常量池中。 自适应代码生成 优化动态程序 在汇编代码中,方法调用是通过call指令完成的。不同平台上call指令的具体形式不尽相同,不同类型的call指令,其具体格式也不尽相同。 在面向对象的语言中,虚拟方法分派通常被编译为对分派表(dispatch table)中地址的间接调用(indirect call,即需要从内存中读取真正的调用地址)。这是因为,根据不同的类继承结构分派虚拟调用时可能会有多个接收者。每个类中都有一个分派表,其中包含了其虚拟调用的接收者信息。静态方法和确知只有一个接收者的虚拟方法可以被编译为对固定调用地址的直接调用(direct call)。一般来说,这可以大大加快执行速度。 假设应用程序是使用C++开发的,对代码生成器来说,在编译时已经可以获取到程序的所有结构性信息。例如,由于在程序运行过程中,代码不会发生变化,所以在编译时就可以从代码中判断出,某个虚拟方法是否只有一种实现。正因如此,编译器不仅不需要因为废弃代码而记录额外的信息,还可以将那些只有一种实现的虚拟方法转化为静态调用。 假如应用程序是使用Java开发的,起初某个虚拟方法可能只有一种实现,但Java允许在程序运行过程中修改方法实现。当JIT编译器需要编译某个虚拟方法时,更喜欢的是那些永远只存在一种实现的,这样编译器就可以像前面提到的C++编译器一样做很多优化,例如将虚拟调用转化为直接调用。但是,由于Java允许在程序运行期间修改代码,如果某个方法没有声明final修饰符,那它就有可能在运行期间被修改,即使它看起来几乎不可能有其他实现,编译器也不能将之优化为直接调用。 在Java世界中,有一些场景现在看起来一切正常,编译器可以大力优化代码,但是如果某天程序发生了改变的话,就需要将相关的优化全部撤销。对于Java来说,为了能够媲美C++程序的执行速度,就需要一些特殊的优化措施。 JVM使用的策略就是“赌”。JVM代码生成策略的假设条件是,正在运行的代码永远不变。事实上,大部分时间里确实如此。但如果正在运行的代码发生了变化,违反了代码优化的假设条件,就会触发其簿记系统(bookkeeping system)的回调功能。此时,基于原先假设条件生成的代码就需要被废弃掉,重新生成,例如为已经转化为直接调用的虚拟调用重新生成相关代码。因此,“赌输”的代价是很大的,但如果“赌赢”的概率非常高,则从中获得的性能提升就会非常大,值得一试。 一般来说,JVM和JIT编译器所做的典型假设包括以下几点: - 虚拟方法不会被覆盖。由于某个虚拟方法只存在一种实现,就可以将之优化为一个直接调用。 - 浮点数的值永远不会是NaN。大部分情况下,可以使用硬件指令来替换对本地浮点数函数库的调用。 - 某些try语句块中几乎不会抛出异常。因此,可以将catch语句块中的代码作为冷方法对待。 - 对于大多数三角函数来说,硬件指令fsin都能够达到精度要求。如果真的达不到,就抛出异常,调用本地浮点数函数库完成计算。 - 锁竞争并不会太激烈,初期可以使用自旋锁(spinlock)替代。 - 锁可能会周期性地被同一个线程获取和释放,所以,可以将对锁的重复获取操作和重复释放操作直接省略掉。 深入JIT编译器 优化字节码 有些时候,对Java源代码做优化会适得其反。绝大部分写出可读性很差的代码的人都声称是为了优化性能,其实就是照着一些基准测试报告的结论写代码,而这些性能测试往往只涉及了字节码解释执行,没有经过JIT编译器优化,所以并不能代表应用程序在运行时的真实表现。例如,某个服务器端应用程序中包含了大量对数组元素的迭代访问操作,程序员参考了那些报告中的结论,没有设置循环条件,而是写一个无限for循环,置于try语句块中,并在catch语句块中捕获ArrayIndexOutOfBoundsException异常。这种糟糕的写法不仅使代码可读性极差,而且一旦运行时对之优化编译的话,其执行效率反而比普通循环方式低得多。原因在于,JVM的基本假设之一就是“异常是很少发生的”。基于这种假设,JVM会做一些相关优化,所以当真的发生异常时,处理成本就很高。 代码流水线 代码生成概述 在生成优化代码时,如何分配寄存器非常重要。编译器教材上都将寄存器分配问题作为图的着色问题处理,这是因为同时用到的两个变量不能共享同一个寄存器,从这点上讲,与着色问题相同。同时使用的多个变量可以用图中相连接的节点来表示,这样,寄存器分配问题就可以被抽象为“如何为图中的节点着色,才能使相连节点有不同的颜色”。这里可用颜色的数量等于指定平台上可用寄存器的数量。不过,遗憾的是,从计算复杂性上讲,着色问题是NP-hard的,也就是说现在还没有一个高效的算法(指可以在多项式时间内完成计算)能解决这个问题。但是,着色问题可以在线性对数时间内给出近似解,因此大多数编译器都使用着色算法的某个变种来处理寄存器分配问题。 自适应内存管理 堆管理基础 对象的分配与释放 一般来说,为对象分配内存时,并不会直接在堆上划分内存,而是先在线程局部缓冲(thread local buffer)或其他类似的结构中找地方放置对象,然后随着应用程序的运行、新对象的不断分配,垃圾回收逐次执行,这些对象可能最终会被提升到堆中保存,也有可能会当作垃圾被释放掉。 为了能够在堆中给新创建的对象找一个合适的位置,内存管理系统必须知道堆中有哪些地方是空闲的,即还没有存活对象占用。内存管理系统使用空闲列表(free list)—串联起内存中可用内存块的链表,来管理内存中可用的空闲区域,并按照某个维度的优先级排序。 在空闲列表中搜索足够存储新对象的空闲块时,可以选择大小最适合的空闲块,也可以选择第一个放得下的空闲块。这其中会用到几种不同的算法去实现,各有优劣,后文会详细讨论。 垃圾回收算法 在后文中,根集合(root set)专指上述搜索算法的初始输入集合,即开始执行引用跟踪时的存活对象集合。一般情况下,根集合中包括了因为执行垃圾回收而暂停的应用程序的当前栈帧中所有的对象,包含了可以从当前线程上下文的用户栈和寄存器中能得到的所有信息。此外,根集合中还包含全局数据,例如类的静态属性。简单来说就是,根集合中包含了所有无须跟踪引用就可以得到的对象。 Java使用的是准确式垃圾回收器(exact garbage collector),可以将对象指针类型数据和其他类型的数据区分开,只需要将元数据信息告知垃圾回收器即可,这些元数据信息,一般可以从Java方法的代码中得到。 近些年,使用信号来暂停线程的方式受到颇多争议。实践发现,在某些操作系统上,尤以Linux为例,应用程序对信号的使用和测试很不到位,还有一些第三方的本地库不遵守信号约定,导致信号冲突等事件的发生。因此,与信号相关的外部依赖已经不再可靠。 分代垃圾回收 事实上,将堆划分为两个或多个称为代(generation)的空间,并分别存放具有不同长度生命周期的对象,可以提升垃圾回收的执行效率。在JRockit中,新创建(young)的对象存放在称为新生代(nursery)的空间中,一般来说,它的大小会比老年代(old collections)小很多,随着垃圾回收的重复执行,生命周期较长的对象会被提升(promote)到老年代中。因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。 新生代垃圾回收的速度比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无须提升到老年代。理想情况下,新生代垃圾回收可以大大提升系统的吞吐量,并消除潜在的内存碎片。 写屏障 在实现分代式垃圾回收时,大部分JVM都是用名为写屏障(write barrier)的技术来记录执行垃圾回收时需要遍历堆的哪些部分。当对象A指向对象B时,即对象B成为对象A的属性的值时,就会触发写屏障,在完成属性域赋值后执行一些辅助操作。 写屏障的传统实现方式是将堆划分成多个小的连续空间(例如每块512字节),每块空间称为卡片(card),于是,堆被映射为一个粗粒度的卡表(card table)。当Java应用程序将某个对象赋值给对象引用时,会通过写屏障设置脏标志位(dirty bit),将该对象所在的卡片标记为脏。 这样,遍历从老年代指向新生代的引用时间得以缩短,垃圾回收器在做新生代垃圾回收时只需要检查老年代中被标记为脏的卡片所对应的内存区域即可。 JRockit中的垃圾回收 老年代垃圾回收 JRockit不仅将卡表应用于分代式垃圾回收,还用在并发标记阶段结束时的清理工作,避免搜索整个存活对象图。这是因为JRockit需要找出在执行并发标记操作时,应用程序又创建了哪些对象。修改引用关系时通过写屏障可以更新卡表,存活对象图中的每个区域使用卡表中的一个卡片表示,卡片的状态可以是干净或者脏,有新对象创建或者对象引用关系修改了的卡片会被标记为脏。在并发标记阶段结束时,垃圾回收器只需要检查那些标记为脏的卡片所对应的堆中区域即可,这样就可以找到在并发标记期间新创建的和被更新过引用关系的对象。 性能与伸缩性 线程局部分配 在JRockit中,使用了名为线程局部分配(thread local allocation)的技术来大幅加速对象的分配过程。正常情况下,在线程内的缓冲区中为对象分配内存要比直接在需要同步操作的堆上分配内存快得多。垃圾回收器在堆上直接分配内存时是需要对整个堆加锁的,对于多线程竞争激烈的应用程序来说,这将会是一场灾难。因此,如果每个Java线程能够有一块局部对象缓冲区那么绝大部分的对象分配操作只需要移动一下指针即可完成,在大多数硬件平台上,只需要一条汇编指令就行了。这块转为分配对象而保留的区域,就称为线程局部缓冲区(thread local area,TLA)。 为了更好地利用缓存,达到更高的性能,一般情况下,TLA的大小介于16KB到128KB之间,当然,也可以通过命令行参数显式指定。当TLA被填满时,垃圾回收器会将TLA中的内容提升到堆中。因此,可以将TLA看作是线程中的新生代内存空间。 当Java源代码中有new操作符,并且JIT编译器对内存分配执行高级优化之后,内存分配的伪代码如下所示: 1 2 3 4 5 6 7 8 9 10 11 | object allocateNewobject(Class objectclass){ Thread current getcurrentThread(): int objectSize=alignedSize(objectclass) if(current.nextTLAOffset+objectSize> TLA_SIZE){ current.promoteTLAToHeap();//慢,而且是同步操作 current.nextTLAOffset=0; } Object ptr= current.TLAStart+current.nextTLAOffset: current.nextTLAOffset + objectSize; return ptr: } | 为了说明内存分配问題,在上面的伪代码中省略了很多其他关联操作。例如如果待分配的对象非常大,超过了某个阈值,或对象太大导致无法存放在TLA中,则会直接在堆中为对象分配内存。 NUMA架构 NUMA(non-uniform memory access,非统一内存访问模型)架构的出现为垃圾回收带来了更多挑战。在NUMA架构下,不同的处理器核心通常访问各自的内存地址空间,这是为了避免因多个CPU核心访问同一内存地址造成的总线延迟。每个CPU核心都配有专用的内存和总线,因此CPU核心在访问其专有内存时速度很快,而要访问相邻CPU核心的内存时就会相对慢些,CPU核心相距越远,访问速度越慢(也依赖于具体配置)传统上,多核CPU是按照UMA(uniform memory access,统一内存访问模型)架构运行的,所有的CPU核心按照统一的模式无差别地访问所有内存。 为了更好地利用NUMA架构,垃圾回收器线程的组织结构应该做相应的调整。如果某个CPU核心正在运行标记线程,那么该线程所要访问的那部分堆内存最好能够放置在该CPU的专有内存中,这样才能发挥NUMA架构的最大威力。在最坏情况下,如果标记线程所要访问的对象位于其他NUMA节点的专有内存中,这时垃圾回收器通常需要一个启发式对象移动算法。这是为了保证使用时间上相近的对象在存储位置上也能相近,如果这个算法能够正确工作,还是可以带来不小的性能提升的。这里所面临的主要问题是如何避免对象在不同NUMA节点的专有内存中重复移动。理论上,自适应运行时系统应该可以很好地处理这个问题。 大内存页 内存分配是通过操作系统及其所使用的页表完成的。操作系统将物理内存划分成多个页来管理,从操作系统层面讲,页是实际分配内存的最小单位。传统上,页的大小是以4KB为基本单位划分的,页操作对进程来说是透明的,进程所使用的是虚拟地址空间,并非真正的物理地址。为了便于将虚拟页面转换为实际的物理内存地址,可使用名为旁路转换缓冲(translation lookaside buffer,TLB)的缓存来加速地址的转换操作。从实现上看,如果页面的容量非常小的话,会导致频繁出现旁路转换缓冲丢失的情况。 修复这个问题的一种方法就是将页面的容量调大几个数量级,例如以MB为基本单位。现代操作系统普遍倾向于支持这种大内存页机制。 很明显,当多个进程分别在各自的寻址空间中分配内存,而页面的容量又比较大时,随着使用的页面数量越来越多,碎片化的问题就愈发严重,像进程要分配的内存比页面容量稍微大一点的情况,就会浪费很多存储空间。对于在进程内自己管理内存分配回收、并有大量内存空间可用的运行时来说,这不算什么问题,因为运行时可以通过抽象出不同大小的虚拟页面来解决。 通常情况下,对于那些内存分配和回收频繁的应用程序来说,使用大内存页可以使系统的整体性能至少提升10%。 JRockit对大内存页有很好的支持。 近实时垃圾回收 JRockit Real Time 低延迟的代价是垃圾回收整体时间的延长。相比于并行垃圾回收,在程序运行的同时并发垃圾回收的难度更大,而频繁中断垃圾回收则可能带来更多的麻烦。事实上,这并非什么大问题,因为大多数使用JRockit Real Time的用户更关心系统的可预测性,而不是减少垃圾回收的总体时间。大多数用户认为暂停时间的突然增长比垃圾回收总体时间的延长更具危害性。 软实时的有效性 软实时是JRockit Real Time的核心机制。但非确定性系统如何提供指定程度的确定性,例如像垃圾回收器这样的系统如何保证应用程序的暂停时间不会超过某个阈值?严格来说,无法提供这样的保证,但由于这样的极端案例很少,所以也就无关紧要了。 当然,没有什么万全之策,确实存在无法保证暂停时间的场景。但实践证明,对于那些堆中存活对象约占30%-50%的应用程序来说, JRockit Real Time的表现可以满足服务需要,而且随着JRockit Real Time各个版本的发行,30%-50%这个阈值在不断提升,可支持的暂停时间阈值则不断降低。 工作原理 - 高效的并行执行 - 细分垃圾回收过程,将之变成几个可回滚、可中断的子任务(work packet) - 高效的启发式算法 事实上,实现低延迟的关键仍是尽可能多让Java应用程序运行,保持堆的使用率和碎片化程度在一个较低的水平。在这一点上, JRockit Real Time使用的是贪心策略,即尽可能推迟STW式的垃圾回收操作,希望问题能够由应用程序自身解决,或者能够减少不得不执行STW式操作的情况,最好在具体执行的时候需要处理的对象也尽可能少一些。 JRockit Real Time中,垃圾回收器的工作被划分为几个子任务。如果在执行其中某个子任务时(例如整理堆中的某一部分内存),应用程序的暂停时间超过了阈值,那么就放弃该子任务恢复应用程序的执行。用户根据业务需要指定可用于完成垃圾回收的总体时间,有些时候,某些子任务已经完成,但没有足够的时间完成整个垃圾回收工作,这时为了保证应用程序的运行,不得不废弃还未完成的子任务,待到下次垃圾回收的时候再重新执行,指定的响应时间越短,则废弃的子任务可能越多。 前面介绍过的标记阶段的工作比较容易调整,可以与应用程序并发执行。但清理和整理阶段则需要暂停应用程序线程(STW)。幸运的是,标记阶段会占到垃圾回收总体时间的90%。如果暂停应用程序的时间过长,则不得不终止当前垃圾回收任务,重新并发执行,期望问题可以自动解决。之所以将垃圾回收划分为几个子任务就是为了便于这一目标的实现。 内存操作相关API 析构方法 Java中的析构函数的设计就是一个失误,应避免使用。 这不仅仅是我们的意见,也是Java社区的一致意见。 JVM的行为差异 对于JVM来说,一定谨记,编程语言只能提醒垃圾回收器工作。就Java而言,在设计上它本身并不能精确控制内存系统。例如,假设两个JVM厂商所实现软引用在缓存中具有相同的存活时间,这本就是不切实际的。 另外一个问题就是大量用户对System.gc()方法的错误使用。System.gc()方法仅仅是提醒运行时“现在可以做垃圾回收了”。在某些JVM实现中,频繁调用该方法导致了频繁的垃圾回收操作,而在某些JVM实现中,大部分时间忽略了该调用。 我过去任职为性能顾问期间,多次看到该方法被滥用。很多时候,只是去掉对 System.gc方法的几次调用就可以大幅提升性能,这也是 JRock中会有命令行参数-xx:AllowSystemGC=False来禁用System,gc方法的原因。 陷阱与伪优化 部分开发人员在写代码时,有时会写一些“经过优化的”的代码,期望可以帮助完成垃圾回收的工作,但实际上,这只是他们的错觉。记住,过早优化是万恶之源。就Java来说,很难在语言层面控制垃圾回收的行为。这里的主要问题时,开发人员误以为垃圾回收器有固定的运行模式,并妄图去控制它。 除了垃圾回收外,对象池(object poll)也是Java中常见的伪优化(false optimization)。有人认为,保留一个存活对象池来重新使用已创建的对象可以提升垃圾回收的性能,但实际上,对象池不仅增加了应用程序的复杂度,还很容易出错。对于现代垃圾收集器来说,使用java.lang.ref.Reference系列类实现缓存,或者直接将无用对象的引用置为null就好了,不用多操心。 事实上,基于现代VM,如果能够合理利用书本上的技巧,例如正确使用java.lang.ref.Reference系列类,注意Java的动态特性,完全可以写出运行良好的应用程序。如果应用程序真的有实时性要求,那么一开始就不该用Java编写,而应该使用那些由程序员手动控制内存的静态编程语言来实现应用程序。 JRockit中的内存管理 需要注意的是,花大力气鼓捣JVM参数并不一定会使应用程序性能有多么大的提升,而且反而可能会干扰JVM的正常运行。 线程与同步 基本概念 每个对象都持有与同步操作相关的信息,例如当前对象是否作为锁使用,以及锁的具体实现等。一般情况下,为了便于快速访问,这些信息被保存在每个对象的对象头的锁字(lock word)中。JRockit使用锁字中的一些位来存储垃圾回收状态信息,虽然其中包含了垃圾回收信息,但是本书还是称之为锁字。 对象头还包含了指向类型信息的指针,在 JRockit中,这称为类块(class block)下图是 JRockit中Java对象在不同的CPU平台上的内存布局。为了节省内存,并加速解引用操作,对象头中所有字的长度是32位。类块是一个32位的指针,指向另一个外部结构,该结构包含了当前对象的类型信息和虚分派表(virtual dispatch table)等信息。 就目前来看,在绝大部分JVM(包括JRockit)中,对象头是使用两个32位长的字来表示的。在JRockit中,偏移为0的对象指针指向当前对象的类型信息,接下来是4字节的锁字。在SPARC平台上,对象头的布局刚好反过来,因为在使用原子指令操作指针时,如果没有偏移的话,效率会更高。与锁字不同,类块并不为原子操作所使用,因此在SPARC平台上,类块被放在锁字后面。 原子操作(atomic operation)是指全部执行或全部不执行的本地指令。当原子指令全部执行时,其操作结果需要对所有潜在访问者可见。 原子操作用于读写锁字,具有排他性,这是实现JVM中同步块的基础。 难以调试 死锁是指两个线程都在等待对方释放自己所需的资源,结果导致两个线程都进入休眠状态。很明显,它们再也醒不过来了。活锁的概念与死锁类似,区别在于线程在竟争时会采取主动操作,但无法获取锁。这就像两个人面对面前进,在一个很窄的走廊相遇,为了能继续前进,他们都向侧面移动,但由于移动的方向相反导致还是无法前进。 Java API synchronized关键字 在Java中,关键字synchronized用于定义一个临界区,既可以是一段代码块,也可以是个完整的方法,如下所示: 1 2 3 | public synchronized void setGadget(Gadget g){ this.gadget = g; } | 上面的方法定义中包含synchronized关键字,因此每次只能有一个线程修改给定对象的gadget域。 在同步方法中,监视器对象是隐式的,即当前对象this,而对静态同步方法来说,监视器对象是当前对象的类对象。上面的示例代码与下面的代码是等效的: 1 2 3 4 5 | public void setGadget(Gadget g){ synchronized(this){ this.gadget = g; } } | java.lang.Thread类 Java中的线程也有优先级概念,但是否真的起作用取决于JVM的具体实现。setPriority方法用于设置线程的优先级,提示JVM该线程更加重要或不怎么重要。当然,对于大多数JVM来说,显式地修改线程优先级没什么大帮助。当运行时“有更好的方案”时, JRockit JVM甚至会忽略Java线程的优先级。 正在运行的线程可以通过调用yield方法主动放弃剩余的时间片,以便其他线程运行,自身休眠(调用wait方法)或等待其他线程结束再运行(调用join方法)。 volatile 关键字 在多线程环境下,对某个属性域或内存地址进行写操作后,其他正在运行的线程未必能立即看到这个结果。在某些场景中,要求所有线程在执行时需要得知某个属性最新的值,为此,Java提供了关键字volatile来解决此问题。 使用volatile修饰属性后,可以保证对该属性域的写操作会直接作用到内存中。原本,数据操作仅仅将数据写到CPU缓存中,过一会再写到内存中,正因如此,在同一个属性域上,不同的线程可能看到不同的值。目前,JVM在实现volatile关键字时,是通过在写属性操作后插入内存屏障代码来实现的,只不过这种方法有一点性能损耗。 人们常常难以理解“为什么不同的线程会在同一个属性域上看到不同的值”。一般来说,目前的机器的内存模型已经足够强,或者应用程序的本身结构就不容易使非volatile属性出现这个问题。但是,考虑到JIT优化编译器可能会对程序做较大改动,如果开发人员不留心的话,还是会出现问题的。下面的示例代码解释了在Java程序中,为什么内存语义如此重要,尤其是当问题还没表现出来的时候。 1 2 3 4 5 6 7 8 9 10 11 | public class My Thread extends Thread{ private volatile boolean finished; public void run(){ while(!finished){ // } } public void signalDone(){ this.finished = true } } | 如果定义变量finished时没有加上volatile关键字,那么在理论上,JIT编译器在优化时,可能会将之修改为只在循环开始前加载一次finished的值,但这就改变了代码原本的含义如果finished的值是false,那么程序就会陷入无限循环,即使其他线程调用了signalDone方法也没用。Java语言规范指明,如果编译器认为合适的话,可以为非 volatile变量在线程内创建副本以便后续使用。 由于一般会使用内存屏障来实现volatile关键字的语义,会导致CPU缓存失效,降低应用程序整体性能,使用的时候要谨慎。 Java中线程与同步机制的实现 Java内存模型 现在CPU架构中,普遍使用了数据缓存机制以大幅提升CPU对数据的读写速度,减轻处理器总线的竞争程度。正如所有的缓存系统一样,这里也存在一致性问题,对于多处理器系统来说尤其重要,因为多个处理器有可能同时访问内存中同一位置的数据内存模型定义了不同的CPU,在同时访问内存中同一位置时,是否会看到相同的值的情况。 强内存模型(例如x86平台)是指,当某个CPU修改了某个内存位置的值后,其他的CPU几乎自动就可以看到这个刚刚保存的值。在这种内存模型之下,内存写操作的执行顺序与代码中的排列顺序相同。弱内存模型(例如IA-64平台)是指,当某个CPU修改了某个内存位置的值后其他的CPU不一定可以看到这个刚刚保存的值(除非CPU在执行写操作时附有特殊的内存屏障类指令),更普遍的说,所有由Java程序引起的内存访问都应该对其他所有CPU可见,但事实上却不能保证立即可见。… ### 深入理解JVM&G1GC 笔记 - URL: https://jiankunking.com/java-jvm-gc-g1.html - Content type: original - Published: 2019-08-03 - Updated: 2019-08-03 - Summary: 《深入理解 JVM&G1GC》学习笔记,整理 JVM 内存结构、垃圾回收原理、G1 算法与调优方法。 - Categories: Java - Tags: Reading Notes, Java, G1, JVM Article text: 文章速览 《深入理解 JVM&G1GC》学习笔记,整理 JVM 内存结构、垃圾回收原理、G1 算法与调优方法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自:《深入理解JVM&G1GC》 作者:周明耀 本书很一般,建议粗略的看看就行 出版时间:2017-06-01 推荐阅读官方文档: https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-g1-garbage-collector1.html#GUID-3A99AE6C-F80A-4565-A27C-B4AEDF5CDF71 调优: https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-garbage-collector-tuning.html#GUID-4914A8D4-DE41-4250-B68E-816B58D4E278 PDF版文档: Hotspot Virtual Machine Garbage Collection Tuning Guide JVM GC基本知识 引言 G1内部主要有四个操作阶段: - 年轻代回收(A Young Collection) - 运行在后台的并行循环(A Background,Concurrent Cycle) - 混合回收(A Mixed Collection) - 全量回收(A Full GC) 基本术语 Java相关术语 Interned Strings 在Java语言中有8种基本类型和一种比较特殊的类型 String这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。常量池就类似一个Java系统级别提供的缓存。8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。 - 直接使用双引号声明出来的String对象会直接存储在常量池中 - 如果不是用双引号声明的String对象,可以使用String提供的Intern方法。 intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。 通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。 Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串池的位置调整到Java堆内,这个改动意味着你再也不会被固定的内存空间限制了。所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。字符串池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用 String intern()。 Java 对象头 在HotSpot虚拟机中,对象在内存中的布局可以分成对象头、实例数据、对齐填充三部分。 - 对象头:它主要包括对象自身的运行行元数据,比如哈希码、GC分代年龄、锁状态标志等,同时还包含一个类型指针,指向类元数据,表明该对象所属的类型。 - 实例数据:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)。 - 对齐填充:它不是必要存在的,仅仅起着占位符的作用 对象头大小在32位HotSpot VM和64位 HotSpot VM之间是不一样的,对象头在32位系统上占用8yte,在64位系统上占用16yte。我们可以通过Java对象布局工具获取头大小,这个工具简称为JOL。 G1 涉及术语 Metaspace JDK8 HotSpot JVM使用本地内存来存储类元数据信息并称为元空间(Metaspace)。 默认情况下,大部分类元数据都在本地内存中分配,类元数据只受可用的本地内存限制(容量取决于是32位或是64位操作系统的可用虚拟内存大小)。新参数(MaxMetaspace Size)用于限制本地内存分配给类元数据的大小。如果没有指定这个参数,元空间会在运行时根据需要动态调整。 一般情况下,适时地监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。持续的元空间垃圾回收情况如果频繁发生,说明可能存在类、类加载器导致的内存泄漏或是大小设置不合适。 G1 GC与Metaspace相关的选项如下: - -XX:MetaspaceSize:初始化元空间的大小(默认12 Mbytes在32bit client VM and 16 Mbytes在32bit server VM,在64 bit VM上会更大些)。 - -XX:MaxMetaspaceSize:最大元空间的大小(默认本地内存)。 - -XX:MinMetaspaceFreeRatio:扩大空间的最小比率,当GC后,内存占用超过这一比率,就会扩大空间。 - -XX:MaxMetaspaceFreeRatio:缩小空间的最小比率,当GC后,内存占用低于这一比率,就会缩小空间。 Mixed GC Event 即混合GC事件,在这个事件内部,所有的年轻代Region和一部分老年代Region一起被回收。混合GC事件一定是跟在Minor GC之后的,并且混合GC只有在存活对象元数据存在的情况下才会触发。 Reclaimable Gl GC为了能够回收,创建了一系列专门用于存放可回收对象的Region。这些Region都在个链表队列里面,这个队列只包含存活率小于-XX: G1MixedGCLiveThresholdPercent(默认85%)的Region。Region的值除以整个Java堆区,如果大于-XX:G1HeapWastePercen(默认5%),则启动回收机制。 Rset 全称Remembered Set,简称Rset,即跟踪指向某个堆区(Region)内的对象引用。 在标记存活对象时,G1使用RememberSet的概念,将每个分区外指向分区内的引用记录在该分区的RememberSet中,避免了对整个Heap的扫描,使得各个分区的GC更加独立。堆内存中的每个区都有一个RSet,Rset的作用是让堆区能并行独立地进行垃圾集合。RSet所占用的JVM内存小于总大小的5%。在这样的背景下,可以看出G1GC大大提高了触发 Full GC时的Heap占用率,同时也使得 Minor GC的暂停时间更加可控,对于内存较大的环境非常友好。 G1 GC引入了一些新的选项。G1RSetUpdatingPauseTimePercent设置STW阶段(独占阶段)为G1收集器指定更新RememberSet的时间占总STW时间的期望比例,默认为10。而G1ConcRefinementThreads则是在程序运行时维护RememberSet的线程数目。通过对这两个值的对应调整,我们可以把STW阶段的RememberSet更新工作压力更多地移到并行阶段。 CSet 全称Collection Set,简称CSet,即收集集合,保存一次GC中将执行垃圾回收的区间(Region)。GC时在CSet中的所有存活数据(Live Data)都会被转移(复制/移动)。集合中的堆区可以是Eden, Survivor和/或Old Generation。CSets所占用的JVM内存小于总大小的1%。 从这里可以知道,实际上CSet相当于一个大圈,里面包含了很多的小圈(Rset),这些圈圈都是需要被回收的信息。这样可以把CSet比作垃圾场,RSet是垃圾场里面一个个绿色的可回收垃圾桶。 PLAB 全称为Promotion Local Allocation Buffers,它被用于年轻代回收。PLAB的作用是避免多线程竞争相同的数据,处理方式是每个线程拥有独立的PLAB,用于针对幸存者和老年空间。当应用开启的线程较多时,最好使用-XX:-ResizePlaB来关闭PLAB()的大小调整,以避免大量的线程通信所导致的性能下降。 TLAB 全称为Thread Local Allocation Buffers,即线程本地分配缓存,是一个线程专用的内存分配区域。 总的来说,TLAB是为了加速对象分配而生的。由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每一次对象分配都必须要进行同步,而在竞争激烈的场合分配的效率又会进一步下降。考虑到对象分配几乎是Java最最常用的操作,所以JVM就使用了TLAB这种线程专属的区间来避免多线程冲突,提高对象分配的效率。TLAB本身占用了Eden区的空间,即JVM会为每一个Java线程分配一块TLAB空间。 对于G1 GC来说,TLAB是Eden的一个Region,被一个单一线程用于分配资源。主要用途是让一个线程通过栈操作方式独享内存空间,用于对象分配,这样比多个线程之间共享资源要快很多。如果每个线程的分配内存不够,那么它会去全局内存池申请新的内存。这样也就是说,如果TLAB值设置过小,容易造成频繁申请,也就会造成GC性能下降。反之,如果设置过大,会造成TLAB使用不完,也就是说内存浪费。 Region 从字面上来说, Region表示一个区域,每个区域里面的字母代表不同的分代内存空间类型(如[E]Eden,[O]Old,[S]Survivor),空白的区块不属于任何一个分区。G1可以在需要的时候任意指定这个区域属于Eden或是O区之类的。 Ergonomics Heuristic Decision 在很多英文书里都能看到这串单词,特别是Ergonomics Heuristi,它们的字面意思是人体工程学,可以理解为适合人类理解的行为、习惯。GC日志里面看到Ergonomics这个单词,它后面一般跟着的是G1 GC相关的详细描述,比如堆内存日志、CSet划分等,通常采用选项-XX:+PrintAdaptiveSizePolicy时会看到这个单词。 Top-at-mark-start 每个区间记录着两个TAMS指针(Top-at-mark-start),分别为prevTAMS和nextTAMS在TAMS以上的对象是新分配的,因而被视为隐式标记。 JVM&GC 深入知识 Java虚拟机内存模型 程序计数器 程序计数器,英文全称Program Counter Register,它是一块很小的内存空间,它是运行速度最快的存储区域,这是因为它位于不同于其他存储区的地方—处理器内部。寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配。实际上在Java应用程序内部不能直接控制寄存器,也不能在程序中感觉到寄存器存在的任何迹象。可以把程序计数器看作当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器的工作就是通过改变程序计数器的值来选择下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。 简单概括上面的描述,即在多线程环境下,为了让线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间互不影响、独立存储,因此这块内存是线程私有的。JVM中的寄存器类似于物理寄存器的一种抽象模拟,正如前面说的,它是线程私有的,所以生命周期与线程的生命周期保持一致。 根据Java虚拟机定义来看,程序寄存器区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemory Error情况的区域。 虚拟机栈 JVM的架构是基于栈的,即程序指令的每一个操作都要经过入栈和出栈这样的组合型操作才能完成。 总的来说,栈的优势是访问速度比堆要快,它仅次于寄存器,并且栈数据是可以被共享的。栈的缺点是存储在栈里面的数据大小与生存期必须是确定的,从这一点来看,栈明显缺乏灵活性。虚拟机栈内主要被用来存放一些基本类型的变量,例如int、 short、long、byte、foat、 double、boolea、char,以及对象引用。 前面说过,虚拟机栈有一个很重要的特殊性,就是存放在栈内的数据可以共享。假设同时定义: 1 2 | int a=1; int b=1; | 对于上面的代码,虚拟机处理第一条语句,首先它会在栈内创建一个变量为a的引用,然后查找栈内是否有1这个值,如果没找到,就将1存放进来,然后将a指向1。接下来处理第二条语句,在创建完b的引用变量后,因为在栈内已经有1这个值,便将b直接指向1。这样,就出现了a与b同时均指向1的情况。这时,如果存在第三条语句,它针对a再次定义为a=4,那么编译器会重新搜索栈内是否有4值,如果没有,则将4存放进来,并令a指向4,如果已经有了,则直接将a指向这个地址,因此a值的改变不会影响到b的值。要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享的方式存在明显的不同,因为这种情况a的修改并不会影响到b,它是由虚拟机完成的,这样的做法有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。 与程序计数器一样,Java虚拟机栈也是线程私有的内存空间,它和Java线程在同一时间创建,它保存方法的局部变量、部分结果,并参与方法的调用和返回。 虚拟机栈在运行时使用一种叫作栈帧的数据结构保存上下文数据,栈帧里面存放了方法的局部变量表、操作数栈、动态连接方法和返回地址等信息。每一个方法的调用都伴随着栈帧的入栈操作,相应地,方法的返回则表示栈帧的出栈操作。 使用JClassLib工具可以查看Class文件中每个方法所分配的最大局部变量区的容量。JClassLib工具是开源软件,它可以用于查看 Class文件的结构,包括常量池、接口、属性、方法,还可以用于查看文件的字节码。 Java堆 Java堆区在JVM启动的时候即被创建,它只要求逻辑上是连续的,在物理空间上可以是不连续。所有的线程共享Java堆,在这里可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。 正是因为Java堆区是GC的重点回收区域,所以GC极有可能会在大内存的使用和频繁进行垃圾回收过程上成为系统性能瓶颈。为了解决这个问题,JVM的设计者们开始考虑是否一定需要将对象实例存储到Java堆区内。基于OpenJDK深度定制的TaobaoJVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。 方法区 方法区主要保存的信息是类的元数据。方法区与堆空间类似,它也是被JVM中所有的线程共享的区域。如下图所示,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息。类型信息包括类的完整名称、父类的完整名称、类型修饰符(public/protected/private)和类型的直接接口类表。 常量池包括类方法、域等信息所引用的常量信息。域信息包括域名称、域类型和域修饰符。方法信息包括方法名称、返回类型、方法参数、方法修饰符、方法字节码、操作数栈和方法栈帧的局部变量区大小以及异常表。方法区是线程间共享的,当两个线程同时需要加载一个类型时,只有一个类会请求ClassLoader加载,另一个线程则会等待。总而言之,方法区内保存的信息大部分来自于Class件,是Java应用程序运行必不可少的重要数据。 在Hotspot虚拟机中,方法区也被称为永久区,是一块独立于Java堆的内存空间。虽然被叫作永久区,但是在永久区中的对象同样也是可以被GC回收的,只是对于GC的对应策略与Java堆空间略有不同。 GC针对永久区的回收,通常主要从两个方面分析:一是GC对永久区常量池的回收,二是永久区对类元数据的回收。HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。 垃圾收集算法 根搜索算法 在HotSpot中,根对象集合中包含了5个元素,Java栈内的对象引用、本地方法栈内的对象引用、运行时常量池中的对象引用、方法区中类静态属性的对象引用以及与一个类对应的唯一数据类型的Class对象。 这部分了解一下就好 注意,在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或者finalized方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行finalize方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由条由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize方法中执行缓慢,或者发生了死循环(更极端的情况),很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalized中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合。如果对象这时候还没有逃脱,那它就真的离死不远了。 标记清除算法(Mark-Sweep) 算法涉及几个概念,先来了解一下mutator和collector,这两个名词经常在垃圾收集算法中出现,collector指的就是垃圾收集器,而 mutator是指除了垃圾收集器之外的部分,比如说我们的应用程序本身。mutator的职责一般是NEW(分配内存)、READ(从内存中读取内容)、WRITE(将内容写入内存),而collector则就是回收不再使用的内存来供mutator进行NEW操作的使用。mutator根对象一般指的是分配在堆内存之外,可以直接被mutator直接访问到的对象,一般是指静态/全局变量以及ThreadLocal变量。 复制算法(Copying) 基于分代的概念,Java堆区如果还要更进一步细分的话,还可以划分为年轻代(YoungGen)和老年代(OldGen),其中年轻代又可以被划分为Eden空间、From Survivor空间和To Survivor空间。在HotSpot中,Eden空间和另外两个Survivor空间默认所占的比例是8:1,当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。当执行一次Minor GC(年轻代的垃圾回收),Eden空间中的存活对象会被复制到To空间内,并且之前已经经历过一次 Minor GC并在From空间中存活下来的对象如果还年轻的话同样也会被复制到To空间内。需要注意的是,在满足两种特殊情况下,Eden和From空间中的存活对象将不会被复制到To空间内。首先是如果存活对象的分代年龄超过选项“-XX:MaxTenuringThreshold”所指定的阈值时,将会直接晋升到老年代中。其次当To空间的容量达到阈值时,存活对象同样也是直接晋升到老年代中。当所有的存活对象都被复制到To空间或者晋升到老年代后,剩下的均为垃圾对象,这就意味着GC可以对这些已经死亡了的对象执行一次Minor GC,释放掉其所占用的内存空间。 标记压缩算法(Mark-Compact) 在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。简单来说,就是针对不同的代空间,从而结合使用不同的垃圾收集算法。为年轻代选择的垃圾收集算法通常是以速度优先,因为年轻代中所存储的瞬时对象生命周期非常短暂,可以有针对性地使用复制算法,因此执行Minor GC时,一定要保持高效和快速。而年轻代中的生存空间通常都比较小,所以回收年轻代时一定会非常频繁。但老年代通常使用更节省内存的回收算法,因为老年代中所存储的对象生命周期都非常长,并且老年代占据了大部分的堆空间,所以老年代的Full GC并不会跟年轻代的Minor GC一样频繁,不过一旦程序中发生一次Full GC,将会耗费更长的时间来完成,那么在老年代中使用标记-清除算法或者标记-压缩算法执行垃圾回收将会是不错的选择。 Garbage Collection GC 概念 在许多情况下,GC不应该成为影响系统性能的瓶颈,可以根据以下六点来评估一款GC的性能。 - 吞吐量:程序的运行时间(程序的运行时间+内存回收的时间)。 - 垃圾收集开销:吞吐量的补数,垃圾收集器所占时间与总时间的比例。 - 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。 - 收集频率:相对于应用程序的执行,收集操作发生的频率。 - 堆空间:Java堆区所占的内存大小。 - 快速:一个对象从诞生到被回收所经历的时间。 Parallel收集器 需要注意的是,垃圾收集器中吞吐量和低延迟这两个目标本身是相互矛盾的,因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。 举个例子,在60s的JVM总运行时间里,GC的执行频率是20秒/次,那么60s内一共会执行3次内存回收,按照每次GC耗时100ms来计算,最终一共会有300ms(3×100)被用于执行垃圾回收。但是如果我们将选项“-XX:MaxGCPauseMills”的值调小后,年轻代的内存空间也会自动调整,内存空间越小就越容易被耗尽,也就越容易造成GC的执行频繁发生。之前在60s的JVM总运行时间里,最终会有300ms被用于执行内存回收,而如今GC的执行频率却是10s/次,60s内将会执行6次内存回收,按照每次GC耗时60ms来计算,虽然看上去暂停时间更短了,但最终会耗时360ms(6×60)用于执行内存回收,很明显程序的吞吐量下降了。所以大家在设置这两个选项时,一定需要注意控制在一个折中的范围之内。Parallel收集器还提供个“-XX:UseAdaptiveSizePolicy”选项用于设置GC的自动分代大小调节策略,一旦设置这个选项后,就意味着开发人员将不再需要显式地设置年轻代中的一些细节参数,JVM会根据自身当前的运行情况动态调整这些相关参数。 Garbage First (G1) GC G1很重视老年代的垃圾回收,一旦整个堆空间占有率达到指定的阈值(启动时可配置),G1会立即启动一个独占的并行初始标记阶段(initial-mark phase)进行垃圾回收。在G1 GC,判断的是整个Java堆内部老年代的占有率,足以见G1对老年代的重视。 初始标记阶段一般和年轻代GC一起运行,一旦初始标记阶段结束,并行多线程的标记阶段就开始启动去标记所有老年代还存活的对象,注意这个标记阶段不是独占式的,它允许应用程序线程和它并行执行。当这个标记阶段运行完毕之后,为了再次确认是否有逃过扫描的对象,“启动一个独占式的再次标记阶段(remark phase),尝试标记所有遗漏的对象。在这个再次标记阶段结束之后,G1就掌握了所有的老年代 Region的标记信息,这和国家的户口统计方式差不多。一旦老年代的某些Region内部不存在任何的存活对象,它就可以在下一个阶段,即清除阶段(cleanup phase)被清除了,就是可以销户了,又被放回了可用Region队列。同样地,再次标记阶段结束后就可以对一些老年代执行收集动作。 前面提到了CSet概念,一个CSet里面可以包含多少Region取决于多少空间可以被释放、G1停顿目标时间这两个因素。前面说起过混合GC(Mixed GC),这里就要具体说明一下了。当CSet被确定之后,会在接下来的一个年轻代回收过程当中对CSet进行回收,通过年轻代GC的几个阶段,一部分的老年代Region会被回收并放入年轻代使用。这个概念很灵活,即G1只关注你有没有存活对象了,如果没有,无论你属于老年代,还是属于年轻代,你都会被回收并放入可用Region队列,下一次你被分配到哪里就不确定了。也正是因为Region、混合收集这些特性,让G1对老年代的垃圾收集方式有别于Serial GC、Parallel GC和CMS GC,G1采用Region方式让对象之间的联系存在于虚拟地址之上,这样就不需要针对老年代的压缩和回收动作对整个Java堆执行扫描,为老年代回收节约了时间。 G1 设计思路 Gl把整个Java堆划分为若干个区间(Region)。每个Region大小为2的倍数,范围在1MB~32MB之间,可能为1MB、2MB、4MB、8MB、16MB、32MB。所有的Region有一样的大小,在JVM生命周期内不会被改变。 注意,在年轻代、混合代、Full GC这三个阶段,年轻代的Eden Region和Survivor Region的数量会随时变化。Humongous Region(大对象 Region)是老年代Region的一部分,里面的对象超过每个Region的50%空间,这一点有别于一般对象Region。 从之前的介绍我们知道没有必要去刻意区分Region的用途,因为G1设计Region的分配原则是很灵活的。一开始G1会从可用 Region队列里面挑选出Region并设置为Eden Region,一个Eden Region里面填满对象以后,又会从可用Region队列里再挑出一个。当所有的Eden Region都被填满时,一个年轻代GC收集就会开始执行了,在这个收集阶段,我们会收集Eden和Survivor Region,所有的存活对象要么进入到下一个Survivor region,要么进入老年代Region。 G1提供了一个选项-XX:InitiatingHeapOccupancyPercent,默认值是Java堆空间的45%,这个选项决定了是否开始一次老年代回收动作,即年轻代GC结束之后,G1会评估剩余的对象是否达到了45%这个阈值。 如果标记阶段(Marking Phase)结束后一个老年代的Region已经不存在对象,那么它会被放回可用Region队列,反之,它会被放入混合收集器。 由于标记阶段不是一个独占式的多线程并行程序,这样应用程序线程就会和它一起并行执行。为了避免标记阶段占用过多的CPU资源,G1采用时间片方式分段执行操作,即在时间片内全力运行,然后休息一段时间,这个休息时间就是让应用程序尽可能多地使用CPU资源运行。 大对象(Humongous Object) 大对象Region属于老年代的一部分,它只包含一个对象。当并行标记阶段发现没有存活对象时,G1会回收这个大对象 Region,注意这个动作可以是一个批量回收。 全垃圾收集(Full Garbage Collection) G1的Full GC和Serial GC的Full GC采用的是同一种算法。Full GC会对整个Java堆进行压缩。G1的Full GC是单线程的,会引起较长的停顿时间,因此G1的设计目标是减少Full GC的发生次数。 并行循环(Concurrent Cycle) 一个G1并行循环包括几个阶段的活动:初始标记(Initial Marking)、并行Root区间扫描(Concurrent Root Region Scanning)、并行标记(Concurrent Marking)、重标记(Remarking)和清除(Cleanup)。除了最后的Cleanup阶段以外,其余阶段都属于标记存活对象阶段。 初始标记阶段的目的是收集所有GC根(Roots)。 Roots是一个对象的起源指针。为了收集根引用,从应用线程开始,应用线程必须停下来,所以初始标记阶段是一个独占式的。由于个年轻代GC必须收集所有的Roots,所以G1的初始标记在一个年轻代GC里完成。 并行根区间扫描阶段必须扫描和标记所有幸存者区间的对象引用,这… ### 数据库索引设计与优化 笔记 - URL: https://jiankunking.com/relational-database-index-design-and-the-optimizers.html - Content type: original - Published: 2019-08-03 - Updated: 2019-08-03 - Summary: 《数据库索引设计与优化》学习笔记,整理索引结构、SQL 处理、索引设计方法和常见认知误区。 - Categories: Database - Tags: Reading Notes, MySQL, Design, Index, Database, Oracle, SQL Server, DB2, Optimizers Article text: 文章速览 《数据库索引设计与优化》学习笔记,整理索引结构、SQL 处理、索引设计方法和常见认知误区。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Database 本文整理自:《数据库索引设计与优化》 作者:Tapio Lahdenmaki,Michael Leach 出版时间:2015-06-01 概述 索引误区 误区1:索引层级不要超过5层 由于非叶子页通常都会留在内存或者读缓存中,所以通常索引任意一个叶子页的时间为10ms~20ms,这是固定的。所以,对索引层数的限制是没有什么意义的。 误区2:单表的索引数不要超过6个 我建议不要给表的索引数目设置上限。 保证所有的SQL语句都能够流畅运行是设计的底线。我们总能找到一种方法来达到这一点。如果为了达到这一点需要在表上创建10个索引,那么你就应该在表上建立10个索引。 误区3:不应该索引不稳定的列 索引行是按索引键的顺序存储的,所以当索引键中存一列被更新时,DBMS可能不得不把相应的行从旧的索引位置移到新的位置来保持这一顺序。这个新的位迓可能与旧的位置位于相同的叶子页上,在这种情况下,只有一个页会受到影响。然而,如果被修改的键是第一列或唯一的列,那么新的索引行可能必须被迁移到一个不同的叶子页上,即DBMS必须更新两个叶子页。三十年前,如果这个索引为一个4层索引,这也许需要6次磁盘随机读取:3次常规读取,即2次非叶子页读取和1次叶子页读取,加上新的位置所涉及的3次随机读取。当一次随机读取耗时30ms 时,迁移一个索引行可能会给该更新操作额外增加6 x 30ms =180ms 的响应时间。因此,不稳定的列很少被索引就不足为奇了。 现在,当四层索引中三个层级的非叶子页保留在内存中时,一次磁盘随机读取需要 l0 ms ,响应的时间变成了 2 x 10ms = 20ms 。此外,许多索引为多列索引,也称作复合或组合索引,它通常包含多列,以使得索引键值唯一。当不稳定的列为复合索引的尾列更新这个不稳定的列绝不会导致其迁移到新的叶子页。因此,在当前的磁盘条件下,更新一个不稳定的列只会对该更新操作增加10ms的响应时间。 在当前磁盘条件下,只有在更新频率多于10次/秒的情况下,不稳定列才可能成为问题。 创建索引的目的应该是在硬件容量限制的前提下保证所有的数据库调用运行的足够快。 系统化的索引设计 - 找到由于索引不合适而导致运行太慢的查询语句 最差输入:导致执行时间最长的变量值 - 设计索引,使所有查询语句都运行的足够快 表的维护(插入、更新、删除)也必须足够快 表和索引结构 DBMS 会意识到多个索引或表页需要被顺序地读取,且能识別出那些不在缓冲池中的页。随后,它将发出多页I/O请求,每次请求的页的数量由DBMS决定。只有那些不在缓冲池中的页会被从磁盘服务器上读取,因为那些已经在缓冲池中的页中可能包含了尚未被写人磁盘的更新数据。 SQL处理过程 谓词 WHERE子句由一个或者多个谓词(搜索参数)组成。 1 2 3 4 5 6 | # SQL 3.1 WHERE SEX = 'M' AND (WEIGHT > 90 OR HEIGHT > 190) | SQL 3.1中有三个简单谓词,它们是: - SEX = ‘M’ - WEIGHT > 90 - HEIGHT > 190 同样,它们也可以被认为是两个组合谓词: - WEIGHT > 90 OR HEIGHT > 190 - SEX = ‘M’ AND ( WEIGHT > 90 OR HEIGHT > 190 ) 谓词表达式是索引设计的主要入手点。如果一个索引能够满足SELECT查询语句的所有谓词表达式,那么优化器就很有可能建立起一个高效的访问路径。 核实确认访问路径(执行计划) 为SELETE语句创建理想的索引【重点】 很多调优人员(尽管没经验)认为,如果一个SQL语句使用了索引,那这个 SQL就是被很好地优化过的,我对此感到很惊讶。你应该总是问自己,”这是不是可用的最好的索引?” 或 “再添加另外一个索引能否提升响应性能?”,又或者 “全表扫描会不会更快地返回结果?” 三星索引 三星索引:查询语句的理想索引。 1 2 3 4 5 6 7 | DECLARE CURSOR41 CURSOR FOR SELECT CNO, FNAME FROM CUST WHERE LNAME = :LNAME AND CITY = :CITY ORDER BY FNAME | 星级是如何给定的 如果与一个查询相关的索引行是相邻的,或者至少相距足够靠近的话,那这个索引就可以被标记上第一颗星。这最小化了必须扫描的索引片的宽度。 如果索引行的顺序与查询语句的需求一致,则索引可以被标记上第二颗星。这排除了排序操作。 如果索引行包含的查询语句中的所有列,那么索引就可以被标记上第三颗星。这避免了访问表的操作:仅访问索引就可以了。 对于这三颗星,第三颗通常是最重要的。将一个列排除在索引之外可能会导致许多速度较慢的磁盘随机读。我们把一个至少包含第三颗星的索引称为对应查询语句的宽索引。 宽索引:宽索引是指一个至少满足第三颗星的索引。该索引包含了SELECT语句所涉及的所有列,因而能够使得查询只需访问索引而无需访问表。 为了满足第一颗星 首先取出所有等值谓词的列(WHERE COL=…)。把这些列作为索引最开头的列——以任意顺序都可以。对于CURSOR41来说,三星索引可以以LNAME、CITY或者以CITY、LNAME开头。在这两种情况下,必须扫描的索引片宽度将被缩减至最窄。 为了满足第二颗星 将ORDER BY列加入到索引中。不要改变这些列的顺序,但是忽略那些在第一步中已经加入索引的列。例如,如果CURSOR41在ORDER BY 中有重复的列,如ORDER BY LNAME、FNAME或者是ORDER BY FNAME、CITY,只有FNAME需要在这步中被加入到索引中去。当FNAME是索引的第三列时,结果集中的记录无须排序就已经是以正确的顺序排列的了。第一次读取操作将返回FNAME值最小的那一行。 为了满足第三颗星 将查询语句中剩余的列加到索引中去,列在索引中添加的顺序对查询语句的性能没有影响,但是将易变的列放在最后能降低更新的成本。现在,索引已包含了满足无须回表的访问路径所需的所有列。 最终三星索引将会是:(LNAME, CITY, FNAME, CNO) 或 (CITY, LNAME, FNAME, CNO) CURSOR41在以下方面是最为挑剔的: - WHERE 条件不包含范围谓词(BETWEEN、>、>=等) - FROM 语句只涉及单表 - 所有谓词对于优化器来说都足够简单 范围谓词与三星索引 1 2 3 4 5 6 7 | DECLARE CURSOR43 CURSOR FOR SELECT CNO, FNAME FROM CUST WHERE LNAME BETWEEN :LNAME1 AND :LNAME2 AND CITY = :CITY ORDER BY FNAME | 让我们尝试为这个 CURSOR 设计一个三星索引。大部分的推论与 CURSOR41 相同,但是“BETWEEN 谓词”将“=谓词”替代后将会有很大的影响。我们将会以相反的顺序依次考虑三颗星,按理说,这代表了理解的难度。 首先是最简单的星(虽然非常重要),第三颗星。按照先前所述,确保查询语句中的所有列都在索引中就能满足第三颗星。这样不需要访问表,那么同步读也就不会造成问题。 添加 ORDER BY 列能使索引满足第二颗星,但是这个仅在将其放在 BETWEEN 谓词列 LNAME 之前的情况下才成立,如索引 (CITY, FNAME, LNAME)。由于 CITY 的值只有一个(=谓词),所以使用这个索引可以使结果集以 FNAME 的顺序排列,而不需要额外的排序。但是如果 ORDER BY 字段加在 BETWEEN 谓词列 LNAME 后面,如索引 (CITY, LNAME, FNAME),那么索引行不是按 FNAME 顺序排列的,因而就需要进行排序操作。因此,为了满足第二颗星,FNAME 必须在 BETWEEN 谓词列 LNAME 前面,如索引 (FNAME, …) 或索引 (CITY, FNAME, …)。 再考虑第一颗星,如果 CITY 是索引的第一个列,那我们将会有一个相对较窄的索引片需要扫描(MC=1),这取决于 CITY 的过滤因子。但是如果用索引 (CITY, LNAME, …) 的话,索引片会更窄,这样在有两个匹配列的情况下我们只需要访问真正需要的索引行。但是,为了做到这样,并从一个很窄的索引片中获益,其他列(如 FNAME)就不能放在这两列之间。 MC:match column(匹配列) 所以我们的理想索引会有几颗星呢?首先它一定能有第三颗星,但是,正如我们刚才所说,我们只能有第一颗星或者第二颗星,而不能同时拥有两者!换句话说,我们只能二选一: - 避免排序 — 拥有第二颗星 - 拥有可能的最窄索引片,不仅将需要处理的索引行数降至最低,而且将后续处理量,特别是表中数据行的同步读,减少到最少 — 拥有第一颗星 在这个例子中,BETWEEN 谓词或者任何其他范围谓词的出现,意味着我们不能同时拥有第一颗星和第二颗星。也就是说我们不能拥有一个三星索引。这就意味着我们需要在第一颗星和第二颗星中做出选择。通常这不是一个困难的选择,因为第一颗星一般比第二颗星更重要,虽然并不总是这样。 为查询语句设计最佳索引的算法 根据以上的讨论,理想的索引是一个三星索引。然而,正如我们所见,当存在范围谓词时,这是不可能实现的。我们(也许)不得不牺牲第二颗星来满足一个更窄的索引片(第一颗星),这样,最佳索引就只拥有两颗星。这也就是为什么我们需要仔细区分理想和最佳。在这个例子中理想索引是不可能实现的。将这层因素考虑在内,我们可以对所有情况下创建最佳索引(也许不是理想索引)的过程公式化。创建出的索引将拥有三颗星或者两颗星。 首先设计一个索引片尽可能窄(第一颗星)的宽索引(第三颗星)。如果查询使用这个索引时不需要排序(第二颗星),那这个索引就是三星索引。否则这个索引只能是二星索引,牺牲第二颗星。或者采用另一种选择,避免排序,牺牲第一颗星保留第二颗星。这种二星索引中的一个将会是相应查询语句的最佳索引。 为查询语句创建最佳索引的算法 候选 A - 取出对于优化器来说不过分复杂的等值谓词列。将这些列作为索引的前导列(以任意顺序皆可)。 - 将选择性最好的范围谓词作为索引的下一个列,如果存在的话。最好的选择性是指对于最差的输入值有最低的过滤因子。只考虑对于优化器来说不过分复杂的范围谓词。 - 以正确的顺序添加ORDER BY语列,忽略在第一步或者第二步已添加的列。 - 以任意顺序将 SELECT 语句中其余的列添加至索引中(但是需要以不易变的列开始)。 举例:CURSOR43 候选 A 为 (CITY, LNAME, FNAME, NCNO)。 由于 FNAME 在范围谓词列 LNAME 的后面,候选 A 引起了 CURSOR43 的一次排序操作。 候选 B 如果候选 A 引起了所给查询语句的一次排序操作,那么还可以设计候选 B。根据定义,对于候选 B 来说第二颗星比第一颗星更重要。 - 取出对于优化器来说不过分复杂的等值谓词列。将这些列作为索引的前导列(以任意顺序皆可)。 - 以正确顺序添加 ORDER BY 列(如果 ORDER BY 列有 DESC 的话,加上 DESC)。忽略在第1步中已经添加的列。 - 以任意顺序将 SELECT 语句中其余的列添加至索引中(但是需要以不易变的列开始)。 举例:CURSOR43 候选 B 为 (CITY, FNAME, LNAME, CNO)。 需要注意的是,到目前为止,我们所做的只是设计理想索引或是最佳索引。但是这是否是实际可行的,我们在这个阶段还不好说。 前瞻性的索引设计 基本概念 访问 根据定义,DBMS读取一个索引行或一个表行的成本称为一次访问 : 索引访问或表访问。如果DBMS扫描索引或表的一个片段(被读取的行在物理上是彼此相邻的),那么第一行的读取即为一次随机访问。对于后续行的读取,每行都是一次顺序访问。在当前的硬件条件下,顺序访问的成本比随机访问的成本低得多。一次索引访问的成本与一次表访问的成本基本上是相同的。 读取一组连续的索引行 物理上彼此相邻是什么意思? 索引上的所有行都通过指针链接在一起,链接的先后顺序由索引的键值严格定义。当几个索引行的键值相同时,就根据索引行存储的指针值进行链接。在传统的索引设计(从某个角度看,是理想化的)中,链表从LP1(叶子页1)开始,随后链接LP2,以此类推。这样(假设每个磁道可以放12个叶子页,当前的硬件通常可以容纳更多),叶子页就组成了一个连续的文件,LP1至LP12存储在磁盘柱面的第一个磁道,LP13至LP24存储在下一个磁道,如此继续,当第一个柱面存满后,下一组LP就会被存储在下一个柱面的首个磁道上。换句话说,就是叶子页之间没有其他页。 现在,读取一个连续的索引行(即一个索引片,或者包含了单个键值或者一个范围的键值所对应的索引行)就非常快了。一次磁盘旋转会将多个叶子页读取进内存中,而且只有在磁盘指针移到下一个柱面时才需要进行一次短暂的寻址、 不过,这个完美的顺序还是会被打破的,至少有以下三个影响因素 : - 如果一个叶子页没有足够的空间存储新插入的索引行,那么叶子页就必须被分裂。之后链表仍会按照正确的顺序链接索引行,但是这与底层的物理存储顺序就不再一致了,一些按道理应该是顺序的访问就变成随机访问了。不过索引的充足可以再次恢复最理想的顺序。 - 意向不到的数据增长可能会填满原本连续的空间(区或类似的概念)。操作系统于是就会寻找另外有一个连续的空间,并将它连接到原来空间的后面。这时候从第一个区跨到第二个区访问就会产生一次随机访问,不过这种情况影响不大。 3.RAID 5条带会将前几个叶子页存储在一个驱动器上,将后面的叶子页存放在另外的驱动器上。这就会产生额外的随机读,但实际上条带的积极作用要大过随机读带来的性能恶化,一个智能的磁盘服务器可以将后续的叶子页并行的从多个驱动器上读取至磁盘缓存中,从而大大降低了单个叶子页的I/O时间。此外,在RAID 5条带策略下,一个被频繁访问的索引的不太可能导致某一个磁盘负载过高,因为I/O请求会被均匀低分布到RAID 5阵列内的多个磁盘驱动器。 忽略上述情况,我们仍然假设,如果两个索引行在链表上彼此相邻(或者在唯一索引中,相同键值的行指针意味着彼此相邻),那么我们就认为这两行在物理上也相邻。这就意味着QUBE认为所有的索引都有最理想的顺序。 读取一组连续的表行 读取一组连续的表行有如下两种情况: - 全表扫描 从TP1(表页1)开始,读取该页上所有的记录,然后再访问TP2,一次类推。按照记录在表页中存储的顺序进行读取,没有其他特殊的顺序。 - 聚簇索引扫描 读取索引片上第一个索引行,然后获取相应的表行,再访问第二个索引行,以此类推。如果索引行与对应的表行记录顺序完全一致(聚簇率为100%),那么除了第一次之外的所有表访问就都是顺序访问。表记录的链接方式跟索引不一样。单个表页中记录的顺序无关紧要,只要访问的下一个表记录在同一个表页或者相邻的下一个表页内就可以了。 同索引一样,存储表的传统方式也是将所有表页保留在一个连续的空间内。引起顺序杂乱或碎片化的因素也和索引中的相似,但又两个地方不同: - 如果往表中插入的记录在聚簇索引所定义的主页中装不下,则通常不会移动现有的行,而是会将新插入的记录存储到离主页尽可能近的表页中。对第二个页的随机I/O会使聚簇索引扫描变得更慢,但是如果这条记录离主页很近,这些额外的开销就可以被避免,因为顺预读功能会一次性将多个表页装载到数据库缓存中。即使顺序预读功能没有使用,也只有当该页在数据库缓存被覆盖的情况下才会发生额外的随机I/O。 - 一条记录被更新后,可能因为表行过长导致其无法再存储于当前的表页中。这是DBMA就必须将该行记录迁移至另外一个表页中,同时在原有的表页中存储指向新表页的指针。当该行被访问时,会引入额外的随机访问。 表可以通过重组来还原行记录的顺序,从而减少不必要的随机访问。 计算访问次数 随机访问 我们首先思考一下磁盘读与访问的区别。**一次磁盘读所访问的对象是一个页,而一次访问的访问对象则是一行。**一次随机磁盘读会将一整页(通常会包含很多行)读取至数据库的缓冲池中,但是根据定义,前后两次随机读不太可能会访问到同一个页。 使用满足需求的成本最低的索引还是所能达到的最有索引 当有多个等值谓词作为匹配列时,我们需要考虑这些列在索引上的先后顺序。经常变化的列应当尽可能的排在后面。 更改现有索引列的顺序和在现有索引列之间添加新列同样危险。在这两种情况下,现有的select的执行速度都可能会急剧下降,因为匹配列减少了,或者引入了排序(导致过早产生结果集) 半宽索引(最大化索引过滤) 在现有索引的末端添加缺少的谓词列可以消除大量的随机访问,因为这样能引入索引过滤过程。 影响索引设计过程的因素 I/O时间估算: - 随机读取 10ms(页的大小为4KB或8KB) - 顺序读取 40MB/s 这些数据是假定系统使用当前硬件并在一个合理的负载下运行时的值。一些系统可能运行的更慢或处理超负荷状态。 困难谓词 大体上,假设一个谓词的判定结果为false,而这时如果不检查其他谓词就不能确定地将一行记录排除在外,那么这类谓词对优化器而言就是太过困难的。 过滤因子隐患 当以下三个条件同时满足时,这种过滤因子隐患可能会产生 : - 访问路径中没有排序 - 第一屏结果一建立就回应 - 不是所有的谓词字段都参与定义带扫描的索引片–换句话说就是,不是所有的字段都是匹配字段。 被动式索引设计 被动式的方法与莱特兄弟创造守架飞机的经历非常相似。本质上就是把查询放在一起,推下悬崖,然后看他能否起飞。换句话说,就是为应用设计一个没有索引的原型,然后开始运行一些查询。又或者,创建原始索引集,然后通过运行应用来看那些索引被用到,那些没有被用到。即使是一个小型的数据库系统,运行速度慢的查询也会被很快凸显出来。 被动式调优的方法也被用来理解和调优一个性能没有满足预期的已有应用。 对结果集排序 除了全表扫描和全索引扫描,结果集的排序就是最有用的警示信号了。引起排序的原因可能有以下两种: - 没有可使查询语句避免排序的索引。 - 优化器所选择的访问路径包含了一次多余的排序。 有很多数据库顾问将排序视为敌人。我们认为,哪些强调随机I/O带来致命影响的顾问更值得信任。 成本估算 一些数据库管理系统的EXPLAIN功能显示了优化器对所选访问路径的本地响应时间的估算,或至少显示了对CPU时间的估算。 不幸的是,以下两个严重问题限制了使用成本估算方法的价值: - 优化器所做出的的本地响应时间估算可能与实际相差很大 - 当谓词使用绑定变量时(显然这是很普遍的),优化器对过滤因子的估算是基于平均输入值的,或更差情况下,基于默认值。为了获取更有价值的最差情况估值,EXPLAIN中的绑定变量必须用最差情况下的输入值来代替。这是一个需要应用知识的累人操作。 为表连接设计索引 预测表的访问顺序 在大部分情况下,可以使用以下经验法则来预测最佳的表访问顺序 : 将包含最低数量本地行的表作为外层表。 本地行的数量是指最大过滤因子过滤本地谓词之后所剩余的行数。 经验法则忽略了以下因素: - 排序。 - 很小的表。非常小的表及其索引可能长期存在于数据库的缓冲池中,至少在一个连接查询中,没有页会被读取多次。在这样的表和索引上进行随机读取所耗费的时间小于0.1ms,至少在页被第一次读取之后是这样的。所以,当这样的表不是外层表时,对其大量的随机读取也不会称为问题。 - 聚簇比例。索引中行的顺序和表中行的顺序的关联性(对于聚簇索引而言,该关联性在表重组后为100%),可能会影响对最佳访问顺序的选择,当然,除非索引是宽索引。 最好的基于成本的优化器在进行路径选择时会把这些因素考虑进来。因此,他找出的访问顺序可能比我们基于本地行的数量的经验所得出的结果更优。 合并扫描连接和哈希连接 合并扫描连接 执行过程如下 - 执行表或索引扫描以找出满足本地谓词的所有行。 - 随后可能会进行排序,如果这些扫描未按所要求的顺序提供结果集。 - 对前两者生成的临时表进行合并。 在以下情况下,合并扫描会比嵌套循环快。 - 用于连接的字段上没有可用的索引。在这种情况下,若使用嵌套循环,那么内层表可能需要被扫描很多次。在实际情况中,用于连接的列上面没有索引的情况很少见,因为大部分连接谓词都是基于“主键等于外键”这一条件的。 - 结果表很大。在这种情况下,若使用嵌套循环连接,可能会导致相同的页被不断的重复访问。 - 连接查询中不止一张表的过滤因子很低。如我们所见,嵌套循环可能导致对内层表(或者内层表索引)的大量随机访问。 例如: 1 2 3 4 5 6 7 8 | DECLARE CURSOR81 CURSOR FOR SELECT CNAME, CTYPE, INO, IEUR FROM CUST, INVOICE WHERE CUST.CTYPE = :CTYPE AND IDATE > :IDATE AND CUST.CNO = INVOICE.CNO | 哈希连接 哈希连接本质上是用哈希算法代替排序算法的合并扫描连接。首先,对较小的结果集用哈希算法计算其连接字段,并将其保存在一个临时表中;然后,再扫描其他的表(或索引片),并通过(计算得到的)哈希值将满足本地谓词条件的每一行记录与临时表中相应的行进行匹配。 若结果行集已在索引中满足了所要求的顺序,那么合并扫描的速度将更快。若合并扫描需要进行排序,那么哈希连接的速度可能更快,尤其是当其中一个行集能够全部留在内存中时(对一个哈希表进行一次随机访问所花费的CPU时间,通常会比排序和合并一行所花费的时间少)。如果两个行集很大,那么哈希表会根据可用内存的大小对哈希表进行分区(同一时间内存中只有一个分区),而另外一个行集会被扫描多次。 为什么连接的性能表现较差 模糊的索引设计 “在连接字段上建索引”是最古老的索引建议之一。事实上,这是基于建议的一个扩展 : “为主键创建一个索引,并且为每一个外键创建一个由此外键作为前导列的索引”。在连接谓词上建索引使得嵌套循环称为一个可行的方案,但包含连接谓词列并不一定能够提供完全可接受的响应时间。连接谓词列上的索引和本地谓词上的索引通常都需要是宽索引。而且,不同的表访问顺序可能导致完全不同的索引需求。 为子查询设计索引 从性能的角度看,子查询与连接十分相似。**实际上,现今的优化器通常会在进行访问路径的选择之前,先将子查询重写为一个连接。**若优化器没有进行重写,那么子查询的类型本身可能就决定了表访问顺序。内外层无关联的子查询通常会从最内层的SELECT开始执行。结果集被保存在一张临时表中,等待下一个SELECT的访问。内外层有关联的子查询通常会从最内层的SELECT开始执行。无论是何种情况,同连接一样,应当基于能够形成最快访问路径的表访问顺序进行索引设计。若最佳的表访问顺序未被选中,那么程序开发人员可能需要对语句进行重写,在某些情况下还可能要使用连接。 为UNION语句设计索引 通过UNION或UNION ALL连接的SELECT语句是逐个分别进行优化和指向的。因此,应该为每个独立的SELECT设计合适的索引。需要注意一点,带ORDER BY的UNION可能会导致提前物化。 对于表设计的思考 冗余数据 有两种通过冗余数据优化连接速度的方法: - 将某列拷贝至依赖表(向下反范式法)。 - 将汇总数据添加至父表(向上反范式法)。 向下反范式化 不过,总体而言,当我们考虑引入向下反范式化时,需要预测一下冗余字段更新时可能会导致最差情况下的索引随机访问次数。 反范式化的成本 考虑性能时最令人关注的通常是,为了更新表及索引上的冗余字段锁带来的I/O时间。在向下反范式化中,这可能需要移动大量的索引行,从而导致一个简单的UPDATE运行的很慢。向上反范式化不太可能因为一次简单的更新操作而引发I/O剧增。不过INSERT,UPDATE和DELETE可能导致父表及其索引上的一些额外I/O。在极端情况下,如每秒10次以上的INSERT或UPDATE,由这些I/O带来的磁盘负载可能会成为问题。 嵌套循环连接和合并扫描连接/哈希连接 VS 反范式化 许多数据库专家不愿意将冗余列添加至事务型表上,这是可以理解的。反范式化不仅仅是查询速度和更新速度之间的一个权衡,在某种程度上,他还是性能和数据完整性之间的一个权衡,即使在使用触发器来维护冗余数据的情况下。然而,当嵌套循环引入了过多的随机访问,且MS/HJ耗费了过多的CPU时间时,反范式化可能成为唯一的选择。尽管如此,在决定采用这一极端的方案之前,我们必须确保所有能够避免这一方案的方法都已经考虑过了。 无意识的表设计 从性能的角度看,我们非常难以理解为何有那么多的数据库中存在具有1 : 1或1: C(C = 有条件的;即0或1)关系的表。 为何要建四张表而非只建一张CUST表?只要关系永远不会变成1 : M,那么灵活性就不会成为问题。在本例中,客户要么是公司,要么是个人,且不会有客户死亡两次! 将这四张表合成一张部分字段为空的表(对于每一行,要么公司相关的字段为空,要么个人相关的字段为空;同样,所有活着的客户的死亡相关的字段为空),这是存储空间和性能(随机访问的次数)之间的权衡。为空的数据并不违反范式。 在不考虑硬件性能的情况下设计表可能会有如下问题 : - 即便是在最佳索引条件下,随机访问的次数仍可能会很高。 - 复杂连接可能使得索引设计变得非常困难。 - 优化器可能对复杂连接做出错误的访问路径选择。 星型连接 介绍 星型连接与普通连接的差别主要有两个方面: - 如下图所示,位于星型结构中心位置的表称为事实表,它的数据量远大于它周围的表—维度表。 - 最佳的访问路径通常是包含维度表的笛卡尔积,这意味着他们没有相同的冗余列,满足本地谓词的维度表数据行都会参与连接。 SALES 销售记录 STORE 出售的店铺 ITEM 出售的商品 DATE 出售的时间 CUST 出售的客户 在星型连接中,事实表的数据量通过都比较大。在这种情况下,至少依据经验法则,事实表应该作为嵌套循环连接方式中最内层的表。一般情况下未读表都没有共同的列,所以这种链接顺序意味着是笛卡尔连接。 事实表的索引 事实上,宽索引通常比事实表还大,原因有两个 : - 表通常都进行了压缩处理,而索引没有。 - 新增一条记录通常都追加到表的尾部,因此事实表并不需要分散的空闲空间。但插入到索引(除了聚簇索引)上的位置是随机的。为了避免频繁的索引重组,在当前硬件条件下,通常10亿行的表将花费几个小时,大多数的索引在叶子节点上都需要留出足够的空闲空间,可能为30% ~ 40%。 汇总表 即使是在理想索引的情况下,一些针对10亿条记录的事实表进行的查询也会导致大量的I/O访问。提高这类查询的性能的唯一方式就是使用汇总表(查询表)。这类表是反范式化的事实表。如果表不是特别大(比如只包含几百万行数据),那么这是一个比较可行的方案,因为反模式化查询只需针对汇总表,而不需要多表关联。 如果频繁查询每周的销售情况,那么可以针对每周的消费记录建立一张汇总表。比较好的汇总表设计是根据周,商品,商店汇总一条记录。汇总表根据周和商品进行汇总后,数据量可以大幅度减少。在这种情况下,查询的响应时间可能降低到不到1秒钟。 汇总表上的索引通常会比较小,他唯一的显示因素就是刷新此表所需的时间。如同所有新鲜事物一样,汇总表的方案也会带来新的问题。 - 如果用户的查询需求多样,汇总表的设计会比索引的设计更困难,不过,已经有一些工具基于查询日志来协助汇总表的设计。 - 如果优化器不能选择正确的汇总表,那么… ### Java性能优化权威指南 笔记 - URL: https://jiankunking.com/java-performance.html - Content type: original - Published: 2019-08-03 - Updated: 2019-08-03 - Summary: 《Java性能优化权威指南》学习笔记,整理 JVM、垃圾回收、性能监控、基准测试和应用调优方法。 - Categories: Java - Tags: Performance, Reading Notes, Java, JVM Article text: 文章速览 《Java性能优化权威指南》学习笔记,整理 JVM、垃圾回收、性能监控、基准测试和应用调优方法。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自:《Java性能优化权威指南》 作者:Charlie Hunt / Binu John 出版时间:2014-03 操作系统性能监控 CPU使用率 大多数的操作系统的CPU使用率分为用户态CPU使用率和系统态CPU使用率。 用户态CPU使用率是指执行应用程序代码的时间占总CPU时间的百分比。 系统态CPU使用率是指应用执行操作系统调用的时间占总CPU时间的百分比。系统态CPU使用率高意味着共享资源有竞争或者I/O设备之间有大量的交互。 既然原本用于执行操作系统内核调用的CPU周期也可以用来执行应用代码,所以理想情况下,应用达到最高性能和扩展性时,它的系统态CPU使用率为0%,所以提高应用性能和扩展性的一个目标是尽可能降低系统态CPU使用率。 对于计算密集型应用来说,不仅要监控用户态和系统态CPU使用率,还要进一步监控每时钟指令数(Instructions Per Clock,IPC)或每指令时钟周期(Cycles Per Instruction,CPI)等指标。这两个指标对于计算密集型应用来说很重要,因为现代操作系统自带的CPU使用率监控工具只能报告CPU使用率,而没有CPU执行指令占用CPU时钟周期的百分比,这意味着,即便CPU在等待着内存中的数据,操作系统工具仍然会报告CPU繁忙。这种情况通常被称为停滞。当CPU执行指令所用的操作数据不在寄存器或者缓存中时,就会发生停滞,由于指令执行前必须等待数据从内存中装入CPU寄存器,所以一旦发生停滞,就会浪费时钟周期。CPU停滞通常会等待好几百个时钟周期,因此提高计算密集型应用性能的策略就是减少停滞或者改善CPU高速缓存使用率,从而减少CPU在等待内存数据时浪费的时钟周期。 CPU调度程序运行队列 监控CPU调度程序运行队列对于分辨系统是否满负荷也有重要意义。运行队列中就是那些已准备好运行、正等待可用CPU的轻量级进程。如果准备运行的轻量级进程数超过系统所能处理的上限,运行队列就会很长。运行队列长表明系统负载可能饱和。系统运行队列长度等于虚拟机处理器的个数时,用户不会明显感觉到性能下降。此处虚拟处理器的个数就是系统硬件线程的个数,也是Java API Runtime.availableProcessors()的返回值。当运行队列长度达到虚拟处理的4倍或者更多时,系统的响应就非常迟缓了。 一般性的指导原则是:如果在很长一段时间里,运行队列的长度一直都超过虚拟处理器个数的1倍,就需要关注了,只是暂时还不需要立刻采取行动。如果在很长一段时间里,运行队列长度达到虚拟处理器个数的3~4倍或更高,则需要立刻引起注意和采取行动。 内存使用率 系统在进行页面交换或者使用虚拟内存时,Java应用或JVM会表现出明显的性能问题。当应用运行所需的内存超过可用物理内存时,就会发生页面交换。为了应对这种可能出现的情况,通常要为系统配置swap空间。swap空间一般会在一个独立的磁盘分区上。当应用耗尽内存时,操作系统会将应用的一部分置换到磁盘上的swap空间。通常是应用中最少运行的部分,以免影响整个应用或者应用最忙的那部分。当访问应用中被置换出去的部分时,就必须将它从磁盘置换进内存,而这种置换活动会对应用的响应性和吞吐量造成很大影响。 JVM垃圾收集器在系统页面交换时的性能也很差,这是由于垃圾收集器为了回收不可达对象所占用的空间,需要访问大量的内存。如果Java堆得一部分被置换出去,就必须先置换进内存以便垃圾收集器扫描存活对象,这会增加垃圾收集的持续时间。垃圾收集是一种Stop-The-World操作,即停止所有正在运行的应用线程,如果此时系统正在进行页面交换,则会引起JVM长时间的停顿。 监控抢占式上下文切换 让步式上下文切换时指执行线程主动释放CPU,抢占式上下文切换时指线程因为分配的时间片用尽而被迫放弃CPU或者被其他优先级更高的线程锁抢占。pidstat的输出结果中cswch/s是每秒的让步式上下文切换,nvccswch/s是抢占式上下文切换。 监控线程迁移 我们发现,待运行线程在处理器之前的迁移也会导致性能的下降。大多数操作系统的CPU调度程序会将待运行线程分配给上次运行它的虚拟处理器。如果这个虚拟处理器忙,调度程序就会将待处理线程迁移到其他可用的虚拟处理器。线程迁移会对应用性能造成影响,这是因为新的虚拟处理器缓存中可能没有待运行线程所需的数据或状态信息。多核系统上运行Java应用可能会发生大量的线程迁移,减少迁移的策略是创建处理器组并将应用分配给这些处理器组。一般性准则是,如果横跨多核或虚拟处理器的Java应用每秒迁移超过500次,将Java应用绑定在处理器组上就有好处。 网络I/O使用率 应用性能改进的考虑 单次读写数据量小而网络读写量大的应用会消耗大量的系统态CPU,产生大量的系统调用。对于这类应用,减少系统态CPU的策略是减少网络读写的系统调用。此外,使用非阻塞的Java NIO而不是阻塞的java.net.Socket,减少处理请求和发送相应的线程数,也可以改善应用性能。 从非阻塞Socket中读取数据的策略是,应用在每次读请求时尽可能多地读取数据。同样,当往Socket中写数据时,每个写调用应该尽可能多地写。 JVM概览 HotSpot 运行时 命令行选项 HotSpot VM 命令行选项有3类: - 标准选项(Standard option):标准选项是Java virtual Machine Specification要求所有java JVM 都必须实现的选项。 - 非标准选项(NonStandard option):非标准选项(以–X为前缀),不保证也不强制所有JVM实现都必须支持。 - 非稳定选项(Developer option):非稳定选项(以-XX为前缀),通常为了特定需要而对JVM的运行进行矫正。选项名称前+代表 true启用,-代表false关闭。 VM生命周期 启动器启动HotSpot VM时会执行一系列操作。步骤概述如下: - 解析命令行选项 - 设置堆的大小和JIT编译器 如果命令行没有明确设置堆的大小和JIT编译器,启动器则通过自动优化进行设置。 - 设定环境变量如:LD_LIBRARY_PATH和CLASSPATH - 如果命令行有-jar选项,启动器则从指定JAR的manifest中查找Main-Class,否则从命令行读取Main-Class - 使用标准Java本地接口(Java Native Interface,JNI)方法JNI_CreateJavaVM在新创建的线程中创建HotSpot VM - 一旦创建并初始化号HotSpot VM,就会加载Java Main-Class,启动器也会从Java Main-Class中取得Java main方法的参数 - HotSpot VM通过JNI方法CallStartVoidMethod调用Java main方法,并将命令行选项传给它 VM类加载阶段 类加载阶段 对于给定的Java类或接口,类加载时会依据它的名字找到Java类的二进制类文件,定义Java类,然后创建代表这个类或者接口的java.lang.Class对象。如果没有找到Java类或接口的二进制表示就会抛出NoClassDefFound。此外,类加载阶段会对类的格式进行语法检查,如果有错,则会抛出ClassFormatError或UnsupportedClassVersionError。Java类加载前,HotSpot VM必须先加载它的所有超类和超接口,如果类的继承层次有错,例如Java类是它自己的超类或超接口(类层次递归),HotSpot VM则会抛出ClassCircularityError。如果所引用的直接超接口本身并不是接口,或者直接超类实际上是接口,HotSpot VM则会抛出IncompatibleClassChangeError。 链接的第一步是验证,检查类文件的语义、常量池符号以及类型。如果检查有错,就会抛出VerifyError。链接的下一步是准备,它会创建静态字段,初始化为标准默认值,以及分配方法表。请注意,此时还没有执行任何Java代码。接下来解析符号引用,这一步是可选的。然后初始化类,运行类构造器。这是迄今为止,类中运行的第一段Java代码。值得注意的是,初始化类需要首先初始化超类(不会初始化超接口)。 如:int的标准默认值为0;public static int value=123,准备阶段将其初始化为0而不是123,value=123的赋值操作在类构造器()中。 public static final int value=123,编译时会为value在字段属性表中生成ConstantValue,从而在准备阶段就被初始化成123。 Java Virtual Machine Specification规定首次使用类时进行类初始化,而Java Language Specification则允许在链接阶段符号解析时灵活处理,只要保持语言的语义不变,JVM依次执行加载、链接和初始化,保证及时抛出错误即可。出于性能优化的考虑,通常直到类初始化时HotspotVM才会加载和链接类。这意味着,类A引用类B。加载A不一定导致加载B(除非B需要验证)。执行B的第一条指令会导致初始化B,从而加载和链接B。 类加载器委派 当请求类加载器查找和加载某个类时,该类加载器可以转而请求别的类加载器来加载。这被称为类加载器委派。类的首个类加找器称为初始类加载器(Initiating ClassLoader),最终定义类的类加载器称为定义类加载器(Defining ClassLoader)。就字节码解析而言,某个类的初始类加载器是指对该类进行常量池符号解析的类加载器。 类加载器之间是层级化关系,每个类加载器都可以委派给上一级类加载器。这种委派关系定义了二进制类的查找顺序。Java SE类加载器的层级查找顺序为启动类加载器、扩展类加载器及系统类加载器。系统类加载器是默认的应用程序类加载器,它加载Java类的main方法并从classpath上加载类。应用程序类加载器可以是Java SE系统自带的类加载器,或者由应用程序开发人员提供。扩展类加载器则JavaSE系统实现,它负责从JRE(Java Runtime Environment,Java运行环境)的lib/ext目录下加载类。 启动类加载器 启动类加载器是由HotSpot VM实现的,负责加载BOOTCLASSPATH路径中的类,如包含Java SE类库的rt.jar。为了加快启动速度,Client模式的HotSpot VM可以通过称为类教据共享(Class Data Sharing)的特性使用已经预加载的类。这个特性默认为开启,可由HotSpot VM命令行开关-Xshare:on开启,-Xshare:off关闭。到本书编写时为止,Server模式的HotSpot VM还不支持类数据共享,而且即便是Client模式,也只有使用Serial收集器时才支持该机制。 类型安全 Java类或接口的名字为全限定名(包括包名)。Java的类型由全限定名和类加载器唯一确定。 HotSpot类元数据 类加载时,HotSpot VM会在永久代创建类的内部表示instanceKlass或arrayKlass。instanceKlass应用了与之对应的java.lang.Class实例,后者是前者的Java镜像。HotSpot VM内部使用称为klassOop的数据结构访问instanceKlass。后缀“Oop”表示普通对象指针,所以klassOop是应用java.lang.Class的HotSpot内部抽象,它是指向Klass(与Java类对应的内部表示)的普通对象指针。 内部的类加载数据 类加载过程中,HotSpot VM维护了3张散列表。SystemDictionary包含已加载的类,它将建立类名/类加载器(包括初始类加载器和定义类加载器)与klassOop对象之间的映射。目前只有在安全点事才能移除SystemDictionary中的元素。Placeholder-Table包含当前正在加载的类,它用于检查ClassCircularityError,多线程类加载器并行加载类时也会用到它。LoaderConstraintTable用于追踪类型安全检查的约束条件。这些散列表都需要加锁保证访问安全,在HotSpot VM中,这个锁称为SystemDictionary_lock。通常,HotSpot VM借助类加载器对象锁对加载类的过程进行序列化。 字节码验证 Java是一门类型安全语言,官方标准的Java编译器(javac)可以生成合法的类文件和类型安全的字节码,但Java虚拟机无法确保字节码一定是由可信的javac编译器产生的,所以在链接时必须进行字节码验证以保障类型安全。 类数据共享 类数据共享是Java 5引人的特性,以缩短Java程序(特別是小程序)的启动时间,同时也能减少它们的内存占用。使用Java HotSpot JRE安装程序在32位平台上安装Java运行环境(JRE)时,安装程序会加载系统jar中的部分类,变成私有的内部表示并转储成文件,称为共享文档(Shared Archive)。如果过没有使用Java HotSpot JRE安装程序,也可以手工生成该文件。之后调用Java虚拟机时,共享文档会映射到JVM内存中,从而减少减少加载这些类的开销,也使得这些类的大部分JVM允数椐能在多个JVM进程间共享。 解释器 HotSpot VM解释器是一种基于模板的解释器。JVM启动时,HotSpot VM运行时系统利用内部TemplateTable中的信息在内存中生成解析器。TemplateTable包含于每个字节码对应的机器代码,每个模板描述一个字节码。 HotSpot VM解释器堪于模板的设计要好于传统的switch语句循环方式。switch语句需要重复执行比较操作,最差情况需要和所冇字节码比较。此外,switch语句必须使用单独的软件栈传递 Java 参数。HotSpot VM使用本地C栈传递 Java 参数。一些存储在C变量中的HotSpot VM内部变量,例如Java线程的程序计数器或栈指针,并不能保证总是存储在底层硬件寄存器中。结果,管理这些软件解释器数据结构就会占去总执行时间的相当大一部分。不过总体来说,HotSpot解释器显著缩短HotSpot VM和实体机之间的性能差距,解释速度也明显变快了,然而代价是大量与机器相关的代码。例如,Intel X86平台特定的代码大约有10000行,SPARC平台专用的代码大约打14000行。由于需要支持动态代码生成(JIT编译),整体的代码量和复杂度也显著变大。并且调 试动态生成的机器码( JIT编译代码)比调试静态代码困难多了。虽然这些不利于运行时系统的改善,但也并非不可能完成的任务。 异常处理 当与Java的语义约束冲突时,Java虚拟机会用异常通知程序。异常处理由HotSpot VM解释器、JIT编译器和其他HotSpot VM组件一起协作实现。异常处理主要有两种情形,同一方法中抛出和捕获异常,或由调用方法捕获异常。异常可以由抛出字节码、VM内部调用返回、JNI调用返回或Java调用返回所引发。 线程管理 HotSpot VM通过协作、轮询的机制创建安全点。简中来说,线程会经常询问:“我该在安全点停住么? ”高效地询问这个问题并不是件容易的事。线程在状态变迁的过程中,会经常询问这个问题,但并非所有的状态变迁都会如此询问,比如线程离开HotSpot VM进入本地代码的情况。此外,JIT编译代码从java方法中返回或正作循环迭代的某个阶段时,线程也会询问“我该在安全点停住吗? ”。正在执行解释代码的线程通常不会询问它们是否该在安全点停住。相反,当解释器切换到不同的分配表时,会请求安全点。切换操作中包含一部分代码,用以询问何时离开安全点。当离开安全点时,分配表会再次切换回来。一旦请求了安全点,VMThread就必须在继续执行VM操作前等待,直到确定所行线程都已进入安全点保全状态为止。在安全点时,VMThread用Threads_lock阻塞所有正在运行的线程,VM操作完成后 , VMThread释放Threads_lock。 Java本地接口(JNI) 切记,一旦在应用中使用JNI,就意味着丧失了Java平台的两个好处。首先,依赖JNI的Java应用难以在多种异构的硬件平台上运行。即便应用中Java语言编写的部分可以移植到多种硬件平台,采用本地编程语言的部分也需要重新编译。换句话说,一旦使用JNI就失去了Java承诺的特性,即“一次编写,到处运行”。其次,Java是强类型和安全的语言,本地语言如C或C++则不是。因此,Java开发者用JNI编写应用时必须格外小心。误用本地方法可能破坏整个应用。鉴于此,在调JNI方法前,Java应用常常需要安仝检查。额外的安全检查以及HotSpot VM在Java与JNI之间的数据复制会降低应用的性能。 HotSpot VM追踪正在执行本地方法的线程时必须特別小心。在HotSpot VM的某些活动过程中,尤其是垃圾收集的某些阶段,线程必须在安全点时暂停,以保证Java内存堆不被更改,确保垃圾收集的准确性。当HotSpot VM线程执行本地代码到达安全点时,线程可以继续执行本地代码,直到它Java代码或者发起JNI调用为止。 VM致命错误处理 HotSpot内部使用信号进行通信。当无法识别信号时,将调用致命错误处理程序。在无法识别的情况下,它可能来自应用程序JNI代码,OS本地库,JRE本地库或JVM本身的错误。 HotSpot VM垃圾收集器 分代垃圾收集 垃圾收集器不需要扫描整个(可能比新生代更大)老年代就能识别新生代中的存活对象,从而缩短Minor GC的时间。HotSpot VM的垃圾收集器使用称为卡表(CardTable)的数据结构来达到这个目的。老年代以512字节为块划分成若十张卡(Card)。卡表是个单字节数组,每个数组元素对应堆中的一张卡。每次老年代对象中某个引用新生代的字段发生变化时,HotSpot VM就必须将该卡所对位的卡表元素设置为适当的值,从而将该引用字段所在的卡标记为脏。在Minor GC过程中,垃圾收集器只会在脏卡中扫描查找老年代-新生代引用。 HotSpot VM的字节码解释器和JIT编译器使用写屏障(Write Barrier)维护卡表。写屏障是一小段将卡状态设罝为脏的代码。解释器每次执行更新引用的字节码时,都会执行一段写屏障;JIT 编译器在生成更新引用的代码后,也会生成一段写屏障。虽然写屏障使得应用线程增加了一些性能开销,但Minor GC变快了许多,整天的垃圾收集效率也提高了许多。通常应用的吞吐量也会有所改善。 新生代 需要指出的是,在Minor GC过程中,Survivor可能不足以容纳Eden和另一个Survivor中的存活对象。如果Survivor 中的存活对象溢出,多余的对象将被移到老年代。这称为过早提升(Premature Promotion)。这会导致老年代中短期存活对象的增长,可能会引发严重的性能问题。再进一步说,在Minor GC过程中,如果老年代满了而无法容纳更多的对象,Minor GC之后通常就会进行Full GC,这将导致遍历整个Java堆。这称为提升失败(Promotion Failure)。 快速内存分配 对象内存分配器的操作需要和垃圾收集器紧密配合。垃圾收集器必须记录它回收的空间,而分配器在重用堆空间之前需要找到可以满足其分配需求的空闲空间。垃圾收集器以复制方式回收HotSpot VM新生代,其好处在于回收以后Eden总为空,在Eden中运用被称为指计碰撞(Bump-the-Pointer)的技术就可以有效地分配空间。这种技术追踪最后一个分配的对象(常称为top),当有新的分配请求时,分配器只需要检查top和eden未端之间的空间是否能容纳。如果能容纳,top则跳到新近分配对象的未端。 重要的Java应用大多是多线程的,因此内存分配的操作需要考虑多线程安全。如果只用全局锁,在Eden中的分配操作就会成为瓶颈而降低性能。HotSpot VM没有采用这种方式,而是以一种称为线程本地分配缓冲区(thread-Local Allocation Buffer,TLAB)的技术,为每个线程设置各自的缓冲区(即Eden的一小块),以此改善多线程分配的吞吐量。因为每个TLAB都只有一个线程从中分配对象,所以可以使用指针碰撞技术快速分配而不需要任何锁。然而当线程的TLAB填满需要获取新的空间时(不常见),它就需要采用多线程安全的方式了。大部分时候,HotSpot VM的new Object()操作只需要大约十条指令。垃圾收集器清空Eden区域,然后就可以支持快速内存分配了。 HotSpot VM JIT编译器 经典的寄存器分配策略是图着色算法,通常可以使机器寄存器的使用率达到最高,而且多余的值很少会卸载到栈中。图表示的是同时有哪些变量在使用.以及哪些寄存器可以存放这些变量。如果同时存活的变量数超过了可用的寄存器数,重要性最低的变量将被移到栈中,使得其他变量可以使用寄存器。指派某个变量给寄存器通常需要来回几次构建图和着色。这也导致了它的不足,图着色算法花费的时间、数据结构所需的空间都比较昂贵。 JVM性能监控 垃圾收集 重要的垃圾收集数据 重要的垃圾收集数据包括: - 当前使用的垃圾收集器 - Java堆的大小 - 新生代和老年代的大小 - 永久代的大小 - Minor GC的持续时间 - Minor GC的频率 - Minor GC的空间回收量 - Full GC的持续时间 - Full GC的频率 - 每个并发垃圾收集周期内的空间回收量 - 垃圾收集前后Java堆的占用量 - 垃圾收集前后新生代和老年代的占用量 - 垃圾收集前后永久代的占用量 - 是否老年代或永久代的占用触发了 Full GC - 应用是否显式调用了 System.gc() 垃圾回收报告 JIT编译器 可以使用-XX:+PrintCompilation监控HotSpot JIT编译器。-XX:+PrintCompilation为每次编译生成一行日志。 日志样例如下: 1 2 3 4 5 6 7 8 | 7 java. lang String: indexOf (151 bytes) 8% ! sun. awt. image. PNGImageDecoder: produceImage a 960 (1920 bytes) 9 ! sun awt. image. PNGImageDecoder: produceImage (1920 bytes) 10 java. lang. AbstractStringBuilder: append(40 bytes) 11 n java. lang System: arraycopy (static) 12 s java util. Hashtable: get (69 bytes) 13 b java util. HashMap: indexFor (6 bytes) 14 made zombie java. awt. geom. Path2DSIterator: isDone (20 bytes) | JVM性能调优入门 应用程序的系统需求 吞吐量 吞吐量是对单位时间内处理工作量的度量。设计吞吐量需求时,我们一般不考虑它对延迟或者响应时间的影响。通常情况下,增加吞吐量的代价是延迟的增加或内存使用的增加。 吞吐量性能需求的一个典型例子是,应用程序每秒需要完成2500次事务。 延迟或响应性 延迟,或者响应性,是对应用程序收到指令开始工作直到完成该工作所消耗时间的度量。 定义延迟或响应性需求时并不考虑程序的吞吐量。通常情况下,提高响应性或缩小延迟的代价是更低的吞吐量、或者更多的内存消耗(或者二者同时发生) 延迟或响应需求的一个典型例子是,应用程序应该在60毫秒内完成交易请求的处理工作。 内存占用 内存占用指在同等程度的吞吐量、延迟、可用性和可管理性前提下,运行应用程序所需的内存大小。内存占用通常以运行应用程序需要的Java堆大小或者运行应用程序需要的总内存大小来表述。一般情况下,通过增大Java堆的方式增加可用内存能够提高吞吐量、降低延迟或者兼顾二者。应用程序的可用内存减少时,吞吐量和延迟通常都会受到影响。应用程序的内存占用限制了固定内存的机器上能同时运行的应用程序实例数。 内存占用需求的一个典型例子是,应用程序需要在拥有8GB内存的系统上以单个实例方式运行或者在24GB内存的系统上以3个应用程序实例方式运行。 性能收集调优基础 性能属性 - 吞吐量:是评价垃圾收集器能力的重要指标之一,指不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用程序达到的最高性能指标。 - 延迟:也是评价垃圾收集器能力的重要指标,度量标准是缩短由于垃圾收集引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用程序运行时发生抖动。 - 内存占用:垃圾收集器流畅运行所需要的内存数量。 这其中任何一个属性性能的提高几乎都是以另一个或两个属性性能的损失作代价的。换句话说,某一个属性上的性能提高总会牺牲另一个或两个属性。然而,对大多数的应用而言,极少出现这三个属性的重要程度都同等的情况。很多时候,某一个或两个属性的性能要比另一个重要。 我们需要了解对应用程序而言哪些系统需求是最重要的,也需要知道对应用程序而言这三个性能属性哪些是最重要的。确定哪些属性最重要,并将其映射到应用程序的系统需求,对应用程序而言非常重要。 原则 谈到JVM垃圾收集器调优也有三个需要理解的基本原则。 - 每次Minor GC都尽可能多地收集垃圾对象。我们把这称作“Minor GC回收原则”。遵守这原则可以减少应用程序发生 Full GC的频率。Full GC的持续时间总是最长的,是应用程序无法达到其延迟或吞吐量要求的罪魁祸首。 - 处理吞吐量和延迟问题时,垃圾处理器能使用的内存越大,即Java堆空间越大,垃圾收集的效果越好,应用程序运行也越流畅。我们称之为“GC内存最大化原则”。 - 在这三个性能属性(吞吐量、延迟、内存占用)中任意选择两个进行JVM垃圾收集器调优。我们称之为“GC调优的3选2原则”。 调优JVM垃圾收集的过程中谨记这三条原则能帮助你更轻松地调优垃圾收集,达到应用程序的性能要求。 确定内存占用 HotSpot VM堆布局 通过-Xmn可以很方便地设定新生代空间的初始值和最大值。有一点需要特别注意,如果-Xms和-Xmx并没有设定为同一个值,使用-Xmn选项时,Java堆的大小变化不会影响新生代空间,即新生代空间的大小总保持恒定,而不是随着Java堆大小的扩展或缩减做相应的调整。因此,请注意,只有在-Xms与-Xmx设定为同一值时才使用-Xmn选项。 老年代空间的大小会根据新生代的大小隐式设定。老年代空间的初始值为-Xmx的值减去XX: NewSize的值。老年代空间的最小值为-Xmx的值减去-XX:MaxNewSize的值。如果-Xms与Xmx设置为同一值,同时使用了-Xmn,或者-XX:NewSize与-XX:MaxNewsize一样,则老年代的大小为-Xmx(或-Xms)的值减去-Xmn。 实际上,当HotSpot VM发现当前可用空间不足以容纳下一次Minor GC提升的对象时就会进行Full GC。与因空间问题导致的Minor GC过程中的对象提升失败比较起来,这种方式的代价要小得多。从失败的对象提升中恢复是一个很昂贵的操作。永久代没有足够的空间存储新的VM或类元数据时也会发生Full GC。 如果Full GC缘于老年代空间已满,即使永久代空间并没有用尽,老年代和永久代都会进行垃圾收集。同样,如果Full GC由永久代空间用尽引起,老年代和永久代也都会进行垃圾收集,无论老年代是否还有空闲空间。开启-XX:+UseParallelGC或-XX:+UseParallelOldGC时,如果关闭-XX:-ScavengeBeforeFullG… ### 程序员常用词汇 - URL: https://jiankunking.com/coder-vocabulary.html - Content type: original - Published: 2019-08-01 - Updated: 2019-08-01 - Summary: 持续整理程序员在架构设计、软件开发、测试、运维和团队协作中常见的中英文技术词汇、缩写及概念,方便工作沟通和快速查阅。 - Categories: Misc - Tags: Misc Article text: 文章速览 持续整理程序员在架构设计、软件开发、测试、运维和团队协作中常见的中英文技术词汇、缩写及概念,方便工作沟通和快速查阅。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Misc 整理工作中常用的技术词汇,包括架构、开发、运维等领域的专业术语,持续更新中。 在线:程序员常用词汇 ### [译]ZGC: 一个可伸缩的低延迟垃圾收集器 - URL: https://jiankunking.com/zgc-a-scalable-low-latency-garbage-collector.html - Content type: translation - Published: 2019-07-31 - Updated: 2019-07-31 - Summary: 翻译介绍 ZGC 的设计目标、并发标记与转移机制、性能表现,以及低延迟垃圾回收的实现思路。 - Categories: Java - Tags: JVM, GC, ZGC, Low-Latency - Original source: https://openjdk.java.net/jeps/333 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]ElasticSearch 查询的秘密 - URL: https://jiankunking.com/elasticsearch-query-secret.html - Content type: repost - Published: 2019-07-29 - Updated: 2019-07-29 - Summary: 分析 Elasticsearch 的倒排索引、Term Dictionary、Term Index、FST 与 Posting List 压缩原理,解释高效检索的基础。 - Categories: Elasticsearch - Tags: Elasticsearch, Query - Original source: https://blog.csdn.net/wlei0618/article/details/125846561 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### [转]Epoll 的本质是什么? - URL: https://jiankunking.com/epoll-principle.html - Content type: repost - Published: 2019-05-24 - Updated: 2019-05-24 - Summary: 从网卡收包、CPU 中断和进程调度出发,分析 select、poll 到 epoll 的演进过程及高性能事件通知原理。 - Categories: Linux - Tags: Linux, Epoll - Original source: https://my.oschina.net/editorial-story/blog/3052308 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Java 原子操作的实现原理 - URL: https://jiankunking.com/java-atomic-operation-principle.html - Content type: original - Published: 2019-03-27 - Updated: 2019-03-27 - Summary: 介绍 Java 原子操作的实现原理,分析处理器缓存、总线锁、缓存锁和 CAS 等底层机制。 - Categories: Java - Tags: Java, Atomic, Concurrent Article text: 文章速览 介绍 Java 原子操作的实现原理,分析处理器缓存、总线锁、缓存锁和 CAS 等底层机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自《Java并发编程的艺术》第二章 作者:方腾飞 魏鹏 程晓明 原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。 术语定义 在了解原子操作的实现原理前,先要了解一下相关的术语: 术语名称 英文 解释 缓存行 | Cache line | 缓存的最小操作单位 | 比较并交换 | Compare and Swap | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。 | CPU流水线 | CPU pipeline | CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5~6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5~6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度 | 内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线 | 处理器如何实现原子操作 32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。 在Intel 2019年的文档中,该部分阐述基本不变,具体可以查考文末Intel文档的2957页 8.1 LOCKED ATOMIC OPERATIONS 使用总线锁保证原子性 第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如图2-3所示。 原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。 Intel文档的2959页 8.1.2 Bus Locking 使用缓存锁保证原子性 第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。 频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图2-3所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。 但是有两种情况下处理器不会使用缓存锁定: - 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。 - 第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。 Intel文档的2961页 8.1.4 Effects of a LOCK Operation on Internal Processor Caches Java如何实现原子操作 在Java中可以通过锁和循环CAS的方式来实现原子操作。 使用循环CAS实现原子操作 JVM中的CAS操作正是利用了处理器提供的CMPXCHG(Compare and Exchange)指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | private AtomicInteger atomicI = new AtomicInteger(0); private int i = 0; public static void main(String[] args) { final Counter cas = new Counter(); List ts = new ArrayList(600); long start = System.currentTimeMillis(); for (int j = 0; j < 100; j++) { Thread t = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) { cas.count(); cas.safeCount(); } } }); ts.add(t); } for (Thread t : ts) { t.start(); } // 等待所有线程执行完成 for (Thread t : ts) { try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(cas.i); System.out.println(cas.atomicI.get()); System.out.println(System.currentTimeMillis() - start); } /** * 使用CAS实现线程安全计数器 */ private void safeCount() { for (; ; ) { int i = atomicI.get(); boolean suc = atomicI.compareAndSet(i, ++i); if (suc) { break; } } } /** * 非线程安全计数器 */ private void count() { i++; } | 从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。 CAS实现原子操作的三大问题 在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。CAS虽然很高效地解决了原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。 - ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 1 2 3 4 5 6 | public boolean compareAndSet( V expectedReference, // 预期引用 V newReference, // 更新后的引用 int expectedStamp, // 预期标志 int newStamp // 更新后的标志 ) | - 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。 - 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。 使用锁机制实现原子操作 锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。 推荐阅读 Intel® 64 and IA-32 architectures software developer’s manual ### [转]阿里分布式事务解决方案 Fescar 解析 - URL: https://jiankunking.com/alibaba-seata-design.html - Content type: repost - Published: 2019-03-01 - Updated: 2019-03-01 - Summary: 转载分析阿里开源分布式事务中间件Fescar(后改名Seata),了解分布式事务框架的设计思路和实现原理。 - Categories: Architecture - Tags: Fescar, Seata, Distributed, Transaction, 2PC - Original source: https://github.com/alibaba/fescar - Original author: 阿里巴巴中间件团队 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 查看MySQL InnoDB 表索引的高度 - URL: https://jiankunking.com/view-the-height-of-mysql-innodb-table-indexes.html - Content type: original - Published: 2019-02-18 - Updated: 2019-02-18 - Summary: 通过实例演示如何使用 innodb_ruby 查看 MySQL InnoDB 的 B+Tree 索引高度,并解释高扇出结构的特点。 - Categories: MySQL - Tags: MySQL, Index, InnoDB, height Article text: 文章速览 通过实例演示如何使用 innodb_ruby 查看 MySQL InnoDB 的 B+Tree 索引高度,并解释高扇出结构的特点。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL B+树索引在数据库中有一个特点就是高扇出性,因此B+树的高度一般都在2-4层。本文通过实际案例演示如何使用innodb_ruby工具查看MySQL InnoDB表索引的高度。 在看《MySQL技术内幕:InnoDB存储引擎》B+树索引章节中看到这么一句话: 但是B+索引在数据库中有一个特点就是高扇出性,因此在数据库中,B+树的高度一般都在2-4层,也就是说查找某一键值的行记录时最多只需要2-4次IO。因为当前一般的机械磁盘每秒至少可以做100次IO,2-4次的IO意味着查询时间只需要0.02-0.04秒。 那么,当一个表很大的时候,索引还是是2-4层吗?那么这时搜索子节点会不会很慢? 下面通过user库中的uc_users表,来验证一下。 表结构如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | CREATE TABLE `uc_users` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(40) DEFAULT NULL COMMENT 'username (login principal)', `connection_id` bigint(20) DEFAULT NULL COMMENT 'username (login principal)', `email` varchar(254) DEFAULT NULL COMMENT 'email (login principal)', `email_verified` tinyint(1) NOT NULL DEFAULT '0', `phone_number` varchar(20) DEFAULT NULL COMMENT 'mobile phone number (login principal?)', `phone_verified` tinyint(1) NOT NULL DEFAULT '0', `display_name` varchar(40) DEFAULT NULL COMMENT 'name for displaying', `nickname` varchar(40) DEFAULT NULL COMMENT 'nickname', `given_name` varchar(40) DEFAULT NULL COMMENT 'given name or first name', `family_name` varchar(40) DEFAULT NULL COMMENT 'family name or surname', `middle_name` varchar(40) DEFAULT NULL COMMENT 'middle name', `avatar_url` varchar(2000) DEFAULT NULL COMMENT 'avatar image url', `password` varchar(255) DEFAULT NULL COMMENT 'password hash (login credential)', `password_strength` int(11) DEFAULT NULL COMMENT 'password strength', `enabled` tinyint(1) NOT NULL DEFAULT '1', `locked` tinyint(1) NOT NULL DEFAULT '0', `type` smallint(6) NOT NULL COMMENT 'type (for lite-auth)', `source` varchar(100) DEFAULT NULL COMMENT 'where user come from', `last_login_at` timestamp NULL DEFAULT NULL, `gender` varchar(10) DEFAULT NULL, `birth_date` varchar(10) DEFAULT NULL, `zone_info` varchar(20) DEFAULT NULL, `locale` varchar(20) DEFAULT NULL, `website` varchar(2000) DEFAULT NULL, `address` varchar(1000) DEFAULT NULL, `metadata` varchar(5000) DEFAULT NULL, `created_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, `external_source` varchar(255) DEFAULT NULL, `external_id` varchar(255) DEFAULT NULL, `reg_client_id` varchar(128) DEFAULT NULL, `version` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `index_phone_number` (`phone_number`), KEY `index_email` (`email`), KEY `index_username` (`username`), KEY `index_created_at` (`created_at`), KEY `index_updated_at` (`updated_at`) ) ENGINE=InnoDB AUTO_INCREMENT=2022228456 DEFAULT CHARSET=utf8 COMMENT='user table'; | 索引信息如下: 数据量:5968 8046 查看index level 通过innodb_ruby查看。 具体语法参见:https://github.com/jeremycole/innodb_ruby/wiki 方法一 1、登录MySQL对应机器,找到MySQL数据存放位置 1 | ps -ef | grep MySQL | 2、找到ibdata文件位置 3、执行如下命令 1 | innodb_space -s ibdata1 -T user/uc_users space-indexes | 执行结果如下: - id:表示此索引的ID。 - name:索引的名称,PRIMARY代表的就是聚集索引,因为InnoDB表是聚集索引组织表,行记录就是聚集索引;idx_c就是辅助索引的名称。 - root:索引中根节点的page号,可以看出聚集索引的Root节点是第3号page(前0、1、2号Page已经被使用),辅助索引的根节点是第4、5、6、7、8个page。 - fseg:page的说明,internal表示非叶子节点或属于根节点,leaf表示叶子节点(也就是数据页)。 - used:索引使用了多少个page,可以看出聚集索引的非叶子节点使用了1819个page,叶子节点使用了1112265个page。 - allocated:索引分配了多少个page,可以看出聚集索引的非叶子节点分配了2522个page,叶子节点分配了1271136个page。 - fill_factor:索引的填充度,used/allocated表示填充度,也就是实际使用的大小百分比。 现在我们知道了Root节点页后,就可以使用innodb_ruby的另外一个功能,打印页结构信息,需要了解InnoDB页结构。 1 2 3 | # 这里查看是索引中根节点的page号为3的索引信息 即主键索引id列 # 从返回结果中可以看到index_id=>55 innodb_space -s ibdata1 -T user/uc_users -p 3 page-dump | 执行结果如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | [root@uoc-db2 MySQL3306]# innodb_space -s ibdata1 -T user/uc_users -p 3 page-dump #: fil header: {:checksum=>2973709054, :offset=>3, :prev=>nil, :next=>nil, :lsn=>47538913608, :type=>:INDEX, :flush_lsn=>0, :space_id=>31} fil trailer: {:checksum=>2973709054, :lsn_low32=>294273352} page header: {:n_dir_slots=>2, :heap_top=>225, :garbage_offset=>192, :garbage_size=>42, :last_insert_offset=>0, :direction=>:left, :n_direction=>1, :n_recs=>3, :max_trx_id=>0, :level=>3, :index_id=>55, :n_heap=>7, :format=>:compact} fseg header: {:leaf=> , fseg=2>, :internal=> , fseg=1>} sizes: header 120 trailer 8 directory 4 free 16189 used 195 record 63 per record 21.00 page directory: [99, 112] system records: {:offset=>99, :header=> {:next=>129, :type=>:infimum, :heap_number=>0, :n_owned=>1, :min_rec=>false, :deleted=>false, :length=>5}, :next=>129, :data=>"infimum\x00", :length=>8} {:offset=>112, :header=> {:next=>112, :type=>:supremum, :heap_number=>1, :n_owned=>4, :min_rec=>false, :deleted=>false, :length=>5}, :next=>112, :data=>"supremum", :length=>8} garbage records: {:format=>:compact, :offset=>192, :header=> {:next=>171, :type=>:node_pointer, :heap_number=>5, :n_owned=>0, :min_rec=>false, :deleted=>false, :nulls=>[], :lengths=>{}, :externs=>[], :length=>5}, :next=>171, :type=>:clustered, :key=>[{:name=>"id", :type=>"BIGINT", :value=>2002372990}], :row=>[], :sys=>[], :child_page_number=>1206485, :length=>12} {:format=>:compact, :offset=>171, :header=> {:next=>171, :type=>:node_pointer, :heap_number=>4, :n_owned=>0, :min_rec=>false, :deleted=>false, :nulls=>[], :lengths=>{}, :externs=>[], :length=>5}, :next=>171, :type=>:clustered, :key=>[{:name=>"id", :type=>"BIGINT", :value=>55861094}], :row=>[], :sys=>[], :child_page_number=>948104, :length=>12} records: {:format=>:compact, :offset=>129, :header=> {:next=>150, :type=>:node_pointer, :heap_number=>2, :n_owned=>0, :min_rec=>true, :deleted=>false, :nulls=>[], :lengths=>{}, :externs=>[], :length=>5}, :next=>150, :type=>:clustered, :key=>[{:name=>"id", :type=>"BIGINT", :value=>1}], :row=>[], :sys=>[], :child_page_number=>375704, :length=>12} {:format=>:compact, :offset=>150, :header=> {:next=>213, :type=>:node_pointer, :heap_number=>3, :n_owned=>0, :min_rec=>false, :deleted=>false, :nulls=>[], :lengths=>{}, :externs=>[], :length=>5}, :next=>213, :type=>:clustered, :key=>[{:name=>"id", :type=>"BIGINT", :value=>47377252}], :row=>[], :sys=>[], :child_page_number=>948104, :length=>12} {:format=>:compact, :offset=>213, :header=> {:next=>112, :type=>:node_pointer, :heap_number=>6, :n_owned=>0, :min_rec=>false, :deleted=>false, :nulls=>[], :lengths=>{}, :externs=>[], :length=>5}, :next=>112, :type=>:clustered, :key=>[{:name=>"id", :type=>"BIGINT", :value=>68265101}], :row=>[], :sys=>[], :child_page_number=>1644800, :length=>12} | 页结构信息中有一个level字段,表示的就是Root节点页的高度,同样,level + 1就等于这个索引的高度。 比如再看看phone_number索引列(page号为4),结果如下: https://github.com/jiankunking/backups/blob/master/MySQL/index/phone_number.page.contents 方法二 1、查看innodb_page_size 1 | SHOW GLOBAL STATUS LIKE 'Innodb_page_size'; | 执行结果如下: 2、通过hexdump这样的工具就可以快速定位到所需要的树高度信息 1 | hexdump -s 49216 -n 02 uc_users.ibd | 执行结果如下: 查看uc_users表,49216表示的是316384+64(这里innodb_page_size设置为了16384,如果是8192就是38192),即第3个页偏移量64位置开始读取2个字节,这里PAGE_LEVEL为00 03,那么索引的高度就为4。 ### 异地多活高可用架构设计 - URL: https://jiankunking.com/multi-live-high-available-architecture-design.html - Content type: original - Published: 2019-02-10 - Updated: 2019-02-10 - Summary: 异地多活高可用架构设计实践,分析业务何时需要多活、如何划分单元与流量、怎样处理数据一致性、故障切换及系统复杂度等核心问题。 - Categories: Architecture - Tags: High Availability, Multi-Live, Disaster-Recovery Article text: 文章速览 异地多活高可用架构设计实践,分析业务何时需要多活、如何划分单元与流量、怎样处理数据一致性、故障切换及系统复杂度等核心问题。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 异地多活架构设计实践:何时需要异地多活、如何构建、需要考虑的核心问题。 如何构建应用的异地多活? 概要 随着业务的快速发展,对于很多公司来说,构建于单地域的技术体系架构,会面临诸如下面的多种问题:基础设施的有限性限制了业务的可扩展性;机房、城市级别的故障灾害,影响服务的可持续性。 为解决遇到的这些问题,公司可以选择构建异地多活架构,在同城/异地构建多个单元(业务中心)。各个业务单元可以分布在不同的地域,从而有效解决了单地域部署带来的基础设施的扩展限制、服务可持续性。 异地多活是近几年比较热门的一个话题,那么在实际业务中什么时候需要去做这件事?如何去做?做的时候需要考虑什么? 何时去做? 个人感觉取决于以下几个方面: - 业务发展 - 基础设施状况 - 技术积淀 如何做? 目前在网上搜索到的异地多活方案来看,基本都是阿里、饿了么、京东、微博这些互联网大厂的实践,这些大厂的方案有一个共同点就是:大量的自研组件,来做相关的数据同步,业务切分等等,那么,对于很多传统企业或者相对小一些的企业,应该如何来做这件事? - 根据业务特性借助合适的公有云服务 做的时候,需要注意什么? - 真正需要做异地多活的业务有哪些? - 基础设施如何? - 对于不可用时间的容忍程度是多少? 业务背景 - 在所有的系统中用户中心都是核心业务,因为它是进入其它很多业务前提。 - 我们这边IDC不是很稳定,之前发生过几次机房大规模故障,比如机房网络挂了,整个机房对外不可用了。 以上两点是我们这次要做用户中心异地容灾的出发点,以便在面对机房级别故障时,保证服务可用性。 业务梳理 用户中心从整体来看,对外主要提供:注册、登陆、查询用户信息等服务。这些服务又有以下几个特点: - 登陆的优先级最高 - 事务性要求低 涉及的公共组件主要有: - MySQL:用户数据存储 - Redis:Authorization Code、短信验证码、账号锁定、access token等的存储 - Zookeeper:Dubbo依赖 方案 用户中心是通过外包的形式进行开发的,目前已上线并交付给另一个外包商运维,所以在考虑容灾一期方案的时候,需要考虑尽量不动代码。 目标 一期目标 当北京机房出现故障的时候,可以一定时间内把流量切到青岛机房这边,保证用户中心核心服务的基本可用。 二期目标 用户中心通过异地多活,实现高可用(需要集团智能DNS支持)。 架构设计 一期架构 当北京机房发生故障的时候,可以把流量快速切换到青岛这边,以保障用户中心核心服务可用。 具体方案如下: - 通过otter近实时的将北京机房核心业务数据同步到青岛机房。 - 青岛机房部署Redis、ZooKeeper等中间件。 - 青岛机房部署用户中心的核心应用(实例正常部署、运行,只是平时不会有访问)。 具体架构如下: 可以达到的效果: - 当北京机房出现故障的时候,可以在一定时间内把流量切到青岛机房这边,保证用户中心核心服务的基本可用,但此时已登录用户需要重新登录。 - 一定时间:取决于DNS修改ip时间+DNS TTL时间,目前来看TTL是10分钟,人工修改ip应该很快,所以一定时间是10~20分钟。 存在的缺点: - 北京机房非故障期间,青岛机房的机器,仅做数据库同步,存在一定的资源浪费。 - 当北京机房出现故障,流量切换到青岛机房后,只能保证登陆这一核心服务的可用。对于注册等需要修改数据库的服务,均不支持,如果在此期间访问这类服务,会发生异常。 二期架构 二期的目的就是修正一期架构的缺点,通过异地多活,实现高可用。 二期青岛机房会替换为阿里云机房。 具体方案如下: - 通过阿里云DTS服务实现两地机房数据库同步,保证北京、阿里云数据的近实时一致性。 - 北京、阿里云两地机房均提供在线服务,提高资源利用率。 - 梳理服务优先级,修改应用代码,支持服务降级。 - 当某个机房(阿里云或者北京)出现故障的时候,通过DNS服务把流量切换到另一个机房。 - 如果两地部署的时候,没有冗余一定硬件资源,则需要实施服务降级。 - 目前集团DNS解析,无法提供自动检测服务是否可用的功能,也就无法自动进行切换。 - 服务可用性,可以通过我们这边的多点拨测进行监控,当多点拨测不可用的时候,发送告警通知给相关人员,以便人工介入。 - 多点拨测告警,应该会提供两类:1、某个拨测点不通的时候 2、所有拨测点均不可用的时候。 - 目前集团DNS解析,TTL生效最短时间是10分钟,无法自定义TTL时间。 具体架构如下: 可以达到的效果: - 如果集团DNS可以提供,类似阿里云云解析的网站监控功能并能灵活设置TTL时间,这时当北京机房或者阿里云机房出现故障后,就可以在很短的时间(部分服务最大异常时间)内自动进行流量切换。 此处只是以阿里云云解析示例,只要能提供类似的服务均可。 - 如果集团DNS无法提供类似阿里云云解析的网站监控及灵活设置TTL时间的功能,则部分服务最大异常时间还是取决于DNS修改ip时间+DNS TTL时间。 名词解释 什么是网站监控? HTTP/HTTPS实时探测域名解析记录,支持自定义端口,实时发现宕机立即告警; 全网分布式监控,在中国各个地区模拟用户端真实请求,监控结果真实可靠; 支持宕机暂停、容灾切换,最大限度的解决服务中断对您的业务带来的损失; 容灾切换支持A记录、CNAME域名,满足各种场景的容灾切换需求; 什么情况会被网站监控判断为宕机并发送告警通知? 监控结果中,HTTP/HTTPS的返回码大于500的服务器错误情况,才会报警通知。 举例说明:如果设置了四个探测点 北京联通、深圳阿里巴巴、上海电信、重庆联通。 场景一:四个探测点中50%的监控点无法收到您服务器的响应,或50%的监控点收到返回码大于等于500时,才会判断您的网站为宕机情况。 场景二:四个探测点中有50%以上的探测点探测您的网站返回码是小于500的情况,则不会判断您的网站为宕机。 云解析DNS“流量管理” 云解析“流量管理”可以在您设置的每条解析线路下,根据权重比例轮询返回解析结果。当线路下的IP宕机时可以通过监控自动发现,并将宕机IP从当前线路下摘除,直到监控IP正常时会恢复解析。同时,当一条解析线路下的所有IP都宕机时,可以切换至其他正常线路。最大程度保证您的网站服务高可用,减小损失。 部分服务最大异常时间 比如北京机房出现异常,这时转发到阿里云机房的流量是可以正常访问,只有转发到北京机房的流量是异常的。 这时如果使用网站监控或者类似服务,进行监控,并设置拨测间隔为1分钟,TTL生效时间为1秒,那么最多有60+1秒部分服务异常时间,之后DNS会自动把北京机房的ip自动踢掉,流量全部切到阿里云。 补充 - 一期、二期方案的实现均强依赖于集团的DNS服务 - 用户中心通过ip暴露的服务,一但出现机房级别的故障,一期、二期方案均无法保证该部分服务可用。 - 其实除了DNS这种方案,还有一种方案就是用类似F5这种设备,作跨机房负载,但必须是gslb,而且两端必须是相同的设备。 小结 对于,非一线互联网大厂的公司而言,是实现异地容灾的时候,借助公有云是很有必要的,比如: - 数据跨机房同步,可以使用阿里云的DTS(Data Transmission Service) 服务,目前DTS支持关系型数据库、NoSQL、大数据(OLAP)等数据源间的数据传输。 它是一种集数据迁移、数据订阅及数据实时同步于一体的数据传输服务。 - 跨机房分布式数据库,可以使用OceanBase。金融环境下通常对数据可靠性有更高的要求,OceanBase每一次事务提交,对应日志总是会在多个数据中心实时同步,并持久化。即使是数据中心级别的灾难发生,总是可以在其他的数据中心恢复每一笔已经完成的交易,实现了真正金融级别的可靠性要求。 - 异地多活由于各个公司的业务、基础设施及要解决的问题皆不尽相同,所以选择适合自己的就好。 - 或者直接使用云数据库RDS MySQL 版 ### [转]Otter数据一致性 - URL: https://jiankunking.com/otter-data-consistency.html - Content type: repost - Published: 2019-02-05 - Updated: 2019-02-05 - Summary: 分析Otter数据同步中的一致性处理思路,讨论多地同时修改、双向同步冲突等场景下的事前控制与事后修复方案及其适用边界。 - Categories: MySQL - Tags: MySQL, Otter - Original source: https://github.com/alibaba/otter/wiki The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### 原生Docker PaaS平台架构设计 - URL: https://jiankunking.com/pass-platform-architecture-design.html - Content type: original - Published: 2019-01-10 - Updated: 2019-01-10 - Summary: 介绍基于原生 Docker 的 PaaS 平台架构设计、核心功能和演进过程,并说明向 Kubernetes 迁移的背景。 - Categories: Architecture - Tags: Docker, PaaS, Container, Platform Article text: 文章速览 介绍基于原生 Docker 的 PaaS 平台架构设计、核心功能和演进过程,并说明向 Kubernetes 迁移的背景。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 构建一个PaaS平台(原生Docker已逐步下线,目前主要使用Kubernetes) 背景 目前在用的PaaS平台是之前购买的一个商业产品,但没有源码,运维期也早就结束了,所以在后期使用过程中会遇到一些各种各样的问题,对于使用、运维都造成一定的困扰。 老PaaS的架构及基本功能如下: 重构 为什么选择重构PaaS平台而不是全部迁移kubernates集群? kubernates集群的确提供了很多优秀的特性,比如:RC、滚动更新或回滚、资源监控和日志记录、负载均衡等等。 但在目前我们这边的环境来看,迁移kubernates集群有如下几个问题: - 无法无感知迁移,即迁移到kubernates集群的过程中及迁移到kubernates集群后,不增加用户的使用、学习成本,但应用引入kubernates集群之后,很难保证这一点。因为我们这边的用户大多是我们公司的供应商,供应商其实不太关心,你平台所提供的各种新特性、功能,更不想因为这些新特性、功能增加他们的使用、学习成本。 - 我们这边很多项目本身是有硬负载的,比如F5,所以kubernates提供的负载均衡功能,也就显的不那么重要。 - 日志部分,我们已经打通各个平台的日志、监控,不再需其他的组件。 - 滚动更新或回滚,老PaaS平台木有,重构后新版中准备加入(二期)。 - RC类似功能,目前不打算支持。 架构及用到组件梳理 新PaaS功能点梳理 迁移 通过无缝迁移,在用户无感知的情况下实现迁移。 为什么要做到无感知迁移? 老PaaS中目前的项目数量是:193个,应用数量是:673个 总实例数:1485,其中生产环境的实例数:894 如果这些项目、应用,因为你的重构都需要改动的话,那么推广难度是很大的,所以需要尽量做到,对于用户来说无感知迁移。 以下迁移部分,都需要在新PaaS上线前完成并在上线一段时间准实时同步过来。 ### Java 垃圾回收算法之G1 - URL: https://jiankunking.com/java-gc-g1.html - Content type: original - Published: 2019-01-02 - Updated: 2019-01-02 - Summary: G1垃圾收集器详解:分区回收算法、Region划分、Mixed GC、调优参数等核心内容,附带官方文档和推荐阅读资料。 - Categories: Java - Tags: G1, JVM, GC Article text: 文章速览 G1垃圾收集器详解:分区回收算法、Region划分、Mixed GC、调优参数等核心内容,附带官方文档和推荐阅读资料。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java G1垃圾收集器详解:分区回收算法、Region划分、Mixed GC、调优参数等核心内容,附带官方文档和推荐阅读资料。 有可能是全网最全的G1总结 推荐阅读文章: G1GC:分区回收算法说的是什么? 推荐阅读官方文档: https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-g1-garbage-collector1.html#GUID-3A99AE6C-F80A-4565-A27C-B4AEDF5CDF71 调优: https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-garbage-collector-tuning.html#GUID-4914A8D4-DE41-4250-B68E-816B58D4E278 PDF版文档: Hotspot Virtual Machine Garbage Collection Tuning Guide G1(Garbage-First)回收器是在JDK1.7中正式使用的全新垃圾回收器,G1拥有独特的垃圾回收策略,从分代上看,G1依然属于分代垃圾回收器,它会区分年代和老年代,依然有eden和survivor区,但从堆的结构上看,它并不要求整个eden区、年清代或者老年代都连续。它使用了全新的分区算法。 其特点如下: - 并行性:G1在回收期间,可以由多个GC线程同时工作,有效利用多核计算能力。 - 并发性:G1拥有与应用程序交替执行的能力,因此一般来说,不会在整个回收期间完全阻塞应用程序。 - 分代GC:与之前回收器不同,其他回收器,它们要么工作在年轻代要么工作在老年代。G1可以同时兼顾年轻代与老年代。 - 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS,只是简单的标记清除,在若干次GC后CMS必须进行一次碎片整理,G1在每次回收时都会有效的复制对象,减少空间碎片。 - 可预见性:由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收范围,因此对于全局停顿也能得到更好的控制。 一、G1的内存划分和主要收集过程 G1收集回收器将堆进行分区,划分为一个个的区域,每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生一次停顿时间。 G1的收集过程可能有4个阶段: - 新生代GC - 并发标记周期 - 混合收集 - (如果需要)进行Full GC。 二、G1的新生代GC 新生代GC的主要工作是回收eden区和survivor区。 一旦eden区被占满,新生代GC就会启动。新生代GC收集前后的堆数据如下图所示,其中E表示eden区,S表示survivor区,O表示老年代。 可以看到,新生代GC只处理eden和survivor区,回收后,所有的eden区都应该被清空,而survivor区会被收集一部分数据,但是应该至少仍然存在一个survivor区,类比其他的新生代收集器,这一点似乎并没有太大变化。另一个重要的变化是老年代的区域增多,因为部分survivor区或者eden区的对象可能会晋升到老年代。 三、G1并发标记周期 G1的并发阶段和CMS有些类似,它们都是为了降低一次停顿时间,而将可以和应用程序并发执行的部分单独提取出来执行。 并发标记周期针对老年代 图片来源 并发标记周期可分为以下几步: - 初始标记:标记从根节点直接可达的对象。这个阶段会伴随一次新生代GC,它是会产生全局停顿的,应用程序在这个阶段必须停止执行。 - 根区域扫描:由于初始标记必然会伴随一次新生代GC,所以在初始化标记后,eden被清空,并且存活对象被移到survivor区。在这个阶段,将扫描由survivor区直接可达的老年代区域,并标记这些直接可达的对象。这个过程是可以和应用程序并发执行的。但是根区域扫描不能和新生代GC同时发生(因为根区域扫描依赖survivor区的对象,而新生代GC会修改这个区域),故如果恰巧此时需要新生代GC,GC就需要等待根区域扫描结束后才能进行,如果发生这种情况,这次新生代GC的时间就会延长。 - 并发标记:G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断。 - 重新标记:和CMS一样,重新标记也是会使应用程序停顿,由于在并发标记过程中,应用程序依然运行,因此标记结果可能需要修正,所以在此阶段对上一次标记进行补充。在G1中,这个过程使用SATB(Snapshot-At-The-Begining)算法完成,即G1会在标记之初为存活对象创建一个快照,这个快照有助于加速重新标记的速度。 - 独占清理:顾名思义,这个阶段会引起停顿。它将计算各个区域的存活对象和GC回收比例并进行排序,识别可供混合回收的区域。在这个阶段,还会更新记忆集。该阶段给出了需要被混合回收的区域并进行了标记,在混合回收阶段,需要这些信息。 - 并发清理阶段:识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。 SATB全称是Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。那么它是怎么维持并发GC的正确性的呢?根据三色标记算法,我们知道对象存在三种状态:白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。灰:对象被标记了,但是它的field还没有被标记或标记完。黑:对象被标记了,且它的所有field也被标记完了。 SATB 利用 write barrier 将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根 Stop The World 地重新扫描一遍即可避免漏标问题。 因此G1 Remark阶段 Stop The World 与 CMS了的remark有一个本质上的区别,那就是这个暂停只需要扫描有 write barrier 所追中对象为根的对象, 而 CMS 的remark 需要重新扫描整个根集合,因而CMS remark有可能会非常慢。 四、混合回收 在并发标记周期中,虽有部分对象被回收,但是回收的比例是非常低的。但是在并发标记周期后,G1已经明确知道哪些区域含有比较多的垃圾对象,在混合回收阶段,就可以专门针对这些区域进行回收。当然G1会优先回收垃圾比例较高的区域(回收这些区域的性价比高),这正是G1名字的由来(Garbage First Garbage Collector:译为垃圾优先的垃圾回收器),这里的垃圾优先(Garbage First)指的是回收时优先选取垃圾比例最高的区域。 这个阶段叫做混合回收,是因为在这个阶段,即会执行正常的年轻代GC,又会选取一些被标记的老年代区域进行回收,同时处理了新生代和老年代。 混合回收会被执行多次,直到回收了足够多的内存空间,然后,它会触发一次新生代GC。新生代GC后,又可能会发生一次并发标记周期的处理,最后又会引起混合回收,因此整个过程可能是如下图: 五、必要时的Full GC 和CMS类似,并发收集让应用程序和GC线程交替工作,因此在特别繁忙的情况下无可避免的会发生回收过程中内存不足的情况,当遇到这种情况,G1会转入一个Full GC 进行回收。 以下4种情况会触发这类的Full GC: 1、并发模式失效 G1启动标记周期,但在Mix GC之前,老年代就被填满,这时候G1会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads等)。 GC日志如下的示例: 解决办法:发生这种失败意味着堆的大小应该增加了,或者G1收集器的后台处理应该更早开始,或者需要调整周期,让它运行得更快(如,增加后台处理的线程数)。 2、晋升失败 (to-space exhausted或者to-space overflow) G1收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。(G1在进行GC的时候没有足够的内存供存活对象或晋升对象使用),由此触发了Full GC。 下面日志中(可以在日志中看到(to-space exhausted)或者(to-space overflow)),反应的现象是混合式GC之后紧接着一次Full GC。 这种失败通常意味着混合式收集需要更迅速的完成垃圾收集:每次新生代垃圾收集需要处理更多老年代的分区。 解决这种问题的方式是: - 增加 -XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。 - 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。 - 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。 3、疏散失败 (to-space exhausted或者to-space overflow) 进行新生代垃圾收集是,Survivor空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在GC日志中通常是: 这条日志表明堆已经几乎完全用尽或者碎片化了。G1收集器会尝试修复这一失败,但可以预期,结果会更加恶化:G1收集器会转而使用Full GC。 解决这种问题的方式是: - 增加 -XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。 - 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。 - 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。 4、Humongous Object 分配失败 当Humongous Object 找不到合适的空间进行分配时,就会启动Full GC,来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象。 对于Humongous Object 的处理还有一种方式就是切换GC算法到ZGC,因为ZGC中对于Humongous Object 的回收不会特殊处理(比如不会延迟收集)。 六、巨型对象 Humongous Object:巨型对象 Humongous regions:巨型区域 对于G1而言,只要超过regin大小的一半,就被认为是巨型对象。巨型对象直接被分配到老年代中的“巨型区域”。这些巨型区域是一个连续的区域集。StartsHumongous 标记该连续集的开始,ContinuesHumongous 标记它的延续。 在分配巨型对象之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 Full GC。 对于巨型对象,有以下几个点需要注意: - 没有被引用的巨型对象会在标记清理阶段或者Full GC时被释放掉。 - 为了减少拷贝负载,只有在Full GC的时候,才会压缩大对象region。 - 每一个region中都只有一个巨型对象,该region剩余的部分得不到利用,会导致堆碎片化。 - 如果看到由于大对象分配导致频繁的并发回收,需要把大对象变为普通的对象,建议增大Region size。(或者切换到ZGC) 对于增大Region size有一个负面影响就是:减少了可用region的数量。因此,对于这种情况,你需要进行相应的测试,以查看是否实际提高了应用程序的吞吐量或延迟。 当巨型对象对象大于region的时候如何处理? Humongous objects always take up a number of regions. If a humongous object is smaller than one region then it takes up the whole region. If a humongous object is larger than N regions and smaller than (N+1) regions then it takes up (N+1) regions. No allocations are allowed in the free space, if any, of the last region. https://openjdk.org/jeps/278 七、常见调优参数 1、-XX:MaxGCPauseMillis=N 默认200毫秒 前面介绍过使用GC的最基本的参数: -XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200 前面2个参数都好理解,后面这个MaxGCPauseMillis参数该怎么配置呢?这个参数从字面的意思上看,就是允许的GC最大的暂停时间。G1尽量确保每次GC暂停的时间都在设置的MaxGCPauseMillis范围内。那G1是如何做到最大暂停时间的呢?这涉及到另一个概念,CSet(collection set)。它的意思是在一次垃圾收集器中被收集的区域集合。 - Young GC:选定所有新生代里的region。通过控制新生代的region个数来控制young GC的开销。 - Mixed GC:选定所有新生代里的region,外加根据global concurrent marking统计得出收集收益高的若干老年代region。在用户指定的开销目标范围内尽可能选择收益高的老年代region。 在理解了这些后,我们再设置最大暂停时间就有了方向。首先,我们能容忍的最大暂停时间是有一个限度的,我们需要在这个限度范围内设置。但是应该设置的值是多少呢?我们需要在吞吐量跟MaxGCPauseMillis之间做一个平衡。如果MaxGCPauseMillis设置的过小,那么GC就会频繁,吞吐量就会下降。如果MaxGCPauseMillis设置的过大,应用程序暂停时间就会变长。G1的默认暂停时间是200毫秒,我们可以从这里入手,调整合适的时间。 2、-XX:G1HeapRegionSize=n 设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。 - -XX:ParallelGCThreads=n(调整G1垃圾收集的后台线程数) 设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。 如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。 - -XX:ConcGCThreads=n(调整G1垃圾收集的后台线程数) 设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。 3、 -XX:InitiatingHeapOccupancyPercent=45(调整G1垃圾收集运行频率) 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。 该值设置太高:会陷入Full GC泥潭之中,因为并发阶段没有足够的时间在剩下的堆空间被填满之前完成垃圾收集。 如果该值设置太小:应用程序又会以超过实际需要的节奏进行大量的后台处理。 避免使用以下参数:避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。 八、细节 1、G1 mixed GC时机? mixed gc中也有一个阈值参数 -XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc. 在分配humongous object之前先检查是否超过 initiating heap occupancy percent, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 Full GC。 为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。 一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。 2、XX:G1 HeapRegionSize 默认值? 默认把堆内存按照2048份均分,最后得到一个合理的大小。 3、直接内存配置 Q: 什么时候用直接内存? A: 读写频繁的场合,出于性能考虑,可以考虑使用直接内存。 直接内存也是 Java 程序中非常重要的组成部分,特别是 NIO 被广泛使用之后,直接内存可以跳过 Java 堆,使 Java 程序可以直接访问原生堆空间。因此可以在一定程度上加快内存的访问速度。直接内存可以用 -XX:MaxDirectMemorySize 设置,默认值为最大堆空间,也就是 -Xmx。当直接内存达到最大值的时候,也会触发垃圾回收,如果垃圾回收不能有效释放空间,直接内存溢出依然会引起系统的 OOM。 一般而言直接内存在访问读写上直接内存有较大优势(速度较快),但是在内存空间申请的时候,直接内存毫无优势而言。 4、RSet 全称是Remembered Set,是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。 RSet究竟是怎么辅助GC的呢? 在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。 5、Root Scanning - [Ext Root Scanning (ms)] – How long it took to scan the external (non-heap) roots such as classloaders, JNI references, JVM system roots, etc. Shows elapsed time, “Sum” is CPU time - [Code Root Scanning (ms)] – How long it took to scan the roots that came from the actual code: local vars, etc. 九、JDK 12中G1的新特性 1、可中断 mixed GC 如果 Mixed GC 的 G1 存在超出暂停目标的可能性,则使其可被中止。 2、G1未使用分配内存即时返回 增强 G1垃圾收集器,以便在空闲时自动将 Java 堆内存返回给操作系统。 十、GC 发展趋势 其实可以看到Java 垃圾回收器的趋势,就是在大内存堆的前提下尽 GC 可能的降低对应用程序的影响;从 CMS 的分阶段增量标记,到 G1 通过 SATB 算法改正 remark 阶段的 Stop The World 的影响,再到 ZGC/C4甚至在标记阶段无需 Stop The World,莫不如此。 十一、结尾 推荐几种学习这种GC的方式: - 看JEP(JDK Enhancement Proposal)知道它的来龙去脉。 - 看相应算法的paper(之前看Shenandoah GC Paper的时候,就有一种收获很大的感觉,因为Shenandoah GC的处理方式,介于G1跟ZGC之间,所以看了Shenandoah GC Paper感觉对于G1、ZGC的理解也更加深入了)。 会在文章结束,补充上JEP官网地址跟我收集的一些GC资料(包含部分paper)github地址。 补一个我自己归纳的GC图: 各种GC算法都是围绕着,图中内容展开的,只是各自的处理方式不同而已。 资料推荐: 1、GC算法及paper https://github.com/jiankunking/books-recommendation/tree/master/GC 2、Java相关书籍推荐 https://github.com/jiankunking/books-recommendation/tree/master/Java 参考文献 1、实战JAVA虚拟机 JVM故障诊断与性能优化 2、jeps 3、其它 https://www.oracle.com/technetwork/articles/java/g1gc-1984535.html https://plumbr.io/handbook/gc-tuning-in-practice/other-examples/humongous-allocations ### [转]分布式缓存的一致性Hash算法 - URL: https://jiankunking.com/distributed-cache-consistent-hash-algorithm.html - Content type: repost - Published: 2018-12-20 - Updated: 2018-12-20 - Summary: 介绍分布式缓存的一致性哈希算法,并分析如何通过虚拟节点改善节点增减时的数据迁移和负载不均衡问题。 - Categories: Architecture - Tags: Reading Notes, Distributed, Cache, Consistent-Hash, Algorithm - Original author: 李智慧 The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### SOFAMosn 如何提高 GoLang 的转发性能 - URL: https://jiankunking.com/sofamosn-golang-performance.html - Content type: original - Published: 2018-11-22 - Updated: 2018-11-22 - Summary: 结合 SOFAMosn 分析 Go 网络转发性能,讨论 Goroutine 开销、并发模型及高并发场景下的优化思路。 - Categories: Go - Tags: Performance, Go, SOFAMosn Article text: 文章速览 结合 SOFAMosn 分析 Go 网络转发性能,讨论 Goroutine 开销、并发模型及高并发场景下的优化思路。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 通过SOFAMosn了解goroutine只能在一定并发量级上降低并发编程的难度(goroutine内存占用2kb+) 高并发的场景还是NIO比较适合 GoLang 的转发性能比起 C++ 肯定是稍有逊色的,为了尽可能的提高 MOSN 的转发性能,我们在线程模型上进行优化,当前 MOSN 支持两种线程模型,用户可根据场景选择开启适用的模型。 模型一 如下图所示,使用 GoLang 默认的 epoll 机制,对每个连接分配独立的读写协程进行阻塞读写操作,proxy 层做转发时,使用常驻 worker 协程池负责处理 Stream Event - 此模型在 IO 上使用 GoLang 的调度机制,适用于连接数较少的场景,例如:mosn 作为 sidecar、与 client 同机部署的场景 模型二 如下图所示,基于 Netpoll 重写 epoll 机制,将 IO 和 PROXY 均进行池化,downstream connection 将自身的读写事件注册到 netpoll 的 epoll/kqueue wait 协程,epoll/kqueue wait 协程接受到可读事件,触发回调,从协程池中挑选一个执行读操作。 - 使用自定义 Netpoll IO 池化操作带来的好处是: - 当可读事件触发时,从协程池中获取一个 goroutine 来执行读处理,而不是新分配一个 goroutine,以此来控制高并发下的协程数量 - 当收到链接可读事件时,才真正为其分配 read buffer 以及相应的执行协程。这样 GetBytes() 可以降低因为大量空闲链接场景导致的额外协程和 read buffer 开销 - 此模型适用于连接数较多、可读连接数量受限的情况,例如:mosn 作为 api gateway 的场景 本文整理自SOFAMosn官方文档 ### ReentrantReadWriteLock原理解析 - URL: https://jiankunking.com/java-reentrantreadwritelock.html - Content type: original - Published: 2018-11-11 - Updated: 2018-11-11 - Summary: 基于 JDK 11 源码分析 ReentrantReadWriteLock 的读写状态、锁降级、公平策略和线程竞争机制。 - Categories: Java - Tags: Java, AQS, ReentrantReadWriteLock Article text: 文章速览 基于 JDK 11 源码分析 ReentrantReadWriteLock 的读写状态、锁降级、公平策略和线程竞争机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java Java JDK 11 ReentrantReadWriteLock 原理分析 1、前言 希望在阅读本文之前,建议先看一下以下三篇文章: 1、面试必备:Java AQS 实现原理(图文)分析 2、面试必备:Java AQS Condition的实现分析 3、面试必备:Java Volatile的内存语义与AQS锁内存可见性 读完了以上三篇文章,先看一下ReentrantReadWriteLock的代码路径: 1 | package java.util.concurrent.locks; | 来先猜一下ReentrantReadWriteLock会如何实现? 都在java.util.concurrent包下,那么可以明确一点,那就是关于锁的实现,应该用的就是AQS,那么,读锁、写锁会不会对应的就是AQS中的共享模式与独占模式? 2、读写锁使用场景 读是多于写(比如cache) 一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。 3、读写锁接口:ReadWriteLock 代码地址:ReadWriteLock 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public interface ReadWriteLock { /** * Returns the lock used for reading. * * @return the lock used for reading */ Lock readLock(); /** * Returns the lock used for writing. * * @return the lock used for writing */ Lock writeLock(); } | 4、读写锁的接口与示例 ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()方法和writeLock()方法,而其实现:ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,这些方法以及描述如表所示: 接下来,通过一个缓存示例说明读写锁的使用方式,示例代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | public class Cache { static Map map = new HashMap(); static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); static Lock r = rwl.readLock(); static Lock w = rwl.writeLock(); // 获取一个key对应的value public static final Object get(String key) { r.lock(); try { return map.get(key); } finally { r.unlock(); } } // 设置key对应的value,并返回旧的value public static final Object put(String key, Object value) { w.lock(); try { return map.put(key, value); } finally { w.unlock(); } } // 清空所有的内容 public static final void clear() { w.lock(); try { map.clear(); } finally { w.unlock(); } } } | 上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。Cache使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。 5、ReentrantReadWriteLock脉络梳理 代码地址:ReentrantReadWriteLock 先看一下继承结构: 再看一下代码结构: 图中可以看出ReentrantReadWriteLock的实现还是比较复杂的,所以接下来主要分析ReentrantReadWriteLock实现关键点,包括: - 读写状态的设计 - 写锁的获取与释放 - 读锁的获取与释放 - 锁降级 5.1 读写状态的设计 读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。 如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如下图所示: 当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁是如何迅速确定读和写各自的状态呢? 答案是通过位运算。假设当前同步状态值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是S+0x00010000。 1、0x0000FFFF=00000000000000001111111111111111(16个0 16个1) 2、>>>: 无符号右移,忽略符号位,空位都以0补齐 3、0x00010000=10000000000000000(1个1 16个0) 根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。 5.2 写锁的获取与释放 写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | protected final boolean tryAcquire(int acquires) { /* * Walkthrough: * 1. If read count nonzero or write count nonzero * and owner is a different thread, fail. * 2. If count would saturate, fail. (This can only * happen if count is already nonzero.) * 3. Otherwise, this thread is eligible for lock if * it is either a reentrant acquire or * queue policy allows it. If so, update state * and set owner. */ Thread current = Thread.currentThread(); int c = getState(); int w = exclusiveCount(c); if (c != 0) { // 存在读锁或者当前获取线程不是已经获取写锁的线程 if (w == 0 || current != getExclusiveOwnerThread()) return false; if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) return false; setExclusiveOwnerThread(current); return true; } | 该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。 写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。 5.3 读锁的获取与释放 读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。获取读锁的实现从Java 5到Java 6变得复杂许多,主要原因是新增了一些功能,例如getReadHoldCount()方法,作用是返回当前线程获取读锁的次数。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。因此,这里将获取读锁的代码做了删减,保留必要的部分,如代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 | protected final int tryAcquireShared(int unused) { for (;;) { int c = getState(); int nextc = c + (1 << 16); if (nextc < c) throw new Error("Maximum lock count exceeded"); if (exclusiveCount(c) != 0 && owner != Thread.currentThread()) return -1; if (compareAndSetState(c, nextc)) return 1; } } | 在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。 读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)。 5.4 锁降级 锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。 接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public void processData() { readLock.lock(); if (!update) { // 必须先释放读锁 readLock.unlock(); // 锁降级从写锁获取到开始 writeLock.lock(); try { if (!update) { // 准备数据的流程(略) update = true; } readLock.lock(); } finally { writeLock.unlock(); } // 锁降级完成,写锁降级为读锁 } try { // 使用数据的流程(略) } finally { readLock.unlock(); } } | 上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。 锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。 RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。 6、小结 RentrantReadWriteLock的具体流程梳理完了,回过头来想一下前言的问题,好像并没有得到答案,那么来到ReentrantReadWriteLock代码中,此处主要看一下读锁的获取、释放是否对应AQS中的共享模式。 6.1 读锁的获取、释放 1 2 3 4 5 6 7 8 | public void lock() { //看到这里是不是就明白了,我们的猜想是正确的 sync.acquireShared(1); } public void unlock() { //看到这里是不是就明白了,我们的猜想是正确的 sync.releaseShared(1); } | 先来看一下ReadLock的具体实现,在ReentrantReadWriteLock初始化的时候,会在构造函数中初始化ReadLock、WriteLock,具体代码如下: 1 2 3 4 5 6 7 8 | public ReentrantReadWriteLock() { this(false); } public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); } | 从ReentrantReadWriteLock构造函数的代码中,可以看到ReadLock初始化的参数是ReentrantReadWriteLock,那么ReadLock需要ReentrantReadWriteLock来做什么呢? 来看一下ReadLock: 1 2 3 4 | private final Sync sync; protected ReadLock(ReentrantReadWriteLock lock) { sync = lock.sync; } | 从ReadLock的构造函数中,可以看出,ReadLock需要获取到Sync,那么Sync是谁,又是用来做什么的? 其实,如果看过JUC下面代码的话,看到Sync,就明白它应该就是AQS的实现类,通过它来实现相关锁的操作。 来看一下代码验证一下: 1 2 3 4 5 6 7 | /** * Synchronization implementation for ReentrantReadWriteLock. * Subclassed into fair and nonfair versions. */ abstract static class Sync extends AbstractQueuedSynchronizer { //具体代码略 } | 看到这里可以大体得出这么一个结果:ReadLock获取锁的时候,是通过ReentrantReadWriteLock 内部Sync类来获取的共享锁,也就是读锁的获取是对应AQS中的共享模式。 点进 sync.acquireShared(1)方法,可以看到是调用Sync的父类AQS中方法: 1 2 3 4 | public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); } | 看到这里,也就明白为啥AQS子类需要重写: - tryAcquire - tryRelease - tryReleaseShared - isHeldExclusively 等方法了。 7、参考资料 本文第4、5小节整理自:《Java并发编程的艺术》 ### Java AQS Condition的实现分析 - URL: https://jiankunking.com/java-aqs-condition.html - Content type: original - Published: 2018-10-27 - Updated: 2018-10-27 - Summary: 基于《Java并发编程的艺术》分析 AQS Condition 的等待队列、await、signal 及线程重新竞争锁的过程。 - Categories: Java - Tags: AQS, Condition, Concurrent, Lock Article text: 文章速览 基于《Java并发编程的艺术》分析 AQS Condition 的等待队列、await、signal 及线程重新竞争锁的过程。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 本文整理自《Java并发编程的艺术》第五章 作者:方腾飞 魏鹏 程晓明 AQS:AbstractQueuedSynchronizer ConditionObject是同步器AQS的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。 下面将分析Condition的实现,主要包括:等待队列、等待和通知,下面提到的Condition如果不加说明均指的是ConditionObject。 1、等待队列 等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。 一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列,等待队列的基本结构如图5-9所示。 如图所示,Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。 在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列,其对应关系如图5-10所示。 2、等待 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。 Condition的await()方法,如下所示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 当前线程加入等待队列 Node node = addConditionWaiter(); // 释放同步状态,也就是释放锁 int savedState = fullyRelease(node); int interruptMode = 0; while (!isOnSyncQueue(node)) { LockSupport.park(this); if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; if (node.nextWaiter != null) unlinkCancelledWaiters(); if (interruptMode != 0) reportInterruptAfterWait(interruptMode); } | 调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。 如果从队列的角度去看,当前线程加入Condition的等待队列,该过程如图5-11示。 如图所示,同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。 3、通知 调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。 Condition的signal()方法,如代码清单5-23所示。 代码清单5-23 ConditionObject的signal方法 1 2 3 4 5 6 7 8 | public final void signal() { //isHeldExclusively() AQS 子类实现 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first = firstWaiter; if (first != null) doSignal(first); } | 调用该方法的前置条件是当前线程必须获取了锁,可以看到signal()方法进行了isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。 节点从等待队列移动到同步队列的过程如图5-12所示。 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。 Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。 ### MySQL InnoDB存储引擎:外键与锁 - URL: https://jiankunking.com/mysql-innodb-foreign-key-lock.html - Content type: original - Published: 2018-10-17 - Updated: 2018-10-17 - Summary: 结合InnoDB外键检查过程说明索引选择与加锁机制,分析父表记录锁如何阻塞子表插入和更新,并给出排查外键锁等待问题的思路。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 结合InnoDB外键检查过程说明索引选择与加锁机制,分析父表记录锁如何阻塞子表插入和更新,并给出排查外键锁等待问题的思路。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:《MySQL技术内幕:InnoDB存储引擎》 第二版 作者:姜承尧 出版时间:2013-05 外键主要用于引用完整性的约束检查在InnoDB存储引擎中,对于一个外键列,如果没有显式地对这个列加索引,InnoDB存储引擎会自动对其加一个索引,因为这样可以避免表锁。 这比Oracle数据库做得好,Oracle数据库不会自动添加索引,用户必须自己手动添加,这也导致了Oracle数据库中可能产生死锁。 对于外键值的插入或更新,首先需要检查父表中的记录,既SELECT父表。但是对于父表的SELECT操作,不是使用一致性非锁定读的方式,因为这会发生数据不一致的问题,因此这时使用的是SELECT…LOCK IN SHARE MODE方式,即主动对父表加一个S锁。如果这时父表上已经这样加X锁,子表上的操作会被阻塞,如下: 在上述的例子中,两个会话中的事务都没有进行COMMIT或ROLLBACK操作,而会话B的操作会被阻塞。这是因为 id为3的父表在会话 A中已经加了一个X锁,而此时在会话 B中用户又需要对父表中 id为3的行加一个 S锁,这时 INSERT的操作会被阻塞。设想如果访问父表时,使用的是一致性的非锁定读,这时Session B会读到父表有id=3的记录,可以进行插入操作。但是如果会话A对事务提交了,则父表中就不存在id为3的记录。数据在父、子表就会存在不一致的情况。 ### MySQL InnoDB存储引擎:行锁的3种算法 - URL: https://jiankunking.com/mysql-innodb-row-lock-algorithm.html - Content type: original - Published: 2018-10-17 - Updated: 2018-10-17 - Summary: 解析 InnoDB 的 Record Lock、Gap Lock 和 Next-Key Lock 三种行锁算法及其防止幻读的机制。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 解析 InnoDB 的 Record Lock、Gap Lock 和 Next-Key Lock 三种行锁算法及其防止幻读的机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:《MySQL技术内幕:InnoDB存储引擎》 第二版 作者:姜承尧 出版时间:2013-05 行锁的三种算法 InnoDB存储引擎有3种行锁的算法,其分别是: - Record Lock:单个行记录上的范围 - Gap Lock:间隙锁,锁定一个范围,但不包含记录本身 - Next-Key Lock:Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身 Record Lock总是会锁住索引记录,如果InnoDB存储引擎建立的时候没有设置任何一个索引,这时InnoDB存储引擎会使用隐式的主键来进行锁定。 Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,innodb对于行的查询都是采用这种锁定算法。例如一个索引有9,11,13,20这4个值,那么该索引可能被Next-Key Locking的范围为(左开右闭 ): (- &,9] (9,11] (13,20] (20,+ &) 采用Next-Key Lock的锁定技术称为Next-Key Locking。这种设计的目的是为了解决幻读(Phantom Problem)。利用这种锁定技术,锁定的不是单个值,而是一个范围。 当查询的索引含有唯一属性时,innodb存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即锁住索引记录本身,而不再是范围。 对于唯一索引,其加上的是Record Lock,仅锁住记录本身。但也有特别情况,那就是唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么加锁的情况依然是Next-key lock。 1 2 3 4 5 6 7 8 9 10 | DROP TABLE IF EXISTS t; CREATE TABLE t (a INT PRIMARY KEY); INSERT INTO t VALUES (1), (2), (5); | 表t中共有1、2、5三个值。在上面的例子中,在会话A中首先对a=5进行X锁定。而由于a是主键且唯一,因此锁定的仅是5这个值,而不是(2,5)这个范围,这样在会话B中插入值4而不会阻塞,可以立即插入并返回。即锁定由Next-Key Lock算法降级为了Record Lock,从而提高应用的并发性。正如前面所介绍的,Next-Key降级为Record Lock仅在查询的列是唯一索引的情况下。若是辅助索引,则情况会完全不同。同样,首先根据如下代码创建测试表Z: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | CREATE TABLE Z ( a INT, b INT, PRIMARY KEY (a), KEY (b) ); INSERT INTO Z VALUES (1, 1), (3, 1), (5, 3), (7, 6), (10, 8); | 表Z的列b是辅助索引,若在会话A中执行下面的SQL语句: 1 | SELECT * FROM Z WHERE b=3 FOR UPDATE; | 很明显,这时SQL语句通过索引列b进行查询,因此其使用传统的Next-Key Locking技术加锁,并且由于有两个索引,其需要分别进行锁定。对于聚集索引,其仅对列a等于5的索引加上Record Lock。而对于辅助索引,其加上的是Next-Key Locking,锁定的范围是(1,3),特别需要注意的是,InnoDB存储引擎会对辅助索引下一个键值加上gap lock,即还有一个辅助索引范围为(3,6)的锁。 因此,若在新会话B中运行下面的SQL语句,都会被阻塞: 1 2 3 | SELECT * FROM Z WHERE a=5 LOCK IN SHARE MODE; INSERT INTO Z SELECT 4,2; INSERT INTO Z SELECT 6,5; | 第一个SQL语句不能执行,因为在会话A中执行的SQL语句已经聚集索引中列a=5的值加上X锁,因此执行会被阻塞。第二个SQL语句,主键插入4,没有问题,但是插入的辅助索引值2在锁定的范围(1,3)中因此执行同样会被阻塞。第三个SQL语句,插入的主键6没有被锁定,5也不在范围(1,3)之间。但插入的值5在另一个锁定范围(3,6)中,故同样需要等待。而下面的SQL语句,不会被阻塞,可以立即执行: 1 2 3 | INSERT INTO Z SELECT 8,6; INSERT INTO Z SEELCT 2,0; INSERT INTO Z SELECT 6,7; | 从上面的例子中可以看到,Gap Lock的作用是为了阻止多个事务将记录插入到同一个范围内,而这会导致Phantom Problem问题的产生。 例如在上面的例子中,会话A中用户已经锁定了b=3的记录。若此时没有Gap Lock锁定(3,6),那么用户可以插入索引b列为3的记录,这会导致会话A中的用户再次执行同样查询时会返回不同的记录,导致Phantom Problem问题的产生。 用户可以通过以下两种方式来显式地关闭Gap Lock: - 将事务的隔离级别设置为READ COMMITTED - 将参数innodb_locks_unsafe_for_binlog设置为1 在上述的配置下,除了外键约束和唯一性检查依然需要的Gap Lock,其余情况仅使用Record Lock进行锁定。但需要牢记的是,上述设置破坏了事务的隔离性,并且对于replication,可能会导致主从数据的不一致。此外,从性能上来看,READ COMMITTED也不会优于默认的事务隔离级别READ REPEATABLE。 在InnoDB存储引擎中,对于INSERT的操作,其会检查插入记录的下一条记录是否被锁定,若已被锁定,则不允许查询。对于上面的例子,会话A已经锁定了表z中b=3的记录,即已经锁定了(1,3)的范围,这时若在其他会话中进行如下的插入同样会导致阻塞: 1 | INSERT INTO Z SELECT 2,2; | 因为在辅助索引列b上插入值为2的记录时,会监测到下一个记录3已经被索引。而将插入修改为如下的值,可以立即执行: 1 | INSERT INTO Z SELECT 2,0; | 最后再次提醒的是,对于唯一键值的锁定,Next-Key Lock降级为Record Lock仅存在于查询所有的唯一索引一列。若唯一索引由多个列组成,而查询是查找多个唯一索引列中的其中一个,那么查询其实是range类型查询,而不是point类型查询故InnoDB存储引擎依然使用Next-Key Lock进行锁定。 解决 Phantom Problem 在默认的事务隔离级别下,即REPEATABLE READ下,InnoDB存储引擎采用 Next-Key Locking机制来避免Phantom Problem (幻像问题)。这点可能不同于与其他的数据库,如Oracle数据库,因为其可能需要在SERIALIZABLE的事务隔离级别下才能解决 Phantom Problem。 Phantom Problem是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。 下面将演示这个例子,使用前一小节所创建的表t。表t由1、2、5这三个值组成: 1 2 3 4 5 6 7 8 9 10 | DROP TABLE IF EXISTS t; CREATE TABLE t (a INT PRIMARY KEY); INSERT INTO t VALUES (1), (2), (5); | 若这时事务T1执行如下的SQL语句: 1 | SELECT * FROM t WHERE a> 2 FOR UPDATE; | 注意这时事务T1并没有进行提交操作,上述应该返回5这个结果。若与此同时,另一个事务T2插入了 4这个值,并且数据库允许该操作,那么事务T1再次执行上述SQL语句会得到结果4和5。这与第一次得到的结果不同,违反了事务的隔离性,即当前事务能够看到其他事务的结果。其过程如表6-13所示: InnoDB存储引擎采用Next-Key Locking的算法避免Phantom Problem。对于上述的SQL语句SELECT * FROM t WHERE a>2 FOR UPDATE,其锁住的不是5这单个值,而是对(2, +〇〇)这个范围加了 X锁。因此任何对于这个范围的插入都是不被允许的,从而避免 Phantom Problem。 InnoDB存储引擎默认的事务隔离级别是REPEATABLE READ,在该隔离级别下, 其采用Next-Key Locking的方式来加锁。而在事务隔离级别READ COMMITTED下,其仅采用Record Lock,因此在上述的示例中,会话A需要将事务的隔离级别设置为READ COMMITTED。 此外,用户可以通过InnoDB存储引擎的Next-Key Locking机制在应用层面实现唯一性的检查。 例如: 1 2 3 4 | SELECT * FROM table WHERE col=xxx LOCK IN SHARE MODE; If not found any row: # unique for insert value INSERT INTO table VALUES (...); | 如果用户通过索引査询一个值,并对该行加上一个SLock,那么即使査询的值不在,其锁定的也是一个范围,因此若没有返回任何行,那么新插人的值一定是唯一的。也许有读者会有疑问,如果在进行第一步SELECT •••LOCK IN SHARE MODE操作时,有多个事务并发操作,那么这种唯一性检査机制是否存在问题。其实并不会,因为这时会导致死锁,只有一个事务的插人操作会成功,而其余的事务会抛出死锁的错误,如表6-14所示。 ### MySQL InnoDB存储引擎:分区表 - URL: https://jiankunking.com/mysql-innodb-partition-table.html - Content type: original - Published: 2018-10-13 - Updated: 2018-10-13 - Summary: 系统介绍 MySQL 分区表的原理、水平分区特点、常见分区类型,以及分区设计的适用场景和风险。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 系统介绍 MySQL 分区表的原理、水平分区特点、常见分区类型,以及分区设计的适用场景和风险。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:《MySQL技术内幕:InnoDB存储引擎》 第二版 作者:姜承尧 出版时间:2013-05 MySQL分区表介绍 分区是一种表的设计模式,正确的分区可以极大地提升数据库的查询效率,完成更高质量的SQL编程。但是如果错误地使用分区,那么分区可能带来毁灭性的的结果。 分区功能并不是在存储引擎层完成的,因此不只有InnoDB存储引擎支持分区,常见的存储引擎MyISAM、NDB等都支持分区。 但是并不是所有的存储引擎都支持,如CSV、FEDORATED、MERGE等就不支持分区。在使用此分区功能前,应该对选择的存储引擎对分区的支持有所了解。 MySQL数据库在5.1版本时添加了对分区的支持,分区的过程是将一个表或索引分解为多个更小、更可管理的部分。就访问数据库的应用而言,从逻辑上讲,只有一个表或一个索引,但是在物理上这个表或索引可能由数十个物理分区组成。每个分区都是独立的对象,可以独自处理,也可以作为一个更大对象的一部分进行处理。 MySQL数据库支持的分区类型为水平分区(指将同一个表中不同行的记录分配到不同的物理文件中),并不支持垂直分区(指将同一表中不同列的记录分配到不同的物理文件中)。此外,MySQL数据库的分区是局部分区索引,一个分区中既存放了数据又存放了索引。而全局分区是指,数据存放在各个分区中,但是所有数据的索引放在一个对象中。目前,MySQL数据库还不支持全局分区。 可以通过以下命令来查看当前数据库是否启用了分区功能: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | MySQL> show global variables like '%partition%'; +-------------------+-------+ | Variable_name | Value | +-------------------+-------+ | have_partitioning | YES | +-------------------+-------+ 1 row in set (0.04 sec) MySQL> show plugins *************************** 43. row *************************** Name: partition Status: ACTIVE Type: STORAGE ENGINE Library: NULL License: GPL | 有时候可能会有这么一种误区,只要启用了分区,数据库就会运行的更快。这个结论结论是存在很多问题的,就经验来看,分区可能会给某些SQL语句性能带来提高,但是分区主要用于数据库高可用性的管理。在OLTP应用中,对于分区的使用应该非常小心,总之,如果只是一味地使用分区,而不理解分区是如何工作的,也不清楚你的应用如何使用分区,那么分区极有可能会对性能产生负面的影响。 MySQL分区类型 RANGE分区 RANGE分区,是最常用的一种分区类型,基于属于一个给定连续区间的列值,把多行分配给分区。这些区间要连续且不能相互重叠,使用VALUES LESS THAN操作符来进行定义。 LIST分区 LIST分区和RANGE分区类似,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择,而非连续的。 LIST分区通过使用“PARTITION BY LIST(expr)”来实现,其中“expr” 是某列值或一个基于某个列值、并返回一个整数值的表达式,然后通过“VALUES IN (value_list)”的方式来定义每个分区,其中“value_list”是一个通过逗号分隔的整数列表。 HASH分区 HASH分区的目的是将数据均匀地分布到预先定义的各个分区中,保证各分区的数据量大致都是一样的。在RANGE和LIST分区中,必须明确指定一个给定的列值或列值集合应该保存在哪个分区中;而在HASH分区中,MySQL自动完成这些工作,用户所要做的只是基于将要进行哈希分区的列值指定一个列值或表达式,以及指定被分区的表将要被分隔成的分区数量。 要使用HASH分区来分割一个表,要在CREATE TABLE 语句上添加一个“PARTITION BY HASH (expr)”子句,其中“expr”是一个返回一个整数的表达式。它可以仅仅是字段类型为MySQL 整型的一列的名字。此外,你很可能需要在后面再添加一个“PARTITIONS num”子句,其中num是一个非负的整数,它表示表将要被分割成分区的数量,如果没有包括一个PARTITIONS子句,那么分区的数量将默认为1。 LINER HASH MySQL还支持线性哈希功能,它与常规哈希的区别在于,线性哈希功能使用的一个线性的2的幂(powers-of-two)运算法则,而常规哈希使用的是求哈希函数值的模数。 线性哈希分区和常规哈希分区在语法上的唯一区别在于,在“PARTITION BY” 子句中添加“LINEAR”关键字。 KEY分区 KEY分区和HASH分区相似,不同之处在于HASH分区使用用户定义的函数进行分区,支持字符串HASH分区,KEY分区使用MySQL数据库提供的函数进行分区,这些函数基于与PASSWORD()一样的运算法则。 COLUMNS 在前面说了RANGE、LIST、HASH和KEY这四种分区中,分区的条件是:数据必须为整形(interger),如果不是整形,那应该需要通过函数将其转化为整形,如YEAR(),TO_DAYS(),MONTH()等函数。MySQL5.5版本开始支持COLUMNS分区,可视为RANGE分区和LIST分区的一种进化。COLUMNS分区可以直接使用非整形的数据进行分区,分区根据类型直接比较而得,不需要转化为整形。此外,RANGE COLUMNS分区可以对多个列的值进行分区。 COLUMNS分区支持以下的数据类型: - 所有的整形类型,如INT、SMALLINT、TINYINT和BIGINT。而FLOAT和DECIMAL则不予支持。 - 日期类型,如DATE何DATETIME。其余的日期类型不予支持。 - 字符串类型,如CHAR、VARCHAR、BINARY和VARBINARY。而BLOB和TEXT类型不予支持。 分区中的NULL值 MySQL数据库允许对NULL值做分区,但是处理的方法与其他数据库可能完全不同。MySQL数据库的分区总是视NULL值小于任何的一个非NULL值,这和MySQL数据库中处理NULL值的ORDER BY操作是一样的。 因此对于不同的分区类型,MySQL数据库对于NULL值的处理也是各不相同。 - 对于RANGE分区,如果向分区列插入了NULL值,则MySQL数据库会将该值放入最左边的分区。 - 对于LIST分区,如果向分区列插入了NULL值,则必须显示地指出哪个分区放入NULL值,否则会报错。对于LIST分区,如果向分区列插入了NULL值,则必须显示地指出哪个分区放入NULL值,否则会报错。 - 对于HASH和KEY分区,对于NULL值的处理方法和RANGE分区、LIST分区不一样。任何分区函数都会将含有NULL值的记录返回为0。 分区和性能 分区真的会加快数据库的查询吗?实际上可能根本感觉不到查询速度的提升,甚至会发现查询速度急剧下降,因此在合理使用分区之前,必须了解分区的使用环境。 数据库的应用分为两类:一类是OLTP(在线事务处理),如Blog、电子商务、网络游戏等;另一类是OLAP(在线分析处理),如数据仓库、数据集市。对于OLAP的应用,分区的确是可以很好地提高查询的性能,因为OLAP应用大多数查询需要频繁地扫描一张很大的表。假设有一张1亿行的表,其中有一个时间戳属性列。用户的查询需要从这张表中获取一年的数据。如果按时间戳进行分区,则只需要扫描相应的分区即可。这就是前面介绍的分区修剪技术。 对于OLTP的应用,分区应该非常小心。在这种应用下,通常不可能会获取一张大表10%的数据,大部分都是通过索引返回几条记录即可。而根据B+树索引的原理可知,对于一张大表,一般的B+树需要2~3次的磁盘IO。因此B+树可以很好地完成操作,不需要分区的帮助,并且设计不好的分区会带来严重的性能问题。 如很多开发团队会认为含有1000w行的表是一张非常巨大的表,所以他们往往会选择采用分区,如对主键做10个HASH的分区,这样每个分区就只有100w的数据了,因此查询应该变得更快了。如select * from table where pk=@pk。但是有没有考虑过这样一种情况:100w和1000w行的数据本身构成的B+树的层次都是一样的,可能都是2~3层。那么上述走主键分区的索引并不会带来性能的提高。好的,如果1000w的B+树高度是3,100w的B+树高度是2,那么上述按主键分区的索引可以避免1次IO,从而提高查询的效率。这没问题,但是这张表只有主键索引,没有任何其他的列需要查询的。如果还有类似如下的SQL:select * from table where key=@key,这时对于key的查询需要扫描所有的10个分区,即使每个分区的查询开销为2次IO,则一共需要20次IO。而对于原来单表的设计,对于KEY的查询只需要2~3次IO。 由以上结论可以看出,对于在OLTP场景中使用分区一定要特别小心了。 MySQL 5.7对分区的改进 在MySQL 5.6里面,分区的信息是在MySQL Server层维护的(在.par文件里面),InnoDB引擎层是不知道有分区这个概念的,InnoDB引擎层把每一个分区都当成一张普通的InnoDB表。在打开一个分区表时,会打开很多个分区,打开这些分区表就相当于打开了同等数量的InnoDB表,这需要更多内存存放InnoDB表的元数据和各种与ibd文件打开相关的各种cache与handler的信息。在MySQL 5.7里面,InnoDB引入了Native Partitioning,它把分区的信息从Server层移到了InnoDB层,打开一个分区表和打开一个InnoDB表的内存开销基本是一样的。 ### MySQL InnoDB存储引擎:一致性锁定读 - URL: https://jiankunking.com/mysql-innodb-consistent-locking-read.html - Content type: original - Published: 2018-10-11 - Updated: 2018-10-11 - Summary: 介绍 InnoDB 一致性锁定读的两种方式:SELECT FOR UPDATE 与 LOCK IN SHARE MODE,以及共享锁、排他锁的使用场景。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 介绍 InnoDB 一致性锁定读的两种方式:SELECT FOR UPDATE 与 LOCK IN SHARE MODE,以及共享锁、排他锁的使用场景。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:《MySQL技术内幕:InnoDB存储引擎》 第二版 作者:姜承尧 出版时间:2013-05 在前一小节中讲到,在默认配置下,即事务的隔离级别为 REPEATABLE READ 模式下, InnoDB 存储引擎的 SELECT 操作使用一致性非锁定读。但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。而这要求数据库支持加锁语句,即使是对于SELECT的只读操作。InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作: 1 2 | SELECT......FOR UPDATE SELECT......LOCK IN SHARE MODE | SELECT…FOR UPDATE对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。 SELECT…LOCK IN SHARE MODE对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。 对于一致性非锁定读,即使读取的行已被执行了 SELECT…FOR UPDATE,也是可以进行读取的,这和之前讨论的情况一样。此外,SELECT…FOR UPDATE, SELECT…LOCK IN SHARE MODE必须在一个事务中,当事务提交了,锁也就释放了。因此在使用上述两句SELECT锁定语句时,务必加上BEGIN,START TRANSACTION 或者SET AUTOCOMMIT =0 。 ### MySQL InnoDB存储引擎:一致性非锁定读 - URL: https://jiankunking.com/mysql-innodb-consistent-nonlocking-read.html - Content type: original - Published: 2018-10-11 - Updated: 2018-10-11 - Summary: 解析 InnoDB 一致性非锁定读、MVCC 快照读取机制,以及不同事务隔离级别下快照数据的差异。 - Categories: MySQL - Tags: Reading Notes, MySQL, InnoDB Article text: 文章速览 解析 InnoDB 一致性非锁定读、MVCC 快照读取机制,以及不同事务隔离级别下快照数据的差异。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:MySQL 本文整理自:《MySQL技术内幕:InnoDB存储引擎》 第二版 作者:姜承尧 出版时间:2013-05 一致性的非锁定行读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE、UPDATE操作,这是读取操作不会因此而会等待行上锁的释放,相反,InnoDB会去读取行的一个快照数据。 下图直观展示了一致性的非锁定行读: 之所以称其为非锁定读,因为不需要等待访问的行上X锁的释放。快照数据是指该行的之前版本的数据,该实现是通过undo段来完成。而undo段用来在此事务中回滚数据,因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。 可以看到,非锁定读机制极大地提髙了数据库的并发性。在InnoDB存储引擎的默认设置下,这是默认的读取方式,即读取不会占用和等待表上的锁。但是在不同事务隔离级别下,读取的方式不同,并不是在每个事务隔离级别下都是采用非锁定的一致性读。此外,即使都是使用非锁定的一致性读,但是对于快照数据的定义也各不相同。 通过图6-4可以知道,快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本。就图6-4所显示的,一个行记录可能有不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(MultiVersionConcurrencyControl MVCC)。 在事务隔离级别READ COMMITTED和REPEATABLE READ(InnoDB存储引擎的默认事务隔离级别)下,InnoDB存储引擎使用非锁定的一致性读。然而,对于快照数据的定义却不相同。在READ COMMITTED事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本(关键在于事务之间的隔离性)。来看下面的一个例子,首先在当前MySQL数据库的连接会话A中执行如下SQL语句: 1 2 3 4 5 6 7 8 9 10 11 | # Session A MySQL> BEGIN; Query OK, 0 rows affected (0.00 sec) Sql> SELECT * FROM parent WHERE id =1; +----+ | id | +----+ | 1 | +----+ 1 row in set (0.00 sec) | 会话A中已通过显式地执行命令BEGIN开启了一个事务,并读取了表parent中id为1的数据,但是事务并没有结束。与此同时,用户再开启另一个会话B,这样可以模拟并发的情况,然后对会话B做如下的操作: 1 2 3 4 5 6 | MySQL> BEGIN; Query OK, 0 rows affected (0.00 sec) MySQL> UPDATE parent SET id=3 WHERE id=l; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 warnings: 0 | 在会话B中将事务表parent中id为1的记录修改为id=3,但是事务同样没有提交,这样id=1的行其实加了一个X锁。这时如果在会话A中再次读取id为1的记录,根据InnoDB存储引擎的特性,即在READ COMMITTED和REPEATETABLE READ的事务隔离级别下会使用非锁定的一致性读。回到之前的会话A,接着上次未提交的事务,执行SQL语句SELECT * FROM parent WHERE id=1的操作,这时不管使用READ COMMITTED还是REPEATABLE READ的事务隔离级别,显示的数据应该都是: 1 2 3 4 5 6 7 | MySQL> SELECT FROM parent WHERE id =l; +----+ | id | +----+ | 1 | +----+ 1 row in set (0.00 sec) | 由于当前id=1的数据被修改了1次,因此只有一个行版本的记录。接着,在会话B中提交上次的事务。 1 2 3 | # Session B MySQL> commit Query OK, 0 rows affected (0.01 sec) | 在会话B提交事务后,这时在会话A中再运行SELECT * FROM parent WHERE id=1的SQL语句,在READ COMMITTED和REPEATABLE事务隔离级别下得到结果 就不一样了。对于READ COMMITTED的事务隔离级别,它总是读取行的最新版本,如果行被锁定了,则读取该行版本的最新一个快照(fresh snapshot)。在上述例子中,因为会话B已经提交了事务,所以READ COMMITTED事务隔离级别下会得到如下结果: 1 2 3 4 5 6 7 | MySQL>SELECT @@tx_isolation\G; **************************** 1.row **************************** @@tx_isolation: READ-COMMITTED 1 row in set (0.00 sec) MySQL> SELECT FROM parent WHERE id=1: Empty set (0.00 sec) | 而对于REPEATETABLE 的事务隔离级别,总是读取事务开始时的行数据。因此对于REPEATETABLE READ事务隔离级别,其得到的结果如下: 1 2 3 4 5 6 7 8 9 10 11 12 | MySQL> SELECT @@tx_isolation\G; **************************** 1.row **************************** @@tx_isolation: REPEATABLE-READ 1 row in set (0.00 sec) MySQL> SELECT FROM parent WHERE id=1; +----+ | id | +----+ | 1 | +----+ 1 row in set (0.00 sec) | 下面将从时间的角度展现上述演示的示例过程,如表6-8所示。需要特别注意的是,对于READ COMMITTED 的事务隔离级别而言,从数据库理论的角度来看,其违反了事务ACID中的I的特性,即隔离性。 ### 日志服务架构设计 - URL: https://jiankunking.com/log-service-architecture-design.html - Content type: original - Published: 2018-10-10 - Updated: 2018-10-10 - Summary: 介绍面向多个 Elasticsearch 集群和数十 TB 数据规模的日志服务架构设计,重点关注简单性、稳定性与治理。 - Categories: Architecture - Tags: Elasticsearch, Architecture, Log, Filebeat Article text: 文章速览 介绍面向多个 Elasticsearch 集群和数十 TB 数据规模的日志服务架构设计,重点关注简单性、稳定性与治理。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Architecture 在满足业务需求的前提下,代码、架构,越简单,越稳定。 集群规模:Elasticsearch集群数10+,单集群数据量30T左右。 NOW 前言 最近想把之前做过的日志项目及个人的思考梳理一下,于是有了本文。 背景 我们这边应用部署的环境比较复杂,主要有以下几种: - 机器直接部署 - 通过原生docker部署 - 通过kubernetes集群部署 部署环境不统一,导致查看应用日志很不方便。 业务需求 与部署环境对应,对于日志收集需求分为以下几类: - 机器上的文本日志(直接运行在物理机或者虚拟机中的应用日志) - 运行在docker容器中的应用日志 - 运行在kubernetes集群中的应用日志 具体业务需求可以拆分为: - 按照项目、应用、实例维度检索日志并支持搜索关键字高亮(因为大家检索日志的时候,肯定是检索某个项目、某个应用、某个实例的日志) - 支持检索出某条想要的日志后,可以查看上下文(查看该日志所在日志文件的日志上下文) - 支持日志下载(目前支持两种场景:搜索结果下载、上下文下载;支持两种方式:在线下载、离线下载) - 支持自动化批量部署、卸载Agent,部署、卸载过程可视化 - 单实例支持多elasticsearch集群 - 支持文本日志、docker日志、k8s日志并能与将日志与其业务意义对应上。(即不管是哪种日志形式、来源,最终都需要与业务意义上的项目、应用、实例对应起来,因为对于日志的使用者来说,查询日志的出发点肯定是查询某个项目、某个应用(可以不选)、某个实例(可以不选)、某段时间的日志。) - 支持部署到业务自己的集群中 需求已经明确了,下面看一下业界方案。 业界日志系统架构 - Collector的作用是: - 清洗、汇聚数据,减少对于后端集群的压力。 - 安全,不允许Agent直连kafka等内部集群,保证一定的安全性,即使后端发生调整也能保证对于Agent连接、认证方式的稳定。 - MQ的作用是削峰填谷、解耦、多次消费。 上图的架构是业界比较通用的一种架构,对于各种场景都考虑的比较全。 既然业界的架构已经这么完备,那么我们是否就直接采用呢? 对于我们而言,有以下几个问题: - 涉及的组件比较多,链路比较长,运维比较麻烦 - 这一整套架构,不利于单独部署(比如某个业务应用部署机房网络是隔离的,而且项目又不大,只能提供有限的几台机器,这时候如果需要部署业界这套架构的话,资源就会比较受限,如果想做到即支持业界架构组件的可插拔(比如可灵活的决定是否需要Collector、MQ),那么就需要运维几套配置或代码) - 最关键的就是其中组件提供的功能,我们目前用不到。比如MQ的削峰填谷、多次消费。 组件选择 选择组件,我们这边主要是从以下几个方面进行考量的: - 组件对应的开源生态完整、活跃度高 - 对应的技术栈是我们所熟悉的,我们这边语言技术栈主要是Java、Go,如果组件语言是C、Ruby,应该就被排除了。 - 运维成本 - 易部署、性能好 Agent 一提到日志收集方案,大家第一个想到的肯定是ELK(Elasticsearch、Logstash、Kibana ),但Logstash依赖于JVM不管是性能还是简洁性,都不是日志收集agent的首选。 个人感觉一个好的agent应该是资源占用少,性能好,不依赖别的组件,可以独立部署。而Logstash明显不符合这几点要求,也许正是基于这些考虑elastic推出了Filebeat。 Collector、MQ Elasticsearch集群在部署的时候,一般都是提前估计好容量、机器、shard等信息,因为Elasticsearch集群运行后,再水平拓展,比较麻烦,而我们这边由于业务及成本限制无法很好的预估容量,所以就结合公司实际要求:使用日志服务的业务方自带机器,也就是业务方会有独立的Elasticsearch集群。 每个业务方都使用自己的Elasticsearch集群,所以集群压力不会很大,从而Collector、MQ这两个组件对于我们的作用也就很小了。 ETL 因为Elasticsearch Ingest Node完全可以满足我们的解析需求,所以就没有必要再引入Logstash等相关组件了。 到这里,基本可以看出我们的架构如下: 架构设计的几个原则: - 合适优于业界领先 - 简单优于复杂 - 演化优于一步到位 具体实现 基于需求及EFK套件,梳理我们场景中特有的东西: - docker日志的场景比较单一,都是通过之前一个产品A发布部署的,其docker命名规则比较统一,可以通过截取docker.container.name来获取应用名字;同时在部署的时候,可以知道部署目标机器的ip,这样就可以通过应用+ip来作为实例名称。 - k8s场景也比较统一,都是通过之前一个产品B发布部署的,其pod命名规则比较统一,可以通过截取kubernetes.pod.name来获取应用名字(但需要通过namespaces关联到tenant,再通过tenant与项目一一对应);k8s中的pod.name就是唯一的,以此来作为实例名称即可。 - 文本日志:因为文本日志主要的场景是已经裸机部署的应用,这种场景下,不存在应用自动迁移的情况,所以文本日志的应用名称、实例名称可以在部署的时候打上标签即可。 具体规则及解析见下图(实例部分处理暂未标注): 推荐写日志到文本文件中,使用标准输出就好。 到这里可以发现我们选择Filebeat来作为日志的收集端,Elasticsearch来存储日志并提供检索能力。 那么,日志的清洗在哪里做呢? 日志的清洗一般有两种方式: - 先把日志收集到kafka,再通过Logstash消费kafka的数据,来清洗数据 - 直接通过Elasticsearch的[Ingest Node]来清洗数据,因为Ingest Node也支持Grok表达式 对于,我们的场景而言,我们需要清洗数据的要求比较简单,主要是应用、实例名称的截取还有文本日志中日志时间的处理(@timestamp重置,时区处理),所以我们选择了方案2。 在我们的方案中,并没有提供Kibana 的界面直接给用户用,而是我们自己根据公司业务独立开发的。 前端界面为什么不采用Kibana,而需要自己开发? - kibana对于业务开发人员有一定的学习成本 - kibana界面没有很好的将日志内容与业务意义关联起来(界面选择总比一次次的输入要好,这也是我们将日志的项目、应用、实例等业务信息解析出来的原因) - log-search支持Query String,因此对于熟悉kibana的开发人员来说,在我们自己开发的前端界面检索效果是一样的。 - kibana无法查看某条日志的上下文 log-search提供的功能可以参见github:https://github.com/jiankunking/log-search 如果日志需要清洗的比较多,可以采用方案1,或者先不清洗,先把数据落到Elasticsearch,然后在查询的时候,进行处理。比如在我们的场景中,可以先把日志落到Elasticsearch中,然后在需要检索应用名称的时候,通过代码来处理并获取app名字。 演进(二期) 集群规模 Elasticsearch集群10+,单集群数据量30T左右。 其它 DaemonSet 以DaemonSet方式部署Filebeat来收集日志,其实收集也是宿主机/var/lib/docker/containers目录下的日志。 Running Filebeat on Kubernetes Sidecar 一个POD中运行一个sidecar的日志agent容器,用于采集该POD主容器产生的日志。 莫名想起了istio。 Filebeat可以以sidecar模式来进行容器日志的收集,也就是filebeat和具体的服务容器部署在同一个pod内,指定收集日志的路径或文件,> 即可将日志发送到指定位置或Elasticsearch这类的搜索引擎。 每个pod内部署filebeat的模式,好处是和具体的应用服务低耦合,可扩展性强,不过需要在yaml进行额外配置。 ### [转]当你在浏览器中输入 google.com 并且按下回车之后发生了什么? - URL: https://jiankunking.com/what-happens-when.html - Content type: repost - Published: 2018-08-17 - Updated: 2018-08-17 - Summary: 从键盘输入、URL 解析、DNS、网络连接、HTTP 请求到浏览器渲染,解释访问网站时发生的完整过程。 - Categories: Network - Tags: Network, HTTP - Original source: https://github.com/skyline75489/what-happens-when-zh_CN The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Java Volatile的内存语义与AQS锁内存可见性 - URL: https://jiankunking.com/java-volatile-aqs.html - Content type: original - Published: 2018-05-23 - Updated: 2018-05-23 - Summary: 分析 Java volatile 的内存语义与内存屏障,并解释 AQS 和 ReentrantLock 如何保证锁内代码的可见性。 - Categories: Java - Tags: Java, AQS, Volatile Article text: 文章速览 分析 Java volatile 的内存语义与内存屏障,并解释 AQS 和 ReentrantLock 如何保证锁内代码的可见性。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 提到volatile首先想到就是:保证可见性和禁止指令重排序。但ReentrantLock是如何保证代码段中变量的可见性?本文深入分析volatile的内存语义、内存屏障以及AQS锁如何利用volatile实现内存可见性。 提到volatile首先想到就是: 如果理解了,大家考虑这么一个问题:ReentrantLock(或者其它基于AQS实现的锁)是如何保证代码段中变量(变量主要是指共享变量,存在竞争问题的变量)的可见性? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | private static ReentrantLock reentrantLock = new ReentrantLock(); private static int count = 0; //... // 多线程 run 如下代码 reentrantLock.lock(); try { count++; } finally { reentrantLock.unlock(); } //... | 既然提到了可见性,那就先熟悉几个概念: JMM JMM:Java Memory Model 即 Java 内存模型 The Java Memory Model describes what behaviors are legal in multithreaded code, and how threads may interact through memory. It describes the relationship between variables in a program and the low-level details of storing and retrieving them to and from memory or registers in a real computer system. It does this in a way that can be implemented correctly using a wide variety of hardware and a wide variety of compiler optimizations. Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量主要是指共享变量,存在竞争问题的变量。Java内存模型规定所有的变量都存储在主内存中,而每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(根据Java虚拟机规范的规定,volatile变量依然有共享内存的拷贝,但是由于它特殊的操作顺序性规定——从工作内存中读写数据前,必须先将主内存中的数据同步到工作内存中,所有看起来如同直接在主内存中读写访问一般,因此这里的描述对于volatile也不例外)。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来完成。 重排序 在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型: - 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 - 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 - 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序: 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。 JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。 happens-before - 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。 - 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。 - volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(对一个volatile变量的读,总是能看到【任意线程】对这个volatile变量最后的写入) - 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。 两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。 内存屏障 - 硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。 - 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据; - 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。 - 内存屏障有两个作用: - 阻止屏障两侧的指令重排序; - 强制把写缓冲区/高速缓存中的数据等写回主内存,让缓存中相应的数据失效。 volatile的内存语义 从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。 从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果: - volatile写和锁的释放有相同的内存语义; - volatile读与锁的获取有相同的内存语义。 volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。 volatile变量自身具有下列特性: - 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。 - 原子性:对任意单个volatile变量的读/写具有原子性,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。 volatile写和volatile读的内存语义: - 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。 - 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。 - 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。 JMM针对编译器制定的volatile重排序规则表 - 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。 - 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。 - 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障几乎是不可能的。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。 - 在每个volatile写操作的前面插入一个StoreStore屏障。 - 在每个volatile写操作的后面插入一个StoreLoad屏障。 - 在每个volatile读操作的后面插入一个LoadLoad屏障。 - 在每个volatile读操作的后面插入一个LoadStore屏障。 LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。 上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。 下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图. 图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。 这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。 下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图: 图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。 AQS 对于AQS需要了解这么几点: - 锁的状态通过volatile int state来表示。 - 获取不到锁的线程会进入AQS的队列等待。 - 子类需要重写tryAcquire、tryRelease等方法。 AQS 详解参见:面试必备:Java AQS 实现原理(图文)分析 ReentrantLock 以公平锁为例,看看 ReentrantLock 获取锁 & 释放锁的关键代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | /** * The synchronization state. */ private volatile int state; /** * Returns the current value of synchronization state. * This operation has memory semantics of a {@code volatile} read. * @return current state value */ protected final int getState() { return state; } // 释放锁 protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c);// 释放锁的最后,写volatile变量state return free; } // 获取锁 protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState();// 获取锁的开始,首先读volatile变量state if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } | 通过ReentrantLock实例调用lock()、unlock()时,acquires、releases的值都是1。 公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。从而保证了代码段中变量(变量主要是指共享变量,存在竞争问题的变量)的可见性。 小结 如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。 - 首先,声明共享变量为volatile。 - 然后,使用CAS的原子条件更新来实现线程之间的同步。 - 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。 前文我们提到过,编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。 推荐阅读: https://jiankunking.com/java-volatile-keyword.html 本文参考: 1、《Java并发编程的艺术》 方腾飞 魏鹏 程晓明 著 2、Java 可重入锁内存可见性分析 ### Java Lambda表达式 实现原理分析 - URL: https://jiankunking.com/java-lambda.html - Content type: original - Published: 2018-04-05 - Updated: 2018-04-05 - Summary: 基于JDK 9分析Lambda表达式的实现原理,包括函数式接口、invokedynamic指令、LambdaMetafactory等核心机制。 - Categories: Java - Tags: Java, Lambda Article text: 文章速览 基于JDK 9分析Lambda表达式的实现原理,包括函数式接口、invokedynamic指令、LambdaMetafactory等核心机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 基于JDK 9分析Lambda表达式的实现原理,包括函数式接口、invokedynamic指令、LambdaMetafactory等核心机制。 本文分析基于JDK 9 一、目标 本文主要解决两个问题: 1、函数式接口 到底是什么? 2、Lambda表达式是怎么实现的? 先介绍一个jdk的bin目录下的一个字节码查看工具及反编译工具:javap 二、函数式接口 1 2 3 4 | @FunctionalInterface interface IFunctionTest { public void print(T x); } | 通过javap 反编译IFunctionTest.class 可以看到如下信息: 1 2 3 4 5 | $C:\Users\Code\Java\study>javap -p IFunctionTest.class Compiled from "FunctionTest.java" interface IFunctionTest { public abstract void print(T); } | 可以看到函数式接口编译完之后依然是一个接口,这个接口具有唯一的一个抽像方法。 为什么说需要是唯一一个抽象方法? 1 2 3 4 5 | @FunctionalInterface interface IFunctionTest { public void print(T x); public void print22(T x,int rr); } | 虽然不能在函数式接口中定义多个方法,但可以定义默认方法、静态方法、定义java.lang.Object里的public方法: 1 2 3 4 5 6 7 8 9 10 11 12 | @FunctionalInterface interface Print { public void print(T x); default void doSomeMoreWork1(){ // Method body } static void printHello(){ System.out.println("Hello"); } @Override boolean equals(Object obj); } | 反编译文件内容如下: 1 2 3 4 5 6 7 8 | $C:\Users\Code\Java\study>javap -p IFunctionTest.class Compiled from "FunctionTest.java" interface IFunctionTest { public abstract void print(T); public void doSomeMoreWork1(); public static void printHello(); public abstract boolean equals(java.lang.Object); } | 三、Lambda 3.1 示例代码 1 2 3 4 5 6 7 8 9 10 11 12 13 | public class LambdaTest { public static void printString(String s, Print print) { print.print(s); } public static void main(String[] args) { printString("test", (x) -> System.out.println(x)); } } @FunctionalInterface interface Print { public void print(T x); } | 通过javac编译LambdaTest.java文件,会生成LambdaTest.class、Print.class两个class文件。 1 | javac LambdaTest.java | 3.2 对于lambda实现的猜测 那么编译器对Lambda 都做了什么?反编译一下代码如下: 1 2 3 4 5 6 7 8 | C:\Users\Code\Java\study>javap -p LambdaTest.class Compiled from "LambdaTest.java" public class LambdaTest { public LambdaTest(); public static void printString(java.lang.String, Print); public static void main(java.lang.String[]); private static void lambda$main$0(java.lang.String); } | 由上面的代码可以看出编译器会根据Lambda表达式生成一个私有的静态函数: 1 | private static void lambda$main$0(java.lang.String); | 为了验证上面的转化是否正确? 我们在代码中定义一个lambda$main$0这个的函数,最终代码如下所示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class LambdaTest { public static void printString(String s, Print print) { print.print(s); } public static void main(String[] args) { printString("test", (x) -> System.out.println(x)); } private static void lambda$main$0(String s) { } } @FunctionalInterface interface Print { public void print(T x); } | 上面的代码在编译时会报错,错误信息如下: 1 2 3 4 5 6 7 8 9 10 | C:\Users\Code\Java\study>javac LambdaTest.java LambdaTest.java:8: 错误: 符号lambda$main$0(String)与LambdaTest中的 compiler-synt hesized 符号冲突 private static void lambda$main$0(String s) { ^ LambdaTest.java:1: 错误: 符号lambda$main$0(String)与LambdaTest中的 compiler-synt hesized 符号冲突 public class LambdaTest { ^ 2 个错误 | 有了上面的内容,可以知道的是Lambda表达式在Java 9中首先会生成一个私有的静态函数,这个私有的静态函数干的就是Lambda表达式里面的内容,那么又是如何调用的生成的私有静态函数(lambda$main$0(String s))呢? 3.3 反编译代码详解 查看更加详细的反编译结果: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | $C:\Users\Code\Java\study> javap -p -v -c LambdaTest.class Classfile /C:/Users/Code/Java/study/LambdaTest.class Last modified 2018-4-5; size 1184 bytes MD5 checksum b144b5a936a04a7c975eae93c7370174 Compiled from "LambdaTest.java" public class LambdaTest minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #9.#24 // java/lang/Object."":()V #2 = InterfaceMethodref #25.#26 // Print.print:(Ljava/lang/Object;)V #3 = String #27 // test #4 = InvokeDynamic #0:#33 // #0:print:()LPrint; #5 = Methodref #8.#34 // LambdaTest.printString:(Ljava/lang/String;LPrint;)V #6 = Fieldref #35.#36 // java/lang/System.out:Ljava/io/PrintStream; #7 = Methodref #37.#38 // java/io/PrintStream.println:(Ljava/lang/String;)V #8 = Class #39 // LambdaTest #9 = Class #40 // java/lang/Object #10 = Utf8 #11 = Utf8 ()V #12 = Utf8 Code #13 = Utf8 LineNumberTable #14 = Utf8 printString #15 = Utf8 (Ljava/lang/String;LPrint;)V #16 = Utf8 Signature #17 = Utf8 (Ljava/lang/String;LPrint;)V #18 = Utf8 main #19 = Utf8 ([Ljava/lang/String;)V #20 = Utf8 lambda$main$0 #21 = Utf8 (Ljava/lang/String;)V #22 = Utf8 SourceFile #23 = Utf8 LambdaTest.java #24 = NameAndType #10:#11 // "":()V #25 = Class #41 // Print #26 = NameAndType #42:#43 // print:(Ljava/lang/Object;)V #27 = Utf8 test #28 = Utf8 BootstrapMethods #29 = MethodHandle #6:#44 // invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; #30 = MethodType #43 // (Ljava/lang/Object;)V #31 = MethodHandle #6:#45 // invokestatic LambdaTest.lambda$main$0:(Ljava/lang/String;)V #32 = MethodType #21 // (Ljava/lang/String;)V #33 = NameAndType #42:#46 // print:()LPrint; #34 = NameAndType #14:#15 // printString:(Ljava/lang/String;LPrint;)V #35 = Class #47 // java/lang/System #36 = NameAndType #48:#49 // out:Ljava/io/PrintStream; #37 = Class #50 // java/io/PrintStream #38 = NameAndType #51:#21 // println:(Ljava/lang/String;)V #39 = Utf8 LambdaTest #40 = Utf8 java/lang/Object #41 = Utf8 Print #42 = Utf8 print #43 = Utf8 (Ljava/lang/Object;)V #44 = Methodref #52.#53 // java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; #45 = Methodref #8.#54 // LambdaTest.lambda$main$0:(Ljava/lang/String;)V #46 = Utf8 ()LPrint; #47 = Utf8 java/lang/System #48 = Utf8 out #49 = Utf8 Ljava/io/PrintStream; #50 = Utf8 java/io/PrintStream #51 = Utf8 println #52 = Class #55 // java/lang/invoke/LambdaMetafactory #53 = NameAndType #56:#60 // metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; #54 = NameAndType #20:#21 // lambda$main$0:(Ljava/lang/String;)V #55 = Utf8 java/lang/invoke/LambdaMetafactory #56 = Utf8 metafactory #57 = Class #62 // java/lang/invoke/MethodHandles$Lookup #58 = Utf8 Lookup #59 = Utf8 InnerClasses #60 = Utf8 (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; #61 = Class #63 // java/lang/invoke/MethodHandles #62 = Utf8 java/lang/invoke/MethodHandles$Lookup #63 = Utf8 java/lang/invoke/MethodHandles { public LambdaTest(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 1: 0 public static void printString(java.lang.String, Print); descriptor: (Ljava/lang/String;LPrint;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=2 0: aload_1 1: aload_0 2: invokeinterface #2, 2 // InterfaceMethod Print.print:(Ljava/lang/Object;)V 7: return LineNumberTable: line 3: 0 line 4: 7 Signature: #17 // (Ljava/lang/String;LPrint;)V public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: ldc #3 // String test 2: invokedynamic #4, 0 // InvokeDynamic #0:print:()LPrint; 7: invokestatic #5 // Method printString:(Ljava/lang/String;LPrint;)V 10: return LineNumberTable: line 6: 0 line 7: 10 private static void lambda$main$0(java.lang.String); descriptor: (Ljava/lang/String;)V flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC Code: stack=2, locals=1, args_size=1 0: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream; 3: aload_0 4: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 7: return LineNumberTable: line 6: 0 } SourceFile: "LambdaTest.java" InnerClasses: public static final #58= #57 of #61; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles BootstrapMethods: 0: #29 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;Method arguments: #30 (Ljava/lang/Object;)V #31 invokestatic LambdaTest.lambda$main$0:(Ljava/lang/String;)V #32 (Ljava/lang/String;)V | 这个 class 文件展示了三个主要部分:常量池、构造器方法和 printString、main、lambdamainmain0方法还有lambda表达式生成的内部类。 3.3.1 动态链接 每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking)。 C/C++ 代码一般被编译成对象文件,然后多个对象文件被链接到一起产生可执行文件或者 dll。在链接阶段,每个对象文件的符号引用被替换成了最终执行文件的相对偏移内存地址。在 Java中,链接阶段是运行时动态完成的。 当 Java 类文件编译时,所有变量和方法的引用都被当做符号引用存储在这个类的常量池中。符号引用是一个逻辑引用,实际上并不指向物理内存地址。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次。一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。 3.3.2 常量池 JVM 维护了一个按类型区分的常量池,一个类似于符号表的运行时数据结构。尽管它包含更多数据。Java 字节码需要数据。这个数据经常因为太大不能直接存储在字节码中,取而代之的是存储在常量池中,字节码包含这个常量池的引用。 常量池中可以存储多种类型的数据: - 数字型 - 字符串型 - 类引用型 - 域引用型 - 方法引用 3.3.3 方法 每一个方法包含四个区域: - 签名和访问标签 - 字节码 - LineNumberTable:为调试器提供源码中的每一行对应的字节码信息 - LocalVariableTable:列出了所有栈帧中的局部变量 操作码 作用 aload0 | 这个操作码是aload格式操作码中的一个。它们用来把对象引用加载到操作码栈。表示正在被访问的局部变量数组的位置,但只能是0、1、2、3 中的一个。还有一些其它类似的操作码用来载入非对象引用的数据,如iload, lload, float 和 dload。其中 i 表示 int,l 表示 long,f 表示 float,d 表示 double。局部变量数组位置大于 3 的局部变量可以用 iload, lload, float, dload 和 aload 载入。这些操作码都只需要一个操作数,即数组中的位置。 | ldc | 这个操作码用来将常量从运行时常量池压栈到操作数栈。 | getstatic | 这个操作码用来把一个静态变量从运行时常量池的静态变量列表中压栈到操作数栈。 | return | 这个操作码属于ireturn、lreturn、freturn、dreturn、areturn 和 return 操作码组。每个操作码返回一种类型的返回值,其中 i 表示 int,l 表示 long,f 表示 float,d 表示 double,a 表示 对象引用。没有前缀类型字母的 return 表示返回 void。 | 函数调用操作码 作用 invokestatic | 调用类方法(静态绑定,速度快) | invokevirtual | 指令调用一个对象的实例方法(动态绑定) | invokespecial | 指令调用实例初始化方法、私有方法、父类方法。(静态绑定,速度快) | invokeinterface | 调用引用类型为interface的实例方法(动态绑定) | invokedynamic | JDK 7引入的,主要是为了支持动态语言的方法调用 | 3.3.4 代码分析 注意反编译后main方法部分: 1 2 3 4 5 6 7 8 9 10 11 12 | public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 // ldc 这个操作码用来将常量从运行时常量池压栈到操作数栈 0: ldc #3 // String test // 注意下面两句:通过实例调用 print 2: invokedynamic #4, 0 // InvokeDynamic #0:print:()LPrint; //调用静态方法 printString 7: invokestatic #5 // Method printString:(Ljava/lang/String;LPrint;)V 10: return | 那么,既然是调用实例方法,那么实例在哪? 1 2 3 4 5 6 7 8 9 10 | InnerClasses: public static final #58= #57 of #61; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles BootstrapMethods: 0: #29 invokestatic java/lang/invoke/LambdaMetafactory.metafac… ### Java AQS 实现原理(图文)分析 - URL: https://jiankunking.com/java-aqs.html - Content type: original - Published: 2018-03-03 - Updated: 2018-03-03 - Summary: 通过图文和源码分析 Java AQS 的实现原理,包括 FIFO 同步队列、独占模式、共享模式和线程唤醒机制。 - Categories: Java - Tags: AQS, Concurrent, Lock Article text: 文章速览 通过图文和源码分析 Java AQS 的实现原理,包括 FIFO 同步队列、独占模式、共享模式和线程唤醒机制。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java AQS(AbstractQueuedSynchronizer)是Java并发编程的核心基础,ReentrantLock、Semaphore、CountDownLatch等同步器都基于AQS实现。本文通过图文结合的方式深入分析AQS的实现原理,包括FIFO队列、独占模式与共享模式等核心机制。 AQS:AbstractQueuedSynchronizer 1、AQS设计简介 - AQS的实现是基于一个FIFO的等待队列。 - 使用单个原子变量来表示获取、释放锁状态(final int)改变该int值使用的是CAS。(思考:为什么一个int值可以保证内存可见性?) - 子类应该定义一个非公开的内部类继承AQS,并实现其中方法。 - AQS支持exclusive与shared两种模式。 - 内部类ConditionObject用于支持子类实现exclusive模式 - 子类需要重写: - tryAcquire - tryRelease - tryReleaseShared - isHeldExclusively等方法,并确保是线程安全的。 贯穿全文的图(核心): 模板方法设计模式:定义一个操作中算法的骨架,而将一些步骤的实现延迟到子类中。 2、类结构 - ConditionObject类 - Node类 - N多方法 3、FIFO队列 等待队列是CLH(Craig, Landin, and Hagersten)锁队列。 通过节点中的“状态”字段来判断一个线程是否应该阻塞。当该节点的前一个节点释放锁的时候,该节点会被唤醒。 1 2 3 4 5 | private transient volatile Node head; private transient volatile Node tail; //The synchronization state. //在互斥锁中它表示着线程是否已经获取了锁,0未获取,1已经获取了,大于1表示重入数。 private volatile int state; | AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。 state的访问方式有三种: - getState() - setState() - compareAndSetState() AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。 不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。 自定义同步器实现时主要实现以下几种方法: - isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。 - tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。 - tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。 - tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 - tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。 以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。 再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后续动作。 一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现: - tryAcquire-tryRelease - tryAcquireShared-tryReleaseShared 中的一种即可。 当然AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。 以下部分来自源码注释: 每次进入CLH队列时,需要对尾节点进入队列过程,是一个原子性操作。在出队列时,我们只需要更新head节点即可。在节点确定它的后继节点时, 需要花一些功夫,用于处理那些,由于等待超时时间结束或中断等原因, 而取消等待锁的线程。 节点的前驱指针,主要用于处理,取消等待锁的线程。如果一个节点取消等待锁,则此节点的前驱节点的后继指针,要指向,此节点后继节点中,非取消等待锁的线程(有效等待锁的线程节点)。 我们用next指针连接实现阻塞机制。每个节点均持有自己线程,节点通过节点的后继连接唤醒其后继节点。 CLH队列需要一个傀儡结点作为开始节点。我们不会再构造函数中创建它,因为如果没有线程竞争锁,那么,努力就白费了。取而代之的方案是,当有第一个竞争者时,我们才构造头指针和尾指针。 线程通过同一节点等待条件,但是用另外一个连接。条件只需要放在一个非并发的连接队列与节点关联,因为只有当线程独占持有锁的时候,才会去访问条件。当一个线程等待条件的时候,节点将会插入到条件队列中。当条件触发时,节点将会转移到主队列中。用一个状态值,描述节点在哪一个队列上。 4、Node 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | static final class Node { //该等待节点处于共享模式 static final Node SHARED = new Node(); //该等待节点处于独占模式 static final Node EXCLUSIVE = null; //表示节点的线程是已被取消的 static final int CANCELLED = 1; //表示当前节点的后继节点的线程需要被唤醒 static final int SIGNAL = -1; //表示线程正在等待某个条件 static final int CONDITION = -2; //表示下一个共享模式的节点应该无条件的传播下去 static final int PROPAGATE = -3; //状态位 ,分别可以使CANCELLED、SINGNAL、CONDITION、PROPAGATE、0 volatile int waitStatus; volatile Node prev;//前驱节点 volatile Node next;//后继节点 volatile Thread thread;//等待锁的线程 //ConditionObject链表的后继节点或者代表共享模式的节点。 //因为Condition队列只能在独占模式下被能被访问,我们只需要简单的使用链表队列来链接正在等待条件的节点。 //然后它们会被转移到同步队列(AQS队列)再次重新获取。 //由于条件队列只能在独占模式下使用,所以我们要表示共享模式的节点的话只要使用特殊值SHARED来标明即可。 Node nextWaiter; //Returns true if node is waiting in shared mode final boolean isShared() { return nextWaiter == SHARED; } ....... } | waitStatus不同值含义: - SIGNAL(-1):当前节点的后继节点已经 (或即将)被阻塞(通过park) , 所以当当前节点释放或则被取消时候,一定要unpark它的后继节点。为了避免竞争,获取方法一定要首先设置node为signal,然后再次重新调用获取方法,如果失败,则阻塞。 - CANCELLED(1):当前节点由于超时或者被中断而被取消。一旦节点被取消后,那么它的状态值不在会被改变,且当前节点的线程不会再次被阻塞。 - CONDITION(-2) :该节点的线程处于等待条件状态,不会被当作是同步队列上的节点,直到被唤醒(signal),设置其值为0,重新进入阻塞状态. - PROPAGATE(-3:)共享模式下的释放操作应该被传播到其他节点。该状态值在doReleaseShared方法中被设置的。 - 0:以上都不是 该状态值为了简便使用,所以使用了数值类型。非负数值意味着该节点不需要被唤醒。所以,大多数代码中不需要检查该状态值的确定值。 一个正常的Node,它的waitStatus初始化值是0。如果想要修改这个值,可以使用AQS提供CAS进行修改。 5、独占模式与共享模式 在锁的获取时,并不一定只有一个线程才能持有这个锁(或者称为同步状态),所以此时有了独占模式和共享模式的区别,也就是在Node节点中由nextWaiter来标识。比如ReentrantLock就是一个独占锁,只能有一个线程获得锁,而WriteAndReadLock的读锁则能由多个线程同时获取,但它的写锁则只能由一个线程持有。 5.1、独占模式 5.1.1 独占模式同步状态的获取 1 2 3 4 5 6 7 8 9 | //忽略中断的(即不手动抛出InterruptedException异常)独占模式下的获取方法。 //该方法在成功返回前至少会调用一次tryAcquire()方法(该方法是子类重写的方法,如果返回true则代表能成功获取). //否则当前线程会进入队列排队,重复的阻塞和唤醒等待再次成功获取后返回, //该方法可以用来实现Lock.lock public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } | 该方法首先尝试获取锁(tryAcquire(arg)的具体实现定义在了子类中),如果获取到,则执行完毕,否则通过addWaiter(Node.EXCLUSIVE), arg)方法把当前节点添加到等待队列末尾,并设置为独占模式。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | private Node addWaiter(Node mode) { //把当前线程包装为node,设为独占模式 Node node = new Node(Thread.currentThread(), mode); // 尝试快速入队,即无竞争条件下肯定成功。如果失败,则进入enq自旋重试入队 Node pred = tail; if (pred != null) { node.prev = pred; //CAS替换当前尾部。成功则返回 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } //插入节点到队列中,如果队列未初始化则初始化,然后再插入。 private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } | 如果tail节点为空,执行enq(node);重新尝试,最终把node插入.在把node插入队列末尾后,它并不立即挂起该节点中线程,因为在插入它的过程中,前面的线程可能已经执行完成,所以它会先进行自旋操作acquireQueued(node, arg),尝试让该线程重新获取锁!当条件满足获取到了锁则可以从自旋过程中退出,否则继续。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); //如果它的前继节点为头结点,尝试获取锁,获取成功则返回 if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } //判断当前节点的线程是否应该被挂起,如果应该被挂起则挂起。 //等待release唤醒释放 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) //在队列中取消当前节点 cancelAcquire(node); } } | 如果没获取到锁,则判断是否应该挂起,而这个判断则得通过它的前驱节点的waitStatus来确定: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; //该节点如果状态如果为SIGNAL。则返回true,然后park挂起线程 if (ws == Node.SIGNAL) return true; //表明该节点已经被取消,向前循环重新调整链表节点 if (ws > 0) { /* * Predecessor was cancelled. Skip over predecessors and * indicate retry. */ do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { //执行到这里代表节点是0或者PROPAGATE,然后标记他们为SIGNAL,但是 //还不能park挂起线程。需要重试是否能获取,如果不能,则挂起。 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } //挂起当前线程,且返回线程的中断状态 private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } | 最后,我们对获取独占式锁过程对做个总结: AQS的模板方法acquire通过调用子类自定义实现的tryAcquire获取同步状态失败后->将线程构造成Node节点(addWaiter)->将Node节点添加到同步队列对尾(addWaiter)->节点以自旋的方法获取同步状态(acquirQueued)。在节点自旋获取同步状态时,只有其前驱节点是头节点的时候才会尝试获取同步状态,如果该节点的前驱不是头节点或者该节点的前驱节点是头节点单获取同步状态失败,则判断当前线程需要阻塞,如果需要阻塞则需要被唤醒过后才返回。 获取锁的过程: - 当线程调用acquire()申请获取锁资源,如果成功,则进入临界区。 - 当获取锁失败时,则进入一个FIFO等待队列,然后被挂起等待唤醒。 - 当队列中的等待线程被唤醒以后就重新尝试获取锁资源,如果成功则进入临界区,否则继续挂起等待。 5.1.2 独占模式同步状态的释放 既然是释放,那肯定是持有锁的该线程执行释放操作,即head节点中的线程释放锁. AQS中的release释放同步状态和acquire获取同步状态一样,都是模板方法,tryRelease释放的具体操作都有子类去实现,父类AQS只提供一个算法骨架。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } //如果node的后继节点不为空且不是作废状态,则唤醒这个后继节点, //否则从末尾开始寻找合适的节点,如果找到,则唤醒 private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread); } | 过程:首先调用子类的tryRelease()方法释放锁,然后唤醒后继节点,在唤醒的过程中,需要判断后继节点是否满足情况,如果后继节点不为空且不是作废状态,则唤醒这个后继节点,否则从tail节点向前寻找合适的节点,如果找到,则唤醒。 释放锁过程: - 当线程调用release()进行锁资源释放时,如果没有其他线程在等待锁资源,则释放完成。 - 如果队列中有其他等待锁资源的线程需要唤醒,则唤醒队列中的第一个等待节点(先入先出)。 5.2、共享模式 5.2.1 共享模式同步状态的获取 - 当线程调用acquireShared()申请获取锁资源时,如果成功,则进入临界区。 - 当获取锁失败时,则创建一个共享类型的节点并进入一个FIFO等待队列,然后被挂起等待唤醒。 - 当队列中的等待线程被唤醒以后就重新尝试获取锁资源,如果成功则唤醒后面还在等待的共享节点并把该唤醒事件传递下去,即会依次唤醒在该节点后面的所有共享节点,然后进入临界区,否则继续挂起等待。 5.2.2 共享模式同步状态的释放 - 当线程调用releaseShared()进行锁资源释放时,如果释放成功,则唤醒队列中等待的节点,如果有的话。 6. AQS小结 java.util.concurrent中的很多可阻塞类(比如ReentrantLock)都是基于AQS来实现的。AQS是一个同步框架,它提供通用机制来原子性管理同步状态、阻塞和唤醒线程,以及维护被阻塞线程的队列。 JDK中AQS被广泛使用,基于AQS实现的同步器包括: - ReentrantLock - Semaphore - ReentrantReadWriteLock(后续会出文章讲解) - CountDownLatch - FutureTask 每一个基于AQS实现的同步器都会包含两种类型的操作,如下: - 至少一个acquire操作。这个操作阻塞调用线程,除非/直到AQS的状态允许这个线程继续执行。 - 至少一个release操作。这个操作改变AQS的状态,改变后的状态可允许一个或多个阻塞线程被解除阻塞。 基于“复合优先于继承”的原则,基于AQS实现的同步器一般都是:声明一个内部私有的继承于AQS的子类Sync,对同步器所有公有方法的调用都会委托给这个内部子类。 7.后续 后面会推出以下有关AQS的文章,已加深对于AQS的理解 - AQS ConditionObject对象解析 - AQS 应用案例 ReentrantReadWriteLock解析 - Java Volatile的内存语义与AQS锁内存可见性 8.思考 多人抢锁 多个线程同时取争取一个锁(在争取之前资源未被锁定),这时候如何保证,只有一个人能获取到? 下面以非公平锁来看一下 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | /** * Performs non-fair tryLock. tryAcquire is implemented in * subclasses, but both need nonfair try for trylock method. */ @ReservedStackAccess final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } /** * Atomically sets synchronization state to the given updated * value if the current state value equals the expected value. * This operation has memory semantics of a {@code volatile} read * and write. * * @param expect the expected value * @param update the new value * @return {@code true} if successful. False return indicates that the actual * value was not equal to the expected value. */ protected final boolean compareAndSetState(int expect, int update) { return STATE.compareAndSet(this, expect, update); } /** * Atomically sets the value of a variable to the {@code newValue} with the * memory semantics of {@link #setVolatile} if the variable's current value, * referred to as the witness value, {@code ==} the * {@code expectedValue}, as accessed with the memory semantics of * {@link #getVolatile}. * *

The method signature is of the form {@code (CT1 ct1, ..., CTn ctn, T expectedValue, T newValue)boolean}. * *

The symbolic type descriptor at the call site of {@code * compareAndSet} must match the access mode type that is the result of * calling {@code accessModeType(VarHandle.AccessMode.COMPARE_AND_SET)} on * this VarHandle. * * @param args the signature-polymorphic parameter list of the form * {@code (CT1 ct1, ..., CTn ctn, T expected… ### TCP与UDP 笔记 - URL: https://jiankunking.com/tcp-and-udp-notes.html - Content type: original - Published: 2018-02-13 - Updated: 2018-02-13 - Summary: 《图解 TCP/IP》学习笔记,整理 TCP、UDP、端口、连接管理、可靠传输和拥塞控制等传输层知识。 - Categories: Network - Tags: Reading Notes, Network, TCP, UDP Article text: 文章速览 《图解 TCP/IP》学习笔记,整理 TCP、UDP、端口、连接管理、可靠传输和拥塞控制等传输层知识。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Network 本文整理自:《图解TCP/IP 第5版》 作者:[日] 竹下隆史,[日] 村山公保,[日] 荒井透,[日] 苅田幸雄 著 译者:乌尼日其其格 出版时间:2013-07 传输层的作用 TCP提供可靠的通信传输,而UDP则常被用于让广播和细节控制交给应用的通信传输。 两种传输层协议TCP和UDP TCP TCP是面向连接的、可靠的流协议。流就是指不间断的数据结构,你可以把它想象成排水管道中的水流。TCP为提供可靠性传输,实行“顺序控制”或“重发控制”机制。此外还具备“流控制(流量控制)”、“拥塞控制”、提高网络利用率等众多功能。 UDP UDP是不具有可靠性的数据报协议。细微的处理它会交给上层的应用去完成。UDP情况下,虽然可以确保发送消息的大小,却不能保证消息一定会到达。因此,应用有时会根据自己的需要进行重发处理。 TCP与UDP区分 TCP用于在传输层有必要实现可靠性传输的情况。由于它是面向有连接并具备顺序控制、重发控制等机制的。所以它可以为应用提供可靠传输。 UDP主要用于那些对高速传输和实时性有较高要求的通信或广播通信。举一个IP电话进行通话的例子。如果使用TCP,数据在传送途中如果丢失会被重发,但是这样无法流畅地传输通话人的声音,会导致无法进行正常交流。而采用UDP,它不会进行重发处理。从而也就不会有声音大幅度延迟到达的问题。即使有部分数据丢失,也只是影响某一小部分的通话。此外,在多播与广播通信中也使用UDP而不是TCP。RIP、DHCP等基于广播的协议也要依赖于UDP。 端口号 端口号定义 数据链路和IP中的地址,分别指的是MAC地址和IP地址。前者用来识别同一链路中不同的计算机,后者用来识别TCP/IP网络中互连的主机和路由器。传输层也有类似概念,就是端口号。端口号用来识别同一台计算机中进行通信的不同应用程序。因此,它也被称为程序地址。 通过IP地址、端口号、协议号进行通信识别 TCP/IP或UDP/IP通信中通常采用5个信息来识别一个通信。它们是“源IP地址”、“目标IP地址”、“协议号”、“源端口号”、“目标端口号”。只要其中某一项不同,则被认为是其他通信。 端口号与协议 端口号由其使用的传输层协议决定。因此,不同的传输协议可以使用相同的端口号。例如,TCP与UDP使用同一个端口号,但使用目的各不相同。 数据到达IP层后,会先检查IP首部中的协议号,再传给相应协议的模块。传给TCP或UDP去做端口号处理。即使是同一个端口号,由于传输协议是各自独立地进行处理,因此相互之间不会影响。 UDP UDP是User Datagram Protocol的缩写,即用户数据包协议。 UDP不提供复杂控制机制,利用IP提供面向无连接的通信服务。且它是将应用程序发来的数据在收到的那一刻,立即按照原样发送到网络上的一种机制。 UDP面向无连接,可以随时发送数据。它常用于几个方面: - 包总量较少的通信(DNS、SNMP等) - 视频、音频等多媒体通信(即时通信) - 限定于LAN等特定网络中的应用通信 - 广播通信(广播、多播) TCP TCP是Transmission Control Protocol的缩写,传输控制协议。 TCP充分实现了数据传输时各种控制功能,可以进行丢包的重发控制,还可以对次序乱掉的分包进行顺序控制。此外,TCP作为一种面向有连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费。 连接 连接是指各种设备、线路,或网络中进行通信的两个应用程序为了相互传递消息而专有的、虚拟的通信线路,也叫做虚拟电路。 一旦建立了连接,进行通信的应用程序只是用这个虚拟的通信线路发送和接收数据,就可以保障信息的传输。应用程序不用顾虑IP网络上可能发生的各种问题,依然可以转发数据。TCP则负责控制链接的建立、断开、保持等管理工作。 TCP的特点及其目的 为了通过数据包实现可靠性传输,需要考虑很多事情,例如数据的破坏、丢包、重复记忆分片顺序混乱等问题。如不能解决这些问题,也就无从谈起可靠传输。 TCP通过检验和、序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现可靠性传输。 通过序列号与确认应答提高可靠性 在TCP中,当发送端数据到达接受主机时,接收端主机会返回一个已收到的消息的通知。这个消息叫做确认应答(ACK Positive Acknowledgement)。 TCP通过肯定的确认应答(ACK)实现可靠的数据传输。当发送端将数据发出之后会等待对端的确认应答。如果有确认应答,说明数据已经成功到达对端。 在一定时间内没有等到确认应答,发送端就可以认为数据已经丢失,并进行重发。由此,即使产生了丢包,仍然能够保证数据能够到达对端,实现可靠传输。 未确认应答并不意味着数据一定丢失。也有可能是数据对方已经收到,只是返回的确认应答在途中丢失。 为了防止出现随意重发的情况,就需要引入一种机制,它能够识别是否已经接收数据,又能判断是否需要接收。 这些确认应答处理、重发控制以及重复控制等功能都可以通过序列号实现。序列号是按照顺序给发送数据的每个字节(8位字节)都标上号码的编号(序列号的初始值并非为0。而是在建立连接以后由随机数生成。而后面的计算则是对每一字节加一)。接收端查询接收数据TCP首部中序列号和数据的长度,将自己下一步应该接收的序列号作为确认应答返送回去。这样,通过序列号和确认应答号,TCP可以实现可靠传输。 重发超时如何确定 重发超时是指在重发数据之前,等待确认应答到来的那个特定时间间隔。如果超过了这个时间仍未收到确认应答,发送端将进行数据重发。那么这个重发超时的具体时间长度又是如何确定的呢? 最理想的是,找到一个最小时间,它能保证“确认应答一定能在这个时间内返回”。然而这个时间长短随着数据包途径的网络环境的不同而有所变化。例如在高速的LAN中时间相对较短,而在长距离的通信当中应该比LAN要长一些。即使是在同一个网络中,根据不同时段的网络堵塞程度时间的长短也会发生变化。TCP要求不论处在何种网络环境下都要提供高性能通信,并且无论网络拥堵情况发生何种变化,都必须保持这一特性。为此,它在每次发包时都会计算往返时间及其偏差。将这个往返时间和偏差相加重发超时的时间,就是比这个总和要稍大一点的值。 在BSD的Unix以及Windows系统中,超时都以0.5秒为单位进行控制,因此重发超时都是0.5秒的整数倍。不过,由于最初的数据包还不知道往返时间,所以其重发超时一般设置为6秒左右。数据被重发之后若还是收不到确认应答,则进行再次发送。此时,等待确认应答的时间将会以2倍、4倍的指数函数延长。此外,数据也不会被无限、反复地重发。达到一定重发次数之后,如果仍没有任何确认应答返回,就会判断为网络或对端主机发生了异常,强制关闭连接。并且通知应用通信异常强行终止。 连接管理 TCP提供面向有连接的通信传输。面向有连接是指在数据通信开始之前先做好通信两端之间的准备工作。 UDP是一种面向无连接的通信协议,因此不检查对端是否可以通信,直接将UDP包发出去。TCP与此相反,它会在数据通信之前,通过TCP首部发送一个SYN包作为建立连接的请求等待确认应答(TCP中发送第一个SYN包的一方叫客户端,接收这个的一方叫服务端)。如果对端发来确认应答,则认为可以进行数据通信。如果对端的确认应答未能到达,就不会进行数据通信。此外,在通信结束时会进行断开连接的处理(FIN包)。 可以使用TCP首部用于控制的字段来管理TCP连接(也叫控制域)。一个连接的建立与断开,正常过程至少需要来回发送7个包才能完成。(建立一个TCP连接需要发送3个包,这个过程也称为3次握手) TCP以段为单位发送数据 在建立TCP连接的同时,也可以确定发送数据包的单位,我们也可以称其为“最大消息长度”(MSS:Maximum Segment Size)。 最理想的情况是,最大消息长度正好是IP中不会被分片处理的最大数据长度。 TCP在传输大量数据时,是以MSS的大小将数据进行分割发送的。进行重发时也是以MSS为单位。 MSS是在三次握手的时候,在两端主机之间被计算得出。两端的主机在发出建立连接的请求时,会在TCP首部中写入MSS选项,告诉对方自己的接口能够适应的MSS的大小(为附加MSS选项,TCP首部将不再是20字节,而是4字节的整数倍)。然后会在两者之间选择一个较小的值投入使用。 SYN(synchronous建立联机) ACK(acknowledgement 确认) FIN(finish结束) 利用窗口控制提高速度 TCP以1个段为单位,每发一个段进行一次确认应答的处理,如下图,这样传输的缺点是,包的往返时间越长通信性能就越低。 为解决这个问题,TCP引入了窗口这个概念。如下图,确认应答不再是以每个分段,而是以更大的单位进行确认时,转发时间将会被大幅度的缩短。就是说,发送端主机,在发送了一个段以后不必要一直等待确认应答,而是继续发送。 窗口大小就是指无需等待确认应答而可以继续发送数据的最大值。 如下图中,窗口大小为4个段。 这个机制实现了使用大量的缓冲区(Buffer 在此处标识临时保存收发数据的场所。通常是在计算机内存中开辟的一部分空间),通过对多个段同时进行确认应答的功能。 用滑动窗口方式并行处理: 下面的图中发送数据中高亮圈起的部分正是前面所提到的窗口。在这个窗口内的数据即便没有收到确认应答也可以发送出去。此外,从该窗口中能看到的数据因其某种数据已在传输中丢失,所以发送端才能收到确认应答,这种情况也需要重发。为此,发送端主机在等到确认应答返回之前,必须在缓冲区中保留这部分数据。 在滑动窗口以外的部分包括尚未发送的数据以及已经确认对端已收到的数据。当数据发出后若如期收到确认应答就可以不用再重发,此时数据皆可以从缓冲区清除。 收到确认应答,将窗口滑动到确认应答中的序列号的位置。这样可以顺序地将多个段同时发送提高通信性能。这种机制也被称为滑动窗口控制。 滑动窗口方式: 窗口控制与重发控制 使用窗口控制中, 如果出现段丢失怎么办? 首先考虑确认应答未能返回的情况。这种情况下,数据已经达到对端,是不需要进行重发的。然而,在没有使用窗口控制的时候,没有收到确认应答的数据会被重发。而使用了窗口控制,如下图,某些确认应答即便丢失也无需重发。 其次,考虑一下某个报文段丢失的情况。如下图,接收主机如果收到一个自己应该接收的序号以外的数据时,会针对当前位置收到数据返回确认应答(不过即使接收端主机收到的包序号并不连续,也不会将数据丢弃而是暂时保存至缓冲区中)。 当某一报文段丢失后,发送端会一直收到序号为1001的确认应答,这个确认应答好像在提醒发送端“我想接收的是从1001开始的数据”。因此,在窗口比较大,又出现报文段丢失的情况下,同一个序号的确认应答将会被重复不断地返回。而发送端主机如果连续3次收到同一个确认应答(之所以连续收到3次而不是两次的理由是因为,即使数据段的序号被替换两次也不会触发重发机制)。就会将其所对应的数据进行重发。这种机制比之前提到的超时管理更加高效,因此也被称作高速重发控制。 流控制 发送端根据自己的实际情况发送数据。但是,接收端可能收到的是一个毫无关系的数据包有可能会在处理其他问题上花费一些时间。因此在为这个数据包做其他处理时会耗费一些时间,甚至在高负荷情况下无法接收任何数据。如此一来,如果接收端将本应该接收的数据丢弃的话,就又会触发重发机制,从而导致网络流量的浪费。 为了防止这种现象发生,TCP提供一种机制可以让发送端根据接收端的实际接收能力控制发送的数据量。这就是所谓的流控制。它的具体操作时,接收端主机向发送端主机通知自己可以接收数据的大小,于是发送端会发送不超过这个限制的数据。该大小限度就被称为窗口大小。 TCP首部中,专门有一个字段用来通知窗口大小。接收主机将自己的可以接收的缓冲区大小放入这个字段通知给发送端。这个值越大,说明网络的吞吐量越高。 不过,接收端这个缓冲区一旦面临数据溢出时,窗口大小的值也会随之被设置为一个更小的值通知给发送端,从而控制数据发送量。就是说,发送端主机会根据接收端主机的指示,对发送数据的量进行控制。这也形成了一个完整的TCP流控制(流量控制)。 根据窗口大小控制流量过程示例: 当接收端收到从3001号开始的数据段后其缓冲区即满,不得不暂时停止接收数据。之后,在收到发送窗口更新通知后通信才得以继续进行。如果这个窗口更新通知在传输途中丢失,可能会导致无法继续通信。为避免此类问题,发送端主机会时不时发送一个叫做窗口探测的数据段,此数据段仅含一个字节以获取最新的窗口大小信息。 拥塞控制 有了TCP窗口控制,收发主机之间即使不再以一个数据段为单位发送确认应答,也能够连续发送大量数据包。然而,如果在通信刚开始就发送大量数据,也可能会引发其他问题。 TCP为了防止该问题的出现,在通信一开始就会通过一个叫做慢启动的算法得出的数值,对发送数据量进行控制。 首先,为了在发送端调节所要发送数据的量,定义了一个叫做“拥塞窗口”的概念。于是在慢启动的时候,将这个拥塞窗口的大小设置为1个数据段(1MSS)发送数据,之后每收到一次确认应答(ACK),拥塞窗口的值就加1。在发送数据包时,将拥塞窗口的大小与接收端主机通知的窗口大小做比较,然后按照它们当中较小的那个值,发送比其还要小的数据量。 如果重发采用超时机制,那么拥塞窗口的初始值可以设置为1以后再进行慢启动修正。有了上述这些机制,就可以有限的减少通信开始时连续发包导致的网络拥堵,还可以避免网络拥塞情况的发生。 不过,随着包的每次往返,拥塞窗口也会以1、2、4等指数函数的增长,拥堵状况激增甚至导致网络拥塞的发生。为了防止这些,引入了慢启动阀值的概念。只要拥塞窗口的值超出这个阀值,在每收到一次确认应答时,只允许以下面这种比例方法拥塞窗口: 拥塞窗口越大,确认应答的数目也会增加,不过随着每收到一个确认应答,其涨幅也会逐渐减少,甚至小过比一个数据段还要小的字节数。所以,拥塞窗口的大小会呈直线上升的趋势。 TCP通信开始时,并没有设置相应的慢启动阈值(与窗口的最大值相同),而是在超时重发时才会设置为当时拥塞窗口一半的大小。 由重发确认应答而触发的高速重发与超时重发机制的处理多少有些不同。因为前者要求至少3次的确认应答数据段到达对方主机后才会触发,相比后者网络的拥堵要轻一些。 而由重复确认应答进行高速重发控制时,慢启动阈值的大小被设置为当时窗口大小的一半(严格来说,是设置为“实际已发送但未收到确认应答的数据量”的一半)。然后将窗口的大小设置为该慢启动阈值+3个数据段的大小。 有了这样一种控制,TCP的拥塞窗口如上图所示发生变化。由于窗口的大小会直接影响数据被转发的吞吐量,所以一般情况下,窗口越大,越会形成高吞吐量的通信。 当TCP通信开始以后,网络吞吐量会逐渐上升,但是随着网络拥堵的发生吞吐量也会急剧下降。于是会再次进入吞吐量慢慢上升的过程。因此所谓TCP的吞吐量的特点就好像是在逐步占领网络带宽的感觉。 UDP首部的格式 下图展示了UDP首部的格式。除去数据的部分正式UDP的首部。UDP首部由源端口号,目标端口号,包长和校验和组成。 源端口号(Source Port) 表示发送端端口号,字段长16位。该字段是可选项,有时可能不会设置源端口号。没有源端口号的时候该字段的值设置为0。可用于不需要返回的通信中。 目标端口号(Destination Port) 表示接收端端口,字段长度16位。 包长度(Length) 该字段保存了UDP首部的长度跟数据的长度之和。单位为字节(8位字节)。 校验和(Checksum) 校验和是为了提供可靠的UDP首部和数据而设计。在计算校验和时,附加在UDP伪首部与UDP数据报之前。通过在最后一位增加一个“0”将全长增加16倍。此时将UDP首部的校验和字段设置为“0”。然后以16比特为单位进行1的补码和,并将所得到的1的补码和写入校验和字段。 接收主机在收到UDP数据报以后,从IP首部获知IP地址信息构造UDP伪首部,再进行校验和计算。校验和制度按的值是校验和字段以外余下部分的1的补码和。因此,包括校验和字段在内的所有数据之和结果为“16位全部为1”时,才会被认为所收到的数据时正确的。 另外,UDP也可有可能不用校验和。此时,校验和字段中填入0。这种情况下,由于不进行校验和计算,协议处理的开销(在处理实际数据之外,为了进行通信控制的处理而不得不付出的必要的消耗部分)就会降低,从而提高数据转发的速度。然而,如果UDP首部的端口号或是IP首部的IP地址遇到损坏,那么可能会对其他通信造成不好的影响。因此,在互联网中比较推荐使用校验和检查。 校验和计算中计算UDP伪首部的理由 TCP/IP中识别一个通信的应用需要5大要素,它们分别是“源IP地址”、“目标IP地址”、“源端口”、“目标端口”、“协议号”。然而,在UDP的首部中只包含它们当中的两项(源端口和目标端口),余下的3项都包含在IP首部里。 假定其他3项都被破坏?显然,这极有可能会导致应该收包的应用收不到包,不该收到包的应用却收到了包。 为了避免这类问题,有必要验证一个通信中必要的5项识别码是否正确。为此,在校验和的计算中就引入和伪首部的概念。 此外,IPv6中的IP首部没有检验和字段。TCP和UDP通过伪首部,得以对5项数字进行校验,从而实现即使在IP首部并不可靠的情况下仍然能够提供可靠的通信传输。 TCP首部格式 TCP中没有表示包长度和数据长度的字段。可由IP层获知TCP的包长,由TCO的包长可知数据的长度。 源端口号(Source Port) 表示发送端端口号,字段长16位。 目标端口号(Destination Port) 表示接收端端口号,字段长度16位。 序列号(Sequence Number) 字段长32位。序列号(序号)是指发送数据的位置,每发送一次数据,就累加一次该数据字节数的大小。 序列号不会从0或1开始,而是建立连接时由计算机生成的随机数作为其初始值,通过SYN包传给接收端主机。然后再将每转发过去的字节数累加到初始值上表示数据的位置。此外,在建立连接和断开连接时发送的SYN包和FIN包虽然并不携带数据,但是也会作为一个字节增加对应的序列号。 确认应答号(Acknowledgement Number) 确认应答号字段长度32位。是指下一次应该受到的数据的序列号。实际上,它是指已收到确认应答号减一为止的数据。发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。 数据偏移(Data Offset) 该字段表示TCP所传输的数据部分应该从TCP包的哪个位开始计算,当然也可以把它看做TCP首部的长度。该字段长4位,单位为4字节(即32位)。 保留(Reserved) 该字段主要是为了以后扩展时使用,其长度为4位。一般设置为0,但即使收到的包在该字段不为0,此包也不会被丢弃。 控制位(Control Flag) 字段长为8位,每一位从左至右分别为CWR、ECE、URG、ACK、PSH、RST、SYN、FIN。这些控制标志也叫作控制位。 CWR(Congestion Window Reduced) CWR标志与后面的ECE标志都用于IP首部的ECN字段。ECE标志为1时,则通知对方已将拥塞窗口缩小。 ECE(ECN-Echo) ECE标志表示ECN-Echo。置为1会通知通信对方,从对方到这边的网络有拥塞。在收到数据包的IP首部中ECN为1时将TCP首部中的ECE设置为1。 URG(Urgent Flag) 该位为1时,确认应答的字段变为有效。TCP规定除了最初建立连接时的SYN包之外该位必须设置为1。 PSH(Push Flag) 该位为1时,表示需要将受到的数据立即传给上层应用协议。PSH为0时,则不需要立即传而是先进性缓存。 RST(Reset Flag) 该位为1时表示TCP连接中出现异常必须强制断开连接。 SYN(Synchronize Flag) 用于建立连接。SYN为1 表示希望建立连接,并在其序列号的字段进行序列号初始值的设定。 FIN(Fin Flag) 该位为1时,表示今后不会再有数据发送,希望断开连接。 窗口大小(Window Size) 窗口大小(Window Size) 该字段长为16位。用于通知从相同TCP首部的确认应答号所指位置开始能够接收的数据大小(8位字节)。TCP不允许发送超过此处所示大小的数据。不过,如果窗口为0,则表示可以发送窗口探测,以了解最新的窗口大小。但这个数据必须是1个字节。 校验和(Checksum) TCP的校验和与UDP相似,区别在于TCP的校验和无法关闭。 TCP和UDP一样在计算校验和的时候使用TCP伪首部。 接收端在收到TCP数据段以后,从IP首部获取IP地址信息构造TCP伪首部,再进行校验和计算。由于校验和字段里保存着除本字段以外洽谈部分的和的补码值,一次如果计算校验和字段在内的所有数据的16位和以后,得出的结果是“16位全部为1”说明所收到数据是正确的。 紧急指针(Urgent Pointer) 略 选项(Options) 略 ### [转]理解 Go Channels - URL: https://jiankunking.com/go-channels.html - Content type: repost - Published: 2018-01-18 - Updated: 2018-01-18 - Summary: 深入理解Go语言Channel的实现原理,包括goroutines并发执行、channel通讯同步机制等核心内容。 - Categories: Go - Tags: Go, Channel - Original source: https://blog.lab99.org/post/golang-2017-10-04-video-understanding-channels.html#fa-song-jie-shou The full copied/translated body is omitted from this machine-readable corpus; follow the canonical and original-source URLs above. ### Go 1.9 Sync Map - URL: https://jiankunking.com/go-sync-map.html - Content type: original - Published: 2017-12-16 - Updated: 2017-12-16 - Summary: 基于Go 1.9源码解析sync.Map的实现原理,包括空间换时间的设计思路、读写分离机制、动态调整策略等核心内容。 - Categories: Go - Tags: Go, Sync, Map Article text: 文章速览 基于Go 1.9源码解析sync.Map的实现原理,包括空间换时间的设计思路、读写分离机制、动态调整策略等核心内容。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Go 基于Go 1.9源码解析sync.Map的实现原理,包括空间换时间的设计思路、读写分离机制、动态调整策略等核心内容。 概要 Package 本文主要阐述:Load、Store、Delete,更加详细的阐述可以参考源码描述(建议先大体浏览一下Map源码)。 实现思路 - 空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。 - 使用只读数据(read),避免读写冲突。 - 动态调整,miss次数多了之后,将dirty数据提升为read。 - double-checking。 - 延迟删除。 删除一个键值只是打标记(会将key对应value的pointer置为nil,但read中仍然有这个key:key;value:nil的键值对),只有在提升dirty的时候才清理删除的数据。 - 优先从read读取、更新、删除,因为对read的读取不需要锁。 - 虽然read和dirty有冗余数据,但这些数据是通过指针指向同一个数据,所以尽管Map的value会很大,但是冗余的空间占用还是有限的。 数据结构 Map 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | // Map is a concurrent map with amortized-constant-time loads, stores, and deletes. // It is safe for multiple goroutines to call a Map's methods concurrently. // // It is optimized for use in concurrent loops with keys that are // stable over time, and either few steady-state stores, or stores // localized to one goroutine per key. // // For use cases that do not share these attributes, it will likely have // comparable or worse performance and worse type safety than an ordinary // map paired with a read-write mutex. // // The zero Map is valid and empty. // // A Map must not be copied after first use. //该 Map 是线程安全的,读取,插入,删除也都保持着常数级的时间复杂度。 //多个 goroutines 协程同时调用 Map 方法也是线程安全的。该 Map 的零值是有效的, //并且零值是一个空的 Map 。线程安全的 Map 在第一次使用之后,不允许被拷贝。 type Map struct { mu Mutex // read contains the portion of the map's contents that are safe for // concurrent access (with or without mu held). // // The read field itself is always safe to load, but must only be stored with // mu held. // // Entries stored in read may be updated concurrently without mu, but updating // a previously-expunged entry requires that the entry be copied to the dirty // map and unexpunged with mu held. // 一个只读的数据结构,因为只读,所以不会有读写冲突。 // 所以从这个数据中读取总是安全的。 // 实际上,实际也会更新这个数据的entries,如果entry是未删除的(unexpunged), 并不需要加锁。如果entry已经被删除了,需要加锁,以便更新dirty数据。 read atomic.Value // readOnly // dirty contains the portion of the map's contents that require mu to be // held. To ensure that the dirty map can be promoted to the read map quickly, // it also includes all of the non-expunged entries in the read map. // // Expunged entries are not stored in the dirty map. An expunged entry in the // clean map must be unexpunged and added to the dirty map before a new value // can be stored to it. // // If the dirty map is nil, the next write to the map will initialize it by // making a shallow copy of the clean map, omitting stale entries. // dirty数据包含当前的map包含的entries,它包含最新的entries(包括read中未删除的数据,虽有冗余,但是提升dirty字段为read的时候非常快,不用一个一个的复制,而是直接将这个数据结构作为read字段的一部分),有些数据还可能没有移动到read字段中。 // 对于dirty的操作需要加锁,因为对它的操作可能会有读写竞争。 // 当dirty为空的时候, 比如初始化或者刚提升完,下一次的写操作会复制read字段中未删除的数据到这个数据中。 dirty map[interface{}]*entry // misses counts the number of loads since the read map was last updated that // needed to lock mu to determine whether the key was present. // // Once enough misses have occurred to cover the cost of copying the dirty // map, the dirty map will be promoted to the read map (in the unamended // state) and the next store to the map will make a new dirty copy. // 当从Map中读取entry的时候,如果read中不包含这个entry,会尝试从dirty中读取,这个时候会将misses加一, // 当misses累积到 dirty的长度的时候, 就会将dirty提升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。 misses int } | readOnly 1 2 3 4 5 6 7 | // readOnly is an immutable struct stored atomically in the Map.read field. type readOnly struct { m map[interface{}]*entry // true if the dirty map contains some key not in m. // 如果Map.dirty有些数据不在中的时候,这个值为true amended bool } | entry 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // An entry is a slot in the map corresponding to a particular key. type entry struct { // p points to the interface{} value stored for the entry. // // If p == nil, the entry has been deleted and m.dirty == nil. // // If p == expunged, the entry has been deleted, m.dirty != nil, and the entry // is missing from m.dirty. // // Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty // != nil, in m.dirty[key]. // // An entry can be deleted by atomic replacement with nil: when m.dirty is // next created, it will atomically replace nil with expunged and leave // m.dirty[key] unset. // // An entry's associated value can be updated by atomic replacement, provided // p != expunged. If p == expunged, an entry's associated value can be updated // only after first setting m.dirty[key] = e so that lookups using the dirty // map find the entry. //p有三种值: //nil: entry已被删除了,并且m.dirty为nil //expunged: entry已被删除了,并且m.dirty不为nil,而且这个entry不存在于m.dirty中 //其它: entry是一个正常的值 p unsafe.Pointer // *interface{} } | Value 1 2 3 4 5 6 7 8 9 10 11 | // A Value provides an atomic load and store of a consistently typed value. // Values can be created as part of other data structures. // The zero value for a Value returns nil from Load. // Once Store has been called, a Value must not be copied. // // A Value must not be copied after first use. type Value struct { noCopy noCopy v interface{} } | Load 据指定的key,查找对应的值value,如果不存在,通过ok反映。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | func (m *Map) Load(key interface{}) (value interface{}, ok bool) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] // 如果没找到,并且m.dirty中有新数据,需要从m.dirty查找,这个时候需要加锁 if !ok && read.amended { m.mu.Lock() // Avoid reporting a spurious miss if m.dirty got promoted while we were // blocked on m.mu. (If further loads of the same key will not miss, it's // not worth copying the dirty map for this key.) //double check,避免加锁的时候m.dirty提升为m.read,这个时候m.read可能被替换了。 read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { e, ok = m.dirty[key] // Regardless of whether the entry was present, record a miss: this key // will take the slow path until the dirty map is promoted to the read // map. m.missLocked() } m.mu.Unlock() } if !ok { return nil, false } return e.load() } func (m *Map) missLocked() { m.misses++ if m.misses < len(m.dirty) { return } m.read.Store(readOnly{m: m.dirty}) m.dirty = nil m.misses = 0 } | Store 更新或者新增一个entry 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | // Store sets the value for a key. func (m *Map) Store(key, value interface{}) { read, _ := m.read.Load().(readOnly) // 从 read map 中读取 key 成功并且取出的 entry 尝试存储 value 成功,直接返回 if e, ok := read.m[key]; ok && e.tryStore(&value) { return } m.mu.Lock() read, _ = m.read.Load().(readOnly) if e, ok := read.m[key]; ok { if e.unexpungeLocked() {//确保未被标记成删除,即e 指向的是非 nil 的 // The entry was previously expunged, which implies that there is a // non-nil dirty map and this entry is not in it. //m.dirty中不存在这个键,所以加入m.dirty m.dirty[key] = e } e.storeLocked(&value) } else if e, ok := m.dirty[key]; ok { e.storeLocked(&value) } else { if !read.amended { // We're adding the first new key to the dirty map. // Make sure it is allocated and mark the read-only map as incomplete. m.dirtyLocked() m.read.Store(readOnly{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) } m.mu.Unlock() } // tryStore stores a value if the entry has not been expunged. // // If the entry is expunged, tryStore returns false and leaves the entry // unchanged. func (e *entry) tryStore(i *interface{}) bool { p := atomic.LoadPointer(&e.p) if p == expunged { return false } for { if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { return true } p = atomic.LoadPointer(&e.p) if p == expunged { return false } } } func (m *Map) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly) m.dirty = make(map[interface{}]*entry, len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } } } func (e *entry) tryExpungeLocked() (isExpunged bool) { p := atomic.LoadPointer(&e.p) for p == nil { // 将已经删除标记为nil的数据标记为expunged if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { return true } p = atomic.LoadPointer(&e.p) } return p == expunged } // unexpungeLocked ensures that the entry is not marked as expunged. // If the entry was previously expunged, it must be added to the dirty map // before m.mu is unlocked. // unexpungeLocked 函数确保了 entry 没有被标记成已被清除。 // 如果 entry 先前被清除过了,那么在 mutex 解锁之前,它一定要被加入到 dirty map 中 //如果 entry 的 unexpungeLocked 返回为 true,那么就说明 entry //之前被标记成了 expunged,并经过 CAS 操作成功把它置为 nil。 func (e *entry) unexpungeLocked() (wasExpunged bool) { return atomic.CompareAndSwapPointer(&e.p, expunged, nil) } | Delete 删除一个键值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | // Delete deletes the value for a key. func (m *Map) Delete(key interface{}) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { delete(m.dirty, key) } m.mu.Unlock() } if ok { e.delete() } } func (e *entry) delete() (hadValue bool) { for { p := atomic.LoadPointer(&e.p) // 已标记为删除 if p == nil || p == expunged { return false } // 原子操作,e.p标记为nil if atomic.CompareAndSwapPointer(&e.p, p, nil) { return true } } } | 疑问 已经删除的key,再次Load的时候,会怎么样? 1 2 3 4 5 6 7 | func (e *entry) load() (value interface{}, ok bool) { p := atomic.LoadPointer(&e.p) if p == nil || p == expunged { return nil, false } return *(*interface{})(p), true } | 在Map Load方法中调用e.load()时,load方法会识别该值是否已被删除 Reference https://studygolang.com/articles/10511 http://www.jianshu.com/p/43e66dab535b ### 关于Spring AOP与IOC的个人思考 - URL: https://jiankunking.com/spring-aop-ioc-think.html - Content type: original - Published: 2016-12-24 - Updated: 2016-12-24 - Summary: 结合JDK动态代理、CGLIB和代理模式分析Spring AOP的实现原理,并从关注点分离、依赖注入和对象管理角度记录对AOP与IOC的理解。 - Categories: Java - Tags: AOP, IOC, Spring Article text: 文章速览 结合JDK动态代理、CGLIB和代理模式分析Spring AOP的实现原理,并从关注点分离、依赖注入和对象管理角度记录对AOP与IOC的理解。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 在阅读本文前,强烈建议阅读: Java JDK 动态代理(AOP)使用及实现原理分析 AOP是Spring提供的关键特性之一。AOP即面向切面编程,是OOP编程的有效补充。使用AOP技术,可以将一些系统性相关的编程工作,独立提取出来,独立实现,然后通过切面切入进系统。从而避免了在业务逻辑的代码中混入很多的系统相关的逻辑——比如权限管理,事物管理,日志记录等等。这些系统性的编程工作都可以独立编码实现,然后通过AOP技术切入进系统即可。从而达到了将不同的关注点分离出来的效果。 本文深入剖析Spring的AOP的原理。 一、AOP 的实现原理 AOP分为静态AOP和动态AOP。 静态AOP是指AspectJ实现的AOP,他是将切面代码直接编译到Java类文件中。 动态AOP是指将切面代码进行动态织入实现的AOP。 Spring的AOP为动态AOP,实现的技术为:JDK提供的动态代理技术 和 CGLIB(动态字节码增强技术)。尽管实现技术不一样,但都是基于代理模式,都是生成一个代理对象。 1、JDK动态代理 JDK部分解析参考: Java JDK 动态代理(AOP)使用及实现原理分析 2、CGLIB(code generate libary) 字节码生成技术实现AOP,其实就是继承被代理对象,然后Override需要被代理的方法,在覆盖该方法时,自然是可以插入我们自己的代码的。 因为需要Override被代理对象的方法,所以自然CGLIB技术实现AOP时,就必须要求需要被代理的方法不能是final方法,因为final方法不能被子类覆盖。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | package net.aazj.aop; import java.lang.reflect.Method; import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; public class CGProxy implements MethodInterceptor{ private Object target; // 被代理对象 public CGProxy(Object target){ this.target = target; } public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("do sth before...."); Object result = proxy.invokeSuper(obj, args); System.out.println("do sth after...."); return result; } public Object getProxyObject() { Enhancer enhancer = new Enhancer(); // 设置父类 enhancer.setSuperclass(this.target.getClass()); // 设置回调 enhancer.setCallback(this); // 在调用父类方法时,回调 this.intercept() // 创建代理对象 return enhancer.create(); } } | 测试: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | public interface UserService { public void addUser(User user); public User getUser(int id); } public class UserServiceImpl implements UserService { public void addUser(User user) { System.out.println("add user into database."); } public User getUser(int id) { User user = new User(); user.setId(id); System.out.println("getUser from database."); return user; } } public class CGProxyTest { public static void main(String[] args){ // 被代理的对象 Object proxyedObject = new UserServiceImpl(); CGProxy cgProxy = new CGProxy(proxyedObject); UserService proxyObject = (UserService) cgProxy.getProxyObject(); proxyObject.getUser(1); proxyObject.addUser(new User()); } } | 输出结果: 1 2 3 4 5 6 | do sth before.... getUser from database. do sth after.... do sth before.... add user into database. do sth after.... | 它的原理是:生成一个父类 enhancer.setSuperclass(this.target.getClass()) 的子类enhancer.create(),然后对父类的方法进行拦截enhancer.setCallback(this). 二、思考 从以上两种代理方式可以看出,实现AOP的关键是:动态代理,即将需要用的接口、类再包装一层,通过动态修改字节码文件实现各种拦截与通知。 注意,两者(JDK动态代理、CGLIB)都需要:要代理真实对象的实例。 比如:在Spring MVC的Controller层一般@Autowired是Service接口,但带有@Service标识的却是实现Service接口的实体类,这样对于JDK动态代理来说已经足以生成代理类了(其实,不过是cglib还是jdk的动态代理,你直接@Autowired Service接口实现类,也是可以注入成功的,但不如注入Service接口灵活),大家在跟踪代码的时候可以看一下Spring注入的bean真正的类型,你就可以发现它是代理生成的实例。 比如这种: 带有注解标识的接口或者在Spring.XML中配置的bean会在Spring初始化的时候,被Spring通过反射加载实例化到Spring容器中。 做过Client/Server架构开发的朋友应该知道,在Application运行过程中一般都会有一个应用上下文Context,一般将一些系统信息放在里面,比如一些登录信息、WCF连接实例等。这些信息在系统的任何地方都可以取到(其实就是一些顶级变量集合,生命周期最长的一些家伙)。 换个角度想一下,如果我们在Application初始化的时候,用反射(获取要代理对象的实例)和动态代理获取有注解标识或者在xml中配置bean的实例,并放到应用上下文Context中,在需要的地方都能取到,这不就是一个简单版的Spring 容器吗? ### Java HashMap - URL: https://jiankunking.com/java-hashmap.html - Content type: original - Published: 2016-09-26 - Updated: 2016-09-26 - Summary: 基于JDK 1.8分析HashMap的实现原理,包括数据结构、put/get流程、扩容机制、红黑树转换等核心内容。 - Categories: Java - Tags: Java, HashMap Article text: 文章速览 基于JDK 1.8分析HashMap的实现原理,包括数据结构、put/get流程、扩容机制、红黑树转换等核心内容。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 基于JDK 1.8分析HashMap的实现原理,包括数据结构、put/get流程、扩容机制、红黑树转换等核心内容。 代码基于 Jdk1.8 最近在工作用到Map等一系列的集合,于是,想仔细看一下其具体实现。 结构 1 2 | public class HashMap extends AbstractMap implements Map, Cloneable, Serializable | 抽象类AbstractMap 1 | public abstract class AbstractMap implements Map | 该类实现了Map接口,具体结构如下: 该类代码很简单,不再赘述。 序列化接口:Serializable 该接口没有什么好说的,但通过该接口,就解释了为什么HashMap总一些字段是用transient来修饰。 一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。 阅读JDK中类注释 HashMap是无序的 如果希望保持元素的输入顺序应该使用LinkedHashMap 除了非同步和允许使用null之外,HashMap与Hashtable基本一致。 此处的非同步指的是多线程访问,并至少一个线程修改HashMap结构。结构修改包括任何新增、删除映射,但仅仅修改HashMap中已存在项值得操作不属于结构修改。 初始容量与加载因子是影响HashMap的两个重要因素。 1 | public HashMap(int initialCapacity, float loadFactor) | 初始容量默认值: 1 2 3 4 | /** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 | 加载因子默认值: 1 2 3 4 | /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f; | 容量是HashMap在创建时“桶”的数量,而初始容量是哈希表在创建时分配的空间大小。加载因子是哈希表在其容量自动增加时能达到多满的衡量尺度(比如默认为0.75,即桶中数据达到3/4就不能再放数据了)。 默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。 当重新调整HashMap大小的时候,会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。 所以 HashMap应该避免在多线程环境下使用。 默认0.75这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。 存储形式 链表形式存储?树形结构? 1 2 3 4 5 6 | * This map usually acts as a binned (bucketed) hash table, but * when bins get too large, they are transformed into bins of * TreeNodes, each structured similarly to those in * java.util.TreeMap. Most methods try to use normal bins, but * relay to TreeNode methods when applicable (simply by checking * instanceof a node). | 源码阅读 添加元素 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | /** * Associates the specified value with the specified key in this map. * If the map previously contained a mapping for the key, the old * value is replaced. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with key, or * null if there was no mapping for key. * (A null return can also indicate that the map * previously associated null with key.) */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * Implements Map.put and related methods * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; //hashmap第一次添加元素,调用resize()方法初始化table if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //通过与运算判断tab[hash]位置是否有值 //从newNode这里可以看出,hashmap中key value是以Node实例的形式存放的 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //tab[i]有元素,则需要遍历结点后再添加 else { Node e; K k; // hash、key均等,说明待插入元素和第一个元素相等,直接更新 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode)//如果p类型为TreeNode,调用树的添加元素方法(红黑树冲突插入) e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); else { //不是TreeNode,即为链表,遍历链表,查找给定关键字 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //到达链表的尾端也没有找到key值相同的节点,则生成一个新的Node p.next = newNode(hash, key, value, null); //创建新节点后若超出树形化阈值,则转换为树形存储 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash);//当桶中链表的数量>=9的时候,底层则改为红黑树实现 break; } //如果找到关键字相同的结点 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //更新p指向下一个节点 p = e; } } // e不为空,即map中存在要添加的关键字 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize();//扩容 afterNodeInsertion(evict); return null; } | 小注: 1、回调 1 2 | afterNodeAccess(e); afterNodeInsertion(evict); | 是为LinkedHashMap回调准备的。 2、计算hash值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | /** * Computes key.hashCode() and spreads (XORs) higher bits of hash * to lower. Because the table uses power-of-two masking, sets of * hashes that vary only in bits above the current mask will * always collide. (Among known examples are sets of Float keys * holding consecutive whole numbers in small tables.) So we * apply a transform that spreads the impact of higher bits * downward. There is a tradeoff between speed, utility, and * quality of bit-spreading. Because many common sets of hashes * are already reasonably distributed (so don't benefit from * spreading), and because we use trees to handle large sets of * collisions in bins, we just XOR some shifted bits in the * cheapest possible way to reduce systematic lossage, as well as * to incorporate impact of the highest bits that would otherwise * never be used in index calculations because of table bounds. */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } | ‘>>>’:无符号右移,忽略符号位,空位都以0补齐 value >>> num – num 指定要移位值value 移动的位数。 即按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。 ^异或:两个操作数的位中,相同则结果为0,不同则结果为1。 这也正好解释了为什么HashMap底层数组的长度总是 2 的 n 次方。因为这样(数组长度-1)正好相当于一个“低位掩码”。“异或”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。 以初始长度16为例,16-1=15。 2进制表示是00000000 00000000 00001111。 和某hash值做“异或”操作如下,结果就是截取了最低的四位值。 1 2 3 4 | 10100101 11000100 00100101 00000000 00000000 00001111 ---------------------------------- 00000000 00000000 00000101 //高位全部归零,只保留末四位 | 更详细的步骤如下: 3、存储结构 获取元素 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | /** * Returns the value to which the specified key is mapped, * or {@code null} if this map contains no mapping for the key. * *

More formally, if this map contains a mapping from a key * {@code k} to a value {@code v} such that {@code (key==null ? k==null : * key.equals(k))}, then this method returns {@code v}; otherwise * it returns {@code null}. (There can be at most one such mapping.) * *

A return value of {@code null} does not necessarily * indicate that the map contains no mapping for the key; it's also * possible that the map explicitly maps the key to {@code null}. * The {@link #containsKey containsKey} operation may be used to * distinguish these two cases. * * @see #put(Object, Object) */ public V get(Object key) { Node e; return (e = getNode(hash(key), key)) == null ? null : e.value; } /** * Implements Map.get and related methods * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node getNode(int hash, Object key) { Node[] tab; Node first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && //hash & length-1 定位数组下标 (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { //第一个节点是TreeNode,则采用位桶+红黑树结构, //调用TreeNode.getTreeNode(hash,key), //遍历红黑树,得到节点的value if (first instanceof TreeNode) return ((TreeNode)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } | 树节点的查找: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | /** * Calls find for root node. */ final TreeNode getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); } /** * Finds the node starting at root p with the given hash and key. * The kc argument caches comparableClassFor(key) upon first use * comparing keys. *通过hash值的比较,递归的去遍历红黑树, compareableClassFor(Class k):判断实例k对应的类是否实现了Comparable接口,如果实现了该接口并 在某些时候如果红黑树节点的元素are of the same "class C implements Comparable" type *利用他们的compareTo()方法来比较大小,这里需要通过反射机制来check他们到底是不是属于同一个类,是不是具有可比较性. */ final TreeNode find(int h, Object k, Class kc) { TreeNode p = this; do { int ph, dir; K pk; TreeNode pl = p.left, pr = p.right, q; if ((ph = p.hash) > h) p = pl; else if (ph < h) p = pr; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; else if (pl == null) p = pr; else if (pr == null) p = pl; else if ((kc != null || (kc = comparableClassFor(k)) != null) && (dir = compareComparables(kc, k, pk)) != 0) p = (dir < 0) ? pl : pr; else if ((q = pr.find(h, k, kc)) != null) return q; else p = pl; } while (p != null); return null; } | 元素包含containsKey 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | /** * Returns true if this map contains a mapping for the * specified key. * * @param key The key whose presence in this map is to be tested * @return true if this map contains a mapping for the specified * key. */ public boolean containsKey(Object key) { return getNode(hash(key), key) != null; } /** * Implements Map.get and related methods * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node getNode(int hash, Object key) { Node[] tab; Node first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && //判断tab[hash]位置是否有值 (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode)first).getTreeNode(hash, key); //遍历寻找 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } /** * Calls find for root node. */ final TreeNode getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); } /** * Returns root of tree containing this node. * 获取红黑树的根 */ final TreeNode root() { for (TreeNode r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } } /** * Finds the node starting at root p with the given hash and key. * The kc argument caches comparableClassFor(key) upon first use * comparing keys. */ final TreeNode find(int h, Object k, Class kc) {// k即key,kc为null TreeNode p = this; do { int ph, dir; K pk; TreeNode pl = p.left,… ### Java-JDK-动态代理(AOP)使用及实现原理分析 - URL: https://jiankunking.com/java-jdk-aop.html - Content type: original - Published: 2016-08-07 - Updated: 2016-08-07 - Summary: 代理是一种常用的设计模式,其目的就是为其他对象提供一个代理以控制对某个对象的访问。本文详细分析JDK动态代理的使用方式及底层实现原理,通过源码追踪揭示动态代理的奥秘。 - Categories: Java - Tags: AOP, Dynamic-Proxy, Reflection Article text: 文章速览 代理是一种常用的设计模式,其目的就是为其他对象提供一个代理以控制对某个对象的访问。本文详细分析JDK动态代理的使用方式及底层实现原理,通过源码追踪揭示动态代理的奥秘。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:Java 代理是一种常用的设计模式,其目的就是为其他对象提供一个代理以控制对某个对象的访问。本文详细分析JDK动态代理的使用方式及底层实现原理,通过源码追踪揭示动态代理的奥秘。 一、什么是代理? 代理是一种常用的设计模式,其目的就是为其他对象提供一个代理以控制对某个对象的访问。代理类负责为委托类预处理消息,过滤消息并转发消息,以及进行消息被委托类执行后的后续处理。 代理模式UML图: 简单结构示意图: 为了保持行为的一致性,代理类和委托类通常会实现相同的接口,所以在访问者看来两者没有丝毫的区别。通过代理类这中间一层,能有效控制对委托类对象的直接访问,也可以很好地隐藏和保护委托类对象,同时也为实施不同控制策略预留了空间,从而在设计上获得了更大的灵活性。Java 动态代理机制以巧妙的方式近乎完美地实践了代理模式的设计理念。 二、Java 动态代理类 Java动态代理类位于java.lang.reflect包下,一般主要涉及到以下两个类: (1)Interface InvocationHandler:该接口中仅定义了一个方法 1 | public object invoke(Object obj,Method method, Object[] args) | 在实际使用时,第一个参数obj一般是指代理类,method是被代理的方法,如上例中的request(),args为该方法的参数数组。这个抽象方法在代理类中动态实现。 (2)Proxy:该类即为动态代理类,其中主要包含以下内容: protected Proxy(InvocationHandler h):构造函数,用于给内部的h赋值。 static Class getProxyClass( ClassLoader loader, Class[] interfaces):获得一个代理类,其中loader是类装载器,interfaces是真实类所拥有的全部接口的数组。 static Object newProxyInstance(ClassLoaderloader, Class[] interfaces,InvocationHandler h):返回代理类的一个实例,返回后的代理类可以当作被代理类使用(可使用被代理类的在Subject接口中声明过的方法) 所谓DynamicProxy是这样一种class:它是在运行时生成的class,在生成它时你必须提供一组interface给它,然后该class就宣称它实现了这些interface。你当然可以把该class的实例当作这些interface中的任何一个来用。当然,这个DynamicProxy其实就是一个Proxy,它不会替你作实质性的工作,在生成它的实例时你必须提供一个handler,由它接管实际的工作。 在使用动态代理类时,我们必须实现InvocationHandler接口 通过这种方式,被代理的对象(RealSubject)可以在运行时动态改变,需要控制的接口(Subject接口)可以在运行时改变,控制的方式(DynamicSubject类)也可以动态改变,从而实现了非常灵活的动态代理关系。 动态代理步骤: - 创建一个实现接口InvocationHandler的类,它必须实现invoke方法 - 创建被代理的类以及接口 - 通过Proxy的静态方法 newProxyInstance(ClassLoaderloader,Class[]interfaces,InvocationHandler h)创建一个代理 - 通过代理调用方法 三、JDK的动态代理怎么使用? 1、需要动态代理的接口: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | package jiankunking; /** * 需要动态代理的接口 */ public interface Subject { /** * 你好 * * @param name * @return */ public String SayHello(String name); /** * 再见 * * @return */ public String SayGoodBye(); } | 2、需要代理的实际对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | package jiankunking; /** * 实际对象 */ public class RealSubject implements Subject { /** * 你好 * * @param name * @return */ @Override public String SayHello(String name) { return "hello " + name; } /** * 再见 * * @return */ @Override public String SayGoodBye() { return " good bye "; } } | 3、调用处理器实现类(有木有感觉这里就是传说中的AOP啊) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | package jiankunking; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; /** * 调用处理器实现类 * 每次生成动态代理类对象时都需要指定一个实现了该接口的调用处理器对象 */ public class InvocationHandlerImpl implements InvocationHandler { /** * 这个就是我们要代理的真实对象 */ private Object subject; /** * 构造方法,给我们要代理的真实对象赋初值 * * @param subject */ public InvocationHandlerImpl(Object subject) { this.subject = subject; } /** * 该方法负责集中处理动态代理类上的所有方法调用。 * 调用处理器根据这三个参数进行预处理或分派到委托类实例上反射执行 * * @param proxy 代理类实例 * @param method 被调用的方法对象 * @param args 调用参数 * @return * @throws Throwable */ @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //在代理真实对象前我们可以添加一些自己的操作 System.out.println("在调用之前,我要干点啥呢?"); System.out.println("Method:" + method); //当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用 Object returnValue = method.invoke(subject, args); //在代理真实对象后我们也可以添加一些自己的操作 System.out.println("在调用之后,我要干点啥呢?"); return returnValue; } } | 4、测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | package jiankunking; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; /** * 动态代理演示 */ public class DynamicProxyDemonstration { public static void main(String[] args) { //代理的真实对象 Subject realSubject = new RealSubject(); /** * InvocationHandlerImpl 实现了 InvocationHandler 接口,并能实现方法调用从代理类到委托类的分派转发 * 其内部通常包含指向委托类实例的引用,用于真正执行分派转发过来的方法调用. * 即:要代理哪个真实对象,就将该对象传进去,最后是通过该真实对象来调用其方法 */ InvocationHandler handler = new InvocationHandlerImpl(realSubject); ClassLoader loader = handler.getClass().getClassLoader(); Class[] interfaces = realSubject.getClass().getInterfaces(); /** * 该方法用于为指定类装载器、一组接口及调用处理器生成动态代理类实例 */ Subject subject = (Subject) Proxy.newProxyInstance(loader, interfaces, handler); System.out.println("动态代理对象的类型:" + subject.getClass().getName()); String hello = subject.SayHello("jiankunking"); System.out.println(hello); // String goodbye = subject.SayGoodBye(); // System.out.println(goodbye); } } | 5、输出结果如下: 四、动态代理怎么实现的? 从使用代码中可以看出,关键点在: 1 | Subject subject = (Subject) Proxy.newProxyInstance(loader, interfaces, handler); | 通过跟踪提示代码可以看出:当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用。 也就是说,当代码执行到:subject.SayHello(“jiankunking”)这句话时,会自动调用InvocationHandlerImpl的invoke方法。这是为啥呢? 下面是代码跟分析的过程,不想看的朋友可以直接看结论 以下代码来自:JDK1.8.0_92 既然生成代理对象是用的Proxy类的静态方newProxyInstance,那么我们就去它的源码里看一下它到底都做了些什么? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | /** * Returns an instance of a proxy class for the specified interfaces * that dispatches method invocations to the specified invocation * handler. * *

{@code Proxy.newProxyInstance} throws * {@code IllegalArgumentException} for the same reasons that * {@code Proxy.getProxyClass} does. * * @param loader the class loader to define the proxy class * @param interfaces the list of interfaces for the proxy class * to implement * @param h the invocation handler to dispatch method invocations to * @return a proxy instance with the specified invocation handler of a * proxy class that is defined by the specified class loader * and that implements the specified interfaces * @throws IllegalArgumentException if any of the restrictions on the * parameters that may be passed to {@code getProxyClass} * are violated * @throws SecurityException if a security manager, s, is present * and any of the following conditions is met: *

    *
  • the given {@code loader} is {@code null} and * the caller's class loader is not {@code null} and the * invocation of {@link SecurityManager#checkPermission * s.checkPermission} with * {@code RuntimePermission("getClassLoader")} permission * denies access;
  • *
  • for each proxy interface, {@code intf}, * the caller's class loader is not the same as or an * ancestor of the class loader for {@code intf} and * invocation of {@link SecurityManager#checkPackageAccess * s.checkPackageAccess()} denies access to {@code intf};
  • *
  • any of the given proxy interfaces is non-public and the * caller class is not in the same {@linkplain Package runtime package} * as the non-public interface and the invocation of * {@link SecurityManager#checkPermission s.checkPermission} with * {@code ReflectPermission("newProxyInPackage.{package name}")} * permission denies access.
  • *
* @throws NullPointerException if the {@code interfaces} array * argument or any of its elements are {@code null}, or * if the invocation handler, {@code h}, is * {@code null} */ @CallerSensitive public static Object newProxyInstance(ClassLoader loader, Class[] interfaces, InvocationHandler h) throws IllegalArgumentException { //检查h 不为空,否则抛异常 Objects.requireNonNull(h); final Class[] intfs = interfaces.clone(); final SecurityManager sm = System.getSecurityManager(); if (sm != null) { checkProxyAccess(Reflection.getCallerClass(), loader, intfs); } /* * 获得与指定类装载器和一组接口相关的代理类类型对象 */ Class cl = getProxyClass0(loader, intfs); /* * 通过反射获取构造函数对象并生成代理类实例 */ try { if (sm != null) { checkNewProxyPermission(Reflection.getCallerClass(), cl); } //获取代理对象的构造方法(也就是$Proxy0(InvocationHandler h)) final Constructor cons = cl.getConstructor(constructorParams); final InvocationHandler ih = h; if (!Modifier.isPublic(cl.getModifiers())) { AccessController.doPrivileged(new PrivilegedAction() { public Void run() { cons.setAccessible(true); return null; } }); } //生成代理类的实例并把InvocationHandlerImpl的实例传给它的构造方法 return cons.newInstance(new Object[]{h}); } catch (IllegalAccessException|InstantiationException e) { throw new InternalError(e.toString(), e); } catch (InvocationTargetException e) { Throwable t = e.getCause(); if (t instanceof RuntimeException) { throw (RuntimeException) t; } else { throw new InternalError(t.toString(), t); } } catch (NoSuchMethodException e) { throw new InternalError(e.toString(), e); } } | 我们再进去getProxyClass0方法看一下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | /** * Generate a proxy class. Must call the checkProxyAccess method * to perform permission checks before calling this. */ private static Class getProxyClass0(ClassLoader loader, Class... interfaces) { if (interfaces.length > 65535) { throw new IllegalArgumentException("interface limit exceeded"); } // If the proxy class defined by the given loader implementing // the given interfaces exists, this will simply return the cached copy; // otherwise, it will create the proxy class via the ProxyClassFactory return proxyClassCache.get(loader, interfaces); } | 真相还是没有来到,继续,看一下 proxyClassCache 1 2 3 4 | /** * a cache of proxy classes */ private static final WeakCache[], Class> proxyClassCache = new WeakCache<>(new KeyFactory(), new ProxyClassFactory()); | 奥,原来用了一下缓存啊 那么它对应的get方法啥样呢? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | /** * Look-up the value through the cache. This always evaluates the * {@code subKeyFactory} function and optionally evaluates * {@code valueFactory} function if there is no entry in the cache for given * pair of (key, subKey) or the entry has already been cleared. * * @param key possibly null key * @param parameter parameter used together with key to create sub-key and * value (should not be null) * @return the cached value (never null) * @throws NullPointerException if {@code parameter} passed in or * {@code sub-key} calculated by * {@code subKeyFactory} or {@code value} * calculated by {@code valueFactory} is null. */ public V get(K key, P parameter) { Objects.requireNonNull(parameter); expungeStaleEntries(); Object cacheKey = CacheKey.valueOf(key, refQueue); // lazily install the 2nd level valuesMap for the particular cacheKey ConcurrentMap> valuesMap = map.get(cacheKey); if (valuesMap == null) { //putIfAbsent这个方法在key不存在的时候加入一个值,如果key存在就不放入 ConcurrentMap> oldValuesMap = map.putIfAbsent(cacheKey,valuesMap = new ConcurrentHashMap<>()); if (oldValuesMap != null) { valuesMap = oldValuesMap; } } // create subKey and retrieve the possible Supplier stored by that // subKey from valuesMap Object subKey = Objects.requireNonNull(subKeyFactory.apply(key, parameter)); Supplier supplier = valuesMap.get(subKey); Factory factory = null; while (true) { if (supplier != null) { // supplier might be a Factory or a CacheValue instance V value = supplier.get(); if (value != null) { ret… ### 通过IL分析C#中的委托、事件、Func、Action、Predicate之间的区别与联系 - URL: https://jiankunking.com/csharp-il-delegate-event-func-action-predicate.html - Content type: original - Published: 2015-05-04 - Updated: 2015-05-04 - Summary: 通过 IL 代码分析 C# 中委托、事件、Func、Action 与 Predicate 的实现原理、区别和联系。 - Categories: C# - Tags: C#, Delegate, Action, Predicate Article text: 文章速览 通过 IL 代码分析 C# 中委托、事件、Func、Action 与 Predicate 的实现原理、区别和联系。 作者:衣舞晨风 (jiankunking)内容类型:原创主题:C# 一直以来都是对于事件与委托比较混淆,而且不太会用。找了个时间,总结了一下,感觉清晰了很多。本文通过IL代码深入分析委托、事件、Func、Action、Predicate的本质区别与联系。 先说一下个人理解的结论吧: delegate是C#中的一种类型,它实际上是一个能够持有对某个方法的引用的类。 delegate声明的变量与delegate声明的事件,并没有本质的区别,事件是在delegate声明变量的基础上包装而成的,类似于变量与属性的关系(在IL代码中可以看到每一个delegate声明的事件都对应是私有的delegate声明的变量),提升了安全性。 Action 与Func:这两个其实说白了就是系统定义好的Delegate,他有很多重载的方法,便于各种应用情况下的调用。他在系统的System命名空间下,因此全局可见。 首先了解一下, ILDasm中图标含义:   该图来自:http://www.cnblogs.com/zery/p/3366175.html 委托创建步骤: - 用delegate关键字创建一个委托,包括声明返回值和参数类型。 - 使用的地方接收这个委托。 - 创建这个委托的实例并指定一个返回值和参数类型匹配的方法传递过去。 一、事件与委托 新建一个事件委托测试项目:EventDelegateTest。 具体代码如下: 1 2 3 4 5 6 7 8 9 10 | namespace EventDelegateTest { public class TestClass { public delegate int delegateAction(); public event delegateAction OnActionEvent; public delegateAction daNew; } } | 编译代码后,使用 Visual Studio 2010自带的ILDASM.EXE: 打开该dll,可以看到如下信息: 从上图可以看出如下几点信息: 1、delegate 委托 public delegate int delegateAction();在IL中是以类(delegateAction)的形式存在的 .NET将委托定义为一个密封类,派生自基类System.MulticastDelegate,并继承了基类的三个方法: 2、event public event delegateAction OnActionEvent;在IL中不仅仅对应event OnActionEvent而且还对应一个field OnActionEvent;而field OnActionEvent与 public delegateAction daNew生成的field daNew是一样的. 都是以字段(field )的形式存在的。 双击event OnActionEvent可以看到如下信息: 在IL中事件被封装成了包含一个add_前缀和一个remove_前缀的的代码段。 其中,add_前缀的方法其实是通过调用Delegate.Combine()方法来实现的,组成了一个多播委托;remove_就是调用Delegate.Remove()方法,用于移除多播委托中的某个委托。 也就是说:事件其实就是一个特殊的多播委托。 那么对于事件进行这一次封装有什么好处呢? 1、因为delegate可以支持的操作非常多,比如我们可以写onXXXChanged += aaaFunc,把某个函数指针挂载到这个委托上面,但是我们也可以简单粗暴地直接写onXXXChanged = aaaFunc,让这个委托只包含这一个函数指针。不过这样一来会产生一个安全问题:如果我们用onXXXChanged = aaaFunc这样的写法,那么会把这个委托已拥有的其他函数指针给覆盖掉,这大概不是定义onXXXChanged的程序员想要看到的结果。 小注:虽然事件不能直接=某个函数,也不可以直接=null 2、还有一个问题就是onXXXChanged这个委托应该什么时候触发(即调用它所包含的函数指针)。从面向对象的角度来说,XXX改变了这个事实(即onXXXChaned的字面含义)应该由包含它的那个对象来决定。但实际上我们可以从这个对象的外部环境调用onXXXChanged,这既产生了安全问题也不符合面向对象的初衷。  说到这里对于事件与委托的管理算是说明白了,那么平时常用的Action与Func,与委托又有什么关系呢? 二、Action 与Func Action 委托:封装一个方法,该方法具有参数(0到16个参数)并且不返回值。 具体形式如下:https://msdn.microsoft.com/zh-cn/library/system.action(v=vs.110).aspx Func 委托:封装一个具有参数(0到16个参数)并返回 TResult 参数指定的类型值的方法。 具体形式如下:https://msdn.microsoft.com/zh-cn/library/bb534960(v=vs.110).aspx 那么这Action与Func是怎么实现的呢? 1、Action(以Action 委托:封装一个方法,该方法具有两个参数并且不返回值为例) 从微软公布的源码中,可以看到,如下实现: 1 | public Action ac; | 上面这个声明就是:该方法具有两个参数并且不返回值的委托。 其余使用方式与委托变量一样。 2、Func(以Func 委托:封装一个具有两个参数并返回 TResult 参数指定的类型值的方法为例) 从微软公布的源码中,可以看到,如下实现: 此处,可以看出Func与Action是类似的,唯一的区别就是,Func必须指定返回值的类型,使用方式与委托咱们自己使用委托变量是一样的,直接使用相应参数的Func或者Action声明变量,=或者+=挂载函数(方法即可) 这两个其实说白了就是系统定义好的Delegate,他有很多重载的方法,便于各种应用情况下的调用。他在系统的System命名空间下,因此全局可见。 三、Predicate 是返回bool型的泛型委托,Predicate有且只有一个参数,返回值固定为bool。表示定义一组条件并确定指定对象是否符合这些条件的方法。 此方法常在集合(Array 和 List)的查找中被用到,如:数组,正则拼配的结果集中被用到。 官方文档: https://docs.microsoft.com/zh-cn/dotnet/api/system.predicate-1?redirectedfrom=MSDN&view=netframework-4.8 具体用法demo如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; namespace IconTest { public partial class Form2 : Form { Predicate myPredicate; int[] myNum = new int[8] { 12, 33, 89, 21, 15, 29, 40, 52 }; public int[] myResult; public Form2() { InitializeComponent(); myPredicate = delegate(int curNum)              { if (curNum % 2 == 0) { return true; } else { return false; } }; } private void Form2_Load(object sender, EventArgs e) { myResult = Array.FindAll(myNum, myPredicate); } } } | 上例中说明了Predicate的使用,FindAll方法中,参数2即是一个Predicate,在具体的执行中,每一个数组的元素都会执行指定的方法,如果满足要求返回true,并会被存放在结果集中,不符合的则被剔除,最终返回的集合,即是结果判断后想要的集合。 Array.FindAll 泛型方法: https://docs.microsoft.com/zh-cn/dotnet/api/system.array.findall?redirectedfrom=MSDN&view=netframework-4.8#System_Array_FindAll__1___0___System_Predicate___0__ 以上代码执行结果为: 那么Predicate与委托又有什么关系呢? 从微软源码中可以看出Predicate是返回bool型的泛型委托,从本质上来说与Func、Action、事件、委托变量并无本质区别。 四、资料 参考文章: http://www.zhihu.com/question/28932542 关于事件部分应用注意可以参考: http://www.cnblogs.com/buptzym/archive/2013/03/15/2962300.html .NET Framework 源码: https://referencesource.microsoft.com Delegate 类: https://docs.microsoft.com/zh-cn/dotnet/api/system.delegate?redirectedfrom=MSDN&view=netframework-4.8