[{"content":" Claude Fable 5 提示指南 Anthropic 最近重新发布了 Fable 5 和 Mythos 5，随之配套更新了官方的提示指南。这篇笔记是对为 Claude Fable 5 编写提示 这篇文档的阅读整理，主要关注从 Opus 4.8 迁移过来要注意哪些行为变化，以及在 agentic 场景下如何配置 prompt 和 scaffold\n背景与能力定位 Fable 5 的定位不太一样。文档一开始就说，取得最佳成果的团队会把它用在「最棘手的未解决问题」上，而不是在简单工作负载上测试它——因为那样往往会低估它的能力范围。\n和 Opus 4.8 相比，几个方向有明显改进：\n长周期自主性：能连续跑多日的目标导向任务，在长期运行中保持指令记忆能力 首次正确率：以往需要数天迭代的系统，有团队报告说现在可以一次性实现 视觉能力：解读密集技术图像、截图的准确度显著提高，还经过训练可以用 bash 和裁剪工具处理翻转、模糊或含噪声的图像 代码审查：跨代码库和仓库历史的缺陷发现召回率高于 Opus 4.8 应对模糊性：收到复杂多线程请求时能自行界定范围、提出澄清问题并执行 并行子 agent 调度：比之前的模型更可靠，能稳定管理与子 agent 之间的持续通信 不过有个限制需要注意：Fable 5 不适用于攻击性网络安全或生物/生命科学工作，它对此类敏感任务有严格的防护墙，这类请求会返回 stop_reason: \u0026quot;refusal\u0026quot;，配置回退到 Opus 4.8。 挺搞笑的，在 X 上见到有人查 Claude Code 后台日志，发现了一个系统标签：TOO_DUMB_TO_NEED_FABLE，被工程师嘲讽：说实话，我压根没想到你们真的会去翻日志😹\n行为变化与迁移要点 文档里最核心的一部分，是讲从 Opus 4.8 迁移过来会遇到的几个行为差异。\n回合时长变长了。 在较高 effort 设置下，单个请求可能运行数分钟，自主运行可能持续数小时。文档说这是团队适应 Fable 5 时遇到的最大转变之一。迁移前需要调整客户端超时、流式传输和进度指示器，并考虑把测试框架改成异步方式检查运行状态（比如定时任务），而不是阻塞等待。\neffort 控制是主要杠杆。 Fable 5 引入了 high / xhigh / medium / low 几个 effort 级别。文档建议大多数任务用 high 作为默认值，能力最密集的工作负载用 xhigh，日常工作用 medium 或 low。有个有意思的描述：Fable 5 上较低的 effort 设置通常超过先前模型 xhigh 的性能——也就是说，没必要无脑拉满。\n再往下看，会发现在较高 effort 下它会做一些「超出任务所需」的事情：对没有问的选项进行比较、冗长地解释根本原因、写很重的 PR 描述，或给下一行代码写注释。文档说只需要一条简短的简洁性指令就能管住这些行为，不需要逐一列举。\n长时间运行场景的几个要点 这部分对 agentic workflow 的设计影响较大\n核实进度声明。 文档特别提到，在长时间自主运行中，要指示 Fable 5 根据实际工具结果审核进度。Anthropic 自己测试的结论是：这样做能几乎完全消除虚假状态报告，哪怕是专门设计用于诱发这类问题的任务也能规避。\n明确边界。 Fable 5 偶尔会做未经请求的操作——比如在没被要求时起草 email、创建防御性的 git 分支备份。需要在 prompt 里明确定义它应该做什么和不应该做什么。\n并行子 agent 调度。 文档建议频繁使用子 agent，提供清晰的委派指导，并优先采用编排器与子 agent 之间的异步通信（而不是阻塞等待每个返回）。长期存活的子 agent 能在各子任务之间保留上下文，可以通过缓存读取节省时间，同时避免因最慢的子 agent 而出现瓶颈。\n记忆系统。 文档说「当 Fable 5 能够记录先前运行的经验教训并加以参考时，其表现尤为出色」。建议提供一个记录笔记的位置，简单到一个 Markdown 文件就够了。还可以让它回顾过去的会话来引导记忆系统。\n两个比较少见但需要知道的情况 罕见的提前停止。 在长会话的深入阶段，Fable 5 偶尔会用纯文本的意图声明（「我现在将运行 X」）结束回合而不发出工具调用，或者在已有足够信息继续时暂停请求许可。这时候回复「继续」或「直接端到端完成」就可以了。对于自主 pipeline，文档建议加一条系统提醒处理这种情况。\n罕见的上下文预算担忧。 在非常长的会话里，Fable 5 偶尔会建议开启新会话、提议总结并交接，或削减自己的工作。这种情况最常在测试框架向模型显示剩余 token 倒计时时触发。文档建议尽可能避免显示明确的上下文预算计数。\n可读性与沟通 在扩展对话或 agent 式对话中，Fable 5 可能会生成难以理解的文本：密集的箭头链式简写、深入的实现细节、引用用户从未看到的思考内容，或过于技术化的措辞。添加沟通风格补充说明可以缓解这个问题～\n迁移时的几个注意点 文档最后整理了几条建议，值得关注的：\n从难度范围的顶端开始测试。 选一个比分配给先前模型更难的任务，让 Fable 5 界定范围、提出澄清问题并执行。\n重构现有的 prompt 和 skill。 为之前模型开发的 skill 对 Fable 5 而言往往过于规定性，可能会降低输出质量。如果默认性能更好，应该审查并考虑移除旧指令。Fable 5 也擅长根据从当前任务学到的内容即时更新 skill。\n个人觉得现在的模型能力越来越强，那些要求边界明显的 skill 会消灭模型探索的能力，这要求我们编写 skill 要适当规范化和更加的通用化，不应因为过多的边界而限制了模型的发挥。\n不要指示 Claude 在响应中复现其推理。 告诉模型将内部推理回显、转录或解释的 prompt 指令，可能会在 Fable 5 上触发 reasoning_extraction 拒绝类别，导致更频繁回退到 Opus 4.8。迁移时需要审核现有 skill 和系统提示中的「展示思考过程」类指令。如果应用程序需要推理可见性，应该改为读取 adaptive thinking 中的结构化 thinking 块。\n总结 从这份文档来看，Fable 5 的核心转变是从「单次对话中的强大模型」变成了「能跑几天的自主 agent」。相应地，使用方式也需要从「逐条指令」转向「给目标、给边界、异步检查」。之前为 Opus 4.8 写的很多 prompt 技巧可能不再必要，甚至适得其反——这倒是给了一个机会重新审视并精简现有的框架。\n","permalink":"https://Jul1en-Lin.github.io/p/claude-fable-5-%E6%8F%90%E7%A4%BA%E6%8C%87%E5%8D%97/","title":"Claude Fable 5 提示指南"},{"content":" 相关博客：Codex 工作流更新-v2 Codex 工作流更新-v3 最近 OpenAI 的一篇博客： Codex-maxxing for long-running work 分享了关于长周期复杂任务的指南，并针对我已有的工作流作出一些更新。\n长周期的复杂任务通常不是一次 prompt 改完代码就结束。它可能要经历调查、实现、预览、反馈、等 CI、继续修改、准备 PR、后续检查。该博客更像是总结了 Claude Code goal 和 loop 命令的思想 💭 v2 现状 v2 从项目初始化开始： setup-light / setup-full 生成 AGENTS.md、CLAUDE.md 和 docs/ 记忆文件，让 Agent 进入一个项目后，知道行为规范。它不太关注于任务线本身，比如预览页面谁来看，用户反馈放在哪里，PR review 几个小时后才来又怎么接着改，这些长期任务流程问题是 v2 未涉及的。\n长时间复杂任务 OpenAI 这篇博客把 Codex 看成一个 persistent workspace：一件复杂任务有自己的线程、记忆、工具和审阅对象，重要的有以下几点。\nDurable thread： 给长期任务一个固定会话，维护会话的上下文、旧决策、待办和用户反馈容易丢失。\nMemory： 不只依赖聊天记录，而是把项目状态、决策、开放事项写进可以查看、可以 diff、可以复用的文件。对应到当前工作流里， docs/ 就承担了 memory 的作用，跨项目 or 会话的个性要求则可以放进个人记忆或知识库。\nSteering： Codex 工作时，用户可以继续补充 prompt。比如“先不要接短信登录”“这个错误提示太别扭”“等预览部署完再继续”。任务就不再是一次性指令，而是可以边做边调整的任务队列。\nThread automation： 定时任务，比如等待部署、看 review 是否更新、检查支持工单有没有新回复。\nSide panel： 产物本身要进入审阅循环。Markdown、CSV、PDF、slides、本地页面预览都可以直接作为审阅对象，用户的评论继续变成下一轮任务上下文。\nGoals： 目标要能有具体的量化标准：如要写清楚页面、接口、测试、异常情况分别达到什么结果。\n这些点合在一起，就能把任务进化成一个工作循环：\n目标 -\u0026gt; 线程 -\u0026gt; 项目记忆 -\u0026gt; 调用工具 -\u0026gt; 产出 -\u0026gt; review -\u0026gt; 下一轮任务\n这张图可以作为本次更新的总览。\n三层模型 通过上述总结可以把工作流分成三层：\nThread： 保存一条长期任务的上下文 Project memory： 保存 repo 内的事实，也就是 AGENTS.md 和 docs/* Execution surfaces： 接触真实工作界面，比如 browser、Chrome、computer use、connectors、skills 而 v2 正好属于 Project memory 这一层，所以不需要做改进。另外，长期任务容易越做越多，所以边界要提前写清楚，例如可以配置相关 Hooks\n哪些任务适合长期线程？\n不是所有任务都要开长期线程。一次性命令、小改动、马上能验完的问题，按 v2 的普通流程就够。\n更适合长期线程的场景：\n会跨多轮修改 需要用户看预览或多次反馈 需要等待 CI、部署、review、第三方回复 需要跨工具处理，比如 GitHub + 本地代码 + 浏览器 后续可能沉淀成 skill 中断后还要接着做 Prompt 模板 下次遇到复杂任务需要长时间工作流的场景，可以直接这样编写 prompt： 可以手动引用 loop goal 命令进一步约束 Agent，确保执行正确\n这是一个长期任务线程：\u0026lt;任务名\u0026gt;。 先读 AGENTS.md 和 docs/agent_workflow.md。 必要时再读 docs/project_status.md、docs/project_spec.md、docs/architecture.md。 目标： - \u0026lt;目标 1\u0026gt; - \u0026lt;目标 2\u0026gt; 验收标准： 1. \u0026lt;可以检查的结果\u0026gt; 2. \u0026lt;需要运行的检查\u0026gt; 3. \u0026lt;需要用户审阅的产物\u0026gt; 执行规则： - 先调查，不要急着改代码 - 改前说明方案和影响范围 - 改后运行相关检查 - 涉及 UI 时，启动本地预览并给出地址 - 不要 push、发布、删除数据、发送消息，除非用户明确批准 - 任务中断前，更新 docs/project_status.md 这里的关键是验收标准要能检查。不要只写“完成登录功能”，而是写清楚哪些页面、哪些接口、哪些测试、哪些异常情况必须通过。\nTools 此外还要说清楚，任务需要碰到哪些工具。可手动引用或在规范文档、prompt 中提示\n常见选择：\n本地网页预览：用 browser 需要用户登录态：用 Chrome 必须点桌面软件：用 computer use GitHub、Gmail、Slack、Calendar：用对应的 MCP 反复使用的流程：整理成 skill 需要隔一段时间回来查：用 thread automation checkpoint 的判断 v2 里 checkpoint 更像 commit 前的项目状态检查；这里 checkpoint 更像一次任务阶段记录。\n需要 checkpoint 的情况包括：\n当前阶段已经完成 任务要暂停 要交给另一个 Agent 后续需要接着做 出现新的风险或阻塞 行为、架构、依赖或项目状态发生变化 与之前的 checkpoint 思想不同，不是为了每次 commit 都按需更新记忆文档，而是为了让任务之后能接上。如果只是普通小改动，就不需要每次都更新一堆 docs。\n复盘 v2 规定了 Agent 对项目的维护方式，v3 则是面对复杂长期任务时总结的思想\n博客来源：\nCodex-maxxing for long-running work ","permalink":"https://Jul1en-Lin.github.io/p/codex-%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%9B%B4%E6%96%B0-v3/","title":"Codex 工作流更新-v3"},{"content":" SkyWalking 相关笔记：Spring Cloud 核心概念 链路追踪 微服务拆分后，一次请求可能经历下面的过程：\n用户请求进入 Gateway Gateway 调用商品服务、订单服务或库存服务 订单服务继续调用支付服务 服务访问 MySQL、Redis 或消息队列 部分调用串行执行，部分调用并行执行 这些服务可能由不同团队开发，部署在不同服务器上，甚至使用不同语言。只看单个服务的日志是很难追溯到问题的，比如\n请求具体经过了哪些服务 服务之间是什么依赖关系 各个接口的性能怎么样 这就需要链路追踪。链路追踪会记录一次请求在多个服务之间的流转路径、耗时和状态，把分散在各个服务里的调用信息集中展示。它的核心是通过唯一的 Trace ID 串联跨服务调用，用 Span 表示一次具体处理单元，再通过 Context 传递让链路可以连续下去。\n这里用快递包裹的物流追踪来类比链路追踪。看物流轨迹时，我们会关心包裹经过了哪些转运节点、每个节点花了多少时间、派送过程有没有异常。链路追踪也是类似的思路：给复杂的网络请求画一张“流程图”，记录一次操作经过了哪些服务、每个环节耗时多少、是否出现异常。\n链路追踪 快递物流 Trace 整个物流链路 Trace ID 快递单号 Span 包裹每经过一个转运中心产生的一条记录，包含到达时间、状态等信息 链路追踪的作用主要有四个：\n保障系统可用性：结合 CPU、内存、成功率等指标，提前发现异常并触发告警 优化性能和资源使用：通过完整 Trace 看清每个服务节点耗时，找到慢调用 提升故障排查效率：用 Trace ID 把跨服务日志、错误信息串成统一视图 理解服务依赖与拓扑：自动展示服务间调用关系、调用频率和成功率，辅助架构分析 链路追踪适合找调用关系和慢点，但不完全等同于日志。 Trace 说明“哪一步慢、哪一步错”，日志继续解释“当时的参数和业务状态是什么”。如果还要定位到具体方法和代码行，再进入性能剖析。\n常见方案 链路追踪本身是一类问题，不是 SkyWalking 独有的能力。常见方案大概有这些：\nZipkin：Twitter 开源的分布式链路追踪系统，设计思路来自 Google Dapper 论文，常用于请求链路采集、存储和查询 CAT：美团点评开源的分布式实时监控系统，理念来源于 eBay 的 CAL 系统，报表和监控能力比较完整，但通常需要代码埋点 SkyWalking：Apache 顶级项目，面向微服务和分布式架构，支持 Agent 自动采集，UI 功能较完整，也支持插件扩展 简单对比图：\n维度 Zipkin CAT SkyWalking 接入复杂度 接入相对简单，常和 Spring Cloud 生态集成 需要代码埋点 通过 Java Agent 启动，代码无侵入 数据粒度 接口级 代码级，可细化到具体代码块 方法级，支持 RPC、HTTP 支持语言 Java、C#、Python、Node.js、Go、Ruby、Scala 等 Java、C/C++、Python、Node.js、Go 等 Java、Python、Node.js、PHP、Go、Ruby 等 调用链可视化 支持 支持 支持 聚合报表 较少 很丰富 较丰富 服务依赖图 简单 简单 较好 告警支持 不直接提供 支持 支持 存储机制 内存、Elasticsearch、MySQL 等 MySQL、本地文件、HDFS 等 Elasticsearch、MySQL、BanyanDB、PostgreSQL 等 社区情况 文档丰富，功能迭代相对慢 国内使用多，社区活跃度一般 社区活跃，更新较频繁 主要优势 部署简单，适合快速接入基础 Trace 报表完整，适合综合监控 无侵入、Apache 项目、生态较完整 主要不足 报表能力弱，功能相对单一 代码侵入性高 插件开发门槛较高 选择 SkyWalking 的原因 综合来看，SkyWalking 的能力更完整，无代码侵入接入、多语言、报表、告警和插件体系都比较适合当前 Java Spring 微服务项目。\n核心组件 SkyWalking 架构分为三类组件：探针 Agent、后端 Backend 和前端 UI。其中 OAP 是后端的核心部分，负责接收、处理和存储来自探针的数据，并生成聚合指标。\n组件 作用 比喻（交通网系统） Agent 探针，通过字节码增强收集应用运行时数据 摄像头 OAP 接收、分析、聚合数据，并提供查询和告警能力 交通数据处理系统（后端） UI 展示拓扑、服务指标、Trace、日志、剖析任务和告警 Dashboard 看板（前端） 关键术语 术语 含义 APM Application Performance Monitor，应用性能监控，通过收集和分析响应时间、吞吐量、错误率等数据来帮助优化系统性能 OAP Observability Analysis Platform，观测分析平台，是 SkyWalking 后端的核心部分 Agent 探针，通过字节码增强无侵入采集应用运行时数据 Metrics 指标，比如服务响应时间、服务成功率、分钟请求数 Trace 一次完整的分布式请求，由多个 Span 或 Segment 组成 Trace ID 一次 Trace 的全局标识，常用于 Trace 与日志互查 Span 一次具体操作，比如接收 HTTP 请求、执行方法、调用数据库 Endpoint 服务入口，比如 GET:/orders/{id} 或一个 RPC 方法 Context Propagation 把 Trace 上下文传到下游服务或异步任务 Sampling 只采集部分请求，控制 Agent、网络和存储开销 Topology 根据调用数据得到的服务依赖关系 Apdex 根据响应时间评估用户体验的指标 OAL OAP 中用于分析 Trace 和 Mesh 流量的规则语言 MQE Metrics Query Expression，当前告警规则使用的指标表达式 安装部署 下载包说明 课件里是手动下载安装 SkyWalking，需下载两个包：\nSkyWalking APM：服务端包，里面包含 OAP、UI、配置文件和启动脚本，用来启动 SkyWalking 后端与可视化界面 Java Agent：探针包，用来接入 Spring Boot 服务，通过 -javaagent 挂到业务 JVM 上采集 Trace、Metrics 等数据 从 8.8.0 之后，APM 和 Agent 分开安装，所以只下载 Java Agent 不够。需要先准备 SkyWalking APM 服务端，再给业务服务接入 Java Agent。\nAPM 历史版本：https://archive.apache.org/dist/skywalking Java Agent 历史版本：https://archive.apache.org/dist/skywalking/java-agent Docker Compose 部署 目录可以这样放：\nskywalking/ ├── compose.yaml └── config/ └── alarm-settings.yml compose.yaml：\nservices: banyandb: image: apache/skywalking-banyandb:0.10.2 container_name: skywalking-banyandb command: - standalone - --stream-root-path - /data/stream-data - --measure-root-path - /data/measure-data ports: - \u0026#34;17912:17912\u0026#34; - \u0026#34;17913:17913\u0026#34; volumes: - banyandb-data:/data healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;nc -nz 127.0.0.1 17912\u0026#34;] interval: 5s timeout: 5s retries: 60 oap: image: apache/skywalking-oap-server:10.4.0 container_name: skywalking-oap depends_on: banyandb: condition: service_healthy environment: SW_STORAGE: banyandb SW_STORAGE_BANYANDB_TARGETS: banyandb:17912 SW_HEALTH_CHECKER: default JAVA_OPTS: \u0026#34;-Xms1g -Xmx1g\u0026#34; ports: - \u0026#34;11800:11800\u0026#34; - \u0026#34;12800:12800\u0026#34; volumes: - ./config/alarm-settings.yml:/skywalking/ext-config/alarm-settings.yml:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;bash -c \u0026#39;echo \u0026gt;/dev/tcp/127.0.0.1/12800\u0026#39;\u0026#34;] interval: 15s timeout: 5s retries: 20 start_period: 60s ui: image: apache/skywalking-ui:10.4.0 container_name: skywalking-ui depends_on: oap: condition: service_healthy environment: SW_OAP_ADDRESS: http://oap:12800 ports: - \u0026#34;8080:8080\u0026#34; volumes: banyandb-data: 先创建一个可用的 config/alarm-settings.yml：\nrules: {} hooks: {} 启动：\ncd skywalking docker compose up -d docker compose ps 检查 OAP：\ncurl http://localhost:12800/internal/l7check 如果 Spring Boot 运行在宿主机，Agent 连接 127.0.0.1:11800。如果应用也在 Compose 中，地址应写成 oap:11800，不能写 localhost:11800，因为容器内的 localhost 指向应用容器自己。\nSpring Boot 服务接入 接入 SkyWalking 前先确认两件事：\nSkyWalking OAP 和 UI 已经启动，能访问 http://127.0.0.1:8080，如果改过 UI 端口就换成自己的端口 微服务本身能正常跑通业务请求，否则后面 UI 上即使没有 Trace，也不好判断是业务问题还是 Agent 接入问题 下载与放置 Java Agent 从 SkyWalking 官方 Downloads 页面下载 Java Agent 后，一般先解压 SkyWalking APM 包，再在 APM 目录中新建 agent 文件夹，把 Java Agent 解压后的内容复制进去。这样后面启动业务服务时，就可以指向这个 agent/skywalking-agent.jar，也比较方便。\n启动参数接入 以 order-service 为例，通过 -javaagent 参数配置 SkyWalking Agent 来追踪微服务。 最直接的启动方式是在 Java 启动参数里增加： # 换成自己本地的 agent 安装路径 java \\ -javaagent:/opt/skywalking-agent/skywalking-agent.jar \\ -Dskywalking.agent.service_name=order-service \\ -Dskywalking.collector.backend_service=127.0.0.1:11800 \\ -jar order-service.jar -javaagent 必须放在 -jar 前面。\n几个参数含义：\n-javaagent：后面换成自己本地的 skywalking-agent.jar 路径 -Dskywalking.agent.service_name：服务名称，比如 order-service -Dskywalking.collector.backend_service：SkyWalking OAP 的 gRPC 地址，默认是 127.0.0.1:11800（需开放端口号） 启动完成后，打开 SkyWalking UI 左侧的服务页面，就能看到已经接入的服务。其他服务也按同样方式加 Agent 参数，只是 service_name 要改成各自的服务名。\n也可以通过环境变量配置：\nexport SW_AGENT_NAME=order-service export SW_AGENT_INSTANCE_NAME=order-service-local-01 export SW_AGENT_COLLECTOR_BACKEND_SERVICES=127.0.0.1:11800 java -javaagent:/opt/skywalking-agent/skywalking-agent.jar \\ -jar order-service.jar 常用配置：\nAgent 配置 环境变量 说明 agent.service_name SW_AGENT_NAME 服务名，同一业务服务的多个实例保持一致 agent.instance_name SW_AGENT_INSTANCE_NAME 实例名，同一服务内保持唯一 agent.namespace SW_AGENT_NAMESPACE 可用于区分环境或命名空间 agent.cluster SW_AGENT_CLUSTER 标记数据中心或集群 collector.backend_service SW_AGENT_COLLECTOR_BACKEND_SERVICES OAP gRPC 地址 服务名建议使用明确且固定的英文名，比如 gateway-service、order-service、inventory-service。不要把 Pod IP 或随机端口放到服务名里，这类信息应该放在实例名中。\nDocker 中接入 Spring Boot 镜像可以把 Agent 放进镜像：\nFROM eclipse-temurin:21-jre WORKDIR /app COPY agent /opt/skywalking-agent COPY target/order-service.jar /app/app.jar ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-javaagent:/opt/skywalking-agent/skywalking-agent.jar\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;/app/app.jar\u0026#34;] 应用服务配置：\norder-service: image: example/order-service:1.0.0 environment: SW_AGENT_NAME: order-service SW_AGENT_INSTANCE_NAME: order-service-01 SW_AGENT_COLLECTOR_BACKEND_SERVICES: oap:11800 depends_on: oap: condition: service_healthy 如果同一个镜像启动多个副本，不要把 SW_AGENT_INSTANCE_NAME 写死。可以不配置，让 Agent 生成实例名，或者由 Kubernetes 使用 Pod 名注入。\n自定义追踪 自动插件主要覆盖框架入口和常见中间件。纯业务方法、自研协议、特殊线程模型可能不会自动出现，这时可以使用 Java Agent Toolkit。\nToolkit 依赖 \u0026lt;properties\u0026gt; \u0026lt;skywalking.version\u0026gt;9.6.0\u0026lt;/skywalking.version\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.skywalking\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;apm-toolkit-trace\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${skywalking.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; Toolkit 版本建议和 Java Agent 版本保持一致。\n相关注解 @Trace 会把方法加入链路追踪，自动生成 Span 并记录方法耗时和调用关系。operationName 可以自定义 Span 名称，方便在 Trace 页面里阅读。@Tag 顾名思义打上标签，可以把参数或返回值写入当前入口，方便辨认\n@Tag 里的 key 是标签名，value 是取值表达式。\nimport org.apache.skywalking.apm.toolkit.trace.Trace; import org.apache.skywalking.apm.toolkit.trace.Tag; @Trace(operationName = \u0026#34;OrderService/createOrder\u0026#34;) @Tag(key = \u0026#34;order.userId\u0026#34;, value = \u0026#34;arg[0]\u0026#34;) @Tag(key = \u0026#34;order.id\u0026#34;, value = \u0026#34;returnedObj.id\u0026#34;) public Order createOrder(Long userId, CreateOrderCommand command) { return orderRepository.save(userId, command); } 如果要同时记录多个信息，可以使用 @Tags：\nimport org.apache.skywalking.apm.toolkit.trace.Tag; import org.apache.skywalking.apm.toolkit.trace.Tags; import org.apache.skywalking.apm.toolkit.trace.Trace; @Trace(operationName = \u0026#34;queryById\u0026#34;) @Tags({ @Tag(key = \u0026#34;request\u0026#34;, value = \u0026#34;arg[0]\u0026#34;), @Tag(key = \u0026#34;response\u0026#34;, value = \u0026#34;returnedObj\u0026#34;) }) public OrderInfo queryById(Integer orderId) { return orderMapper.selectById(orderId); } arg[n] 表示第 n 个入参，从 0 开始；returnedObj 表示方法返回值，returnedObj.id 表示取返回对象里的 id 字段。\n日志关联与上传 要想在 SkyWalking UI 里查看日志，并把日志和 Trace 关联起来，需要分成两件事：\n在本地日志中打印 Trace ID，方便从日志跳到 Trace 通过 gRPC 把日志上传到 OAP，在 SkyWalking UI 中统一查询 添加依赖 Spring Boot 默认常用 Logback：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.skywalking\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;apm-toolkit-logback-1.x\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.6.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 编写 xml 文件 在 src/main/resources 下新建 logback-spring.xml。Spring Boot 会优先加载 logback-spring.xml，它比普通 logback.xml 更适合 Spring Boot 项目，也能使用 Spring 的扩展能力。\n下面这个配置只负责把 Trace ID 打到本地控制台日志里：\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;appender name=\u0026#34;CONSOLE\u0026#34; class=\u0026#34;ch.qos.logback.core.ConsoleAppender\u0026#34;\u0026gt; \u0026lt;encoder class=\u0026#34;ch.qos.logback.core.encoder.LayoutWrappingEncoder\u0026#34;\u0026gt; \u0026lt;layout class=\u0026#34;org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.TraceIdMDCPatternLogbackLayout\u0026#34;\u0026gt; \u0026lt;Pattern\u0026gt; %d{yyyy-MM-dd HH:mm:ss.SSS} [%X{tid}] [%thread] %-5level %logger{36} - %msg%n \u0026lt;/Pattern\u0026gt; \u0026lt;/layout\u0026gt; \u0026lt;/encoder\u0026gt; \u0026lt;/appender\u0026gt; \u0026lt;root level=\u0026#34;INFO\u0026#34;\u0026gt; \u0026lt;appender-ref ref=\u0026#34;CONSOLE\u0026#34;/\u0026gt; \u0026lt;/root\u0026gt; \u0026lt;/configuration\u0026gt; 有 Agent 且当前线程处于 Trace 中时，%X{tid} 会输出 Trace ID；没有可用 Trace 时会显示 TID: N/A。\n如果希望写出完整 SkyWalking 上下文，可以把 %X{tid} 换成 %X{sw_ctx}，内容包括：\nserviceName, instanceName, traceId, traceSegmentId, spanId 上传日志 只打印 Trace ID 还不等于上传日志。上面的配置只能让应用自己的控制台日志带上 Trace ID，SkyWalking UI 的 Log 页面还看不到这些日志。\n如果要把日志上传到 OAP，需要在 logback-spring.xml 里添加 GRPCLogClientAppender：\n\u0026lt;appender name=\u0026#34;GRPC_LOG\u0026#34; class=\u0026#34;org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender\u0026#34;\u0026gt; \u0026lt;encoder class=\u0026#34;ch.qos.logback.core.encoder.LayoutWrappingEncoder\u0026#34;\u0026gt; \u0026lt;layout class=\u0026#34;org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.TraceIdMDCPatternLogbackLayout\u0026#34;\u0026gt; \u0026lt;Pattern\u0026gt; %d{yyyy-MM-dd HH:mm:ss.SSS} [%X{tid}] [%thread] %-5level %logger{36} - %msg%n \u0026lt;/Pattern\u0026gt; \u0026lt;/layout\u0026gt; \u0026lt;/encoder\u0026gt; \u0026lt;/appender\u0026gt; \u0026lt;root level=\u0026#34;INFO\u0026#34;\u0026gt; \u0026lt;appender-ref ref=\u0026#34;CONSOLE\u0026#34;/\u0026gt; \u0026lt;appender-ref ref=\u0026#34;GRPC_LOG\u0026#34;/\u0026gt; \u0026lt;/root\u0026gt; 这里要注意：GRPCLogClientAppender 上报日志时，会自动附带 Trace ID、Segment ID 和 Span ID。也就是说，layout 控制的是日志正文格式；日志与 Trace 的关联信息由 gRPC reporter 自动带上。 等重启服务并发起请求后，就可以在 SkyWalking UI 的 Log 页面查询日志，也可以根据 Trace ID 过滤日志并跳转到对应 Trace。\n告警 SkyWalking OAP 的告警规则位于 apm 的 config/alarm-settings.yml。Docker 镜像可以把同名文件挂载到 /skywalking/ext-config 来覆盖默认配置。\n可以把告警类比成烟雾报警器：当系统出现响应慢、成功率低这类异常时，就像检测到烟雾一样触发通知，提醒研发或运维尽快处理。\nSkyWalking 默认规则主要覆盖这些场景：\n服务平均响应时间超过阈值 服务成功率低于阈值 服务响应时间百分位数超过阈值，比如 P50、P75、P90、P95、P99 服务实例平均响应时间超过阈值 Endpoint 平均响应时间超过阈值 数据库访问平均响应时间超过阈值 Endpoint 关系平均响应时间超过阈值 规则字段 简单罗列一些规则字段，用到的时候再查即可。\n字段 说明 规则 ID 必须以 _rule 结尾 expression MQE 表达式，结果必须是单值布尔结果 period 参与判断的分钟窗口 silence-period 告警触发后的静默时间 recovery-observation-period 条件恢复后连续观察多少个周期再发送恢复通知 include-names 只匹配指定实体 exclude-names 排除指定实体 include-names-regex 用正则匹配实体名 tags 附加标签，常用 level 标记告警级别 hooks 指定本规则调用哪些通知目标 message 告警文本，{name} 会替换成实体名 Webhook Webhook 是 OAP 把告警推送给外部系统的一种方式。告警触发后，OAP 会使用 HTTP POST 和 application/json 请求目标地址，请求体是告警消息数组，在编写代码时需要注意用 List\u0026lt;\u0026gt;~\n接收方只要提供一个可访问的 HTTP 地址，就可以接收 SkyWalking 推送的告警数据。常见用途包括推送到钉钉、企业微信、飞书、邮箱，或者转给内部告警服务继续处理。\n配置 完整的 alarm-settings.yml 可以这样写：\nrules: service_resp_time_rule: expression: sum(service_resp_time \u0026gt; 1000) \u0026gt;= 3 period: 5 silence-period: 10 recovery-observation-period: 2 message: \u0026#34;服务 {name} 最近 5 分钟内多次超过 1000ms\u0026#34; tags: level: WARNING hooks: - webhook.ops hooks: webhook: ops: urls: - http://host.docker.internal:8088/webhooks/skywalking recovery-urls: - http://host.docker.internal:8088/webhooks/skywalking headers: Authorization: Bearer replace-with-your-token 排查顺序 实际遇到问题需要排查时可以按这个顺序：\n先看服务拓扑，确认调用方向和异常节点 再看 Service、Instance、Endpoint 指标，缩小时间范围 打开慢请求 Trace，查看哪个 Span 占用时间最多 用 Trace ID 查询关联日志，检查参数、异常和业务状态 接口内部仍看不出原因时，创建 Trace Profiling 任务 JVM 出现 CPU、分配或锁问题时，创建 Java App Profiling 任务 确认指标规则后配置告警，再接 Webhook 通知 ","permalink":"https://Jul1en-Lin.github.io/p/skywalking/","title":"SkyWalking"},{"content":" Seata Seata 是 Spring Cloud Alibaba 体系里常用的分布式事务解决方案。它解决的核心问题是：微服务拆分之后，一个业务请求可能跨多个服务、多个数据库，Seata 负责把这些分散的本地事务组织成一个全局事务，让整条业务链路尽量保持一致。\n可以先这样记：\nEureka、Nacos、OpenFeign、Gateway 解决服务治理：注册、发现、调用、网关转发。 Sentinel 解决流量治理：限流、熔断、降级、系统保护。 Seata 解决事务治理：跨服务写库后的数据一致性。 学习 Seata 时可以先不展开某个注解或配置，先看这条主线：\n微服务把一个业务动作拆成多个本地事务，Seata 再用全局事务把这些本地事务协调起来。\n为什么需要 Seata 单体应用里，下单、扣库存、扣余额可能都在一个工程、一个数据库里。此时只要开启一个本地事务，就能保证：\n订单创建成功； 库存扣减成功； 账户扣款成功； 中间任一步失败就整体回滚。 拆成微服务后，情况变复杂了：\n服务 数据库 本地事务能管什么 订单服务 订单库 只能保证订单表修改成功或失败 库存服务 库存库 只能保证库存表修改成功或失败 账户服务 账户库 只能保证账户表修改成功或失败 问题在于：每个服务只能保证自己的本地事务，没人天然知道整条下单链路应该一起提交，还是一起回滚。\n典型异常场景：\n订单服务创建订单成功。 库存服务扣减库存成功。 账户服务扣减余额时超时。 最后变成“订单有了，库存少了，钱没扣”。 这就是分布式事务要解决的核心问题：一个业务动作被拆成多个本地事务后，如何避免“部分成功、部分失败”。\n这张图可以抓三个角色：TM 发起全局事务，RM 执行各自的本地分支，TC 负责从全局视角决定提交或回滚。\nSeata 的三个核心角色 Seata 的模型可以理解为：业务服务继续执行自己的本地操作，但每个本地事务分支都要登记到同一个全局事务里。\n角色 全称 作用 TC Transaction Coordinator 全局事务协调者，通常就是 Seata Server，负责维护全局事务状态。 TM Transaction Manager 全局事务管理器，通常在入口业务服务里，负责开启、提交、回滚全局事务。 RM Resource Manager 资源管理器，负责管理本地分支事务，并向 TC 注册和上报状态。 放到下单业务里：\n订单服务通常是 TM，因为它是业务入口。 库存服务、账户服务中的数据库资源通常由 RM 管理。 Seata Server 是 TC，负责协调所有分支。 整体流程可以这样看：\nsequenceDiagram participant Client as 用户请求 participant Order as 订单服务(TM) participant TC as Seata Server(TC) participant Stock as 库存服务(RM) participant Account as 账户服务(RM) Client-\u0026gt;\u0026gt;Order: 下单 Order-\u0026gt;\u0026gt;TC: 开启全局事务 Order-\u0026gt;\u0026gt;Stock: 扣减库存 Stock-\u0026gt;\u0026gt;TC: 注册分支事务 Order-\u0026gt;\u0026gt;Account: 扣减余额 Account-\u0026gt;\u0026gt;TC: 注册分支事务 alt 全部分支成功 Order-\u0026gt;\u0026gt;TC: 提交全局事务 TC-\u0026gt;\u0026gt;Stock: 分支提交 TC-\u0026gt;\u0026gt;Account: 分支提交 else 任一分支失败 Order-\u0026gt;\u0026gt;TC: 回滚全局事务 TC-\u0026gt;\u0026gt;Stock: 分支回滚 TC-\u0026gt;\u0026gt;Account: 分支回滚 end CAP 和 BASE 学习 Seata 前，经常会遇到 CAP 和 BASE。这两个概念不用过度抽象，它们主要是在解释：分布式系统为什么很难只追求“绝对强一致”。\nCAP 目标 含义 C：Consistency 一致性，多个节点看到的数据结果一致。 A：Availability 可用性，请求能得到正常响应。 P：Partition tolerance 分区容错性，网络异常时系统还能继续运行。 微服务一定会发生远程调用，所以网络问题无法完全避免。也就是说，P 基本是必须接受的。一旦发生网络分区，系统通常要在强一致性和可用性之间做取舍。\nBASE BASE 是工程上的折中思路：\nBasically Available：基本可用，系统不要因为局部失败完全瘫痪。 Soft State：软状态，允许短时间中间状态存在。 Eventually Consistent：最终一致，通过补偿、重试、异步修复让数据最终对上。 所以 Seata 的重点不是把分布式系统变得和单机数据库一样，而是在一致性、性能、可用性、业务侵入之间找一个能落地的方案。\n两阶段提交（2PC） 2PC 是分布式事务里最经典的提交思想：先询问所有参与者能不能提交，再根据所有人的结果统一提交或回滚。 阶段一：Prepare 第一阶段，协调者先问所有参与者：\n能不能提交？ 资源是否准备好？ 本地操作是否可以进入待提交状态？ 参与者准备好后回复“可以”，否则回复“不可以”。\n阶段二：Commit or Rollback 如果所有参与者都同意，协调者通知大家提交。 只要有一个参与者不同意，协调者通知大家回滚。 主要问题 协调者故障时，参与者可能不知道该提交还是回滚。 一阶段需要锁定资源，等到二阶段结束才释放，导致事务资源长时间得不到释放，锁定周期长，性能和可用性压力比较大。 所以 2PC 是重要基础，但不一定是所有业务的最佳选择。Seata 的 XA 模式更接近标准 2PC，而 AT 模式是在两阶段思想上做了工程优化。\nXA 模式 XA 模式依赖数据库原生对 XA 协议的支持，是基于两阶段提交实现的 工作机制原理图 执行逻辑\n开启事务：事务管理器（TM）开启一个全局事务，并于事务协调器（TC）建立连接，TC返回一个全局事务 ID（XID）给 TM 分支事务注册与执行：资源管理器（RM）收到业务请求操作后，会向 TC 注册分支事务（可能有多个RM，故称是分支事务），执行事务 SQL，并携带 XID 保证事务一致性 分支事务状态报告：RM 执行完分支事务后，向 TC 报告分支事务的执行状态 TC 决定事务提交或回滚策略：TM 等待 RM 中的所有的分支任务执行完成后，通知 TC 事务结束。TC 接收通知后会检查各个分支事务的执行状态，如果所有的分支事务都成功，则通知所有 RM 提交所有事务；若有其中一个分支事务失败，则通知所有的 RM 回滚事务 RM 接收 TC 通知并执行分支事务提交或回滚：RM 接收 TC 指令后执行对应的 commit 或 rollback 操作 优缺点 优点\n事务强一致性：XA 模式能满足 ACID 原则，确保分布式事务的强一致性 实现简单且无代码侵入：常用数据库都支持 XA 协议，使用 Seata 的 XA 模式无需修改业务代码，只需简单配置即可 缺点\n依赖关系型数据库、驱动、连接池对 XA 的支持 性能较差：因为是基于两阶段提交，故也保留了两阶段的缺点：一阶段需要锁定资源，等待二阶段结束才释放——资源锁持有时间长 AT 模式 AT 也是基于二阶段提交实现的，但它不会像 XA 一样把数据库事务一直保持到第二阶段。一阶段会提交业务数据和 undo_log，先释放数据库连接与本地锁；全局事务是否结束，则继续由 TC 和全局锁控制。\n优缺点 优点\n适合支持本地 ACID 事务且被 Seata 数据源代理支持的关系型数据库 无代码侵入 一阶段本地事务先提交，持有资源时间短，不会像 XA 那样长时间持有数据库事务，性能开销小 需要注意\n依赖关系型数据库的本地 ACID 事务，并通过数据源代理解析 SQL 全局锁发生冲突时会重试，冲突频繁会增加等待和回滚成本 二阶段回滚前需要校验 After Image，数据被其他操作改动时不能直接覆盖 undo_log undo_log 可以理解成 Seata 给数据修改准备的“后悔药”。它记录业务 SQL 修改前后的数据状态，方便全局失败时自动生成反向 SQL。\n当业务 SQL 修改数据时，Seata 会记录两份数据快照：\nBefore Image：SQL 执行前记录当前要修改的数据的快照——全局失败时用于回滚 After Image：SQL 执行后的快照——回滚前校验是否发生脏写 比如库存从 100 扣到 99：\nBefore Image：100 After Image：99 如果全局事务失败，就根据 Before Image 把库存恢复成 100 本地锁与全局锁 AT 会同时使用数据库本地锁和 Seata 全局锁，两者解决的问题不同。\n本地锁： 数据库执行 UPDATE、DELETE 或 SELECT FOR UPDATE 时产生的行锁，只在当前数据库本地事务中生效。本地事务提交或回滚后，本地锁就会释放。\n全局锁： TC 维护的逻辑锁，通常根据数据源、表名和主键标识被修改的行。分支事务在一阶段提交前必须先获得全局锁；本地事务提交后，全局锁仍由当前全局事务持有，直到二阶段提交或回滚结束。\n所以一阶段提交后会出现这样的状态：\n数据库本地事务已经提交，本地锁和连接已经释放 业务数据对普通数据库查询已经可见 全局锁仍然存在，其他 AT 全局事务不能提交对同一行的修改 一阶段 AT 一阶段会在同一个本地事务中完成业务数据修改和回滚日志记录：\nRM 开启本地事务，业务 SQL 获取数据库本地锁。 数据源代理解析 SQL，确定表名、条件和要修改的数据。 查询修改前的数据，生成 Before Image。 执行业务 SQL，再根据主键查询修改后的数据，生成 After Image。 将 Before Image、After Image、XID、Branch ID 等信息写入 undo_log。 RM 向 TC 注册分支事务，并申请当前数据行的全局锁。 获得全局锁后，在同一个本地事务中提交业务数据和 undo_log，随后释放本地锁与数据库连接。 RM 向 TC 报告一阶段执行结果。 如果全局锁获取失败，当前分支不能提交本地事务。RM 会在超时时间内重试；一直无法获得时，本地事务回滚，同时释放本地锁。\n二阶段 全局提交：\nTM 通知 TC 提交全局事务，TC 将分支标记为提交并释放对应的全局锁。 RM 收到提交请求后可以快速返回，后续异步批量删除 undo_log。 这里不需要再次提交业务数据，因为业务数据在一阶段已经提交完成。\n全局回滚：\nRM 收到 TC 的回滚请求后开启新的本地事务，并尝试获取业务数据的本地锁。 根据 XID 和 Branch ID 查询对应的 undo_log。 将数据库当前值与 After Image 比较，检查一阶段之后数据是否又被修改。 数据一致时，根据 Before Image 和原业务 SQL 信息生成反向 SQL，恢复业务数据。 删除对应的 undo_log，提交本地回滚事务并向 TC 报告结果。 TC 确认分支回滚完成后释放全局锁。 如果当前数据与 After Image 不一致，说明发生了脏写或有其他操作修改了这行数据，此时不能直接用 Before Image 覆盖，需要按照异常处理策略处理。\n写隔离 写隔离依赖全局锁。两个全局事务修改同一行时，后提交本地事务的分支必须等待前一个全局事务释放全局锁。\n图中 tx1 一阶段提交后已经释放本地锁，所以 tx2 可以获得本地锁并更新同一行。但是 tx1 仍持有全局锁，tx2 在提交本地事务前无法获得全局锁，只能重试。tx1 二阶段提交并释放全局锁后，tx2 才能继续提交。\n如果 tx1 进入二阶段回滚，它还需要重新获取本地锁执行反向 SQL。此时若本地锁被等待全局锁的 tx2 持有，tx1 会等待；tx2 获取全局锁超时后回滚本地事务并释放本地锁，tx1 才能完成补偿回滚。\n读隔离 AT 一阶段会提前提交本地事务，因此普通 SELECT 不参与全局锁竞争，可能读到另一个全局事务尚未完成二阶段提交的数据。从全局事务范围看，默认只能达到读未提交。\n如果业务需要全局范围的读已提交，可以使用 SELECT FOR UPDATE。Seata 会代理这类查询并申请全局锁：当锁被 tx1 持有时，tx2 会释放本地锁并重试，直到 tx1 完成二阶段并释放全局锁后再读取数据。\n这里的读已提交是全局事务范围的隔离效果。数据库本身仍需要配置为读已提交或更高的本地隔离级别。\nAT = 一阶段提交业务数据和 undo_log，用全局锁控制并发写；二阶段提交时异步清理日志，失败时根据数据镜像自动补偿。\nTCC 模式 TCC = Try / Confirm / Cancel\n它和 AT 最大的区别是：AT 通过数据源代理、undo_log 和全局锁自动管理分支事务，TCC 则要求业务自己实现资源预留、确认和取消逻辑。它不依赖数据库提供特定的分布式事务能力，也可以管理缓存、消息、远程接口等业务资源。\n图中两个 Try 分支都成功时进入 Confirm，只要有一个 Try 失败，就进入 Cancel 释放已经预留的资源。Confirm 和 Cancel 是二选一的二阶段操作。\n三个阶段 阶段 核心任务 库存例子 Try 检查业务条件并预留资源 可用库存减 1，冻结库存加 1 Confirm 使用 Try 预留的资源完成业务 冻结库存减 1，正式完成扣减 Cancel 释放 Try 预留的资源 冻结库存减 1，可用库存加 1 Try 不能只检查资源而不做预留。比如 Try 只检查库存充足，却没有扣减可用库存，那么进入 Confirm 前库存还可能被其他请求用掉，Confirm 就无法保证成功。比较常见的设计是把资源从“可用”转成“冻结”，让后续 Confirm 只确认结果，Cancel 只释放资源。\n执行流程 一个全局事务包含多个 TCC 分支时，流程如下：\nTM 开启全局事务，获得 XID。 每个参与服务执行自己的 Try 方法，完成业务检查和资源预留，并向 TC 注册分支事务。 所有 Try 都成功后，TM 通知 TC 提交全局事务，TC 调用各分支的 Confirm。 只要有一个 Try 失败，TM 就通知 TC 回滚全局事务，TC 调用已经注册分支的 Cancel。 Confirm 或 Cancel 执行完成后，RM 向 TC 报告分支结果；执行失败时，TC 会按照策略继续重试。 Try、Confirm 和 Cancel 都是独立的本地事务，不会像 XA 一样长期占用数据库连接和行锁。Try 完成资源预留后就会提交本地事务，所以 TCC 的并发性能通常较好。\n业务接口 Seata 使用 @TwoPhaseBusinessAction 标记 Try 方法，并指定 Confirm 和 Cancel 方法。BusinessActionContext 会携带 XID、Branch ID、Action Name 以及 Try 阶段需要传递到二阶段的业务参数。\n@LocalTCC public interface InventoryTccAction { @TwoPhaseBusinessAction( name = \u0026#34;inventoryTccAction\u0026#34;, commitMethod = \u0026#34;confirm\u0026#34;, rollbackMethod = \u0026#34;cancel\u0026#34;, useTCCFence = true ) boolean tryFreeze( BusinessActionContext context, @BusinessActionContextParameter(paramName = \u0026#34;productId\u0026#34;) Long productId, @BusinessActionContextParameter(paramName = \u0026#34;count\u0026#34;) Integer count ); boolean confirm(BusinessActionContext context); boolean cancel(BusinessActionContext context); } @LocalTCC 用于本地 Bean 形式的 TCC 接口。远程服务也可以作为 TCC 资源，关键是三个阶段必须围绕同一份业务资源设计。\n设计要求 Try： 完成参数校验、资源检查和资源预留。Try 成功后，必须保证 Confirm 有条件完成。\nConfirm： 只处理 Try 已预留的资源，不再执行可能失败的业务检查。Confirm 需要支持重复调用。\nCancel： 释放 Try 预留的资源，并恢复到执行 Try 前可继续使用的状态。Cancel 同样需要支持重复调用。\nTCC 的三个常见问题 问题 含义 处理思路 幂等 网络超时或结果未确认时，Confirm、Cancel 可能被重复调用 根据 XID 和 Branch ID 记录分支状态，已经完成的操作直接返回成功 空回滚 Try 请求未到达或执行失败，但 TC 已经调用 Cancel Cancel 找不到 Try 记录时不修改业务资源，同时记录已回滚状态 悬挂 Cancel 先完成，迟到的 Try 请求之后又开始执行 Try 执行前检查分支是否已经回滚，已经回滚则拒绝执行 Seata 可以通过 TCC Fence 处理这些问题。开启 useTCCFence = true 后，框架会使用 tcc_fence_log 记录分支状态，并让 Fence 记录与业务操作处于同一个本地事务中。\n优缺点 优点\n不依赖底层数据库，可以管理跨数据库、跨应用的业务资源 Try 提交后不长期持有数据库锁，适合并发量和性能要求较高的核心链路 资源预留粒度由业务控制，可以根据场景进行优化 需要注意\n三个阶段都需要业务实现，代码侵入和设计成本较高 需要额外维护冻结资源、业务状态或中间表 Confirm、Cancel 必须考虑重试、幂等、空回滚和悬挂 TCC 适合库存、账户、优惠券这类可以明确预留资源的核心业务。若业务资源无法冻结，或者很难设计可靠的 Confirm 和 Cancel，通常不适合直接使用 TCC。\nSaga 模式 Saga 主要解决长流程事务。一个业务可能要依次调用订单、库存、支付、积分、物流和第三方系统，如果使用 XA 长时间持有资源，整个流程的性能和可用性压力会很大；有些参与者也无法提供 TCC 所需的三个接口。\nSaga 会把长事务拆成多个可以独立提交的本地事务。每个正向服务执行成功后立即提交，不持有全局锁；后续步骤失败时，再按相反顺序调用已经成功步骤对应的补偿服务。\n正向动作 补偿动作 创建订单 取消订单 扣减库存 释放库存 发放积分 撤销积分 创建物流单 取消物流单 比如依次执行“创建订单 -\u0026gt; 扣减库存 -\u0026gt; 扣减余额”，如果扣减余额失败，状态机不会撤销一个数据库事务，而是先补偿库存，再取消订单，让业务最终回到可接受的状态。\n状态机 Seata 的 Saga 模式基于状态机引擎实现。业务流程通过状态图定义，并生成 JSON 状态语言文件。\n状态机中的一个节点可以调用一个正向服务，也可以配置对应的补偿节点。除了普通服务调用，还支持条件分支、并行执行、子流程、参数映射、状态判断和异常捕获。\n执行流程如下：\n状态机启动全局事务并生成 XID，同时持久化状态机实例。 引擎根据状态图执行正向服务，每个服务在自己的本地事务中提交。 每个节点的开始、结束和执行状态都会写入状态日志，便于故障后恢复。 所有节点成功时，状态机正常结束。 某个节点失败时，引擎根据流程定义决定继续重试、向前恢复，或者触发补偿。 触发补偿后，引擎按照成功节点的相反顺序执行补偿服务，直到流程结束。 补偿 补偿是一个新的业务操作，不等于数据库回滚。比如“发放积分”的补偿可以是“扣回积分”，但如果积分已经被使用，补偿就可能失败。因此正向服务和补偿服务都需要保存业务状态，并明确哪些状态允许继续执行或补偿。\n补偿服务需要考虑：\n幂等： 正向服务和补偿服务都可能因为超时而重试，同一个业务键不能重复修改数据 空补偿： 正向请求未成功到达，但补偿请求先执行时，应记录补偿状态并返回成功 悬挂： 已经完成空补偿后，迟到的正向请求必须被拒绝 故障恢复 Saga 不一定只能向后补偿。对于某些流程，前面已经完成的动作很难撤销，或者继续完成后续步骤更合理，此时可以恢复状态机上下文并继续向前执行。 常见处理方向有两种：\n反向补偿： 按相反顺序撤销已经成功的步骤，适合补偿动作明确的流程 正向恢复： 重试失败节点并继续完成后续步骤，适合业务更希望最终成功的流程 状态机日志记录了节点执行结果，因此服务或状态机重启后，可以根据已有状态继续处理，不需要从头执行整个流程。\n隔离问题 Saga 的本地事务会立即提交，也没有全局锁，所以不能保证事务隔离。中间状态可能被其他请求读取或继续使用，之后即使触发补偿，也不一定能够完全恢复。\n比如先给账户增加余额，再执行后续步骤。如果余额在流程结束前已经被使用，后续失败时就无法直接扣回。因此设计 Saga 时需要限制中间资源的使用，或者调整执行顺序，让难以补偿、风险较高的操作尽量晚执行。\n优缺点\n优点\n每个步骤独立提交本地事务，不长期持有数据库锁，适合长流程 可以调用第三方系统或旧系统，不要求参与者提供 TCC 三阶段接口 状态机支持异步、并行、条件分支和故障恢复，适合复杂服务编排 需要注意\n不保证隔离，中间状态可能被其他业务读取和修改 每个正向服务需要设计对应的补偿服务，补偿本身也可能失败 业务状态、幂等、重试和人工处理机制需要提前设计 Saga 适合流程长、参与系统多、允许最终一致的业务。对于金额、库存等不能暴露中间状态，且补偿失败影响较大的场景，需要先评估隔离和补偿是否可以可靠实现。\n四种模式怎么选 选择 Seata 模式时需按业务约束往下判断：是否必须强一致，是否只是关系型数据库短事务，能不能在业务上预留资源，失败后能不能补偿。判断有先后关系：越靠前，越偏数据库事务能力；越靠后，越依赖业务自己设计预留、确认、撤销和补偿。\n模式 优先选择条件 代价 不适合的场景 XA 所有分支资源支持 XA，业务要求强一致和较高隔离 分支事务和数据库锁会持续到二阶段 长事务、高并发热点资源 AT 关系型数据库、本地 ACID 事务、SQL 可被 Seata 代理 依赖 undo_log、全局锁和 SQL 解析，复杂 SQL 要谨慎 非关系型资源、外部系统、无法代理的 SQL TCC 资源可以预留，业务能实现 Try、Confirm、Cancel 需要处理幂等、空回滚、悬挂，接口改造成本高 资源无法冻结，或业务方不愿维护三阶段接口 Saga 流程长，参与系统多，允许通过补偿达到最终一致 不保证隔离，补偿服务本身也要考虑失败后的再次执行 金额、库存等不能暴露中间状态的核心资源 选择顺序 强一致和隔离优先： 所有参与资源都支持 XA，并且可以接受较长的锁持有时间时，考虑 XA。 普通数据库短事务： 主要是关系型数据库增删改查，希望减少业务改造时，优先考虑 AT。 资源可以预留： 并发压力比较高，又能设计冻结资源和三阶段接口时，考虑 TCC。 长流程和外部系统： 事务持续时间长，参与者无法统一接入 AT 或 TCC，并且业务允许补偿时，考虑 Saga。 如果一个流程既不能接受长时间锁定，又无法自动回滚、预留资源或设计可靠补偿，说明当前事务范围过大，需要先划分清楚业务边界。常见做法是把大事务拆成更小的本地事务，再通过消息、事件表、人工处理或对账流程处理后续状态～\n部署方式 Seata 在 Spring Cloud Alibaba 项目中，通常会和注册中心、配置中心、远程调用一起出现。\n典型接入步骤：\n启动 Seata Server，也就是 TC 业务服务引入 Seata 依赖 配置事务分组、注册中心和配置中心 在每个业务库里创建 undo_log 表 在入口业务方法上使用 @GlobalTransactional 示例：\n@GlobalTransactional public void createOrder(OrderDTO orderDTO) { orderService.saveOrder(orderDTO); stockClient.deduct(orderDTO.getProductId(), orderDTO.getCount()); accountClient.deduct(orderDTO.getUserId(), orderDTO.getMoney()); } 由于文档更新较勤，部署步骤会有些差异，最好阅读官方文档进行部署 👉 https://seata.apache.org/zh-cn/docs/user/quickstart ","permalink":"https://Jul1en-Lin.github.io/p/seata/","title":"Seata"},{"content":"这篇主要整理 Redis 缓存的数据更新方式、淘汰策略和常见异常场景。\n数据变更后的处理 当 MySQL 发生新增、修改、删除时，缓存也要跟着处理。常见思路是：\n先修改 MySQL。 删除 Redis 中对应的 key。 下次查询时再按实时生成流程写回 Redis。 第二步选择“删除缓存”，不直接修改缓存里的值。原因是缓存里的数据可能不是数据库单表字段，可能经过了关联查询、计算、排序、过滤，直接修改缓存值容易遗漏逻辑。\n如果倒过来，先删缓存再改 MySQL，在单一线程下一般没有问题；在并发情况下，可能出现这种顺序：\n线程 A 删除缓存。 线程 B 查询缓存未命中，读取旧 MySQL 数据，并写入 Redis。 线程 A 修改 MySQL。 这会导致 Redis 里留下的还是旧数据，后续处理起来会比较麻烦。\n缓存更新策略 缓存更新本质上是在处理两个问题：\nRedis 里应该提前放哪些数据。 MySQL 数据变化后，Redis 里的旧数据怎么办。 定期生成 定期生成就是按固定周期把一批数据写入 Redis。比如每隔一段时间，通过 shell 或 Python 脚本从 MySQL 查出热门数据，再写入缓存。\n适合场景：\n数据变化频率不高。 查询量比较大。 允许短时间读到旧数据。 它的好处是读请求来了以后可以直接查 Redis，请求链路比较短。问题是数据会有延迟：如果 MySQL 已经变化，但下一轮缓存生成还没执行，用户可能读到旧数据。\n实时生成 实时生成也就是常见的 Cache Aside 读流程：\n请求查询 key，先访问 Redis。 Redis 命中则直接返回；未命中则查询 MySQL。 MySQL 查询到数据，返回并更新 Redis；未查询到则直接返回。 这种方式不会一开始就把所有数据放进 Redis，而是用户访问到哪个 key，就把哪个 key 放进去。它适合数据量比较大、热点数据会自然出现的场景。\n经过一段时间的动态平衡，Redis 中存储的数据会逐渐成为热点数据。随着时间推移，数据越写越多，可能逐渐达到 Redis 的内存上限。旧的热点 key 访问变少，新的热点 key 又无法插入，此时就需要引入内存淘汰策略。\n注意事项 内存淘汰策略 如果 Redis 内存达到上限，就会触发淘汰策略。这里不一定是物理内存，也可能是 Redis 配置中规定的最大内存容量。\n常见策略有以下几种：\nFIFO（First In First Out）：先进先出，删除缓存中存在时间最久的 key。 LRU（Least Recently Used）：淘汰最久未使用的 key，根据最近一次访问时间选择。 LFU（Least Frequently Used）：淘汰访问次数最少的 key，根据最近一段时间的访问频率选择。 Random：随机淘汰 key。 缓存预热 Redis 服务器首次接入时没有任何数据，所有请求都会先访问 MySQL，再逐渐写入 Redis，数据库的压力只会慢慢下降。\n缓存预热就是为了解决这个问题：它将定期生成和实时生成结合，通过离线统计先找出一批热点数据，预先导入 Redis，提前帮数据库承担一部分访问压力。随着后续访问变化，再逐渐使用新的热点数据淘汰旧数据。\n缓存穿透（Cache Penetration） 请求查询一个不存在的数据，Redis 没有，MySQL 也没有。每次请求都会打到 MySQL，缓存没有起到保护作用。\n可能原因：\n业务设计不合理，缺少必要的参数校验。 开发或运维误操作，将部分数据从数据库中删除。 恶意攻击。 常见处理：\n缓存空值：MySQL 查询不到时，把空结果也写入 Redis，并设置较短的过期时间。 布隆过滤器：先判断 key 是否可能存在，不可能存在就直接拦截。 参数校验：明显非法的 id、页码、业务参数，不进入缓存和数据库查询流程。 缓存空值时要注意过期时间不能太长，否则真实数据后来写入 MySQL 后，Redis 里仍然保留空结果，用户会继续查询不到。\n缓存雪崩（Cache Avalanche） 短时间内大量缓存 key 同时失效，或者 Redis 整体不可用，导致缓存命中率大幅下降。请求集中访问 MySQL，数据库压力迅速上升，甚至直接宕机。\n典型场景：\nRedis 突然不可用，或者集群模式下大量节点宕机。 某个时间点大量 key 同时过期。 处理思路：\n给过期时间增加随机值，避免大量 key 同时失效。 对特别核心的数据做缓存预热。 对数据库访问进行限流，避免请求把 MySQL 打满。 缓存雪崩是“大面积缓存同时失效”的问题。沿着这个问题继续看，还会出现一个更具体的场景：不是很多 key 同时失效，而是针对热点 key 失效，大量请求都冲向同一份数据，这就是缓存击穿。\n缓存击穿（Cache Breakdown） 缓存击穿可以看作缓存雪崩的特殊情况。热点 key 突然失效时，大量并发请求同时发现 Redis 未命中，随后集中访问 MySQL。\n它和缓存雪崩的区别：\n缓存雪崩：大量 key 同时失效，影响面大。 缓存击穿：一个热点 key 失效，但访问量非常大。 常见处理：\n热点 key 设置更长的过期时间，或者使用逻辑过期控制刷新。 进行服务降级。 访问数据库时使用分布式锁，限制查询数据库的并发数。 ","permalink":"https://Jul1en-Lin.github.io/p/redis-%E7%BC%93%E5%AD%98/","title":"Redis 缓存"},{"content":"视频来源：Every Type Of API Explained in 18 Minutes. 这篇笔记主要将 API 类型到底是怎么联系起来，它们分别是在不同通信需求下长出来的方案：有的解决“怎么请求数据”，有的解决“服务之间怎么快一点”，有的解决“不要一直轮询”，还有的解决“事件太多以后怎么管理”。\n先放一张关系图，后面再逐个展开。\n整体脉络 最早我们只是想让客户端和服务端通信：页面要用户信息，服务端返回用户信息。这时 REST 就够用了，它把数据抽象成资源，用 GET、POST、PUT、DELETE 这些 HTTP 方法去操作。企业系统里，光“能通信”还不够。银行、保险、CRM 这类系统更在意契约、事务、安全和错误处理，所以 SOAP 这种更正式、更重的协议就有存在空间。\n后面系统拆成微服务以后，问题又变了。服务之间要互相调用，而且调用次数很多、延迟敏感，这时 RPC / gRPC 这条线就出现了。它们不再把重点放在“资源 URL”，而是更像“调用远程函数”。\n再往前端看，移动端和复杂页面经常只需要一小部分字段。REST 有时会返回太多数据，有时又要请求好几次，GraphQL 就是为这种“我只要这些字段”的需求出现的。\n接着是实时场景。传统 HTTP 是客户端主动问服务端，适合普通请求，但不适合“有变化马上告诉我”。所以 Webhook、SSE、WebSocket 开始出现：Webhook 适合第三方系统回调你，SSE 适合服务端单向推送，WebSocket 适合双方都要实时说话。如果实时内容从文字消息变成音视频，就轮到 WebRTC。它解决的是浏览器和客户端之间的实时音视频、屏幕共享、点对点通信。\n再往后，系统越来越多，很多业务不适合同步等待。订单创建后，库存、通知、风控、数据分析都要动起来，但订单服务不应该一个个等它们处理完。这就是 Event-Driven API、MQTT、AMQP、Kafka 这条线：用消息和事件把系统解耦。\n最后还有两个偏“规范 / 连接层”的东西。AsyncAPI 不是一个运行时协议，它更像异步系统里的 OpenAPI，用来说明有哪些事件、谁生产、谁消费。MCP 则是 AI 时代出现的新接口规范，解决模型接外部工具和数据源时重复集成的问题。\n请求响应：REST 和 SOAP REST API REST 可以理解成最常见的 Web API 形态。客户端发一个 HTTP 请求，服务端返回一份数据。视频里用了“餐厅服务员”的类比：你告诉服务员要什么，服务员去厨房拿，再把结果带回来。对应到系统里，就是客户端请求服务端，服务端返回 JSON。\nREST 的核心是资源。比如用户是资源，订单是资源，文章也是资源。这样做的好处是简单、通用、跨平台。Web 前端、移动端、小程序、服务端脚本，基本都能很容易调用 REST API。\nGET /users/1 POST /orders PUT /users/1 DELETE /items/9 实际公司场景里，GitHub REST API 就是非常典型的例子。你可以通过 REST API 获取仓库、Issue、Pull Request、用户信息，也可以做自动化脚本和第三方集成。\n缺点：REST 适合普通请求响应，但它不是所有场景的答案。如果页面一次要聚合很多资源，一次调用可能不能返回全部所需要的，会请求很多次；如果服务之间要高频调用，JSON + HTTP 的开销也可能偏大；如果需要实时推送，REST 本身也不擅长。\nSOAP API SOAP 是更早、更正式的一套通信协议。它用 XML 包一层固定结构，里面会有 envelope、header、body 这些部分。视频里把 REST 比成比较随意的电话沟通，把 SOAP 比成正式合同，这个类比挺准确。它更稳定、规范、强契约。它可以配合 WSDL 描述服务能力，也有一整套错误处理、安全、事务相关的规范。\n所以 SOAP 现在看起来没那么“新”，但在企业系统里仍然很常见。比如 Salesforce SOAP API 主要用于企业系统和 Salesforce 之间做数据集成，Microsoft Exchange Web Services 这类历史系统也长期走 SOAP / XML 这一类接口风格。\n这里可以这么记：REST 更适合互联网应用的通用接口，SOAP 更适合企业系统里需要严格契约的集成。\n服务间调用：RPC 和 gRPC RPC RPC 的意思是 Remote Procedure Call，也就是远程过程调用。它想解决的问题很直接：服务拆开以后，我不想每次都手写 HTTP 请求、解析 JSON、处理各种细节，我希望像调用本地函数一样调用远程服务。\n比如订单服务要校验用户是否登录，如果用 RPC 的思路，就可以像这样理解：\nauthService.verifyUser(userId) 实际执行时这个函数不在本地，它会通过网络请求到认证服务。现实里，RPC 有很多形态。比如以太坊生态里很多节点都暴露 JSON-RPC API ，钱包、区块浏览器、脚本工具会通过它查询区块、交易、账户状态。Cloudflare Workers 也有 Workers RPC 这种更接近方法调用的接口模型。\nRPC 的好处是调用表达简单，适合内部服务。需要注意的是，远程调用毕竟还是网络调用，不能真的当成本地函数一样随便调用。网络延迟、超时、重试、幂等，这些都要考虑。\ngRPC API gRPC 可以看成现代版本的 RPC。它用 Protocol Buffers 定义数据结构，用 HTTP/2 做传输，所以比传统 JSON + HTTP 更适合高频服务间通信。\n它解决的需求主要有几个：\n服务拆多以后，内部调用很多，希望序列化和传输更快 多语言服务之间需要统一接口定义 有些接口不只是请求响应，还需要服务端流、客户端流、双向流 这也是 gRPC 和普通 REST 很不一样的地方。REST 更像“我请求一个资源，你返回结果”，gRPC 更像“我调用你的某个能力，而且这个调用可以是流式的”。公开资料里，gRPC 在大型微服务系统里很常见。比如 Uber 的实时 Push 平台 就把协议演进到基于 gRPC 的双向流，CNCF 也有 Netflix 使用 gRPC 的案例 。\n如果只是普通前后端接口，REST 更容易调试和接入。如果是内部微服务、高吞吐、低延迟、跨语言调用，gRPC 会更适合。\n前端取数：GraphQL GraphQL API GraphQL 解决的是 REST 里的一个常见问题：要么拿多了，要么拿少了。\n比如一个用户页只想显示用户名和邮箱，但 REST 接口可能直接返回完整用户资料，这就是 over-fetching。反过来，如果页面还要订单、评论、收藏，又可能要连续请求好几个接口，这就是 under-fetching。GraphQL 的思路是让客户端声明自己需要什么字段： 例如：\nquery { user(id: \u0026#34;1\u0026#34;) { name email orders { total } } } 服务端按这个查询返回刚好需要的数据。这样前端灵活度会高很多，尤其适合移动端、多端应用、复杂后台页面。\n实时通知：Webhook、SSE、WebSocket Webhook API Webhook 的出现，是因为轮询太浪费。\n传统 API 是一直问服务端发送类似心跳包的询问。Webhook 的思路是反过来：你先给对方一个 callback URL，等事件发生时，对方主动发 HTTP 请求通知你。所以 Webhook 有时候也被叫 reverse API。不需要你时刻监控数据，数据变化后会主动来找你。比如你的 github 上的 push、pull request、issue 等事件发生时都会通知到你的邮箱里～\nWebhook 适合“事件发生后通知我”，但它不是持续连接。对方请求你一次，你处理一次。\nSSE SSE 是 Server-Sent Events，适合服务端持续往浏览器推消息。\n它的模型很简单：浏览器发起一个 HTTP 请求，服务端不立刻关闭连接，而是有新数据就顺着这个连接继续往下发。它主要是单向的，也就是服务端到客户端。\n所以 SSE 很适合这些场景：\n任务进度 日志流 通知流 股票价格、比分、状态更新 AI 回复的流式输出 实际应用里，OpenAI API 的 streaming 响应就使用了 SSE 这种形式，适合一边生成一边返回内容，这里可以和 流式传输 放在一起看。它比 WebSocket 简单，因为底层仍然是普通 HTTP，浏览器里也有 EventSource 这样的原生能力。SSE 主要适合服务端单方面推送客户端。如果双方都要高频互发消息，就要考虑 WebSocket。\nWebSocket API WebSocket 解决的是双向实时通信。它先通过 HTTP 握手，然后升级成 WebSocket 连接。连接建立后，客户端和服务端都可以主动发消息。\n这和 SSE 的区别很关键：SSE 更像服务端一直往你这里播报，WebSocket 更像双方开了一条实时电话线。Discord Gateway 就是 WebSocket API，客户端通过它接收实时事件、心跳、状态变化。\nWebSocket 的优势就是适合聊天、协作编辑、在线游戏、行情系统。代价是连接状态要维护，心跳、断线重连、扩容、负载均衡都比普通 HTTP 更复杂。\n实时音视频：WebRTC WebRTC API WebRTC 面向音频、视频、屏幕共享，甚至点对点传文件，是一套浏览器和客户端实时通信能力。它可以处理音视频编解码、网络穿透、带宽自适应等问题。实际连接前通常还需要信令服务帮两端交换连接信息。\n这里可以把 WebRTC 和 WebSocket 区分开：WebSocket 更适合传结构化消息，WebRTC 更适合实时媒体流。很多系统会一起用它们，比如用 WebSocket 做信令，再用 WebRTC 传音视频。\nAI 接工具：MCP MCP Server MCP 是 Model Context Protocol，模型上下文协议是 AI 应用这两年才开始大量出现的一类接口规范。AI 助手想访问文件、数据库、代码仓库、日历、搜索、内部系统时，如果每个工具都单独写一套集成，成本会很高。MCP 的思路是给模型和外部工具之间加一层统一协议。只要工具提供 MCP Server，支持 MCP 的客户端就能用比较统一的方式接入。\n你可以类比成 AI 工具世界里的通用连接头，比如一个 coding assistant 通过 MCP 读取项目文件、查 Git 历史、访问文档或数据库，就不需要每个能力都重新做一套私有集成。Model Context Protocol 官方文档 把 MCP 定位为连接 LLM 应用和外部数据源、工具的开放协议；OpenAI Apps SDK 也支持围绕 MCP server 描述工具能力。\nMCP 和前面那些 API 不完全在同一层。REST、GraphQL、gRPC 更像服务接口；MCP 更像 AI 应用里的连接协议。它可以把 REST API、数据库、文件系统这些能力包装成模型可调用的工具。\n消息与事件：MQTT、AMQP、Event-Driven API、Kafka MQTT MQTT 是一个非常轻量的协议，很多 IoT 设备带宽小、电量有限、网络也不稳定，MQTT 协议就非常合适，在智能家居、传感器、工业设备、车联网中都很常见。\n它采用发布订阅模型。设备把消息发到某个 topic，订阅这个 topic 的客户端就能收到消息，像一个大广播一样把消息给传出去。比如温度传感器发布数据，手机 App、仪表盘、自动化系统都可以收到它的信息。\nAMQP AMQP 更偏可靠消息队列，注重消息不能丢、要能确认、要能路由、要能持久化，如果出现错误也会有错误的处理机制。如果一个支付消息、订单消息、医疗系统消息丢了，后果就比较严重。AMQP 这类协议会提供 acknowledgement、持久化、事务、路由等能力。RabbitMQ 是 AMQP 生态里很典型的实现。\nEvent-Driven API Event-Driven API 更像一种架构思路，不是某一个具体协议。\n传统同步调用里，订单服务可能要依次调用库存服务、邮件服务、物流服务、分析服务。如果其中一个服务挂了，整个链路就容易被拖住。事件驱动的做法是：订单服务只发布一个 OrderPlaced 事件。库存服务监听这个事件去扣库存，邮件服务监听它去发确认邮件，分析服务监听它去记录数据。每个服务做自己的事，不需要订单服务挨个等结果。\n这个模式的好处是解耦，后面新增一个短信通知服务，只要监听订单事件就行。\nApache Kafka Kafka 可以理解成事件驱动架构扩展到大规模之后的基础设施。普通消息队列通常是消息被消费后就删除，而 Kafka 更像一条可持久化、可回放的事件日志。最早就是 LinkedIn 为了处理大规模站内事件流做出来的，后来进入 Apache 生态。视频里举了 Netflix、Uber、LinkedIn 这类例子，本质都差不多：用户行为、播放、司机位置、消息、连接关系，这些事件量非常大，而且后续很多系统都要消费。\n普通异步任务RabbitMQ / AMQP 可能就够了。如果是海量事件、日志、埋点、实时数据管道，Kafka 更合适。\nAsyncAPI 当系统里只有几个 HTTP 接口时，用 OpenAPI / Swagger 就能把接口列清楚。但如果公司里有几十个服务通过 Kafka、MQTT、AMQP、WebSocket 通信，问题就变成：谁会发布什么事件？事件字段是什么？谁会消费？版本怎么演进？\nAsyncAPI 就是给这类异步接口写规范，它会描述 channel、message、schema、producer、consumer 等信息。这样新同事接手时不用到处翻代码，也能知道系统里有哪些事件。\nAsyncAPI 官方文档 里把它定位为描述事件驱动和消息 API 的规范，案例页 也能看到它主要服务的是异步系统文档化、工具生成和团队协作。\n这里可以这么理解：Kafka / MQTT / AMQP 是通信方式，AsyncAPI 是说明书。\n简单选择 需求 可以优先考虑 普通前后端请求、CRUD、公开接口 REST 企业系统强契约、XML、事务和安全规范 SOAP 内部服务像调用函数一样互相调用 RPC 微服务高性能、跨语言、流式调用 gRPC 前端只想拿需要的字段 GraphQL 第三方系统事件发生后通知你 Webhook 服务端持续向浏览器推送 SSE 客户端和服务端双向实时通信 WebSocket 音视频、屏幕共享、P2P 通信 WebRTC IoT 设备、低带宽、小消息 MQTT 可靠消息队列、企业业务消息 AMQP 服务之间通过事件解耦 Event-Driven API 大规模事件流、日志、实时数据管道 Kafka AI 模型接工具和数据源 MCP 异步 API 的文档和契约 AsyncAPI 资料来源 GitHub REST API GitHub GraphQL API GitHub Webhooks Stripe Webhooks Slack Incoming Webhooks Discord Gateway Discord Webhooks Binance WebSocket Streams Salesforce SOAP API Shopify Admin GraphQL API Ethereum JSON-RPC API Cloudflare Workers RPC WebRTC OpenAI Streaming Responses Model Context Protocol OpenAI Apps SDK Uber Next Gen Push Platform on gRPC CNCF Netflix gRPC case study AWS IoT Core MQTT Azure Service Bus AMQP Amazon EventBridge Apache Kafka Introduction AsyncAPI Docs ","permalink":"https://Jul1en-Lin.github.io/p/api-%E7%B1%BB%E5%9E%8B%E6%80%BB%E7%BB%93/","title":"API 类型总结"},{"content":"这次用 Docker 部署 11 个 Redis 节点，九个用于部署集群，两个用于扩容练习，集群的结构类似于如图，由于 Redis 部署集群时内部的随机性，可能节点的静态 ip 所属的角色都不太一样，无伤大雅～ Step 1：创建目录和配置 创建 redis-cluster 目录，在目录内部创建 docker-compose.yml 和 generate.sh 文件，对应命令为\nmkdir redis-cluster cd redis-cluster vim docker-compose.yml vim generate.sh 文件结构为\nredis-cluster/ ├── docker-compose.yml └── generate.sh Step 2：编写脚本 脚本 generate.sh 里内容为\nfor port in $(seq 1 9); \\ do \\ mkdir -p redis${port}/ touch redis${port}/redis.conf cat \u0026lt;\u0026lt; EOF \u0026gt; redis${port}/redis.conf port 6379 bind 0.0.0.0 protected-mode no appendonly yes cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 cluster-announce-ip 172.30.0.10${port} cluster-announce-port 6379 cluster-announce-bus-port 16379 EOF done # 注意 cluster-announce-ip 的值有变化. for port in $(seq 10 11); \\ do \\ mkdir -p redis${port}/ touch redis${port}/redis.conf cat \u0026lt;\u0026lt; EOF \u0026gt; redis${port}/redis.conf port 6379 bind 0.0.0.0 protected-mode no appendonly yes cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 cluster-announce-ip 172.30.0.1${port} cluster-announce-port 6379 cluster-announce-bus-port 16379 EOF done 保存后就能执行脚本啦，这个脚本的意思是在当前目录 redis-cluster 中建立从 redis 1～redis 11 的文件夹，每个中都有对应节点的必要配置文件。 ⚠️注意：如果你服务器上有其他的网段占用，则需要修改当前脚本与后续的 YAML 文件中的网段配置，只要不冲突即可。\nsh generate.sh 执行完后如果没有任何反应那就是执行成功了，我们可以看看 Step 3：配置 YAML 我们接着来配置 docker-compose.yml 文件，内容有点长\nversion: \u0026#39;3.7\u0026#39; networks: mynet: ipam: config: - subnet: 172.30.0.0/24 services: redis1: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis1 restart: always volumes: - ./redis1/:/etc/redis/ ports: - 6371:6379 - 16371:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.101 redis2: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis2 restart: always volumes: - ./redis2/:/etc/redis/ ports: - 6372:6379 - 16372:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.102 redis3: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis3 restart: always volumes: - ./redis3/:/etc/redis/ ports: - 6373:6379 - 16373:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.103 redis4: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis4 restart: always volumes: - ./redis4/:/etc/redis/ ports: - 6374:6379 - 16374:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.104 redis5: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis5 restart: always volumes: - ./redis5/:/etc/redis/ ports: - 6375:6379 - 16375:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.105 redis6: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis6 restart: always volumes: - ./redis6/:/etc/redis/ ports: - 6376:6379 - 16376:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.106 redis7: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis7 restart: always volumes: - ./redis7/:/etc/redis/ ports: - 6377:6379 - 16377:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.107 redis8: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis8 restart: always volumes: - ./redis8/:/etc/redis/ ports: - 6378:6379 - 16378:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.108 redis9: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis9 restart: always volumes: - ./redis9/:/etc/redis/ ports: - 6379:6379 - 16379:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.109 redis10: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis10 restart: always volumes: - ./redis10/:/etc/redis/ ports: - 6380:6379 - 16380:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.110 redis11: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis11 restart: always volumes: - ./redis11/:/etc/redis/ ports: - 6381:6379 - 16381:16379 command: redis-server /etc/redis/redis.conf networks: mynet: ipv4_address: 172.30.0.111 如果没问题，就执行 sudo docker-compose up -d，启动容器并后台运行。\nStep 4：配置集群 还有一个步骤，就是让各自的节点都能“认识对方”，目前只是配置了各个节点的配置，还没有配置它们之间的联系，需要用到一行命令，手动的将各个主机都联系起来。 这个命令直接在 cluster 路径下直接执行即可，无需进入 Redis 服务。\nredis-cli --cluster create 172.30.0.101:6379 172.30.0.102:6379 172.30.0.103:6379 172.30.0.104:6379 172.30.0.105:6379 172.30.0.106:6379 172.30.0.107:6379 172.30.0.108:6379 172.30.0.109:6379 --cluster-replicas 2 执行后 redis 会默认帮你弄好集群的分片配置，这里都描述了每个分片的 slot 数量，主从节点的分配等。确认无误的话就能输入 yes 下一步真正配置咯 到这里 Redis 的分片集群就真正配置好了，我们还剩两台 Redis 服务器未用上。这是用于扩容的，继续。\n","permalink":"https://Jul1en-Lin.github.io/p/docker-%E9%83%A8%E7%BD%B2-redis-%E9%9B%86%E7%BE%A4/","title":"Docker 部署 Redis 集群"},{"content":" 概述 其实集群的概念很广泛。广义上，只要多个机器共同组成一个分布式系统，都可以叫集群。之前的主从复制、哨兵模式，也可以看成广义集群。Redis 这里说的集群，一般指 Redis Cluster。它主要解决的是存储空间不足的问题：把数据拆到多台机器上，每台机器只保存一部分数据。\n哨兵模式解决的是可用性问题，但本质上还是主从节点都保存完整数据。只要数据全集超过单机能承受的内存，就需要引入分片。\n分片 分片可以理解成把数据一片一片分开。分片就是把数据拆成多份，分别放到不同节点上。 所以每一台机器上面的数据都是不一样的。 比如有 1TB 数据，可以这样拆：\n2 台机器，每台大约存 512GB。 3 台机器，每台大约存 300 多 GB。 4 台机器，每台大约存 256GB。 机器越多，每台机器承担的数据越少。但是实际部署时，每个存数据的主节点通常还要配从节点，用来提高可用性。所以扩容的时候并不是只需要一台，可能是需要很多台机器。对于数据存放的思路，有以下这几种算法可以实现。\n哈希求余 哈希求余的思路是：\nhash(key) % N N 是分片数量。Redis 的数据都是 key-value 结构，所以可以先对 key 做 hash，再对分片数量求余，得到应该放到哪个分片。查询时也一样：key 不变，hash 函数不变，算出来的分片编号也不变。\n这种方式简单，但是扩容很麻烦。因为一旦分片数量 N 改了，hash(key) % N 的结果会整体变化，很多数据都要重新搬运。\n一致性哈希 一致性哈希把 hash 空间看成一个环，比如范围是 0 ~ 2^32 - 1。分片也落在这个环上，key 计算 hash 后，按环上的位置往顺时针跑，找到第一个分片的分割点，那就属于是当前的分片。\n就像图里的，原本只有分片 012，若新增了分片 3，则扩容时不用让所有 key 重新分布，只需要搬动新节点影响到的一段数据。图中的 keyC 原本是属于分片 0 的，现在顺时针跑遇到的第一个分片是 3，那么它就属于新分片 3 的范围咯～\n优点：\n比哈希求余的数据搬运量小 扩容成本更低 但有个问题是如果节点分布不好，分片上的数据量可能不均匀，专业数据叫做数据倾斜～\nRedis 哈希槽 Redis Cluster 真正采用的是哈希槽方案，将哈希求余➕一致性哈希，结合起来了。\nslot = CRC16(key) % 16384 Redis Cluster 固定有 16384 个槽位，也就是 2^14 个。key 不是直接映射到分片，而是先映射到槽位，再由槽位映射到分片。\n流程可以理解为：key -\u0026gt; 槽位(slot) -\u0026gt; 分片\n扩容或迁移时，不需要按单个 key 去重新分配，而是迁移一部分槽位。只有这些槽位对应的数据需要搬运，所以比简单的哈希求余更适合扩容。\n例如 3 个分片时，可以大致这样分：\n0 号分片：[0, 5461] 1 号分片：[5462, 10923] 2 号分片：[10924, 16383] 如果扩成 4 个分片，也可以从原来的几个分片中各拿出一部分槽位给新分片。这样每个分片大约持有 4096 个槽位。\n槽位数量 Redis 使用 16384 个槽位，是一个折中的数量。可以简单的算一下它的大小的，看着挺多，其实没占多少空间\n16384 bit = 2048 byte = 2KB 如果槽位太少，每个分片能分到的槽位少，数据均衡性不容易保证，某些槽位可能有很多 key，某些槽位也可能没有 key。\n如果槽位太多，节点之间通信的元数据会变大。因为 Redis 节点之间需要通过心跳包通信，心跳包中包含了该节点持有哪些 slots，这个是使用位图这样的数据结构表示的。如果槽位数量变成 65536，位图就会变成 8KB，虽然看着也不大，但是在这种频繁发送心跳包的场景里，网络带宽负担还是比较明显。\nslots 通常会用位图表示：第 i 位为 1，表示这个节点持有第 i 个槽位；第 i 位为 0，表示不持有。注意这里表示的是节点是否持有槽位，不是这个槽位里有没有 key。\n集群部署 具体部署步骤单独整理到：Docker 部署 Redis 集群 。\n部署集群后，可以通过 docker-compose ps 命令来查看各个节点的状态 若想查看它们之间的分片主从关系，可通过 cluster nodes 查看，这个命令要在 redis-cli 中执行 Redis 分片后，每个分片的槽位都不同，如果在分片 1 中，写入的 key 刚好位于分片 2 的 slot，则会无法插入，如图（主节点与从节点同理） 如需要插入 key 1，则需要手动切换至 172.30.0.102:6379 这个节点，非常麻烦，有没有办法能够既可以插入，又可以无需手动切换 redis 节点呢？有的有的，只需要在进入 redis-cli 中加入 -c 命令，Redis 客户端就会根据当前 key 实际算出来的槽位号，自动找到匹配的分片主机，也就是能够在分片 1 节点上插入属于其他分片的 key 值。 这次就能帮你切换到应该的节点，成功。仅需输入一次 -c 即可\n故障转移 这里的故障转移与Redis 哨兵 的不太一样，主要的区别在于投票选出新的主节点上\n具体演示下，将 redis 1 主节点给手动停掉，命令 docker stop redis1 可以看到，101 主节点挂了后，原先 101 的从节点：105 被提拔为了新的主节点，101 原本的从节点 106 此刻也变成了 105 的从节点，那么我们再试试重启 101 节点，看看是什么变化（docker start redis1） 它也变成了 105 的从节点。 能看出集群也能够处理故障转移，而且这看起来跟哨兵的故障转移很像，那我们说的不同点在哪呢？不同在于过程～\n过程 故障判定 节点 A 给节点 B 发送 ping 包，B 就会给 A 返回一个 pong 包。ping 和 pong 除了 message type 属性之外，其他部分都是一样的。这里包含了集群的配置信息（该节点的 id，该节点从属于哪个分片，是主节点还是从节点，从属于谁，持有哪些 slots 的位图……） 每个节点，每秒钟，都会给一些随机的节点发起 ping 包，而不是全发一遍。这样设定是为了避免在节点很多的时候，心跳包也非常多（比如有 9 个节点，如果全发，就是 9 * 8 有 72 组心跳了，而且这是按照 N^2 这样的级别增长的）。 当节点 A 给节点 B 发起 ping 包，B 不能如期回应的时候，此时 A 就会尝试重置和 B 的 TCP 连接，看能否连接成功。如果仍然连接失败，A 就会把 B 设为 PFAIL 状态（相当于主观下线） A 判定 B 为 PFAIL 之后，会通过 Redis 内置的 Gossip 协议，和其他节点进行沟通，向其他节点确认 B 的状态。（每个节点都会维护自己的“下线列表”，由于视角不同，每个节点的下线列表也不一定相同）。 此时 A 发现其他很多节点，也认为 B 为 PFAIL，并且数目超过总集群个数的一半，那么 A 就会把 B 标记成 FAIL （相当于客观下线），并且把这个消息同步给其他节点（其他节点收到之后，也会把 B 标记成 FAIL）。 故障迁移 如果 B 是从节点，那么不需要进行故障迁移 如果 B 是主节点，那么就会由 B 的从节点（比如 C 和 D）触发故障迁移了\n从节点判定自己是否具有参选资格。如果从节点和主节点已经太久没通信（此时认为从节点的数据和主节点差异太大了，说明很久没有同步过数据），时间超过阈值，就失去竞选资格。 具有资格的节点，比如 C 和 D，就会先休眠一定时间。休眠时间 = 500 ms 基础时间 + [0, 500 ms] 随机时间 + 排名 * 1000 ms。（offset 越大，数据就越接近主节点，排名就要靠前，休眠时间就更短） 比如 C 的休眠时间到了，C 就会给其他所有集群中的节点，进行拉票操作。但是只有主节点才有投票资格（谁休眠时间短，大概率就是新的主节点了） 主节点就会把自己的票投给 C（每个主节点只有 1 票）。当 C 收到的票数超过主节点数目的一半，C 就会晋升成主节点（C 自己负责执行 slaveof no one，并且让 D 执行 slaveof C）。 同时，C 还会把自己成为主节点的消息，同步给其他集群的节点。大家也都会更新自己保存的集群结构信息。 更多的时候，是为了选一个节点出来，至于选谁，没那么重要。所以你看，不同点在于：哨兵，是先竞选出 leader，leader 负责找一个从节点升级成主节点，这里是直接投票选出新的主节点\n集群扩容 扩容主节点 这里将 redis-10 作为主节点，redis-11 作为从节点。\n# 添加 redis-10 的地址（172.30.0.110:6379）到集群中的任意一个节点地址（172.30.0.101:6379） redis-cli --cluster add-node 172.30.0.110:6379 172.30.0.101:6379 添加成功后 cluster nodes 查看 redis-10 已经作为主节点进入集群，但还未分配到 slots 重新分配 slots\n# reshard （重新切分）后的地址是集群中的任意节点地址 redis-cli --cluster reshard 172.30.0.101:6379 执行后会进行交互操作，具体有\n选择多少个 slots 分配到该节点（原先有 3 个分片，现扩容至 4 分片，16384 slots 平均到一个分片为 4096） 选择将 slots 转移到哪个节点上（此处填写节点的 id，如图） 这些 slots 从哪些节点搬运过来（all 表示每个主节点都搬一点，若选择 done 则需手动填写主节点 id） 确定之后，会初步打印出搬运方案，让用户确认，之后就会进行集群的 key 搬运工作，主节点就配置好了\n扩容从节点 仅需一行命令\n# 添加 redis-11（172.30.0.111:6379）到集群中（集群任意一个节点地址）作为从节点 （--cluster-slave），后跟上要跟随的主节点 id redis-cli --cluster add-node 172.30.0.111:6379 172.30.0.101:6379 --cluster-slave --cluster-master-id [172.30.0.110 节点的 nodeId（主节点 id）] 执行完毕后，从节点就已经被添加完成了，可见 redis-10 占了其他三个分片的部分 slots，redis-11 也成功成为了 redis-10 的从节点 总结 Redis Cluster 的核心是分片。主从复制和哨兵主要解决可用性，集群模式进一步解决单机存储容量限制。部署后可以通过 redis-cli -c 让客户端自动跟随槽位跳转；扩容时则先添加主节点，再通过 reshard 分配 slots，最后把新从节点挂到对应主节点下面。\n","permalink":"https://Jul1en-Lin.github.io/p/redis-%E9%9B%86%E7%BE%A4/","title":"Redis 集群"},{"content":"Redis 的主从复制 模式下，一旦主节点不能提供服务，就需要人工进行主从切换，这非常麻烦。后续如果要解决单机存储容量限制，需要继续看集群 。\n主从复制局限性 主从复制能够很好地平衡数据一致性问题，但是当遇到故障时，还是会遗留一些问题：\n主节点发生故障时，需要手动切换主节点，非常复杂。 主节点能够将读压力分担到从节点中，但是主节点仍然承担写压力，受到单机限制。 来看第一个问题，如果出现故障了，大致流程是：\n检查主节点是否健康，是否还能工作或抢救。 如果短时间不能排查问题，则需要手动挑一个从节点设置为新的主节点。 把选好的从节点通过 slaveof no one 升级成主节点。 再将原来的从节点通过 slaveof 主节点的 ip port 连上新的主节点。 修改客户端配置，让客户端能够顺利连接到另一个主节点。 这步骤看着都头疼\u0026hellip;于是 Redis 就引入了哨兵来解决这个问题。通常哨兵也不会只设置一个，而会部署一个哨兵集群，防止哨兵挂了或单个哨兵误判。\n实现原理 正常运行状态时，哨兵集群中的每个节点都是单独的 redis-sentinel 进程，会监控现有的 redis-master 和 slave。它们的监控是通过 TCP 长连接定期发送心跳包。\n不过，一个哨兵节点发现主节点挂了还不够，需要多个哨兵节点共同认同这件事，才会进行接下来的操作，主要是为了防止误判。\n当多数哨兵都认为主节点挂了，就会接着往下操作：\n哨兵节点中会先挑选出一个 leader，由这个 leader 负责从现有从节点中挑选一个作为新的主节点。leader 会通过投票方式选出，每个哨兵都有一票，票数多的成为 leader。 挑选出从节点后，哨兵会控制这个节点执行 slaveof no one，并控制其他节点修改 slaveof 到新的主节点上。 然后自动通知客户端。 Docker 部署 这里使用 Docker 做多个 Redis 的部署练习，结构是一个主节点、两个从节点、一个哨兵集群（三个 sentinel）。\n主从节点配置 在云服务器上运行 Docker，首先要安装 Docker：\napt install docker-compose 如果本身有 Redis 进程，可以先停掉之前的服务：\nservice redis-server stop 然后使用 Docker 拉取 Redis 镜像：\ndocker pull redis:5.0.9 首次拉取可能会出现拉取超时，这是由于国内网络环境导致的。要么给云服务器加代理，要么配置镜像源。我这里配置了镜像源，只需要逐条执行即可：\nsudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json \u0026gt;/dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; { \u0026#34;registry-mirrors\u0026#34;: [ \u0026#34;https://docker.1ms.run\u0026#34;, \u0026#34;https://hub-mirror.c.163.com\u0026#34; ] } EOF sudo systemctl daemon-reload sudo systemctl restart docker sudo docker pull redis:5.0.9 最后使用下面的命令查看镜像是否拉取成功：\ndocker images 我们需要创建两个文件夹。建议在主目录中建一个 redis 文件夹，在里面再分成 redis-data 和 redis-sentinel 文件夹：\nmkdir redis cd redis mkdir redis-data mkdir redis-sentinel 最终你在 redis 目录中会有这两个文件夹：\n进入 redis-data：\ncd redis-data 新建配置文件：\nvim docker-compose.yml 注意这个文件名称必须是 docker-compose.yml，然后把这段配置粘贴进去：\nversion: \u0026#39;3.7\u0026#39; services: master: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-master restart: always command: redis-server --appendonly yes ports: - 6379:6379 slave1: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-slave1 restart: always command: redis-server --appendonly yes --slaveof redis-master 6379 ports: - 6380:6379 slave2: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-slave2 restart: always command: redis-server --appendonly yes --slaveof redis-master 6379 ports: - 6381:6379 最后启动：\ndocker-compose up -d 哨兵配置 进入哨兵文件夹：\ncd redis-sentinel 同样创建配置文件：\nvim docker-compose.yml 配置内容如下：\nversion: \u0026#39;3.7\u0026#39; services: sentinel1: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-sentinel-1 restart: always command: redis-sentinel /etc/redis/sentinel.conf volumes: - ./sentinel1.conf:/etc/redis/sentinel.conf ports: - 26379:26379 sentinel2: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-sentinel-2 restart: always command: redis-sentinel /etc/redis/sentinel.conf volumes: - ./sentinel2.conf:/etc/redis/sentinel.conf ports: - 26380:26379 sentinel3: image: \u0026#39;redis:5.0.9\u0026#39; container_name: redis-sentinel-3 restart: always command: redis-sentinel /etc/redis/sentinel.conf volumes: - ./sentinel3.conf:/etc/redis/sentinel.conf ports: - 26381:26379 # 用于解析处于不同局域网的主节点。 # 主从节点使用另一个 docker-compose 配置，这个 Docker 网络内无法直接解析对应域名。 # 可以用 docker network ls 列出 Docker 中的局域网。 networks: default: external: name: redis-data_default 最后执行：\ndocker-compose up -d -d 的意思是在后台运行。执行到这里就部署完成了。\n如果想查看 Redis 日志，可以使用：\ndocker-compose logs 这是各个哨兵节点的日志：\n也能查看主从节点日志，只需切换到对应文件夹即可：\n","permalink":"https://Jul1en-Lin.github.io/p/redis-%E5%93%A8%E5%85%B5/","title":"Redis 哨兵"},{"content":" 相关博客：Claude 迁移 Codex 工作流更新 背景 这次主要是在整理 Codex / Claude Code / 其他 Agent 之间的项目初始化工作流。 之前有两个入口，一个是偏 Claude 的 setup，主要维护 CLAUDE.md；另一个是偏 Codex 的 codex-agents-init，主要维护 AGENTS.md。刚开始看起来只是名字不同，但实际用的时候会发现，这两个入口很容易记混，而且 Agent 自己也不一定能稳定判断当前到底运行在 Claude Code、Codex，还是其他兼容环境里。\n所以这次调整的重点在于统一入口：以后都走 setup 系列，只是根据项目复杂度分成 setup-light 和 setup-full。\n优化 最后没有继续保留默认 setup，也不再使用 codex-agents-init 这个名字，而是拆成两个更明确的入口：setup-light 和 setup-full。 setup-light 保留做小项目、脚本、demo、短期实验，只保留最基本的项目状态和 Agent 工作流记录：\n. ├── AGENTS.md ├── CLAUDE.md └── docs/ ├── project_status.md └── agent_workflow.md setup-full 适合长期项目、产品型项目、多 Agent 接力，需要完整一点的项目记忆：\n. ├── AGENTS.md ├── CLAUDE.md └── docs/ ├── brainstorm.md ├── project_spec.md ├── architecture.md ├── project_status.md ├── changelog.md ├── decisions.md ├── agent_workflow.md └── bugs/ 这里比较重要的是 AGENTS.md 和 CLAUDE.md 的关系。Codex 原生读取 AGENTS.md，Claude Code 则通过 CLAUDE.md 里的 @AGENTS.md 间接读取同一份规则。也就是说真正维护的只有 AGENTS.md，CLAUDE.md 只是桥接文件，避免两边各写一套规则，后面越改越不一致。\nsetup 现在负责什么 新的 setup 系列不再只服务 Claude，而是作为通用的项目初始化入口。它会创建或更新 AGENTS.md，写入 CLAUDE.md 的桥接内容，按 light / full 创建对应的 docs/ 文件，同时迁移旧的 codex-long-term-docs block，移除旧的 claude-long-term-docs block。\n这里需要注意的是，它只替换 managed block，不会整文件覆盖。已经存在的用户规则、已有的 docs 文件，都应该保留下来。setup-light 实际调用的是 --profile light，setup-full 调用的是 --profile full。\ncodex-agents-init 目录已经删掉了，主要是避免以后又出现两个入口。后续如果继续改初始化逻辑，优先改 setup-light / setup-full 的 skill，尽量不要重新造一个新名字。\nAGENTS.md 的定位 这次我不想让 AGENTS.md 变成一份很长的操作手册。它应该告诉 Agent 必须遵守什么，以及更详细的 workflow 去哪里读。checkpoint、commit、bug、handoff 的细节步骤，放到 docs/agent_workflow.md 会更合适。\n所以 AGENTS.md 里保留的是文件职责：docs/brainstorm.md 是想法池，不能当最终需求；docs/project_spec.md 是产品行为的事实来源；docs/architecture.md 是系统设计和模块边界；docs/project_status.md 记录当前里程碑、进度、阻塞和下一步；docs/changelog.md 记录有意义的用户可见或开发可见变化；docs/decisions.md 记录重要技术决策；复杂 bug 则先进入 docs/bugs/ 做研究报告。\n这次新增了一条比较关键的规则：\nBefore any git commit, check whether the change requires a checkpoint. 意思是提交前不能只想着 git commit，要先判断这次改动会不会影响项目长期记忆。如果影响产品行为、架构、依赖、项目进度、阻塞、下一步，或者涉及长时间任务交接，就要先更新相关 docs。\n具体的 checkpoint / commit / bug / handoff 流程则放到 docs/agent_workflow.md。这样 AGENTS.md 不会越来越长，Agent 真要执行的时候也有地方继续读。\ncheckpoint 的作用 之前的 /update-docs-and-commit 是有用的，因为它能让 AI 长时间自主工作时留下一个项目状态记录。但是这个命令是会在每次提交都会更新文档，对小项目来说没问题，但是对于一些长期的项目，还需要进一步的优化。\n所以后面改成 /checkpoint。\n/checkpoint 的逻辑是：先读当前项目记忆，优先看 docs/agent_workflow.md，再看 git status 和 diff，判断这次状态是否值得长期保存。如果值得保存，就更新 docs/project_status.md，按需更新 docs/changelog.md、docs/decisions.md，复杂问题则补 docs/bugs/ 报告，最后再 commit 一次作为恢复点。\n它的作用更偏向状态判断：让 AI 在一个任务阶段结束时判断，现在这个状态以后还需不需要恢复。如果只是普通小改动，可以正常提交；但如果这次改动影响行为、架构、依赖、进度、阻塞、交接上下文或未解决风险，就应该先 checkpoint。我觉得 AI 能力越强，对它的 Hermes 应该适当放宽一些，让 AI 自主决定提交～\n当前规则是：\nThe agent may propose a checkpoint, but must create one when changes affect behavior, architecture, dependencies, project status, blockers, next actions, long-running task progress, handoff context, or unresolved risk. Commit only the checkpoint-relevant files and current work. Do not push unless the user explicitly asks or the current task grants push/publish authorization. 这里默认不 push。这样可以让 AI 自主维护状态，但不会未经授权把内容发布出去。\nsetup skill 排版重构 后来又对 setup-light 和 setup-full 的 SKILL.md 做了一次排版重构。\n之前的问题是模板内容太靠前，Agent 一进来先看到一大段生成内容，反而不容易立刻知道自己应该怎么执行。后面改成先说明 profile 和事实来源，再写执行流程和安全规则，最后才放模板。\n新的主结构是：\nProfile Sources Of Truth Workflow Safety Rules Minimal Templates Managed Block 这样做的好处是，Profile 先告诉 Agent light / full 分别适合什么项目；Sources Of Truth 明确 AGENTS.md、CLAUDE.md、SKILL.md 的职责；Workflow 放在前面，Agent 不需要先读完大段模板；Safety Rules 提前强调不能覆盖用户已有内容、不能自动 commit、不能做无关修改。\nMinimal Templates 和 Managed Block 继续保留在 SKILL.md 中，脚本仍然从 skill 中抽取模板和 managed block。这样以后只需要改 skill，就能更新生成结果，不需要在脚本里再重复维护一份模板。\n复盘 这次更新的核心是把工作流拆清楚。初始化规则交给 setup-light 和 setup-full，长期项目记忆放进 AGENTS.md 和 docs/，Claude Code 通过 @AGENTS.md 复用同一套规则，细流程放进 docs/agent_workflow.md，长时间自主工作再靠 /checkpoint 建立恢复点。\n我觉得这里比较重要的一点是：AGENTS.md 只负责放最核心的规则和文件职责，尽量精简，如果需要更详细的说明可给出对应文件的索引，让 AI 按需寻找。比如一开始我规定 AGENTS.md 里放着具体的 workflow 流程，具体到每一步都写上去了，那我们就可以将这些具体步骤放到 docs/agent_workflow.md，在 AGENTS.md 中只需给出对应的文档索引，模型就会去主动找，这样后面维护起来更清楚。\n这次还有一个经验：工作流本身也可以让 AI 参与 review，减少一次性拍定方案的风险。最开始只是想合并 Claude 和 Codex 的初始化入口，后来让 AI 从批判角度检查，发现 AGENTS.md 承载了太多流程说明；再继续拆流程，又发现小项目不需要完整 docs，所以才拆成 light 和 full；最后又发现脚本不应该重复维护大量模板内容，于是改成从 SKILL.md 里抽取模板。\n","permalink":"https://Jul1en-Lin.github.io/p/codex-%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%9B%B4%E6%96%B0-v2/","title":"Codex 工作流更新-v2"},{"content":" Sentinel Sentinel 是微服务里非常典型的一类保护组件。它负责的是流量管控：当流量突然变大、某个下游接口变慢、异常开始扩散时，系统不要被一路拖垮。\n如果说 Spring Cloud 核心概念 解决的是“服务怎么拆、怎么找、怎么调”，那么 Sentinel 更像是在这些调用链外面再加一层保险。微服务拆开后，请求就不再只经过一个系统，而是会在网关、服务 A、服务 B、数据库、缓存之间层层传递。任何一个点出问题，都有可能变成整条链路的雪崩。\n这也是为什么 Sentinel 的知识点看起来很多，既有流控规则，也有热点参数限流、熔断降级、授权规则、FallbackFactory、规则持久化。表面上它们是不同章节，本质上其实都在回答同一个问题：系统变复杂之后，怎么把风险拦在局部，而不是让故障扩散到全局。\n主线 先理解为什么微服务需要限流，以及流控规则怎么配。 再区分基于 QPS 和基于并发线程数的两种限流思路。 然后继续往下看流控模式，直接、关联、链路分别适合什么场景。 再往后是流控效果，warm up 和排队等待本质上是在控制“放行的节奏”。 当普通流控还不够精细时，就需要热点参数限流。 如果问题已经不是“流量太大”，而是“服务本身变慢、变脆”，就要进入熔断降级。 熔断之后，调用方不能直接报错给用户，所以又引出了 fallback、FallbackFactory 和自定义异常返回结果。 最后，学习环境里可以靠 Dashboard 临时配规则，但生产环境必须考虑规则持久化，也就是 pull / push 模式。 为什么微服务需要 Sentinel 在微服务里，一个请求可能会先经过网关，再调用订单服务，订单服务再调用户服务、库存服务、支付服务。只要中间有一个节点响应慢了，上游线程就会被占住；如果大量线程一起等待，下游故障很快就会影响到上游。 如果是流量太大，就先做限流。 如果是某个依赖明显不稳定，就做熔断降级。 如果不同来源的请求优先级不同，就做授权控制。 如果请求被拦下来了，就返回一个可控、可读的降级结果。 如果系统已经准备上线，就把这些规则从“内存里的临时配置”变成“可以持久化管理的正式规则”。 一、流量控制 1. 为什么先学流控 Sentinel 最核心的起点就是流量控制。因为很多线上问题，单位时间内进来的请求太多，或者请求处理得太慢，导致系统承受不住。\n比如某个查询接口平时每秒只有 20 个请求，数据库完全顶得住。但活动一开始，流量瞬间变成每秒 2000 个请求，这时数据库、线程池、连接池都可能先后被打满。流控做的事，就是在系统快到极限之前，主动拒绝一部分请求，保持系统正常运行。\n基于流量控制的算法有木桶算法、漏斗算法和令牌算法。此处不过多展开。\n2. 基于 QPS 和基于并发线程数的流控 QPS 是每秒请求数，限流策略是限制“请求进入速度”。比如一个接口每秒最多只希望处理 100 次调用，那么就可以基于 QPS 配规则。\n并发线程数的限流策略为关注同一时刻有多少请求正在处理中。它适合保护那些执行时间较长、线程占用明显的接口。因为有些接口虽然每秒请求数不高，但每个请求都很慢，此时就要考虑线程占用限制与释放的问题。 3. 流控规则的核心字段 配置流控规则时，不管是在代码里还是在控制台里配置，有几个核心参数是必须了解的\nresource，针对哪个资源生效，通常就是某个接口、某个方法、某个服务调用点。 grade，按什么维度限流，是 QPS 还是并发线程数。 count，规定了 QPS 的阈值。 strategy，三种流控模式——直接、关联、链路。 controlBehavior，超过阈值之后怎么处理，是直接失败、预热还是排队等待。 如果用代码方式理解，一个最基础的流控规则大概长这样：\nFlowRule rule = new FlowRule(); rule.setResource(\u0026#34;queryGoods\u0026#34;); // 规定资源 rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 规定限流规则 rule.setCount(100); // QPS 设置为 100 FlowRuleManager.loadRules(List.of(rule)); 这段代码规定资源名叫 queryGoods 的入口，QPS 超过 100 以后就开始限流。\n4. 三种流控模式，直接、关联、链路 直接模式 直接模式最好理解，就是“谁超了就限谁”。比如 /goods/list 接口访问量过大，就直接对 /goods/list 做限流。 这种模式适合最常见的单接口保护场景。\n关联模式 关联模式的是限制与重要接口相关联的资源 例如订单创建接口和商品查询接口都要访问数据库，秒杀时商品查询量暴增，可能会把数据库资源占满，影响到下单。这时就可以设置，当“商品查询”资源压力过大时，限制“商品查询”，把资源留给“下单”这种更重要的业务。\n链路模式 链路模式关注的是，同一个资源可能从不同调用入口进入，但这些入口的优先级不一样。 比如一个 queryUser() 方法，既会被普通查询页面调用，也会被下单流程调用。虽然最终访问的是同一个方法，但“下单链路里的 queryUser()”和“普通查询链路里的 queryUser()”业务价值不同。链路模式就能针对某一条调用路径单独限流。 这说明 Sentinel 的资源观念不只是“接口名”，而是“接口在整条调用链里扮演什么角色”。 5. 流控效果：快速失败、warm up、排队等待 阈值一旦超了，系统并不一定只有“直接拒绝”这一种做法。 warm up 和排队等待，就是在讨论“限流之后的处理节奏”。\n快速失败 快速失败是最直接的方式。超过阈值后，后续请求立刻被拒绝。这种方式实现简单，保护性最强，适合大多数接口。\nwarm up warm up 适合服务冷启动的情况，如：资源正在初始化、数据库连接也刚建立，假设这个服务的 QPS 为 100，刚启动时请求就达到阈值，此时服务器的资源还未完全初始化，也有可能服务器的崩溃。 所以 warm up 的思路不是一开始就放开全部流量，而是先给一个较低的通过阈值，然后随着时间逐步升高，线性地升到设定的目标阈值。\n排队等待 排队等待适合对“匀速处理”有要求的场景。比如某些写库操作，这时可以让请求先处于队列排队，按稳定节奏通过。\n6. 热点参数限流 资源的接口的访问量是不一样的，有些接口访问的频繁，我们称之为热点接口 例如商品详情接口 /goods/{id} 平时很稳，但某个爆款商品 id=1001 突然被全网抢购。这时接口本身不一定超限，真正热点的是参数 1001。如果仍然按接口整体限流，就会误伤所有其他商品请求。 热点参数限流能做到：\n同一个接口，不同参数值分开统计。 对高频热点参数单独限流。 必要时还能给某些特定参数配置例外阈值。 也就是说，普通流控解决的是“这个门太多人进”，热点参数限流解决的是“门本身没问题，但总有一小撮人疯狂挤同一个窗口”。 7. 限流算法的理解重点 常见限流算法通常有下面几类：\n固定窗口 / 滑动窗口 漏桶 / 令牌桶 二、熔断降级 1. 熔断的三种触发条件 慢调用比例 当一个资源的响应时间持续很长，而且慢请求所占比例超过阈值时，就可以触发熔断。 这个规则适合处理接口请求过慢的场景。\n异常比例 如果在一个统计窗口内，请求中的异常占比明显超过阈值，就说明这个资源已经处于高风险状态，可以暂时熔断。 这个规则强调的是接口请求失败的比例。\n异常数 如果在统计窗口内，异常总数超过阈值时触发熔断。\n2. 熔断状态机 状态机用于监控接口\nCLOSED 表示正常放行，请求可以正常通过。 OPEN 表示熔断打开，这段时间内请求不会继续请求目标资源，而是直接走降级逻辑，可能会请求到另外的备用接口。 HALF_OPEN 表示半开，也就是给系统一次“试着恢复”的机会。它不会一次性把流量全放开，而是先用少量探测请求看看下游是否真的恢复了。如果恢复了，就回到 CLOSED；如果还是不行，就重新进入 OPEN。 三、授权规则与自定义异常返回 1. 授权规则 授权规则关注的是“谁可以访问我”，对请求来源进行白名单放行 / 黑名单拦截。 在微服务里，不同来源的请求优先级可能不同。比如某个接口只允许内部服务调用，不允许外部来源直接访问；或者同一个资源，对某些来源放行，对另一些来源拦截。 Sentinel 的授权规则一般会基于 origin 来做判断。你可以把 origin 理解成请求来源标识。规则的本质就是，对资源设置白名单或黑名单，让不同来源得到不同处理。\n2. 自定义异常返回结果 当请求被 Sentinel 拦住时，如果直接把原始异常栈丢给前端，用户体验会很差，前端也很难统一处理。所以实际项目里，通常会把“被限流”“被降级”“被授权拦截”这些情况包装成统一的业务返回。 在 Spring MVC 项目中，常见做法是实现 BlockExceptionHandler：\n@Component public class CustomBlockExceptionHandler implements BlockExceptionHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, String resourceName, BlockException e) throws Exception { response.setStatus(429); response.setContentType(\u0026#34;application/json;charset=UTF-8\u0026#34;); response.getWriter().write(\u0026#34;{\\\u0026#34;code\\\u0026#34;:429,\\\u0026#34;msg\\\u0026#34;:\\\u0026#34;当前请求被限流或降级，请稍后再试\\\u0026#34;}\u0026#34;); } } 四、Fallback 行为与封装接口 FallbackFactory 1. 为什么熔断后还需要 fallback 行为 熔断之后还需要降级逻辑，也就是 fallback。降级逻辑的目标不是把功能完整替代掉，而是在主流程不可用时，提供一个能接受的兜底结果。\n2. FallbackFactory Spring 为我们封装了 FallbackFactory 接口，此实例将在出现任何类型的错误时被调用，任何关于 fallback 的错误降级都可实现这个接口，捕获异常。它可以拿到触发降级的异常对象 Throwable，这样在写兜底逻辑时就有更多上下文。 实现栗子🌰\n@FeignClient(name = \u0026#34;user-service\u0026#34;, fallbackFactory = UserClientFallbackFactory.class) // 需声明 fallbackFactory public interface UserClient { @GetMapping(\u0026#34;/users/{id}\u0026#34;) String queryUserName(@PathVariable(\u0026#34;id\u0026#34;) Long id); } @Component public class UserClientFallbackFactory implements FallbackFactory\u0026lt;UserClient\u0026gt; { @Override public UserClient create(Throwable cause) { return id -\u0026gt; \u0026#34;用户服务暂时不可用，执行了降级逻辑，原因：\u0026#34; + cause.getMessage(); } } 五、规则管理与持久化 1. 规则配置 当前的配置文件都是存放于内存中的，重启服务器后规则就没了，且微服务中多台示例部署，每个实例都规则可能都不同，管理配置会非常困难，我们需要平台将规则配置统一管理起来，方便修改同步。\n2. pull / push 模式 pull 模式 应用会主动从某个外部规则源读取配置，比如 Nacos、Apollo、ZooKeeper、文件或数据库。应用更像是规则的消费者，规则中心保存正式配置，应用自己去拉取。\npush 模式 控制台或配置中心把规则配置主动推送到已经注册的实例中，实例（服务器）收到后会立刻更新 它的好处是实时性更好，规则修改后生效更快，也更接近真正的集中治理模式。生产环境里都会建议将 Sentinel 规则配置台注册到服务中心如 nacos 上，然后再由 nacos 推送，方便统一管理。 ","permalink":"https://Jul1en-Lin.github.io/p/sentinel/","title":"Sentinel"},{"content":" Git stash 在使用 Git 开发时，我们经常会遇到一种很尴尬的情况：当前功能还没写完，代码处于“半成品”状态，既不适合提交 commit，也不想直接丢掉。但这时突然来了一个新的任务，比如需要马上切换分支去修 Bug，或者需要拉取远程最新代码。\n问题就出现了：Git 默认不太喜欢你带着一堆未提交的修改到处切换分支。因为这些修改可能会和目标分支的文件产生冲突，Git 无法确定你到底想保留哪一份内容。\n这时候就需要 git stash。\nstash 的作用可以理解为：把当前还不想提交的修改临时存起来，让工作区恢复到干净状态。等你处理完别的事情后，再把这些修改取回来继续写。\n核心理解 Git 里常见的代码状态主要有三个区域：\n区域 含义 工作区 Working Directory 你正在编辑的文件内容 暂存区 Staging Area / Index 已经git add，准备提交的内容 本地仓库 Repository 已经 commit 的历史版本 普通的 git commit 是把暂存区内容正式保存到版本历史里。\n而 git stash 是把当前工作区和暂存区里的修改临时打包，放进一个 Git 管理的临时栈里。\n当前写到一半的代码 -\u0026gt; git stash -\u0026gt; 临时保存到 stash 栈中 -\u0026gt; 工作区恢复干净 -\u0026gt; 切换分支 / 拉代码 / 修 Bug -\u0026gt; git stash pop 或 git stash apply -\u0026gt; 恢复刚才写到一半的代码\n这里涉及到了栈的知识。\nstash 保存的内容不是只有一份，而是可以保存多份。每执行一次 git stash，Git 就会往 stash 栈里新增一条记录。\n最新的一条通常叫：stash@{0}，数字越小，表示越新。\n再旧一点的是：stash@{1}、stash@{2}\n最常用操作 临时保存当前修改 最基础的命令是：\ngit stash 或者更推荐写成：\ngit stash push 执行后，Git 会把当前工作区和暂存区里的修改保存起来，然后让项目回到一个干净状态。\n比如你正在开发登录功能：\nLoginController.java 修改中 UserService.java 修改中 但是突然需要切换到 hotfix 分支修 Bug，这时候可以执行：\ngit stash push 然后再切换分支：\ngit checkout hotfix 或者：\ngit switch hotfix 这样当前没写完的登录功能不会被丢掉，也不会影响你去处理别的任务。\n添加说明 如果 stash 记录多了，只看 stash@{0}、stash@{1} 会很难分清楚每一份是干什么的。\n所以更推荐加上说明：\ngit stash push -m \u0026#34;开发登录功能，临时保存\u0026#34; 之后查看 stash 列表时，就能看到这条说明。\n这就像给临时保存的代码贴了一个便利贴，后面找起来会轻松很多。\n查看 stash 列表 查看当前保存过的 stash：\ngit stash list 示例：\nstash@{0}: On feature-login: 开发登录功能，临时保存 stash@{1}: On dev: 修改订单模块样式 stash@{2}: On main: 调整配置文件 这里需要注意，stash 是按照时间倒序排列的，最新的永远在最上面。\n查看某个 stash 里改了什么 只看简略变更：\ngit stash show stash@{0} 如果想看具体代码差异：\ngit stash show -p stash@{0} 其中 -p 表示以 patch 的形式展示详细修改内容。\n如果你 stash 记录很多，恢复之前最好先看一下，避免把不相关的旧代码恢复回来。\n恢复 恢复 stash 主要有两个命令：apply 和 pop。\n它们看起来都能把代码拿回来，但差别很重要。\napply git stash apply\n恢复代码，stash 记录还保存着。\n默认恢复最新的一条：git stash apply stash@{0}\ngit stash apply stash@{0} 如果要恢复指定记录：git stash apply stash@{1}\n也就是说，你把临时代码拿回来了，但 Git 不会自动删除那条 stash。\n适合的场景是：你不确定这份 stash 恢复后是否完全正确，想先拿出来试试看。如果没问题，再手动删除。\npop git stash pop\n恢复代码，并尝试删除这条 stash 记录。\n它相当于：git stash apply 加上 git stash drop\n也就是说，代码恢复回来之后，这条临时记录就会从 stash 栈里移除。\n不过如果恢复时发生冲突，Git 通常不会直接删除 stash 记录，这样可以避免代码丢失。\npop 更适合已经确定这条 stash 就是要继续使用的场景。\napply 和 pop 的区别 命令 是否恢复代码 是否删除 stash 记录 适合场景 git stash apply 是 否 想先恢复看看，保留备份 git stash pop 是 是 确定要恢复并继续开发 删除 删除特定 stash git stash drop stash@{0} 这会删除指定的一条 stash。\n如果你已经用 apply 恢复过代码，并且确认这条 stash 不需要了，就可以手动删除。\n清空所有 stash git stash clear 这个命令会删除所有 stash 记录。\n这个操作要谨慎，因为一旦清空，普通方式下就很难再找回来了。\n一般不建议随手执行，除非你非常确定这些临时修改都不需要了。\n注意事项 这是学习 stash 时非常容易踩坑的地方。\n默认情况下：git stash会保存已被 Git 跟踪的文件修改，如 git add 到暂存区的修改这两类文件\n但它默认不会保存新建但还没有被 Git 跟踪的文件，如.gitignore 忽略的文件，这个新文件不会被 stash 保存。\n保存未跟踪文件 如果想把新建的、还没被 Git 跟踪的文件也一起 stash，可以使用：git stash push -u\n-u 是 \u0026ndash;include-untracked 的简写，表示把未跟踪文件也保存进去。\neg：git stash push -u -m \u0026quot;保存登录功能和新建测试文件\u0026quot;，这样新建文件也会一起被临时保存。\n保存被忽略的文件 如果连 .gitignore 里忽略的文件也想一起保存，可以使用git stash push -a命令，这会把所有文件都包含进去，包括一些本地构建产物、缓存文件、临时文件也一起 stash 进去~\nstash 指定文件 有时候你并不想保存所有修改，只想 stash 某几个文件。那就在stash后面跟上具体的文件名，跟git add操作一致\ngit stash push -m \u0026#34;只保存配置文件修改\u0026#34; src/main/resources/application.yml 如果有多个文件，就用空格分开：\ngit stash push -m \u0026#34;保存登录相关修改\u0026#34; LoginController.java UserService.java 这样 Git 只会把指定文件的修改临时保存起来，其他文件的修改还会留在工作区。\n这个操作在多人协作时很有用。比如你只想临时收起某个模块的修改，但另一个模块还要继续调试，就可以精确 stash。\n从 stash 创建新分支 有时候你写着写着发现：这部分代码不应该继续放在当前分支了，应该单独开一个新分支。\n这时可以使用：\ngit stash branch new-branch-name stash@{0} 执行逻辑：\n基于 stash 创建时所在的提交创建一个新分支 切换到这个新分支 把 stash 里的修改恢复出来 如果成功，删除对应的 stash 这个命令适合写着写着发现分支开错了的情况。比如你本来在 dev 分支上随手改了登录功能，但后来发现应该新开一个 feature-login 分支。这时就可以先 stash，再用 git stash branch 把修改转移到新分支上。\nstash 冲突解决 stash 恢复时也可能产生冲突。比如你 stash 的时候修改了 UserService.java，后来目标分支也修改了同一个文件的同一段代码。此时执行git stash pop：\ngit stash pop Git 可能会提示冲突。\n冲突文件里通常会出现类似结构：\n\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt; Updated upstream 当前分支中的代码 ======= stash 中恢复出来的代码 \u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt; Stashed changes 解决方式和普通 merge 冲突类似：先打开冲突文件，手动决定保留哪部分代码。修改完成后再次add冲突文件后文件执行就可以接下来的工作了。\n如果你使用的是 pop，并且发生了冲突，要注意检查 stash 是否还在：\ngit stash list 如果冲突导致 stash 没有被删除，解决完之后可以手动删除：\ngit stash drop stash@{0} 命令总结 命令 作用 git stash 临时保存当前修改 git stash push 更推荐的 stash 写法 git stash push -m \u0026quot;说明\u0026quot; 保存 stash 并添加说明 git stash list 查看 stash 列表 git stash show stash@{0} 查看某条 stash 的简略变更 git stash show -p stash@{0} 查看某条 stash 的详细代码差异 git stash apply 恢复最新 stash，但不删除记录 git stash apply stash@{1} 恢复指定 stash，但不删除记录 git stash pop 恢复最新 stash，并删除记录 git stash drop stash@{0} 删除某条 stash git stash clear 清空所有 stash git stash push -u 保存未跟踪文件 git stash push -a 保存所有文件，包括 ignored 文件 git stash branch new-branch stash@{0} 从 stash 创建新分支并恢复修改 理解图 git stash 本质上是 Git 提供的临时保存现场机制。它适合在代码还没完成、不想提交，但又必须切换任务时使用。它不会替代 commit，而是补充 commit 之前那段混乱、临时、半成品的开发过程。用得好的话，stash 可以让你在多个任务之间切换时更从容，不至于因为工作区不干净而手忙脚乱~\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/git-stash/","title":"Git stash"},{"content":" SpringCloud Config Spring Cloud Config 解决的是微服务项目中“配置分散、环境复杂、修改成本高”的问题。单体项目里，配置通常写在本地 application.yml 中，应用启动时直接读取即可。但微服务项目会拆出多个服务，每个服务又会区分开发、测试、生产等环境。如果每个服务都自己维护一份配置，时间久了就会出现配置重复、版本混乱、修改困难的问题。\n配置中心的思路就是把配置从服务代码里抽出来，交给一个统一的服务管理。Spring Cloud Config 中，承担统一管理角色的是 Config Server，真正使用配置的业务服务是 Config Client。Config Server 从 Git、本地文件系统、Vault、JDBC 等后端读取配置，再通过 HTTP 接口提供给各个客户端。Config Client 启动时访问 Config Server，拉取属于自己的配置并加载到 Spring Environment 中。\n为什么需要 Spring Cloud Config 微服务拆分之后，配置数量会快速增加。比如一个电商系统可能有用户服务、订单服务、商品服务、网关服务，每个服务又可能有 dev、test、prod 三套环境。如果所有配置都放在各自服务内部，修改数据库地址、缓存地址、开关配置时，就需要分别进入多个服务修改，甚至重新打包发布。\nSpring Cloud Config 把配置统一放到远程配置仓库中，服务本身只保留“我是谁、我要去哪里拉配置”这类最基础的信息。这样一来，配置的维护位置就从“每个微服务内部”变成了“统一配置仓库”，配置读取入口也从“服务本地文件”变成了“Config Server”。\n这种变化带来两个直接好处。第一，配置可以集中管理，避免同一个配置在多个服务中重复维护。第二，配置可以通过 Git 追踪历史，谁改了什么、什么时候改的，都有记录。对于团队协作和多环境管理来说，这比散落在各个服务里的配置更可靠。\nConfig Server 与 Config Client 的关系 Config Server 是配置中心服务端，它不一定直接保存配置文件，而是连接一个配置后端。入门阶段最常见的是 Git 仓库。Git 仓库中可以存放类似 user-service-dev.yml、order-service-prod.yml 这样的配置文件。Config Server 启动后，会读取这些配置，并通过 HTTP 接口提供给客户端。\nConfig Client 是具体业务服务，例如 user-service、order-service、gateway-service。这些服务启动时会主动访问 Config Server。访问时，客户端会告诉服务端三个关键信息：应用名、环境和配置版本。Config Server 根据这三个信息找到对应配置，再返回给客户端。\n这里的三个信息非常重要。application 表示应用名，通常来自 spring.application.name。profile 表示环境，例如 dev、test、prod。label 通常表示 Git 分支或标签，例如 main、master、v1.0。所以 Config Server 本质上是在回答一个问题：某个应用在某个环境下，需要读取哪个版本的配置。\n如果 user-service 启动时指定了应用名为 user-service，环境为 dev，配置仓库分支为 main，那么它访问 Config Server 时，Config Server 就会尝试查找和 user-service-dev 相关的配置，并把结果组合成 Spring 可以识别的 PropertySource 返回给客户端。\nConfig Server 简单部署 搭建 Config Server 的第一步是创建一个普通 Spring Boot 服务，并添加 Config Server 依赖。这个依赖让当前服务具备从配置仓库读取配置并对外暴露配置接口的能力。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-config-server\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 然后在启动类上添加 @EnableConfigServer。这个注解的作用是开启 Config Server 功能，让这个 Spring Boot 应用不再只是普通服务，而是可以作为配置中心服务端工作。\n@SpringBootApplication @EnableConfigServer public class ConfigServerApplication { public static void main(String[] args) { SpringApplication.run(ConfigServerApplication.class, args); } } 接下来需要告诉 Config Server 配置仓库在哪里。最常见的是配置 Git 仓库地址。\nserver: port: 8888 spring: application: name: config-server cloud: config: server: git: uri: https://example.com/config-repo.git default-label: main server.port 决定 Config Server 的访问端口，常见示例端口是 8888。spring.cloud.config.server.git.uri 指向远程配置仓库地址。default-label 表示默认读取哪个分支或标签。\n启动 Config Server 后，如果配置仓库中存在 user-service-dev.yml，客户端或浏览器可以通过类似 http://localhost:8888/user-service/dev/main 的路径访问配置。这个路径中的 user-service 对应应用名，dev 对应环境，main 对应 Git 分支。\n本地学习时也可以使用 native 模式，把配置仓库放在本地目录中。它的好处是简单，不需要先准备远程 Git 仓库，但真实项目中更常用 Git，因为 Git 更适合团队协作、版本回退和变更审计。\nspring: profiles: active: native cloud: config: server: native: search-locations: file:///D:/config-repo Config Client 简单部署 Config Client 是业务服务。它的目标不是管理配置，而是在启动时从 Config Server 拿到自己的配置。客户端需要添加 spring-cloud-starter-config 依赖。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-config\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 新版本更推荐使用 spring.config.import 的方式导入远程配置。这样写的含义很明确：当前应用启动时，除了读取本地配置，也会从 Config Server 导入配置。\nspring: application: name: user-service profiles: active: dev config: import: optional:configserver:http://localhost:8888 这里的因果关系要理解清楚。spring.application.name 决定应用名，所以它影响 Config Server 查找哪个服务的配置。spring.profiles.active 决定当前环境，所以它影响读取 dev、test 还是 prod 配置。spring.config.import 决定配置来源，所以它告诉 Spring Boot：请到这个 Config Server 地址拉取远程配置。\n如果配置仓库中有 user-service-dev.yml，内容如下：\nuser: level: vip 那么 user-service 启动后，就可以像读取本地配置一样读取到 user.level。从业务代码角度看，它不需要关心这个配置原本来自 Git 还是本地文件，因为配置最终都会进入 Environment。\n@RestController public class UserController { @Value(\u0026#34;${user.level:normal}\u0026#34;) private String userLevel; @GetMapping(\u0026#34;/level\u0026#34;) public String level() { return userLevel; } } 到这里，Spring Cloud Config 的第一条主线就完整了：配置放在 Git 中，Config Server 读取 Git，Config Client 启动时从 Config Server 拉取配置。\n刷新机制 Config Client 启动时会拉取配置，但服务启动完成后，它不会自动每秒去检查配置仓库有没有变化。也就是说，如果你修改了 Git 仓库中的配置，已经运行中的服务通常不会立即感知。\n这就产生了一个新问题：配置中心解决了“配置放在哪里、启动时怎么读取”的问题，但还没有完全解决“运行时配置变化后怎么生效”的问题。\n最基础的方式是手动刷新。比如通过 Actuator 的 /actuator/refresh 端点让某个服务重新加载配置。这个方式在单个服务、单个实例时还能接受，但在真实微服务场景中很快会变得麻烦。\n假设系统有 5 个服务，每个服务部署 3 个实例，那么一次配置变化可能需要处理 15 个实例。如果人工逐个调用刷新接口，不仅低效，还容易漏掉某个实例。为了让配置变更自动传播，就需要 Webhook 和 Spring Cloud Bus 配合。\nWebhook 的概念 Webhook 是一种事件回调机制。它和轮询正好相反。轮询是服务不断去问配置仓库有没有变化；Webhook 是配置仓库发生变化后，主动通知指定地址。\n以 Git 仓库为例，当开发者修改配置并 push 到 GitHub、GitLab 或 Gitee 后，仓库平台可以向 Config Server 的某个接口发送 HTTP POST 请求。这个请求的意义不是传输完整配置，而是告诉 Config Server：配置仓库已经发生变化，你需要处理刷新。\n在 Spring Cloud Config 中，Config Server 引入 spring-cloud-config-monitor 后，可以开启 /monitor 端点。Webhook 通常就配置到这个 /monitor 端点上。于是配置变化后的链路变成：开发者修改配置，push 到仓库，仓库触发 Webhook，Webhook 请求 Config Server 的 /monitor，Config Server 获知配置变化。\nWebhook 只解决了“谁来告诉 Config Server 仓库变了”的问题。但 Config Server 知道配置变了以后，还要通知所有相关微服务实例刷新。这个广播能力就是 Spring Cloud Bus 要解决的问题。\nSpring Cloud Bus 自动刷新机制 Spring Cloud Bus 可以理解为分布式系统中的消息总线。它把多个微服务实例通过消息代理连接起来，例如 RabbitMQ 或 Kafka。Config Server 收到配置变化通知后，可以把刷新事件发送到 Bus 上，再由 Bus 广播给相关的 Config Client 实例。\n有了 Spring Cloud Bus，配置刷新链路就从“人工逐个刷新实例”变成了“触发一次事件，消息总线自动广播”。这个变化的关键价值是降低运维成本，并减少遗漏实例的风险。\n完整流程可以按因果顺序理解。首先，配置仓库发生变化。然后，Webhook 把变化通知给 Config Server 的 /monitor。Config Server 收到通知后，不直接逐个调用服务，而是发布一个 RefreshRemoteApplicationEvent。接着，Spring Cloud Bus 通过 RabbitMQ 或 Kafka 把这个事件广播出去。最后，Config Client 收到事件后刷新本地 Environment，并重新创建或刷新带有 @RefreshScope 的 Bean。\n@RefreshScope 很关键。配置刷新不是让所有对象都自动变成新值。只有那些被纳入刷新范围的 Bean，才会在刷新事件到来后重新读取配置。因此，运行时可能变化的配置类，通常会配合 @RefreshScope 使用。\nWebhook + Bus 简单配置 Config Server 侧需要具备两个能力：接收仓库变更通知，以及把刷新事件发布到消息总线。因此需要添加 monitor 和 bus 相关依赖。以 RabbitMQ 为例：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-config-monitor\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-bus-amqp\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; Config Client 侧也需要接入 Bus，因为客户端要能从消息总线接收刷新事件。同时还需要 Actuator 参与刷新相关能力。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-bus-amqp\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-actuator\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; Config Server 和 Config Client 要连接同一个消息代理。如果使用 RabbitMQ，可以配置连接信息。\nspring: rabbitmq: host: localhost port: 5672 username: guest password: guest 如果某个配置类需要运行时刷新，可以使用 @RefreshScope。\n@Component @RefreshScope @ConfigurationProperties(prefix = \u0026#34;app\u0026#34;) public class AppProperties { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } 配置仓库的 Webhook 地址通常指向 Config Server 的 /monitor。本地测试时，也可以手动向 /monitor 发送请求，模拟仓库触发通知。\ncurl -X POST http://localhost:8888/monitor \\ -H \u0026#34;Content-Type: application/x-www-form-urlencoded\u0026#34; \\ -d \u0026#34;path=user-service\u0026#34; 这里的 path=user-service 表示和 user-service 相关的配置发生变化，Config Server 会尝试将刷新事件发送给匹配的应用。\n加密环境 配置中心集中管理配置后，新的问题出现了：敏感配置也会集中起来。数据库密码、Redis 密码、第三方接口密钥如果直接以明文形式存入 Git 仓库，一旦仓库权限配置不当、人员误操作、日志暴露或历史提交泄露，就可能造成安全问题。\n所以，配置中心不仅要解决“配置统一管理”，还要解决“敏感配置如何安全存储”。Spring Cloud Config 的加密机制就是为这个问题服务的。\n它的基本思路是：Git 仓库里不直接保存明文密码，而是保存以 {cipher} 开头的密文。Config Server 读取配置时识别到 {cipher} 前缀，就在返回给 Config Client 前完成解密。这样，仓库里是密文，业务服务拿到的是正常可用的明文配置。\n加密与解密端点 Config Server 提供 /encrypt 和 /decrypt 两个端点。/encrypt 用来把明文变成密文，/decrypt 用来验证密文是否可以正确还原。\n例如，把一个明文密码加密：\ncurl localhost:8888/encrypt -s -d mysecret 返回的密文需要加上 {cipher} 前缀后写入配置文件。\nspring: datasource: username: appuser password: \u0026#39;{cipher}682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda\u0026#39; 如果使用 .properties 文件，加密值不要再额外加引号，否则可能影响解密。\nspring.datasource.password={cipher}682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda /decrypt 常用于本地验证密文是否正确。\ncurl localhost:8888/decrypt -s -d 682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda 如果 Config Server 解密失败，它不会简单地把错误密文当成密码继续使用，而是可能生成带 invalid 前缀的属性。这种设计是为了避免密文被误当成真实密码传给客户端。\n对称加密 对称加密的特点是加密和解密使用同一个密钥。它的优点是配置简单，非常适合学习和本地 demo。Config Server 只需要知道这个共享密钥，就能完成 /encrypt 和 /decrypt。\nencrypt: key: mysecretkey 也可以用环境变量保存密钥，避免把密钥写死在配置文件中。\nENCRYPT_KEY=mysecretkey 对称加密的风险也来自这个“同一个密钥”。因为加密和解密都依赖它，一旦密钥泄露，别人就可以解密所有使用这个密钥生成的配置密文。所以对称加密虽然方便，但在生产环境中要非常重视密钥的存放和权限控制。\n非对称加密 非对称加密使用一对密钥：公钥和私钥。通常可以用公钥加密，用私钥解密。它比对称加密更适合正式环境，因为加密能力和解密能力可以分离，密钥管理也更规范。\n在 Spring Cloud Config 中，非对称加密通常通过 keystore 配置。可以用 JDK 自带的 keytool 创建测试密钥库。\nkeytool -genkeypair -alias mytestkey -keyalg RSA \\ -keystore server.jks 更完整的测试命令可以包含密码和证书信息。\nkeytool -genkeypair -alias mytestkey -keyalg RSA \\ -dname \u0026#34;CN=Web Server,OU=Unit,O=Organization,L=City,S=State,C=US\u0026#34; \\ -keypass changeme -keystore server.jks -storepass letmein 把生成的 server.jks 放到 Config Server 的 classpath 下，然后配置 keystore 信息。\nencrypt: keyStore: location: classpath:/server.jks password: letmein alias: mytestkey secret: changeme 这里的 location 指向密钥库位置，password 用于打开密钥库，alias 指定使用哪个密钥，secret 是密钥自身的密码。相比对称加密，非对称加密配置更复杂，但安全边界更清晰。\n复习总结 Spring Cloud Config 的知识链路可以从“配置管理”一路推到“自动刷新”和“安全加密”。\n最开始的问题是微服务配置太分散，所以引入 Config Server 和 Config Client。Config Server 统一读取配置，Config Client 启动时拉取配置。配置集中之后，又会遇到运行时变更不生效的问题，所以引入刷新机制。手动刷新在多实例场景下成本高，于是使用 Webhook 发现配置仓库变化，再通过 Spring Cloud Bus 把刷新事件广播给所有相关客户端。配置集中管理后，敏感信息也被集中存储，因此还需要加密机制。Config Server 通过 {cipher}、/encrypt、/decrypt、对称密钥或非对称密钥库来保护敏感配置。\n简化记忆如下：\nConfig Server 解决“配置统一放在哪里、怎么对外提供”。 Config Client 解决“业务服务启动时怎么拿配置”。 Webhook 解决“配置仓库变化后怎么通知 Config Server”。 Spring Cloud Bus 解决“Config Server 怎么把刷新事件广播给所有实例”。 @RefreshScope 解决“哪些 Bean 能在运行时重新加载配置”。 {cipher} 解决“敏感配置如何以密文形式保存在 Git 中”。 对称加密适合快速配置，非对称加密适合更正式的安全场景。 ‍\n","permalink":"https://Jul1en-Lin.github.io/p/springcloud-config/","title":"SpringCloud Config"},{"content":" Redis 主从复制 概念与结构 Redis 主从复制指的是：让一个 Redis 主节点，也就是 master，把自己的数据同步给一个或多个从节点，也就是 replica。以前很多资料会写 slave，现在更推荐使用 replica 这个说法，但学习时看到 slave 也要能对应上。\n主从复制最基本的结构是：\nmaster ├── replica 1 ├── replica 2 └── replica 3 在这种结构中，master 通常负责处理写请求，replica 负责接收主节点同步过来的数据，并承担一部分读请求。这样做的核心目的有三个：第一是读写分离，第二是提高可用性，第三是扩展系统的读取能力。Redis 官方文档也说明，基础复制模型是 leader-follower，也就是 master-replica 模型，replica 会尽量成为 master 的精确副本。\n实际理解时可以把主从复制想成主库负责写，从库负责跟随。客户端写入数据时，一般写到主节点；主节点接收到写命令后，再把这些变更同步给从节点。客户端读取数据时，可以从从节点读取，从而减轻主节点压力。\n不过要注意，Redis 默认使用异步复制，主节点不会每次写入都等待所有从节点确认完成后再返回结果，所以主从之间可能存在短暂的数据延迟。\n安全性、只读与传输延迟 安全性：Redis 主从复制可以提高系统可用性，但它不是严格意义上的“备份方案”。如果主节点发生误删、误写，这些错误操作也可能同步到从节点，导致从节点也会同步主节点，进而删除数据。所以如果数据安全非常重要，不能只依赖主从复制，还要考虑 RDB、AOF、定期备份、权限控制和运维策略。 官方文档里也特别提醒过一种情况：如果主节点关闭持久化，并且崩溃后自动重启，那么主节点可能以空数据集启动，从节点继续复制它时，也可能被同步成空数据集。\n只读：从节点replica通常配置为只读模式，由 replica-read-only 控制，它能防止误把写请求打到从节点，降低主从数据不一致的风险。如果主动尝试在从节点中修改数据，Redis 是会直接返回错误信息的。 毕竟从节点来写数据，那就不算从节点了，那就是翻身做主人了！\n传输延迟：由于 Redis 默认是异步复制，写命令先在主节点执行，然后再通过复制流发送给从节点。如果网络波动、从节点处理较慢、复制缓冲区压力较大，就可能出现从节点短暂落后主节点的情况。读写分离场景下，如果业务对“刚写完马上读到最新数据”要求非常高，就不能随便把读请求分流到从节点。\n拓扑结构 主从复制不只有“一主一从”。根据系统规模和用途，常见拓扑可以分成几类。\n单主单从：它是最简单的结构，一个 master 对应一个 replica。这个结构容易理解，也适合学习和小规模部署。 一主多从：一个 master 同步给多个 replica，这是比较常见的读扩展结构。多个从节点可以承担读请求，减轻主节点压力。 链式或树状复制：Redis 支持 replica 连接到另一个 replica，形成级联复制结构。官方文档也提到，replica 可以接受其他 replica 的连接，从而形成类似级联的复制结构。 这种结构的好处是可以减轻 master 的出口压力，但问题是链路变长后，复制延迟可能变大。越靠后的从节点，越可能落后主节点，具体要实现哪种要根据实际业务要求。\n第四种是在哨兵或集群场景中的复制关系。Sentinel 主要负责监控和故障转移，Cluster 则用于分片和高可用。它们不是主从复制本身，但实际项目中经常和主从复制配合出现。 总结图片\n基本流程 Redis 主从复制的基本流程可以理解成三句话：先建立连接，再同步数据，最后持续跟随。\n当一个 replica 想要复制 master 时，它会先连接 master，并发起复制请求。Redis 使用 PSYNC 机制进行同步判断。主节点会根据 replica 提供的信息判断：这次可以部分复制，还是必须全量复制。官方文档说明，如果部分重同步不可行，replica 会请求全量重同步；如果可行，就只获取断开期间缺失的那部分命令流。\n复制流程可以拆成下面几步：\nreplica 连接 master，主动发起 PSYNC master 判断同步方式：是选择全量复制或部分复制 如果需要全量复制，master 生成 RDB 快照 master 将 RDB 发送给 replica replica 加载 RDB 数据 master 再发送生成 RDB 期间积累的增量命令 （因为replica在加载RDB文件时 master 可能还会产生新的文件和命令~） 进入稳定阶段，master 持续把写命令传播给 replica （建立 TCP 长连接） 进入稳定阶段后，如果从节点也开启了 AOF，则从节点也要进行响应的 AOF 判断，在必要时执行 bgrewriteaof命令 首次建立主从关系时，通常会走全量复制。因为 replica 没有旧的同步历史，master 只能给它一份完整数据。\n之后如果网络短暂中断再恢复，就有机会走部分复制，只补上中间缺失的命令。如何判断是走部分还是全量复制呢？下面章节会有详细说明~\nreplid 和 offset 的作用 Redis 主从复制要解决两个判断问题：\n我们是不是同一条复制历史？ 已经同步到哪里了？ 第一个问题对应着 replid；第二个问题对应 offset\nreplid 可以理解为“复制历史身份”。官方文档说每个 master 都有一个 replication ID，它标记某一段数据集历史。master 和 replica 如果处在同一条复制历史上，就会共享对应的 replication ID。当从节点从属于主节点的话，它的 replication ID 就是主节点的 replication ID。相当于主节点靠 replication ID 来认小弟~\noffset 可以理解为“复制进度”。master 产生复制流时，offset 会随着复制流字节数不断增长。replica 会记录自己已经处理到哪个 offset。如果从节点的 offset 与主节点的 offset 不一致，说明就有数据没有同步过来了（通常是这样）。官方文档也说明，replication ID + offset 可以标识 master 数据集的一个精确版本。\n举个栗子：\nmaster replid = abc, offset = 1000；replica replid = abc, offset = 800\n这说明它们属于同一条复制历史，但 replica 落后了 200 的复制流。如果 master 的复制积压缓冲区里还保留着 offset 800 之后的数据，那么 replica 只需要部分复制，补上缺失的那一段就可以。\n如果 replid 不一样，或者 offset 太旧，master 已经没有保留那段复制流，那么就只能走全量复制。\n此处提到了积压缓冲区，我们来看看它是什么~\n积压缓冲区 积压缓冲区就是当主从复制建立后，master 会持续把写命令传播给 replica，同时也把这些写命令对应的复制流记录到复制积压缓冲区里。里面保存的是最近一段写命令的复制流和offset 开始值与最大值，直到从节点重新连接。\n重新连接后，主节点会检查 replication ID + offset，看看这个从节点还是不是之前的小弟。如果 replication ID 一致，就说明是曾经复制过的小弟，就会检查它的 offset，如果从节点的 offset 太旧了，主节点的 offset 的最小值都比从节点的 offset 大，此时就会丢弃，重新进行全量复制。（因为主节点在积压缓冲区的范围是有限的，若数据比较多，缓冲区中会丢弃更加久的数据，保留最近的数据，offset 会慢慢变大）\n全量复制 全量复制，也叫 full resynchronization，意思是从节点需要重新获得一份完整的数据集。\n典型触发场景有三种：\n第一次建立主从关系 replica 落后太多，offset 不在 backlog 范围内 replid 不匹配，说明不是同一条复制历史（同一个小弟~） 全量复制流程大致是：\nreplica 发起同步请求 master 判断无法部分复制 master 生成 RDB 快照 master 将 RDB 文件发送给 replica replica 加载 RDB master 补发生成 RDB 期间收到的写命令 master 在生成 RDB 的同时，仍然可能继续接收新的写命令。这些新写命令不能丢，所以 master 会把这段时间的增量命令缓冲起来。等 replica 加载完 RDB 后，再把这些增量命令发过去。Redis 官方文档对全量同步的描述也是：master 后台生成 RDB 文件，同时缓冲新的写命令，RDB 传输并加载完成后，再把缓冲命令发送给 replica。\n全量复制的缺点是开销比较大。它涉及 RDB 生成、RDB 传输、replica 加载整个数据集。如果数据量很大，可能带来磁盘、网络、CPU、内存等压力。\n部分复制 部分复制，也叫 partial resynchronization，意思是 replica 不需要重新同步整个数据集，只需要补上自己断线期间缺失的那部分命令。\n部分复制通常发生在 replica 和 master 短暂断开后重新连接时。replica 会向 master 发送自己保存的 replid 和 offset，master 根据这些信息判断能不能只补增量。官方文档说明，replica 连接 master 时会使用 PSYNC 发送旧 master 的 replication ID 和已经处理的 offset；如果 master 缓冲区里还有对应数据，就可以发送增量部分，否则走全量同步。\n部分复制的判断可以简化成：\n如果 replid 一样，并且 offset 还在复制积压缓冲区范围内： 走部分复制 只补缺失命令 否则： 走全量复制 重新同步完整数据 部分复制的优点很明显：更快、更轻量、网络开销更小。对于短暂网络抖动、从节点短暂掉线这种情况，部分复制非常重要。\n实时复制 当全量复制或部分复制完成后，主从关系进入稳定阶段。这个阶段 master 会继续接收客户端写命令，并把这些写命令传播给 replica。replica 按顺序执行收到的命令，从而让数据尽量跟上 master。这个阶段也可以叫命令传播阶段，或者增量复制阶段。\n实时复制依赖的是主从节点之间的 TCP 长连接传输~\n基本流程：\n客户端写入 master master 执行写命令 master 将写命令传播给 replica replica 顺序执行写命令 主从数据继续保持接近一致 在进行实时复制时，需要保证连接处于持续可用的状态，所以引入了“心跳包”的机制：\n主节点默认每隔一段时间就会给从节点发送一个 ping 命令，从节点收到就回 pong 从节点默认每个一个时间周期就会给主节点发送一个特定的请求，会上报当前复制主节点数据的进度（offset） 实时复制不代表强一致。因为复制仍然以异步为主，网络和执行时间都会带来延迟。主从数据持续接近一致，而不是任何时刻都绝对一致。\n主从连接与心跳机制的区别 主从连接和心跳机制有联系，但不是一回事。主从连接强调的是 replica 与 master 建立复制通道，完成握手并确认同步方式；\n心跳机制强调的是复制关系建立之后，双方持续保持联系，replica 周期性向 master 汇报同步进度，master 借此判断从节点是否在线、是否落后。如果心跳异常或连接中断，从节点会重新连接 master，并根据 replid + offset 尝试进行部分复制。\n全量复制的无硬盘模式优化 无硬盘复制即 diskless replication\n正常全量复制时，master 需要生成 RDB 文件。传统方式是先把 RDB 写到磁盘，然后再从磁盘读取并发送给 replica，流程大概为：\nmaster 生成 RDB 到磁盘 master 从磁盘读取 RDB 发送给 replica replica 加载 RDB 无硬盘复制，也就是 diskless replication，是对全量复制阶段的优化。它的核心思想是：master 不先把 RDB 落盘，而是直接通过 socket 把 RDB 流式发送给 replica。在 diskless replication 中子进程会直接通过网络把 RDB 发给 replicas，而不使用磁盘作为中间存储。\n可以这样对比：\n无硬盘复制的好处是减少磁盘 I/O，尤其适合磁盘压力大、数据集较大、多个 replica 同步的场景。不过它不一定在所有场景都更优。如果网络条件差，或者需要更稳定的落盘中间文件，普通方式也可能更合适。\n另外要注意：无硬盘复制优化的是全量复制阶段，不是部分复制阶段。部分复制本来就是补增量命令，不需要重新传完整 RDB。\nrunid 与 replid 的区别 runid 和 replid 都像“身份标识”，但它们不是一个东西。\nrunid 是 Redis 实例自身的运行时 ID。你可以把它理解成“这个 Redis 进程的身份证”。Redis INFO 命令文档中，run_id 被描述为用于识别 Redis server 的随机值，也会被 Sentinel 和 Cluster 使用。\nreplid 是复制历史 ID。它不是用来识别 Redis 进程本身，而是用来识别某一段复制历史。主从复制、PSYNC、部分复制判断时，更关键的是 replid + offset。\n二者可以这样区分：\n对比项 runid replid 关注对象 Redis 实例本身 复制历史，redis 服务重启后就不一样了 用途 识别 server 实例 判断复制历史、辅助部分复制 是否直接决定部分复制 不是关键 是关键参考 常见位置 INFO server 里的run_id INFO replication 里的master_replid Redis 还可能维护两个 replication ID：当前 master_replid 和 master_replid2。官方文档说明，第二个 replication ID 是为了故障转移后的部分重同步服务的；当 replica 被提升为 master 后，它会保留旧 master 的复制 ID 信息，从而尽量让其他 replica 不必全部重新全量复制。\n最小实践配置 如果只是做学习 demo，可以启动两个 Redis 实例。假设主节点端口是 6379，从节点端口是 6380。\n从节点配置中可以写：\nreplicaof 127.0.0.1 6379 或者在从节点运行时执行：\nREPLICAOF 127.0.0.1 6379 Redis 官方文档也说明，配置基础复制可以在 replica 配置文件里添加 replicaof \u0026lt;master-ip\u0026gt; \u0026lt;master-port\u0026gt;，也可以通过 REPLICAOF 命令让实例开始同步 master。\n常用观察命令：\nINFO replication ROLE INFO replication 可以看到当前实例的复制角色、复制 ID、复制偏移量、连接的从节点数量等信息。ROLE 可以直接查看当前节点是 master 还是 replica，以及复制状态。Redis 官方文档也提到，INFO replication 和 ROLE 都能提供当前复制相关参数。\n从节点晋升主节点 从节点断开主节点有两种情况\n从节点主动断开：使用slaveof no one，这时候从节点就能晋升为主节点，这意味着我们主动干预了 Redis 的结构 主节点挂了：这个时候从节点不会主动晋升为主节点，它必须通过人工干预的方式，将主节点重新启动 复习总结 Redis 主从复制的核心，是 master 持续把自己的数据变化同步给 replica。它常用于读写分离、提高可用性和扩展读能力，但 Redis 默认是异步复制，所以主从之间可能有短暂延迟。\n复制流程可以理解为：replica 先连接 master，然后通过 PSYNC 请求同步。master 根据 replid 和 offset 判断是全量复制还是部分复制。全量复制会发送完整 RDB，再补发期间的增量命令；部分复制只补断开期间缺失的命令。稳定后，master 持续把写命令传播给 replica，形成实时复制。\nreplid 表示复制历史，offset 表示同步进度。runid 表示 Redis 实例自身身份，不要和 replid 混淆。无硬盘复制是全量复制阶段的优化，它通过 socket 直接发送 RDB，减少磁盘 I/O。\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/redis-%E4%B8%BB%E4%BB%8E%E5%A4%8D%E5%88%B6/","title":"Redis 主从复制"},{"content":" Reids 事务 核心意义 Redis 事务的主要作用，是把多条命令“打包”为一组，然后按照顺序执行。它解决的核心问题不是像 MySQL 那样提供完整的强事务能力，而是在执行这一组命令时，尽量避免其他客户端的命令插入到这组操作中间。\n可以先用一个生活例子理解。\n你和朋友约好晚上去吃烧烤。你先到了烧烤店，点了牛肉串、羊肉串、五花肉等，但是你告诉服务员：\n“我人还没齐，你先把单子记下来，先别急着烤。”\n过了一会儿，朋友到了，又加了一些主菜和烤腰子。最后你说：\n“好了，开始烤吧。”\n这个过程里，前面点的和后面追加的菜，会被当成同一组订单一起处理。重点是：这组订单中间不会被别人插队。\n对应到 Redis 事务里也是类似的：\nMULTI 开启事务 命令1 进入队列 命令2 进入队列 命令3 进入队列 EXEC 执行事务 在 MULTI 之后、EXEC 之前，客户端发送的命令不会立刻执行，而是先进入事务队列。只有当 Redis 收到 EXEC 命令时，才会把队列里的命令按顺序执行。\n对应图片：\n执行机制 再说说它的执行机制，Redis 事务的基本流程是：\n客户端发送 MULTI -\u0026gt; Redis 开启事务状态 -\u0026gt; 后续命令进入队列，返回 QUEUED -\u0026gt; 客户端发送 EXEC -\u0026gt; Redis 按顺序执行队列中的命令 -\u0026gt; 返回执行结果\n执行过程可以理解为：\nMULTI：告诉 Redis，我要开始打包命令了 SET name lin：先别执行，放进队列 INCR count：先别执行，放进队列 EXPIRE name 60：先别执行，放进队列 EXEC：现在开始按顺序执行队列里的命令 在事务执行期间，Redis 会连续执行这组命令，中间不会穿插其他客户端的命令。这个特性和 Redis 的单线程命令执行模型有关。但是要注意，Redis 事务不是 MySQL 那种完整强事务。它更像是：\n命令队列 + 顺序提交。\n对应图片：\n常用命令 Redis 事务相关的核心命令主要有 5 个\n命令 作用 理解 MULTI 开启事务 后续命令不立即执行，而是进入队列 EXEC 执行事务 按顺序执行队列里的所有命令 DISCARD 取消事务 清空事务队列，不执行 WATCH 监视 key 用于实现乐观锁 UNWATCH 取消监视 取消对 key 的监控 MULTI：开启事务 表示开启一个事务。\n执行 MULTI 后，客户端再发送的命令会进入事务队列，而不是立刻执行。\nMULTI SET k1 v1 SET k2 v2 这时候 Redis 通常会返回：QUEUED，表示命令已经入队啦！没等到 EXEC 我是不会执行的！\nEXEC：提交并执行事务 表示提交事务。\n执行 EXEC 后，Redis 会把事务队列中的命令按照进入队列的顺序依次执行。\nMULTI SET name tom INCR count EXEC 真正修改数据的时间点不是 SET 和 INCR 发出时，而是 EXEC 发出之后。\nDISCARD：取消事务 表示取消当前事务，并清空事务队列。\n例如：\nMULTI SET name tom INCR count DISCARD 执行 DISCARD 后，前面入队的 SET name tom 和 INCR count 都不会执行。同时要注意，如果开启事务后，服务器突然挂了，重新启动事务就不存在咯，也相当于discard的效果！\nWATCH：监视 key 用于监视一个或多个 key，常用来实现乐观锁。\n基本思想是：\n我先看一下这个 key 的值，然后准备修改它。如果在我提交事务之前，这个 key 被别人改过了，那我的事务就提交失败。\n具体如何使用呢？在开启事务前watch一下key即可~\nWATCH stock GET stock MULTI DECR stock EXEC 如果在 WATCH stock 之后、EXEC 之前，有其他客户端修改了 stock，那么当前客户端的 EXEC 会失败。\nUNWATCH：取消监视 用于取消对 key 的监视。通常当你不想继续执行当前事务逻辑时，可以使用它取消监控。\n与 MySQL 事务的区别 它们不是一个级别的东西，Redis 事务更轻量、更简单；MySQL 事务更完整、更严格。\n对比项 Redis 事务 MySQL 事务 定位 命令打包 + 顺序执行 完整数据库事务 原子性 更像“整体提交，不被插队”，只强调全部执行 强调真正的 all-or-nothing，确保全部都可以正确执行 回滚 不支持 MySQL 那样的自动回滚 支持回滚 隔离性 依赖 Redis 单线程顺序执行，只是事务执行期间不被插队 有完整隔离级别，如读已提交、可重复读等 一致性 通常不作为强一致事务理解 ACID 语义更完整 适用场景 缓存、计数器、轻量批量更新 订单、支付、转账等强事务业务 对应图片：\n原子性 这个地方很容易混乱，也是课堂板书里重点强调的地方。\n单条 Redis 命令通常具有原子性 Redis 本身是单线程执行命令的，同一时刻只会处理一个命令。\n所以像下面这种单条命令：\nINCR count 它本身通常可以理解为原子操作。不会出现加到一半被其他命令插进来的情况。\nRedis 事务的“原子性”比较弱 Redis 事务的原子性，不能完全按照 MySQL 的事务原子性理解。\nRedis 事务可以做到一组命令按顺序执行，执行过程中不会被其他客户端命令插队\n但是它做不到 MySQL 那种要么全部成功、要么失败后全部回滚，它只确认命令能全部执行，但不保证完全对~\n这就是 Redis 事务和 MySQL 事务非常大的区别。\nMySQL 的原子性更严格 MySQL 事务强调的是要么全部成功、要么全部失败，而且支持失败后可以回滚的功能\nRedis 并没有实现这种功能，虽然这种功能强大，但也是需要一定的性能开销，所以 Redis 没有实现这种功能开发者可能也有一定的考虑~\nWATCH 实现思想：乐观锁 详细来说，Redis 的 watch 是基于版本号1这样的机制实现了乐观锁2\n版本号这个概念在 CAS3 中的 ABA 问题4也涉及过，其思想方法和实现方式上是十分相似的~\n虽然说我之前的博客也有对乐观锁、CAS 和 ABA 问题有详细的讲解，但不影响阅读体验，就在这简略介绍下\n什么是乐观锁？ 乐观锁的想法是：\n我先假设冲突不会发生，所以不提前把资源锁死；等真正提交时，再检查有没有被别人改过。\n悲观锁是：我觉得一定会有人抢，所以我先加锁。别人想操作，必须等我释放锁。\n乐观锁是：我觉得大概率不会冲突，所以大家都可以先操作，提交时再检查有没有冲突，如果冲突了，我就失败重试。\n版本号 当执行watch key时，这时就会给 key 分配一个版本号，此处的版本号可以理解为“一个整数”，key 在每次修改后，版本号就会+1，此处假设 watch 之后 key 的版本号默认为1。\n然后有两个客户端，客户端1执行了watch key，然后开启了事务，客户端2对key进行了更新操作，此时版本号+1。客户端1在队列中也安排了key的更新操作，然后客户端1 执行 exec，（exec 执行时间在客户端2对 key 更新操作之后）。此时 exec 在执行事务命令之后就会做出判定，判定watch时候的版本号与现在的版本号是否一致：如果一致，说明当前key在事务开启到最终执行的这个过程中都没有别的客户端进行修改，就能正常执行；反之，如果不一致，说明 key 在其他客户端就改过了，因此就丢弃了这个事务的操作，返回 nil ~\n所以 watch 本质上不是给 key 上了一层字面意义上的 “锁”，而是观察 key 是否被改过！\n它能解决一部分问题，但不能自动解决所有问题。\nMULTI/EXEC 能保证多条命令作为一组提交且按照顺序执行，执行过程中不被其他客户端命令插队\n但是库存问题的关键通常不是简单地“几条命令一起执行”，而是先判断库存是否足够再决定要不要扣减\n而 Redis 事务有一个限制：\n事务中的命令是先入队，EXEC 时才执行。客户端不能在事务执行过程中，根据上一条命令的结果临时决定下一条命令是否执行。\n也就是说，下面这种思路在 Redis 事务里不够安全\nMULTI GET stock 如果 stock \u0026gt; 0: DECR stock EXEC 因为 GET stock 在 EXEC 前只是进入队列，并没有真正返回可用于客户端判断的结果。\n所以，单靠 MULTI/EXEC 不适合处理复杂的“先读结果，再根据结果决定是否写”的业务。\nABA的核心是：\n在通过A的值相同判定是否有插队，由于次数的修改有加也有减，就可能会出现ABA的问题，那么有一些解决方案：限制此处的操作（只能加或者只能减）、或者引入“版本号”概念，每次修改都让“版本号”+1，再次使用判定的时候就不是用值来判定了，而是看版本号\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n乐观锁 \u0026amp; 悲观锁 加锁的时候预测这个锁出现竞争性的可能性大还是小？\n预测这个锁出现竞争可能性小 —— 乐观锁\n预测这个锁出现竞争可能性大 —— 悲观锁\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nCAS是什么？\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nCAS的ABA问题是怎么样的\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://Jul1en-Lin.github.io/p/reids-%E4%BA%8B%E5%8A%A1/","title":"Reids 事务"},{"content":" Spring Cloud 核心概念 Spring Cloud 简单概念笔记：Eureka、Nacos、OpenFeign 与 Gateway 版本说明：本文以 Spring Boot 3 / Spring Cloud 新版本思路为主。很多旧教程会使用 bootstrap.yml、Ribbon 等写法，学习时可以看，但自己写新项目时更建议优先使用 Spring Cloud LoadBalancer、spring.config.import 等新写法。\n微服务里的几个核心问题 单体项目里，前端请求通常直接打到一个后端服务：\nflowchart LR A[浏览器/客户端] --\u0026gt; B[单体后端应用] B --\u0026gt; C[(数据库)] 微服务项目拆分后，系统可能变成这样：\nflowchart LR A[浏览器/客户端] --\u0026gt; G[Gateway 网关] G --\u0026gt; U[用户服务 user-service] G --\u0026gt; O[订单服务 order-service] G --\u0026gt; P[商品服务 product-service] O --\u0026gt; U O --\u0026gt; P U --\u0026gt; DB1[(用户库)] O --\u0026gt; DB2[(订单库)] P --\u0026gt; DB3[(商品库)] 拆分之后会出现几个新问题：\n服务越来越多，调用方怎么知道某个服务现在在哪台机器、哪个端口？ 同一个服务可能部署多个实例，请求应该打到哪一个实例？ 服务上线、下线、宕机后，调用方如何及时感知？ 公共认证、日志、限流、跨域等逻辑放在哪里更合适？ 多个服务的配置如何统一管理、动态刷新？ 对应到 Spring Cloud 生态中，常见组件分工如下：\n问题 典型组件 作用 服务在哪里 Eureka / Nacos Discovery 服务注册与服务发现 多实例怎么选 Spring Cloud LoadBalancer / Nacos 权重 客户端负载均衡 服务之间怎么调用 OpenFeign 声明式 HTTP 远程调用 外部请求从哪里进 Spring Cloud Gateway API 网关、统一入口 配置怎么统一管 Nacos Config / Spring Cloud Config 配置中心、动态配置 服务启动后注册到注册中心；调用方从注册中心发现目标服务实例；负载均衡器从多个实例中选一个；OpenFeign 发起服务间调用；Gateway 负责外部入口和公共过滤逻辑；Nacos 还可以统一管理配置。\nEureka：注册中心、服务注册与服务发现 Eureka 是什么 Eureka 是 Netflix OSS 体系里的服务注册与发现组件。它主要分成两类角色：\nEureka Server：注册中心，负责保存服务实例信息。 Eureka Client：微服务应用，启动后把自己注册到 Eureka Server，也可以从 Eureka Server 获取其他服务列表。 可以把 Eureka Server 理解成一个“服务通讯录”其他服务调用 user-service 时，不需要写死 IP 和端口，只需要通过服务名去查。\n服务注册 服务注册指的是：服务启动后，把自己的服务名、IP、端口、健康状态等信息上报给注册中心。\n以 user-service 为例，它启动时会把自己注册进去：\nserver: port: 8081 spring: application: name: user-service eureka: client: service-url: defaultZone: http://localhost:8761/eureka/ spring.application.name 很关键，它就是服务名。服务之间一般不是通过 localhost:8081 调用，而是通过 user-service 这个逻辑名称调用。\n服务发现 服务发现指的是：调用方根据服务名，从注册中心拿到目标服务的实例列表。\n例如 order-service 想调用 user-service，它不需要知道 user-service 部署在哪，只需要知道服务名：\norder-service -\u0026gt; 注册中心 -\u0026gt; 查询 user-service -\u0026gt; 得到实例列表 -\u0026gt; 选择一个实例调用 如果 user-service 有多个实例：\nuser-service ├── 192.168.1.10:8081 ├── 192.168.1.11:8081 └── 192.168.1.12:8081 注册中心会保存这些实例，调用方再结合负载均衡策略选择一个。\nEureka 简单部署流程 第一步：创建 Eureka Server 依赖示例：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-eureka-server\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 启动类：\n@SpringBootApplication @EnableEurekaServer public class EurekaServerApplication { public static void main(String[] args) { SpringApplication.run(EurekaServerApplication.class, args); } } 配置文件：\nserver: port: 8761 spring: application: name: eureka-server eureka: client: register-with-eureka: false fetch-registry: false service-url: defaultZone: http://localhost:8761/eureka/ 这里的两个配置需要注意：\nregister-with-eureka: false：Eureka Server 不把自己当作普通服务注册。 fetch-registry: false：Eureka Server 不需要从自己这里拉取服务列表。 启动后访问：\nhttp://localhost:8761 可以看到 Eureka 控制台。\n第二步：创建服务提供者 user-service 依赖示例：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-eureka-client\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置文件：\nserver: port: 8081 spring: application: name: user-service eureka: client: service-url: defaultZone: http://localhost:8761/eureka/ 简单接口：\n@RestController @RequestMapping(\u0026#34;/users\u0026#34;) public class UserController { @GetMapping(\u0026#34;/{id}\u0026#34;) public String getUser(@PathVariable Long id) { return \u0026#34;user id = \u0026#34; + id; } } 启动后，Eureka 控制台中会出现 USER-SERVICE。\n第三步：创建服务消费者 order-service server: port: 8082 spring: application: name: order-service eureka: client: service-url: defaultZone: http://localhost:8761/eureka/ 后面可以通过 OpenFeign 或 RestTemplate 调用 user-service。\n负载均衡：为什么不能只调用一个实例 负载均衡解决什么问题 当一个服务部署多个实例时，请求不能永远打到同一个实例，否则会导致：\n某台机器压力过大； 其他实例空闲浪费； 单个实例宕机后请求失败； 系统整体吞吐量上不去。 负载均衡就是在多个实例中选择一个实例来处理请求。\n服务名调用与负载均衡 以前很多教程会讲 Ribbon，现在新版本更推荐 Spring Cloud LoadBalancer。\n服务调用时通常不是这样写：\nhttp://localhost:8081/users/1 而是这样写：\nhttp://user-service/users/1 这里的 user-service 不是域名，而是注册中心中的服务名。调用流程大致是：\n先根据 user-service 去注册中心拉取实例列表； 负载均衡器从列表里选一个实例； 把 http://user-service/users/1 转成类似 http://192.168.1.10:8081/users/1； 最后发起真正的 HTTP 请求。 常见负载均衡策略 常见策略包括：\n轮询：第 1 次打实例 A，第 2 次打实例 B，第 3 次打实例 C，然后循环。 随机：随机选择一个实例。 权重：权重越高，被选中的概率越大。 同集群优先：消费者优先调用同一个集群或同一个机房内的服务实例。 Nacos：注册中心 + 配置中心 Nacos Nacos 是阿里开源的动态服务发现、配置管理和服务管理平台。在 Spring Cloud Alibaba 体系中，Nacos 通常承担两个角色：\nNacos Discovery：服务注册与发现，类似 Eureka。 Nacos Config：配置中心，集中管理应用配置，支持动态刷新。 所以 Nacos 比 Eureka 的能力更综合。Eureka 主要关注服务注册发现，而 Nacos 同时可以做注册中心和配置中心。\nNacos 服务注册与发现 服务启动后，将自己注册到 Nacos：\nserver: port: 8081 spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 启动多个服务后，可以在 Nacos 控制台看到服务列表。\nNacos 控制台默认地址通常是：\nhttp://localhost:8848/nacos 常见默认账号密码是：\nnacos / nacos 具体以你本地安装版本为准。\n服务下线 服务下线有两种理解：\n一种是应用真正停止。例如你关闭了某个 user-service 实例。注册中心会通过心跳或健康检查发现它不可用，然后把它从可用列表中剔除或标记异常。\n另一种是手动下线实例。在 Nacos 控制台中可以把某个实例临时下线。这样它虽然进程还在，但消费者不会再优先调用它。\n典型使用场景：\n服务要灰度发布，先下线旧实例； 某台机器异常，先摘掉流量； 临时维护，不希望继续接收请求。 理解重点：\n下线不是删除服务，而是让该实例暂时不参与调用。\n权重配置 Nacos 支持给实例设置权重。权重越高，被调用的概率越大。\n例如：\nuser-service 实例 A：权重 1 user-service 实例 B：权重 5 如果负载均衡策略使用权重，实例 B 大约会获得更多流量。\n配置示例：\nspring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 weight: 5 权重常用于：\n新版本灰度发布：新版本先给较小权重，观察稳定后再逐步提高； 机器性能不同：高配置机器设置更高权重； 临时降流量：把某个实例权重调低。 flowchart LR C[consumer] --\u0026gt; LB[Nacos + LoadBalancer] LB --\u0026gt;|少量流量| A[user-service v1 / weight=1] LB --\u0026gt;|更多流量| B[user-service v2 / weight=5] 同集群优先 同集群优先的意思是：消费者优先调用与自己处在同一集群、同一机房或同一区域的服务实例。\n例如：\n上海集群：user-service A、order-service A 北京集群：user-service B、order-service B 上海的 order-service 优先调用上海的 user-service，北京的 order-service 优先调用北京的 user-service。\n这个做的好处是网络延迟更低、跨机房调用更少，速度更加快。还有就是一个集群故障时，可以再考虑降级调用其他集群。\n配置示例：\nspring: cloud: nacos: discovery: cluster-name: SHANGHAI 如果要让 Spring Cloud LoadBalancer 更好地结合 Nacos 的集群信息，需要根据项目版本启用对应的 Nacos LoadBalancer 集成配置，例如：\nspring: cloud: loadbalancer: nacos: enabled: true 权重解决“谁多接一点流量”，集群优先解决“尽量调用离我近的服务”。\n环境隔离：Namespace、Group、DataId Nacos 里常见的隔离概念有三个：\n概念 用途 类比 Namespace 环境级隔离 dev、test、prod 三个空间 Group 分组隔离 同一环境下按业务线或项目分组 DataId 配置文件标识 user-service-dev.yml 最常用的是 Namespace。比如你可以创建三个命名空间：\ndev 开发环境 test 测试环境 prod 生产环境 这样开发环境的服务和配置不会误连到生产环境。\n示例：\nspring: cloud: nacos: discovery: namespace: dev-namespace-id config: namespace: dev-namespace-id 注意：namespace 配的通常不是命名空间名称，而是命名空间 ID。\nNacos 配置中心 配置中心的价值是：把配置从代码包里抽出来，集中放到 Nacos 管理。\n以前配置可能写在每个服务自己的 application.yml 中：\n用了 Nacos Config 后，可以把这些配置放在 Nacos 控制台中。应用启动时从 Nacos 拉取配置，运行中还可以监听配置变化。\nsequenceDiagram participant App as user-service participant Nacos as Nacos Config App-\u0026gt;\u0026gt;Nacos: 启动时拉取 user-service-dev.yml Nacos--\u0026gt;\u0026gt;App: 返回配置内容 App-\u0026gt;\u0026gt;App: 使用配置启动 Nacos--\u0026gt;\u0026gt;App: 配置变化通知 App-\u0026gt;\u0026gt;App: 动态刷新部分配置 现代写法示例：\nspring: application: name: user-service cloud: nacos: config: server-addr: 127.0.0.1:8848 group: DEFAULT_GROUP file-extension: yaml config: import: - optional:nacos:user-service-dev.yaml 如果使用动态刷新，可以配合 @RefreshScope，这就支持不用重新启动服务即可更改配置：\n@RestController @RefreshScope public class ConfigController { @Value(\u0026#34;${user.level:normal}\u0026#34;) private String userLevel; @GetMapping(\u0026#34;/config/user-level\u0026#34;) public String getUserLevel() { return userLevel; } } 在 Nacos 控制台修改配置后，接口返回值可以随配置变化而变化。\nNacos 简单部署流程 第一步：启动 Nacos Server 学习环境可以直接本地启动 Nacos 单机版，常见端口是8848\n启动成功后访问：\nhttp://localhost:8848/nacos 第二步：服务接入 Nacos Discovery 添加依赖：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-discovery\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置：\nserver: port: 8081 spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 启动后在控制台查看服务列表。\n第三步：服务接入 Nacos Config 添加依赖：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-alibaba-nacos-config\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置：\nspring: application: name: user-service cloud: nacos: config: server-addr: 127.0.0.1:8848 file-extension: yaml config: import: - optional:nacos:user-service-dev.yaml 然后在 Nacos 控制台新建配置：\nData ID: user-service-dev.yaml Group: DEFAULT_GROUP 配置格式: YAML 配置内容示例：\nuser: level: vip 第四步：多环境隔离 创建命名空间：\ndev prod 然后在服务配置中指定命名空间 ID：\nspring: cloud: nacos: discovery: namespace: dev-namespace-id config: namespace: dev-namespace-id 这样开发环境服务只注册到开发环境空间，开发环境配置也只从开发空间读取。\nEureka 与 Nacos 的区别 对比项 Eureka Nacos 定位 服务注册与发现 服务注册发现 + 配置中心 + 服务管理 生态 Netflix OSS / Spring Cloud Netflix Spring Cloud Alibaba / Nacos 生态 配置中心 不提供，需要搭配其他组件 原生支持配置中心 控制台能力 相对简单，主要查看服务实例 更丰富，可管理服务、实例、权重、配置、命名空间等 权重配置 原生能力弱，通常依赖负载均衡策略扩展 原生支持实例权重 环境隔离 通常靠不同注册中心或配置区分 支持 Namespace、Group 等隔离方式 服务下线 更多依赖心跳和实例状态 控制台可更方便地下线实例、调整权重 适合场景 学习注册中心原理、传统 Spring Cloud 项目 国内项目常见，适合注册中心和配置中心统一管理 OpenFeign：声明式服务调用 OpenFeign 是什么 OpenFeign 是一个声明式 HTTP 客户端。所谓声明式，就是你不用手写完整的 HTTP 请求过程，只需要定义一个接口，Spring Cloud 会帮你生成代理对象去调用远程服务。\n没有 Feign 时，你可能要写：\nrestTemplate.getForObject(\u0026#34;http://user-service/users/1\u0026#34;, String.class); 用了 Feign 后，你可以写成接口，服务类只需要实现这个接口功能即可。\n@FeignClient(name = \u0026#34;user-service\u0026#34;) public interface UserClient { @GetMapping(\u0026#34;/users/{id}\u0026#34;) String getUser(@PathVariable(\u0026#34;id\u0026#34;) Long id); } 在业务代码里像调用本地方法一样调用：\n@Service public class OrderService { private final UserClient userClient; public OrderService(UserClient userClient) { this.userClient = userClient; } public String getOrderUser(Long userId) { return userClient.getUser(userId); } } OpenFeign 调用流程 sequenceDiagram participant O as order-service participant F as OpenFeign 代理对象 participant R as 注册中心 Eureka/Nacos participant LB as LoadBalancer participant U as user-service O-\u0026gt;\u0026gt;F: userClient.getUser(1) F-\u0026gt;\u0026gt;R: 根据 user-service 查询实例 R--\u0026gt;\u0026gt;F: 返回实例列表 F-\u0026gt;\u0026gt;LB: 选择一个实例 LB--\u0026gt;\u0026gt;F: 返回目标地址 F-\u0026gt;\u0026gt;U: 发起 HTTP 请求 /users/1 U--\u0026gt;\u0026gt;F: 返回用户信息 F--\u0026gt;\u0026gt;O: 返回方法结果 OpenFeign 主要负责服务内部之间的远程调用 ， Gateway 是网关，是外部请求进入系统的统一入口。这两个很容易搞混\nOpenFeign 简单部署流程 第一步：消费者服务添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-openfeign\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 第二步：启动类开启 Feign @SpringBootApplication @EnableFeignClients public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } } 第三步：定义 Feign 接口 @FeignClient(name = \u0026#34;user-service\u0026#34;) public interface UserClient { @GetMapping(\u0026#34;/users/{id}\u0026#34;) String getUser(@PathVariable(\u0026#34;id\u0026#34;) Long id); } 第四步：注入并调用 @RestController @RequestMapping(\u0026#34;/orders\u0026#34;) public class OrderController { private final UserClient userClient; public OrderController(UserClient userClient) { this.userClient = userClient; } @GetMapping(\u0026#34;/{id}\u0026#34;) public String getOrder(@PathVariable Long id) { String user = userClient.getUser(1L); return \u0026#34;order id = \u0026#34; + id + \u0026#34;, \u0026#34; + user; } } 6.4 OpenFeign 常见注意点 @FeignClient(name = \u0026quot;user-service\u0026quot;) 中的 name 一般写注册中心里的服务名。 @PathVariable 建议明确写变量名，例如 @PathVariable(\u0026quot;id\u0026quot;)。 OpenFeign 默认适合服务间 HTTP 调用，不适合传输特别大的文件。 Gateway：网关服务 Gateway Spring Cloud Gateway 是微服务系统的 API 网关。它通常位于客户端和后端服务之间，作为统一入口。\nflowchart LR A[前端/客户端] --\u0026gt; G[Spring Cloud Gateway] G --\u0026gt; U[user-service] G --\u0026gt; O[order-service] G --\u0026gt; P[product-service] Gateway 常见职责：\n路由转发：根据路径把请求转发到不同服务； 统一认证：判断用户是否登录、Token 是否有效； 日志记录：记录请求耗时、IP、路径； 跨域处理：统一配置 CORS； 限流降级：保护后端服务； 请求/响应改写：添加请求头、修改路径等。 Gateway 的三个核心概念 概念 作用 举例 Route 路由 定义请求转发到哪里 /api/users/**转发到user-service Predicate 断言 判断请求是否匹配这条路由 Path、Method、Header、Host Filter 过滤器 在请求前后做增强处理 鉴权、加请求头、日志、限流 一个请求进入 Gateway 后，大致流程如下：\nsequenceDiagram participant C as Client participant G as Gateway participant P as Predicate participant F as Filter Chain participant S as Service C-\u0026gt;\u0026gt;G: 发送请求 /api/users/1 G-\u0026gt;\u0026gt;P: 判断匹配哪条 Route P--\u0026gt;\u0026gt;G: 匹配 user-service 路由 G-\u0026gt;\u0026gt;F: 执行 pre 过滤器 F-\u0026gt;\u0026gt;S: 转发到 user-service S--\u0026gt;\u0026gt;F: 返回响应 F-\u0026gt;\u0026gt;G: 执行 post 过滤器 G--\u0026gt;\u0026gt;C: 返回结果 Gateway 简单部署流程 第一步：添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-gateway\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 如果要通过注册中心用服务名转发，还需要接入 Eureka 或 Nacos Discovery。\n第二步：配置路由 以 Nacos 为例：\nserver: port: 10010 spring: application: name: gateway-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/users/** filters: - StripPrefix=1 - id: order-service-route uri: lb://order-service predicates: - Path=/api/orders/** filters: - StripPrefix=1 这里要理解几个点：\nuri: lb://user-service 表示通过负载均衡调用注册中心里的 user-service。 Path=/api/users/** 表示路径匹配时才走这条路由。 StripPrefix=1 表示转发前去掉第一层路径，例如 /api/users/1 转成 /users/1。 请求链路：\n客户端请求：/api/users/1 Gateway 匹配：Path=/api/users/** StripPrefix=1 后：/users/1 转发目标：lb://user-service/users/1 GatewayFilter、GlobalFilter 与自定义 Filter GatewayFilter GatewayFilter 是路由级过滤器，只对某一条或某几条路由生效。\n例如：\nspring: cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/users/** filters: - AddRequestHeader=X-Source, gateway - StripPrefix=1 这个过滤器只对 user-service-route 生效。它会给转发到后端服务的请求添加请求头：\nX-Source: gateway 常见内置 GatewayFilter：\nFilter 作用 AddRequestHeader 添加请求头 AddResponseHeader 添加响应头 StripPrefix 去掉路径前缀 PrefixPath 添加路径前缀 RewritePath 重写路径 RequestRateLimiter 请求限流 Retry 请求失败重试 GlobalFilter GlobalFilter 是全局过滤器，会对所有匹配到路由的请求生效。\n它适合处理公共逻辑：\n登录校验； Token 解析； 请求日志； 灰度标记； 统一添加请求头； 统计请求耗时。 举一个简单实现栗子\n@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono\u0026lt;Void\u0026gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst(\u0026#34;Authorization\u0026#34;); if (token == null || token.isBlank()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } @Override public int getOrder() { return -1; } } getOrder() 用来控制过滤器顺序。数值越小，优先级越高。\npre 与 post 过滤逻辑 Gateway 的过滤器通常可以分成两个阶段：\npre 阶段：请求转发到后端服务之前执行。 post 阶段：后端服务返回响应之后执行。 flowchart LR A[请求进入 Gateway] --\u0026gt; B[pre 过滤器：鉴权/日志/加请求头] B --\u0026gt; C[转发到后端服务] C --\u0026gt; D[post 过滤器：统计耗时/改响应头] D --\u0026gt; E[返回客户端] pre 适合做：鉴权、参数校验、添加请求头、路径改写、限流。\npost 适合做：响应日志、统计耗时、添加响应头、统一包装响应。\n自定义 GatewayFilterFactory 如果你想写一个只在某些路由上生效的自定义过滤器，可以写自定义 GatewayFilterFactory。\n示例：\n@Component public class CheckHeaderGatewayFilterFactory extends AbstractGatewayFilterFactory\u0026lt;CheckHeaderGatewayFilterFactory.Config\u0026gt; { public CheckHeaderGatewayFilterFactory() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -\u0026gt; { String value = exchange.getRequest().getHeaders().getFirst(config.headerName); if (value == null || value.isBlank()) { exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }; } public static class Config { private String headerName; public String getHeaderName() { return headerName; } public void setHeaderName(String headerName) { this.headerName = headerName; } } } 配置中使用：\nspring: cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/users/** filters: - name: CheckHeader args: headerName: X-Token 命名规则要注意：\nCheckHeaderGatewayFilterFactory -\u0026gt; 配置中写 CheckHeader 也就是说，类名一般以 GatewayFilterFactory 结尾，配置时去掉这个后缀。\n8.5 自定义 GlobalFilter 如果逻辑对所有路由都生效，比如统一日志，可以写 GlobalFilter。\n@Component public class LogGlobalFilter implements GlobalFilter, Ordered { @Override public Mono\u0026lt;Void\u0026gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) { long start = System.currentTimeMillis(); String path = exchange.getRequest().getURI().getPath(); return chain.filter(exchange).then(Mono.fromRunnable(() -\u0026gt; { long cost = System.currentTimeMillis() - start; System.out.println(\u0026#34;path=\u0026#34; + path + \u0026#34;, cost=\u0026#34; + cost + \u0026#34;ms\u0026#34;); })); } @Override public int getOrder() { return 0; } } 理解重点：\nGatewayFilter 更像“某条路由的局部增强”，GlobalFilter 更像“整个网关的全局拦截器”。\n请求链路串联 学了这么多概念，在这里做一个简单的知识串联吧~\n假设用户访问订单详情：\nGET /api/orders/1001 完整流程如下：\nsequenceDiagram participant C as Client participant G as Gateway participant N as Nacos/Eureka participant O as order-service participant F as OpenFeign participant U as user-service C-\u0026gt;\u0026gt;G: GET /api/orders/1001 G-\u0026gt;\u0026gt;G: GlobalFilter 鉴权、日志 G-\u0026gt;\u0026gt;G: Predicate 匹配 /api/orders/** G-\u0026gt;\u0026gt;N: 根据 order-service 查询实例 N--\u0026gt;\u0026gt;G: 返回 order-service 实例列表 G-\u0026gt;\u0026gt;O: 负载均衡转发请求 O-\u0026gt;\u0026gt;F: 调用 UserClient.getUser(userId) F-\u0026gt;\u0026gt;N: 根据 user-service 查询实例 N--\u0026gt;\u0026gt;F: 返回 user-service 实例列表 F-\u0026gt;\u0026gt;U: 负载均衡调用 user-service U--\u0026gt;\u0026gt;F: 返回用户信息 F--\u0026gt;\u0026gt;O: 返回结果 O--\u0026gt;\u0026gt;G: 返回订单详情 G-\u0026gt;\u0026gt;G: post 过滤器记录耗时 G--\u0026gt;\u0026gt;C: 返回响应 Gateway：负责外部入口、路由、过滤器； Nacos/Eureka：负责服务注册与发现； LoadBalancer：负责从多个实例中选一个； OpenFeign：负责服务内部之间的声明式 HTTP 调用； Nacos Config：虽然不在请求链路中，但负责服务启动和运行时读取配置。 ‍\n","permalink":"https://Jul1en-Lin.github.io/p/spring-cloud-%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/","title":"Spring Cloud 核心概念"},{"content":" Claude 迁移 Codex 工作流更新 工作流框架 这篇博客旨在为从 Claude 转到 Codex 的工作者提供一些注意事项与 Tips。\nClaude code 的工作方式与 Codex 还是有些许不一样，Claude code 更适合长对话的上下文，将模糊的想法转为可实现的东西，而 Codex 更专注于任务的执行，所以需要稍微改变一下我们的使用习惯。\n先介绍下我的 Claude 工作流，大致为：先沉淀想法brainstorm.md → 生成规格文档project_spec.md → 拆架构与里程碑 → issue 化 bug → 研究报告驱动修复 → commit 同步 changelog/status，维护项目的变更日志与进度。\n首先最明显的改变即是 CLAUDE.md 改为 AGENTS.md，但他们维护重心都是要求要短、准确、实用，不要写成又长又泛的“愿景文档”。\n我的长期维护文件也有变化，新增了decisions.md\n. ├── AGENTS.md # Codex 的长期项目指令，替代 CLAUDE.md ├── docs/ │ ├── brainstorm.md # 想法池，偏发散 │ ├── project_spec.md # 产品规格，偏稳定 │ ├── architecture.md # 架构设计 │ ├── project_status.md # 当前进度与里程碑 │ ├── changelog.md # 用户可读/开发可读变更记录 │ ├── decisions.md # 重要技术决策记录 │ └── bugs/ │ └── issue-xxx-research.md # 定位、写研究报告，再修 bug 我之前的工作流有一个问题，即每次commit会把变化放在 project_status 和 changelog，时间长了这部分记录会非常长，这类决策后面会反复影响实现。decisions.md 专门记录技术取舍，比放进 changelog 更清除\n在自己整理完想法文档后，建议不要让 Codex 直接让它根据对话结果自由生成项目规格，更加稳健的是执行以下指令\n阅读 docs/brainstorm.md。\n请不要修改代码。只做需求整理，生成 docs/project_spec.md。\n要求：\n区分「已确定需求」「待确认问题」「暂不实现」 每个核心功能必须包含：用户场景、输入、输出、边界条件、失败情况 不要脑补未在 brainstorm 中出现的功能 如果发现冲突，写入「待确认问题」，不要自行决定 如果有不确定的则向用户提问，直至你有信心能完成最初的 MVP Bug 工作流 我在 Claude 中处理 Bug 的工作流为：复杂 bug → 写研究报告research report → 提issue → 基于研究报告修复，这套工作流同样可适用于 Codex\nStep 1：创建研究报告\n阅读 GitHub issue #23 和相关代码。\n第一阶段：只调查，不修改代码。\n请生成 docs/bugs/issue-23-research.md，内容包括：\n现象复述 最小复现路径 相关文件和调用链 根因假设，按可能性排序 推荐修复方案 需要新增/修改的测试 风险点 完成后停止，不要修复。\nStep 2：基于研究报告修复 bug\n基于 docs/bugs/issue-23-research.md 修复 bug。\n要求：\n先实现最小修复，不做无关重构 添加或更新测试 运行相关测试 更新 docs/changelog.md 和 docs/project_status.md 最后给出变更摘要、测试结果、风险说明 当然，可以将这些可复用的prompt封装成skill或者专属于你的命令行，这都是可行的。\n维护记忆文档 上述提到的维护长期文件，我们可以将这套规则写入 Codex 的记忆文档中，我们每次初始化记忆文档时，Codex 就会自动帮我们维护这些长期文件，这对长时间的工作是非常重要。\n同样我们也可以创建相关的skill\n以在 Windows PowerShell 为例，创建这个目录：\nmkdir \u0026#34;$HOME\\.agents\\skills\\codex-agents-init\u0026#34; mkdir \u0026#34;$HOME\\.agents\\skills\\codex-agents-init\\scripts\u0026#34; ~/.codex 主要是 Codex 配置和全局 AGENTS.md；用户级 Skill 的推荐位置是 $HOME/.agents/skills。\n然后创建SKILL.md：\nnotepad \u0026#34;$HOME\\.agents\\skills\\codex-agents-init\\SKILL.md\u0026#34; 把下面内容完整粘进去👇：\nname: codex-agents-init\ndescription: Initialize or update a repository AGENTS.md for Codex with long-term documentation maintenance rules. Use this when the user asks to initialize AGENTS.md, set up Codex project instructions, add docs/project_spec.md architecture/status/changelog rules, or create a document-driven Codex workflow.\nCodex AGENTS.md Initialization Skill Use this skill when the user asks to initialize or update Codex project instructions, especially with phrases like:\n初始化 AGENTS.md 初始化 agents 创建 Codex 项目规范 加上长期文件维护规则 初始化 docs 工作流 添加 project_spec / architecture / project_status / changelog 维护规范 set up AGENTS.md for this repo initialize Codex instructions Goal Create or update the current repository so that Codex has durable project instructions and long-term documentation maintenance rules.\nThe expected repository structure is:\n. ├── AGENTS.md └── docs/ ├── brainstorm.md ├── project_spec.md ├── architecture.md ├── project_status.md ├── changelog.md ├── decisions.md └── bugs/ Required behavior\nWhen invoked:\nLocate the repository root.\nPrefer git rev-parse --show-toplevel. If that fails, use the current working directory. Create AGENTS.md if it does not exist.\nIf AGENTS.md exists, preserve existing content.\nDo not delete existing project-specific rules. Do not overwrite the whole file. Append or replace only the managed block marked by: \u0026lt;!-- BEGIN: codex-long-term-docs --\u0026gt; \u0026lt;!-- END: codex-long-term-docs --\u0026gt; Ensure these files/directories exist:\ndocs/brainstorm.md docs/project_spec.md docs/architecture.md docs/project_status.md docs/changelog.md docs/decisions.md docs/bugs/ Do not overwrite existing documentation files.\nIf a file already exists, leave its content unchanged. If a file is missing, create it with a minimal useful template. Add long-term maintenance rules to AGENTS.md.\nThe rules must state:\ndocs/brainstorm.md is an idea pool and must not be treated as final requirements. docs/project_spec.md is the source of truth for product behavior. docs/architecture.md is the source of truth for system design and module boundaries. docs/project_status.md tracks current milestone, progress, blockers, and next actions. docs/changelog.md records user-visible or developer-visible behavior changes. docs/decisions.md records important technical decisions and rationale. docs/bugs/ stores complex bug investigation reports. For complex bugs, first create a research report under docs/bugs/, then implement the fix in a separate step unless the user explicitly asks otherwise. Before finishing implementation tasks, update docs/changelog.md and docs/project_status.md when relevant. Do not perform broad rewrites or unrelated refactors during initialization. Prefer using the bundled script: powershell -ExecutionPolicy Bypass -File .agents/skills/codex-agents-init/scripts/init-codex-agents.ps1 If the skill is installed in the user-level skill directory, run the script from that location instead:\npowershell -ExecutionPolicy Bypass -File \u0026#34;$HOME\\.agents\\skills\\codex-agents-init\\scripts\\init-codex-agents.ps1\u0026#34; After running, summarize:\nCreated files Skipped existing files Updated AGENTS.md Any errors or manual follow-up needed Safety rules\nNever delete existing documentation. Never overwrite an existing AGENTS.md. Never make unrelated code changes. Never commit automatically unless the user explicitly asks. If shell execution is unavailable, perform the same changes manually. 然后创建脚本：\nnotepad \u0026#34;$HOME\\.agents\\skills\\codex-agents-init\\scripts\\init-codex-agents.ps1\u0026#34; 在脚本里粘贴下面这份：\n$ErrorActionPreference = \u0026#34;Stop\u0026#34; function Get-RepoRoot { try { $root = git rev-parse --show-toplevel 2\u0026gt;$null if ($LASTEXITCODE -eq 0 -and $root) { return $root.Trim() } } catch { # Fallback below. } return (Get-Location).Path } $RepoRoot = Get-RepoRoot Set-Location $RepoRoot $Created = New-Object System.Collections.Generic.List[string] $Skipped = New-Object System.Collections.Generic.List[string] $Updated = New-Object System.Collections.Generic.List[string] function Ensure-Directory { param([string]$Path) if (!(Test-Path $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null $Created.Add($Path + \u0026#34;/\u0026#34;) } else { $Skipped.Add($Path + \u0026#34;/\u0026#34;) } } function Ensure-File { param( [string]$Path, [string]$Content ) $Dir = Split-Path $Path if ($Dir -and !(Test-Path $Dir)) { New-Item -ItemType Directory -Path $Dir -Force | Out-Null $Created.Add($Dir + \u0026#34;/\u0026#34;) } if (Test-Path $Path) { $Skipped.Add($Path) return } Set-Content -Path $Path -Value $Content -Encoding UTF8 $Created.Add($Path) } $AgentsBlock = @\u0026#34; \u0026lt;!-- BEGIN: codex-long-term-docs --\u0026gt; ## Long-term documentation workflow This repository uses durable documentation files under `docs/` as project memory. Before modifying code, read the relevant documentation files and preserve their roles. ### Documentation sources of truth - `docs/brainstorm.md`: rough ideas, pain points, and early product thinking. Do not treat this file as final requirements. - `docs/project_spec.md`: source of truth for product behavior, user scenarios, accepted requirements, non-goals, edge cases, and failure behavior. - `docs/architecture.md`: source of truth for system design, module boundaries, data flow, key abstractions, dependencies, and trade-offs. - `docs/project_status.md`: source of truth for current milestone, progress, blockers, open questions, and next actions. - `docs/changelog.md`: chronological record of user-visible or developer-visible behavior changes. - `docs/decisions.md`: important technical decisions and their rationale. - `docs/bugs/`: complex bug investigation reports. ### Before implementation - Read `docs/project_spec.md`, `docs/architecture.md`, and `docs/project_status.md` when relevant. - If `docs/brainstorm.md` conflicts with `docs/project_spec.md`, follow `docs/project_spec.md`. - If requirements are ambiguous, record assumptions or questions in `docs/project_status.md` instead of silently guessing. - Prefer small, coherent, reversible changes. ### Complex bug workflow For complex bugs: 1. First create a research report under `docs/bugs/`. 2. The report should include: - Symptom summary - Minimal reproduction path - Related files and call chain - Root-cause hypotheses ranked by likelihood - Recommended fix - Required tests - Risks and rollback notes 3. Do not implement the fix in the same step unless explicitly asked. ### Before finishing a task - Run relevant tests, or explain why they could not be run. - Update `docs/changelog.md` when behavior changes, a feature is added, a user-visible bug is fixed, configuration changes, or a breaking change is introduced. - Update `docs/project_status.md` when milestone progress, blockers, or next actions change. - Do not update `docs/changelog.md` for pure formatting, comment-only changes, test-only refactors, or internal renames with no behavior change. - Summarize changed files, tests run, and remaining risks. \u0026lt;!-- END: codex-long-term-docs --\u0026gt; \u0026#34;@ function Ensure-AgentsMd { $Path = \u0026#34;AGENTS.md\u0026#34; if (!(Test-Path $Path)) { Set-Content -Path $Path -Value \u0026#34;# AGENTS.md`n`n$AgentsBlock`n\u0026#34; -Encoding UTF8 $Created.Add($Path) return } $Existing = Get-Content -Path $Path -Raw $Pattern = \u0026#34;(?s)\u0026lt;!-- BEGIN: codex-long-term-docs --\u0026gt;.*?\u0026lt;!-- END: codex-long-term-docs --\u0026gt;\u0026#34; if ($Existing -match $Pattern) { $NewContent = [regex]::Replace($Existing, $Pattern, $AgentsBlock.Trim()) Set-Content -Path $Path -Value $NewContent -Encoding UTF8 $Updated.Add($Path + \u0026#34; managed block\u0026#34;) return } $Separator = if ($Existing.Trim().Length -eq 0) { \u0026#34;\u0026#34; } else { \u0026#34;`n`n\u0026#34; } Add-Content -Path $Path -Value ($Separator + $AgentsBlock) $Updated.Add($Path + \u0026#34; appended managed block\u0026#34;) } Ensure-AgentsMd Ensure-File \u0026#34;docs/brainstorm.md\u0026#34; @\u0026#34; # Brainstorm This file stores rough ideas, pain points, feature intuition, and early product thinking. Do not treat this file as final requirements. ## Raw ideas - ## Pain points - ## Core feature candidates - ## Open questions - \u0026#34;@ Ensure-File \u0026#34;docs/project_spec.md\u0026#34; @\u0026#34; # Project Specification This file is the source of truth for product behavior. ## Product goal - ## Users and scenarios - ## Core requirements - ## Non-goals - ## Inputs and outputs - ## Edge cases - ## Failure behavior - ## Acceptance criteria - \u0026#34;@ Ensure-File \u0026#34;docs/architecture.md\u0026#34; @\u0026#34; # Architecture This file is the source of truth for system design and module boundaries. ## Overview - ## Module boundaries - ## Data flow - ## Key abstractions - ## External dependencies - ## Risks and trade-offs - \u0026#34;@ Ensure-File \u0026#34;docs/project_status.md\u0026#34; @\u0026#34; # Project Status ## Current milestone Milestone: Status: ## Done - ## In progress - ## Blocked / Questions - ## Next actions 1. 2. 3. \u0026#34;@ Ensure-File \u0026#34;docs/changelog.md\u0026#34; @\u0026#34; # Changelog All notable changes to this project should be documented here. ## Unreleased ### Added - ### Changed - ### Fixed - ### Removed - \u0026#34;@ Ensure-File \u0026#34;docs/decisions.md\u0026#34; @\u0026#34; # Technical Decisions This file records important technical decisions and their rationale. ## Template ### YYYY-MM-DD - Decision title Status: Proposed / Accepted / Rejected / Superseded Context: Decision: Alternatives considered: Consequences: \u0026#34;@ Ensure-Directory \u0026#34;docs/bugs\u0026#34; Write-Host \u0026#34;\u0026#34; Write-Host \u0026#34;Codex AGENTS.md documentation workflow initialized.\u0026#34; Write-Host \u0026#34;Repository root: $RepoRoot\u0026#34; Write-Host \u0026#34;\u0026#34; Write-Host \u0026#34;Created:\u0026#34; if ($Created.Count -eq 0) { Write-Host \u0026#34; - None\u0026#34; } else { $Created | ForEach-Object { Write-Host \u0026#34; - $_\u0026#34; } } Write-Host \u0026#34;\u0026#34; Write-Host \u0026#34;Updated:\u0026#34; if ($Updated.Count -eq 0) { Write-Host \u0026#34; - None\u0026#34; } else { $Updated | ForEach-Object { Write-Host \u0026#34; - $_\u0026#34; } } Write-Host \u0026#34;\u0026#34; Write-Host \u0026#34;Skipped existing:\u0026#34; if ($Skipped.Count -eq 0) { Write-Host \u0026#34; - None\u0026#34; } else { $Skipped | ForEach-Object { Write-Host \u0026#34; - $_\u0026#34; } } 这样你的 Skill 结构就是：\n$HOME\\.agents\\skills\\codex-agents-init\\ ├── SKILL.md └── scripts\\ └── init-codex-agents.ps1 保存后，重启 Codex 桌面端,之后你在 Codex 里可以这样说：\n/codex-agents-init 初始化当前项目的 AGENTS.md，并加上长期文件维护规范。\n或者直接说：\n初始化 AGENTS.md，并创建 docs/project_spec.md、architecture.md、project_status.md、changelog.md 等长期维护文件。\n结语 以上就是我目前的工作流的整体迁移，整体来说都是比较无缝衔接的，希望这篇帖子对你有所帮助~\n","permalink":"https://Jul1en-Lin.github.io/p/claude-%E8%BF%81%E7%A7%BB-codex-%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%9B%B4%E6%96%B0/","title":"Claude 迁移 Codex 工作流更新"},{"content":" Redis 进阶 持久化 Redis 的持久化有两个策略\nRDB -\u0026gt; Redis DataBase AOF -\u0026gt; Append Only File RDB RDB 的触发时机有两种，一种是手动存储，一种是定时存储。将数据备份到数据库中，通常存在一个后缀名为 .rdb的文件\n定时存储通过配置文件修改\n主要讲手动存储，它有两个命令，save 和 bgsave\nsave：执行save操作，Redis 会全力以赴的进行快照1生成的工作，此时就会阻塞 Redis 客户端的其他命令，达到类似 keys * 的效果\nbgsave：bgsave 就是background save，在后台生成一个新的进程，来进行并发的编程。新的进程会 fork 一份父进程的完整数据（包括 pcb，内存中的虚拟地址，文件描述符表等\u0026hellip;..），将完整数据存入到 .rdb 文件中，当bgsave执行过程中，若有其他的数据插入，则可能会有迟滞性，因为新的数据不同步到子进程。\n注意：由于子进程有着跟父类一样的文件描述符表，故子进程也可以对文件进行读写操作。\n执行逻辑为：判定是否存在其他的子进程，若存在则将当前的bgsave返回，否则新建一个子进程。\n此处的 fork 拷贝操作是“写时拷贝”的机制实现的——即如果父进程与子进程里的数据是一模一样的，父进程与子进程会共用同一份内存空间，当对内存数据做出修改后，才会触发真正的物理内存的拷贝。新的数据会插入到父进程中，子进程数据保持原有的数据不更新。\nRDB 这个二进制文件默认通过二进制的方式将内存中的数据以压缩的形式保存\n后续 Redis 重启，会重新加载这个 RDB 文件，若 RDB 损坏则可能启动失败。针对 RDB 损坏的问题，自带了一个 RDB 的检查工具redis-check-rdb*\nRDB 的持久化操作是可以重复执行的，此时会生成一个 RDB 镜像文件，将快照数据先保存至一个临时的文件中，当快照生成完毕后，将旧的 RDB 文件删除，将临时的文件夹改名为dump.rdb（默认文件名）\nRDB 的优缺点：\nRDB 是紧凑压缩过的二进制文件，代表了 Redis 中某个时间点上的数据快照，非常适用于备份 Redis 加载 RDB 恢复数据远远快于 AOP RDB 的这种方式存储就决定了它的数据备份无法达到数据的实时持久化，因为每次的 bgsave 操作都要 fork 一个子进程出来，这是一个重量级的操作 RDB 使用的是特定的二进制格式保存，且随着时间迁移，Redis 会有多个 RDB 版本，可能会有兼容性问题 AOF AOF 以日志形式记录 Redis 的写操作命令，重启时重新执行这些命令恢复数据，默认是后缀为 .aof的文件。\n写入策略通过配置文件设置，有三种模式：\nalways：每次写命令都立即刷盘，数据最安全，但性能开销最大 everysec：每秒刷盘一次（默认），最多丢失 1 秒数据，兼顾性能与安全 no：由操作系统决定刷盘时机，性能最高，但数据安全性最低 AOF 重写\n随着写操作增多，AOF 文件会持续膨胀。Redis 提供重写机制压缩文件体积，命令为 bgrewriteaof\n类似 bgsave，fork 出一个子进程，基于当前内存数据生成最新且最精简的命令集 重写期间的新写入命令会同时写入旧的 AOF 文件和 AOF 重写缓冲区 子进程完成重写后，将缓冲区的增量命令追加到新文件，最后用新文件替换旧文件 同样利用\u0026quot;写时拷贝\u0026quot;机制，避免全量内存复制 AOF 文件以文本协议存储，具备可读性，但体积通常比 RDB 大。若文件损坏，可使用自带的 redis-check-aof 工具修复。\nRedis 重启时若同时存在 AOF 和 RDB，默认优先加载 AOF。\nAOF 的优缺点：\n数据安全性更高，通过合理的 appendfsync 策略可实现近实时持久化 文件为文本格式，易读且兼容性好，没有 RDB 的版本兼容问题 文件体积通常大于 RDB，恢复速度比 RDB 慢（需逐条重放命令） 在高并发写入场景下，always 策略会显著影响性能；everysec 模式下若发生故障仍可能丢失 1 秒数据 ‍\n快照 快照（Snapshot）是指系统在某一特定时刻的完整逻辑状态记录。它类似于对运行中的数据进行“拍照”，保留该瞬间的所有信息，即便后续原始数据发生变化，快照内容依然停留在抓拍的那一刻。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://Jul1en-Lin.github.io/p/redis-%E8%BF%9B%E9%98%B6/","title":"Redis 进阶"},{"content":" Hooks 概念 学习概念最好的方法就是参考官方文档：code.claude.com/docs/en/hooks Hooks 是用户定义的 shell 命令、HTTP 端点或 LLM 提示，它们在 Claude Code 的生命周期中的特定点自动执行。用于参考查找事件模式、配置选项、JSON 输入/输出格式以及异步 hooks、HTTP hooks 和 MCP 工具 hooks 等高级功能。\n生命周期（Lifecyle） Hook 在 Claude Code 会话的特定时间点触发。当事件被触发且匹配器匹配时，Claude Code 会将关于该事件的 JSON 上下文传递给您的钩子处理器。\n对于命令 Hook，输入会通过标准输入（stdin）到达 对于 HTTP Hook，输入会作为 POST 请求体到达 然后你的处理器可以检查输入、采取行动，并可选择返回一个决策。\n事件分为三种节奏：每次会话一次（ SessionStart ， SessionEnd ）、每次回合一次（ UserPromptSubmit ， Stop ， StopFailure ），以及在每个工具调用中（在代理循环内）（ PreToolUse ， PostToolUse ），如图：\n执行时间 下表总结了每个事件触发的时间。Hooks 事件部分记录了每个事件的完整输入模式和决策控制选项。\nEvent 触发时机 SessionStart 当会话开始或恢复 UserPromptSubmit 当你提交提示词，在 Claude 处理它之前 PreToolUse 在工具调用执行之前。可能会阻止它 PermissionRequest 当权限对话框出现时 PermissionDenied 当自动模式分类器拒绝工具调用时，返回 {retry: true} 告诉模型它可以重试被拒绝的工具调用 PostToolUse 工具调用成功后 PostToolUseFailure 工具调用失败后 Notification 当 Claude Code 发送通知时 SubagentStart 当子代理被创建时 SubagentStop 当子代理完成时 TaskCreated 当任务通过 TaskCreate 创建时 TaskCompleted 当任务被标记为完成时 Stop 当 Claude 完成响应 StopFailure 当本轮交互因API 错误而终止时触发，此时输出内容及退出码将被忽略 TeammateIdle 当代理团队的队友即将进入空闲状态 InstructionsLoaded 当 CLAUDE.md 或 .claude/rules/*.md 加载至上下文时触发；会话启动及会话中文件懒加载时均会触发 ConfigChange 当会话期间配置文件发生变化时触发 CwdChanged 工作目录发生变更时触发（例如 Claude 执行 cd 命令），可搭配 direnv 等工具实现响应式环境管理。 FileChanged 当磁盘上的被监听文件发生变更时触发，匹配器字段用于指定需要监听的文件名称。 WorktreeCreate 通过 \u0026ndash;worktree 参数或 isolation: \u0026ldquo;worktree\u0026rdquo; 配置创建 Git 工作区时触发该行为会替换 Git 的默认逻辑 WorktreeRemove 移除 Git 工作区时触发，触发时机为会话退出或子代理执行完毕后 PreCompact 在上下文压缩之前触发 PostCompact 上下文压缩完成后触发 Elicitation MCP 服务器在工具调用过程中请求用户输入时触发 ElicitationResult 用户响应 MCP 信息征询后、响应结果回传至服务器前触发 SessionEnd 会话结束时触发 如何解析 要了解这些部分如何协同工作，可以以 PreToolUse 钩子举例，它用于阻止破坏性 shell 命令。 matcher 会缩小到 Bash 工具调用，而 if 条件会进一步缩小到以 rm 开头的命令，因此 block-rm.sh 只会在两个过滤器都匹配时才会触发\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PreToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Bash\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;if\u0026#34;: \u0026#34;Bash(rm *)\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;\\\u0026#34;$CLAUDE_PROJECT_DIR\\\u0026#34;/.claude/hooks/block-rm.sh\u0026#34; } ] } ] } } 该脚本从标准输入读取 JSON 输入，提取命令，如果包含 rm -rf ，则返回 permissionDecision 的 \u0026ldquo;deny\u0026rdquo; ：\n#!/bin/bash # .claude/hooks/block-rm.sh COMMAND=$(jq -r \u0026#39;.tool_input.command\u0026#39;) if echo \u0026#34;$COMMAND\u0026#34; | grep -q \u0026#39;rm -rf\u0026#39;; then jq -n \u0026#39;{ hookSpecificOutput: { hookEventName: \u0026#34;PreToolUse\u0026#34;, permissionDecision: \u0026#34;deny\u0026#34;, permissionDecisionReason: \u0026#34;Destructive command blocked by hook\u0026#34; } }\u0026#39; else exit 0 # allow the command fi 现在假设 Claude Code 决定运行 Bash \u0026ldquo;rm -rf /tmp/build\u0026rdquo; 。以下是解析流程图\n如何配置 Hooks 在 JSON 配置文件中定义。配置具有三个级别的嵌套：\n选择要响应的 hook 事件，例如 PreToolUse 或 Stop\n添加一个匹配器组来过滤何时触发，例如“仅适用于 Bash 工具”\n定义一个或多个 hook 处理器来在匹配时运行\n具体 hook 实现可参考官方文档：https://code.claude.com/docs/en/hooks-guide#reload-environment-w\u0026hellip; 如果不知道如何手动配置，那就把网站丢给他让它给你配置就好了~！\nRAG 中文语义就是检索增强生成，是一种技术理念\n概念 RAG 是指对大语言模型输出进行优化，使其能够在生成响应之前引用训练数据来源之外的权威知识库。 LLM用海量数据进行训练，使用数十亿个参数为回答问题、翻译语言和完成句子等任务生成原始输出。\n在 LLM 本就强大的功能基础上，RAG 将其扩展为能访问特定领域或组织的内部知识库，所有这些都无需重新训练模型。 这是一种经济高效地改进 LLM 输出的方法，让它在各种情境下都能保持相关性、准确性和实用性。\n为什么RAG很重要？ LLM 技术的性质给 LLM 响应带来了不可预测性。此外，LLM 训练数据是静态的，从而为其掌握的知识限定了截止日期。\nLLM 面临的已知挑战包括：\n在没有答案的情况下提供虚假信息。 在用户需要具体的最新响应时，提供过时或宽泛的信息。 依据非权威来源创建响应。 由于术语混淆，不同的培训来源使用相同的术语来谈论不同的事情，因此会产生不准确的响应。 可以将大语言模型 看作是一个过于热情的新员工，他拒绝随时了解时事，但总是会绝对自信地回答每一个问题，这就有很大可能会回答错或者过时。则向量数据库能够提供实时信息来源。\nRAG 是解决其中这些问题的一种方法。它会重定向 LLM，从权威的、预先确定的知识来源中检索相关信息。 组织可以更好地控制生成的文本输出\n工作原理 如果没有 RAG，LLM 会接收用户输入，并根据训练信息（即其已知信息）创建响应。\n如果有 RAG，则会引入一个信息检索组件，利用用户输入首先从新数据源提取信息。用户查询和相关信息都提供给 LLM。 LLM 使用新知识及其训练数据来创建更好的响应。以下各部分概述了该过程。\n创建外部数据 LLM 原始训练数据集之外的新数据称为外部数据。它可以来自多个数据来源，例如 API、数据库或文档存储库。\n数据可能以各种格式存在，例如文件、数据库记录或长篇文本。另一种称为嵌入语言模型的技术将数据转换为数字表示形式并将其存储在向量数据库中。\n检索相关信息 下一步是执行相关性搜索。用户查询将转换为向量表示形式，并与向量数据库匹配。\n例如，一个可以回答组织的人力资源问题的智能聊天机器人。如果员工搜索 ：“我有多少年假？” ，系统将检索年假政策文档以及员工个人过去的休假记录。将会返回这些与员工输入的内容高度相关的特定文档。 相关性是使用数学向量计算和表示法计算得出并确立的。\n增强 LLM 提示 接下来，RAG 模型通过在上下文中添加检索到的相关数据来增强用户输入或提示（就是用户输入 + RAG模型添加的相关数据发给LLM）\n更新外部数据 下一个问题可能是——如果外部数据过时了怎么办？ 要维护当前信息以供检索，可以异步更新文档并更新文档的嵌入表示形式，通过自动化实时流程或定期批处理来执行此操作。\n具体的流程图所示：\n检索增强生成与语义搜索有什么区别？ 语义搜索可以完善 RAG 结果，适用于想要在其 LLM 应用程序中添加大量外部知识源的组织。现代企业在各种系统中存储大量信息，例如手册、常见问题、研究报告、客户服务指南和人力资源文档存储库等。上下文检索在规模上具有挑战性，因此会降低生成输出质量。\n语义搜索技术可以扫描包含不同信息的大型数据库，并更准确地检索数据。 例如，他们可以回答诸如 “去年在机械维修上花了多少钱？” 之类的问题，方法是将问题映射到相关文档并返回特定文本而不是搜索结果。 然后，开发人员可以使用该答案为 LLM 提供更多上下文。\nRAG 中的传统或关键字搜索解决方案对知识密集型任务产生的结果有限。 相比之下，语义搜索技术可以完成知识库准备的所有工作（单词嵌入、文档分块和其他复杂问题） 。它们还生成语义相关的段落和按相关性排序的标记词，以最大限度地提高 RAG 有效载荷的质量。\n我的搜索引擎项目不是 RAG，是用到了语义搜索模块，RAG项目讲结果作为上下文重新再交给LLM生成，我的是直接返回结果。\nSkill 它是模块化、可复用、按需加载的结构化能力封装标准。\n什么是skills：Skill不是写得更好的prompt，而是可安装、可复用、带执行能力的能力包；它既不是纯文本提示，也不是静态知识库，而是让AI主动识别任务、调用工具、执行动作的标准化单元。一个文件夹即一个Skill，最小仅需一个 skill.md 即可运行；包含三核心组件——YAML元数据+Markdown指令层+可执行脚本（Python/Shell/JS）+按需加载的参考资料。\n与prompt的区别 Prompt是反应式、一次性、高开销：用户提问才响应，对话结束即失效，越长越贵，无法跨会话复用，上下文占用大。\nSkill是主动式、持久化、低消耗：安装一次长期生效，AI自主判断调用时机；按需加载资源，支持多Skill组合成流水线；包含prompt但远超prompt——它是结构化、可执行、可迭代的能力工程产物。\n内部四层结构 Skill内部四层结构解析\nYAML Front Matter（元数据层）： name 和 description 为必填项，其中description是AI路由决策的核心依据，决定能否被精准触发。\nMarkdown正文（指令层）：定义角色、核心任务、输出格式、约束条件等，相当于AI执行任务的“操作手册”。\nScripts目录（执行层）：存放Python/Shell/JavaScript等可执行脚本，AI在沙箱中真实运行代码，不止于生成文字。\nResources目录（资源层）：存放合规手册、知识文档等参考资料，仅在指令明确引用时才加载，避免无效token消耗。\n‍\nAgent调用Skill全流程演示（以会议审计为例）\n路由阶段：用户输入“分析年度采购会议记录，检查超预算违规项”，Agent扫描所有Skill description，LLM推理命中关键词“会议记录”“审计”“预算”，锁定 meeting-auditor 技能。\n加载阶段：系统将 meeting-auditor.skill.md 全文载入上下文，AI获得完整审查逻辑与输出模板。\n引用阶段：指令中声明“参考compliance-rules.md第三章”，仅该章节被加载，其余合规文档零占用。\n推理阶段：AI结合会议记录+合规条款生成审计报告草稿。\n执行阶段：用户确认后输入“归档”，Agent调用归档脚本，在沙箱中执行并返回唯一归档编号——全程一句自然语言驱动全链路自动化。\n","permalink":"https://Jul1en-Lin.github.io/p/agent-%E6%A6%82%E5%BF%B5/","title":"Agent 概念"},{"content":" Git LFS 接触此技术背景 本人一直有将笔记备份的习惯，虽然已经有云同步，但是异地存档能再加多层保险\n随时间推移，笔记内容也越来越多，我的笔记快照的体积越来越大，最近一次直接超出了 Git 单次推送的最大体积限制（100MB），在查询解决方法时，发现 Git 有一种叫 LFS 的技术（Large File Storage），他能很好的解决此问题。\n技术介绍 Git LFS 是 Git 的一个开源扩展，旨在解决 Git 在处理大尺寸二进制文件（如视频、数据集、压缩包）时的性能瓶颈。\n因为 Git 的设计初衷是管理文本文件，它会完整记录文件的每一个版本。对于像我这样提交这么大我体积的的 ZIP 包，如果修改一次再提交，仓库体积会立刻翻倍。\n底层原理 那么它的思路就是把大文件给“拆开”。\nLFS 的解决方法是：当你将文件放入 LFS 管理时，Git 仓库中存储的不再是那个 180MB 的实体，而是一个几百字节的指针文件。真实的二进制数据被存储在 GitHub 的 LFS 专用服务器上。\n当执行 git checkout​ 时，LFS 会根据指针里的哈希值，自动从服务器下载对应的真实文件覆盖到你的工作区。这样本地的 .git 目录（版本库历史）依然保持精简，这个设计非常巧妙。\n技术实现 我的最近一次提交是错误的，虽然提交了但是 push 失败了，故必须通过重置索引并应用 LFS 规则来修复。\n首先要安装 LFS 扩展\n# 在你本地的git工作区 git lfs install 标记你的大文件由 LFS 来管理，以我的笔记快照为例，它的文件格式就是 .ZIP，那么命令就是\ngit lfs track \u0026#34;*.zip\u0026#34; 注意：这会生成或更新 .gitattributes 文件，必须将其一同提交\n重新添加并提交\n由于你之前的 Commit 已经包含了这个超限文件，需要先撤销上一次失败的提交，重新打包提交\n# 撤销上一次 commit，但保留文件变动 git reset --soft HEAD~1 # 重新添加属性文件和大文件 git add . git commit -m \u0026#34;commit message\u0026#34; git push origin master 执行到这一步，就已经能正确 push 上去了\n后续维护 看到这会有疑惑：那么这次能提交成功了，后续该如何维护呢？\n维护 LFS 需要关注 ​.gitattributes​ 文件、本地存储占用以及云端配额三个维度\n追踪管理\n你需要明确告诉 Git 哪些文件属于大文件。使用命令 git lfs track \u0026quot;你的文件\u0026quot;​。 这会更新 .gitattributes​ 文件。每次更新必须提交这个属性文件，否则协作成员拉取代码时，Git 会将其当作普通大文件处理，再次导致推送失败\n监控状态\n使用 git lfs ls-files：查看当前索引中哪些文件正受 LFS 管理 使用 git lfs status：检查当前暂存区的对象状态 本地存储清理\n长期频繁更新大文件会导致本地 .git/lfs​ 目录体积激增。使用 git lfs prune 可以安全删除本地不再被当前分支引用的旧版二进制块。\n配额管理\nGitHub 免费账户提供 1GB 存储和 1GB 每月下行流量，这要注意它的限制，如果配额耗尽，别人拉下来的包都是指针文本，不会真正下载内容\n希望对你有帮助，祝您身体健康。\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/git-lfs-%E7%AA%81%E7%A0%B4%E5%A4%A7%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E9%99%90%E5%88%B6/","title":"Git LFS 突破大文件上传限制"},{"content":" 抽奖系统项目 此博客旨在记录学习项目时遇到的各种技术问题，以便后续复盘学习，同时起到记录勉励作用。持续更新\u0026hellip;\n遇到的问题 技术细节错误 Mybatis字段处理器TypeHandler：写库时自动对手机号做对称加密，读库时自动解密还原，统一把加解密收敛在持久层，便于在注册校验（如重复手机号校验）中复用，避免业务层手动处理明文。\n测试的时候遇到了登录错误的问题\n测试已有管理员账户登录时，输入的正确的密码返回异常。开始以为是密码的Md5加密方式出了问题，日志捕捉后发现并没有走到验证密码这一步，且SQL语句查询正常。继续往前排查，发现校验密码前会有用户手机号AES加密解密的过程，如果手机号对不上就会返回异常。问题出现在字段处理器的手机号字段的加密方式与解密方式不匹配，数据库存的是生成的十六进制密文，但解密时把这串十六进制文本当成普通UTF-8字节数组去解，导致登录查询阶段提前报错。表现上是像密码错误，其实根本原因是phone_number解密失败。\n各类相似方法取舍 StringUtils 用户名 / 密码校验时，hasLength与hasText区别\n​StringUtils.hasLength(str)​：只要不是 null​ 且长度 \u0026gt; 0​ 就是 true​，\u0026quot; \u0026quot;​（空格）会返回 true ​StringUtils.hasText(str)​：要求有“可见文本”，即不是 null​、长度 \u0026gt; 0​，且至少有一个非空白字符，如果是\u0026quot; \u0026quot;​（全是空格）会返回 false ​@Valid​ 和 @Validated 在Controller层请求体对象做字段检验（@NotNull​、@NotBlank​ 等）时，优先使用@Valid\n​@Validated 更适合做分组校验，适合后续新增 / 更新不同的规则 异常通知处理 在校验参数时考虑是否根据不同的错误设置不同状态码 or 默认返回200\n感觉接口能正常工作，应该返回200，统一结果返回时可以看到具体问题\n如传参错误本质上是客户端请求错误，理应返回4xx，且客户端SDK、重试策略、缓存策略也常依赖状态码 反序列化问题 问题背景 在奖品创建接口中，由于需要上传奖品的属性以及奖品的图片，是一份表单格式的数据，所以用到 ​@RequestPart​，在用到此注解中产生了一些问题。\n接口为：\n@PostMapping(value = \u0026#34;/create\u0026#34;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Long createPrize(@RequestPart(\u0026#34;params\u0026#34;) @Valid CreatePrizeRequest createPrizeRequest, @RequestPart(\u0026#34;file\u0026#34;) MultipartFile file) 测试工具使用 Body -\u0026gt; form-data 传参：\n​params：text，内容为 JSON ​file：图片文件 报错 ​Required part 'params' is not present 阶段：Multipart part 匹配阶段 触发原因：后端按 @RequestPart(\u0026quot;params\u0026quot;) 查找 part，但请求中没有被识别为该 part（key 不一致、请求头/boundary 异常、part 类型不匹配等） 由于@RequestPart 不是简单地拿字符串，它需要一个可被消息转换器识别并转换的 part\n如果 params​ 是标准 JSON part（application/json），Spring 默认转换器通常可直接反序列化 如果 params​ 作为 text/plain 发送，默认链路可能无法稳定命中 JSON 反序列化，需要自定义 converter 踩坑点 项目版本及依赖差异： 当前项目（Spring Boot 4）实际使用 tools.jackson.* 直接照搬 com.fasterxml.* 版本的 converter 会出现类找不到或不兼容 过时的 API： 当时想复用现成的converter，将JSON字符串很好的解析成需要的对象属性，但由于版本差异老抽象类已经不支持使用了\n改进 自定义读取型 converter 自定义MultipartJackson2HttpMessageConverter类\n让它实现 HttpMessageConverter\u0026lt;Object\u0026gt; 并支持 application/json​、text/plain​、application/octet-stream当设成 Text 传 JSON，也能被识别 仅支持读取（canWrite=false），避免影响现有的响应序列化 使用 tools.jackson.databind.ObjectMapper 主流依赖进行反序列化 实现配置类：在 MVC 转换器链中优先注册 自定义MessageConverterConfig类\n通过 extendMessageConverters 注册 放在索引 0​，保证优先匹配 @RequestPart(\u0026quot;params\u0026quot;) 两个类的关系 ​MultipartJackson2HttpMessageConverter​：负责 “怎么转换” ​MessageConverterConfig​：负责 “把谁注册进 Spring，并定义优先级” 运行逻辑 请求进来，命中 @RequestPart(\u0026quot;params\u0026quot;) Spring 在转换器列表里找谁能读这个 part ​MessageConverterConfig​ 提前注册的 MultipartJackson2HttpMessageConverter 被选中 converter 调 ObjectMapper.readValue(...)​ 转成 CreatePrizeRequest类 测试用例 Method：POST URL：/prize/create Body：form-data ​params​：Type=Text，值： {\u0026#34;name\u0026#34;:\u0026#34;小车模\u0026#34;,\u0026#34;description\u0026#34;:\u0026#34;1:64车模\u0026#34;,\u0026#34;price\u0026#34;:200} ​file​：Type=File，选择图片 不手动改 Content-Type，让工具自动生成 multipart boundary 可复用代码 当接口需要同时接收“文件 + JSON 文本”且前端以 form-data text 方式传 JSON 时，最稳妥做法是：\n保持 @RequestPart 接口语义 增加只读型 multipart JSON converter 在 MVC 链路中前置注册 这样既保留参数校验能力（@Valid​），也能兼容测试工具常见的 text/plain JSON 传法\nConverter类：\n/** * 自定义 multipart 参数转换器。 * 目标是让 @RequestPart(\u0026#34;params\u0026#34;) 能解析 text/plain 的 JSON 文本。 */ @Component public class MultipartJackson2HttpMessageConverter implements HttpMessageConverter\u0026lt;Object\u0026gt; { private static final List\u0026lt;MediaType\u0026gt; SUPPORTED_MEDIA_TYPES = Arrays.asList( MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN, MediaType.APPLICATION_OCTET_STREAM ); private final ObjectMapper objectMapper; public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @Override public boolean canRead(Class\u0026lt;?\u0026gt; clazz, MediaType mediaType) { return isSupportedMediaType(mediaType); } @Override public boolean canWrite(Class\u0026lt;?\u0026gt; clazz, MediaType mediaType) { return false; } @Override public List\u0026lt;MediaType\u0026gt; getSupportedMediaTypes() { return SUPPORTED_MEDIA_TYPES; } @Override public Object read(Class\u0026lt;?\u0026gt; clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { // multipart 中 params 为 text 时，按 UTF-8 文本读取后再转对象。 byte[] bodyBytes = inputMessage.getBody().readAllBytes(); String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.isBlank()) { throw new HttpMessageNotReadableException(\u0026#34;请求体为空\u0026#34;, inputMessage); } try { return objectMapper.readValue(body, clazz); } catch (Exception e) { throw new HttpMessageNotReadableException(\u0026#34;params不是合法JSON\u0026#34;, e, inputMessage); } } @Override public void write(Object o, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { // 该转换器只用于请求反序列化，不参与响应序列化。 throw new HttpMessageNotWritableException(\u0026#34;MultipartJackson2HttpMessageConverter不支持写入\u0026#34;); } private boolean isSupportedMediaType(MediaType mediaType) { if (mediaType == null) { return true; } return SUPPORTED_MEDIA_TYPES.stream().anyMatch(mediaType::isCompatibleWith); } } Config类：\n/** * 注册自定义消息转换器，兼容 multipart/form-data 中 text 类型的 JSON 参数。 */ @Configuration public class MessageConverterConfig implements WebMvcConfigurer { private final MultipartJackson2HttpMessageConverter multipartJackson2HttpMessageConverter; public MessageConverterConfig(MultipartJackson2HttpMessageConverter multipartJackson2HttpMessageConverter) { this.multipartJackson2HttpMessageConverter = multipartJackson2HttpMessageConverter; } /** * 将自定义转换器放在前面，优先处理 @RequestPart 场景下的 params 文本反序列化。 */ @Override public void extendMessageConverters(List\u0026lt;HttpMessageConverter\u0026lt;?\u0026gt;\u0026gt; converters) { converters.add(0, multipartJackson2HttpMessageConverter); } } RedisTemplate 默认序列化器导致 JSON 缓存解析失败 问题背景 在抽奖系统项目中，活动服务提供了 getDetail​ 接口用于查询活动详情。为提升查询性能，在创建活动时将活动详情序列化为 JSON 并缓存到 Redis，后续查询时优先读取缓存\n核心代码流程如下：\n// 写入缓存：先用 JacksonUtil 手动序列化为 JSON 字符串，再存入 Redis String json = JacksonUtil.serialize(detailDto); redisTemplate.opsForValue().set(key, json, CACHE_TIMEOUT); // 读取缓存：从 Redis 取出 JSON 字符串，再用 JacksonUtil 手动反序列化 String json = redisTemplate.opsForValue().get(key); ActivityDetailDto dto = JacksonUtil.deSerialize(json, ActivityDetailDto.class); 问题现象 调用 getDetail​ 接口时，从 Redis 读取到了缓存数据，但反序列化阶段抛出异常：\nCannot parse JSON Caused by: tools.jackson.core.exc.StreamReadException: Illegal character ((CTRL-CHAR, code 0)): only regular white space (\\r, \\n, \\t) is allowed between tokens 通过 Redis 可视化工具查看缓存内容，发现存储的 value 前面有大量 NUL（\\0）控制字符：\nJSON 本身内容是正确的，但前缀的 NUL 字符导致 Jackson 解析器无法识别。\n反序列化失败后，代码进入 catch 分支，执行了 redisTemplate.delete(key)​ 将缓存删除，造成缓存永远无法命中，每次查询都回源数据库，这非常影响性能\n排查过程 初步怀疑：JacksonUtil 序列化问题？ 最初怀疑是 JacksonUtil.serialize() 方法在序列化过程中引入了控制字符。但审查代码后发现：\npublic static String serialize(Object object) { return JacksonUtil.tryParse(() -\u0026gt; OBJECT_MAPPER.writeValueAsString(object)); } ​ObjectMapper.writeValueAsString()​ 是 Jackson 标准 API，输出的一定是合法的 JSON 字符串，不可能产生 NUL 前缀，故排除此怀疑\n定位问题：RedisTemplate 默认序列化器 进一步检查发现，ActivityServiceImpl 中注入的是：\n@Autowired private RedisTemplate\u0026lt;String, String\u0026gt; redisTemplate; 但项目中没有自定义 Redis 配置类。这意味着 Spring Boot 自动配置的 RedisTemplate​ 使用的是默认序列化器\n查阅 Spring Data Redis 源码，RedisTemplate​ 默认的 key 和 value 序列化器都是 JdkSerializationRedisSerializer：\n// org.springframework.data.redis.core.RedisTemplate if (this.defaultSerializer == null) { this.defaultSerializer = new JdkSerializationRedisSerializer(...); } ⚠️ 注意：虽然泛型声明为 RedisTemplate\u0026lt;String, String\u0026gt;​，但 泛型只是编译期约束，实际运行时序列化器仍然是默认的 JdkSerializationRedisSerializer，它会对任何对象（包括 String）执行 JDK 对象序列化。\n还原完整问题链路 写入阶段 JacksonUtil.serialize(dto) → 纯 JSON 字符串 ✅ ↓ RedisTemplate.set(key, json) ↓ JdkSerializationRedisSerializer.serialize(json) → JDK 二进制序列化 ❌ ↓ (添加 NUL 开头的二进制头) Redis 存储: [NUL NUL NUL ... ] + {\u0026#34;activityId\u0026#34;:21,...} 读取阶段 RedisTemplate.get(key) ↓ JdkSerializationRedisSerializer.deserialize(bytes) → 还原出带 NUL 的字符串 ↓ JacksonUtil.deSerialize(json) → 解析失败！遇到非法控制字符 ❌ 所以可以总结问题根因了：JdkSerializationRedisSerializer​ 对已经是 JSON 字符串的 value 做了二次序列化，在 JSON 前面添加了 JDK 序列化协议的二进制头（体现为 NUL 控制字符），导致反序列化时 Jackson 无法解析\n额外发现：set() 方法参数误用 redisTemplate.opsForValue().set(key, value, CACHE_TIMEOUT); 这里第三个参数 CACHE_TIMEOUT​（值为 3600L）本意是设置超时时间，但 ​set(K, V, long)​ ​ 的三参数重载中第三个参数是 offset（偏移量） ，并非过期时间。正确的超时设置需要使用四参数版本：\nredisTemplate.opsForValue().set(key, value, CACHE_TIMEOUT, TimeUnit.SECONDS); 对比项目中的正确用法 有趣的是，同项目中 UserServiceImpl​ 的发送邮箱验证码接口sendEmailCodeInternal​里，缓存邮箱验证码使用的是 StringRedisTemplate：\n@Autowired private StringRedisTemplate stringRedisTemplate; ​StringRedisTemplate​ 是 RedisTemplate\u0026lt;String, String\u0026gt;​ 的子类，构造函数中已将序列化器设置为 ​StringRedisSerializer​，所以邮箱验证码的存取从未出现过类似问题。\n解决方案 方案思路 既然问题出在\u0026quot;手动 Jackson 序列化 + 默认 JDK 序列化器\u0026quot;的冲突，我选择让 RedisTemplate 自身接管 JSON 序列化，彻底移除手动转换步骤。\n新增 Redis 配置类 @Configuration public class RedisConfig { @Bean public RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate\u0026lt;String, Object\u0026gt; template = new RedisTemplate\u0026lt;\u0026gt;(); template.setConnectionFactory(connectionFactory); // key 使用 String 序列化 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 使用 Jackson JSON 序列化，自动完成对象 ↔ JSON 转换 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } } ​GenericJackson2JsonRedisSerializer​ 会在 JSON 中自动写入 @class 类型信息，读取时可以自动还原为正确的 Java 类型\n更改后的Redis存入的数据如图，已经能正常序列化：\n简化业务代码 写入缓存 —— 直接存对象：\n- redisTemplate.opsForValue().set(key, JacksonUtil.serialize(detailDto), CACHE_TIMEOUT); + redisTemplate.opsForValue().set(key, detailDto, CACHE_TIMEOUT, TimeUnit.SECONDS); 读取缓存 —— 直接取对象：\n- String json = redisTemplate.opsForValue().get(key); - ActivityDetailDto dto = JacksonUtil.deSerialize(json, ActivityDetailDto.class); + Object cached = redisTemplate.opsForValue().get(key); + if (cached instanceof ActivityDetailDto dto) { + return dto; + } 完全移除了 JacksonUtil 在缓存场景的使用，以及之前为兼容脏数据写的大段 fallback 解析逻辑。\n核心收获 RedisTemplate vs StringRedisTemplate 特性 ​RedisTemplate ​StringRedisTemplate 默认序列化器 ​JdkSerializationRedisSerializer ​StringRedisSerializer 存入 Redis 的格式 JDK 二进制（不可读） 纯文本（可读） 取出后类型 Object（需强转） String 适用场景 需自定义序列化器后使用 简单字符串 KV 📌 教训：使用 RedisTemplate​ 时，必须显式配置序列化器，否则默认的 JDK 序列化会与手动 JSON 序列化产生冲突。\n常见的 Redis 序列化器选择 序列化器 存储格式 类型信息 适用场景 ​JdkSerializationRedisSerializer 二进制 内嵌 ❌ 不推荐，可读性差且有安全风险 ​StringRedisSerializer 纯字符串 无 简单字符串、需手动 JSON 转换 ​Jackson2JsonRedisSerializer JSON 无 明确类型的场景 ​GenericJackson2JsonRedisSerializer JSON + @class 有 通用 JSON 对象缓存 ✅ ​set() 方法重载辨析 // ❌ 错误：第三个参数是 offset（偏移量），不是超时时间 set(K key, V value, long offset) // ✅ 正确：显式指定时间单位的过期时间 set(K key, V value, long timeout, TimeUnit unit) // ✅ 正确：使用 Duration 的过期时间 set(K key, V value, Duration timeout) 总结 这个问题的表面现象是\u0026quot;JSON 解析失败\u0026quot;，但深层原因是对 RedisTemplate 默认行为的认知不足。整个排查链条为：\nJSON 解析报控制字符异常 → Redis 中缓存值有 NUL 前缀 → JdkSerializationRedisSerializer 对 JSON 字符串二次序列化 → 未自定义 RedisTemplate 的序列化器配置 最终通过添加 RedisConfig​ 配置类，使用 GenericJackson2JsonRedisSerializer 让 RedisTemplate 自身处理 JSON 序列化，从根本上解决了问题，同时也大幅简化了业务代码。\n","permalink":"https://Jul1en-Lin.github.io/p/%E6%8A%BD%E5%A5%96%E7%B3%BB%E7%BB%9F/","title":"🎏 抽奖系统"},{"content":" 博客系统项目 技术栈：SpringBoot + MyBatisPlus\n‍\n遇到的问题以及处理细节点：\n在处理返回博客信息的时候，通常不需要返回整个博客的信息BlogInfo类，只需要提供部分有用的信息返回给前端，故新建一个BlogInfoResponse类，也有利于数据安全\n在BlogInfo转换成BlogInfoResponse类时，用到对象的.stream()流，用.map()方法匹配，再将stream流转换为列表\n// 获取博客信息 将BlogInfo转换成BlogInfoResponse 提取部分博客的信息显示返回前端 Stream\u0026lt;BlogInfoResponse\u0026gt; stream = blogInfos.stream().map(blogInfo -\u0026gt; { BlogInfoResponse blogInfoResponse = new BlogInfoResponse(); BeanUtils.copyProperties(blogInfos, blogInfoResponse); return blogInfoResponse; }); // 再把流转换成List // return stream.collect(Collectors.toList()); 返回的列表可修改 return stream.toList(); // 返回的列表不可修改，安全性更高 上课时候，老师对转换为列表的处理是stream.collect(Collectors.toList())，它返回的是一个可修改的列表，大多数实现返回 ArrayList，官方文档指出：它不保证返回 List 的类型、可变性、可序列化性或线程安全性。如果下游代码修改了这个集合（如 list.add()），不会报错，这在多线程或需保持数据纯净的场景下可能是隐患\nstream.toList()：返回是不可变的列表，如果尝试调用如list.add()，则会直接抛出异常。在不需要后续修改列表的场景下，优先选择 stream.toList()。它不仅语法简洁，而且提供了更强的内存效率和数据安全性！\n执行流程：\n实现登录的两种模式\n使用Session：Session持久化——Spring Session 持久化.pdf 存储到MySQL 存储到Redis 不使用Session：JWT令牌\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.jsonwebtoken\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jjwt-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.11.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.jsonwebtoken\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jjwt-impl\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.11.5\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.jsonwebtoken\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jjwt-jackson\u0026lt;/artifactId\u0026gt; \u0026lt;!-- or jjwt-gson if Gson is preferred --\u0026gt; \u0026lt;version\u0026gt;0.11.5\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 参数校验工具——Jakarta Validation：支持声明式、基于注释的数据验证\n引入依赖：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-validation\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 加密（MD5）\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E7%BB%9F%E9%A1%B9%E7%9B%AE/","title":"博客系统项目"},{"content":" MyBatis MyBatis 是一款半自动对象关系映射持久层框架，其核心通过 XML 或注解实现原生 SQL 与 Java 对象的解耦映射，在消除冗余 JDBC 代码的同时，保留了对 SQL 执行逻辑的控制\n接下来先讲传统Mybatis的配置\nMyBatis配置 mybatis是IDE和数据库的桥梁，便于在后端也能执行SQL，故配置必须需要连接数据库~\n💫application.yml配置💫必备\n# 数据库连接配置 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8\u0026amp;useSSL=false\u0026amp;allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 # 设置日志文件的文件名 logging: file: name: spring-book.log MyBatis yml配置 在application.yml中可以增加mybatis的相关配置，如配置驼峰自动转换\nmybatis: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.spring.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-j\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; xml配置（动态SQL） xml一般是在写SQL中，简单的SQL已经无法满足业务需求，需要动态SQL时，需要的mybatis的配置，动态SQL就在xml文件里写。\n一般放在resources.mapper包下，命名规范为xxxMapper.xml，对应某个类。效果和注解Spring的@Select、@Update注解作用一致\n\u0026lt;!--将以下放到xml文件头--\u0026gt; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.bite.com.book.mapper.BookMapper\u0026#34;\u0026gt; \u0026lt;!--namespace为对应mapper包的某个类所在位置，位置可修改，如下--\u0026gt; \u0026lt;/mapper\u0026gt; MyBatisX 是一款plugin，支持xml的快速跳转和生成代码，非常好用~\nMyBatis-Generator插件 这个插件使用也是非常的广泛\n手写mapper还是太麻烦了，有一些插件能够帮我们自动生成一些sql，提高效率，同时需要generator文件\n\u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.generator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-generator-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.6\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;Generate MyBatis Artifacts\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;generate\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--generator.xml配置文件所在位置，位置可修改--\u0026gt; \u0026lt;configurationFile\u0026gt;src/main/resources/generate/generator.xml\u0026lt;/configurationFile\u0026gt; \u0026lt;!-- 允许覆盖生成的文件, mapxml不会覆盖, 采用追加的方式--\u0026gt; \u0026lt;overwrite\u0026gt;true\u0026lt;/overwrite\u0026gt; \u0026lt;verbose\u0026gt;true\u0026lt;/verbose\u0026gt; \u0026lt;!--将当前pom的依赖项添加到生成器的类路径中--\u0026gt; \u0026lt;includeCompileDependencies\u0026gt;true\u0026lt;/includeCompileDependencies\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.33\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/plugin\u0026gt; 上述插件提到的文件generator.xml ，需放所提到的\u0026lt;configurationFile\u0026gt;​对应的位置，位置可修改\n​generator.xml文件内容如下，一些配置项需要修改，如数据库连接信息\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE generatorConfiguration PUBLIC \u0026#34;-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd\u0026#34;\u0026gt; \u0026lt;!-- 配置生成器 --\u0026gt; \u0026lt;generatorConfiguration\u0026gt; \u0026lt;!-- 一个数据库一个context --\u0026gt; \u0026lt;!-- targetRuntime中可修改为MyBatis3，MyBatis3simple生成简单的SQL，选择MyBatis3，SQL更全面 --\u0026gt; \u0026lt;context id=\u0026#34;MysqlTables\u0026#34; targetRuntime=\u0026#34;MyBatis3Simple\u0026#34; defaultModelType=\u0026#34;flat\u0026#34;\u0026gt; \u0026lt;!--去除注释--\u0026gt; \u0026lt;commentGenerator\u0026gt; \u0026lt;property name=\u0026#34;suppressDate\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;suppressAllComments\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/commentGenerator\u0026gt; \u0026lt;!--数据库链接信息，URL需要配置，如端口号，ip，数据表名，要求与application.yml的spring数据库保持一致--\u0026gt; \u0026lt;jdbcConnection driverClass=\u0026#34;com.mysql.jdbc.Driver\u0026#34; connectionURL=\u0026#34;jdbc:mysql://127.0.0.1:3306/java_blog_spring?serverTimezone=Asia/Shanghai\u0026amp;amp;nullCatalogMeansCurrent=true\u0026#34; userId=\u0026#34;root\u0026#34; password=\u0026#34;root\u0026#34;\u0026gt; \u0026lt;/jdbcConnection\u0026gt; \u0026lt;!-- 以下实体类都可手动指定生成文件位置，位置为targetPackage--\u0026gt; \u0026lt;!-- 生成实体类 --\u0026gt; \u0026lt;javaModelGenerator targetPackage=\u0026#34;generator.entity\u0026#34; targetProject=\u0026#34;src/main/java\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;trimStrings\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;/javaModelGenerator\u0026gt; \u0026lt;!-- 生成mapxml文件 --\u0026gt; \u0026lt;sqlMapGenerator targetPackage=\u0026#34;mybatisGenerator\u0026#34; targetProject=\u0026#34;src/main/resources\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/sqlMapGenerator\u0026gt; \u0026lt;!-- 生成mapxml对应client，也就是接口dao --\u0026gt; \u0026lt;javaClientGenerator targetPackage=\u0026#34;generator.mapper\u0026#34; targetProject=\u0026#34;src/main/java\u0026#34; type=\u0026#34;XMLMAPPER\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/javaClientGenerator\u0026gt; \u0026lt;!-- table可以有多个,每个数据库中的表都可以写一个table，tableName表示要匹配的数据库表,也可以在tableName属性中通过使用%通配符来匹配所有数据库表,只有匹配的表才会自动生成文件 --\u0026gt; \u0026lt;table tableName=\u0026#34;user_info\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;useActualColumnNames\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;!-- 对应数据库表的主键 --\u0026gt; \u0026lt;generatedKey column=\u0026#34;id\u0026#34; sqlStatement=\u0026#34;Mysql\u0026#34; identity=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/context\u0026gt; \u0026lt;/generatorConfiguration\u0026gt; 配置完成，双击运行插件即可，配置文件​​generator.xml​就会生成对应的实体类与xml文件\n注意 ​generator.xml文件头放到IDE上会出现报错，无需更改\n🚩MyBatis-plus 是一个MyBatis的增强工具，简化开发，提高效率，类似于Hutool工具包，使用时仅需导入依赖即可\n它封装了许多SQL语句，使用时仅需让mapper类extends BaseMapper\u0026lt;\u0026gt;父类接口，执行时则可以直接调用他的一系列函数，无需手写\n配置 引入依赖 参考官方文档https://baomidou.com/getting-started \u0026lt;!--Spring Boot3--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot3-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.15\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--Spring Boot4--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot4-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.15\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 根据你现使用的SpringBoot版本选择对应的依赖~\n编码 编写对应实体类 编写 Mapper 接口并拓展BaseMapper\u0026lt;T\u0026gt;接口 完成简单两步即可实现内部丰富的函数\n打印执行SQL日志 与mybatis的配置文件类似，只需把名字改为mybatis-plus\n​application.yml\nmybatis-plus: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 识别原理 刚才只是配置了application.yml​中的数据库连接信息，拓展了BaseMapper\u0026lt;\u0026gt;​接口与实现了一个实体类UserInfo​，就能够查询数据库中的user_info这张表并且成功执行SQL了，它是如何识别出来的呢？\n表名 是根据实体类名推断的，如果不一致，使用@TableName注解映射实体类对应的表名是哪个\nid 是否有自增 执行insert操作时，表的id是自增主键，理想情况下ID应自增为3。但是ID一栏mybatis生成了随机数字，扰乱了原本的自增主键\n这是由于mybatis不能自动识别表中是否有自增，哪一个是自增主键，用到注释@TableId映射，它定义主键及其主键的性质（自增、输入，无性质）\n使用注解标识后就能很好的解决这个问题了\n查看源码，主键性质分为如下几类\n字段与属性的映射关系 他的推断规律与推断表名是一样的，如果不一致则用@TableField映射\n条件构造器Wrapper MyBatis-Plus 提供了一套强大的条件构造器（Wrapper），用于构建复杂的数据库查询条件。Wrapper 类允许开发者以链式调用的方式构造查询条件，无需编写繁琐的 SQL 语句，从而提高开发效率并减少 SQL 注入的风险\n它是构建复杂查询和更新条件的关键工具。它允许开发者以链式调用的方式构造 SQL 的 WHERE 子句，提供了极大的灵活性和便利性\nMyBatis-Plus 关于构造器的官方参考文档：https://baomidou.com/guides/wrapper AbstractWrapper：这是一个抽象基类，提供了所有 Wrapper 类共有的方法和属性。它定义了条件构造的基本逻辑，包括字段（column）、值（value）、操作符（condition）等。所有的 QueryWrapper、UpdateWrapper、LambdaQueryWrapper 和 LambdaUpdateWrapper 都继承自 AbstractWrapper\nQueryWrapper：专门用于构造查询条件，支持基本的等于、不等于、大于、小于等各种常见操作。它允许你以链式调用的方式添加多个查询条件，并且可以组合使用 and​ 和 or 逻辑\nUpdateWrapper：用于构造更新条件，可以在更新数据时指定条件。与 QueryWrapper 类似，它也支持链式调用和逻辑组合。使用 UpdateWrapper 可以在不创建实体对象的情况下，直接设置更新字段和条件\nLambdaQueryWrapper + LambdaUpdateWrapper\nLambdaWrapper类：这是一个基于 Lambda 表达式的查询条件构造器，它通过 Lambda 表达式来引用实体类的属性，从而避免了硬编码字段名。提高了代码的可读性和可维护性，尤其是在字段名可能发生变化的情况下\nWrapper类提供了许多SQL关键词，允许你以链式调用的方式添加多个查询条件，对此分类讨论\n基础比较算子\n​eq​：等于（=） ​ne​：不等于（\u0026lt;\u0026gt;） ​gt / ge​：大于（\u0026gt;​） / 大于等于（\u0026gt;=） ​lt / le​：小于（\u0026lt;​） / 小于等于（\u0026lt;=） allEq：全等于，通常接收 Map 进行多字段精确匹配 区间与集合筛选\nbetween / notBetween：值在指定范围内（BETWEEN ... AND ...） in / notIn：值在指定的集合、数组或子查询结果中（IN (...)） inSql / notInSql：通过原生的 SQL 子查询实现集合筛选 文本模式处理\nlike / notLike：模糊匹配，生成的 SQL 默认为 %值% likeLeft / likeRight：左模糊（%值​）与右模糊（值%​）。注意：在 B+ 树索引中，likeRight​ 可能维持索引有效，而 likeLeft 会导致全表扫描 空值与逻辑判定\nisNull / isNotNull：字段是否为 NULL exists / notExists：拼接 EXISTS (subquery) 逻辑 and / or：逻辑连接符，MyBatis-Plus 默认以 AND​ 拼接。若要实现 (A OR B) 这种嵌套逻辑，需在方法中传入 Lambda 表达式 排序、分组与结果控制\nselect：指定投影查询的字段，实现按需取数 groupBy / having：对应 SQL 的分组聚合逻辑 orderByAsc / orderByDesc：指定升序或降序排列 last：直接在 SQL 末尾追加字符串（如 LIMIT​ 或 FOR UPDATE），无视所有规则且存在注入风险 动态 SQL 与高级拼接\napply：用于拼接原生的 SQL 片段，支持通过 {index} 方式进行预编译参数绑定，防止 SQL 注入 nested：不带 AND​ 或 OR 的原生嵌套括号 代码示例 以id为自增主键的用户表名举例\n用户实体类\n@Data @TableName(\u0026#34;user_info\u0026#34;) public class UserInfo { @TableId(type = IdType.AUTO) private Integer id; @TableField(\u0026#34;user_name\u0026#34;) private String userName; private String password; private Integer deleteFlag; private Date createTime; private Date updateTime; } Mapper类\n@Mapper public interface UserInfoMapper extends BaseMapper\u0026lt;UserInfo\u0026gt; { // 注解构造SQL @Select(\u0026#34;select id,username,password,age FROM userinfo WHERE username = #{userName}\u0026#34;) List\u0026lt;UserInfo\u0026gt; queryUserInfoList(String userName); // Wrapper对象传参构造SQL @Select(\u0026#34;select id,username,password,age FROM userinfo ${ew.customSqlSegment}\u0026#34;) List\u0026lt;UserInfo\u0026gt; queryUserInfoList2(@Param(Constants.WRAPPER) QueryWrapper wrapper); // xml + Wrapper 构造SQL // xml内容： //\t\u0026lt;select id=\u0026#34;queryUserInfoList3\u0026#34;\u0026gt; // select id,username,password,age FROM userinfo ${ew.customSqlSegment} //\t\u0026lt;/select\u0026gt; List\u0026lt;UserInfo\u0026gt; queryUserInfoList3(@Param(Constants.WRAPPER) QueryWrapper wrapper); // 参数 + Wrapper 构造SQL @Update(\u0026#34;update userinfo set age = age+ #{addAge} ${ew.customSqlSegment}\u0026#34;) Integer updateUserInfoByCustomSql(@Param(\u0026#34;addAge\u0026#34;) Integer addAge, @Param(Constants.WRAPPER) QueryWrapper wrapper); } Wrapper类（示例）\n@SpringBootTest public class UserInfoMapperTest { @Autowired private UserInfoMapper userInfoMapper; /** * select id,username,password,age FROM userinfo where userName like %min% */ @Test void queryUserInfoList2() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(\u0026#34;userName\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.queryUserInfoList2(queryWrapper); System.out.println(userInfos); } @Test void queryUserInfoList3() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(\u0026#34;userName\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.queryUserInfoList3(queryWrapper); System.out.println(userInfos); } /** * update userinfo set age = age+ 10 where id in (1, 2, 3)) */ @Test void updateUserInfoByCustomSql() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.in(\u0026#34;id\u0026#34;,List.of(1,2,3)); userInfoMapper.updateUserInfoByCustomSql(10,queryWrapper); } /** * select id,username,password from user_info where age = 18 and username like %min% * userInfoMapper 调用BaseMapper的selectList方法 */ @Test void testQueryWrapper(){ QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.select(\u0026#34;id\u0026#34;,\u0026#34;username\u0026#34;,\u0026#34;password\u0026#34;).eq(\u0026#34;age\u0026#34;,18) .like(\u0026#34;username\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.selectList(queryWrapper); System.out.println(userInfos); } /** * delete from user_info where age = 18 */ @Test void testQueryWrapper2(){ QueryWrapper\u0026lt;UserInfo\u0026gt; userInfoQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userInfoQueryWrapper.eq(\u0026#34;age\u0026#34;, 18); int num = userInfoMapper.delete(userInfoQueryWrapper); System.out.println(num); } /** * QueryWrapper * update user_info set delete_flag = 1 where age \u0026lt; 20 */ @Test void testQueryWrapper3(){ QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.lt(\u0026#34;age\u0026#34;,20); // where age \u0026lt; 20 // 更新对象 UserInfo userInfo = new UserInfo(); userInfo.setDeleteFlag(1); int update = userInfoMapper.update(userInfo, queryWrapper); System.out.println(update); } /** * UpdateWrapper * update user_info set delete_flag = 0 where age \u0026lt; 20 */ @Test void testUpdateWrapper(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.set(\u0026#34;delete_flag\u0026#34;,0).lt(\u0026#34;age\u0026#34;,20); int update = userInfoMapper.update(updateWrapper); System.out.println(update); } /** * update user_info set delete_flag = 1,age = 5 where id in (2,3,5,6) */ @Test void testUpdateWrapper2(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.set(\u0026#34;delete_flag\u0026#34;, 1).set(\u0026#34;age\u0026#34;, 5) .in(\u0026#34;id\u0026#34;, List.of(2, 3, 5, 6)); int num = userInfoMapper.update(updateWrapper); System.out.println(num); } /** * update user_info set age = age + 10 where id in (2,3,5,6) */ @Test void testUpdateWrapper3(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.setSql(\u0026#34;age = age +10\u0026#34;) .in(\u0026#34;id\u0026#34;, List.of(2, 3, 5, 6)); int num = userInfoMapper.update(updateWrapper); System.out.println(num); } /** * LambdaQueryWrapper * select id,username,password from user_info where id = 10 and username like %min% */ @Test void testLambdaQueryWrapper() { /* 对照组 QueryWrapper\u0026lt;UserInfo\u0026gt; userInfoQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userInfoQueryWrapper.select(\u0026#34;id\u0026#34;, \u0026#34;username\u0026#34;, \u0026#34;password\u0026#34;) like(\u0026#34;username\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.selectList(userInfoQueryWrapper); System.out.println(userInfos); */ // lambda 1: 使用LambdaWrapper类 LambdaQueryWrapper\u0026lt;UserInfo\u0026gt; lambdaMapper = new LambdaQueryWrapper\u0026lt;\u0026gt;(); lambdaMapper.select(UserInfo::getId,UserInfo::getUserName, UserInfo::getPassword) .eq(UserInfo::getId,10).like(UserInfo::getUserName,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos1 = userInfoMapper.selectList(lambdaMapper); System.out.println(userInfos1); // lambda 2: 使用Wrapper类的lambda方法 QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.lambda().select(UserInfo::getId,UserInfo::getUserName, UserInfo::getPassword) .eq(UserInfo::getId,10).like(UserInfo::getUserName,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos2 = userInfoMapper.selectList(queryWrapper); System.out.println(userInfos2); } /** * LambdaUpdateWrapper * update user_info set delete_flag = 0 where id \u0026lt; 20 */ @Test void testLambdaUpdateWrapper(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.lambda().set(UserInfo::getDeleteFlag, 0) .lt(UserInfo::getId, 20); // where id \u0026lt; 20 int num = userInfoMapper.update(updateWrapper); System.out.println(num); } } ‍\n","permalink":"https://Jul1en-Lin.github.io/p/mybatis/plus-%E5%8F%8A%E9%85%8D%E7%BD%AE/","title":"🥓 MyBatis/plus 及配置"},{"content":" 打印日志 可在application.yml文件中配置\n# 设置日志文件的文件名 logging: file: name: spring-book.log Spring中使用注解@Slf4j，即可打印日志，日志如下 log.info / debug，日常的信息打印\nlog.error，用于排错\nlog.warn，用于警告\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E6%89%93%E5%8D%B0%E6%97%A5%E5%BF%97/","title":"打印日志"},{"content":" 性能测试 掌握性能测试工具是自动化测试中必备的技能，有助于提高效率\nJMeter配置 下载安装 JMeter官网jmeter.apache.org/download_jmeter.cgi ，点击Binary一栏，注意要求JDK为8及以上，选择任意解压包，下载后解压\ncd到你解压后的文件位置，访问bin文件找到jmeter.bat双击即可打开\n每次打开JMeter工具都需要找到文件夹非常麻烦，我们可以配置到环境变量中，这样每次启动只需要在控制台输入jmeter即可快速打开~\n环境配置 搜索“环境变量”，在Path中新增你的JMeter所在的文件夹的bin文件路径\n以我的放在D盘的JMeter文件夹为例\n在终端输入关键词jmeter即可快速打开\n中文配置 首次打开JMeter工具界面默认为英文，我们需要在JMeter的bin目录下，修改它的配置文件properties​，找到language​一栏，修改为zh_CN​，如果有注释 # 需要把它去掉，后面不要留有空格！ 修改后 ctrl + s 保存，重启JMeter即可\n​​ ‍\n","permalink":"https://Jul1en-Lin.github.io/p/jmeter-%E5%AE%89%E8%A3%85%E9%85%8D%E7%BD%AE/","title":"🎁 Jmeter 安装配置"},{"content":"位运算总结图\n判定字符是否唯一 如何从浅到深的挖入这道题？从常见的哈希表到数组模拟哈希表到用到位图思想，想到了用位图\n鸽巢原理对位图进行优化：如果字符串的长度大于26，则一定是有重复的（一共且只有26个英文字母，顶多刚好从a ~ z 全部都过一遍）\n代码实现\npublic boolean isUnique(String astr) { // 位图 + 鸽巢 / 抽屉原理 (优化) int bitMap = 0; // 鸽巢 / 抽屉原理(优化) // 如果字符串的长度大于26，则一定是有重复的（一共且只有26个英文字母，顶多刚好从a ~ z 全部都过一遍） if (astr.length() \u0026gt; 26) return false; for (int i = 0; i \u0026lt; astr.length(); i++) { int x = astr.charAt(i) - \u0026#39;a\u0026#39;; // 先判断字符是否在位图中 if (((bitMap \u0026gt;\u0026gt; x) \u0026amp; 1) == 1) return false; bitMap |= 1;// 把当前字符加入到位图中 bitMap \u0026lt;\u0026lt;= x; } return true; } 保持更新中\u0026hellip;\n","permalink":"https://Jul1en-Lin.github.io/p/%E4%BD%8D%E8%BF%90%E7%AE%97%E7%B1%BB%E9%A2%98%E7%9B%AE/","title":"🛹 位运算类题目"},{"content":" 归并数组 类似于分治快排，归并是从底下往上递归排序，快排是先解决当前部分再往下排，两个的顺序是反的~\nclass Solution { int[] tmp; // 辅助数组 public int[] sortArray(int[] nums) { // 分治归并 if (nums == null || nums.length == 0) return null; tmp = new int[nums.length]; mergeSort(nums, 0, nums.length - 1); return nums; } public void mergeSort(int[] nums, int left, int right) { // 中止条件 if (left \u0026gt;= right) return; // 1. 选取中间点 int mid = (right + left) / 2; // 2. 递归左右区间排序 mergeSort(nums, left, mid); mergeSort(nums, mid + 1, right); // 3. 排序(双指针) // int[] tmp = new int[right - left + 1];// tmp每次都要new 消耗资源 故放到全局变量中 int cur1 = left, cur2 = mid + 1, i = 0; while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) tmp[i++] = nums[cur1] \u0026lt;= nums[cur2] ? nums[cur1++] : nums[cur2++]; // 细节问题：cur1 或者 cur2 可能没有走到最后 // 虽然是两个while 但也只会执行其中一个 while (cur1 \u0026lt;= mid) tmp[i++] = nums[cur1++]; while (cur2 \u0026lt;= right) tmp[i++] = nums[cur2++]; // 4. 归并合到一起 for (int j = left; j \u0026lt;= right; j++) nums[j] = tmp[j - left]; // tmp 要从0开始取 } } 数组中的逆序对 本质上就是依靠“排序数组 + 元素的相对位置不变”的逻辑，从而优化计算逆序对的策略，使其时间复杂度由单个遍历元素的 $o(N²)$​ -\u0026gt; ​$o(1)$\n思路总结 最初以暴力枚举开始，必然会超时$o(N$​$2$​$)$，思考如何优化统计逆序对的逻辑并非单一的枚举每一个元素 第一版\n// 这里的嵌套循环和重置 cur2，本质上还是 O(N^2) while (cur1 \u0026lt;= mid) { while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) { if (nums[cur1] \u0026gt; nums[cur2]) count++; // 还是在暴力找 cur2++; } cur2 = mid + 1; // 回退指针，复杂度爆炸 cur1++; } 能利用数组的单调性​ ， 批量处理元素之间的大小关系（比如 A \u0026gt; B，那 A 后面比 A 大的肯定也 \u0026gt; B），此时逆序对的数量就能以​$o(1)$​**的时间复杂度计算**\n归并排序 （分治）的思想能解决这种问题，不会打乱各自的相对位置，递归会让各自左区间与右区间都是有序的\n优化左右两边[left, mid] [mid + 1, right]​都为有序数组，维护一个辅助数组int[] tmp​ + 双指针int cur1, cur2。这里以递增数组为例，遇到小的元素就往辅助数组上放\n在排序中统计逆序对有两种方式：\n单调递增数组中找逆序对——如果有序递增的左侧数字都比右侧的某一数字都大，那么左边剩下的肯定也比它大 // 最终核心 Merge 逻辑 while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) { if (nums[cur1] \u0026lt;= nums[cur2]) { // 没有逆序对的情况 // 将小的放进tmp数组中，并移动cur1指针 tmp[i++] = nums[cur1++]; } else { // 左边大于右边，则左边 cur1 之后的所有数都能和 cur2 构成逆序对 count += (mid - cur1 + 1); // 👈为了优化这里 tmp[i++] = nums[cur2++]; // 将小的放进tmp中，移动cur2指针，找下一组逆序对 } } 单调递减数组中找逆序对——如果递减的右侧数字比左侧区域的某一数字都要小，那么右边剩下的肯定也比它小 // ⭐从小到大的递减数组版本(只需要更改这里的逻辑，其他代码不变) // [left, mid] [mid + 1, right] // 如果递减的右侧数字比左侧区域的某一数字都要小,那么右边剩下的肯定也比它小 while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) { if (nums[cur1] \u0026lt;= nums[cur2]) { // 没有逆序对的情况 // 将大的放进tmp数组中，并移动cur2指针 tmp[i++] = nums[cur2++]; } else { count += right - cur2 + 1; // 递减后的逻辑区域取的是右边的数组区域 tmp[i++] = nums[cur1++]; // 移动cur1指针，找下一组逆序对 } } 分类讨论：以递增数组为例，每遇到小的元素就将它放到数组tmp上，然后移动对应指针 剩余元素的处理 （边界问题） ：主循环结束后，通常会有一边还剩下一部分元素，cur1​ 或 cur2​ 没走完的情况，需要将剩下的**有序数组**都放到tmp上 // 收尾阶段：直接搬运，无需比较，因为子数组已保证有序 while (cur1 \u0026lt;= mid) tmp[i++] = nums[cur1++]; while (cur2 \u0026lt;= right) tmp[i++] = nums[cur2++]; 细节问题 排序是否破坏相对位置 ​疑问：在递归过程中对子数组进行排序，是否会破坏元素原始的前后位置，导致逆序对统计错误？ 结论：不会——明白了两边的相对位置，只对当前分区的元素位置优化，对结果并没有影响，而且逆序对是肯定存在的 剩余元素的处理（边界问题） 问题：主循环结束后，通常会有一个数组还剩下一部分元素。我初次尝试时，试图在处理剩余元素的 while 循环中继续进行大小比较 (nums[cur] \u0026gt; nums[cur+1])，导致数组越界 数组必然是有序的 -\u0026gt; 根据递归，假设一路递归到只剩一个元素，那它肯定就是有序的，然后一级一级往上返回 -\u0026gt; 那么剩下的数字其实也是底下一层返回来的部分有序的数组，剩下的那部分元素必然有序 解决方案：不需要做任何比较或交换，直接将剩余元素按顺序填入临时数组即可 代码实现\nclass Solution { int count = 0; int[] tmp; // 辅助数组 public int reversePairs(int[] nums) { int n = nums.length; if (n \u0026lt;= 1) return 0; tmp = new int[n]; mergeSort(nums, 0, n - 1); return count; } public void mergeSort(int[] nums, int left, int right) { // 中止条件 if (left \u0026gt;= right) return; // 取中间 int mid = (right + left) / 2; // 继续往下递归 mergeSort(nums, left, mid); mergeSort(nums, mid + 1, right); // 分类讨论 // 优化左右两边都为有序数组(利用暂存数组与双指针来排序)————为了优化查找逆序对的逻辑 // 如果有序递增的左侧数字都比右侧的某一数字都大，那么左边剩下的肯定也比它大 // 此时逆序对的数量就能以o(1)的时间复杂度计算 int cur1 = left, cur2 = mid + 1,i = 0; while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) { if (nums[cur1] \u0026lt;= nums[cur2]) { // 没有逆序对的情况 // 将小的放进tmp数组中，并移动cur1指针 tmp[i++] = nums[cur1++]; } else { // nums[cur1] \u0026gt; nums[cur2]的情况 count += mid - cur1 + 1; // 👈为了优化这里 tmp[i++] = nums[cur2++]; // 将小的放进tmp中，移动cur2指针 } } // ⭐从小到大的递减数组版本(只需要更改这里的逻辑，其他代码不变) // [left, mid] [mid + 1, right] // 如果递减的右侧数字比左侧区域的某一数字都要小,那么右边剩下的肯定也比它小 // while (cur1 \u0026lt;= mid \u0026amp;\u0026amp; cur2 \u0026lt;= right) { // if (nums[cur1] \u0026lt;= nums[cur2]) { // // 没有逆序对的情况 // // 将大的放进tmp数组中，并移动cur2指针 // tmp[i++] = nums[cur2++]; // } else { // count += right - cur2 + 1; // 递减后的逻辑区域取的是右边的数组区域 // tmp[i++] = nums[cur1++]; // } // } // 处理部分未放到tmp的有序数组 // 为什么是剩下的数组都是有序的？————从最底层返回的单个数字就是有序的数组 // 原本剩下的数字其实也是底下一层返回来的部分有序的数组 while (cur1 \u0026lt;= mid) tmp[i++] = nums[cur1++]; while (cur2 \u0026lt;= right) tmp[i++] = nums[cur2++]; // 为上一层返回有序数组，注入到nums中 for (int j = left; j \u0026lt;= right; j++) // 注意j \u0026lt;= right nums[j] = tmp[j - left]; } } ‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E5%88%86%E6%B2%BB-%E5%BD%92%E5%B9%B6%E7%B1%BB%E9%A2%98%E7%9B%AE/","title":"🪭 分治-归并类题目"},{"content":" topK问题 topK问题是经典的算法题，包括有（第k大、第k小、前k大、前k小），这类题的解决方法也比较固定\n堆排序 一句话总结：找前 K 大，用 Size 为 K 的小顶堆；找前 K 小，用 Size 为 K 的大顶堆。\n题目需求 使用的堆 核心逻辑 求前 K 个最大的数 小根堆 我们需要淘汰掉比较小的元素。小根堆的堆顶是堆中最小的，只要新来的元素比堆顶大，就把堆顶踢走，新元素进堆。最后剩下的 K 个就是最大的。 求前 K 个最小的数 大根堆 我们需要淘汰掉比较大的元素。大根堆的堆顶是堆中最大的，只要新来的元素比堆顶小，就把堆顶踢走，新元素进堆。 快速排序 堆排序 vs 快速排序 面试 如果在面试中遇到这种场景，立刻切换到堆排序思路：\n“给你 10 亿个数字，内存只有 1GB，请找出最大的 100 个数。”\n这时候你不能用 QuickSelect，因为你没办法把 10 亿个数字一次性加载到数组里进行 Partition。但你可以建一个只有 100 个格子的小顶堆，让这 10 亿个数字一个个流过去进行“守门员PK”，内存占用极小\n第K个最大 堆排序 这是最反直觉的地方： 题目要找 “第 K 个最大的元素” （或者前 K 大），我们通常使用 小顶堆，且这个堆的大小固定为 K\n用Gemini举例的“守门员”机制就能理解**排升序建大堆，排降序建小堆**了，找K大实际上就是排降序，取第K个大的元素\n==形象的比喻：“精英俱乐部”的守门员==\n想象你要选拔全校“最富有的 10 个人”（Top 10）。你建了一个只有 10 个座位的“富豪俱乐部”。\n​初始阶段：前 10 个人直接进屋坐下。\n​竞争阶段：第 11 个人来了。他想进屋，该跟谁比？\n肯定不能跟屋里最有钱的人比（那是老大，不用动）。 他应该跟屋里 “最穷” 的那个人（第 10 名）比。 ​淘汰机制：\n如果第 11 个人 ​比屋里最穷的人还有钱，那把“最穷”的那个踢出去，第 11 个人坐下。 如果第 11 个人 ​连屋里最穷的都比不过，那直接走人，别想进屋。 在这个比喻里：\n俱乐部 = 堆（Heap） 屋里最穷的人 = 堆顶（Root） 因为我们要随时能找到“最穷”的那个人来做比较标准，所以我们需要 小顶堆（堆顶最小）。 假设我们要从数组 [3, 2, 1, 5, 6, 4]​ 中找 第 2 大 的元素 (k=2​)。 我们需要维护一个 Size = 2 的小顶堆。\n填满堆（前 k 个）： 先把 3, 2 放进去。\n堆状态（小顶）：[2, 3] （堆顶是 2） 遍历剩下的元素：\n遇到 1： 1 \u0026lt; 2 (堆顶)。连门槛都够不着，​丢弃。\n遇到 5： 5 \u0026gt; 2 (堆顶)。挑战成功！\n弹出 2，放入 5。 堆调整后状态：[3, 5] （堆顶变成了 3）。 遇到 6： 6 \u0026gt; 3 (堆顶)。挑战成功！\n弹出 3，放入 6。 堆调整后状态：[5, 6] （堆顶变成了 5）。 遇到 4： 4 \u0026lt; 5 (堆顶)。挑战失败，​丢弃。\n最终结果： 堆里剩下 [5, 6]​。这是全数组最大的两个数。答案： 堆顶 5，就是第 2 大的数。\npublic int findKthLargest(int[] nums, int k) { // 使用堆排序（NlogN） // 找第K大的，实际是使用降序，降序用小顶堆 // PriorityQueue 默认是小顶堆，无需传入Comparator // 创造大小为k的 PriorityQueue\u0026lt;Integer\u0026gt; queue = new PriorityQueue\u0026lt;\u0026gt;(k); // 遍历每个元素 for (int x : nums) { // 如果堆没满，直接加进去 if (queue.size() != k) queue.offer(x); else { // 开始对比 if (x \u0026gt; queue.peek()) { queue.poll(); queue.offer(x);// offer时PriorityQueue会自动排序，最小的排到堆顶 } else { // 忽略，下一个 } } } // 由于queue容量是k，且是小顶堆，所以最顶上那个元素一定是第K大的元素 return queue.peek(); } 快排 本质上是采用了 “分治” 的思想\n核心逻辑： 快排的核心是 partition​（分区），每次操作后，基准元素（pivot）都会回到它在有序数组中最终应该在的位置。\n可以把它想象成整理为有序数组的过程（从小到大）：\n选基准：你从数组里随便拿出有一个元素作为基准（Pivot） 。 选基准元素也很有讲究，一般是使用Random类\nint randomIndex = l + new Random().nextInt(r - l + 1); int randomKey = nums[randomIndex]; 分区（Partition） ：假如把它分为三部分，分别是 \u0026lt;Pivort、=Pivort、\u0026gt;Pivort、把所有比它大的书都扔到它右边，比它小的书都扔到它左边，跟它相等的就落到中间的分区\n归位：做完这一步，两边的元素可能还是乱的（虽然每个区之间还没有排好序，但是已经限制了分区，再怎么排都是在那个区间内动——\u0026lt;Pivort、=Pivort、\u0026gt;Pivort），这个元素的位置就固定了。接下来就是它左右分区的继续再执行 选基准 —\u0026gt; 分区 的递归操作，直到整个数组都排好~\npublic int findKthLargest(int[] nums, int k) { return qsort(nums, 0, nums.length - 1, k); } public int qsort(int[] nums, int l, int r, int k) { // 判断边界条件，且这里不存在 l \u0026gt; r 的情况 if (l == r) return nums[l]; // 随机选择基准元素randomKey int randomIndex = l + new Random().nextInt(r - l + 1); int randomKey = nums[randomIndex]; // 根据基准元素，使数组分三区 // 这里使用三指针 int left = l - 1,right = r + 1; for (int i = l; i \u0026lt; right; ) { if (nums[i] \u0026gt; randomKey) { // 放右边，由于交换出的元素是未知的，未扫描，故不能i++ swap(nums, --right, i); } else if (nums[i] \u0026lt; randomKey) { // 放左边，且交换出的元素已经上一轮扫描过，故i++ swap(nums, ++left, i); i++; } else i++; // 重复元素直接跳过 } // 分类讨论：判断第k大落在的区间 // [l, left] [left + 1, right - 1] [right, r] int a = (left - l) + 1, b = (right - 1) - (left + 1) + 1, c = (r - right) + 1; // abc 分别代表不同分区的元素个数 if (c \u0026gt;= k) { // 在 \u0026gt;randomKey 区间，继续寻找 return qsort(nums, right, r, k); } else if (b + c \u0026gt;= k) { // 上述 c \u0026gt;= k 不成立 则一定落在 ==randomKey 区间 直接返回 return randomKey; }else { // 落在 \u0026lt; key 分区，则找的是 k - b - c的位置，因为 \u0026gt;=key 的都抛弃了，第k 大元素肯定不落在范围内 return qsort(nums, l, left, k - b - c); } } public void swap(int[] nums,int x ,int y) { int tmp = nums[x]; nums[x] = nums[y]; nums[y] = tmp; } 细节问题 为什么分类讨论的时候 k 要与 abc 代表不同分区的元素个数进行比较？\n举个栗子就懂了~\n假设我们要找全班第 ​k​​ 名（第 k 大）\nC 区（\u0026gt;key）：特等生区这里有 c 个人，他们是全班分数最高的。\n判断： 如果 c \u0026gt;= k（比如特等生有 10 人，你要找第 5 名）。 结论： 那第 k 名肯定在这个人堆里。 动作： return qsort(nums, right, r, k); （去特等生区里继续找第 k 名）。 B 区（=key）： 有 b 个人，分数与key一致\n判断： 上面的 c \u0026gt;= k​ 不成立，说明特等生人数不够 k​ 个。这时候看 b + c \u0026gt;= k 结论： 既然 B 区的人分数都一样，随便抓一个就是答案 动作： return randomKey; A区（\u0026lt;key）： 有 a 个人，分数比较低\n判断： 前面的都不成立。说明 C 区（特等生）和 B 区（优等生）加起来的人数都不够 k 个\n这时候我们把前 8 名都排除了，我们要去 A 区找 为什么要减：\n在 A 区里，这些人不知道前面还有 8 个人比他们强。 对于 A 区内部来说，我们要找的那个目标，是 A 区里的第 10 - 3 - 5 = 2​ 名（第 10 - c - b 名） 动作： return qsort(nums, l, left, k - b - c);\n此qsort方法将partition​ 和 quickSelect结合起来了，其实思想都是一样的，只是写法不一样，qsort一次性写完了\n其中上述代码中的分类讨论部分的就是quickSelect，像是是指挥官，故判断是否符合条件也是在这里判断\n​partition则是干累活的，负责根据基准元素，使数组分三区的\n最小K个数 这题有三种解法\n排序 时间复杂度：o(N*logN)\n实现代码\npublic int[] smallestK(int[] arr, int k) { // 1. 边界处理 if (k == 0 || arr == null || arr.length == 0) { return new int[0]; } // 2. 排序 Arrays.sort(arr); // 3. 取前 K 个 return Arrays.copyOf(arr, k); } 堆（大根堆） 时间复杂度：o(N*logK)\n需要建立容量为k的大根堆，来淘汰比较大的元素，大根堆的堆顶就是最大的（PriorityQueue默认是小根堆，需要传入Comparator）\n// 创建一个存放整数的大根堆 PriorityQueue\u0026lt;Integer\u0026gt; maxHeap = new PriorityQueue\u0026lt;\u0026gt;(Comparator.reverseOrder()); 接着遍历数组\n如果堆没满，直接入堆 如果堆满了，就去找比堆顶还要小的数 实现代码\npublic int[] smallestK2(int[] arr, int k) { if (k == 0 || arr.length == 0 || arr == null) return new int[0]; // 堆排序 // 创建大根堆 需传入Comparator int[] ret = new int[k]; PriorityQueue\u0026lt;Integer\u0026gt; queue = new PriorityQueue\u0026lt;\u0026gt;(Comparator.reverseOrder()); for(int x : arr) { // 没满的就入堆 if (queue.size() != k) { queue.offer(x); }else{ if (queue.peek() \u0026gt; x) { // 进去 queue.poll(); queue.offer(x); } } } int index = 0; while(queue.size() != 0) { ret[index] = queue.poll(); index++; } return ret; } 快排 时间复杂度：o(N)\n细节问题 做题时经常能遇到取随机数下标的时候的元素定位错误，原因总结可能有\n分类讨论部分的分区问题 区间长度是否写反了 中止递归条件，如if (r - l + 1 == k) return 实现代码\npublic int[] smallestK3(int[] arr, int k) { quickSort(arr, 0, arr.length - 1, k); int[] ret = new int[k]; for (int i = 0; i \u0026lt; k; i++) ret[i] = arr[i]; return ret; } public void quickSort(int[] num, int l, int r, int k) { if (r - l + 1 == k) return; // 选取随机元素 int index = l + new Random().nextInt(r - l + 1); int key = num[index]; // 定义指针 + 分区 int left = l - 1,right = r + 1; for (int cur = l;cur \u0026lt; right; ) { if (num[cur] \u0026gt; key) swap(num, cur, --right); else if (num[cur] \u0026lt; key) swap(num, cur++, ++left); else cur++; } // 分类讨论 // [l, left] [left + 1, right - 1] [right, r] int a = left - l + 1,b = right - left - 1,c = r - right + 1; if (a \u0026gt;= k) quickSort(num, l, left, k);// a == k 的情况会在quickSort的if (r - l + 1 == k)条件中 return else if (a + b \u0026gt;= k) return; else quickSort(num, right, r, k - a - b); } public void swap(int[] num, int x, int y) { int tmp = num[x]; num[x] = num[y]; num[y] = tmp; } ‍\n","permalink":"https://Jul1en-Lin.github.io/p/%EF%B8%8F-topk%E9%97%AE%E9%A2%98/","title":"🌶️ TopK问题"},{"content":" Git命令大全 前提介绍\ngit是一个分布式版本控制系统，相当于一个时光机，拿来保管代码，一旦出错，可以随时回滚到任何一个历史节点\n其核心架构分为三个区域：工作区（写代码的地方）、暂存区（临时存放的地方）和版本库（安全存档的地方）\n添加文件 ​git add .表示添加所有在工作区的文件至暂存区\n​git add \u0026lt;fileName\u0026gt; \u0026lt;fileName\u0026gt;\neg：git add file1 file2\n提交 提交暂存区的文件至版本库中\n​git commit -m \u0026quot;\u0026lt;填写你要备注的内容\u0026gt;\u0026quot;\neg：git commit -m \u0026quot;no message commit\u0026quot;\n查看日志 查看最近日志：git log​ / git log --pretty=oneline\n记录本地每一次提交的命令 git reflog\n得到每一次的id，此id可用于版本回退功能\n高亮部分就是每次提交的id，用git reflog命令也能得到，且id更加简洁\n版本回退 它有三个版本\n​git reset [--soft / --mixed / --hard] \u0026lt;HEAD\u0026gt;，区别如图所示\n​git reset \u0026lt;使用 git log / git reflog 后查看的对应版本的提交id\u0026gt;，版本id在\u0026lt;查看日志\u0026gt;部分可得到\neg：git reset --mixed 3f50f68\n查看当前仓库状态 查看与上一次提交之后到现在是否对文件进行修改（工作区）\n​git status\n查看是哪个文件被修改了之后，用下面介绍的diff命令即可查看修改的具体内容\n查看差异 查看暂存区与工作区之间的差异：git diff \u0026lt;fileName\u0026gt;\neg：git diff ReadMe\n‍\n查看版本库和工作区文件的区别：git diff HEAD --\u0026lt;fileName\u0026gt;\neg：git diff HEAD -- ReadMe\n撤销修改 如果你在工作区中写了好多天的代码，但是效果还不如初次的，此时你想要退回到未修改的状态，就需要撤销修改操作\n撤销修改虽然可以手动把你自己写的代码删掉，但如果写的太多了，手动删除会有隐患且耗费时间精力\n撤销的目的，就是为了不影响远程仓库，所以撤销修改的前提是代码没有push到远程仓库\n以下是撤销修改的命令，分三种情况\n删除文件 终端命令使用rm \u0026lt;fileName\u0026gt;​，这时会将工作区的文件删除，如果想提交修改的话后续使用add 、commit操作，分为三步 git 也提供了这个命令，将这个流程简化为了两布：git rm \u0026lt;fileName\u0026gt;​，这是将工作区与暂存区的文件同时删除，此时git已经自动将删除后的文件提交到暂存区中，只需要commit操作即可 ‍\n分支 查看本地仓库分支情况 ​git branch\n创建分支 在git branch后加上想要添加分支的名字即可\n​git branch \u0026lt;branchName\u0026gt;​，如：git branch dev\n切换分支 跟撤销修改的命令有点相似，只是少了--\n​git checkout \u0026lt;已有的分支\u0026gt;​，如：git checkout dev\n还能使用切换并创建一个未创建的分支，使用git checkout -b \u0026lt;branchName\u0026gt;​，相当于git branch \u0026lt;branchName\u0026gt; + git checkout \u0026lt;branchName\u0026gt;\n合并分支 合并分支的前提下是在选定某一个分支内对另一分支进行合并，不能合并现在所选的分支上\n​git merge \u0026lt;branchName\u0026gt;（Fast-forward模式）\n例如有两个分支：master 和 dev\n如果想要合并dev分支，那需要讲选中的分支切换到master上，用git branch来查看现在所处的分支，前面带 * 号的就是所在分支\n像这样，所在是分支dev，就使用git checkout master转回master分支\n然后就能进行合并操作了，使用git merge dev，这时master就指向了dev的最新一次提交\n此时的分支情况为\n在这种Fast-forward模式下，删除分支历史时，会丢掉分支的信息，看不出来最新的提交是merge进来的还是master分支上正常提交的，为了更好的溯源以及留痕，日后便于发现是哪个分支提交出了问题，在合并的时候会使用no-ff模式，即No-Fast-forward\n​git merge --no-ff -m \u0026quot;新提交的信息\u0026quot; \u0026lt;branchName\u0026gt;\n其中-m \u0026quot;新提交的信息\u0026quot;表示在使用no-ff的模式下，表示merge之后还带有一个提交，说明no-ff模式下，master必须要指向一个新的提交\n例如：有分支dev、master​，在选中master分支的前提下使用git merge --no-ff -m \u0026quot;merge dev\u0026quot; dev，此时分支的情况为\n==在平时使用中，更加建议使用no-ff模式，能进行更好的区别与溯源==\n合并冲突 如果出现merge冲突，需要手动解决，并进行一次《添加 + 提交》操作！！！如果不提交，是不属于成功的merge\n此时的分支情况\n在git中也支持提交线的可视化图标，使用git log --graph --abbrev-commit，如图所示\n删除分支 删除分支也是只能在其他的分支上删除分支，比如在dev分支上就不能删除dev，必须要切到master分支上才可以\n使用git branch -d \u0026lt;branchName\u0026gt;\n因为创建、合并和删除分支非常快，所以鼓励你使用分支完成某个任务，合并后再删掉分支，这和直接在master分支上工作效果是一样的，但过程更加的安全\n删除分支2 开发过程中，可能dev​分支在中途被废弃了，不需要合并到master​中，直接删除dev​分支，此时强制删除分支用命令git branch -D \u0026lt;branchName\u0026gt;\n在终端命令中也会提醒你，这个分支未被完全合并到master分支上，如果确定你要删除，使用git branch -D \u0026lt;branchName\u0026gt;\n分支策略 在实际开发中，git给我们的分支管理能力是非常重要的，这能帮我们进行多人协同开发\n在git管理中，需要遵守的几个重要约定\nmaster分支应该是非常稳定的，仅用来发布新版本，平时不能在上面修改操作\n干活修改的内容都应该在dev分支上，等到某个时候迎来版本更新，再把dev分支合并到master上，在master分支上发布新版本\n这样每个人都能在基于master上创建dev分支进行协同开发，每个人都有自己的分支，时不时往dev分支上合并即可\n故团队协同开发的分支belike：\nBug分支 在平时开发过程中，master分支一般是稳定的版本，其他分支都是基于master分支进行完善开发，但master也有可能有bug，此时如果没能及时发现小bug，其他分支在有bug的master上基础上进行开发，这就需要我们建立bug分支对master进行bug修复\n假如已经建立了分支dev，但是发现了master上有bug，那是不建议在dev分支上进行bug修复的，因为这违背了分支dev的任务，可能dev的任务是对某个模块进行更新，且已经修改了部分文件，所以我们需要再创建分支专门对master分支进行修复bug\n这时候我们切回master分支后其实能看到分支dev修改的代码，虽然这只是在工作区的修改，还未提交到暂存区，但这也影响了对master的创建bug分支的步骤\n比如在dev上修改了readme文件\n在master分支上也能看到dev修改的内容\n原因是master工作区中的代码有变动的\n虽然代码只存在工作区中，但我们还是不想在master上看到dev修改的代码，且在dev分支上不影响master，可在dev分支上用git stash，将工作区中的内容进行储存。\n​此时的tree .git​仓库一下就能看到stash分区保存在refs上\n此时再返回到master​分支上，就已经看不到dev修改的代码了\n此时就能对master​进行bug修复了，先切回master​分支。然后在master​上新建fix_bug分支并切换到fix_bug，可用git checkout -b fix_bug\n在fix_bug上修改代码后，更新提交后就可以提交到master​上了，可合并后直接提交，这里用no-ff方式提交，命令为git merge --no-ff -m \u0026quot;\u0026lt;commit message\u0026gt;\u0026quot; \u0026lt;devName\u0026gt;\n此时的master分支上就是已经修复好的bug了\n但dev​分支上我们还是以有bug的master​基础上开发的，那怎么办呢？dev​分支的修改信息还存在stash上，我们需要把暂存在stash区的代码给拉回来，这是没拉回来的readme文件\n在上面提到，dev​已经修改了readme的内容，那这些内容都存在了stash​区，且dev本地是没在的。可以用git stash list查看stash区的状态\n现在拉回来的dev分支上的readme文件就显示正常了，用到命令git stash pop，这时readme文件就正常显示了\n假设dev分支上已经完成开发，且已经更新最新一次提交。（add + commit）\n现在要提交到master上，那怎么办呢？它还存有master​原来的bug代码，==如果直接合并到==****==​master​==​==分支上，那会出现合并冲突，需要我们手动解决，手动解决是非常危险的，处理不当可能会污染master分支，变得更加不稳定。== 情况如图所示，会更加直观。\n解决这个问题的一个好的建议是：最好在自己的分支上合并下master​，再让master​去合并dev​，这样做的目的是有冲突可以在本地分支解决并进行测试，而不影响master，这样做此时的状态为：\n具体操作如下，在第一步中不出意外遇到了冲突\n手动修改后别忘了提交，然后切回到master​分支上，再合并dev​，此时master​上就既有修改后的代码，也有dev上提交的新代码且不冲突了\n完成后记得删分支，这是个好习惯😁\n​git branch -d dev​、git branch -d fix_bug\n推送 ​git push origin \u0026lt;远程主机名\u0026gt; \u0026lt;本地分支名\u0026gt;:\u0026lt;远程分支名\u0026gt;​，比如git push origin test-branch:remote-test-branch，远程仓库就会出现\n​remote-test-branch 的新分支\n拉取 Pull其实是有拉取 + 合并的操作，拉取过程中自动帮你合并了，且默认的是fast-forward模式\n​git pull origin \u0026lt;远程branchName\u0026gt;:\u0026lt;要拉取到的本地branchName\u0026gt;\n如果远端的master与本地的分支名字一样，即是拉取到本地master分支，则可以省略:master​，即git pull origin master\n忽略特殊文件 在开发中，有时候有些文件不应该被提交到远端仓库，比如重要的数据库密码等配置文件，那怎么才能让git知道并自动忽略呢？\n在git的工作区根目录上创建一个特殊的.gitignore文件，然后把忽略的文件名填进去，git就会自动忽略这些文件了\n可以手动创建，如：touch .gitignore，也可以在创建远程仓库的时候选择\n然后修改在.gitignore​上写上要忽略的文件类型 / 文件后缀名，修改命令为vim​，如vim .gitignore\n此处我在工作区中新建了file.txt​、a.io​、c.io​、picture.png文件\n查看git状态的时发现，picture.png​没有被忽略，file.txt​、a.io​和单独指定的c.io文件被忽略了，没有被git追踪管理，这个结果是理想的\n有时候.gitignore​文件太大了，记不清具体忽略了哪些文件，但是又不小心建立了被忽略的文件，手动在ignore文件内查看太费劲了，可以使用命令git check-ignore -v \u0026lt;被忽略的文件名\u0026gt;\n这就能看到具体的被忽略的文件的具体位置\n标签管理 标签tag​，可以简单理解为对某次commit 的一次标识，相当于起了个别名。\n相较于难以记住的commit id​，tag​可以很好的解决这个问题，当我们需要回退某个重要的版本时，直接使用标签就能很快的定位到对应的commit\n这里引用hutool 工具包的GitHub仓库，能看到不同版本的tag\n创建标签 在Git中打标签非常简单，首先切换到需要打标签的分支上，直接git tag \u0026lt;name\u0026gt;​就可以打一个新的标签，这样的标签默认指向最新的一次提交\n当然也可以指定某个提交打​tag​，只需在后面加上提交的id，如：git tag v0.9 f7340fd\n描述标签 新建tag​​​的同时还能对标签进行一些描述，使用git tag -a v0.8 -m \u0026quot;XXX\u0026quot; \u0026lt;需要指定的哪次提交id，如果没有默认指向最新的一次\u0026gt;\n查看标签 使用git tag就能看到当前目录下有多少个标签，\n如果需要看具体某一次的tag​的详细描述，则需要命令git show \u0026lt;某一次的tag\u0026gt;​，如：git show v0.8​ / git show v1.0\n删除标签 本地删除： 删除标签与删除分支的操作类似：git tag -d \u0026lt;tagName\u0026gt;​，如git tag -d v0.8​，可见已经删除了tagv0.8\n删除远程仓库标签：\n直接在远程仓库删除 在本地执行git push origin :\u0026lt;你要删除的标签\u0026gt;​，注意这个标签需要先在本地进行删除，才有变更，变更之后才能push 推送标签 将标签推送同步至远程仓库\n**推送单一标签：**​git push origin \u0026lt;标签名\u0026gt;\n**一次性推送所有标签：**​git push origin --tags，就能把本地所有的标签都推送到远程仓库\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/git%E5%91%BD%E4%BB%A4%E5%A4%A7%E5%85%A8/","title":"🪶 Git命令大全"},{"content":" 验证码功能 引入Hutool工具包 Hutool工具是一个开源的Java工具依赖库，封装了许多功能，访问https://hutool.cn，按图中引入依赖即可使用，具体功能可查看官方文档~\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cn.hutool\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;hutool-all\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.8.42\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 实现功能 以下是参考文档代码，以写出到浏览器（Servlet输出）为栗子\nICaptcha captcha = ...; captcha.write(response.getOutputStream()); //Servlet的OutputStream记得自行关闭哦！ 执行流程 ==验证码的服务主要由后端实现，前端只负责发起请求，且验证码的答案一般存于内存/Redis中，这里以Hutool工具实现==\n前端发起GET请求 后端： 利用Hutool工具生成一张图片验证码 将验证码密码放入Session / Redis 中，这里放入Session举栗子 加入验证条件：有效时间 前端拿到图片，将用户输入一起POST到后端 后端校验，返回对应的值 细节问题 线程安全 http是无状态的，后端接口之间/captcha/getCaptcha​与/captcha/check是独立的，内容无法共享数据，那需要校验的时候如何拿到验证码呢？\n​/getCaptcha​接口的成员变量与/check是不共享的，以线程安全角度来说，以下两个方法都是不可取的\n直接声明一个成员变量 在类里声明成员变量，如果多次请求/getCaptcha接口，用户1生成了验证码1，用户2生成了验证码2，成员变量验证码1变化为验证码2，如果这时候用户1拿着正确的验证码1进行校验，肯定是失败的\n交给Spring管理 Spring管理的是Bean成员对象，且这个对象是单例的，所以也有线程安全问题\n解决方法 我们可以使用Session存储，Session保证了对话安全，每个用户都有单独的会话id，把验证码存储到Session中，生成与校验都能通过Session操作存入/拿出\n考虑线程安全问题的场景 对象有没有数据共享 主动创建线程（池）的时候 时间戳 项目开发中，时间的处理和表示通常使用时间戳，能解决跨时区的问题，实现了前后端 UI 解耦，计算也更加便捷\n参数配置 学到Spring就要充分用到管理对象的功能，但是有些时候参数的注入过于繁琐，如需要构造一个图片类型的验证码，需要传入width​、height、验证码长度、干扰因子等等，这些固定参数通常需要放到配置文件中管理，起到解耦作用\n那注入这么多参数，代码非常不美观，故又想到将配置参数转换为一个对象，从对象中去取，就更加优雅了~\n举个栗子，这是构造图片验证码的代码\n参数放在application.yaml文件中\n由于注入的时候要一个一个取，非常麻烦，类似于：\n@Value(\u0026#34;${captcha.width}\u0026#34;) private String width; @Value(\u0026#34;${captcha.height}\u0026#34;) private String height; @Value(\u0026#34;${captcha.codeCount}\u0026#34;) private String codeCount; @Value(\u0026#34;${captcha.lineCount}\u0026#34;) private String lineCount; 创建CaptchaProperties类来对配置对象进行管理\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;captcha\u0026#34;) public class CaptchaProperties { private Integer width; private Integer height; private Integer codeCount; private Integer lineCount; } 在CaptchaController​层面中，构造对象的时候只需注入CaptchaProperties对象，通过取对象中的成员得到值\n@Autowired private CaptchaProperties properties; // 定义图形验证码的长和宽 LineCaptcha captcha = CaptchaUtil.createLineCaptcha(properties.getWidth(), properties.getHeight(), properties.getCodeCount(), properties.getLineCount()); 常量处理 开发中，管理常量的成员一般存入constant​包中，交给Constant类管理\n实现代码 控制层\n@RestController @RequestMapping(\u0026#34;/captcha\u0026#34;) public class CaptchaController { @Autowired private CaptchaProperties properties; // 生成验证码 @RequestMapping(\u0026#34;/getCaptcha\u0026#34;) public void getCaptcha(HttpServletResponse response, HttpSession session) throws IOException { // 定义图形验证码的长和宽 LineCaptcha captcha = CaptchaUtil.createLineCaptcha(properties.getWidth(), properties.getHeight(), properties.getCodeCount(), properties.getLineCount()); //告知浏览器处理响应以作为图片显示 response.setContentType(\u0026#34;image/jpeg\u0026#34;); // 设置session (验证码内容 + 有效时间) session.setAttribute(properties.getSession().getName(),captcha.getCode());// 保存验证码内容 session.setAttribute(properties.getSession().getDate(),System.currentTimeMillis());// 使用时间戳 try { // 验证码写出到浏览器 captcha.write(response.getOutputStream()); } catch (IOException e) { throw new RuntimeException(e); }finally { //Servlet的OutputStream记得自行关闭 response.getOutputStream().close(); } } /** * 如何存储验证码？局部变量nonono！多线程下会修改 * 变成对象交给Spring管理也不行，管理的Bean是单例的，多线程下也能修改 * 把验证码存到session当中，需要用就取出来即可 * @param captcha 用户输入的验证码 * @return 布尔值 */ // 校验验证码 @RequestMapping(\u0026#34;/check\u0026#34;) public boolean check(HttpSession session,String captcha) { if (!StringUtils.hasLength(captcha)) return false; // 获取session内的验证码 String code = (String)session.getAttribute(properties.getSession().getName()); // 获取验证码生成时间 Long captchaTime = (Long)session.getAttribute(properties.getSession().getDate()); if (!StringUtils.hasLength(code) || captchaTime == null) return false; // 校验验证码超时时间——5min 可以使用常量表示5*60*1000 常量类放到constant包中 if (System.currentTimeMillis() - captchaTime \u0026gt; CAPTCHA_TIME_OUT) return false; return captcha.equalsIgnoreCase(code); } } 实例层（ConfigurationProperties、Session）\n@Component @Data @ConfigurationProperties(prefix = \u0026#34;captcha\u0026#34;) public class CaptchaProperties { private Session session; private Integer width; private Integer height; private Integer codeCount; private Integer lineCount; } @Data public class Session { private String name; private String date; } 常量层\npublic class Constant { public static final int CAPTCHA_TIME_OUT = 5*60*1000; } ‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E9%AA%8C%E8%AF%81%E7%A0%81%E5%8A%9F%E8%83%BD/","title":"🧣 验证码功能"},{"content":" 💫 MyBatis／plus MyBatis 是一款半自动对象关系映射持久层框架，其核心通过 XML 或注解实现原生 SQL 与 Java 对象的解耦映射，在消除冗余 JDBC 代码的同时，保留了对 SQL 执行逻辑的控制\n接下来先讲传统Mybatis的配置\nMyBatis配置 mybatis是IDE和数据库的桥梁，便于在后端也能执行SQL，故配置必须需要连接数据库~\n💫application.yml配置💫必备\n# 数据库连接配置 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8\u0026amp;useSSL=false\u0026amp;allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 # 设置日志文件的文件名 logging: file: name: spring-book.log MyBatis yml配置 在application.yml中可以增加mybatis的相关配置，如配置驼峰自动转换\nmybatis: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 添加依赖 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.spring.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-j\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; xml配置（动态SQL） xml一般是在写SQL中，简单的SQL已经无法满足业务需求，需要动态SQL时，需要的mybatis的配置，动态SQL就在xml文件里写。\n一般放在resources.mapper包下，命名规范为xxxMapper.xml，对应某个类。效果和注解Spring的@Select、@Update注解作用一致\n\u0026lt;!--将以下放到xml文件头--\u0026gt; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.bite.com.book.mapper.BookMapper\u0026#34;\u0026gt; \u0026lt;!--namespace为对应mapper包的某个类所在位置，位置可修改，如下--\u0026gt; \u0026lt;/mapper\u0026gt; MyBatisX 是一款plugin，支持xml的快速跳转和生成代码，非常好用~\nMyBatis-Generator插件 这个插件使用也是非常的广泛\n手写mapper还是太麻烦了，有一些插件能够帮我们自动生成一些sql，提高效率，同时需要generator文件\n\u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis.generator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-generator-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.6\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;Generate MyBatis Artifacts\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;generate\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--generator.xml配置文件所在位置，位置可修改--\u0026gt; \u0026lt;configurationFile\u0026gt;src/main/resources/generate/generator.xml\u0026lt;/configurationFile\u0026gt; \u0026lt;!-- 允许覆盖生成的文件, mapxml不会覆盖, 采用追加的方式--\u0026gt; \u0026lt;overwrite\u0026gt;true\u0026lt;/overwrite\u0026gt; \u0026lt;verbose\u0026gt;true\u0026lt;/verbose\u0026gt; \u0026lt;!--将当前pom的依赖项添加到生成器的类路径中--\u0026gt; \u0026lt;includeCompileDependencies\u0026gt;true\u0026lt;/includeCompileDependencies\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.33\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/plugin\u0026gt; 上述插件提到的文件generator.xml ，需放所提到的\u0026lt;configurationFile\u0026gt;对应的位置，位置可修改\ngenerator.xml文件内容如下，一些配置项需要修改，如数据库连接信息\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE generatorConfiguration PUBLIC \u0026#34;-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd\u0026#34;\u0026gt; \u0026lt;!-- 配置生成器 --\u0026gt; \u0026lt;generatorConfiguration\u0026gt; \u0026lt;!-- 一个数据库一个context --\u0026gt; \u0026lt;!-- targetRuntime中可修改为MyBatis3，MyBatis3simple生成简单的SQL，选择MyBatis3，SQL更全面 --\u0026gt; \u0026lt;context id=\u0026#34;MysqlTables\u0026#34; targetRuntime=\u0026#34;MyBatis3Simple\u0026#34; defaultModelType=\u0026#34;flat\u0026#34;\u0026gt; \u0026lt;!--去除注释--\u0026gt; \u0026lt;commentGenerator\u0026gt; \u0026lt;property name=\u0026#34;suppressDate\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;suppressAllComments\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/commentGenerator\u0026gt; \u0026lt;!--数据库链接信息，URL需要配置，如端口号，ip，数据表名，要求与application.yml的spring数据库保持一致--\u0026gt; \u0026lt;jdbcConnection driverClass=\u0026#34;com.mysql.jdbc.Driver\u0026#34; connectionURL=\u0026#34;jdbc:mysql://127.0.0.1:3306/java_blog_spring?serverTimezone=Asia/Shanghai\u0026amp;amp;nullCatalogMeansCurrent=true\u0026#34; userId=\u0026#34;root\u0026#34; password=\u0026#34;root\u0026#34;\u0026gt; \u0026lt;/jdbcConnection\u0026gt; \u0026lt;!-- 以下实体类都可手动指定生成文件位置，位置为targetPackage--\u0026gt; \u0026lt;!-- 生成实体类 --\u0026gt; \u0026lt;javaModelGenerator targetPackage=\u0026#34;generator.entity\u0026#34; targetProject=\u0026#34;src/main/java\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;trimStrings\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;/javaModelGenerator\u0026gt; \u0026lt;!-- 生成mapxml文件 --\u0026gt; \u0026lt;sqlMapGenerator targetPackage=\u0026#34;mybatisGenerator\u0026#34; targetProject=\u0026#34;src/main/resources\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/sqlMapGenerator\u0026gt; \u0026lt;!-- 生成mapxml对应client，也就是接口dao --\u0026gt; \u0026lt;javaClientGenerator targetPackage=\u0026#34;generator.mapper\u0026#34; targetProject=\u0026#34;src/main/java\u0026#34; type=\u0026#34;XMLMAPPER\u0026#34; \u0026gt; \u0026lt;property name=\u0026#34;enableSubPackages\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/javaClientGenerator\u0026gt; \u0026lt;!-- table可以有多个,每个数据库中的表都可以写一个table，tableName表示要匹配的数据库表,也可以在tableName属性中通过使用%通配符来匹配所有数据库表,只有匹配的表才会自动生成文件 --\u0026gt; \u0026lt;table tableName=\u0026#34;user_info\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;useActualColumnNames\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;!-- 对应数据库表的主键 --\u0026gt; \u0026lt;generatedKey column=\u0026#34;id\u0026#34; sqlStatement=\u0026#34;Mysql\u0026#34; identity=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/context\u0026gt; \u0026lt;/generatorConfiguration\u0026gt; 配置完成，双击运行插件即可，配置文件**generator.xml**就会生成对应的实体类与xml文件\n注意 generator.xml文件头放到IDE上会出现报错，无需更改\n🚩MyBatis-plus 是一个MyBatis的增强工具，简化开发，提高效率，类似于Hutool工具包，使用时仅需导入依赖即可\n它封装了许多SQL语句，使用时仅需让mapper类extends BaseMapper\u0026lt;\u0026gt;父类接口，执行时则可以直接调用他的一系列函数，无需手写\n配置 引入依赖 参考官方文档https://baomidou.com/getting-started \u0026lt;!--Spring Boot3--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot3-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.15\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--Spring Boot4--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot4-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.15\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 根据你现使用的SpringBoot版本选择对应的依赖~\n编码 编写对应实体类 编写 Mapper 接口并拓展BaseMapper\u0026lt;T\u0026gt;接口 完成简单两步即可实现内部丰富的函数\n打印执行SQL日志 与mybatis的配置文件类似，只需把名字改为mybatis-plus\napplication.yml\nmybatis-plus: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句 识别原理 刚才只是配置了application.yml中的数据库连接信息，拓展了BaseMapper\u0026lt;\u0026gt;接口与实现了一个实体类UserInfo，就能够查询数据库中的user_info这张表并且成功执行SQL了，它是如何识别出来的呢？\n表名 是根据实体类名推断的，如果不一致，使用@TableName注解映射实体类对应的表名是哪个\nid 是否有自增 执行insert操作时，表的id是自增主键，理想情况下ID应自增为3。但是ID一栏mybatis生成了随机数字，扰乱了原本的自增主键\n这是由于mybatis不能自动识别表中是否有自增，哪一个是自增主键，用到注释@TableId映射，它定义主键及其主键的性质（自增、输入，无性质）\n使用注解标识后就能很好的解决这个问题了\n查看源码，主键性质分为如下几类\n字段与属性的映射关系 他的推断规律与推断表名是一样的，如果不一致则用@TableField映射\n条件构造器Wrapper MyBatis-Plus 提供了一套强大的条件构造器（Wrapper），用于构建复杂的数据库查询条件。Wrapper 类允许开发者以链式调用的方式构造查询条件，无需编写繁琐的 SQL 语句，从而提高开发效率并减少 SQL 注入的风险\n它是构建复杂查询和更新条件的关键工具。它允许开发者以链式调用的方式构造 SQL 的 WHERE 子句，提供了极大的灵活性和便利性\nMyBatis-Plus 关于构造器的官方参考文档：https://baomidou.com/guides/wrapper AbstractWrapper：这是一个抽象基类，提供了所有 Wrapper 类共有的方法和属性。它定义了条件构造的基本逻辑，包括字段（column）、值（value）、操作符（condition）等。所有的 QueryWrapper、UpdateWrapper、LambdaQueryWrapper 和 LambdaUpdateWrapper 都继承自 AbstractWrapper\nQueryWrapper：专门用于构造查询条件，支持基本的等于、不等于、大于、小于等各种常见操作。它允许你以链式调用的方式添加多个查询条件，并且可以组合使用 and 和 or 逻辑\nUpdateWrapper：用于构造更新条件，可以在更新数据时指定条件。与 QueryWrapper 类似，它也支持链式调用和逻辑组合。使用 UpdateWrapper 可以在不创建实体对象的情况下，直接设置更新字段和条件\nLambdaQueryWrapper + LambdaUpdateWrapper\nLambdaWrapper类：这是一个基于 Lambda 表达式的查询条件构造器，它通过 Lambda 表达式来引用实体类的属性，从而避免了硬编码字段名。提高了代码的可读性和可维护性，尤其是在字段名可能发生变化的情况下\nWrapper类提供了许多SQL关键词，允许你以链式调用的方式添加多个查询条件，对此分类讨论\n基础比较算子\neq：等于（=） ne：不等于（\u0026lt;\u0026gt;） gt / ge：大于（\u0026gt;） / 大于等于（\u0026gt;=） lt / le：小于（\u0026lt;） / 小于等于（\u0026lt;=） allEq：全等于，通常接收 Map 进行多字段精确匹配 区间与集合筛选\nbetween / notBetween：值在指定范围内（BETWEEN ... AND ...） in / notIn：值在指定的集合、数组或子查询结果中（IN (...)） inSql / notInSql：通过原生的 SQL 子查询实现集合筛选 文本模式处理\nlike / notLike：模糊匹配，生成的 SQL 默认为 %值% likeLeft / likeRight：左模糊（%值）与右模糊（值%）。注意：在 B+ 树索引中，likeRight 可能维持索引有效，而 likeLeft 会导致全表扫描 空值与逻辑判定\nisNull / isNotNull：字段是否为 NULL exists / notExists：拼接 EXISTS (subquery) 逻辑 and / or：逻辑连接符，MyBatis-Plus 默认以 AND 拼接。若要实现 (A OR B) 这种嵌套逻辑，需在方法中传入 Lambda 表达式 排序、分组与结果控制\nselect：指定投影查询的字段，实现按需取数 groupBy / having：对应 SQL 的分组聚合逻辑 orderByAsc / orderByDesc：指定升序或降序排列 last：直接在 SQL 末尾追加字符串（如 LIMIT 或 FOR UPDATE），无视所有规则且存在注入风险 动态 SQL 与高级拼接\napply：用于拼接原生的 SQL 片段，支持通过 {index} 方式进行预编译参数绑定，防止 SQL 注入 nested：不带 AND 或 OR 的原生嵌套括号 代码示例 以id为自增主键的用户表名举例\n用户实体类\n@Data @TableName(\u0026#34;user_info\u0026#34;) public class UserInfo { @TableId(type = IdType.AUTO) private Integer id; @TableField(\u0026#34;user_name\u0026#34;) private String userName; private String password; private Integer deleteFlag; private Date createTime; private Date updateTime; } Mapper类\n@Mapper public interface UserInfoMapper extends BaseMapper\u0026lt;UserInfo\u0026gt; { // 注解构造SQL @Select(\u0026#34;select id,username,password,age FROM userinfo WHERE username = #{userName}\u0026#34;) List\u0026lt;UserInfo\u0026gt; queryUserInfoList(String userName); // Wrapper对象传参构造SQL @Select(\u0026#34;select id,username,password,age FROM userinfo ${ew.customSqlSegment}\u0026#34;) List\u0026lt;UserInfo\u0026gt; queryUserInfoList2(@Param(Constants.WRAPPER) QueryWrapper wrapper); // xml + Wrapper 构造SQL // xml内容： //\t\u0026lt;select id=\u0026#34;queryUserInfoList3\u0026#34;\u0026gt; // select id,username,password,age FROM userinfo ${ew.customSqlSegment} //\t\u0026lt;/select\u0026gt; List\u0026lt;UserInfo\u0026gt; queryUserInfoList3(@Param(Constants.WRAPPER) QueryWrapper wrapper); // 参数 + Wrapper 构造SQL @Update(\u0026#34;update userinfo set age = age+ #{addAge} ${ew.customSqlSegment}\u0026#34;) Integer updateUserInfoByCustomSql(@Param(\u0026#34;addAge\u0026#34;) Integer addAge, @Param(Constants.WRAPPER) QueryWrapper wrapper); } Wrapper类（示例）\n@SpringBootTest public class UserInfoMapperTest { @Autowired private UserInfoMapper userInfoMapper; /** * select id,username,password,age FROM userinfo where userName like %min% */ @Test void queryUserInfoList2() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(\u0026#34;userName\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.queryUserInfoList2(queryWrapper); System.out.println(userInfos); } @Test void queryUserInfoList3() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(\u0026#34;userName\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.queryUserInfoList3(queryWrapper); System.out.println(userInfos); } /** * update userinfo set age = age+ 10 where id in (1, 2, 3)) */ @Test void updateUserInfoByCustomSql() { QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.in(\u0026#34;id\u0026#34;,List.of(1,2,3)); userInfoMapper.updateUserInfoByCustomSql(10,queryWrapper); } /** * select id,username,password from user_info where age = 18 and username like %min% * userInfoMapper 调用BaseMapper的selectList方法 */ @Test void testQueryWrapper(){ QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.select(\u0026#34;id\u0026#34;,\u0026#34;username\u0026#34;,\u0026#34;password\u0026#34;).eq(\u0026#34;age\u0026#34;,18) .like(\u0026#34;username\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.selectList(queryWrapper); System.out.println(userInfos); } /** * delete from user_info where age = 18 */ @Test void testQueryWrapper2(){ QueryWrapper\u0026lt;UserInfo\u0026gt; userInfoQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userInfoQueryWrapper.eq(\u0026#34;age\u0026#34;, 18); int num = userInfoMapper.delete(userInfoQueryWrapper); System.out.println(num); } /** * QueryWrapper * update user_info set delete_flag = 1 where age \u0026lt; 20 */ @Test void testQueryWrapper3(){ QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.lt(\u0026#34;age\u0026#34;,20); // where age \u0026lt; 20 // 更新对象 UserInfo userInfo = new UserInfo(); userInfo.setDeleteFlag(1); int update = userInfoMapper.update(userInfo, queryWrapper); System.out.println(update); } /** * UpdateWrapper * update user_info set delete_flag = 0 where age \u0026lt; 20 */ @Test void testUpdateWrapper(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.set(\u0026#34;delete_flag\u0026#34;,0).lt(\u0026#34;age\u0026#34;,20); int update = userInfoMapper.update(updateWrapper); System.out.println(update); } /** * update user_info set delete_flag = 1,age = 5 where id in (2,3,5,6) */ @Test void testUpdateWrapper2(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.set(\u0026#34;delete_flag\u0026#34;, 1).set(\u0026#34;age\u0026#34;, 5) .in(\u0026#34;id\u0026#34;, List.of(2, 3, 5, 6)); int num = userInfoMapper.update(updateWrapper); System.out.println(num); } /** * update user_info set age = age + 10 where id in (2,3,5,6) */ @Test void testUpdateWrapper3(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.setSql(\u0026#34;age = age +10\u0026#34;) .in(\u0026#34;id\u0026#34;, List.of(2, 3, 5, 6)); int num = userInfoMapper.update(updateWrapper); System.out.println(num); } /** * LambdaQueryWrapper * select id,username,password from user_info where id = 10 and username like %min% */ @Test void testLambdaQueryWrapper() { /* 对照组 QueryWrapper\u0026lt;UserInfo\u0026gt; userInfoQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userInfoQueryWrapper.select(\u0026#34;id\u0026#34;, \u0026#34;username\u0026#34;, \u0026#34;password\u0026#34;) like(\u0026#34;username\u0026#34;,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos = userInfoMapper.selectList(userInfoQueryWrapper); System.out.println(userInfos); */ // lambda 1: 使用LambdaWrapper类 LambdaQueryWrapper\u0026lt;UserInfo\u0026gt; lambdaMapper = new LambdaQueryWrapper\u0026lt;\u0026gt;(); lambdaMapper.select(UserInfo::getId,UserInfo::getUserName, UserInfo::getPassword) .eq(UserInfo::getId,10).like(UserInfo::getUserName,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos1 = userInfoMapper.selectList(lambdaMapper); System.out.println(userInfos1); // lambda 2: 使用Wrapper类的lambda方法 QueryWrapper\u0026lt;UserInfo\u0026gt; queryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.lambda().select(UserInfo::getId,UserInfo::getUserName, UserInfo::getPassword) .eq(UserInfo::getId,10).like(UserInfo::getUserName,\u0026#34;min\u0026#34;); List\u0026lt;UserInfo\u0026gt; userInfos2 = userInfoMapper.selectList(queryWrapper); System.out.println(userInfos2); } /** * LambdaUpdateWrapper * update user_info set delete_flag = 0 where id \u0026lt; 20 */ @Test void testLambdaUpdateWrapper(){ UpdateWrapper\u0026lt;UserInfo\u0026gt; updateWrapper = new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.lambda().set(UserInfo::getDeleteFlag, 0) .lt(UserInfo::getId, 20); // where id \u0026lt; 20 int num = userInfoMapper.update(updateWrapper); System.out.println(num); } } ‍\n","permalink":"https://Jul1en-Lin.github.io/p/-mybatisplus/","title":"💫 MyBatis／plus"},{"content":" 自动化测试 自动化测试包括\n接口自动化测试\nUI自动化测试\nWeb自动化测试\n客户端界面自动化测试\n这次来讲讲Web 自动化测试。\nWeb 自动化测试 在大厂的研发流程中，自动化测试是\u0026quot;CI/CD（持续集成/持续部署）\u0026ldquo;不可或缺的一环。\n简单来说，Web 自动化测试就是​把原本需要人工在浏览器上点击、输入、验证的操作，通过代码指令让计算机自动执行。\nSelenium 与 WebDriver 目前业界最主流的 Web 自动化工具就是 Selenium。虽然现在也有 Playwright 等新秀，但 Selenium 依然是很多大厂面试的基石。\nSelenium 是什么？ Selenium 不是一个单一的软件，它是一个工具套件。核心组件是 ​Selenium WebDriver。\n它提供了一套跨语言的 API（Java, Python, C#, etc.）。 它通过“原生”方式与浏览器交互，就像真实用户在操作一样。 WebDriver 的工作原理（面试） 这是Selenium官方文档对WebDriver的介绍\n理解了这个图，你就懂了为什么需要下载“驱动”。\n它的运作流程是一个经典的“C/S 架构”（客户端/服务器）：\nClient（你的代码）： 你写的 Java 代码（发送命令，比如 driver.get(\u0026quot;www.baidu.com\u0026quot;)）。 JSON Wire Protocol / W3C Protocol： 代码将命令封装成 JSON 格式的 HTTP 请求，发送给驱动。 Server（浏览器驱动）： 比如 chromedriver.exe。它接收到 HTTP 请求后，解析命令，并调用浏览器的原生接口。 Browser（浏览器）： 执行操作（打开网页、点击按钮），并将结果返回给驱动，驱动再返回给你的代码。 ‍\nSelenium与WebDriver的关系？（面试） Selenium 是一个工具集，而 WebDriver 是其中用于通过原生协议驱动浏览器的核心 API。现在我们常说的 Selenium，通常默认指的就是 Selenium WebDriver。\n安装Selenium库 安装Selenium在Java中只需引入Maven依赖即可\n\u0026lt;!-- https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.seleniumhq.selenium\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;selenium-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.37.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; // version 2025.12.6 Selenium内置了 WebDriver 的 API，但它不包含浏览器驱动文件（exe），故需要手动下载 / 自动下载驱动\n程序想打开web浏览器就需要浏览器驱动，即（WebDriver），WebDriver以本地化的方式来驱动浏览器\n关于“驱动” 浏览器驱动（BrowserDriver）是你的代码和浏览器之间的桥梁。\n为什么需要驱动？ 浏览器（Chrome, Firefox等）都是复杂的闭源或开源软件，出于安全考虑，不允许外部程序直接随意控制。浏览器厂商（Google, Mozilla）为了方便测试，自己开发了对外接口的“遥控器”，这个遥控器就是 Driver。\n常见浏览器与驱动对应表 浏览器 驱动名称 下载关键词 Google Chrome ChromeDriver ​chromedriver Firefox GeckoDriver ​geckodriver Microsoft Edge EdgeDriver ​msedgedriver 驱动版本适配管理（重点）\n版本匹配原则：这是新手最容易报错的地方。\n你的 Chrome 浏览器版本 必须与 ChromeDriver 版本 严格对应。例如，浏览器是 v120，驱动也必须下载 v120 版本的。否则会报错 SessionNotCreatedException。\n我们可以在浏览器内查看当前浏览器版本，安装的selenium 驱动版本，可以在本地c盘用户的C:\\Users\\xxx.cache文件下看到selenium文件夹，里面安装着不同的内核版本\n常见驱动下载方式 手动下载（不建议） （注：虽然可以有程序自动管理驱动，但了解手动匹配依然是基础)\n下载驱动的位置在Selenium官方文档中有提供，地址为\n​https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location/#download-the-driver\n不建议手动下载，因为浏览器的更新功能是默认打开的，什么时候更新是不知道的。可能今天自动化正常执行，第二天就要重新下载最新版的驱动了，且不同的浏览器就要我们手动下载不同的驱动，非常麻烦。\n这也不建议手动关闭浏览器的自动更新功能，不仅测试环境与生产环境有脱节，错过了新的自动化的功能或者改进的地方，而且可能导致Selenium库与旧的浏览器版本兼容，这种头疼砍头的操作不可取。\n由驱动程序自动下载 （注：Selenium 4.6.0 版本开始引入了 Selenium Manager，可以自动管理驱动，但了解WebDriverManager也是非常重要的基础）\n更好的做法不是禁用更新，而是采用更健壮的测试策略\n需要引入WebDriverManager辅助工具库\n用途：它可以让你不用手动去浏览器官网找对应版本的驱动下载，它会在代码运行时自动检测浏览器版本并下载对应的驱动。 引入WebDriverManager依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/io.github.bonigarcia/webdrivermanager --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.github.bonigarcia\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;webdrivermanager\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.1.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; // version 2025.12.6 更新Selenium库至 4.6+ 版本 适用于更加新的项目，直接 new Driver()， Selenium就能给你匹配最新的驱动版本\nSelenium文档中的描述\n传统方案： 使用 webdrivermanager 第三方库。适合维护旧项目（Selenium 3.x 或 4.x 早期版本）\n// 传统方案 WebDriverManager.chromedriver().setup(); // 手动呼叫后勤 WebDriver driver = new ChromeDriver(); 现代方案： 直接使用 selenium-java​ (4.6+)，利用内置的 Selenium Manager，实现零辅助库配置启动\n// 什么都不用配，直接 new // 底层会自动触发 Selenium Manager 去干活 WebDriver driver = new ChromeDriver(); 配置断言 配置断言需要在IDE上手动配置\nassert\n如：assert txt.equals(\u0026ldquo;登录\u0026rdquo;)\nidea中开启断言的方法\n总结 我用Banana Pro生成了这一副插画，能更加形象的了解Web自动化测试执行的逻辑😽\nWeb自动化常用函数 🙈与其像流水账一样罗列 API，不如按照“业务场景”或者“对象层级”来分类\n浏览器级的操作（Driver） 这部分方法是直接作用于WebDriver​对象的，控制着浏览器的生命周期与状态，通常用于测试开始前的准备（Setup）或测试结束后的清理（Teardown），以及用于 “断言 ” 的前置检查。\n获取页面信息 （用于断言） 在自动化测试中，我们怎么知道脚本是不是跳转到了正确的页面？靠的就是这两个方法：\n​​driver.getTitle()​\n​介绍：获取当前浏览器窗口标签页上的标题文本。 用途：这是最常用的断言点。比如登录成功后，验证标题是否包含“个人中心”或“首页” 注意：如果新开窗口后没有切换driver，则driver依旧作用在旧窗口，识别不了另一个窗口的信息，如有需要用到切换窗口的方法，这就引出获取当前 / 所有句柄id\n​​driver.getCurrentUrl()​\n​介绍：获取浏览器地址栏当前的 URL 字符串。 用途：验证重定向是否正确。比如支付完成后，URL 是否跳转到了 /success 页面。 ​​driver.getWindowHandle() / getWindowHandles()​\n介绍：获取句柄（标签页）id\n用途：一般搭配切换句柄的操作\n// getWindowHandle 获取driver当前标签页/句柄id String window = driver.getWindowHandle(); System.out.println(\u0026#34;window: \u0026#34; + window); // getWindowHandles 获取所有标签页/句柄id driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;s-top-left\\\u0026#34;]/a[6]\u0026#34;)).click(); Set\u0026lt;String\u0026gt; handles = driver.getWindowHandles(); // 切换窗口操作 for (String handle : handles) { if (!window.equals(handle)) { driver.switchTo().window(handle); } } String jump_title = driver.getTitle(); String jump_url = driver.getCurrentUrl(); System.out.println(\u0026#34;jump_title：\u0026#34; + jump_title); System.out.println(\u0026#34;jump_url：\u0026#34; + jump_url); // 这样就能正确切换窗口且得到新窗口的title 和 url 浏览器窗口调整 ​​driver.manage().window().maximize()​ ​ / ​.setSize(里面需传入Dimension对象，手动规定尺寸)​ ：\n介绍：自动化脚本启动时，默认通常是小窗口。\n​用途：\n​全屏​：通常建议在 driver​ 初始化后立即调用 maximize()，因为很多网页在小窗口下会折叠菜单（变成汉堡按钮），导致元素不可见或定位失败。 ​指定尺寸​：在测试“响应式布局”时，通过 setSize() 模拟手机或平板的分辨率。 浏览器的关闭 （面试） ​​driver.close()​ ：\n介绍：仅仅关闭当前正在操作的那个标签页（Tab）或窗口 注意：如果当前浏览器只有一个窗口，调用它也会导致浏览器退出，但它不会清除驱动进程。 避坑点： 当调用 driver.close()​ 关闭当前标签页后，WebDriver 并不会自动把焦点切换回剩下的那个标签页\n解决办法：\n在 close 前保存下当前句柄 id，然后删除后便于 driver 切换新的标签页（适用于随便去一个活着的窗口的情况）\ndriver.get(\u0026#34;https://www.baidu.com/\u0026#34;); // 新开百度图片标签页 driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;s-top-left\\\u0026#34;]/a[6]\u0026#34;)).click(); // 删除当前标签页后 driver 指向空，无法对新开的百度图片标签页进行操作 // 故需要在关闭标签页后切换句柄 使driver对象指向新的句柄 String windowHandle = driver.getWindowHandle(); Set\u0026lt;String\u0026gt; windowHandles = driver.getWindowHandles(); driver.close(); // 无法直接获得新的句柄的title 必须使driver切换句柄 // System.out.println(driver.getTitle()); // error~~ // 需要切换句柄 for (String handle : windowHandles) { if (!handle.equals(windowHandle)) { driver.switchTo().window(handle); break； } } System.out.println(driver.getTitle()); // success~~ Parent-Child 模式（标准）\n在实际业务测试中（如：在列表页点击商品 -\u0026gt; 弹出新标签页详情 -\u0026gt; 验证完关闭详情 -\u0026gt; 回到列表页继续点下一个）需要“从哪里来，回哪里去”的逻辑，更有目的性地跳回\n// 场景：在 首页(A) 点击链接打开了 详情页(B)，处理完 B 后关闭并回到 A // 1. 【核心】在这一刻，Driver 还在 A 页面，先保存 A 的身份证 String originalWindowHandle = driver.getWindowHandle(); // 2. 执行打开新页面的操作 driver.findElement(By.id(\u0026#34;open-new-tab-link\u0026#34;)).click(); // 3. 切换到新窗口 (这里假设只要不是原窗口就是新窗口) for (String handle : driver.getWindowHandles()) { if (!handle.equals(originalWindowHandle)) { driver.switchTo().window(handle); break; // 切过去就不动了 } } // --- 此时 Driver 已经在 B 页面了 --- System.out.println(\u0026#34;在新页面操作: \u0026#34; + driver.getTitle()); // 4. 业务结束，关闭当前的新页面 B driver.close(); // ⚠️ 此时 Driver 是悬空的，指向一个不存在的 B // 5. 【核心】显式切换回原窗口 A driver.switchTo().window(originalWindowHandle); // --- 此时 Driver 又回到了 A 页面，复活了 --- System.out.println(\u0026#34;回到原页面: \u0026#34; + driver.getTitle()); ​​driver.quit()​ ：\n介绍：彻底关闭整个浏览器，关闭所有打开的标签页，并销毁 ChromeDriver 进程。 注意：在自动化测试框架的 ​finally​​ 块或 ​@After​​ 钩子中，​必须使用 ​quit()​ ​ ，否则你的服务器内存会被浏览器进程吃光。强调资源释放的重要性！ 浏览器导航 控制页面的前进、后退与刷新：\n​​driver.navigate().back()​ ：模拟点击浏览器“后退”按钮 ​​driver.navigate().forward()​ ：模拟点击浏览器“前进”按钮 ​​driver.navigate().refresh()​ ：强制刷新当前页面 屏幕截图 一般用于记录错误现场 / 检验是否达到预期效果\nFile srcFile = ((TakesScreenshot)driver).getScreenshotAs(OutputType.FILE); FileUtils.copyFile(srcFile, new File(\u0026#34;error.png\u0026#34;)); 浏览器参数设置 在初始化 driver​ 前通过 Options 类配置：\n无头模式：options.addArguments(\u0026quot;--headless\u0026quot;)（不打开界面运行，速度快，但难以看到进行到哪一步） 隐身模式：options.addArguments(\u0026quot;--incognito\u0026quot;) 元素级操作（Element） 需要先通过 driver.findElement()​ 找到元素WebElement后才能调用后续的方法。这是自动化测试中最繁忙的部分，简单来讲就是模拟人类的手指的各种操作\n输入与交互 ​​element.sendKeys(\u0026quot;text\u0026quot;)​ ：\n​介绍：模拟键盘向输入框输入指定内容。 ​高阶用法​：它不仅能输入文字，还能用于​文件上传​（如果 input 类型是 file，直接 sendKeys 文件路径即可），或者模拟按键（如 Keys.ENTER）。 ​​element.clear()​ ：\n介绍：清空输入框中的已有文本。 避坑：在调用 sendKeys​ 之前，建议先调用 clear()​。因为如果输入框有默认值，sendKeys 会直接追加在后面，导致数据错误。如：\n// 如果不进行clear操作，则会在“gpt”文本后追加“chrome”文本 driver.get(\u0026#34;https://www.baidu.com/\u0026#34;); driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;chat-textarea\\\u0026#34;]\u0026#34;)).sendKeys(\u0026#34;gpt\u0026#34;); // 应该clear上一次的文本 // driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;chat-textarea\\\u0026#34;]\u0026#34;)).clear(); driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;chat-textarea\\\u0026#34;]\u0026#34;)).sendKeys(\u0026#34;chrome\u0026#34;); driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;chat-submit-button\\\u0026#34;]\u0026#34;)).click(); ​​element.click()​ ：\n​介绍：模拟鼠标左键点击。 ​适用：不仅是按钮，超链接、复选框、单选按钮，甚至普通的文本（如果有点击事件）都可以点。 获取元素信息 ​​element.getText() / .getAttribute(\u0026quot;value\u0026quot;)​ ：\n​介绍​：获取元素标签对之间的​可见文本。 ​用途：这是验证测试结果的核心。比如下单成功后，获取页面上的提示语“支付成功”，与预期结果进行比对。 注意：如果元素在页面上被隐藏（Hidden），这个方法通常会返回空字符串。 避坑点： 如果为了获取输入框里用户输入的值，用 getText()​ 是拿不到的，必须用 getAttribute(\u0026quot;value\u0026quot;)\n比如想获取红色框的文字，定位这元素所在的位置 发现文字是在placeholder​属性内的，必须要用getAttribute(\u0026quot;placeholder\u0026quot;)才能拿到！\n​getText()​适合用在\u0026lt;标签\u0026gt; \u0026ldquo;xxxx\u0026rdquo; \u0026lt;标签\u0026gt;被标签内包裹的文本信息，如\n文件上传 文件上传不同于我们普通的鼠标点击页面选择文件上传，Selenium是无法控制系统窗口来选择文件的\n操作：无需点击“上传”按钮弹出系统窗口，直接对 type='file'​ 的 \u0026lt;input\u0026gt;​ 标签使用 sendKeys(\u0026quot;文件绝对路径\u0026quot;)\n窗口与句柄切换 ​​driver.switchTo().window(handle)​ ：\n​核心概念——句柄 (Handle) ：每个标签页都有一个唯一的身份证号，叫句柄。\n场景：当点击了一个链接，浏览器打开了一个新的标签页。此时虽然人眼看到了新页面，但 driver​ 的焦点还停留在旧页面上。如果直接操作新页面的元素，会报错：NoSuchElementException\n​操作逻辑：\n获取所有窗口的句柄：driver.getWindowHandles()。 遍历这些句柄，排除掉当前句柄。 使用 switchTo().window(新句柄) 将控制权移交到新页面。 // getWindowHandle 获取driver当前标签页/句柄id String window = driver.getWindowHandle(); System.out.println(\u0026#34;window: \u0026#34; + window); // getWindowHandles 获取所有标签页/句柄id driver.findElement(By.xpath(\u0026#34;//*[@id=\\\u0026#34;s-top-left\\\u0026#34;]/a[6]\u0026#34;)).click(); Set\u0026lt;String\u0026gt; handles = driver.getWindowHandles(); // 切换窗口操作 （随机切换式） for (String handle : handles) { if (!window.equals(handle)) { driver.switchTo().window(handle); break; } } // 这样就能正确切换窗口 特殊交互处理 JS弹窗\n这类弹窗不是 HTML 元素，需使用 driver.switchTo().alert() 切换焦点，三种弹窗都能切换。弹窗一般有这三种\n弹窗类型 描述 操作方法 警告弹窗 只有“确定”按钮 ​alert.accept() 确认弹窗 有“确定”和“取消” ​accept() / dismiss() 提示弹窗 可以输入文本 ​sendKeys(\u0026quot;文本\u0026quot;)​后accept() / dismiss() Selenium的Alert类源码\n等待机制与DOM DOM DOM (Document Object Model) 是浏览器将 HTML 代码解析后生成的一棵“树”。\n每一个 HTML 标签（如 \u0026lt;div\u0026gt;​, \u0026lt;a\u0026gt;​, \u0026lt;button\u0026gt;​）都是这棵树上的一个节点（Node） 存在于 DOM 中： 意味着这棵树上已经长出了这个节点 理解等待的前提是分清元素在 HTML 里的状态，我们可以把网页比作一栋正在盖的房子，那么里面的家具就是元素：\n存在于 DOM ： 相当于房子的地基和框架已经打好了。虽然你还没看到窗户（元素被遮盖了，你在网页上看不到），但窗户的位置已经在建筑图纸里确定了（click-false） 可见 ： 相当于装修完成且灯光照亮。你不仅知道窗户在哪，还能伸手摸到它（click-success） 等待机制 类型 机制 作用范围 核心特点 强制等待 (​​Thread.sleep​​ ) 强行暂停线程 局部 最死板。无论加载多快都要等够时间，浪费效率 隐式等待 (​​implicitlyWait​​ ) 轮询 DOM 查找元素 全局 只管“存在” 。只要 DOM 里有标签就停止等待，不管元素是否可见 显式等待 (​​WebDriverWait​​ ) 指定条件触发 局部 （最优先💫) 最灵活。可等待“可见”、“可点击”等多种状态，满足即止 等待使用方式 // 强制等待 Thread.sleep(1000); // 隐式等待 作用域所有Element元素 直至页面资源完全加载出来 driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(2)); driver.findElement(By.cssSelector(\u0026#34;#s_tab_inner \u0026gt; a.s-ta\u0026#34; + \u0026#34;b-item.s-tab-item_1CwH-.s-tab-more \u0026gt; span\u0026#34;)).click(); // 后续不需要再写等待 隐式等待同时作用于这里 driver.findElement(By.cssSelector(\u0026#34;#content \u0026gt; div:nth-child(6) \u0026gt; div:nth-child(2) \u0026gt; a\u0026#34;)).click(); // 显示等待 WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(2)); // 显示等待结束的满足条件：urlContains/visibilityOfElementLocated/.... wait.until(ExpectedConditions.urlContains(\u0026#34;https://www.baidu.com/more/\u0026#34;)); Actions类 Selenium提供了Actions类，用来模拟在键盘 / 鼠标的操作，更有利于**==复杂式的交互动作==**\n执行 每次执行一个操作链路，后面都要以.perfrom()结尾，否则动作不会真正执行\n鼠标级操作 方法 说明 业务场景 ​​moveToElement(ele)​ 鼠标悬停（Hover） 触发二级下拉菜单显示 ​​contextClick(ele)​ 鼠标右键点击 打开自定义右键菜单 ​​doubleClick(ele)​ 鼠标双击 选中文字或触发特定双击事件 ​​dragAndDrop(source, target)​ 拖拽 A 到 B 滑块验证码、排序列表、拖拽文件 ​​clickAndHold()​ 按住不放 模拟长按效果 键盘级操作 通常配合 Keys 枚举类使用，适合处理快捷键组合。Keys类枚举了许多键盘的按键，列举几个常用的\n​​keyDown(Keys.SHIFT)​ ：按下某个键不松开 ​​keyUp(Keys.SHIFT)​ ：释放按下的键 ​​sendKeys(Keys.ENTER)​ ​：回车键（比元素级的 sendKeys 更通用） 场景示例（全选并删除）\nnew Actions(driver) .keyDown(Keys.CONTROL) .sendKeys(\u0026#34;a\u0026#34;) .keyUp(Keys.CONTROL) // Ctrl + A 全选 .sendKeys(Keys.BACK_SPACE) // 退格删除 .perform(); // 以perform结尾 ‍\n","permalink":"https://Jul1en-Lin.github.io/p/web%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95/","title":"🎓 Web自动化测试"},{"content":"Spring 两个核心思想：IoC \u0026amp; DI 和 AOP\nIoC \u0026amp; DI IoC全名为Inversion of Control，即“控制反转”，何为控制反转呢？\n控制：指的是对象的创建、初始化、销毁和生命周期的管理权\n反转：指这种管理权发生了反转：\n传统模式：一般是由程序员来手动new创建对象、并管理依赖，类似UserController user = new UserController() IoC模式：对象的创建和依赖的注入权交给Spring来管理。程序不再由程序员主动来创建，而是被动等待Spring容器将所需的对象注入 / 提取出来 SpringIoC作为一个容器，它管理（装）的是对象（Bean），通过注解将对象交给Spring管理，有两类注解：\n类注解：@Controller @Service @Repository @Configuration @Component 方法注解：@Bean Q：为什么需要这种模式？\nA：目的是为了解耦合，将对象的创建权交给Spring，各个组件之间不再强依赖，使得代码更加容易维护、管理与扩展\n这里有简单的代码实力，对比传统模式与IoC模式的区别：假设我们有一个用户服务 (​​UserService​​ ) ，它需要调用数据访问层 (​​UserDao​​ ) 来把用户信息保存到数据库\n传统模式：在这种模式下，UserService​ 自己负责创建它需要的依赖对象。\npublic class UserService { // ❌ 缺点：高度耦合 // UserService 必须明确知道 UserDao 的具体实现类是哪个 (UserDaoImpl) // 如果将来想换成 UserDaoNewImpl，必须修改这里的代码 private UserDao userDao = new UserDaoImpl(); public void saveUser(String name) { userDao.add(name); } } 控制权： 在程序员手中。你必须手动写 new UserDaoImpl()。 问题： 代码“写死”了。如果你想做单元测试（Mock 一个假的 UserDao），非常困难。 Spring IoC模式（依赖注入）：在这种模式下，UserService​ 只定义它需要什么，而不关心对象是怎么来的。Spring 容器负责把对象“递”进去。\nimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service // 1. 告诉 Spring：这个类交给你管理，它是一个 Bean（对象） public class UserService { // ✅ 优点：解耦 // UserService 只知道它需要一个 UserDao 接口 // 具体是哪个实现类，由 Spring 容器自动注入 @Autowired private UserDao userDao; public void saveUser(String name) { userDao.add(name); } } 控制权： 在 Spring 容器手中（反转了）。\n发生了什么：\nSpring 扫描到 @Service​，创建并管理了 UserService 的实例。 Spring 看到 @Autowired​，发现 UserService​ 需要一个 UserDao。 Spring 在自己的容器里找到合适的 UserDao​ 实例，直接注入（赋值）给 userDao 变量。 前提是UserDao​也需要被Spring管理，数据层一般添加@Repository注解管理\nimport org.springframework.stereotype.Repository; // 1. 定义接口 public interface UserDao { void add(String name); } // 2. 实现接口，并加上 @Repository 注解 @Repository public class UserDaoImpl implements UserDao { @Override public void add(String name) { // 模拟数据库操作 System.out.println(\u0026#34;保存用户到数据库: \u0026#34; + name); } } 看到这会疑惑为什么要专门写一个UserDao​接口，然后专门交给UserDaoImpl来实现？\n简短的回答是：\n从技术上讲，完全可以不写接口，直接定义一个 UserDao​ 类并加上 @Repository，Spring 照样能工作\n但是使用接口+实现类的模式主要有以下优点：\n这是面向对象设计（SOLID 原则）中的 D (Dependency Inversion Principle)思想——上层模块（Service）不应该依赖于下层模块（DAO）的具体实现，而应该依赖于抽象（接口）。\n​UserService​只需要知道有UserDao​接口，只需要调用UserDao接口内的方法就好了，但具体怎么实现的则不需要知道（是用 MySQL 写的，还是用 Oracle 写的，或者是把数据写到了 Redis 里）\n举例： 如果有一天你需要把数据库从 MySQL 换成 MongoDB，你只需要新写一个 UserDaoMongoImpl​ 实现接口，而不需要修改 UserService 的任何一行代码。\nSpring AOP的原因\nAOP的底层原理是动态管理\nJDK动态代理（标准）\n只能代理接口，如果Bean对象实现了接口，Spring会默认使用JDK动态代理，效率高，原生支持好\nCGLIB代理（第三方）\n如果Bean对象没有实现接口，只是代理了一个普通的类，Spring就必须使用第三方CGLIB库，继承你的类并重写方法来生成代理\n虽然现在SpringBoot能很好的解决这一类问题，但使用接口配合JDK动态管理已经成为了一种惯例\n💫ORM框架的特殊需求（如MyBatis、JPA）\n如果使用的是MyBatis或Spring Data JPA，那就只需要写接口，连实现类都不用写\nMyBatis：你定义UserMapper接口，MyBatis会自动生成代理对象执行SQL JPA：你定义UserRepository​接口，继承JpaRepository，Spring Data自动帮你生成增删改查的实现 在这种情况下，实现接口是必须的，因为实现类是由框架在运行时动态生成的，你也无法去写一个具体的类\n五大注解详解 类注解：@Controller @Service @Repository @Configuration @Component 方法注解：@Bean 着重讲一下类注解，能发现这有五种不同的类注解，但都能交给Spring管理，那他们的区别是什么呢？\n不同的类注解从名字上就能看出主要应用在哪个层，最基本的项目开发都会有 “控制层—\u0026gt;服务逻辑层—\u0026gt;数据层” 的结构，如果更加复杂的项目开发分开的更加细致，所以不同的注解名字也能第一眼就能看出这个对象主要在哪个层在工作\n基本关系为： 控制层调用服务层，服务层调用数据层，数据层返回结果给服务层，服务层再返回结果给控制层。\n@Controller：控制层——接受参数，返回响应（前后端交互的入口）\n@Service：业务逻辑层\n@Repository：数据层\n@Configuration：配置层\n@Component：组件层——除此以外的所有需要交给Spring管理的对象都能用此注解\n我们扒开源码查看，能看出他们之间的关系\n其中@Controller @Service @Repository @Configuration 都实现了@Component，它们都是@Component的衍生注解\n在实际应用中，这五个注解的边界并没有这么清晰，但控制层必须使用@Controller，不可以和其他注解替换\nBeanName 在Spring容器中取出对象时会使用对应的BeanName，类注解与方法注解都有不同的BeanName命名方法\n五大类注解\nBeanName默认为类名的小驼峰写法，如果类名前两位字母均为大写则BeanName为类名本身，举例：\n@Controller public class UserController { public void print() { System.out.println(\u0026#34;do controller\u0026#34;); } } public static void main(String[] args) { /** * 获取 bean 的命名方式 * 首字母需变小写 * 如果连续两个大写字母开头则直接写 */ ApplicationContext run = SpringApplication.run(SpringIocApplication.class, args); UserController bean = (UserController) run.getBean(\u0026#34;userController\u0026#34;); bean.print(); } 方法注解@Bean\n需要搭配五大注解使用，BeanName为默认方法名，举例：\n@Component public class StudentComponent { @Bean public Student s1 () { return new Student(\u0026#34;lili\u0026#34;,9); } @Bean public Student s2 () { return new Student(\u0026#34;Jack\u0026#34;,25); } } public static void main(String[] args) { /** * 同时在注解中也提供了对类 / 方法进行重命名 * 包括 @Controller @Service @Configuration @Repository @Component @Bean * 注解后对应的类名 / 方法名则无效 * 只能填重命名后的 * @Bean(\u0026#34;aaa\u0026#34;) * public Student s2 () { * return new Student(\u0026#34;Jack\u0026#34;,25); * } * 如果是run.getBean(\u0026#34;s2\u0026#34;) 报错：NoSuchBeanDefinitionException */ Student s = (Student)run.getBean(\u0026#34;s2\u0026#34;); System.out.println(s); } DI详解 与之IoC匹配的就是DI，DI全名为Dependency Injection，即依赖注入，既然有IoC管理，那就会有DI从Spring容器中取出来，DI就是承担这样的角色——DI 是 IoC 的一种具体实现方式，也能理解为：容器通过注入依赖来实现控制反转，依赖注入是一个过程\nDI分三种方式注入 属性注入 构造方法注入 Setter注入 属性注入 属性注入是通过@Autowired​实现的。如果有多个属性需要逐行注入。@Autowired只对紧挨着那一行代码生效\n构造方法注入 如果只有一种构造方法，则执行这一种构造方法，无论是有参构造还是无参构造\nprivate UserService service; public UserController(UserService userService) { System.out.println(\u0026#34;执行有参构造方法\u0026#34;); this.service = userService; } public void print() { service.print(); System.out.println(\u0026#34;do controller\u0026#34;); } // 启动项 UserController bean = text.getBean(UserController.class); bean.print(); // 运行结果 // 执行有参构造方法 // do service // do controller 执行成功，print()​拿到service对象\n‍\n如果有多种构造方法，则默认执行无参的构造方法\n存在无参的构造方法 private UserService service; private UserRepository repository; public UserController(UserService userService,UserRepository userRepository) { System.out.println(\u0026#34;执行有参构造方法1\u0026#34;); this.service = userService; this.repository = userRepository; } public UserController(UserService userService) { System.out.println(\u0026#34;执行有参构造方法2\u0026#34;); this.service = userService; } public UserController() { System.out.println(\u0026#34;执行无参构造方法\u0026#34;); } public void print() { service.print(); repository.print(); System.out.println(\u0026#34;do controller\u0026#34;); } // 启动项 UserController bean = text.getBean(UserController.class); bean.print(); 由于执行的是无参的构造方法， print()​方法拿不到service​和repository对象，故报错\n如果不存在无参构造方法，则直接报错。报错日志为：找不到默认的构造方法 private UserService service; private UserRepository repository; public UserController(UserService userService,UserRepository userRepository) { System.out.println(\u0026#34;执行有参构造方法1\u0026#34;); this.service = userService; this.repository = userRepository; } public UserController(UserService userService) { System.out.println(\u0026#34;执行有参构造方法2\u0026#34;); this.service = userService; } // public UserController() { // System.out.println(\u0026#34;执行无参构造方法\u0026#34;); // } public void print() { service.print(); repository.print(); System.out.println(\u0026#34;do controller\u0026#34;); } // 启动项 UserController bean = text.getBean(UserController.class); bean.print(); 可以通过@Autowired 注解来指定构造方法，此时1. 和 2. 的情况都能解决，没有无参构造方法也不会报错，这两种情况都能运行\nprivate UserService service; private UserRepository repository; @Autowired public UserController(UserService userService,UserRepository userRepository) { System.out.println(\u0026#34;执行有参构造方法1\u0026#34;); this.service = userService; this.repository = userRepository; } public UserController(UserService userService) { System.out.println(\u0026#34;执行有参构造方法2\u0026#34;); this.service = userService; } public UserController() { System.out.println(\u0026#34;执行无参构造方法\u0026#34;); } public void print() { service.print(); repository.print(); System.out.println(\u0026#34;do controller\u0026#34;); } // 运行结果 // 执行有参构造方法1 // do service // do repository // do controller ​@Autowired 就是明确该使用哪个构造方法的\nSetter注入 ​Setter​ 注入和属性的Setter ​方法实现类似，只不过在设置 set​ 方法的时候加上@Autowired 注解，如下所示\n// Setter 方法注入 private UserService service; private UserRepository repository; @Autowired public void setUserService(UserService service) { this.service = service; } @Autowired public void setUserService(UserRepository repository) { this.repository = repository; } public void print() { service.print(); repository.print(); System.out.println(\u0026#34;do controller\u0026#34;); } 三种注入的优缺点 属性注入\n优点：简洁，使用方便\n缺点：\n不能注入​​final​​修饰的属性——​final​​的属性有要求，一定需要初始化。要么在属性注入的时候进行初始化，要么在构造方法中进行初始化，但这都违背了注入的初衷：只想从Spring容器中取出来，不想手动初始化，否则我用​​@Autowired​​就没意义了\n构造方法注入\nSpring官方团队（以及Idea的提示）目前最推荐使用的是“构造器注入”\n优点：\n可以注入​​final​​修饰的属性——可以在构造方法中进行初始化，也是解决​​final​​必须初始化的要求\n注入的对象不会被修改，除非有​​set​​方法再对对象修改，否则初始化后就定好了\n通用性好：构造方法是JDK支持的，所以更换任何框架都是使用的\n**依赖对象在使用前一定被完全初始化，因为依赖是在类的构造方法中执行的，而构造方法是在类加载的阶段就会被执行——**​从控制台也能简单看出来，先执行构造方法后再运行服务器\n缺点：注入多个对象时，代码比较繁琐\nSetter注入\n优点：方便在类实例后，重新对该对象进行配置或注入\n缺点：\n不能注入​​final​​修饰的属性\n注入对象可能会被修改，因为提供了setter方法，就有可能被多次调用修改的风险——==这也对应了构造方法的优点：注入的对象不会被修改==\n@Autowired存在的问题 当同一个类型存在多个Bean​时，使用@Autowired会存在问题，示例代码如下：\n@Service public class UserService { @Autowired private Student ss; public void print() { System.out.println(ss); System.out.println(\u0026#34;do service\u0026#34;); } } @Component public class StudentComponent { @Bean(\u0026#34;bbb\u0026#34;) public Student s1 () { return new Student(\u0026#34;lili\u0026#34;,9); } @Bean(\u0026#34;ccc\u0026#34;) public Student s2 () { return new Student(\u0026#34;Jack\u0026#34;,25); } } 能看到报错原因是，非唯一的Bean对象，Spring无法分辨该把哪个对象注入\n如何在不删除Bean对象的前提下，解决上述问题呢？Spring给了三种注解\n​@Primary ​@Qualifier ​@Resource ​@Primary​：当存在多个相同类型的Bean​注入时，加上@Primary，确认默认的实现\n@Service public class UserService { @Autowired private Student ss; public void print() { System.out.println(ss); } } @Component public class StudentComponent { @Bean @Primary // 指定该Bean为默认Bean的实现 public Student s1 () { return new Student(\u0026#34;lili\u0026#34;,9); } @Bean public Student s2 () { return new Student(\u0026#34;Jack\u0026#34;,25); } } // 输出结果 // Student(name=lili, age=9) ​@Qualifier​：指定当前Bean​的对象，在@Qualifier​的value​属性注入Bean​的名字 （默认也是value，内部只有一个String value）\n不能单独使用，需要配合​ ​@Autowired​\n@Service public class UserService { @Autowired @Qualifier(\u0026#34;s2\u0026#34;) private Student ss; public void print() { System.out.println(ss); } } @Component public class StudentComponent { @Bean public Student s1 () { return new Student(\u0026#34;lili\u0026#34;,9); } @Bean public Student s2 () { return new Student(\u0026#34;Jack\u0026#34;,25); } } // 输出结果 // Student(name=Jack, age=25) ​@Resource​：按照Bean​的名称进行注入，通过@Qualifier​里的name​属性注入Bean的名字\n@Service public class UserService { // @Autowired // @Qualifier(\u0026#34;s2\u0026#34;) @Resource(name = \u0026#34;bbb\u0026#34;) private Student ss; public void print() { System.out.println(ss); } } @Component public class StudentComponent { // 作用是一样的 // @Bean({\u0026#34;bbb\u0026#34;,\u0026#34;ccc\u0026#34;}) // @Bean(value = {\u0026#34;bbb\u0026#34;,\u0026#34;ccc\u0026#34;}) @Bean(\u0026#34;bbb\u0026#34;) public Student s1 () { return new Student(\u0026#34;lili\u0026#34;,9); } @Bean public Student s2 () { return new Student(\u0026#34;Jack\u0026#34;,25); } } // 输出结果 // Student(name=lili, age=9) // 注意，使用重命名则原来方法命名就会失效，@Resource(name = \u0026#34;s1\u0026#34;)会报错 常见面试题 @Autowired 与 @Resource 的区别\n​@Autowired​ 是Spring框架提供的注解，@Resource 是JDK提供的注解\n​@Autowired​默认是按照类型注入，@Resource​除了匹配类型，默认按照名称注入。相比于@Autowired​ ，@Resource​ 支持更多的参数设置，例如name​设置，根据名称来获取Bean\n三种注入的优缺点 常见注解有哪些？分别是什么作用？\nweb URL 映射：@RequestMapping\n参数接收和接口响应：@RequestParam，@RequestBody，@ResponseBody\nBean 的存储：@Controller，@Service，@Repository，@Configuration，@Component，@Bean\nBean 的获取：@Autowired，@Qualifier，@Resource\nSpring、SpringMVC、SpringBoot之间的关系与区别 @Autowired的装配顺序 Spring、SpringMVC、SpringBoot之间的关系与区别 Spring在不同的角度回答也不同，分Spring和SpringFramework\nSpring：是Spring家族生态 SpringFramework：是核心容器 ‍\nSpring (Spring Framework)\n它是整个家族的核心容器\n核心能力： IoC 和 AOP 作用： 由Spring负责管理 Java 对象的生命周期，让对象之间解耦。如果没有 Spring，程序员需要手创建对象需要手动new Object()，极其难以维护 Spring MVC\n它是 Spring 框架中的一个Web 模块\n核心能力： 基于 Servlet 规范，实现了 MVC（Model-View-Controller）设计模式 核心组件： DispatcherServlet（前端控制器） 作用： 专门解决 WEB 开发的问题。它负责拦截用户发来的浏览器请求，分发给对应的 Java 方法处理，并返回数据或页面 注意： 它是 Spring 的一部分，不是独立于 Spring 存在的。 Spring Boot\n对Spring的一种封装，是Spring的脚手架，它集成了Spring内的各种功能，并且是一套 “约定大于配置”的工具集。\n它没有创造新的技术（底层还是 Spring MVC, Spring Core），但它通过依赖管理和自动配置，把 Spring 家族原本零散的功能，打包成了一个可以直接运行的“脚手架”，让开发更专注于Spring应用的开发，无需过多关心XML的配置和底层的实现\n核心能力： 自动配置 + 起步依赖（Starter）+ 内嵌服务器（Tomcat/Jetty）\n快速搭建结构，保持稳定，SpringBoot强的地方在于版本管理，有一个父级配置文件，里面写死了几百种常用库的最佳兼容版本号，写代码时，引入依赖就不需要再写版本号，默认会使用SpringBoot规定的最佳版本号\n作用：\n简化配置： 以前用 Spring + SpringMVC，需要配置 web.xml, applicationContext.xml 等一堆文件。Spring Boot 通过扫描你的 jar 包，自动帮你把这些都配好了\n简化部署： 它把 Tomcat 这种 Web 服务器直接塞进了 jar 包里，你运行 java -jar 就能启动网站，不用再去独立安装 Tomcat\n🥱如何理解 “约定大于配置” ？\n不仅仅把核心的功能打包好，而且默认规定了一套规则，程序员开发过程中的约定，大部分都遵守这套规则，如果没有特殊的要求，那就按照默认的规则执行如：\nweb应用的端口号默认是8080\n代码写在哪里？只要你的代码放在主启动类（Main Application）所在的包或者子包下面，我就能扫描到\n静态资源（图片/JS/CSS）放哪里？Spring Boot 约定只要你把文件扔在 src/main/resources/static 文件夹里，我就直接对外开放访问\n总结：约定大于配置 = 系统自带一套“最佳实践”的默认值\n如果没特殊需求，就可以开箱即用 有特殊需求：在application.properties里手动配置，灵活度高 一句话总结：Spring MVC 和 Spring Boot 都属于Spring，Spring MVC 是 Spring的一个MVC 框架，Spring Boot 则是基于 Spring 的一套快速开发整合包（脚手架）\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/spring-ioc-di/","title":"🍀Spring IoC \u0026 DI"},{"content":" 开发模型和测试模型 开发模型 学习测试，我们需要了解产品的开发流程，开发模型实际上指的就是产品的开发流程\n瀑布模型 瀑布模型是其他模型的基础框架，它是最基础的模型，模型如图所示\n特点 / 优点：\n开发流程是线性的，每个阶段执行一次就结束 适合小项目的开发 缺点：\n交付时间长，需要很久才能看到产品 测试阶段执行晚，如果一个需求引入的缺陷要到测试阶段或更晚的阶段才能看到，可能会导致前面的工作大面积的返工 螺旋模型 它与瀑布模型的不同是在需求分析到编码环节都进行风险分析，评估方案后才进入下一级产品\n特点 / 优点：\n强调开发中各个环节的质量 严格的风险管理 增加风险分析与原型 适合规模庞大、复杂度高、风险大的项目 缺点：\n开发成本增高，人员、资金、时间的投入 项目中存在的风险性与风险管理人员的水平有直接的关系 增量模型 / 迭代模型 它的核心思想是不要一次性把系统全部做完，而是分阶段地、分模块地逐步完善。\n每一次的构建（称为 “增量“）都在上一个版本的基础上进行完善与扩展；每一次的迭代都包括开发 → 测试 → 反馈 → 修正的完整过程。\n“增量”强调的是量的增长（功能一点点加）； “迭代”强调的是质的改进（版本不断优化）\n想象你在搭建一座乐高城堡。你不会一次性把所有零件拼完，而是先搭出地基，再加上城门、塔楼、装饰。每加完一部分（增量），你都会检查它是否稳固、是否和之前的部分契合（迭代）。\n这样，当城堡最终完工时，它不仅完整，而且每一层都经过了检验。\n特点 / 优点：\n每个增量都要单独测试，保证它稳定、可集成 每次迭代都能提前发现风险 测试结果反过来指导下一轮开发 适用于大项目且需求不明确的情况 缺点：\n如果项目的方向控制不严，就可能陷入反复重构、推倒重来的恶性循环，尤其在客户频繁修改需求时，团队可能永远都在“打补丁”，而不是向最终目标靠近。 系统整体性不易把握，后期可能会出现接口不兼容，模块耦合度过高等问题 管理和测试成本增高，每一次迭代都需要经过一次完整的开发、测试、评审、部署流程 增量模型 可能先上线一部分的功能 / 模块，后续再进行完善，上线顺序可能是 模块1 -\u0026gt; 功能1 -\u0026gt; 功能2 -\u0026gt; 功能3 -\u0026gt; 功能4 -\u0026gt; 模块2\n迭代模型 敏捷模型 敏捷模型的特点是：轻文档、轻流程、重目标，重产出。\nScrum模型就是敏捷模型中的一种，又称为迭代式增量软件开发模型，可以看成迭代模型与增量模型的结合。\nscrum的基本流程如图所示\n‍\n软件测试 软件测试的最终目的是验证产品==特性==是否满足客户需求~\n软件测试贯穿于软件的整个生命周期~（非常重要！！！！！面试可以装杯）\n🤔延申问题：如何理解软件测试贯穿于软件的整个生命周期~ 这句话？\n先来看软件的生命周期，才能更好的理解软件测试的生命周期\n软件的生命从需求分析就开始了，一直到上线维护\n软件测试的生命周期也从需求分析开始，虽然说在软件声明周期内测试是排在编码之后，但是软件测试的内容从需求分析就开始了，故说 ==软件测试贯穿于整个软件的生命周期！==\n需求分析：从用户角度（软件需求是否合理）、技术角度（技术上是否可行）、测试角度（是否存在业务逻辑错误、冲突等问题）分析 测试计划：指定测试计划——什么时候开发测试，什么时候结束测试，耗时需要多久 测试设计、开发：参考需求文档、技术文档等，来编写测试用例；编写测试文档，明确标注用到的测试方法，测试工具，测试形式等（输出测试设计） 测试执行：充分利用测试用例与工具，尽可能的做到全方面覆盖测试 测试评估：测试是否通过，本次测试是否有遗留BUG，产出测试报告——描述清楚测试的进度、项目进度，得出测试结论：是否具备上线条件 上线：上线后，测试人员跟踪并测试线上环境，确保运行是否正常 运行维护：在运行项目时收集问题并反馈相关负责人 测试模型 测试中有两个重要且标志性的测试模型：V 模型与 W 模型\nV模型 其中测试环节中的单元测试与集成测试一般是由开发人员来测试，单元测试的单元是人为规定的，可以认为是某个接口 / api / 方法 / 类\n它也是基于瀑布模型的，所以缺点也是测试后置，导致风险到后期才发现\n我们希望给测试留有充足的时间，否则会把缺点直接暴露到线上\nW模型 在 W 模型中，V 模型的测试后置的问题得到了解决，如图所示\n特点：\n测试与开发是同步进行的，测试的对象也不仅仅是程序，需求、设计等同样需要测试，有利于能尽早发现问题 缺点：\n注重流程，无法支持敏捷开发的模式，灵活性低 无论是测试线还是开发线，都是线性的，直到上一阶段的完全结束才能开始下一阶段的工作 ‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E5%BC%80%E5%8F%91%E6%A8%A1%E5%9E%8B%E5%92%8C%E6%B5%8B%E8%AF%95%E6%A8%A1%E5%9E%8B/","title":"🌵 开发模型和测试模型"},{"content":" Github 、Gitee 自动同步脚本 解决 GitHub Actions 同步 Gitee 过程中遇到的一些问题 前言 由于是在CSDN上更新博客，随着不断学习，想了想应该不应该局限于这一个平台\n于是萌生出搭建一个属于自己博客网站，最终决定采用Hugo + Github\nHugo工具负责生成静态页面，然后在Github上来部署静态页面，免费搭建出自己的博客\nb站上有非常优质的up来手把手搭建博客网站，我这里看的是莱特雷\n思路 up实现了GitHub自动部署的功能，但由于结合实际情况，我主要的代码存在Gitee的仓库中，在Github上更新不能在我的码云主页上有完整体现\n而用普通的镜像仓库不能做到同步的功能，Gitee导入Github仓库功能本质上是一个一次性克隆的仓库。如果想在Gitee上看到还需要手动同步，非常低效且麻烦\n😶‍🌫️所以我想新增一个懒功能：在GitHub更新Hugo博客后，自动同步到Gitee，并点亮Gitee的 “小绿点”，同时也是为了解决国内访问速度问题\n所以就继续完善莱特雷博主写的GitHub Action，每次 Push 到 GitHub 后，自动推送到 Gitee\n技术实现 配置了 GitHub Actions，利用 SSH 协议 将代码自动同步分发到 Gitee 仓库，实现了 ”一次提交，双端自动部署“ 的高效工作流，要注意：自动化脚本都不能生成密码，否则就不能自动执行了\nSSH密钥生成\n密钥与 Token 配置，生成后，需要分发到不同平台：\nGitHub (Settings -\u0026gt; Secrets -\u0026gt; Actions):\nGITEE_RSA_PRIVATE_KEY: 填入 私钥 (生成的 gitee_id_rsa 文件内容，是以 -----BEGIN RSA PRIVATE KEY----- 开头的格式) GITEE_TOKEN: 填入 Gitee 私人令牌 (在 Gitee 设置 -\u0026gt; 私人令牌中生成，需勾选 projects 和 hook 权限) Gitee (设置 -\u0026gt; SSH 公钥):\n填入公钥 (生成的 gitee_id_rsa.pub 文件内容) 填写配置文件\n遇到的坑 坑一：SSH的密钥格式问题 在同步Gitee仓库的时候出现了报错，虽然GitHub上可以完美推送到仓库中\n原因：\n由于我是直接使用默认的ssh-keygen​生成密钥，生成的是新版 OpenSSH 格式（以 BEGIN OPENSSH PRIVATE KEY​ 开头），而这个脚本环境比较老，只支持PEM格式的私钥\n配置在 GitHub Secrets 里的私钥格式，GitHub Actions 的环境无法识别，是格式不兼容的问题\n解决方法：\n重新生成 PEM 格式密钥，在本地终端执行\n# 在你的本地终端执行（不要在服务器上） # 注意：文件名可以改，不要覆盖了你原本的id_rsa ssh-keygen -t rsa -b 4096 -C \u0026#34;你的邮箱地址.com\u0026#34; -f ./gitee_id_rsa -m PEM 更新Github配置（更新私钥）\n回到你 GitHub 仓库的 Settings -\u0026gt; Secrets and variables -\u0026gt; Actions 找到 GITEE_RSA_PRIVATE_KEY，更新新的私钥 更新后记得保存 更新Gitee配置（更新公钥）\n打开生成的 gitee_id_rsa.pub (公钥) 去 Gitee -\u0026gt; 设置 -\u0026gt; SSH 公钥 删除之前添加的那个报错的公钥，格式不兼容留着也没有用了 添加这个新的公钥 坑二：仓库名称映射问题 解决了密钥问题后，同步依然失败，我怀疑是同步到了错误的仓库\n原因：\n由于前期的维护问题，我的Github上的用户名与Gitee上的用户名并不一致，仓库名也有细微区别，这就使得同步工作变得困难\n解决方法：\n经排查，我发现GitHub上博客仓库名是 Jul1en-Lin.github.io​， Gitee 那边在由于创建仓库的时，仓库访问URL路径默认是仓库名的小写形式，是jul1en-lin.github.io，两边名字不一样，导致不能正确同步\n在配置文件中static_list​列表正确填写上Gitee是正确的仓库访问路径：jul1en-lin.github.io，问题就解决了~\n修改后的配置文件如下，注意要严格对应仓库路径\nname: deploy # 代码提交到main分支时触发github action on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#34;latest\u0026#34; extended: true - name: Build Web run: hugo -D # 推送到Github仓库的Jul1en-Lin.github.io 仓库 - name: Deploy Web uses: peaceiris/actions-gh-pages@v4 with: # 这里是自动化脚本仓库的TOKEN，HUGO_TOKEN是名字，需要自己生成替换 PERSONAL_TOKEN: ${{ secrets.HUGO_TOKEN }} # 需要填写你的博客仓库 EXTERNAL_REPOSITORY: xxxx # 用户名/仓库路径 PUBLISH_BRANCH: main PUBLISH_DIR: ./public commit_message: auto deploy # 关键：为了 Gitee 小绿点，这里必须填你 Gitee 绑定的邮箱和昵称 # 这样生成的 Commit 会归属到你名下，同步过去才有绿点。 user_name: \u0026#39;你的Github用户名\u0026#39; user_email: \u0026#39;你的邮箱\u0026#39; # 这里最好两个地方邮箱都同步 # 同步到 Gitee - name: Sync to Gitee uses: Yikun/hub-mirror-action@master if: always() # 保证即使上面步骤偶尔抽风，这一步也尝试执行 with: # GitHub 用户名 src: 你的用户名 # Gitee 用户名 dst: 你的用户名 # 因格式问题重新配置SSH的私钥(对应 GITEE_RSA_PRIVATE_KEY) dst_key: ${{ secrets.GITEE_RSA_PRIVATE_KEY }} # Gitee 个人访问令牌 (对应 GITEE_TOKEN) dst_token: ${{ secrets.GITEE_TOKEN }} # 需要去 Gitee 生成一个私人令牌(Access Token) static_list: \u0026#34;xxx\u0026#34; # 这里填你要同步的仓库名，注意路径问题 force_update: true debug: true 总结 了解到 SSH 密钥还会有不同格式的区别，熟悉了 GitHub Actions 的配置，这也是第一次接触GitHub Actions，配置文件也是第一次接触，但是静下心来看还是能看的懂并尝试解决的🤗 遇到报错不要慌，仔细看 Log。培养能仔细查看报错日志是非常重要的，遇到的问题可能千奇百怪，如果能看懂报错日志就可以定位出现问题的原因了，这样以后遇到问题也不会害怕。毕竟​**==授人以鱼不如授人以渔~==** 第一次接触自动化部署，虽然配置时麻烦，但配好并且解决问题后的感觉真的很爽，满满自豪感！！！！🥳 ‍\n","permalink":"https://Jul1en-Lin.github.io/p/github%E7%A0%81%E4%BA%91%E5%90%8C%E6%AD%A5%E8%84%9A%E6%9C%AC/","title":"🪐 Github码云同步脚本"},{"content":" 事务 MySQL是一个客户端-服务器结构的程序\n事务的四大原则ACID A——（Atomicity）原子性：\n把多个SQL打包成一个整体，不可分割，要么执行全部，要么不执行\nC——（Consistency）一致性：\n事务执行前与执行后，数据符合正常的逻辑的情况，如小明给小红转账100，事务执行后小明兜里少100块，小红兜里多100块\n==💫I——（Isolation）隔离性：==\n一个数据库中可以执行多个事务，并且要求使多个事务之间互相影响尽量小，故有隔离性概念\nD——（Duration）持久性：\n事务的执行的结果会保存在硬盘上\n它与多线程中的锁1密切相关，其背后的逻辑是相似的\n‍\n锁 锁的存在就是防止线程冲突的情况，一个线程得到了锁，其他的线程如果想要对同一个变量进行修改，必须要等 ==上一个得到锁的线程释放锁后==，其他的线程通过“公平竞争2”得到锁，才能往下执行，不然就只能等，进入阻塞状态\n事务 事务的背后跟锁是密切相关的\n锁的特点 互斥 / 独占\n有锁就会有线程的 ==锁竞争 / 锁冲突==\n其他语言中，上锁、解锁是由 lock（）与 unlock（）配合，但有个缺陷，可能写到最后就忘记解锁了，导致其他线程得不到锁无法执行下去，永远阻塞等待，也叫==#线程饿死#==\n在Java中为了避免这种情况发生，直接规定了synchronize（locker）{ } 需要加锁的逻辑写到括号内，逻辑执行到括号外自动解锁，进入synchronized语句中自动上锁\nlocker 的对象可以是任意的，只要是Object / Object子类的实例。但必须是多个线程对同一个对象进行加锁！！！！\n就像追妹子一样，多个老铁追同一个妹子才能产生锁🔒竞争的情况，要是一人各追一个，加锁就没有意义了~~毕竟线程竞争的本质是多个线程同时访问同一份共享资源产生争夺\n如果其他的线程也尝试加锁操作就会产生阻塞，从而避免线程执行指令的过程中“被插队”\n锁的粒度\n锁内执行的逻辑多，粒度就大；相反代码逻辑少，粒度小\n这里的代码逻辑少==不代表代码的行数==，而是==代码的执行逻辑==，注意区分🎗️\n锁的开销\n锁虽然好，但不是什么时候都需要加锁的，加锁解锁是需要消耗资源的，不能无脑加锁，加锁的代价使得程序的效率更低~~\n可重入锁\n形如这样的代码\nsynchronized(locker){ c.add()； } public synchronized void add(){ count++; } 线程对c进行加锁，但add方法中又有一层锁，对c进行了二次加锁，这会产生问题吗？？\n——Java已经帮我们解决了这个问题\n理论上执行到第二个加锁的地方是会堵塞住无法继续往下执行的，但Java的synchronized做了特殊处理，对可重入锁规定了——第一次加锁成功后，synchronized内部会判定第二次加锁：如果是同一个线程加锁，则会跳过；如果是不同的线程加锁，则加锁生效，阻塞效果~~\n死锁\n一个图了解死锁\n两者都在等待对方释放锁，无限等待下去，形成了==循环依赖==的局面，造成了死锁 还有西方的哲学餐问题（n个线程m把锁） 平时写代码的时候很容易就写成死锁的情况，要千万注意注意再注意！！！！\n死锁很重要\n如何避免写出死锁的代码？我觉得首先要懂得形成死锁的原因\n形成死锁的原因 只要打破其中一个就能避免死锁~~\n锁是互斥的（锁的特性） 锁不可被强行抢占 以上两点是synchronized的特性，改变不了的。所以我们针对下面两个情况着重讨论\n循环依赖请求\n如上我举的例子。\n请求和保持\n如线程A获得了锁，但在锁内部中又请求了第二把锁，属于吃着碗里的 想着锅里的\n与上一个循环依赖请求有密切关系，如\nThread t1 = new Thread(() -\u0026gt; { synchronized (locker) { System.out.println(\u0026#34;T1线程获得了锁\u0026#34;); synchronized (locker2) { System.out.println(\u0026#34;t1线程获得了第二把锁\u0026#34;); } } }); t1.start(); 单独运行这一个线程是没有问题的，但如果我们加多一个，同样是吃着碗里的想着锅里的线程呢？ Thread t1 = new Thread(() -\u0026gt; { synchronized (locker) { System.out.println(\u0026#34;T1线程获得了锁\u0026#34;); synchronized (locker2) { System.out.println(\u0026#34;t1线程获得了第二把锁\u0026#34;); } } }); Thread t2 = new Thread(() -\u0026gt; { synchronized (locker2) { System.out.println(\u0026#34;T2线程获得了锁\u0026#34;); synchronized (locker) { System.out.println(\u0026#34;t2线程获得了第二把锁\u0026#34;); } } }); t1.start(); t2.start(); 最后就导致死锁 如何避免死锁 打破等待循环——写代码的时候避免锁的嵌套 打破请求与保持——约定加锁的顺序，给锁进行编号，从小到大依次加锁 \u0026#160;\u0026#x21a9;\u0026#xfe0e; 公平竞争 操作系统对于线程的调度是复杂的，随机的，我们感知不到，也无法干预\n操作系统的开发大佬们在多线程对锁的竞争方面制定了规则\n因此引入了公平竞争与不公平竞争\n公平竞争vs不公平竞争 公平竞争 他们规定了 多个线程处于阻塞状态等待锁的期间，采取==先来后到==的方式，即最先请求锁的线程会得到锁 不公平竞争 则是当一个线程释放锁后，其他的线程 ==各凭本事== 争取到锁 当然，系统的调度是一个庞大复杂的系统，不同的进程会有不同的调度方式，这种锁竞争方式是灵活多变的，我们只需要知道有这个东西就可以了\n‍\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://Jul1en-Lin.github.io/p/%E4%BA%8B%E5%8A%A1/","title":"事务"},{"content":" 前言 数据结构是一种基于多维数据的多种处理方式，对合适的数据运用合适的数据结构\n集合框架 又称为容器，是在java.util包里的一些Interface和实现类Class，有强大的功能，且平时用到的Scanner、Arrays、Iterator都在Utils包下\n工作中最高频使用的数据结构\nArrayList / 数组 HashMap Queue 其他的Stack、Linkedlist，TreeSet等等很少用到\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E5%89%8D%E8%A8%80/","title":"前言"},{"content":" Map \u0026amp; Set #搜索树# 概念 二叉搜索树/二叉排序树，满足以下性质\n若左树不为空，则左树上所有节点的值都小于父节点的值 若右树不为空，则右树上所有节点的值都大于父节点的值 它的左右子树也满足以上性质 二叉搜索树的中序遍历是有序的：{0，1，2，3，4，5，6，7，8，9}\n搜索树操作 插入Insert 搜索树的插入也要满足本身性质，若插入元素在原本的搜索树中已经拥有，则插入失败\n思路：定义Cur走到null位置，同时Parent做Cur的根节点，记录Cur之前走过的位置，避免cur定位丢失\npublic boolean insert(int key) { if (root == null) { root = new TreeNode(key); return true; } TreeNode cur = root; TreeNode parent = null; while (cur != null) { if (cur.val \u0026lt; key) { parent = cur; cur = cur.right; }else if (cur.val \u0026gt; key){ parent = cur; cur = cur.left; }else { return false; } } TreeNode newNode = new TreeNode(key); if (parent.val \u0026gt; key) { parent.left = newNode; }else { parent.right = newNode; } return true; } 查找Remove 定义Cur遍历搜索树，在对比左右子树.val 与Key的大小关系时，注意Cur的遍历方向\npublic TreeNode searchTree (int key) { if (root == null) { return null; } TreeNode cur = root; while (cur != null) { if (cur.val \u0026lt; key) { cur = cur.right; }else if (cur.val \u0026gt; key){ cur = cur.left; }else { return cur; } } return null; } 删除 删除节点时，要注意不要破坏二叉搜索树的性质\n思路：定义Cur并定位到待删除节点，待删除节点的父节点为Parent，找到Cur后分三种情况\ncur.left == null 三种情况\ncur.right == null 三种情况\ncur两边都不为空的情况\n此时需要替换法进行删除，找到右子树中的最小节点（即最左节点），用它的值来填补到被删除节点中，覆盖Cur节点，再处理节点的指向问题\nQ\u0026amp;A\n为什么不直取一层左子节点？—— 我验证过了用while确实没问题，用if仅检查一层，是无法找到真正的最小节点 主框架：删除的准备工作\npublic void remove(int key) { if (root == null) { System.out.println(\u0026#34;树为空\u0026#34;); return; } TreeNode parent = null; TreeNode cur = root; while (cur != null) { if (cur.val \u0026lt; key) { parent = cur; cur = cur.right; }else if (cur.val \u0026gt; key){ parent = cur; cur = cur.left; }else { removeNode(parent,cur); return; } } } 删除的实际操作在removeNode方法中\nprivate void removeNode(TreeNode parent, TreeNode cur) { if (cur.left == null) { if (cur == root) { root = root.right; }else if (cur == parent.left) { parent.left = cur.right; }else { parent.right = cur.right; } }else if (cur.right == null){ if (cur == root) { root = root.left; } else if (cur == parent.left) { parent.left = cur.left; }else { parent.right = cur.left; } }else { TreeNode targetParent = cur; TreeNode target = targetParent.right; //找到右树中最左边的节点 while (target.left != null) { targetParent = target; target = target.left; } cur.val = target.val; if (target == targetParent.left) { targetParent.left = target.right; }else { targetParent.right = target.right; } } } 搜索 Map和Set是专门搜索的数据结构，但他们的搜索效率与其他们具体实例化的子类有关——两者都是接口\n以前的搜索方式有遍历搜索，二分查找，但都是适用于静态查找。当涉及到插删查改的操作时，我们需要效率更高的查找方式，即动态查找，此时引出查找工具——Map和Set——适合动态查找的集合容器\n模型 我们一般把要搜索的数据叫做关键字，而关键字中可能或带有对应的“值”，我们叫键值对Key-Value\n比如一个数组{1，2，3，4，4，4}，我们要寻找每一个数字出现的次数，此时寻找的数字是关键字，而它们出现的次数则称之为对应的“值” 如数字4出现了3次，则键值对是\u0026lt;4 , 3\u0026gt;或\u0026lt;3 , 4\u0026gt;\n纯Key模型 Key-Value模型 Set中只存储Key模型，而Map存储Key-Value模型\nMap Map是一个接口，存储的是\u0026lt;Key , Value\u0026gt;键值对，并且k是唯一的\n实例化对象需要实现类#TreeMap#或#HashMap#，还有个#LinkedHashMap#，基于HashMap维护了一个双向链表记录来记录元素的插入次序\nTreeMap和HashMap的区别 TreeMap的Key不能传null 常用方法 V put——存放键值\n🧭 步骤：\n调用 key 的 hashCode() 方法 扰动函数处理（JDK 1.8 引入，提升 hash 分布质量） static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 小技巧：h ^ (h \u0026gt;\u0026gt;\u0026gt; 16) 这一步是为了让高位参与运算，因为哈希表大小是 2 的幂，只看低位的话容易冲突。\n根据数组长度计算 index index = (n - 1) \u0026amp; hash; ⚠️ 这里是一个位与操作，比取模 % 快得多（因为 n 是 2 的幂）\n判断桶是否为空：\n如果是空，直接插入\n如果不为空，遍历链表：\n如果已有相同 key（hash 相同 \u0026amp;\u0026amp; equals() 返回 true），则覆盖 否则添加到链表尾部（或树） 链表长度超过 8 时（阈值）：转为红黑树\n元素总数超过负载因子 × 容量时：触发扩容（resize）\nV get——返回key对应的value\nV getOrDefault——返回key对应的value，如果key不存在，==则建立新的key并返回新默认的value==\nV remove——移除的key\nSet\u0026lt;\u0026gt; keySet（需要Set\u0026lt;\u0026gt;来接收）——返回所有不重复的key值的集合\nCollection\u0026lt;\u0026gt; values（需要Collection\u0026lt;\u0026gt;来接收）——返回所有不重复的value的集合\n✨Set\u0026lt;Map.entry\u0026lt;K , V\u0026raquo; entrySet（需要Set\u0026lt;Map.entry\u0026lt;K , V\u0026raquo;接收）——返回所有的Key-Value的映射关系的集合\n关于Map.entry\u0026lt;K , V\u0026gt;\nentry可以看成是一个节点，相当于二叉树的Node节点，将键值打包成entry节点放入Set里面，同时它自身也实现了getValue和getKey方法\nSet\u0026lt;Map.Entry\u0026lt;Object, Integer\u0026gt;\u0026gt; set = map.entrySet(); for (Map.Entry\u0026lt;Object, Integer\u0026gt; entry: set) { System.out.println(\u0026quot;key: \u0026quot;+entry.getKey()+\u0026quot; -\u0026gt; val: \u0026quot;+entry.getValue()); }\n若直接打印set则是各个Key-Value的集合\nboolean containsKey\nboolean containsValue\n总结 Map是一个接口，不能直接实例化对象，如果要实例化对象需要实现类TreeMap或HashMap\nMap中的Key / Value都可以被全部分离出来存到Set / Collection当中，Key是唯一的，Value可以重复\nMap中的Key不能被修改，Value可以被修改，如果要修改Key只能先删除再建立新的Key\nTreeMap的Key不能传null，HashMap可以——TreeMap的remove就不能传null，HashMap可以\n✨Map中传的Key一定要可以比较——搜索树本质上来说每插入一个元素都要进行大小比较\n解决办法：\n给自定义类实现Comparable接口——（TreeMap） 构造TreeMap时传比较器，注意HashMap不能传比较器——（TreeMap） 重写equals和hashCode方法——（HashMap） Set Set也是一个接口，他没有具体的对象，继承自Collection接口类，只能存储Key模型，而且Key也是唯一的，可以把它看成一个塑料袋（集合），里面装着杂乱无序的不重复的数据。\n实现Set接口要实现类#TreeSet#和#HashSet#，还有个#LinkedHashSet#，是在HashSet的基础上维护了一个双向链表来记录元素的插入次序\nTreeSet和HashSet区别 TreeSet插入的Key不能是null——会空指针异常，但HashSet可以 常见方法 boolean add——添加数据，但重复元素不会被添加成功\nboolean remove——删除集合中的所选数据\nvoid clear——清空集合\nboolean contains\nIterator iterator——返回迭代器\n注意Map没有本身继承迭代器接口，如果需要迭代器遍历，需要强转为Set/List/Queue接口或者通过 keySet()、entrySet()、values() 来获取集合视图并迭代\n总结 Set只能存储K，且K也是和Map一样唯一的\n💫TreeSet的底层是用Map实现的，其使用的Key与Object的一个默认的对象（Value）组成键值插入到Map中\nTreeSet插入的Key不能是null，但HashSet可以——故TreeSet的remove不能传null，HashSet可以传null\nSet最大的功能是对Key去重——TreeSet是天然去重的\nSet中的Key也是像Map一样不能修改的，如需修改要删除\n✨Set中的Key也是一定要可比较——原理同Map\n解决方法：\n给自定义类实现Comparable接口——（TreeSet） 构造TreeSet传入比较器，同理HashSet不能传比较器——（TreeSet） 重写equals和hashCode方法——（HashSet) Map和Set的关系 TreeSet的底层是用Map实现的，其使用的Key与Object的一个默认的对象（Value）组成键值插入到Map中 TreeMap和TreeSet的复杂度都是log2N，它们的 K 都是要能比较大小的。 HashMap和HashSet的复杂度都是O（1）， 两者都是集合类型，且底层通常基于哈希表或红黑树来实现，两者的数据结构存在差异：Set主要关注元素的唯一性，但Map更关注键值对的关系 ‍\n前面提到了HashMap，HashSet，LinkedHashMap，LinkedHashSet，那Hash到底是什么呢？为什么他们的效率这么高？这就引入哈希表的概念\n哈希表/散列表 概念 理想的搜索方法：不经过任何的比较，一次直接从表中得到想要的元素。如果构造一种存储结构，能够通过某种函数使得元素与它的关键字/码之间建立某种联系，那么在查找的时候就很容易得到这些元素\n该方式就叫哈希方法，哈希方法中使用的函数就叫做==哈希函数/散列函数==，构造出来的结构就为==哈希表/散列表==\n‍\n每一种数据结构它的哈希函数都不一致，但是哈希表本质上是一个数组，不过数组不是放的单一数据，而是存放的键值对，所以哈希表是数组的一种拓展，这是哈希表的表现形式\n我们将学生信息包括名字和岁数（Key—Value），我们先通过哈希函数让Key进行计算，得出Index，确定键值对entry存放的位置，但是有一个问题，如果数据多了，别的key通过哈希函数可能也会得到相同的下标Index，像这样具有不同关键码但具有相同哈希地址的数据元素称为“同义词”，这个问题我们称之==哈希冲突，==哈希是一种用来进行高效查找的==数据结构==\n哈希冲突 那么有哈希冲突就会有解决哈希冲突问题的办法\n‍\n哈希冲突是必然的，我们做的应该是降低冲突率，引起冲突的一个原因可能是哈希函数设计不合理，但是我们一般都不会涉及到设计哈希函数，但常用的哈希函数我们需要了解\n哈希函数设计的越精妙，产生哈希冲突的可能性就越低，但是无法避免哈希冲突\n那反应哈希冲突率的一个数据叫负载因子\n负载因子 哈希列表的负载因子定义为 α = 填入表中的元素 / 哈希表的长度\n避免哈希冲突 两个方法 : 优化哈希函数、降低负载因子✨\n💯降低负载因子即需要对数组进行扩容，扩容之后需要对原来的数组的元素进行重新哈希分配——即重新哈希\npublic void resize() { Node[] newArray = new Node[array.length * 2]; for(int i = 0; i \u0026lt; array.length; i++) { Node cur = array[i]; while(cur != null) { int newIndex = cur.key%(newArray.length); Node curN = cur.next; cur.next = newArray[newindex]; newArray[newindex] = cur; cur = curN; } } array = newArray; } 解决哈希冲突 两种常见方法：#闭散列#和#开散列#\n注意要区分清楚：哈希冲突的处理方法和哈希函数 哈希函数作用是：建立元素与其存储位置之前的对应关系的，在存储元素时，先通过哈希函数计算元素在哈希表格中的存储位置，然后存储元素。好的哈希函数可以减少冲突的概率，但是不能够绝对避免，万一发生哈希冲突，得需要借助哈希冲突处理方法来解决。\n常见的哈希函数有：直接定址法、除留余数法、平方取中法、随机数法、数字分析法、叠加法等\n常见哈希冲突处理：闭散列（线性探测、二次探测）、开散列(链地址法)、多次散列\n闭散列 也叫#开放地址法#，当发生哈希冲突时， 如果哈希表没有被装满，说明哈希表还有位置可以放，那么就可以把Key放到空的位置上\n线性探测：从发生冲突的位置开始找到空位置放 二次探测：优化了线性探测的问题（不重点解释，需要可再查找） 💫开散列（重点） 开散列，又叫#链地址法#（#拉链法#），首先对关键码集合用哈希函数计算哈希地址，具有相同地址的关键码归于同一子集合，每一个子集合称为一个桶，各个桶中的元素通过一个单链表链接起来，各链表的头结点存储在哈希表中。\n与闭散列不同，这种将链表的头地址存储在哈希表的方式，不会影响与自己哈希地址不同的元素的增删查改的效率\n放入的entry可以用头插也可以用尾插法，与链表的操作相似，由此可见，开散列的每个桶当中都放的哈希冲突的元素，\n但如果出现极端情况，即所有元素都产生了冲突，都放到了同一个桶之中，那哈希表的增删查改的效率就退化为O（N），此时我们可以把这个桶转化为红黑树 TreeMap or TreeSet，效率就为O（log2N）\n树化条件 ：链表的长度 \u0026gt;= 8 \u0026amp;\u0026amp; 数组长度 \u0026gt;= 64 如果红黑树删除后不满足条件或没必要再用红黑树时可以变回为哈希桶的结构\n开散列可以认为是把大集合中的搜索问题转化为了小集合中做搜索了\n💫与Java类集合的关系 HashMap和HashSet都是Java中利用哈希表来实现的Map和Set\nJava会在冲突链表长度大于一定阈值的时候，将链表树化为红黑树TreeSet or TreeMap\nJava是用哈希桶来解决哈希冲突的（链表）\n✨Java计算的哈希值实际上是调用类的hashCode方法，进行Key的对比时是调用equals方法。所以如果要调用自定义类作为HashMap或HashSet的值，必须覆写hashCode和equals方法，而且做到equals相等的对象，其hashCode一定是相等的，反之则不一定——hashCode相等equals未必相等（哈希冲突）\n自定义类重写hashCode（）和equals（）接口\n#平均查找长度# len = 总的比较次数 / 元素个数\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/map--set/","title":"Map \u0026 Set"},{"content":" 数据结构——排序 排序概念 在数据结构中，有许多无序的数据，所谓排序，就是使一串无序的记录，按照其中某个或某些关键字的大小，来进行递增或者递减的排列起来的操作\n#稳定性# 在待排序的数据中，存在一些相同数据的，如果排序之后他们的相对位置保持不变，那么这个排序就是稳定的，相反则不稳定\n常见排序 直接插入排序 InsertSort 类似于扑克牌的排法，先有第一张”牌“，然后插入新的元素进来与前面的”牌“比较，最终使得整幅”牌“趋于有序\n直接插入排序特性总结\n元素集合越接近有序，直接插入排序的效率越高——可以嵌套快排中，优化排序效率\n时间复杂度 o（N2）——每张”牌“都需要进行对比\n空间复杂度 o（1）——基于原本的数组进行排序，没有开辟新的空间\n稳定性：稳定\n‍\n代码块\npublic void insertSort(int[] array) { for (int i = 1; i \u0026lt; array.length; i++) { int tmp = array[i]; int j = i-1; for (; j \u0026gt;= 0; j--) { //如果是 \u0026gt;= 那就不稳定了 if (array[j] \u0026gt; tmp) { array[j+1] = array[j]; }else { //array[j+1] = tmp; break; } } //j == -1 array[j+1] = tmp; } } 希尔排序ShellSort——优化直接插入排序 ✨ 先选定一个整数 Gap，把数据分成多个组，所有距离为 Gap 的记录分在同一个组内，对组内进行排序，然后让 Gap 趋于 1，此时再排序数据就是有序的\n希尔排序特性总结\n希尔排序是对直接插入排序的优化 Gap \u0026gt; 1 的时候都是预排序，是为了等 Gap = 1 的时候的排序效率更加高效（ Gap= 1 时是直接插入排序） 时间复杂度 o（N1.3~1.5） 空间复杂度 o（1）——没有开辟内存 稳定性：不稳定 代码块\npublic void shellSort(int[] array) { int gap = array.length; while (gap \u0026gt;= 1) { shell(array,gap); gap = gap/2; } } public void shell (int[] array,int gap) { for (int i = gap; i \u0026lt; array.length; i++) { int tmp = array[i]; int j = i - gap; for ( ;j \u0026gt;= 0; j -= gap) { if (array[j] \u0026gt; tmp) { array[j+gap] = array[j]; }else { break; } } array[j+gap] = tmp; } } 选择排序SelectSort 每次向后寻找最小（或者最大）的元素，找出下标 minIndex（maxIndex）与初始位置进行交换，直到全部待排序的数据元素排完\n选择排序特性总结\n选择排序的效率不高，通常很少使用 时间复杂度 o（N2） 空间复杂度 o（1） 稳定性：不稳定 代码块\npublic void selectSort(int[] array) { for (int i = 0; i \u0026lt; array.length; i++) { int minIndex = i; for (int j = i + 1; j \u0026lt; array.length; j++) { if (array[j] \u0026lt; array[minIndex]) { minIndex = j; } } swap(array,i,minIndex); } } 堆排序HeapSort 是指利用堆积树（堆）这种数据结构所设计的⼀种排序算法，它是选择排序的⼀种。它是通过堆来进行选择数据。需要注意的是排升序建大堆，排降序建小堆\n堆排序特性总结\n堆排序使用堆来选数，效率高 时间复杂度 o（N * log2N）——数据 N * 树的高度 log2N 空间复杂度 o（1） 稳定性：不稳定 代码块\npublic void heapSort(int[] array) { int end = array.length-1; //创建大根堆 for (int parent = (end-1)/2; parent \u0026gt;= 0; parent-- ) { siftDown(array,parent,array.length-1); } while (end \u0026gt; 0){ swap(array,0,end); siftDown(array,0,end); end--; } } public void siftDown(int[] array, int parent,int length) { int child = 2*parent + 1; while (child \u0026lt; length) { //判断是否有右树 if (child + 1 \u0026lt; length \u0026amp;\u0026amp; array[child] \u0026lt; array[child+1]) { child++; } if (array[child] \u0026gt; array[parent]) { swap(array,parent,child); parent = child; child = 2*parent + 1; }else { break; } } } 冒泡排序BubbleSort 冒泡排序特性总结\n非常容易理解 时间复杂度 o（N2） 空间复杂度 o（1） 稳定性：稳定 代码块\npublic void bubbleSort(int[] array) { //i表示趟数 for (int i = 0; i \u0026lt; array.length-1; i++) { boolean flg = false;//优化 for (int j = 0; j \u0026lt; array.length-1-i; j++) { if (array[j] \u0026gt; array[j+1]) { swap(array,j,j+1); flg = true; } } if (!flg){ break; } } } 快速排序QuickSort✨ 任取待排序元素作为基准值，按照该排序将排序集合分为两个子序列（一边是小于基准值，另一边是大于基准值），然后最左右子序列重复该过程，直至所有元素排列完成\npublic void quickSort(int[] array){ //接口统一性 ---重载 quickSort(array,0,array.length-1); } private static void quickSort(int[] array,int left,int right){ if (left \u0026gt;= right) { return; } int par = partition(array,left,right); quickSort(array,left,par-1); quickSort(array,par+1,right); } 以上代码为快排的主框架，发现与二叉树的前序遍历规则非常像，此地方写递归方法时可想到二叉树前序遍历规则即可快速写出来，后面只需要分析如何按照基准值对数据进行划分即可\n快速排序三种方法的效率对比 挖坑版 \u0026gt; Hoare 版 \u0026gt; 前后指针版\n‍\n将区间按照基准值划分为左右部分的方式有：\nHoare 版 private int partitionHoare(int[] array,int low,int high) { int i = low; int tmp = array[low]; while (low \u0026lt; high) { while (low \u0026lt; high \u0026amp;\u0026amp; array[high] \u0026gt;= tmp) { high--; } while (low \u0026lt; high \u0026amp;\u0026amp; array[low] \u0026lt;= tmp) { low++; } swap(array,low,high); } //left == high swap(array,low,i); return low; } 挖坑版 private int partition(int[] array,int low,int high) { int tmp = array[low]; while (low \u0026lt; high) { while (low \u0026lt; high \u0026amp;\u0026amp; array[high] \u0026gt;= tmp) { high--; } array[low] = array[high]; while (low \u0026lt; high \u0026amp;\u0026amp; array[low] \u0026lt;= tmp) { low++; } array[high] = array[low]; } array[low] = tmp; return low; } 前后指针法 private int partition1(int[] array,int low,int high) { int prv = low;// int cur = low+1;//找tmp小的 int tmp = array[low]; while (low \u0026lt; high) { if (array[cur] \u0026lt; tmp \u0026amp;\u0026amp; array[++prv] != array[cur]) { swap(array,prv,cur); } cur++; } swap(array,prv,low); return prv; } 快排的内部优化 为了使得最开始找的基准值更加合理，提高排序效率，利用三数取中\n1.三数取中 找数据的最左边的元素和最右边的元素，再找中间值，取出他们三者之间的平均值的下标\nprivate static int threeMid(int[] array,int low, int high) { int mid = (low + high) / 2; if (array[low] \u0026gt; array[high]) { if (array[mid] \u0026lt; array[high]) { return high; }else if (array[mid] \u0026gt; array[low]) { return low; }else { return mid; } }else { if (array[mid] \u0026lt; array[low]) { return low; }else if (array[mid] \u0026gt; array[high]) { return high; }else { return mid; } } } 2.嵌套直接插入排序 当数据集合有序的时候，快排还需要遍历只有右子树的情况，浪费了时间，此时嵌套直接插入排序 则可以直接减少遍历\n此时针对快排主框架的情况，直接插入排序要做出一点改变，总体框架不变\nprivate static void insertSortRange(int[] array,int low,int high) { for (int i = low + 1; i \u0026lt; high; i++) { int tmp = array[i]; int j = i-1; for (; j \u0026gt;= low; j--) { // 这里 \u0026gt;= 就不是稳定的排序了！！ if (array[j] \u0026gt; tmp) { array[j+1] = array[j]; }else { //array[j+1] = tmp; break; } } //j == -1 array[j+1] = tmp; } } 优化后的 QuickSort 的主框架\nprivate static void quickSort(int[] array,int left,int right){ if (left \u0026gt;= right) { return; } //优化 内置插入排序 趋于有序时减少检查的次数 if (right - left + 1 \u0026lt;= 8) { insertSortRange(array,left,right); return; } //优化 采用三数取中法 找下标 使得tmp更加合理 int index = threeMid(array,left,right); swap(array,index,left); int par = partition(array,left,right); quickSort(array,left,par-1); quickSort(array,par+1,right); } ‍\n快速排序的特性总结\n快排顾名思义，效率高，综合性能与应用场景都是比较好的\n时间复杂度：\n最好情况下 o（N * log2N） 最坏情况下o（N2） 空间复杂度 o：\n最好情况下 o（log2N）——满二叉树的情况 最坏情况下 o（N）—— 稳定性：不稳定\n非递归快排QuickSortNor 利用栈进行排序\n代码块\npublic static void quickSortNor(int[] array) { int start = 0; int end = array.length-1; int par = partition(array,start,end); Deque\u0026lt;Integer\u0026gt; stack = new LinkedList\u0026lt;\u0026gt;(); if (par \u0026gt; start+1) { stack.push(start); stack.push(par-1); } if (par \u0026lt; end - 1) { stack.push(par + 1); stack.push(end); } while (!stack.isEmpty()){ end = stack.poll(); start = stack.poll(); par = partition(array,start,end); if (par \u0026gt; start+1) { stack.push(start); stack.push(par-1); } if (par \u0026lt; end - 1) { stack.push(par + 1); stack.push(end); } } } 归并排序MergeSort 运用了”分而治之“的思想，采用”分治法“，即是将每个子序列变得有序，再使子序列段有序。若将两个有序表合并成一个有序表，叫二路归并。\n‍\n归并排序特性总结\n归并排序需要开辟新的空间内存来，所以更多考虑的是解决磁盘中的外排序问题 时间复杂度 o（N * log2N） 空间复杂度 o（N） 稳定性：稳定 代码块\npublic static void mergeSort(int[] array) { mergeSortChild(array,0,array.length-1); } public static void mergeSortChild(int[] array,int left,int right) { //判断结束 if (left \u0026gt;= right) { return; } int mid = (right + left) / 2; mergeSortChild(array,left,mid); mergeSortChild(array,mid + 1,right); //合并数组 merge(array,left,mid,right); } private static void merge(int[] array, int left, int mid, int right) { int s1 = left; int e1 = mid; int s2 = mid+1; int e2 = right; int[] tmp = new int[right - left + 1]; int k = 0; while (s1 \u0026lt;= e1 \u0026amp;\u0026amp; s2 \u0026lt;= e2) { if (array[s1] \u0026lt; array[s2]) { tmp[k++] = array[s1++]; }else { tmp[k++] = array[s2++]; } } while (s1 \u0026lt;= e1) { tmp[k++] = array[s1++]; } while (s2 \u0026lt;= e2){ tmp[k++] = array[s2++]; } //赋值给数组 for (int i = 0; i \u0026lt; tmp.length; i++) { //i+left 使得右树tmp数组的数值也能正确对应原数组的下标 array[i + left] = tmp[i]; } } 非递归归并排序MergeSortNor✨ 代码块\npublic void mergeSortNor(int[] array) { int gap = 1; while (gap \u0026lt; array.length) { for (int i = 0; i \u0026lt; array.length; i = i + 2*gap) { int left = i; int mid = i + gap - 1; if (mid \u0026gt;= array.length) { mid = array.length-1; } int right = mid + gap; if (right \u0026gt;= array.length) { right = array.length-1; } merge(array,left,mid,right); } gap *= 2; } } ‍\n‍\n","permalink":"https://Jul1en-Lin.github.io/p/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E6%8E%92%E5%BA%8F/","title":"数据结构——排序"}]