[{"content":"背景 为了防止重要数据丢失，必须进行备份，备份最保险的措施就是异地备份。即使本地组成最安全的磁盘阵列，依然可能被一锅端。文章将介绍如何通过 OpenList + 阿里云盘异地备份重要数据。\n环境 操作系统：debian12 安装 openlist 最简单最安全的方式是使用 docker 安装 openlist。docker-compose.yaml 文件内容如下：\nservices: openlist: image: \u0026#39;openlistteam/openlist:latest\u0026#39; container_name: openlist user: \u0026#39;1001:1001\u0026#39; # Please replace `0:0` with the actual user ID and group ID you want to use to run OpenList. volumes: - \u0026#39;./data:/opt/openlist/data\u0026#39; ports: - \u0026#39;5244:5244\u0026#39; environment: - UMASK=022 - TZ=Asia/Shanghai - OPENLIST_ADMIN_PASSWORD=password restart: unless-stopped 执行如下命令启动 OpenList:\n# 启动之前先修改 data 目录的权限 chown -R 1001:1001 ./data docker compose up -d 在 OpenList 中挂载阿里云盘 根据官方文档进行操作即可。假设\n设置 OpenList 的 WabDav 权限 根据官方文档 操作即可。\n上传文件到 OpenList 假设服务器的地址是： docker.local.com:5244，阿里云盘映射到 OpenList 的名称是：aliyun-drive。有两种方式上传文件：\ncurl rclone curl 上传文件 使用 curl 上传：\ncurl -T size_60MB.txt -u \u0026lt;username\u0026gt;:\u0026lt;password\u0026gt; http://docker.local.com:5244/dav/aliyun-drive/ 使用 curl 命令测试的时候，URL 最后面的 / 不能够少，表示上传文件到 aliyun-drive 文件夹。如果不加最后的 / ，表示上传文件到 openlist 的根目录，并将 size_60MB.txt 重命名为 aliyun-drive。\nrclone 上传文件 安装 rclone 命令：\napt install -y rclone 安装成功之后，可以通过 rclone config 交互式命令配置 rclone，也可以在 ~/.config/rclone/rclone.conf 文件中直接写入配置\n# openlist 是名称，随意命名 [openlist] type = webdav url = http://docker.local.com:5244/dav vendor = other user = admin pass = 2SkGONIcZ4HcPItSBDN8O26ThYY4AV_C 配置文件中的密码是加密后的密码，可以通过如下命令对密码进行加密：\nrclone obscure \u0026#39;mypassword\u0026#39; 将加密后的密码放入到 ~/.config/rclone/rclone.conf 文件的 pass 中\n使用 rclone 上传文件：\nrclone copy size_60MB.txt openlist:aliyun-drive --progress 对文件进行加密 加密方式有两种： 对称加密和非对称加密。\n对称加密速度快，适合加密大文件 非对称加密更安全，但是不适合加密大文件 备份文件的大小通常都比较大，应当使用对称加密。如果每次备份文件使用的对称加密的密钥都相同，某个文件被破解，其他使用相同密钥加密的文件也被破解了，因此每次加密文件使用的对称加密的密钥应当不同。\n每次加密的密钥都不相同，如何管理这些密钥呢？答案是：使用非对称加密的公钥对对称加密的密钥进行加密，并将加密后的密钥与备份文件放在一个压缩包中，这就是对称加密和非对称加密的混合加密方式。\n使用混和加密时，一定一定要保管好非对称加密的私钥，私钥丢了，那就一点办法都没有了，以目前计算机的算力是无法破解的。私钥可以保存到自己的计算机或者是 U 盘上。\n混和加密的步骤如下：\n1. 生成 RSA 密钥对 生成私钥\nopenssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 genpkey: 表示生成私钥 -algorithm RSA: 表示使用 RSA 非对称加密算法 -out private.pem: 输出的私钥保存到 private.pem 文件中 -pkeyopt rsa_keygen_bits:2048: 表示生成的 RSA 密钥的大小为 2048 位。密钥长度越长，安全性越高，但生成和加解密运算时消耗的资源也越多。2048 位是目前推荐的最低安全密钥长度。 导出公钥\nopenssl rsa -in private.pem -pubout -out public.pem rsa: 执行 rsa 非对长加密算法的相关操作 -pubout: 执行输出公钥的操作 -out public.pem: 输出的公钥保存到 public.pem 文件中 -in private.pem: 从 private.pem 文件中提取公钥 2. 生成随机 AES 密钥 openssl rand -out aes.key 32 rand: 表示生成随机字节 -out aes.key: 表示将生成的随机字节写入到 aes.key 文件中 32 表示 32 个字节，即 256 位，$2^{256}$，这是 AES 加密支持的最大长度 3. 使用 AES 密钥加密文件 openssl enc -aes-256-cbc -pbkdf2 \\ -in plain.txt \\ -out data.enc \\ -pass file:./aes.key enc: 表示执行加密操作 -aes-256-cbc: 指定加密算法：AES，使用 256 位密钥长度，工作模式为 CBC(Cipher Block Chaining，密码块链模式) -pbkdf2: 指定使用 PBKDF2（Password-Based Key Derivation Function 2）作为密钥派生函数。这使得基于密码的加密更安全，增加了抗暴力破解能力。默认情况下，openssl enc 以前不使用 PBKDF2，而是用较弱的 EVP_BytesToKey，现代实践推荐加上此选项。 -out data.enc: 表示密文写入到 data.enc 文件中 -in plain.txt: 表示待加密的文件 -pass file:./aes.key: 表示从 aes.key 文件中读取密码。pass 选项有三种传递密码的方式： 直接传递密码：pass:\u0026lt;password\u0026gt; 文件读取密码：file:/path/to/file 读取环境变量：evn:\u0026lt;Variable\u0026gt; 4. 使用公钥加密 AES 密钥 openssl pkeyutl -encrypt -pubin -inkey public.pem -in aes.key -out aes.key.encrypted pkeyutl: 表示执行非对称加解密相关操作 -encrypt：加密操作 -pubin：表示使用公钥加密 -inkey public.pem：公钥文件 -in aes.key：需要机密的数据。 -out aes.key.encrypted：将加密后的数据写入 aes.key.encrypted 文件中。 5.将加密后的文件和加密后的密钥一起打包 mkdir backup-2026-01-08 mv data.enc aes.key.encrypted backup-2026-01-08 tar -czvf backup-2026-01-08.tar.gz backup-2026-01-08 对文件进行解密 1. 解压文件 tar -xvf backup-2026-01-08.tar.gz 2. 使用私钥解密 AES 密钥 cd backup-2026-01-08 openssl pkeyutl -decrypt \\ -inkey private.pem \\ -in aes.key.encrypted \\ -out aes.key pkeyutl: 非对称加解密相关工具 -decrypt: 执行解密操作 -inkey private.pem: 密钥文件路径 -in aes.key.encrypted: 加密文件路径 -out aes.key: 解密后的数据写入到 aes.key 文件中 3. 使用 AES 密钥解密文件 openssl enc -d -aes-256-cbc -pbkdf2 \\ -in data.enc \\ -out plain.txt \\ -pass file:./aes.key enc -d: 执行解密操作 -aes-256-cbc: 指定加密算法：AES，使用 256 位密钥长度，工作模式为 CBC(Cipher Block Chaining，密码块链模式) -pbkdf2: 指定使用 PBKDF2（Password-Based Key Derivation Function 2）作为密钥派生函数。这使得基于密码的加密更安全，增加了抗暴力破解能力。默认情况下，openssl enc 以前不使用 PBKDF2，而是用较弱的 EVP_BytesToKey，现代实践推荐加上此选项。 -in data.enc: 加密文件 -out plain.txt: 解密后的内容输出到 plain.txt 文件中 -pass file:./aes.key: 从 aes.key 文件中读取密钥。pass 选项有三种传递密码的方式： 直接传递密码：pass:\u0026lt;password\u0026gt; 文件读取密码：file:/path/to/file 读取环境变量：evn:\u0026lt;Variable\u0026gt; 4. 检查文件是否成功解密 cat plain.txt 备份文件 工具都准备好后，就可以备份重要文件了，备份步骤：\n打包重要文件 对重要文件进行加密 上传加密后的文件到阿里云盘 删除阿里云盘中过期的备份文件 假设我需要备份服务器上的 ~/docker/minio 文件夹\n# -C 参数表示先进入 ~/docker 文件夹，再进行打包 # 这样解压 minio.tar.gz 文件时，解药出来的就是 minio 而不是 docker/minio tar -czvf minio.tar.gz -C ~/docker minio # 生成 AES 密钥 openssl rand -out aes.key 32 # 使用 AES 密钥对 minio.tar.gz 文件加密 openssl enc -aes-256-cbc -pbkdf2 \\ -in minio.tar.gz \\ -out minio.tar.gz.enc \\ -pass file:./aes.key # 从私钥中导出公钥 openssl rsa -in private.pem -pubout -out public.pem # 使用公钥对 AES 密钥加密 openssl pkeyutl -encrypt -pubin -inkey public.pem -in aes.key -out aes.key.enc # 将备份文件以及密钥放入到一个压缩包中 mkdir backup-2026-01-08 mv minio.tar.gz.enc aes.key.enc backup-2026-01-08 tar -czvf backup-2026-01-08.tar.gz backup-2026-01-08 # 将备份文件通过 openlist 上传到阿里云盘 rclone copy backup-2026-01-08.tar.gz openlist:aliyun-drive --progress # 删除阿里云盘中七天以前的备份 rclone delete \u0026#34;openlist:aliyun-drive/\u0026#34; --min-age 7d --include \u0026#34;backup-*.tar.gz\u0026#34; -v # 清理本地不必要的文件 rm -rf aes.key backup-2026-01-08 backup-2026-01-08.tar.gz minio.tar.gz public.pem 恢复备份文件 # 解压备份文件 tar -xvf backup-2026-01-08.tar.gz # 使用私钥解密加密的 AES 密钥 openssl pkeyutl -decrypt \\ -inkey private.pem \\ -in backup-2026-01-08/aes.key.enc \\ -out aes.key # 使用 AES 密钥解密备份文件 openssl enc -d -aes-256-cbc -pbkdf2 \\ -in backup-2026-01-08/minio.tar.gz.enc \\ -out minio.tar.gz \\ -pass file:./aes.key # 解压备份文件 tar -xvf minio.tar.gz ","date":"2026-01-07T17:13:05+08:00","permalink":"https://www.autmaple.com/post/back-up-server-data-alibaba-cloud-drive/","title":"备份服务器数据到阿里云盘"},{"content":"背景 当服务器暴露在公网环境时，22 端口又处于开放状态，会有大量的机器扫描服务器的 22 端口，试图使用密码登录服务器，获取服务器的使用权。如果 SSH 配置不当，不采取封禁措施，服务器很容易被暴力破解。接下来我将介绍如何合理配置 SSH 以及 fail2ban 工具，防止 ssh 被暴力破解。\n环境 操作系统： debian12 配置 SSH 为了防止黑客使用密码暴力破解 SSH，我们需要进行如下的配置：\nPasswordAuthentication no # 禁止使用密码登录 PermitRootLogin prohibit-password # 允许 root 用户登录，但是禁止使用密码登录 root 用户 PubkeyAuthentication yes # 允许使用公钥进行登录 UsePAM no # 禁止 SSH 登录流程进入 Linux 的“统一认证/会话管理体系”，如果要允许密码登录，则需要设置成 yes AllowUsers root # 只允许 root 用户登录，其他用户不允许登录，多个用户之间使用空格隔开。还可以配置只允许指定 IP/网段的某个用户登录 配置公钥 将允许连接服务器的客户端的公钥放到服务器的 ~/.ssh/authorized_keys 文件中。有两种操作方式：\n手动复制 自动复制 如果公钥不存在，可以使用如下命令先生成公钥\nssh-keygen -t ed25519 -C \u0026#34;your_email@example.com\u0026#34; 手动复制 输出客户端公钥 cat ~/.ssh/id_ed25519.pub 将公钥内容放到服务器的 ``~/.ssh/authorized_keys` 文件中\n自动复制 执行如下的命令自动复制公钥\nssh-copy-id username@remote_host username 替换为远程服务器用户名 remote_host 替换为服务器 IP 地址或者域名 重启 SSH systemctl restart sshd 重启成功后，一定要另开一个终端，看下配置是否生效。如果配置不当，导致无法登录，原先成功建立的 SSH 连接还可以兜底。\n配置 fail2ban 安装 使用如下命令安装 fail2ban\napt install -y fail2ban 配置 [DEFAULT] bantime = 1d # 封禁时间 1 天 findtime = 10m # 查找范围 10 分钟内 maxretry = 3 # 最大尝试次数，配合 findtime 参数使用，表示：10 分钟内失败 3 次，封禁 1 天 backend = systemd # backend 参数决定 fail2ban 从哪里读取日志。如果 ssh 是由 systemd 管理的，直接写 systemd 即可 [sshd] enabled = true 启动并验证 systemctl enable --now fail2ban fail2ban-client status sshd # 查看哪些 ip 被封禁 ","date":"2026-01-04T10:56:42+08:00","permalink":"https://www.autmaple.com/post/using-fail2ban-to-prevent-brute-force-attack-on-ssh/","title":"使用 fail2ban 防止 ssh 暴力破解"},{"content":"日志序列号(LSN)和Checkpoint redo log 的作用是记录对数据库的操作，防止数据库崩溃时，导致的数据丢失。如果对数据行的修改已经被后台线程持久化到磁盘中的数据页中，那 redo log 中记录的对数据库的操作就是多余的。如果不维护 redo log 中冗余的操作，redo log 的大小会随着时间的推移无限增加。而数据库在重启时，会重放 redo log 中记录的操作，由于 redo log 太大，重放 redo log 中的操作时，需要很长的时间。为了解决这个问题，InnoDB 引入 LSN(Log Sequence Number)，Checkpoint。\n在 InnoDB 中，redo log 中的每条日志都带有 LSN(Log Sequence Number)。Checkpoint 本质上是一个 LSN，由 InnoDB 进行维护，后台线程每次把脏页进行刷盘，都会对应更新 Checkpoint LSN。redo log 中的日志被 Checkpoint LSN 分成了两个部分:\nLSN \u0026lt; Checkpoint LSN 的日志: 表示它们对数据库的操作已经被后台线程持久化到磁盘中的数据页中，因此这部分日志是冗余的，可以被删除了。 LSN \u0026gt; Checkpoint LSN 的日志: 表示它们对数据库的操作还未被后台线程持久化到磁盘中的数据页中，不可以被删除。 redo log 使用情况 redo log 日志是一个大小固定循环使用的文件。InnoDB 根据如下的几个变量来跟踪 redo log 的使用情况\ncurrent_lsn: 全局 LSN，最大未分配的 LSN，只增不减，用于分配给最新的 redo log 日志。它根据每条 redo log 日志的大小来进行递增，例如某条 redo log 日志的大小是 500 字节，那么最新的 current_lsn = current_lsn + 500 checkpoint_lsn: 最新一次 checkpoint 对应的 LSN checkpoint_age: checkpoint_age = current_lsn - checkpoint_lsn InnoDB 通过 checkpoint_age 以及 MySQL 参数 innodb_redo_log_capacity 来判断 redo log 的使用情况，redo log 可用空间: innodb_redo_log_capacity - checkpont_age\ncheckpoint_age 越接近 innodb_redo_log_capacity 参数，表示 redo log 可用空间越少 checkpoint_age 离 innodb_redo_log_capacity 参数越远，表示 redo log 可用空间越多 当 redo log 可用空间不够时，需要强制进行刷盘操作，释放磁盘空间。\n脏页刷盘流程 后台线程会根据:\nredo log 的空间压力 buffer pool 中脏页的比例 刷盘策略(如 adaptive flushing) 等 \u0026hellip; 来决定什么时候刷，刷多少页。刷盘流程如下:\n选择一个 LSN 作为 Checkpoint LSN 将 Buffer Pool 所有脏页中 LSN 小于 Checkpoint LSN 的脏页进行刷盘 成功刷盘之后，在 redo log 中记录新的 Checkpoint 值。 脏页如何维护 LSN InnoDB 会为每一条 redo log 分配一个 LSN，对 Buffer Pool 中的数据页进行修改时，除了会将数据页标记为脏页，还会在脏页中记录 LSN，脏页中会记录两个 LSN:\n第一次修改数据页的 LSN 最新一次修改数据页的 LSN 后台线程是如何选择脏页的? 先说几个关键的数据结构，都在 Buffer Pool 中:\nLRU 链表: 按最近使用程度排数据页，主要用于淘汰冷门数据页。 flush_list 链表: 专门挂载脏页，按第一次修改数据页的 LSN 的大小进行升序排序 free_list: 空闲页链表 InnoDB 中有专门的线程(page_cleaner/flusher thread) 负责按策略刷脏页。\n每个脏页都有一个 oldest_modification: 一个 LSN 值。flush_list 是按照 oldest_modification 升序排序的双向链表。每当发现 redo log 占用的空间太多(checkpoint age 接近容量阈值)时，后台线程就会:\n从 flush_list 头部挑选 oldest_modification 最小的那部分脏页 将挑选出来的脏页批量写磁盘 写成功后，将脏页从 flush_list 中清除，并重置 oldest_modification，从而变成干净页。 更新 checkpoint_lsn ","date":"2025-12-29T19:34:00+08:00","permalink":"https://www.autmaple.com/post/mysql-checkpoint-mechanism/","title":"MySQL的Checkpoint机制"},{"content":"如何让 AI 的回答更精准 要让 AI 的回答更精准，需要做到如下的几点:\n给 AI 具体且明确的需求，不让要 AI 觉得模棱两可。 将大问题拆分成小问题，一次只解决一个小问题，并提供足够的上下文 不要将所有的要求都放入一个提示词中 对于 AI 提示词而言，最好的格式是 xml，其次是 markdown。但是 xml 格式有些难写，用 markdown 格式也够用 针对不同的任务使用不同的模型 使用不同的模型来确认和验证模型的正确性 如何通过 AI 将一个想法变成一个产品 当我们有一个想法时，不要直接让 AI 帮我们写，应该先让 AI 帮我们生成详细的产品需求文档(PRD)，并让 AI 根据需求文档制定一个计划，并将需求拆分成 task 和 subtask\n生成需求文档 让 AI 将我们的想法转换成需求文档，提示词模板如下:\nYou\u0026#39;re a senior software engineer. We\u0026#39;re going to build the PRD of a project together. VERY IMPORTANT: - Ask one question at a time - Each question should be based on previous answers - Go deeper on every important detail required IDEA: \u0026lt;paste here your idea\u0026gt; 将提示词发送给 AI 后，AI 会询问我们相关的问题，详细的回答 AI。当你觉得需求都已经明确之后，通过如下的提示词让 AI 将其总结到 PRD 文件中:\nCompile those findings into a PRD. Use markdown format. It should contain the following sections: - Project overview - Core requirements - Core features - Core components - App/user flow - Techstack - Implementation plan AI 回答完毕之后，查看 AI 回复的需求文档，查漏补缺，完成之后将需求文档保存下来，比如保存到 docs/PRD.md 文件中\n根据 PRD 生成任务列表 有了 PRD 之后，让 AI 根据 PRD 生成任务列表，提示词如下:\nBased on the generated PRD, create a detailed step-by-step plan to build this project. Then break it down into small tasks that build on each other. Based on those tasks, break them into smaller subtasks. Make sure the steps are small enough to be implemented in a step but big enough to finish the project with success. Use best practices of software development and project management, no big complexity jumps. Wire tasks into others, creating a dependency list. There should be no orphan tasks. VERY IMPORTANT: - Use markdown - Each task and subtask should be a checklist item - Provide context enough per task so a developer should be able to implement it - Each task should have a number id - Each task should list dependent task ids 将 AI 的回复保存到 docs/todo.md 文件中\n让 AI 根据 PRD 和 TODO 完成任务 You\u0026#39;re a senior software engineer. Study @docs/specs.md and implement what\u0026#39;s still missing in @docs/todo.md. Implement each task each time and respect task and subtask dependencies. Once finished a task, check it in the list and move to the next. 如何选择大模型 如果目标是头脑风暴: 选择 thinking 模型 如果目标是编程，选择专为编程而生的模型。 参考链接 AI CODE GUIDE ","date":"2025-12-29T13:15:55+08:00","permalink":"https://www.autmaple.com/post/building-product-from-scratch-using-ai/","title":"使用AI从0-1构建产品"},{"content":"事务执行流程 START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; 以转账事务为例，看下 MySQL 各组件是如何协同工作的:\n客户端发送 START TRANSACTION 命令给 MySQL Server MySQL Server 收到后，通知 InnoDB 开启事务，并分配事务 ID， 客户端发送第一条 UPDATE 语句，MySQL Server 将 UPDATE 语句交给 InnoDB 执行 InnoDB 查看 UPDATE 语句所涉及的数据行是否在内存的 Buffer Pool 中，如果不在，则将数据行所在的数据页加载到 Buffer Pool 中。 修改数据之前，先对需要修改的数据行上锁。 写 undo log。将修改前的旧值写到 undo log 中 修改 Buffer Pool 中相关数据页的数据行，并将数据页标记为脏页 写 redo log。把对数据行的修改写入 redo log 中 客户端发送第二条 UPDATE 语句，重复 4 - 8 中的步骤 客户端发送 COMMIT; 指令。MySQL Server 收到后，与 InnoDB 开启两阶段提交 MySQL Server 通知 InnoDB 在 redo log 中将事务标记为 PREPARE 状态 InnoDB 成功写入 PREPARE 状态后，给 MySQL Server 回复 ACK MySQL Server 收到 PREPARE 的 ACK 后，会把本次事务对数据行的修改写入到 bin log 中 MySQL Server 成功写入 bin log 后，会通知 InnoDB 将事务标记为 COMMITED 状态 InnoDB 成功写入 COMMITED 状态后，给 MySQL Server 回复 ACK MySQL Server 收到 InnoDB 的 ACK 后，通知客户端事务提交成功。 后台线程定期或强制将 Buffer Pool 中部分脏页写入到磁盘的数据页中 具体的流程图如下:\nsequenceDiagram autonumber participant C as Client participant S as MySQL Server participant I as InnoDB participant BP as InnoDB Buffer Pool participant UL as Undo Log participant RL as Redo Log participant BL as Binlog participant CK as Checkpoint \u0026amp; Flusher C-\u0026gt;\u0026gt;S: START TRANSACTION S-\u0026gt;\u0026gt;I: trx_begin() C-\u0026gt;\u0026gt;S: UPDATE accounts ... S-\u0026gt;\u0026gt;I: exec_update() I-\u0026gt;\u0026gt;BP: 读/加载数据页 I-\u0026gt;\u0026gt;BP: 对相关数据行加锁 I-\u0026gt;\u0026gt;UL: 写 undo 记录 (旧版本) I-\u0026gt;\u0026gt;BP: 写数据页，标记为脏页 I-\u0026gt;\u0026gt;RL: 写 redo 日志到 log buffer C-\u0026gt;\u0026gt;S: COMMIT Note over S,I: 事务提交，两阶段提交开始 S-\u0026gt;\u0026gt;I: prepare() I-\u0026gt;\u0026gt;RL: 写 PREPARE 记录\u0026lt;br/\u0026gt;并刷 redo 到磁盘 (fsync) I--\u0026gt;\u0026gt;S: prepare OK S-\u0026gt;\u0026gt;BL: 写 binlog 事件 + XID BL--\u0026gt;\u0026gt;S: 刷 binlog 到磁盘 (fsync) S-\u0026gt;\u0026gt;I: commit() I-\u0026gt;\u0026gt;RL: 写 COMMIT 记录到 redo I-\u0026gt;\u0026gt;BP: 释放锁 / 标记事务已提交 I--\u0026gt;\u0026gt;S: commit OK S--\u0026gt;\u0026gt;C: COMMIT 成功返回 par 后台线程 loop CK-\u0026gt;\u0026gt;BP: 选择部分脏页 BP-\u0026gt;\u0026gt;I: 写回数据文件 I-\u0026gt;\u0026gt;CK: 更新 checkpoint LSN end end Bin log 和 Redo log 的两阶段提交 客户端通知 MySQL Server 提交事务 MySQL Server 通知 InnoDB 将本次事务的所有变更提交到 redo log，并将事务的 PREPARE 状态也写入 redo log，PREPARE 状态会带有 XID(事务 ID)。变更全部写入成功后 InnoDB 给 MySQL Server 发送 ACK MySQL Server 收到 InnoDB 的 ACK 后，将本次事务的所有变更以及 XID(事务 ID) 写入到 bin log 中。写入成功后，MySQL Server 给 InnoDB 发送 ACK InnoDB 收到 MySQL Server 的 ACK 后，将事务的 COMMITED 状态写入 redo log 中，COMMITED 状态会带有 XID(事务 ID)。成功写入 redo log 后，InnoDB 给 MySQL Server 发送 ACK MySQL Server 收到 InnoDB 的 ACK 后，给客户端发送事务成功提交的消息。 具体的流程如下:\nsequenceDiagram autonumber participant App as Client participant S as MySQL Server participant I as InnoDB participant RL as Redo Log participant BL as Binlog App-\u0026gt;\u0026gt;S: COMMIT Note over S,RL: 阶段 1：InnoDB prepare S-\u0026gt;\u0026gt;I: prepare() I-\u0026gt;\u0026gt;RL: 写本事务所有修改的 redo\u0026lt;br/\u0026gt;并写 PREPARE 记录 RL--\u0026gt;\u0026gt;I: fsync 持久化成功 I--\u0026gt;\u0026gt;S: prepare OK (事务进入 prepared 状态) Note over S,BL: 阶段 2 上半：写 binlog S-\u0026gt;\u0026gt;BL: 写 binlog event + XID BL--\u0026gt;\u0026gt;S: fsync binlog 成功 Note over S,RL: 阶段 2 下半：InnoDB commit S-\u0026gt;\u0026gt;I: commit() I-\u0026gt;\u0026gt;RL: 写 COMMIT 记录到 redo\u0026lt;br/\u0026gt;(通常不强制 fsync) I--\u0026gt;\u0026gt;S: commit OK S--\u0026gt;\u0026gt;App: COMMIT 成功返回 在两阶段提交中，以 bin log 中事务的状态为准。例如启动 MySQL 时， bin log 中事务 10101 的状态是 COMMITED，但是在 redo log 中，事务 10101 的状态还是 PREPARE。此时以 bin log 中的事务状态为准，需要在 InnoDB 中进行\u0026quot;补提交\u0026quot;，避免 bin log 中有，但是引擎层没有的情况。对于 bin log 中事务状态是非提交的情况，一律回滚事务。\n","date":"2025-12-29T12:52:45+08:00","permalink":"https://www.autmaple.com/post/steps-of-mysql-transction/","title":"Mysql事务执行过程"},{"content":"环境 操作系统: debian 12 数据库: mysql 8.4.7 名称 地址 主服务器 192.168.31.111 从服务器 192.168.31.114, 192.168.31.115 搭建步骤 修改主服务器配置 Mysql 配置文件是: /etc/mysql/my.cnf ，在 192.168.31.111 的 my.cnf 文件中加入如下配置\n[mysqld] server_id = 111 # 只能是 1 - (2^32 - 1) 之间的整数 log_bin=mysql-bin # 开启 binlog # binlog_format=ROW # binglog 格式， ROW 最安全可靠，默认值为 ROW。变量已经被废弃，官方未来打算只支持 ROW 格式 binlog_row_image=FULL # 决定 ROW 模式下，binlog 记录“多少列”。FULL 表示所有列 gtid_mode=ON # 开启全局事务 ID，方便从库自动找到位置进行同步 enforce_gtid_consistency=ON # 强制只允许“GTID 安全”的事务，防止生成无法复制的 GTID log_replica_updates=ON # 从库执行的复制事件，也写入自己的 binlog。这样从库可以再作为其他从库的主库 sync_binlog=1 # 每次事务提交，都 fsync binlog 到磁盘 innodb_flush_log_at_trx_commit=1 # 控制 InnoDB redo log 刷盘策略，每次提交事务，redo log 都要刷盘。 innodb_flush_method = O_DIRECT # 表示使用 Direct IO, 绕过操作系统的缓存，直接将数据写入磁盘 重启主服务器 systemctl restart mysql 在主库上创建用于数据同步的用户 CREATE USER \u0026#39;root\u0026#39;@\u0026#39;192.168.31.%\u0026#39; IDENTIFIED BY \u0026#39;111000\u0026#39;; GRANT REPLICATION SLAVE ON *.* TO \u0026#39;root\u0026#39;@\u0026#39;192.168.31.%\u0026#39;; -- 刷新权限 FLUSH PRIVILEGES; 导出主服务器 192.168.31.111 上的相关数据 mysqldump -u root -p \\ --databases better_rss \\ --single-transaction \\ --set-gtid-purged=OFF \\ --source-data=2 \\ \u0026gt; /root/better_rss_dump.sql 参数解释:\n--single-transaction：最关键的一步。它会开启一个事务来读数据，期间主库即使有新数据写入（增删改），备份出的数据也是备份开始那一刻的快照，且不会阻塞主库的读写。 --set-gtid-purged=OFF: 这个参数表示将主库所有数据库的 GTID 范围记录在导出的 SQL 文件头部，因此需要关闭。 --source-data=2：将主库当前的 Binlog 文件名和位置以注释形式记录在 SQL 文件中（作为双重保险）。 将 sql 文件拷贝到从服务器 scp /root/better_rss_dump.sql root@192.168.31.114:/root/ scp /root/better_rss_dump.sql root@192.168.31.115:/root/ 修改从服务器配置 Mysql 配置文件是: /etc/mysql/my.cnf ，在 192.168.31.114 和 192.168.31.115 的 my.cnf 文件中加入如下配置\n[mysqld] server_id=114 # 只能是 1 - (2^32 - 1) 之间的整数 gtid_mode=ON # 开启全局事务 ID，方便从库自动找到位置进行同步 enforce_gtid_consistency=ON # 强制只允许“GTID 安全”的事务，防止生成无法复制的 GTID log_bin=mysql-bin # 开启 binlog binlog_format= ROW # binglog 格式， ROW 最安全可靠 log_replica_updates=ON # # 从库执行的复制事件，也写入自己的 binlog。这样从库可以再作为其他从库的主库 read_only=ON # 限制普通用户只能执行读操作，无法执行写操作。高权限用户不受限制 super_read_only=ON # 限制只能读，不能写。高级权限用户也不能够随便写 注意修改一下 server_id，保证不重复即可:\n192.168.31.114: server_id = 114 192.168.31.115: server_id = 115 重启从数据库:\nsystemctl restart mysql 在从库中导入主库的数据 进入 192.168.31.114 的 Mysql:\nmysql -u root -p 执行如下命令:\n-- 关闭只读模式 SET GLOBAL super_read_only = 0; SET GLOBAL read_only = 0; -- 停止主从复制 STOP REPLICA; -- 清理之前的错误状态和旧的 GTID 信息 RESET REPLICA ALL; -- 重置 GTID 和 bin_log RESET BINARY LOGS AND GTIDS; -- 退出 mysql exit; 导入主库数据:\nmysql -u root -p \u0026lt; /root/better_rss_dump.sql 进入 Mysql\nmysql -u root -p 执行如下的命令:\n-- 开启只读模式 SET GLOBAL read_only = 1; SET GLOBAL super_read_only = 1; -- 设置同步主机 CHANGE REPLICATION SOURCE TO SOURCE_HOST=\u0026#39;192.168.31.111\u0026#39;, SOURCE_PORT=3306, SOURCE_USER=\u0026#39;root\u0026#39;, -- 第三步提前在主库中设置好的用户 SOURCE_PASSWORD=\u0026#39;111000\u0026#39;, -- 第三步提前在主库中设置好的密码 GET_SOURCE_PUBLIC_KEY = 1, -- 当从库使用 caching_sha2_password 认证、且连接没有启用 SSL 时，允许从主库“动态获取 RSA 公钥”，用来安全地传输密码。 SOURCE_AUTO_POSITION=1; -- 表示自动与主库数据保持一致 -- 开启主从复制 START REPLICA; -- 检查主从复制的状态 SHOW REPLICA STATUS\\G 查看主从复制是否成功的关键参数:\nReplica_IO_Running: Yes Replica_SQL_Running: Yes Auto_Position: 1 如果看到这三项，说明基于 GTID 的 MySQL 8.4 集群搭建成功\nQ \u0026amp; A ERROR 3546 (HY000) at line 24: @@GLOBAL.GTID_PURGED cannot be changed: the added gtid set must not overlap with @@GLOBAL.GTID_EXECUTED 解决步骤:\n进入 mysql\nmysql -u root -p 执行\nRESET BINARY LOGS AND GTIDS; Fatal error: The replica I/O thread stops because source and replica have equal MySQL server UUIDs; these UUIDs must be different for replication to work. 解决步骤:\nsystemctl stop mysql rm -rf /var/lib/mysql/auto.cnf systemctl start mysql mysql -u root -p show replica status\\G IP 被 Mysql 封锁了怎么办? 操作步骤如下:\n进入 Mysql\nmysql -u root -p 执行\nTRUNCATE TABLE performance_schema.host_cache; ","date":"2025-12-25T16:00:31+08:00","permalink":"https://www.autmaple.com/post/set-up-mysql-master-slave-replication/","title":"搭建 Mysql 主从复制"},{"content":"前言 在系统中引入消息中间件不可避免的需要处理如下的几个问题:\n如何保证消息不丢失? 如果解决消息重复消费的问题? 如何保证消息的顺序消费? 消息消费失败了怎么办?? 文章就上述几个问题，通过 Java + Kafka 来解释\nKafka 的三种消息投递语义 语义 含义 说明 实现方式 At most once 最多一次 有丢失消息的风险 生产者投递失败后不重试 At least once 至少一次 会导致消息重复 - 生产者投递失败后进行重试\n- 消费者手动提交 ACK Exactly once 只有一次 消息不丢失，消息不重复 Kafka 事务 kafka 处于哪种消息语义取决于生产者配置的 acks 参数，不同 acks 配置的含义:\nacks=0: 生产者发送消息后，完全不等待 Broker 的确认，立即发送下一条消息。吞吐量高，但消息可能在 Broker 接收到之前丢失或者 Broker 只写入了 Leader Partition，将消息写入 Follower Partition 的时候 Leader Partition 所在的 Broker 挂掉，导致消息丢失。 acks=1: 消息只要写入 Leader Partition 就立即发送 ACK。如果 Broker 在将消息写入 Follower Partition 之前挂掉了，会导致消息丢失。 acks=all 或者 acks=-1: 消息成功写入到 Leader Partition 以及所有的 Follower Partition 之后，Broker 才会给 Producer 发送 ACK。这种情况下不会导致消息的丢失。 根据上述 acks 参数的配置:\nacks=0 表示 At most once 语义 acks=1 或 acks=all 表示的是 At least once 语义 spring-kafka 包中，acks 的默认值是 acks=1。\n如何保证消息不丢失? 消息的流转过程: Producer -\u0026gt; Broker -\u0026gt; Consumer\n上述环节中的任意一个环节都有可能造成消息的丢失:\n在 Producer 和 Broker 之间，为了防止消息丢失，需要引入 ACK 确认机制 在 Broker 和 Consumer 之间，为了防止消息丢失，需要关闭自动提交 offet 的功能，改成手动提交 offset Broker 配置不当也可能导致消息的丢失，需要合理的配置: replication.factor 和 min.insync.replicas 参数。 replication.factor 参数表示每个 partition 有多少副本 min.insync.replicas 参数表示集群中每个 partition 至少需要多少副本与 Leader 副本保持同步，集群才可以正常运行。如果处于同步状态的副本数低于 min.insync.replicas 参数，将消息发送到 Broker 时，会被拒绝写入。 看下在 Spring Boot 项目中如何配置 application.yaml 文件:\nspring: kafka: consumer: enable-auto-commit: false # 关闭自动提交 Offset auto-offset-reset: earliest # 找不到 partition 的历史消费 offset 时，将 offset 重置到 partition 最早的位置开始消费 listener: ack-mode: manual_immediate # 消费完毕后立即提交 ack producer: acks: all # Leader 和 Follower Partition 都成功写入后 Broker 再发送 ack properties: enable.idempotence: true # 开启幂等性，确保每个消息只会被存储一次。通过 producer id + sequence id 保证 max.in.flight.requests.per.connection: 5 # 最多允许多少条消息等待 broker 的 ACK。设置成 1 表示消息顺序发送。 retries: 2147483647 # 重试次数 request.timeout.ms: 30000 # 发送请求后等待 Broker 响应的超时时间 delivery.timeout.ms: 120000 # 消息从准备到最终被 Broker 接收并返回 ACK 确认的超时时间，其中包括了 request.timeout.ms linger.ms: 5 # 每个 batch(批次) 收集消息的时间，单位 ms。设置成 0 表示有消息立即发送。大于 0 表示每隔 n 毫秒发送一次消息。 如何解决消息重复消费? 什么情况下会导致重复消费? Producer 将同一条消息多次发送给 Broker，导致 Broker 中存在多条相同的消息 Broker 将同一条消息多次发送给 Consumer 生产端和消费端都有可能导致消息重复消费，接下来看看如何解决\n生产端 生产端导致重复消费的问题，可以让 Producer 开启幂等模式。开启幂等模式后，Producer 发送的消息会携带 Producer Id(PID) 以及自增的序列号，Broker 会根据 PID 以及序列号判断消息在 Broker 中是否存在，如果存在直接返回 ACK，如果不存在，则将消息成功写入 Broker 后，再发送 ACK。\nspring: kafka: producer: acks: all # Leader 和 Follower Partition 都成功写入后 Broker 再发送 ack properties: enable.idempotence: true # 开启幂等性，确保每个消息只会被存储一次。通过 producer id + sequence id 保证 max.in.flight.requests.per.connection: 5 # 最多允许多少条消息等待 broker 的 ACK。设置成 1 表示消息顺序发送。 retries: 2147483647 # 重试次数 request.timeout.ms: 30000 # 发送请求后等待 Broker 响应的超时时间 delivery.timeout.ms: 120000 # 消息从准备到最终被 Broker 接收并返回 ACK 确认的超时时间，其中包括了 request.timeout.ms linger.ms: 5 # 每个 batch(批次) 收集消息的时间，单位 ms。设置成 0 表示有消息立即发送。大于 0 表示每隔 n 毫秒发送一次消息。 这种方式有个问题: 幂等只在 Producer 和 Broker 的同一次会话中有效。Producer ID 是每次建立会话时 Broker 随机分配的，Broker 不会验证消息的内容，只会根据 Producer ID + 序列号 判断消息是否重复。而 Producer 重启后，会被重新分配 PID，导致幂等只在 Producer 与 Broker 的同一次会话中有效，新建立的会话会重新开始计算。\n消费端 Broker 多次投递同一条消息给 Consumer，Consumer 在消费时，需要幂等的消费消息，才能保证系统状态的正确性。\n幂等消费常用的方案:\n数据库唯一键约束去重 Redis + TTL 去重 状态机幂等 数据库唯一键约束 设计思路:\n设计一张消费表，每个消息中携带一个全局唯一的 ID，\n消费时先 insert 记录\n如果插入成功，表示第一次消费，接着执行业务逻辑\n如果插入失败，表示已经消费过，直接返回 ACK 即可。\n适用场景: 消息会落库/业务的最终状态在数据库中\n@KafkaListener(topics = \u0026#34;order.created\u0026#34;, groupId = \u0026#34;order-group\u0026#34;) public void onMessage(ConsumerRecord\u0026lt;String, String\u0026gt; record, Acknowledgment ack) { String eventId = extractEventId(record); // 从 header/payload 提取 // 1) 先做“去重标记” boolean first = consumedRepo.tryMarkConsumed( \u0026#34;order-group\u0026#34;, record.topic(), record.partition(), record.offset(), eventId ); if (!first) { // 重复消息：直接 ack，推进 offset（否则会无限重复拉到） ack.acknowledge(); return; } try { // 2) 执行业务 orderService.handleCreated(record.value()); // 3) 业务成功后提交 offset ack.acknowledge(); } catch (Exception e) { // 业务失败：不 ack，让 Spring Kafka 的重试/DLT 接管 throw e; } } Redis + TTL 设计思路:\n使用 SETNX 指令，将消息唯一 ID 作为 Key, Key 对应的 value 随意，建议设置成数字，节省内存空间\n设置 TTL\n如果 SETNX 指令返回 true，说明是第一次消费，继续执行业务逻辑\n如果 SETNX 指令返回 false, 说明已经消费过了，直接返回 ACK\n适用场景:\n业务结果不一定落 DB 需要极高吞吐 能接受 TTL 窗口内去重（通常足够） @Service public class RedisIdempotencyService { private final StringRedisTemplate redis; public RedisIdempotencyService(StringRedisTemplate redis) { this.redis = redis; } /** @return true=首次消费，false=重复 */ public boolean tryAcquire(String key, Duration ttl) { Boolean ok = redis.opsForValue().setIfAbsent(key, \u0026#34;1\u0026#34;, ttl); return Boolean.TRUE.equals(ok); } /** 可选：失败时释放（谨慎使用） */ public void release(String key) { redis.delete(key); } } @KafkaListener(topics = \u0026#34;order.created\u0026#34;, groupId = \u0026#34;order-group\u0026#34;) public void onMessage(ConsumerRecord\u0026lt;String, String\u0026gt; record, Acknowledgment ack) { String eventId = extractEventId(record); String idemKey = \u0026#34;kafka:idem:order-group:\u0026#34; + eventId; boolean first = idem.tryAcquire(idemKey, Duration.ofDays(3)); if (!first) { ack.acknowledge(); return; } try { orderService.handleCreated(record.value()); ack.acknowledge(); } catch (Exception e) { // 这里是否 release？——通常不建议直接 release // 因为如果业务已部分成功但你没感知到，release 会导致重复执行更危险 throw e; } } 注意:\n不要轻易的释放 redis 中用于判断消息是否被处理过的 key，否则可能会出现重复消费问题 key 的 TTL 一定要合理设置。 状态机幂等 设计思路: 状态之间的转换关系是确定的，例如: CREATED -\u0026gt; PAID -\u0026gt; SHIPPED -\u0026gt; FINISHED，重复消费消息只会尝试做同样的状态转换，不会造成副作用。\n适用场景: 订单、支付、物流、账户等状态流转型业务\n状态转换的逻辑: 当前状态 + 输入事件 =\u0026gt; 新状态\n-- 只有当状态还是 CREATED 才能推进到 PAID UPDATE orders SET status = \u0026#39;PAID\u0026#39;, paid_time = NOW() WHERE order_id = ? AND status = \u0026#39;CREATED\u0026#39;; Java 代码\n@KafkaListener(topics = \u0026#34;order.created\u0026#34;, groupId = \u0026#34;order-group\u0026#34;) public void onMessage(ConsumerRecord\u0026lt;String, String\u0026gt; record, Acknowledgment ack) { int updated = jdbcTemplate.update(sql, orderId); if(updated == 0) { // 可能已经处理过，也可能是乱序消息 // 假设有 1 -\u0026gt; 2 -\u0026gt; 3 -\u0026gt; 4 这 4 种状态转换 // 记录的当前状态是 3 // 此时来了一个从 1 -\u0026gt; 2 的状态转换的消息 // 由于当前状态是 3，因此消息是处理过了，直接丢弃 // 处理过的消息也可以用 redis 快速去重 if(isPrevStateMessage(record)) { ack.acknowledge(); return; } // 假设有 1 -\u0026gt; 2 -\u0026gt; 3 -\u0026gt; 4 这 4 种状态转换 // 记录的当前状态是 1 // 此时来了一个从 2 -\u0026gt; 3 的状态转换消息，这个就是乱序消息 // 如果是乱序消息，将当前消息重新入队或者是发送到其他队列，并回复 ack if(isOutOfOrderMessage(record)) { kafkaTemplate.send(record); ack.acknowledge(); return; } // 如果是重复消息，直接回复 ACK ack.acknowledge(); return; } // 第一次消费，执行相关业务逻辑 try { orderService.handleCreated(record.value()); ack.acknowledge(); } catch (Exception e) { throw e; } // .... } 如何在消费端实现 Exactly Once 语义 消费端需要处理的问题:\n重复消费 乱序消费 有状态的消息和无状态的消息 有状态的消息: 例如订单，有明确的 CREATED -\u0026gt; PAID -\u0026gt; SHIPPED -\u0026gt; FINISHED 状态转换 无状态的消息: 例如点赞，每一次点赞都是一种全新的状态 实现方式 有状态 有状态的消息实现步骤:\nRedis 去重，判断是否处理过(可选) 乱序消息直接 ACK 并重新入队 执行业务幂等 SQL 将消息已处理过的信息写入 Redis 并加上恰当的 TTL 无状态 无状态的消息实现步骤:\n单独增加一张数据库表，例如: user_assets_message，用于持久化记录消息是否被处理 Redis 去重，判断是否处理过(可选) 乱序消息直接 ACK 并重新入队(有状态的消息才会出现乱序的现象) 查询数据库，当前消息是否已经被处理过(最后一步的 Redis 写入失败) 执行业务 SQL 以及消息是否被处理的 SQL，这两个 SQL 必须处于同一个事务中 将消息已经处理过的信息写入 Redis 并加上恰当的 TTL(可选) 思考 重复消费，乱序消费本身是一个小概率事件，是否有必要还加上 Redis 来去重? 大部分的业务都是不需要加上 Redis 来去重的，业务中出现如下的情况时，加上 Redis 去重才有意义:\n重复执行的代价太高 如何设置重试以及死信队列? Kafka 自身是不支持死信队列，但是 spring-kafka 提供了消费重试以及重试失败后将消息发送到指定队列的能力。\n关闭自动提交 + 手动 ack spring: kafka: consumer: enable-auto-commit: false auto-offset-reset: earliest listener: ack-mode: manual_immediate 配置错误处理器: 重试 N 次，最终进入 DLT(Dead Letter Topic) @Configuration public class KafkaDltConfig { @Bean public DefaultErrorHandler errorHandler(KafkaTemplate\u0026lt;Object, Object\u0026gt; template) { // 把失败消息发布到 DLT 的“发布器” DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template, (record, ex) -\u0026gt; new TopicPartition(record.topic() + \u0026#34;.DLT\u0026#34;, record.partition())); // 重试策略：例如重试 3 次，每次间隔 1s（固定退避） FixedBackOff backOff = new FixedBackOff(1000L, 3L); DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff); // 不可重试异常：直接进 DLT（避免白重试） handler.addNotRetryableExceptions( IllegalArgumentException.class, ValidationException.class ); // 可选：记录日志（默认也会打） handler.setRetryListeners((record, ex, deliveryAttempt) -\u0026gt; { // deliveryAttempt: 第几次投递（1 开始） }); return handler; } @Bean public ConcurrentKafkaListenerContainerFactory\u0026lt;Object, Object\u0026gt; kafkaListenerContainerFactory( ConsumerFactory\u0026lt;Object, Object\u0026gt; consumerFactory, DefaultErrorHandler errorHandler) { ConcurrentKafkaListenerContainerFactory\u0026lt;Object, Object\u0026gt; factory = new ConcurrentKafkaListenerContainerFactory\u0026lt;\u0026gt;(); factory.setConsumerFactory(consumerFactory); factory.setCommonErrorHandler(errorHandler); // 你希望手动 ack 的话，确保这里是 MANUAL 或 MANUAL_IMMEDIATE（也可放 YAML） factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE); return factory; } } 关键点: @KafkaListener 注解修饰的方法不要捕获异常，从而交给错误处理器去处理，如果 @KafkaListener 注解修饰的方法捕获了异常，导致调用方无法感知到异常，错误处理器就无法起作用。\n","date":"2025-12-20T15:16:48+08:00","permalink":"https://www.autmaple.com/post/how-to-use-kafka-in-java-correctly/","title":"在Java中如何正确的使用Kafka"},{"content":"今天将从 CPU 的发展历程，来了解缓存一致性协议，Store Buffer, Store Forwarding, Invalidate Buffer, 可见性，重排序(Reorder)，读屏障和写屏障\n单核架构 计算机的两个核心部件: CPU 和内存。CPU 负责计算，内存负责存储指令以及指令所需的数据:\n由于 CPU 的处理速度远远大于从内存获取数据的速度，因此工程师在内存和 CPU 之间加了一层缓存:\n在单核 CPU 架构下，只能够通过提升 CPU 频率来提升性能，导致功耗急剧上升，从而出现了多核 CPU 架构。但是多核架构下需要处理一个问题，那就是缓存如何进行设计? 有两种方案:\n多个 CPU 核心共享高速缓存 每个 CPU 核心管理自己的高速缓存 第一种方案实现比较简单，但是每个核心访问缓存之前需要获取访问缓存的权限，也就是锁，在获取到锁之前需要等待，等待期间会浪费很多 CPU 的性能。\n第二种方案因为每个核心拥有自己的缓存，不需要获取锁，因此性能相对于第一种方案更高，这也是目前 CPU 使用的一种方案:\n每个核心拥有自己的缓存，处于不同核心上的进程，因为进程 A 不能够修改进程 B 的数据，所以可以放心的修改各自缓存中的数据，但是如果同一个进程的多个线程分配在了不同的核心上，就不能够随意的修改各自缓存中的数据了。因为进程是资源分配的最小单位，同一个进程中的多个线程共享进程的资源，因此多线程环境下需要保证缓存的一致性。目前常用的缓存一致性协议是 MESI 协议。\nMESI 协议 MESI(Modify, Exclusive, Shared, Invalid) 硬件缓存一致性协议确保所有核心看到的数据是一样的。在硬件层面实现了缓存一致性。\n在缓存行中会有一个标志位表示当前缓存行的状态:\nM(Modified): 表示缓存行被修改过，是所有缓存中唯一有效(最新)的数据，还未写入内存，与内存中的数据不一致。 E(Exclusive): 表示缓存数据未被修改，只在当前核心的缓存中存在，其他核心的缓存中不存在，是所有缓存中唯一的副本，与内存中的数据一致。 S(Shared): 表示缓存行至少被 2 个核心缓存过，并且没有被修改，与内存中的数据保持一致。写缓存行之前，必须先让其他核心中的缓存行失效。 I(Invalid): 表示缓存行无效，需要重新从内存或其他核心的缓存中加载最新的数据 在 MESI 协议下，核心 C1 写入缓存行 A 之前，需要通知其他的核心将缓存行 A 标记为无效，其他核心收到通知后，将缓存行 A 标记为无效，并需要给核心 C1 返回 ACK 确认，核心 C1 收到全部的 ACK 确认后才会更新缓存行 A，并将缓存行 A 的状态改成 Modifed。\nsequenceDiagram participant C1 as 核心 C1 participant C1C as C1 缓存 participant Bus as 总线 participant S2 as 核心 C2 participant S3 as 核心 C3 participant S4 as 核心 C4 C1-\u0026gt;\u0026gt;Bus: 发送 \u0026#34;Invalidate A\u0026#34; 请求 Bus-\u0026gt;\u0026gt;S2: 广播失效通知 (Invalidate A) Bus-\u0026gt;\u0026gt;S3: 广播失效通知 (Invalidate A) Bus-\u0026gt;\u0026gt;S4: 广播失效通知 (Invalidate A) S2-\u0026gt;\u0026gt;S2: 将 A 标记为无效 S3-\u0026gt;\u0026gt;S3: 将 A 标记为无效 S4-\u0026gt;\u0026gt;S4: 将 A 标记为无效 S2--\u0026gt;\u0026gt;Bus: 发送 ACK S3--\u0026gt;\u0026gt;Bus: 发送 ACK S4--\u0026gt;\u0026gt;Bus: 发送 ACK Bus--\u0026gt;\u0026gt;C1: 所有 ACK 已收到 C1-\u0026gt;\u0026gt;C1C: 写入缓存行 A，状态变为 Modified 通过上面的流程图可以看到，从发送 Invalidate 通知到最终修改缓存行这段期间，CPU 必须等待，这非常的浪费 CPU 的性能，因此工程师在 CPU 和缓存之间加了一个 Store Buffer\nStore Buffer 和读写屏障 Store Buffer 的作用是将数据放入 Store Buffer 中，等收到全部的 ACK 之后，再将 Store Buffer 中的数据写入缓存中，这样 CPU 无需等待，继续执行其他的指令。CPU 获取数据数据时，每个 CPU 核心可以扫描自身的 Store Buffer, 但是不能够访问其他 CPU 核心的 Store Buffer。\nStore Forwarding CPU 虽然可以不用等待，但是这样会将 Invalidate 操作变成了一个异步的行为，这带来了一个可见性问题，看下面的代码:\npublic class InvalidateTest{ private int a = 0; private int b = 0; public void foo() { a = 1; int b = a + 1; System.out.println(b); } } 假设现在缓存行中存在 a = 0，状态是 Shared， 我们看一下执行过程:\n执行 a = 1， 因为缓存行的状态是 Shared，所以需要通知其他的核心，将 a = 1 写入 Store Buffer 中，发送 Invalidate 通知，CPU 继续执行 执行 int b = a + 1;。读取缓存行中 a 的值，此时 a = 0, 因此最后 b 的值为 1 而不是 2 之所以会出现上面的问题，是因为 CPU 没有读取 Store Buffer 中 a 的最新值，而是读取的缓存行中已经过期了的值，因此硬件工程师设计让 CPU 先读取 Store Buffer 中的内容，没有找到再读取缓存中的内容，这个过程叫做 Store Forwarding。\n写屏障 public class InvalidateTest{ private int a = 0; private int b = 0; private int c = 0; public void foo() { a = 1; b = 1; } public void boo() { while(b == 0) continue; assert(a == 1); } } 看下上面的代码: assert(a == 1) 一定会断言成功吗? 答案是不一定，看下下面的执行顺序:\n假设核心 C1 执行 foo() 方法，核心 C2 执行 boo() 方法。C1 缓存中存在 a = 0, 状态是 Shared 和 b = 0, 状态是 Exclusive 独占状态，C2 缓存中存在 a = 0 状态是 Shared\nC1 执行 a = 1, 由于状态是 Shared, 需要发送 Invalidate 通知并写入 Store Buffer C1 执行 b = 1, 由于是 Exclusive 的状态，无需发送 Invalidate 通知，直接写入缓存行，并将状态修改成 Modified C2 执行 while 循环，由于 b 不在缓存中，发出 Read 通知，C1 收到消息 Read 消息后，发现拥有 b 的最新版本，将 b = 1 发送给 C2, 并将 b = 1 的缓存行状态设置成 Shared C2 收到 b = 1 后，同样将 b = 1 的缓存行状态设置成 Shared，同时退出 while 循环 C2 执行 assert(a == 1)，由于缓存中存在 a = 0，状态是 Shared，并且此时还未收到 a 的 Invalidate 通知，因此 a == 1 断言失败 C2 接收到 C1 关于 a 的 Invalidate 通知 根据上面的执行流程就好像: b = 1 先于 a = 1 执行，这就是所谓的乱序执行(Reorder)。从单个核心上来看，执行的顺序并没有变，由于 Invalidate 通知是一个异步操作，如果消息通知的不够及时，就会导致一个乱序问题的操作。\n这个问题该怎么解决呢? 答案是写屏障，写屏障的作用: 写屏障之后的写缓存操作执行之前， 必须先将 Store Buffer 中的写操作先同步到缓存中。\n如果写屏障是要等 Invalidate ACK，那就又回到了没有 Store Buffer 的场景。现代 CPU，写屏障的实现方式是强制将写屏障之后的写操作全部写入 Store Buffer, 而不能直接写缓存，这样一个个的排队就不会出现上述的乱序问题了。\nInvalidate Buffer 和读屏障 通过 Store Buffer 和 Store Forwarding 虽然解决了可见性问题，如果写缓存的操作非常多，Invalidate ACK 回复的又非常慢，导致 Store Buffer 很快就被填满，CPU 就又只能够等待 Store Buffer 可用，硬件工程师为了解决 ACK 回复慢的问题，引入了 Invalidate Buffer。\n核心收到 Invalidate 通知后，立马回复 Invalidate ACK, 并将 Invalidate 操作写入 Invalidate Buffer 中。\n获取数据时，CPU 核心不能够扫描 Invalidate Buffer 来判断缓存是否 Invalidate(过期)\n同样的，引入 Invalidate Buffer 将 Invalidate 操作变成了异步操作，也是会导致一个可见性问题，看下面的代码:\npublic class InvalidateTest{ private int a = 0; private int b = 0; private int c = 0; public void foo() { a = 1; int b = a + 1; System.out.println(b); } public void boo() { int c = a + 1; System.out.println(b); } } 假设核心 C1 执行 foo() 方法，核心 C2 执行 boo() 方法。C1 和 C2 的缓存行中都存在 a = 0;\nC1 执行 a = 1, 发送 Invalidate 通知给 C2 C2 收到 Invalidate 通知后立马回复 ACK， 并将 Invalidate 操作写入 Invalidate Buffer C2 执行 int c = a + 1 的操作，由于 Invalidate 操作还在 Invalidate Buffer 中，还未执行，因此变量 a 所在的缓存行生效，a 的值为 0，最后 b 的值为 1 如果没有 Invalidate Buffer, C2 需要现将 a 所在的缓存行标记为过期，再回复 ACK， 这样执行 int c = a + 1 的时候，a 所在的缓存行已经过期，C2 需要读取 a 的最新值。\n那怎么解决这个可见性的问题呢? 答案是读屏障，读屏障的作用是将 Invalidate Buffer 中的指令全部执行完之后才会继续执行读屏障后的指令，通过读屏障，就可以解决解决 Invalidate Buffer 带来的可见性问题了。\n可见性 invalidate buffer 将 invalidate(过期/失效) 指令放入 invalidate buffer，并立即回应 invalidate ack。CPU 在真正执行 invalidate 指令之前的空档期，如果读取了本应该已经过期但是实际上没有过期的缓存行就会出现可见性的问题。\ninvalidate 指令的作用是将某个缓存行标记为过期，标记缓存行里面的数据是旧的，不是最新的数据，需要重新从内存或缓存中读取最新的数据，但是 invalidate 指令被放入 Invalidate Buffer 中延迟执行，在 invalidate 指令执行之前又出现了读本应该过期的缓存行，就出现了读取旧值，而非最新值的情况，从而出现了可见性问题。\n","date":"2025-11-04T20:23:54+08:00","permalink":"https://www.autmaple.com/post/cache-coherence-protocol-and-read-write-barrier/","title":"缓存一致性协议以及读写屏障"},{"content":"BIO,NIO,AIO 对应三种不同的 IO 模型，本文将详细介绍这三种 IO 模型。\n同步和异步 同步: 任务顺序执行，必须等前一个任务完成后才可以继续执行后面的任务 异步: 任务发起后，不用等待任务完成，可以忙其他的任务，当任务完成时，会主动进行通知 同步和异步主要的区别在于任务完成时是否有通知(回调)机制，如果没有所谓的通知(回调)机制，那就是同步的，如果有通知(回调)机制，那就是异步。不能够通过是否阻塞来判断是同步还是异步\n代码示例 // 同步调用，readFile 方法执行时阻塞等待文件读完 String data = readFile(\u0026#34;file.txt\u0026#34;); System.out.println(\u0026#34;文件内容: \u0026#34; + data); 在 readFile 没有读取完毕之前，程序不会往下执行 // 异步调用，方法立即返回 readFileAsync(\u0026#34;file.txt\u0026#34;, new Callback() { @Override void onComplete(String data) { System.out.println(\u0026#34;文件内容: \u0026#34; + data); } }); System.out.println(\u0026#34;我不用等文件读完，先去干别的事\u0026#34;); readFileAsync 调用后立即返回，不阻塞 文件读取完毕后，调用 onComplete 通知读取的结果 阻塞和非阻塞 阻塞与非阻塞指的是线程的状态。\n阻塞: 指的是 I/O 操作的这段时间，线程处于阻塞状态，等待 I/O 操作完成后唤醒线程并继续往下执行 非阻塞: 指的是 I/O 操作的这段时间，线程处于非阻塞(运行态)状态。线程发起 I/O 操作，发现数据不可用时，立即返回，不等待， 继续向下执行。 代码示例 // 同步调用，readFile方法执行时阻塞等待文件读完 String data = readFile(\u0026#34;file.txt\u0026#34;); // 阻塞等待 IO 完成 System.out.println(\u0026#34;文件内容: \u0026#34; + data); 执行 readFile 时，线程处于阻塞状态，等待 IO 完成 FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ); ByteBuffer buffer = ByteBuffer.allocate(1024); // 不阻塞，直接向下执行 int bytesRead = fileChannel.read(buffer); // 由于没有回调(通知)机制，只能够不断的轮询 while (bytesRead != -1) { buffer.flip(); System.out.println(\u0026#34;读取的内容: \u0026#34; + StandardCharsets.UTF_8.decode(buffer)); buffer.clear(); bytesRead = fileChannel.read(buffer); } fileChannel.close(); 执行 read 方法的时候，如果没有数据，直接返回 0，不阻塞等待 因为没有回调(通知)机制，只能够通过 while 循环来判断 IO 操作是否完成 BIO Blocking I/O(BIO)，是一种同步阻塞的 IO 模型。线程执行 IO 操作时会进入阻塞状态，直到 IO 操作完成时，被唤醒再继续往下执行。线程与 IO 操作的关系是一对一，也就是一个线程对应一个 IO 操作\n代码示例 import java.io.FileInputStream; import java.io.IOException; public class BioFileReadExample { public static void main(String[] args) { String filePath = \u0026#34;test-bio.txt\u0026#34;; try (FileInputStream fis = new FileInputStream(filePath)) { byte[] buffer = new byte[1024]; int bytesRead; // 阻塞式读取，read()会阻塞直到有数据或文件结束 while ((bytesRead = fis.read(buffer)) != -1) { String content = new String(buffer, 0, bytesRead, \u0026#34;UTF-8\u0026#34;); System.out.print(content); } } catch (IOException e) { e.printStackTrace(); } } } NIO Non-Blocking I/O(NIO), 是一种同步非阻塞的 IO 模型。线程发起 IO 操作后，不会进入阻塞状态等待 IO 操作的完成，而是直接往下继续执行。线程与 IO 操作的关系是一对多，也就是一个线程可以管理多个 IO 操作。\nNIO 相较于 BIO 多了一个选择器(Selector)组件，由选择器来管理多个 IO 操作，线程直接与选择器进行对话。\n代码示例 // NIO 服务器示例，非阻塞IO，单线程可同时处理多个连接 ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); // 非阻塞模式 Selector selector = Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞，等待某个通道准备好事件（连接读写等） Set\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); Iterator\u0026lt;SelectionKey\u0026gt; iter = selectedKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { // 有新连接 ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); SocketChannel clientChannel = ssc.accept(); clientChannel.configureBlocking(false); clientChannel.register(selector, SelectionKey.OP_READ); System.out.println(\u0026#34;Accepted new connection\u0026#34;); } else if (key.isReadable()) { // 有数据读 SocketChannel clientChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = clientChannel.read(buffer); if (bytesRead == -1) { clientChannel.close(); } else { buffer.flip(); byte[] data = new byte[buffer.limit()]; buffer.get(data); System.out.println(\u0026#34;Received: \u0026#34; + new String(data)); } } } } AIO Asynchronous I/O(AIO), 是一种异步非阻塞的 IO 模型。线程发起 IO 操作后，不会等待 IO 操作的完成，而是继续往下执行，但是当 IO 操作完成后，会主动通知线程 IO 操作完毕。相较于 NIO 模型，加上了一个主动通知的功能，避免了轮训带来的性能损耗。\n代码示例 import java.nio.ByteBuffer; import java.nio.channels.AsynchronousFileChannel; import java.nio.channels.CompletionHandler; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.charset.StandardCharsets; import java.io.IOException; import java.util.concurrent.CountDownLatch; public class AioFileExample { public static void main(String[] args) throws Exception { String filePath = \u0026#34;test-aio.txt\u0026#34;; // 计数器，等待异步操作完成 CountDownLatch latch = new CountDownLatch(2); // 异步写入示例 asynchronousWrite(filePath, \u0026#34;Hello AIO World!\\n\u0026#34;, latch); // 等待写入完成，再开始读取（简化处理，实际可用回调链式调用） latch.await(); // 重置计数器用于读取 latch = new CountDownLatch(1); // 异步读取示例 asynchronousRead(filePath, latch); // 等待读取完成 latch.await(); System.out.println(\u0026#34;所有异步操作完成\u0026#34;); } // 异步写入文件 public static void asynchronousWrite(String filePath, String content, CountDownLatch latch) throws IOException { AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open( Path.of(filePath), StandardOpenOption.WRITE, StandardOpenOption.CREATE); ByteBuffer buffer = ByteBuffer.wrap(content.getBytes(StandardCharsets.UTF_8)); // 异步写，写完调用回调 fileChannel.write(buffer, 0, null, new CompletionHandler\u0026lt;Integer, Void\u0026gt;() { @Override public void completed(Integer result, Void attachment) { System.out.println(\u0026#34;写入完成，字节数: \u0026#34; + result); try { fileChannel.close(); } catch (IOException e) { e.printStackTrace(); } latch.countDown(); // 计数减1，表示写入完成 } @Override public void failed(Throwable exc, Void attachment) { System.err.println(\u0026#34;写入失败: \u0026#34; + exc); latch.countDown(); try { fileChannel.close(); } catch (IOException e) { e.printStackTrace();} } }); } // 异步读取文件 public static void asynchronousRead(String filePath, CountDownLatch latch) throws IOException { AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open( Path.of(filePath), StandardOpenOption.READ); ByteBuffer buffer = ByteBuffer.allocate(1024); // 异步读，读完调用回调 fileChannel.read(buffer, 0, buffer, new CompletionHandler\u0026lt;Integer, ByteBuffer\u0026gt;() { @Override public void completed(Integer result, ByteBuffer attachment) { System.out.println(\u0026#34;读取完成，字节数: \u0026#34; + result); attachment.flip(); byte[] data = new byte[attachment.limit()]; attachment.get(data); String content = new String(data, StandardCharsets.UTF_8); System.out.println(\u0026#34;文件内容: \\n\u0026#34; + content); try { fileChannel.close(); } catch (IOException e) { e.printStackTrace(); } latch.countDown(); // 计数减1，表示读取完成 } @Override public void failed(Throwable exc, ByteBuffer attachment) { System.err.println(\u0026#34;读取失败: \u0026#34; + exc); latch.countDown(); try { fileChannel.close(); } catch (IOException e) { e.printStackTrace(); } } }); } } ","date":"2025-10-29T17:06:43+08:00","permalink":"https://www.autmaple.com/post/bio-nio-aio/","title":"BIO NIO AIO"},{"content":"return 语句对应两条汇编指令:\n将返回值写入到某个寄存器中 执行 ret 指令返回到调用函数的位置。 在 Java 中，在这两个指令之间可以插入很多其他的指令，比如 finally 代码块中的指令。对于有 finally 语句的代码，执行到 return 语句时，先将返回值放入到返回值寄存器中，然后继续执行 finally 代码块中的指令。理解了这一点就非常容易理解下面这些代码的执行顺序:\npublic static int getInt() { int a = 10; try { return a; } finally { a = 40; } } 输出: 10\n执行到 try 代码块中的 return a 语句时，先将 a 的值，也就是 10 放入到返回值寄存器中，然后执行 finally 代码块中内容。由于 finally 代码块中的指令并没有修改返回值寄存器里面的内容，所以寄存器里面的值还是 10。所以 getInt() 的返回值是 10。\n再看一段在 finally 中修改返回值代码:\npublic static int getInt() { int a = 10; try { return a; } finally { a = 40; return a; } } 输出: 40\n根据我们上面的分析: try 代码块中的 return a 先将 a 的值，也就是 10 放入到返回值寄存器中，然后执行 finally 代码块中的内容，由于 finally 代码块中也有 return 语句，所以将 a 的值，也就是 40 放入到寄存器中，因此 getInt() 的返回值是 40。\n再看下面的这段代码，返回值是多少呢?\npublic static int getInt() { int a = 10; try { System.out.println(a / 0); return a; } catch (ArithmeticException e) { a = 30; return a; } finally { a = 40; } } 输出: 30\n要理解为什么输出是 30, 就得知道 try, catch 和 fianlly 代码块的执行顺序了。只需要记住一点即可: fianlly 里面的语句永远是最后执行的。\n由于 try 中发生异常，所以执行 catch 代码块中代码，执行到 return a; 语句时，将 a 的值也就是 30 放入到返回值寄存器中，因为有 finally 代码块，所以执行 finally 代码块中的语句，由于 finally 代码块中并没有 return 语句，所以返回值寄存器里面的内容没有发生变化，还是 30， 所以 getInt() 的返回值是 30。\n再看一段在 finally 中修改返回值代码:\npublic static int getInt() { int a = 10; try { System.out.println(a / 0); return a; } catch (ArithmeticException e) { a = 30; return a; } finally { a = 40; return a; } } 输出: 40\n这段代码就不解释了。\n总结 return 语句对应两条汇编指令: 将返回值放入到返回值寄存器中 + 返回到调用函数的地方 在 return 两条汇编指令之间可以插入 fianlly 代码块中的指令。 在执行 finally 代码块中的指令之前，会先执行 return 的第一条汇编指令: 将返回值写入到返回值寄存器中 finally 其实是语法糖，让代码更为的简洁，避免代码冗余。如果没有 fianlly 语法糖，就需要将 finally 代码块中的指令在 try 和 catch 中都放一份。 ","date":"2025-10-28T13:29:27+08:00","permalink":"https://www.autmaple.com/post/execution-order-of-return-try-catch-finally-in-java/","title":"Java 中 return, try catch finally 的执行顺序"},{"content":"Mysql 中的深分页问题 前言 实现分页需求时，通常都会使用 limit 来实现，但是当 offset 特别大的时候，查询速度会非常的慢。文章将会详细的介绍深分页为什么会导致性能问题以及如何解决这个问题。\n环境 CREATE TABLE movies ( id INT PRIMARY KEY, title VARCHAR(255), vote_average DECIMAL(4, 3), vote_count INT, status VARCHAR(50), release_date DATE, revenue BIGINT, runtime INT, adult BOOLEAN, backdrop_path VARCHAR(255), budget BIGINT, homepage VARCHAR(255), imdb_id VARCHAR(20), original_language VARCHAR(10), original_title VARCHAR(255), overview TEXT, popularity DECIMAL(8, 3), poster_path VARCHAR(255), tagline VARCHAR(255), production_companies TEXT, production_countries TEXT, spoken_languages TEXT, keywords TEXT, release_year INT, Director VARCHAR(100), AverageRating DECIMAL(3, 1), Poster_Link VARCHAR(255), Certificate VARCHAR(20), IMDB_Rating DECIMAL(3, 1), Meta_score INT, Star1 VARCHAR(100), Star2 VARCHAR(100), Star3 VARCHAR(100), Star4 VARCHAR(100), Writer VARCHAR(255), Director_of_Photography VARCHAR(255), Producers TEXT, Music_Composer VARCHAR(255), genres_list TEXT, Cast_list TEXT, overview_sentiment DECIMAL(5, 4), all_combined_keywords TEXT ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -- 在 release_date 上创建索引 create index idx_release_date on movies (release_date); 数据量\nMySQL root@docker.local.com:test\u0026gt; select count(*) from movies; +----------+ | count(*) | +----------+ | 1071607 | +----------+ 1 row in set Time: 1.120s 慢 SQL 在这种环境下，看下深分页查询需要多长的时间\ntest\u0026gt; select * from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; order by release_date, id limit 800000, 10 [2025-10-09 13:21:13] 10 rows retrieved starting from 1 in 8 s 456 ms (execution: 8 s 411 ms, fetching: 45 ms) 上述 SQL 花了 8.456s 才执行完毕，已经是非常非常慢的 SQL 了， 在生产环境下绝对是灾难级别的问题。看下执行计划:\nMySQL root@docker.local.com:test\u0026gt; explain -\u0026gt; select * -\u0026gt; from movies -\u0026gt; where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; -\u0026gt; order by release_date, id -\u0026gt; limit 800000, 10; +----+-------------+--------+------------+------+------------------+--------+---------+--------+---------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+--------+------------+------+------------------+--------+---------+--------+---------+----------+-----------------------------+ | 1 | SIMPLE | movies | \u0026lt;null\u0026gt; | ALL | idx_release_date | \u0026lt;null\u0026gt; | \u0026lt;null\u0026gt; | \u0026lt;null\u0026gt; | 1005233 | 50.0 | Using where; Using filesort | +----+-------------+--------+------------+------+------------------+--------+---------+--------+---------+----------+-----------------------------+ 可以看到，根本没有使用索引，直接进行的全表扫描，同时还用到了文件排序。有读者可能提出: 会不会排序导致了问题呢? 我们看下不使用排序会花费多长的时间:\ntest\u0026gt; select * from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; limit 800000, 10 [2025-10-09 13:38:09] 10 rows retrieved starting from 1 in 8 s 365 ms (execution: 8 s 316 ms, fetching: 49 ms) 上述 SQL 依然使用了 8.365s, 并没有比使用了 order by 的 SQL 快多少，依然是灾难级别的 SQL。看下执行计划:\nMySQL root@docker.local.com:test\u0026gt; explain -\u0026gt; select * -\u0026gt; from movies -\u0026gt; where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; -\u0026gt; limit 800000, 10; +----+-------------+--------+------------+-------+------------------+------------------+---------+--------+--------+----------+----------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+--------+------------+-------+------------------+------------------+---------+--------+--------+----------+----------------------------------+ | 1 | SIMPLE | movies | \u0026lt;null\u0026gt; | range | idx_release_date | idx_release_date | 4 | \u0026lt;null\u0026gt; | 502616 | 100.0 | Using index condition; Using MRR | +----+-------------+--------+------------+-------+------------------+------------------+---------+--------+--------+----------+----------------------------------+ 去除掉排序后，使用了 idx_release_date 索引，但是速度还是非常的慢，这是为什么呢?\nSQL 执行过程 在 Mysql 中，SQL 的执行过程如下:\n通过 where 进行过滤，找出结果集 SQL 中有 limit N, M，从结果集的第一行开始遍历，直到第 N 行，抛弃前面的 N - 1 行，然后取出后面的 M 行。 读者可能会说: MySQL 为什么不直接从第 N 行开始，而是从第 1 行开始?\n主要是 MySQL 没法像数组那样直接根据下标访问数据。MySQL 的数据存放在一个个数据页中，每个数据页存放的行数是不确定的，因此 MySQL 只能够从第一行开始遍历。\n深分页慢 SQL 优化 深分页导致慢 SQL 的原因: 遍历一个个的数据页，直到找到所需要的数据。这个过程涉及很多次的 I/O，I/O 操作相对于 CPU 来说是非常缓慢的。\n那么该如何进行优化呢?\n首先需要明确: MySQL 的分页操作只能够从第一行开始查找。我们没有办法改变 MySQL 的这个行为，我们能够做的是: 减少 MySQL 遍历的数据页，从而减少 I/O 的次数。\n一个普通的索引，里面存放的数据是索引列以及主键。而完整的数据行则存放了非常多的字段。因此相同大小的数据页，索引的行数远大于完整的数据行。举个例子:\n表有 1GB 数据，每行 1 KB， 也就是 100 万条数据 索引 16MB 数据，每行 16 B 假设一个数据页的大小是 64KB，一个数据页可以存放 64 条完整的数据行，而索引可以存放 4000 条. $ 4000 \\div 64 = 62.5 $。也就是说遍历索引的速度至少是遍历完整数据行的速度的 62.5 倍。并且遍历索引 I/O 次数更少，真实的速度肯定比 62.5 倍更快。\n数据行的大小与索引行的大小差距越大，遍历索引相对于遍历完整数据行的速度就越快。\n因此优化深分页的目标就是将分页的操作转移到索引中，让 MySQL 遍历索引而不是遍历完整数据行，怎么办呢? 答案就是: 覆盖索引(covering index) + 延迟关联。\n覆盖索引可以避免回表，只在索引树中查询所需要的字段 延迟关联指的是延迟对列的访问，也就是说不直接获取所需要的列。 通过覆盖索引查询到的列与外层查询进行匹配，获取所需要的数据。 慢 SQL\nselect * from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; order by release_date, id limit 800000, 10; 慢 SQL 执行时间\ntest\u0026gt; select * from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; order by release_date, id limit 800000, 10 [2025-10-09 13:21:13] 10 rows retrieved starting from 1 in 8 s 456 ms (execution: 8 s 411 ms, fetching: 45 ms) 执行了 8.456s\n慢 SQL 优化后:\nselect a.* from movies as a inner join (select id from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; order by release_date, id limit 800000, 10) as tmp on a.id = tmp.id; 优化后的执行时间:\ntest\u0026gt; select a.* from movies as a inner join (select id from movies where release_date \u0026gt; \u0026#39;1799-01-01\u0026#39; order by release_date, id limit 800000, 10) as tmp on a.id = tmp.id [2025-10-09 15:01:20] 10 rows retrieved starting from 1 in 317 ms (execution: 290 ms, fetching: 27 ms) 只花费了 317ms\n","date":"2025-10-08T22:57:41+08:00","permalink":"https://www.autmaple.com/post/deep-pagination-issue-in-mysql/","title":"Mysql中的深分页问题"},{"content":"在使用 open feign 的时候，我们都是直接在接口上添加 @FeignClient 注解， open feign 会通过服务发现，自动生成接口的实现类并交给 Spring 容器来管理。如果我们已知服务的地址，想直接进行调用，可以手动生成 Feign 客户端:\nimport feign.Feign; import feign.Target; import feign.codec.Decoder; import feign.codec.Encoder; import feign.jackson.JacksonDecoder; import feign.jackson.JacksonEncoder; import org.springframework.cloud.openfeign.support.SpringMvcContract; /** * 动态构建RPC客户端 */ public class FeignClientBuilder\u0026lt;T\u0026gt; { private Encoder encoder = new JacksonEncoder(); private Decoder decoder = new JacksonDecoder(); private Class\u0026lt;T\u0026gt; targetClass; public FeignClientBuilder(Class\u0026lt;T\u0026gt; targetClass) { this.targetClass = targetClass; } // instanceHost: http://192.168.0.3:8088 public T build(String instanceHost) { Feign.Builder builder = Feign.builder() .encoder(encoder) .decoder(decoder) .requestInterceptor(template -\u0026gt; { template.header(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;); }) // 指定 Feign 如何解析注解。 使用 SpringMvcContract 让 Feign 能够理解 SpringMVC 的注解 // 例如 @RequestMapping, @GetMapping 等 .contract(new SpringMvcContract()); // 显式指定 name 和 URL Target\u0026lt;T\u0026gt; target = new Target.HardCodedTarget\u0026lt;\u0026gt;( targetClass, instanceHost, // 自定义唯一标识 instanceHost ); return builder.target(target); } } 上述代码适合已知服务地址直接进行调用的情况。\n","date":"2025-07-22T15:19:11+08:00","permalink":"https://www.autmaple.com/post/how-to-dynamically-instantiate-a-feign-client/","title":"如何动态创建一个 Feign 客户端"},{"content":"前言 在系统中存储密码时，密码是不能够明文进行存储的，这样密码泄漏后，攻击者无法轻易(或者几乎不可能)还原出用户的原始密码，从而避免用户数据被破坏。\n加密和哈希 加密：不论是对称加密还是非对称加密，只要密钥匹配，就可以将密文进行还原。比较适合双方需要进行沟通的场景，例如通话，发送方先对数据加密之后再传输，防止中间人窃听，接收方收到密文后，使用特定的密钥进行解密。 哈希：是单向的，相同的数据经过同一个哈希算法后，会生成相同的哈希值，这个哈希值无法被还原成原始数据。 所以对于密码的存储我们需要使用哈希算法，而非加密算法，这样即使攻击者获取到了密码的哈希值，也无法通过哈希值推导出用户的密码。\n黑客破解密码的手段 虽然密码会使用哈希算法来进行转换，但是如果哈希算法选择不对的话，黑客还是可以很容易的破解出用户的密码，黑客常见的攻击手段：\n字典攻击：使用包含大量常用密码的字典来匹配。 彩虹表攻击：使用提前计算好的哈希值集合来进行匹配。 如何选择哈希算法 哈希算法的特点是相同的输入会有相同的输出，如果单纯的将密码进行哈希计算，会出现黑客破解了一个密码，其他具有相同哈希值的用户也会攻破。因此在将密码进行哈希之前还需要加盐(salt)，并且每个用户的盐是随机的，将密码和随机的盐拼接之后再进行哈希。通过加随机盐的方式，相同的密码由于盐不同，拼接后生成的哈希值不同，黑客每次就只能够破解一个密码，从而增加黑客破解密码的成本。\n虽然加盐大大增加了黑客破解密码的成本，随着计算机算力的提升， 尤其是 GPU 的并行计算能力，简单的加盐密码被破解的几率还是很大，因此我们还需要一种计算速度相对较慢的哈希算法，这样可以让每秒尝试密码的次数大大降低，而对于普通用户而言，慢个几十毫秒是完全可以接受的。\n通过上面的描述，存储密码比较理想的算法应该具备以下的特点：\n单向的 带盐的 计算速度较慢的 通用哈希算法(MD5, SHA-1, SHA-256, SHA-512)是不能够用来处理密码的，因为它们的计算速度太快了，很容易被现代 GPU 给攻破。给密码加密应该选择下面的哈希算法：\nArgon2：当前最优的算法，2015年密码哈希竞赛 (Password Hashing Competition) 的获胜者，被公认为目前最强的密码哈希算法。它不仅可以调整时间成本（CPU消耗），还可以调整内存成本，能有效抵抗 GPU 和 ASIC（专用集成电路）的破解。推荐使用 Argon2id 版本，它结合了 Argon2d 和 Argon2i 的优点，提供了对侧信道攻击和 GPU 破解最好的防护 Bcrypt：可靠且被广泛使用，它内置了盐并且有一个可以调整的代价因子(Cost Factor)。 Scrypt：它是一个“内存困难型”(memory-hard)算法，需要大量内存，这使得利用 GPU 进行大规模并行破解的成本非常高。相比 Argon2，它的灵活性稍微差一点。 优先使用 Argon2，如果系统不支持再选择 Bcrypt 算法。这两个算法生成的哈希值都自带了盐，因此在做数据库设计的时候，可以省略一个字段。\nArgon2 和 Bcrypt 在 Java 中的使用 在 pom.xml 文件中新增依赖:\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.password4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;password4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.8.4\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 代码示例:\npublic static void bcryptEncode() { String raw = \u0026#34;888888\u0026#34;; BcryptFunction instance = BcryptFunction.getInstance(Bcrypt.B, 12); Hash hashObj = Password.hash(raw).with(instance); String encodedText = hashObj.getResult(); System.out.println(\u0026#34;encodedText: \u0026#34; + encodedText); } public static void bcryptDecode() { String raw = \u0026#34;888888\u0026#34;; String encodedText = \u0026#34;$2b$12$Wlm/wmfozYi5UQbMhMbqMOF2qo4RNleLIGSOiSo2BNF0yVNnRRhiO\u0026#34;; BcryptFunction instance = BcryptFunction.getInstanceFromHash(encodedText); boolean result = Password.check(raw, encodedText).with(instance); System.out.println(result); } public static void argon2Encode() { String raw = \u0026#34;888888\u0026#34;; Argon2Function instance = Argon2Function.getInstance(4096, 20, 4, 32, Argon2.ID); Hash hashObj = Password.hash(raw).addRandomSalt(64).with(instance); String encodedText = hashObj.getResult(); System.out.println(encodedText); } public static void argon2Decode() { String raw = \u0026#34;888888\u0026#34;; String encoded = \u0026#34;$argon2id$v=19$m=4096,t=20,p=4$OjcuB72RlQLfIlfwwUrv+6Ibh8DX1FUbK5ofDg8AqrKqYzsju+c/dBwBvntGnc0s5+rEhLO3254AlLmnDG/qrg$2e56eWHaPOtZpGKGpFyBmxxp92N+5thKPRtgmfPBU/Q\u0026#34;; Argon2Function instance = Argon2Function.getInstanceFromHash(encoded); boolean result = Password.check(raw, encoded).with(instance); System.out.println(result); } ","date":"2025-07-18T16:16:34+08:00","permalink":"https://www.autmaple.com/post/how-to-store-password/","title":"如何存储密码?"},{"content":"背景 日常开发 Java 的过程中， 需要连接 Mysql, Redis, Nacos, Sentinel 等组件，并且我经常需要在 Linux，Windows 和 Macos 之间进行切换，切换之后环境里面的数据又不一致，导致切换之后总是需要进行数据迁移，因此想搞一个 24 小时开机的 Linux 服务器。\n刚好手上有一台大学时期的笔记本，工作之后就一直在角落里面吃灰，拿来当 24 小时开机的低功耗 Linux 服务器刚刚好。操作系统选择的是 Debian 12.8。\n温馨提示：文章不介绍怎么装操作系统，只介绍装机之后，遇到的一些问题，以及如何解决这些问题。\n笔记本合上之后自动挂起 成功安装完操作系统后，发现只要把笔记本合上，电脑就自动挂起了，导致 SSH 连接不上，不想电脑自动挂起需要执行如下的操作。\n修改 /etc/systemd/logind.conf 配置文件 HandleLidSwitch=ignore HandleLidSwitchExternalPower=ignore HandleLidSwitchDocked=ignore 重启 systemctl-logind 服务 systemctl restart systemd-logind.service 自动亮屏和熄屏 系统如果正常运行，不挂起，笔记本的屏幕会一直亮着，对于 24 小时的服务器，能省点电费就省点吧。实现原理：通过 acpid 监听合盖和开盖的事件，然后自动执行熄屏和亮屏的命令。\n安装 acpid apt install acpid 开机自启动 acpid systemctl enable acpid.service 查看合盖和开盖事件名称 在终端中运行 acpi_listen 命令，然后手动的合盖和开盖，查看终端中输出的事件名称，下面是我笔记本上输出的事件名称\nbutton/lid LID close button/lid LID open 创建 apci 事件配置文件 在 /etc/acpi/events/ 文件夹中新增一个文件用来监听合盖和开盖的事件，例如：创建一个名为 lid-switch 的文件：\nvim /etc/acpi/events/lid-switch 在文件中添加如下的内容：\nevent=button/lid.* action=/etc/acpi/lid-switch.sh %e event 参数表示监听所有以 button/lid 开头的事件 action 参数表示系统触发监听事件后，执行 /etc/acpi/lid-switch.sh 脚本，并将完整的事件信息 (%e) 作为参数传递给脚本 创建 lid-switch.sh 脚本 创建 /etc/acpi/lid-switch.sh 脚本：\nvim /etc/acpi/lid-switch.sh 脚本内容如下：\n#!/bin/bash logger \u0026#34;ACPI lid event triggered: $@\u0026#34; # `/sys/class/backlight/` 目录在 Linux 系统中用于管理和控制显示器的背光亮度 # 该目录包含了系统中可用的背光设备的信息和接口，通常用于调整屏幕亮度 # 找到笔记本中控制背光的设备 BACKLIGHT_DIR=$(find /sys/class/backlight/ -maxdepth 1 -type l | head -n 1) if [ -z \u0026#34;$BACKLIGHT_DIR\u0026#34; ]; then logger \u0026#34;Error: device not found initially.\u0026#34; else logger \u0026#34;Found backlight device: $BACKLIGHT_DIR\u0026#34; fi # 找出存储笔记本开盖合盖状态的文件 LID_STATE_FILE=$(find /proc/acpi/button/lid/ -name state | head -n 1) # CURRENT_STATE=$(cat /proc/acpi/button/lid/LID0/state | awk \u0026#39;{print $2}\u0026#39;) # 查看笔记本当前的状态 CURRENT_STATE=$(cat $LID_STATE_FILE | awk \u0026#39;{print $2}\u0026#39;) logger \u0026#34;CURRENT_STATE: $CURRENT_STATE\u0026#34; if grep -q \u0026#34;closed\u0026#34; \u0026lt;\u0026lt;\u0026lt; \u0026#34;$CURRENT_STATE\u0026#34;; then logger \u0026#34;Lid closed. Attempting to turn off screen.\u0026#34; if [ -d \u0026#34;$BACKLIGHT_DIR\u0026#34; ]; then # --- Turn off backlight --- logger \u0026#34;Attempting command: echo 0 | tee $BACKLIGHT_DIR/brightness\u0026#34; # 关闭屏幕 echo 0 | tee \u0026#34;$BACKLIGHT_DIR/brightness\u0026#34; EXIT_STATUS=$? logger \u0026#34;tee command finished. Exit status: $EXIT_STATUS\u0026#34; if [ $EXIT_STATUS -ne 0 ]; then logger \u0026#34;ERROR: Failed to write 0 to brightness file!\u0026#34; else # Optional: Read back value to confirm CURRENT_BRIGHTNESS=$(cat \u0026#34;$BACKLIGHT_DIR/brightness\u0026#34;) logger \u0026#34;Brightness value after write: $CURRENT_BRIGHTNESS\u0026#34; fi else logger \u0026#34;Error: Backlight device was not found when trying to turn off.\u0026#34; fi elif grep -q \u0026#34;open\u0026#34; \u0026lt;\u0026lt;\u0026lt; \u0026#34;$CURRENT_STATE\u0026#34;; then logger \u0026#34;Lid opened. Attempting to turn on screen.\u0026#34; if [ -d \u0026#34;$BACKLIGHT_DIR\u0026#34; ]; then # --- Restore brightness --- MAX_BRIGHTNESS=$(cat \u0026#34;$BACKLIGHT_DIR/max_brightness\u0026#34;) logger \u0026#34;Attempting command: echo $MAX_BRIGHTNESS | tee $BACKLIGHT_DIR/brightness\u0026#34; # 打开屏幕 echo \u0026#34;$MAX_BRIGHTNESS\u0026#34; | tee \u0026#34;$BACKLIGHT_DIR/brightness\u0026#34; EXIT_STATUS=$? logger \u0026#34;tee command finished. Exit status: $EXIT_STATUS\u0026#34; if [ $EXIT_STATUS -ne 0 ]; then logger \u0026#34;ERROR: Failed to write $MAX_BRIGHTNESS to brightness file!\u0026#34; else # Optional: Read back value to confirm CURRENT_BRIGHTNESS=$(cat \u0026#34;$BACKLIGHT_DIR/brightness\u0026#34;) logger \u0026#34;Brightness value after restore: $CURRENT_BRIGHTNESS\u0026#34; fi else logger \u0026#34;Error: Backlight device was not found when trying to turn on.\u0026#34; fi else logger \u0026#34;Unknown lid state: $CURRENT_STATE\u0026#34; fi exit 0 问题排查 可以通过下面的指令排查各个阶段的问题\n# 1. 查看合盖和开盖的事件 acpi_listen # 2. 查看 acpid 的日志 journalctl -u acpid.service -f # 3. 查看 `/etc/acpi/lid-switch.sh` 脚本中 `logger` 命令产生的日志 journalctl -f # 4. 查看最大亮度 cat /sys/class/backlight/intel_backlight/max_brightness # 5. 关闭屏幕 echo 0 | sudo tee /sys/class/backlight/intel_backlight/brightness # 6. 打开屏幕 echo $(cat /sys/class/backlight/intel_backlight/max_brightness) | sudo tee /sys/class/backlight/intel_backlight/brightness ","date":"2025-07-06T17:01:09+08:00","permalink":"https://www.autmaple.com/post/laptop-as-a-low-power-linux-server/","title":"笔记本变成低功耗 Linux 服务器"},{"content":"ThreadLocal 让我们非常方便的管理和使用各线程独独有的局部变量，但是如果使用不当，会导致内存泄漏。这篇文章将详细的介绍为什么 ThreadLocal 会发生内存泄漏，以及如何避免内存泄漏的发生。\n内存泄漏 内存泄漏：占有内存空间，但是存储的数据无法被程序使用和释放，随着时间的推移，内存占用量越来越多，最终导致内存溢出。\nJava的四种引用类型 强引用：在任何时候，都不会被垃圾回收器回收，我们正常的 = 赋值就是强引用 软引用：当内存不足的时候，会被垃圾回收器回收 弱引用：任何时候，只要发生了 GC，内存都会被回收 虚引用：最弱的引用类型，软引用和弱饮用好歹还可以使用 get 方法获取到对应的对象，虚引用什么也获取不到。主要的作用就是跟踪对象被垃圾回收器回收的活动。 详细解释 相关源码\npublic class Thread implements Runnable { // ...... /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ // 默认的访问权限：只有处于同一个包中的类可以访问 threadLocals 属性 ThreadLocal.ThreadLocalMap threadLocals = null; /* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. */ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; // ...... } static class ThreadLocalMap { static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } private Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { // We don\u0026#39;t use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } } ThreadLocal 本身并不存储数据，只负责管理线程的局部变量。\nThreadLocal.ThreadLocalMap threadLocals = null; Thread 类中 threadLocals 属性的访问权限是 default. 也就是说只有与 Thread 类处于同一个包中的类才可以访问 threadLocals 属性。并且 Thread 类本身也没有提供任何方法来操作 threadLocals 属性。要操作 threadLocals，只能够通过 ThreadLocal 类。\nthreadLocals 的类型是 ThreadLocalMap. 它通过 Entry[] 数组来存储键值对。但是 Entry 类的定义比较有意思：\nclass Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } Entry 继承了 WeakReference, 并根据构造函数可以知道：Entry 中的 key 属性是一个弱引用。当我们通过 ThreadLocal 操作 threadLocalMap 是，内存示意图如下：\n根据强引用和弱引用的定义，如果 userThreadLocal 等变量不再指向 ThreadLocal, 那么 ThreadLocal 就只有 Entry 中的弱引用 key 指向 ThreadLocal 对象：\n如果发生 GC，ThreadLocal 对象就会垃圾回收器回收占用的内存：\nThreadLocal 对象被回收后，ThreadLocalMap 不会被回收，因为有强引用在使用该对象，但是根据 threadLocals 的定义：\npublic class Thread implements Runnable { // ...... /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ // 默认的访问权限：只有处于同一个包中的类可以访问 threadLocals 属性 ThreadLocal.ThreadLocalMap threadLocals = null; /* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. */ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; // ...... } 只有与 Thread 处于同一个包中的类才可访问 threadLocals 属性，并且源码注释也说明了 threadLocals 属性是交给 ThreadLocal 类来维护的。如果 ThreadLocal 对象不存在了，就没有办法访问 ThradLocalMap 中的元素了。ThreadLocal 虽然被回收了，但是 ThreadLocalMap 却不会被回收，因为有强引用指向它。无法被访问，但是内存又没法被回收，这不就是内存泄漏吗？\n知道了为什么会导致内存泄漏，那该如何避免呢？\n在 ThreadLocal 内存被回收之前手动调用 remove() 方法 避免频繁的创建和删除 ThreadLocal 对象。 内存池中的线程使用 ThreadLocal 一定要注意以上两点。因为线程池中的线程长时间存活的，而 ThreadLocalMap 的生命周期是与 Thread 绑定在一起的。 为什么要使用弱引用？ 如果 Key 使用强引用，开发人员在使用不当的情况下，内存泄漏会更为严重，因为栈中的引用不再指向 ThreadLocal 对象时，Entry 中的 Key 是强引用，堆中的 ThreadLocal 对象永远都无法被回收：\n使用弱引用时，如果外部没有强引用指向 ThreadLocal，那么 ThreadLocal 对象会被回收，因此 ThreadLocalMap 可以检查 Entry 中的 key 是否为 null 来判断 Entry 是否过期，从而释放因使用不当造成的内存泄漏。\nThreadLocal 如何尽可能的减少内存泄漏？ 调用 set() 方法时，清除过期元素 调用 get() 方法时，清除过期元素 调用 remove() 方法时，清除过期元素 只要在访问 ThreadLocal 的过程过程中， 遍历了底层的 Entry 数组，ThreadLocal 都会进行清除过期元素的操作，将清除元素的操作平坦到每一次基础的操作中。\n// set 方法的逻辑： // 1. 通过 key 找到 Entry 在数组中的位置 i // 2. 从数组第 i 个位置开始遍历，直到找到 key 对应的元素或者找到已过期的元素 // 2.1 如果找到 key 对应的元素，直接设置值，然后返回 // 2.2 如果找到过期的元素，将过期的元素删除，并将过期元素所在的位置设置成 set 方法传递过来的 key 和 value private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { Entry[] tab = tableh; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)] ) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } // 如果 key 为 null，说明元素已经过期，进行删除 if (k == null) { // 替换过期的元素 replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } private void remove(ThreadLocal\u0026lt;?\u0026gt; key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); // 删除元素的同时，清理过期元素 expungeStaleEntry(i); return; } } } // key 对应的元素存在且没有发生 hash 冲突，直接返回对应的值 // key 不存在或者是发生了 hash 冲突，遍历数组并清理过期元素 private Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal\u0026lt;?\u0026gt; key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } // staleSlot 表示过期插槽的位置 private void replaceStaleEntry(ThreadLocal\u0026lt;?\u0026gt; key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // slotToExpunge 变量用于存储第一个需要被清除的元素 int slotToExpunge = staleSlot; // 1. 向前遍历，直到找到第一个没有存放 Entry 的 slot // 2. 记录在遍历过程中最后遇到的应该清除的 slot 的位置 for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); // 向后遍历时，如果发现 key 对应的 entry 存在，重新设置 value 的值 // 并将 key 对应的 entry 迁移到方法参数传递过来的已过期元素的位置上 if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // 当前循环的作用是在数组 staleSlot 位置向后查找过期的元素， // slotToExpunge 变量的定义是：第一个需要被清除的元素 // slotToExpunge == staleSlot 表明数组 staleSlot 位置前面没有过期的元素 // 前面没有需要被清除的元素，第 i 个位置就是第一个需要被清除的元素，因此需要将 i 的值赋值给 slotToExpunge if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // k == null 表示第 i 个元素需要被清除 // slotToExpunge == staleSlot 表示 staleSlot 前面没有需要被清除的元素，第 i 个位置就是第一个需要被清除的元素 // 因此需要将 i 的值赋值给 slotToExpunge if (k == null \u0026amp;\u0026amp; slotToExpunge == staleSlot) slotToExpunge = i; } // key 在数组中不存在，则把 key, value 放在 staleSlot 位置上 tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 如果有过期的元素，则清除过期元素 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); } // 清除元素，并重新计算未过期元素在数组中的位置 // 返回从 staleSlot 向后遍历遇到的第一个没有存放元素的位置 private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; // 边遍历边删除元素，同时对元素重新 hash，迁移元素到新的位置上 for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; } // 时间复杂度 N * logN // 返回是否清除过元素 private boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null \u0026amp;\u0026amp; e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); // 时间复杂度 N } } while ( (n \u0026gt;\u0026gt;\u0026gt;= 1) != 0); // 时间复杂度 logN return removed; } ","date":"2025-07-05T11:00:28+08:00","permalink":"https://www.autmaple.com/post/why-threadlocal-may-lead-memory-leak/","title":"为什么 ThreadLocal 可能会导致内存泄漏？"},{"content":"为什么需要 ThreadLocal 线程是最小的执行单元，多线程可以在同一时间同时执行相同的代码。如果多个线程在同一个对象或者是实例上执行，那么这些线程将共享对象或实例中的属性。同时每个线程都有它们自己的局部变量，但是这些局部变量如果不通过参数的方式传递，很难进行共享。\n通过例子来解释是最好。假设有一个 Servlet，这个 Servlet 首先会获取用户的信息，然后执行一些其他的动作。\ndoGet(HttpServletRequest req, HttpServletReresponse resp) { User user = getLoggedInUser(req); doSomething(); doSomethingElse(); renderResponse(resp); } 如果现在 doSomething() 和 doSomethingElse() 也需要用户信息，这个时候你该怎么办呢？你不能够把 user 变成一个实例变量或者是静态变量，因为其他的线程也会执行相同的代码, 可能出现用户信息被替换的情况。这时你可能会把用户信息作为一个参数传递给 doSomething() 和 doSomethingElse():\ndoGet(HttpServletRequest req, HttpServletReresponse resp) { User user = getLoggedInUser(req); doSomething(user); doSomethingElse(user); renderResponse(resp); } 这种方式虽然临时解决了问题，但是这会让每个需要用户信息的方法都新增用户参数，并且随着时间的推移，如果又需要一些其他的信息呢？又增加参数吗？肯定不能够这么做，这大大增加了代码的维护难度，非常的不优雅。\n更为优雅的处理方式是将用户相关的信息放入到 ThreadLocal 中:\nStaticClass.java\nclass StaticClass { static private ThreadLocal\u0026lt;User\u0026gt; threadLocal = new ThreadLocal\u0026lt;\u0026gt;(); static ThreadLocal\u0026lt;User\u0026gt; getThreadLocal() { return threadLocal; } } doGet(HttpServletRequest req, HttpServletResponse resp) { User user = getLoggedInUser(req); StaticClass.getThreadLocal().set(user) try { doSomething() doSomethingElse() renderResponse(resp) } finally { StaticClass.getThreadLocal().remove() } } 然后在需要用户信息的地方在将其取出来：\nUser user = StaticClass.getThreadLocal().get() 什么是 ThreadLocal? ThreadLocal 是 Java 提供的一个类，用于创建和管理线程的局部变量。每个线程都可以独立的访问和修改线程中的变量，而不影响其他的线程。ThreadLocal 的主要作用就是在多线程的环境下保存每个线程独有的数据，线程间互不影响，从而避免锁机制所带来的复杂性和额外的性能开销。\nThreadLocal 类似于一个中间件，它把对线程局部变量的操作进行了封装，通过 ThreadLocal 我们可以非常方便和优雅的访问和管理同一个线程中的局部变量。\nThreadLocal 的使用 有两种方式初始化 ThreadLocal：\n// 1. 如果你希望局部变量有一个默认的初始值，可以使用这种方式 ThreadLocal\u0026lt;Integer\u0026gt; threadId = ThreadLocal.withInitial(() -\u0026gt; 2); // 2. 没有默认值，直接 new 一个对象 ThreadLocal\u0026lt;integer\u0026gt; threadId = new ThreadLocal\u0026lt;\u0026gt;(); 使用示例：\npublic class UserContext { private static ThreadLocal\u0026lt;User\u0026gt; userThreadLocal = new ThreadLocal\u0026lt;\u0026gt;(); public static User getCurrentUser() { return userThreadLocal.get(); } public static void setCurrentUser(User user) { userThreadLocal.set(user); } public static void clear() { userThreadLocal.remove(); } } ThreadLocal 实现原理 前置代码 Thread.java\npublic class Thread implements Runnable { // ...... /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null; /* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. */ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; // ...... } ThreadLocal.java\npublic class ThreadLocal\u0026lt;T\u0026gt; { // ...... ThreadLocalMap getMap(Thread t) { return t.threadLocals; } protected T initialValue() { return null; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } private T setInitialValue() { T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { map.set(this, value); } else { createMap(t, value); } if (this instanceof TerminatingThreadLocal) { TerminatingThreadLocal.register((TerminatingThreadLocal\u0026lt;?\u0026gt;) this); } return value; } // ...... } 设置元素的流程 public class ThreadLocal\u0026lt;T\u0026gt; { // ..... public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { // key 是 ThreadLocal map.set(this, value); } else { createMap(t, value); } } // ..... } 获取元素的流程 public class ThreadLocal\u0026lt;T\u0026gt; { // ...... public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { // key 是 ThreadLocal ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings(\u0026#34;unchecked\u0026#34;) T result = (T)e.value; return result; } } return setInitialValue(); } // ...... } 删除元素 public class ThreadLocal\u0026lt;T\u0026gt; { // ...... public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) { m.remove(this); } } // ...... } 通过上述源码阅读可以知道，ThreadLocal 的关键就是 Thread 类里面的 threadLocals 属性。通过 ThreadLocal 声明的线程局部属性都会保存在 Thread 类的threadLocals 属性中，key 为 ThreadLocal，value 为用户设定的值。\nInheritableThreadLocal InheritableThreadLocal 对 ThreadLocal 进行了扩展，允许在创建子线程的时候将父线程中已存在的线程局部变量继承到子线程中。继承后，父线程和子线程对局部变量的修改是独立的，互不影响。需要注意的是，子线程创建后，父线程中对局部变量的修改，子线程是无法感知的。这点需要注意，接下来是使用示例：\npublic class InheritableThreadLocalDemo { private static InheritableThreadLocal\u0026lt;String\u0026gt; threadLocal = new InheritableThreadLocal\u0026lt;\u0026gt;(); public static void main(String[] args) { threadLocal.set(\u0026#34;mainThread\u0026#34;); System.out.println(\u0026#34;value:\u0026#34;+threadLocal.get()); Thread thread = new Thread(new Runnable() { @Override public void run() { String value = threadLocal.get(); System.out.println(\u0026#34;value:\u0026#34;+value); } }); thread.start(); } } 执行结果：\nvalue:mainThread value:mainThread 如果将上述代码中 InheritableThreadLocal 改成 ThreadLocal, 则子线程中输出的值为 null\npublic class InheritableThreadLocalDemo { private static ThreadLocal\u0026lt;String\u0026gt; threadLocal = new ThreadLocal\u0026lt;\u0026gt;(); public static void main(String[] args) { threadLocal.set(\u0026#34;mainThread\u0026#34;); System.out.println(\u0026#34;value:\u0026#34;+threadLocal.get()); Thread thread = new Thread(new Runnable() { @Override public void run() { String value = threadLocal.get(); System.out.println(\u0026#34;value:\u0026#34;+value); } }); thread.start(); } } 执行结果：\nvalue:mainThread value:null 潜在的问题 ThreadLocal 虽然带来了很多的便利，但是使用不当的话，会导致内存泄漏，尤其是在 Thread 生命周期特别长情况下，比如线程池中的线程。如果当前线程不需要 ThreadLocal 后，应该使用 ThreadLocal 的 remove() 方法手动删除 ThreadLocal 存储的数据。\n在哪些场景下使用 非线程安全的对象：Java 中的很多类都是非线程安全的，例如 SimpleDateFormat，NumberFormat. 通过 ThreadLocal 来使用这些类，可以让每个线程都拥有它们自己的实例，从而避免并发相关的问题。 线程特定上下文数据：需要在应用程序的不同层(方法)中访问的数据可以使用 ThreadLocal，比如用户信息，国际化设置或者是其他的上下文信息。这样可以避免上下文数据通过方法参数的方式到处传递，提升代码的可维护性。 ","date":"2025-07-03T15:24:26+08:00","permalink":"https://www.autmaple.com/post/a-detailed-breakdown-of-threadlocal/","title":"ThreadLocal 的详细介绍"},{"content":"引言 在 Web2.0 时代，用户越来越期望 Web 应用的实时交互功能，而传统的 Web 技术实现起来会比较繁琐且浪费资源。因此在 2011 年的时候，IETF 发布了 WebScoket 协议标准，该协议允许客户端和服务端实时交互和双向沟通。\n这篇博客将介绍什么是 WebSocket，WebSocket 是如何工作的。\n背景 传统的 Web 应用程序依赖于 HTTP 的请求响应模型：客户端发送一次 HTTP 请求，然后等待服务器的响应。这种方式在大多数的场景下都没有问题，但是对于实时性要求比较高的场景，比如实时更新(比赛分数)，直播弹幕，在线聊天，请求响应模型只能够实现一个伪实时系统，且非常的浪费资源。为什么这么说呢？\n请求响应模型要实时获取服务器中最新的消息，只能够通过长轮询(long polling)的方式来实现，但是长轮询的方式非常的消耗资源，多次 HTTP 请求，只有一次请求是有效的， 其他的请求都是无效的， 这种方式既消耗资源，效率又不高。同时请求响应模型实现起来需要在实时性和浪费资源这两个方面进行取舍。要想实时性高，就得加快轮询的频率，这也就意味着无效请求大量增加；要想节省资源，就得降低轮询频率，这就导致实时性变低。\n而 WebSocket 就非常好的解决了请求响应模型的问题。\n什么是 WebSocket WebSocket 是建立在 TCP 长连接上的一个全双工应用层协议:\nTCP 长连接：意味着免去了请求响应模型中重复打开和关闭 TCP 连接的开销。 全双工：意味着通信的双方可以同时发送和接受信息，互不干扰。 WebSocket 的工作原理 客户端通过 HTTP 向服务器发送将协议切换成 WebSocket 的握手请求。如果服务器支持 WebSocket 协议，就会在同一个 TCP 上将 HTTP 协议切换成 WebSocket 协议。升级的过程如下：\n客户端向服务器发送带有指定请求头的 GET 请求，请求示例： GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: http://example.com 比较重要的请求头：\nUpgrade: websocket: 表示客户端想要升级成 WebSocket 协议 Connection: Upgrade: 表示该请求是一个想要升级连接的请求 Sec-WebSocket-Key: 客户端生成的 base64 编码随机值，用于服务器生成响应和验证握手 Sec-WebSocket-Version: 客户端所支持的 WebSocket 协议版本，通常是 13。如果服务端不支持该版本，需要返回一个Sec-WebSocket-Versionheader，用于告知客户端服务端支持的 WeSocket 版本。 Origin: 告诉服务器请求来源于哪个网站，非必须字段，基于安全方面的考虑，强烈推荐带上该请求头。服务端可通过白名单机制来决定是否要应答此次握手。 服务端验证以及接受升级 如果服务器接受升级，服务器需要将响应码设置成 101，并返回指定的一些字段：\nHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= 比较重要的字段：\n101 Switching Protocols: 表示同意切换协议 Upgrade: websocket: 确认切换的协议为：websocket Connection: Upgrade: 确认升级连接 Sec-WebSocket-Accept: 该字段的值由服务器基于请求的 Sec-WebSocket-Key 字段生成。服务端将 Sec-WebSocket-Key 字段的值与一个 GUID 进行拼接，并使用 SHA-1 算法进行 hash 计算，最后将 hash 值使用 Base64 算法进行编码 TCP 连接保持打开 WebSocket 的 URL 以 ws:// 或 wss:// 开头，其他的都与 http:// 和 https:// 类似。\nws:// 明文进行传输 was:// 基于 TSL/SSL 进行密文传输 WebSocket 使用场景 实时聊天应用 在线游戏 多人协同应用 金融交易平台 \u0026hellip;\u0026hellip; 搭建 WebSocket 服务器 引入依赖 在 pom.xml 文件中新增 websocket 的依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 具体代码 WebSocketConfig.java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } WebSocketServerConfig.java import com.alibaba.nacos.common.utils.CollectionUtils; import org.springframework.stereotype.Component; import javax.websocket.HandshakeResponse; import javax.websocket.server.HandshakeRequest; import javax.websocket.server.ServerEndpointConfig; import java.util.List; import java.util.Map; @Component public class WebSocketServerConfig extends ServerEndpointConfig.Configurator { @Override public boolean checkOrigin(String originHeaderValue) { return true; } @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { Map\u0026lt;String, List\u0026lt;String\u0026gt;\u0026gt; parameterMap = request.getParameterMap(); List\u0026lt;String\u0026gt; erpList = parameterMap.get(\u0026#34;erp\u0026#34;); if(!CollectionUtils.isEmpty(erpList)){ sec.getUserProperties().put(\u0026#34;erp\u0026#34;, erpList.get(0)); } } } ChickenSocket.java import com.autmaple.circle.server.config.websocket.WebSocketServerConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.websocket.EndpointConfig; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; @Slf4j @ServerEndpoint(value = \u0026#34;/chicken/socket\u0026#34;, configurator = WebSocketServerConfig.class) @Component public class ChickenSocket { /** * 记录当前在线连接数 */ private static final AtomicInteger onlineCount = new AtomicInteger(0); /** * 存放所有在线的客户端 */ private static final Map\u0026lt;String, ChickenSocket\u0026gt; clients = new ConcurrentHashMap\u0026lt;\u0026gt;(); /** * 与某个客户端的连接会话，需要通过它来给客户端发送数据 */ private Session session; /** * erp唯一标识 */ private String erp = \u0026#34;\u0026#34;; public Session getSession() { return session; } /** * 连接建立成功调用的方法 */ @OnOpen public void onOpen(Session session, EndpointConfig conf) throws IOException { //获取用户信息 try { Map\u0026lt;String, Object\u0026gt; userProperties = conf.getUserProperties(); String erp = (String) userProperties.get(\u0026#34;erp\u0026#34;); this.erp = erp; this.session = session; if (clients.containsKey(this.erp)) { clients.get(this.erp).session.close(); clients.remove(this.erp); onlineCount.decrementAndGet(); } clients.put(this.erp, this); onlineCount.incrementAndGet(); log.info(\u0026#34;有新连接加入：{}，当前在线人数为：{}\u0026#34;, erp, onlineCount.get()); sendMessage(\u0026#34;连接成功\u0026#34;, this.session); } catch (Exception e) { log.error(\u0026#34;建立链接错误{}\u0026#34;, e.getMessage(), e); } } /** * 连接关闭调用的方法 */ @OnClose public void onClose() { try { if (clients.containsKey(erp)) { clients.get(erp).session.close(); clients.remove(erp); onlineCount.decrementAndGet(); } log.info(\u0026#34;有一连接关闭：{}，当前在线人数为：{}\u0026#34;, this.erp, onlineCount.get()); } catch (Exception e) { log.error(\u0026#34;连接关闭错误，错误原因{}\u0026#34;, e.getMessage(), e); } } /** * 收到客户端消息后调用的方法 */ @OnMessage public void onMessage(String message, Session session) { log.info(\u0026#34;服务端收到客户端[{}]的消息:{}\u0026#34;, this.erp, message); //心跳机制 if (message.equals(\u0026#34;ping\u0026#34;)) { this.sendMessage(\u0026#34;pong\u0026#34;, session); } } @OnError public void onError(Session session, Throwable error) { log.error(\u0026#34;Socket:{},发生错误,错误原因{}\u0026#34;, erp, error.getMessage(), error); try { session.close(); } catch (Exception e) { log.error(\u0026#34;onError.Exception{}\u0026#34;, e.getMessage(), e); } } /** * 指定发送消息 */ public void sendMessage(String message, Session session) { log.info(\u0026#34;服务端给客户端[{}]发送消息{}\u0026#34;, this.erp, message); try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error(\u0026#34;{}发送消息发生异常，异常原因{}\u0026#34;, this.erp, message); } } /** * 群发消息 */ public void sendMessage(String message) { for (Map.Entry\u0026lt;String, ChickenSocket\u0026gt; sessionEntry : clients.entrySet()) { String erp = sessionEntry.getKey(); ChickenSocket socket = sessionEntry.getValue(); Session session = socket.session; log.info(\u0026#34;服务端给客户端[{}]发送消息{}\u0026#34;, erp, message); try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error(\u0026#34;{}发送消息发生异常，异常原因{}\u0026#34;, this.erp, message); } } } public ChickenSocket getChickenSocket(String userName) { return clients.get(userName); } } 客户端的使用 var ws = new WebSocket(\u0026#34;ws://docker.local.com/chicken/socket\u0026#34;); ws.onopen = function(event) { console.log(\u0026#34;Connection open ...\u0026#34;); ws.send(\u0026#34;Hello WebSockets!\u0026#34;); }; ws.onmessage = function(event) { console.log( \u0026#34;Received Message: \u0026#34; + event.data); ws.close(); }; ws.onclose = function(event) { console.log(\u0026#34;Connection closed.\u0026#34;); }; ","date":"2025-07-02T14:37:03+08:00","permalink":"https://www.autmaple.com/post/websocket-tutorial/","title":"Websocket 教程"},{"content":"问题描述 使用 SSH 连接远程服务器，长时间没有输入任何命令后，客户端输入命令经常出现无响应、假死的现象。\n原因分析 连接链路上的网络设备为了节省网络资源，主动断开了 TCP 连接。具体来说，网络设备维护了一张 连接状态表, 为了避免 连接状态表 存放大量无效的连接，设置了超时机制。如果在规定时间内连接没有传输任何数据包，就认为该连接无效，从而断开对应的连接。 客户端不知道连接已经断开，继续保持 连接中 的状态 解决方案 有两种解决方案：\n修改客户端的 SSH 配置(推荐) 修改服务端的 SSH 配置(需要服务器权限) 修改客户端的 SSH 配置 修改客户端的 SSH 配置无需服务器权限，操作简单，修改完后立即生效，推荐使用。操作如下：\n在 ~/.ssh/config 文件中新增如下的内容:\nHost * # `*` 表示对所有的服务器使用下方的配置 ServerAliveInterval 60 # 每隔 60s 向服务器发送一次心跳包 ServerAliveCountMax 3 # 服务器连续 3 次未响应心跳包则主动断开连接 修改服务器的 SSH 配置 编辑 /etc/ssh/sshd_config 文件，在文件中新增如下的内容：\nClientAliveInterval 60 # 每 60 秒向客户端发送一次心跳包 ClientAliveCountMax 3 # 客户端连续 3 次未响应心跳包则主动断开连接 ","date":"2025-06-26T12:46:39+08:00","permalink":"https://www.autmaple.com/post/ssh-client-freezes-after-a-period-of-time/","title":"SSH客户端在一段时间之后卡死"},{"content":"什么是 Hugo 静态网站和动态网站 静态网站：网站完全由 HTML + CSS + Javascript 构成，不需要后端服务和数据库的参与。\n动态网站：网站的内容需要后端服务进行渲染，页面的内容会根据用户以及存储在数据库中的数据进行动态渲染。\nHugo 介绍 Hugo 是最流行的静态网站生成器(Static Site Generator)之一，使用 Markdown 编辑网站的内容。它有着丰富的生态和主题，用户可以在社区中选择自己喜欢的主题，如果用户对现有主题不满意，允许用户进行自定义修改，得益于 Go 强大的模版功能，用户也可以轻松的从头开发一个属于自己的主题，但大部分人会选择使用现有的主题，并按照需要在已有的主题上进行自定义的修改。\nHugo 配置文件 hugo 的配置文件名为 hugo.toml。hugo 支持将配置全部放入到 hugo.toml 文件中，也支持将配置文件的内容进行拆分到不同的配置文件中。将配置文件的内容进行拆分时，配置文件必须时在 config/_default/ 目录下，并且文件名必须是 root key，下面的两个配置是等价的：\nconfig/_default/hugo.toml\n[params] foo = \u0026#39;bar\u0026#39; config/_default/params.toml\nfoo = \u0026#39;bar\u0026#39; Hugo 基于环境进行配置 Hugo 支持根据环境对参数配置不同的值，hugo 默认设置了两个环境：\nproduction：使用 hugo 命令时，激活的就是 production 环境。production 环境的配置放在 config/production/ 目录下 development: 使用 hugo server 命令时，激活的就是 development 环境。development 环境的配置放在 config/development/ 目录下 自定义环境：使用 hugo server -e \u0026lt;env_name\u0026gt; 时，激活的就是 env_name 环境。env_name 环境的配置放在 config/\u0026lt;env_name\u0026gt;/ 目录下 Hugo 内容模板 使用 hugo new content \u0026lt;archetype_name\u0026gt;/\u0026lt;page_name\u0026gt; 命令创建一篇文章时，内容模板查找的顺序如下：\n在项目根目录下的 archetypes 目录下寻找是否存在 archetype_name 的文件夹或者是 archetype_name.md 的文件 没有的话则查找名为 default 的文件夹或者是 default.md 的文件 如果项目的根目录下不存在，则查找配置的主题中的 archetypes 文件夹是否存在 archetype_name 的文件夹或者文件 下面通过实际的例子来说明情况：\n查找文件情况：\nhugo new content post/test.md\n查找 archetype 的顺序如下：\narchetypes/post.md archetypes/default.md 主题下的 archetypes/post.md 主题下的 archetypes/default.md 如果管理页面的方式是 page bundle，新建内容时，查找的就是 archetypes 目录下同名的文件夹, 例如：\nhugo new content post/test\n查找 archetype 的顺序如下：\narchetypes/post/ archetypes/default/ 主题下的 archtypes/post/ 主题下的 archtype/default/ Stack 主题 自定义样式 Stack 主题允许用户自定义修改样式，操作步骤如下：\n在博客界面按下 F12 打开开发者工具 使用开发者工具提供的元素选择器定位到相关的元素 调整相关的样式，调整完后，将选择器及其所属的样式全部放入到 assets/scss/custom.scss 文件中 自定义博客布局 Stack 的布局或者 Icon 等不符合你的要求，这个时候可以借助开发者 + 全局搜索的方式来定位代码的位置，然后进行修改。步骤如下：\n打开开发者工具，使用开发者工具提供的元素选择器定位需要修改的元素 将元素中区分度比较大的标识复制下来，这样能够快速的定位元素在 Stack 源码中所处的文件。比如元素的 ID，元素 class 中区分度比较高的 class，或者整个 class 复制下来等等。 在 Stack 源码中全局搜索复制下来的标识，定位元素在 Stack 源码中的哪个文件中 假设定位到的路径是：layouts/a/b/c/d.html，如果需要修改样式，就需要在博客的目录也存在 layouts/a/b/c/d.html 文件。将 Stack 源码中 d.html 文件的内容复制一份到博客目录的 d.html 文件中，最后修改博客目录中的 d.html 即可。 上图中圈出来的图标我不是非常的喜欢，接下来使用图文的方式展示如何去除掉对应的图标\n使用开发者工具定位到图标所在的元素 选择一个区分度比较高的标识符复制下来，这里我选择 class=\u0026quot;widget-icon\u0026quot; 在 Stack 主题的源码中去搜索 class=\u0026quot;widget-icon\u0026quot; 从上图中可以看到，全局搜索一共找到了四个文件，挨个查看文件的内容可以确定图标的源码在 archives.html 文件。\narchives.html 文件的路径：layouts/partials/widget/archives.html。将该文件的目录结构复制一份到博客所在的目录，并将 archives.html 文件复制到博客中，然后修改博客中 layouts/partials/widget/archives.html 文件:\n查看效果：\n自定义 SVG 图标 在 iconfont 选择自己心仪的图标，选择图标时，一定要手动指定一个颜色 复制 SVG 代码，并保存在一个文件中，比如 github.svg 文件中 将 github.svg 在编辑器中打开，并将 fill 字段的值改成 currentColor 将 github.svg 放入到 assets/icons/ 目录下 将网站托管到 Nginx hugo 编译之后，会在项目的根目录下生成一个 public 目录。public 目录下的文件就是静态页面包含的所有资源，其中的 index.html 是静态页面的入口。\n如果没有服务器，则可以将生成的页面托管到 Github Page, Netlify, Vercel 等服务商中。如果有服务器，则可以将生成的静态页面交给 Nginx 处理。\n把静态页面交给 Nginx 处理，只需要把 public 目录下所有的文件放入到 nginx 所在的服务器上。\n假设将 public 目录下的所有文件都放在了 /var/www/hugo/ 目录下，nginx 配置文件 /etc/nginx/nginx.conf 修改后的内容如下：\nuser www-data; worker_processes auto; pid /run/nginx.pid; error_log /var/log/nginx/error.log; include /etc/nginx/modules-enabled/*.conf; events { worker_connections 768; } http { sendfile on; tcp_nopush on; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE ssl_prefer_server_ciphers on; access_log /var/log/nginx/access.log; gzip on; include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; server { listen 80; # 监听80端口 server_name hugo.local.com; # 替换为你的域名，这是个可选配置项, 配置后就可以使用 http://hugo.local.com 访问 root /var/www/hugo; # 设置网站的根目录为 /var/www/hugo 文件夹 index index.html; # 网站的入口文件 location / { try_files $uri $uri/ =404; # 尝试找到请求的文件 } # 处理静态文件 location ~* \\.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf)$ { expires 30d; # 设置缓存时间 access_log off; # 关闭访问日志 } # 错误页面 error_page 404 /404.html; # 自定义404页面 } } 需要注意的是 /var/www/hugo/ 目录的权限，目录的权限需要与 /etc/nginx/nginx.conf 中的 user 参数一致，比如上述配置文件中的配置为: user www-data，那需要使用如下的命令修改目录权限：\nchown -R www-data:www-data /var/www/hugo ","date":"2025-06-17T15:59:02+08:00","permalink":"https://www.autmaple.com/post/building-blog-with-hugo-and-stack/","title":"使用 Hugo + Stack 搭建个人博客"}]