古人学问无遗力，少壮工夫老始成。 纸上得来终觉浅，绝知此事要躬行。
AI时代的隐形杀手：为什么你的团队代码产出翻倍，但交付速度却在原地踏步？
最近在和几个朋友聊天的时候，发现一个特别有意思的现象。大家都在说AI编程工具有多牛，claude code,kiro,codex等这些工具确实让写代码变得飞快，我自己用下来感觉效率至少提升了一半以上。但是奇怪的事情来了，虽然代码写得快了，但项目交付的速度好像并没有跟着提升多少，有时候甚至感觉更慢了。刚开始我以为是个例，结果跟圈子里其他人一聊，发现这个问题还挺普遍的。后来仔细想想，问题可能出在我们一直忽略的地方——代码审查。生产力悖论：写得快了，为什么交付还是慢？我记得去年刚开始用AI编程工具的时候，那种感觉简直不要太爽。以前写一个功能模块可能要大半天，现在有了AI助手，一个上午就能搞定好几个。当时还挺得意，觉得自己的生产力简直爆表了。但是很快就发现问题了。以前我一天可能提交1-2个PR（Pull Request），现在一天能提交5-6个。看起来很厉害对吧？但是这些PR在那里排队等审查的时间越来越长，有时候一个PR要等好几天才有人看。更要命的是，当你手头有3-4个未审查的PR在那里挂着的时候，脑子里总是惦记着这些事情，没法专心做新的功能。这种感觉就像是你同时在做好几件事，结果哪件事都做不好。我们团队的情况可能还算好的，听说有些团队的PR积压更严重，一个PR从提交到合并要一个星期，这完全失去了敏捷开发的意义。审查工作的性质变了以前代码都是人写的，虽然偶尔有bug，但整体逻辑是连贯的，审查起来相对轻松。现在AI生成的代码就不一样了，表面上看起来没问题，但仔细一看总感觉哪里不对劲。我举个例子，前几天我用AI写了一个数据处理的函数，代码跑起来没问题，测试也通过了。但是我的同事在审查的时候发现，这个函数处理边界情况的方式特别复杂，用了很多防御性编程的技巧，但实际上我们的业务场景根本不需要这么复杂的处理。这就是AI代码的特点：功能上没毛病，但可能过度设计了。审查的时候你不仅要看代码对不对，还要判断这个代码有没有必要，会不会给后续维护带来负担。这比以前单纯检查bug要费脑子多了。还有一个问题是，AI生成的代码风格可能跟团队的编码规范不太一致。虽然大部分静态检查工具能发现明显的问题，但有些细节还是需要人工判断。这就导致审查的工作量实际上是增加的，不是减少的。积压的恶性循环代码审查的延迟不是简单的时间延迟，它会触发一系列连锁反应。当你的PR在队列里等待的时候，你可能已经开始下一个功能的开发了。等审查意见回来的时候，你可能已经忘记当初为什么要这么写了，需要重新熟悉代码。我们团队就遇到过这种情况。有一次我提交了一个重构的PR，因为审查队列比较长，等了一个星期才有人看。这期间我又开发了两个新功能，等审查意见回来的时候，发现新功能和重构的代码有冲突，结果花了比原来更多的时间来解决冲突。更糟糕的是，当你同时维护多个未合并的分支时，心理负担会成倍增加。每次切换分支都需要重新理解上下文，这种上下文切换的成本是非常高的。有研究表明，开发者同时处理多个任务时，光是上下文切换就会消耗20-50%的时间。解决方案不是招更多人很多团队遇到这个问题的第一反应就是招更多的高级开发者来做代码审查，或者要求大家加快审查速度。但这些方法都没有抓住问题的本质。代码审查是一个工作流程，不是一个简单的关卡。我们需要把它当作一个系统来优化，而不是单纯地增加处理能力。首先，不是所有的PR都需要同样深度的审查。比如说，修改文档、调整配置文件、简单的bug修复，这些低风险的改动完全可以走快速通道。我们可以根据改动的类型、涉及的文件、代码复杂度等因素来自动评估风险等级。我们团队现在用的方法是给PR打标签，绿色标签表示低风险可以快速审查，黄色表示中等风险需要仔细看看，红色表示高风险需要多人审查。这样审查者可以根据自己的时间安排来选择处理哪些PR。其次，审查队列的可见性很重要。如果一个人手头有15个PR等着审查，另一个人只有2个，这种不平衡需要及时发现和调整。我们现在用一个简单的看板来跟踪所有人的审查队列，这样就能及时发现瓶颈。AI工具在审查中的应用虽然AI写代码可能会增加审查的复杂性，但AI同样可以帮助我们做审查。现在有很多工具可以自动检查代码风格、安全漏洞、性能问题等机械性的内容，让人类审查者可以专注于架构设计和业务逻辑。我们团队最近在试用一些AI代码审查工具，效果还不错。这些工具可以自动标注出可能有问题的代码段，提供修改建议，甚至可以解释为什么某段代码可能有问题。当然，最终的判断还是要靠人，但这些工具确实能提高审查的效率。另外，保持PR的大小也很重要。一个包含几百行改动的PR和几个只有十几行改动的小PR，审查的难度是完全不同的。小的、专注的PR不仅更容易审查，也不容易在队列里腐烂。工具和流程的结合光有好的流程还不够，还需要合适的工具来支撑。现在市面上有一些专门的代码审查管理工具，可以把所有仓库的PR聚合到一个视图里，让你一眼就能看出哪些PR等待时间过长，哪些人的审查队列过载。但工具只是辅助，关键还是要团队意识到这个问题的存在。很多团队还停留在"写代码慢"的思维模式里，没有意识到瓶颈已经转移到了审查环节。我觉得这个问题会越来越普遍，特别是随着AI编程工具的进一步普及。那些能够率先意识到这个问题并做出调整的团队，肯定会在竞争中占据优势。文化和心态的转变除了流程和工具，团队文化也需要调整。以前大家觉得写代码是最重要的，代码审查只是一个必要的流程。现在需要认识到，在AI时代，代码审查的重要性不是降低了，而是提高了。审查者需要从"找错"的心态转变为"保质量"的心态。不仅要确保代码能正常运行，还要确保代码符合团队的技术标准和长期维护的需要。同时，提交者也需要调整心态。不能因为AI能快速生成代码就降低对代码质量的要求，该写的注释还是要写，该做的测试还是要做。毕竟，审查通过只是第一步，代码最终还是要给人维护的。未来的趋势我觉得这个问题在2024年会变得更加突出。随着AI编程工具的进一步发展，代码生成的速度还会继续提升，但人类审查代码的速度不会有质的飞跃。这个矛盾会越来越明显。可能的解决方向有几个：一是AI审查工具的进步，能够承担更多机械性的审查工作；二是开发流程的进一步优化，比如更细粒度的权限控制、更智能的风险评估等；三是团队协作模式的创新，比如结对编程、实时审查等。但不管技术怎么发展，人的因素还是最重要的。代码审查不仅是技术问题，也是沟通问题。好的代码审查能够促进团队成员之间的知识分享，提高整个团队的技术水平。一些实用的建议基于我们团队这段时间的实践，我总结几个比较实用的建议：建立PR优先级机制，不要让所有PR都在一个队列里排队设置审查时间的SLA，比如低风险PR 24小时内必须处理定期回顾审查队列的状况，及时调整人力分配鼓励小而频繁的提交，避免大块头的PR利用自动化工具处理格式、风格等机械性检查培养团队成员的审查技能，不要只依赖几个资深开发者最重要的是，要把代码审查当作一个持续优化的过程，而不是一个固定不变的流程。随着团队规模、技术栈、业务复杂度的变化，审查流程也需要相应调整。说到底，AI时代的软件开发不是要替代人，而是要让人做更有价值的事情。代码生成可以交给AI，但代码的质量把控、架构设计、业务理解，这些还是需要人来做。我们要做的就是找到人和AI的最佳协作模式，让整个开发流程更加高效。这个话题可能很多团队都还没有深入思考过，但我相信很快就会成为一个普遍性的问题。早做准备总比被动应对要好，你们团队有没有遇到类似的情况？欢迎在评论区分享你们的经验和做法。如果这篇文章对你有帮助，欢迎关注@运维躬行录，我会持续分享更多运维和开发相关的实践经验。也别忘了转发给你的同事朋友们，说不定他们也正在为这个问题头疼呢。公众号：耕云躬行录个人博客：躬行笔记
从0到1掌握Ansible：让自动化运维不再是梦想
最近在公司推进自动化运维的时候，发现很多同事对Ansible还是一知半解，要么就是简单用用，要么就是直接放弃。其实Ansible真的没那么复杂，我用了这么多年，今天就把我的实战经验分享给大家。说实话，刚开始接触Ansible的时候我也是懵的。那时候公司有几十台服务器需要管理，每次部署应用或者更新配置都要一台一台去操作，累死累活不说，还容易出错。后来接触到Ansible，真的是解放了双手。Ansible到底是个什么东西？简单来说，Ansible就是一个自动化工具。你可以把它理解成一个"遥控器"，能够同时控制多台服务器干活。比如你要在100台服务器上安装nginx，传统方式得一台台登录去装，用Ansible的话，写个脚本，一键搞定。我记得第一次用Ansible批量部署应用的时候，看着终端里刷刷刷地执行命令，那种感觉就像开了挂一样。原本需要一整天的工作，10分钟就搞定了。控制节点和受控节点的关系这个概念其实很好理解。控制节点就是你安装Ansible的那台机器，受控节点就是你要管理的那些服务器。我一般会把Ansible装在跳板机上，这样管理起来比较方便。控制节点需要能SSH到所有受控节点，这是最基本的要求。# 在控制节点安装Ansible yum install ansible -y # 或者用pip安装 pip install ansible受控节点其实什么都不用装，只要能SSH连接就行。这也是Ansible的一个优势，不像其他工具还要在目标机器上装agent。清单文件(Inventory)：管理服务器的花名册Inventory文件就像是你的服务器通讯录，记录着所有需要管理的机器信息。最简单的inventory文件长这样：[webservers] 192.168.1.10 192.168.1.11 192.168.1.12 [databases] 192.168.1.20 192.168.1.21但实际生产环境中，我会写得更详细一些：[webservers] web01 ansible_host=192.168.1.10 ansible_user=root ansible_ssh_private_key_file=/root/.ssh/id_rsa web02 ansible_host=192.168.1.11 ansible_user=root ansible_ssh_private_key_file=/root/.ssh/id_rsa [databases] db01 ansible_host=192.168.1.20 ansible_user=mysql ansible_become=yes db02 ansible_host=192.168.1.21 ansible_user=mysql ansible_become=yes [production:children] webservers databases这样写的好处是可以给不同的机器设置不同的连接参数。比如有些机器用密钥登录，有些用密码，有些需要sudo权限等等。模块(Modules)：Ansible的工具箱Ansible有几千个模块，每个模块负责不同的功能。刚开始的时候不用全部掌握，先学会常用的几个就够了。文件操作模块copy模块用来复制文件：- name: 复制配置文件 copy: src: /local/path/nginx.conf dest: /etc/nginx/nginx.conf owner: root group: root mode: '0644'file模块用来管理文件和目录：- name: 创建目录 file: path: /opt/myapp state: directory owner: www group: www mode: '0755'软件包管理yum模块在CentOS/RHEL上安装软件：- name: 安装nginx yum: name: nginx state: presentapt模块在Ubuntu/Debian上用：- name: 安装nginx apt: name: nginx state: present update_cache: yes服务管理systemd模块管理系统服务：- name: 启动nginx服务 systemd: name: nginx state: started enabled: yes我在实际使用中发现，掌握这几个模块就能解决80%的日常运维工作了。任务(Tasks)和剧本(Playbooks)：自动化的剧本Task就是一个具体的操作，比如安装软件、复制文件等。Playbook就是把多个Task组织起来，形成一个完整的自动化流程。我来分享一个实际的例子，部署一个简单的web应用：--- - name: 部署web应用 hosts: webservers become: yes vars: app_name: myapp app_version: 1.2.3 tasks: - name: 安装必要的软件包 yum: name: - nginx - python3 - git state: present - name: 创建应用目录 file: path: "/opt/{{ app_name }}" state: directory owner: nginx group: nginx mode: '0755' - name: 下载应用代码 git: repo: https://github.com/company/myapp.git dest: "/opt/{{ app_name }}" version: "{{ app_version }}" notify: restart nginx - name: 复制nginx配置 template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/myapp.conf notify: restart nginx - name: 启动nginx服务 systemd: name: nginx state: started enabled: yes handlers: - name: restart nginx systemd: name: nginx state: restarted这个playbook做了几件事：安装软件、创建目录、下载代码、配置nginx、启动服务。如果配置文件有变化，还会自动重启nginx。变量和模板：让配置更灵活在实际项目中，不同环境的配置肯定是不一样的。这时候就需要用到变量和模板了。我通常会创建不同环境的变量文件：group_vars/production.yml:db_host: prod-db.company.com db_port: 3306 app_env: production log_level: warngroup_vars/staging.yml:db_host: staging-db.company.com db_port: 3306 app_env: staging log_level: debug然后在模板文件中使用这些变量：templates/app.conf.j2:[database] host = {{ db_host }} port = {{ db_port }} [app] environment = {{ app_env }} log_level = {{ log_level }}这样同一个playbook就能适用于不同的环境了。角色(Roles)：模块化管理的艺术当项目变得复杂的时候，把所有东西都写在一个playbook里就不合适了。这时候就需要用到Role。Role的目录结构是这样的：roles/ nginx/ tasks/main.yml handlers/main.yml templates/ files/ vars/main.yml defaults/main.yml我来展示一个nginx role的例子：roles/nginx/tasks/main.yml:--- - name: 安装nginx yum: name: nginx state: present - name: 复制主配置文件 template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: restart nginx - name: 复制站点配置 template: src: "{{ item }}" dest: "/etc/nginx/conf.d/" with_fileglob: - "../templates/sites/*.j2" notify: restart nginx - name: 启动nginx服务 systemd: name: nginx state: started enabled: yesroles/nginx/handlers/main.yml:--- - name: restart nginx systemd: name: nginx state: restarted使用role的playbook就很简洁了：--- - name: 配置web服务器 hosts: webservers roles: - nginx - php - mysql处理器(Handlers)：响应式的自动化Handler是Ansible里一个很有用的特性。它只有在被notify的时候才会执行，而且每次playbook运行时，同一个handler最多只会执行一次。比如说，你修改了nginx配置文件，肯定要重启nginx服务。但如果你在playbook里修改了多个配置文件，你不希望每修改一个就重启一次，而是希望所有修改完成后再重启一次。这时候handler就派上用场了。tasks: - name: 修改nginx主配置 template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: restart nginx - name: 修改站点配置 template: src: site.conf.j2 dest: /etc/nginx/conf.d/site.conf notify: restart nginx - name: 修改ssl配置 template: src: ssl.conf.j2 dest: /etc/nginx/conf.d/ssl.conf notify: restart nginx handlers: - name: restart nginx systemd: name: nginx state: restarted即使三个task都触发了notify，nginx也只会重启一次。实战技巧和踩坑经验SSH密钥管理刚开始用Ansible的时候，我总是为SSH连接的问题头疼。后来发现最好的方式是用密钥认证，而且要做好密钥的分发。# 生成密钥对 ssh-keygen -t rsa -b 4096 -f ~/.ssh/ansible_rsa # 分发公钥到所有受控节点 ssh-copy-id -i ~/.ssh/ansible_rsa.pub user@target_host在inventory文件中指定私钥：[servers] server1 ansible_host=192.168.1.10 ansible_ssh_private_key_file=~/.ssh/ansible_rsa幂等性的重要性Ansible的一个重要特性就是幂等性，也就是说多次执行同一个操作，结果应该是一样的。比如这样写就不是幂等的：- name: 添加用户到sudoers shell: echo "myuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers每次执行都会添加一行，这显然不对。应该这样写：- name: 添加用户到sudoers lineinfile: path: /etc/sudoers line: "myuser ALL=(ALL) NOPASSWD:ALL" state: present错误处理和调试在实际使用中，playbook不可能一次就写对。我总结了几个调试技巧：使用-v参数增加输出详细程度：ansible-playbook -vvv playbook.yml使用debug模块输出变量值：- name: 显示变量值 debug: var: ansible_facts['os_family']使用ignore_errors忽略某些错误：- name: 可能会失败的任务 command: some_command_that_might_fail ignore_errors: yes使用failed_when自定义失败条件：- name: 检查服务状态 command: systemctl is-active nginx register: nginx_status failed_when: nginx_status.rc != 0 and nginx_status.rc != 3性能优化当管理的机器多了之后，性能就成了问题。我用过的几个优化技巧：调整并发数：# ansible.cfg [defaults] forks = 50开启SSH pipelining：[ssh_connection] pipelining = True使用strategy插件：- name: 快速执行的playbook hosts: all strategy: free tasks: - name: 简单任务 ping:配置文件管理Ansible的配置文件ansible.cfg可以放在几个地方，优先级从高到低是：ANSIBLE_CONFIG环境变量指定的文件当前目录下的ansible.cfg家目录下的.ansible.cfg/etc/ansible/ansible.cfg我一般会在项目目录下放一个ansible.cfg：[defaults] inventory = inventory/hosts host_key_checking = False timeout = 30 forks = 20 remote_user = root [ssh_connection] ssh_args = -o ControlMaster=auto -o ControlPersist=60s pipelining = True实际应用场景我在工作中用Ansible解决了很多实际问题，分享几个典型场景：批量系统初始化新机器上架后需要做一堆初始化工作：创建用户、配置SSH、安装基础软件、设置时区等等。用Ansible写个playbook，新机器几分钟就能初始化完成。应用部署以前部署应用要登录每台服务器，停服务、备份、更新代码、重启服务。现在一个playbook搞定，还能做到零停机部署。配置管理系统配置变更是运维的家常便饭，用Ansible可以确保所有机器的配置一致性，还能追踪变更历史。定期维护比如日志清理、证书更新、安全补丁等定期任务，写成playbook配合cron执行，省心省力。说到这里，我想起前段时间公司要给所有服务器更新SSL证书。如果手动操作，几百台机器得忙好几天。用Ansible写了个playbook，半小时就全部搞定了，而且零出错。当然，Ansible也不是万能的。对于一些复杂的编排场景，可能还需要结合其他工具。但对于大部分运维工作来说，Ansible已经足够强大了。学习Ansible最好的方式就是多动手实践。从简单的任务开始，比如批量执行命令、复制文件等，然后慢慢学习更复杂的特性。记住，任何工具都是为了解决实际问题的，不要为了用而用。希望这篇文章能帮助大家更好地理解和使用Ansible。自动化运维的路还很长，但有了Ansible这个利器，我们的工作会轻松很多。如果你觉得这篇文章有用，记得点赞转发，让更多的同行受益！关注@运维躬行录，一起在自动化运维的道路上越走越远！公众号：耕云躬行录个人博客：躬行笔记
救命！文件系统崩了怎么办？Linux系统文件系统错误检查修复完全指南
前段时间我们生产环境遇到了一个特别头疼的问题，服务器突然断电重启后，发现有个分区挂载不上了，系统报错说文件系统有问题。当时真的是冷汗直冒，要知道那个分区里放着重要的业务数据啊！相信很多做运维的朋友都遇到过类似的情况，服务器异常关机、硬盘故障、甚至是误操作，都可能导致文件系统出现错误。今天我就把这些年积累的文件系统检查和修复经验分享给大家，希望能帮到遇到同样问题的兄弟们。什么是文件系统错误在开始讲具体操作之前，我觉得有必要先说说文件系统错误到底是怎么回事。Linux的文件系统就像是一个巨大的图书馆，每个文件都有自己的位置和索引。当系统正常运行时，所有的读写操作都会按照规定的流程进行，文件系统会维护各种元数据信息，比如inode表、目录结构、空闲块列表等等。但是当系统异常关机或者硬盘出现问题时，这些元数据可能会变得不一致。比如说，一个文件明明已经删除了，但是inode表里还记录着它的存在；或者某个数据块被标记为空闲，但实际上还被某个文件占用着。这些不一致的情况就是我们说的文件系统错误。我记得有一次，一台服务器的UPS电池没电了，突然断电导致ext4文件系统出现了大量的orphaned inodes（孤儿inode）。当时系统启动时就卡在那里不动了，后来才知道是文件系统自检发现了问题。fsck工具详解说到Linux文件系统检查和修复，就不得不提fsck这个神器了。fsck全称是File System Consistency Check，顾名思义就是用来检查文件系统一致性的。fsck其实不是一个单独的程序，而是一个前端工具，它会根据文件系统的类型调用相应的检查程序。比如对于ext4文件系统，它会调用fsck.ext4；对于xfs文件系统，它会调用fsck.xfs。fsck的工作原理fsck的工作过程大概可以分为几个阶段：阶段1：检查inode和块这个阶段主要检查每个inode的基本信息是否正确，包括文件类型、权限、链接数等。同时检查inode指向的数据块是否有效。阶段2：检查目录结构检查目录项是否指向有效的inode，目录的父子关系是否正确。阶段3：检查目录连接性确保每个目录都可以从根目录访问到，没有孤立的目录。阶段4：检查引用计数验证每个inode的链接计数是否与实际的目录项数量匹配。阶段5：检查块和inode映射检查块位图和inode位图的一致性，确保没有重复分配或遗漏。实战操作步骤好了，理论说得差不多了，现在来点实际的。当你遇到文件系统错误时，应该怎么处理呢？第一步：进入单用户模式这一步非常重要！在修复文件系统之前，必须确保文件系统没有被挂载，或者以只读方式挂载。如果文件系统正在被使用，fsck可能会造成数据损坏。如果是根文件系统出问题，你需要从救援模式启动：sudo systemctl isolate rescue.target或者在启动时进入单用户模式，在GRUB菜单中编辑启动参数，添加 single 或 1。如果是其他分区，直接卸载就行：sudo umount /dev/sdXn我之前就吃过这个亏，有一次图省事直接对挂载的文件系统运行fsck，结果把好好的文件系统搞坏了，数据都找不回来了。所以这个步骤千万不能省略！第二步：运行fsck检查卸载文件系统后，就可以开始检查了：sudo fsck /dev/sdXn不过我建议先用只读模式检查一下，看看到底有什么问题：sudo fsck -n /dev/sdXn-n 参数表示只检查不修复，这样可以安全地了解文件系统的状况。如果检查发现问题，fsck会给出详细的报告。比如可能会看到这样的输出：/dev/sdb1: Inode 1234 has illegal block(s). /dev/sdb1: Deleted inode 1234 has non-zero size. /dev/sdb1: UNEXPECTED INCONSISTENCY; RUN fsck MANUALLY.第三步：自动修复如果确认要修复，可以使用 -y 参数让fsck自动修复所有发现的问题：sudo fsck -y /dev/sdXn这个过程可能会比较长，特别是对于大容量的硬盘。我见过最夸张的一次，一个2TB的硬盘跑了整整6个小时才修复完成。第四步：重新挂载修复完成后，就可以重新挂载文件系统了：sudo mount -o remount,rw /dev/sdXn如果之前是单用户模式，现在可以退出到正常的多用户模式：sudo systemctl isolate multi-user.target不同文件系统的特殊处理虽然fsck是通用工具，但不同的文件系统还是有一些特殊的地方需要注意。ext4文件系统ext4是目前Linux最常用的文件系统之一，对于ext4，我们可以使用一些特殊的参数：# 强制检查，即使文件系统看起来是干净的 sudo fsck.ext4 -f /dev/sdXn # 修复坏块 sudo fsck.ext4 -c /dev/sdXn # 详细输出 sudo fsck.ext4 -v /dev/sdXn有一次我遇到一个ext4分区，普通的fsck检查不出问题，但是访问某些文件时总是报I/O错误。后来用 -c 参数检查坏块，果然发现了几个坏扇区。xfs文件系统xfs文件系统的修复工具有点特殊，它使用的是 xfs_repair 而不是 fsck.xfs：# 检查但不修复 sudo xfs_repair -n /dev/sdXn # 修复 sudo xfs_repair /dev/sdXn需要注意的是，xfs文件系统必须完全卸载才能修复，不支持只读挂载状态下的修复。btrfs文件系统btrfs有自己的检查和修复工具：# 检查 sudo btrfs check /dev/sdXn # 修复（比较危险，慎用） sudo btrfs check --repair /dev/sdXn说实话，btrfs的修复功能还不是很成熟，我一般不建议在生产环境使用。高级技巧和注意事项使用SMART监控硬盘健康预防总是比治疗要好。我建议大家都装上smartmontools工具，定期检查硬盘的SMART信息：sudo smartctl -a /dev/sdX通过SMART数据，我们可以提前发现硬盘的健康问题，避免文件系统错误的发生。我之前管理的一台服务器，SMART数据显示有几个重定位扇区，当时没太在意，结果过了两个月硬盘就完全挂了，幸好提前做了备份。日志分析系统日志里通常会记录文件系统相关的错误信息。检查 /var/log/syslog、/var/log/messages 或者使用 dmesg 命令，可以帮助我们了解问题的根本原因。dmesg | grep -i "ext4\|xfs\|btrfs"定期备份的重要性这个不用我多说了吧？再好的修复技术也比不上一个完整的备份。我现在用的备份策略是rsync + tar的组合，重要数据每天备份，系统配置每周备份。有条件的话，建议使用专业的备份工具，比如duplicity、bacula等。云服务的话，各大云厂商都有不错的备份服务。系统恢复工具如果问题比较严重，普通的fsck修复不了，可能需要用到一些专业的数据恢复工具。TestDisk 是一个开源的数据恢复工具，可以恢复丢失的分区和修复引导扇区：sudo testdisk /dev/sdXPhotoRec 可以恢复各种类型的文件，即使文件系统已经严重损坏：sudo photorec /dev/sdX不过这些工具操作起来比较复杂，建议在使用前先仔细阅读文档，最好是在测试环境先练练手。真实案例分享让我分享一个印象特别深刻的案例。有一次突然接到公司的紧急电话，说生产环境的数据库服务器访问不了了。我赶紧远程登录，发现MySQL服务起不来，报错说数据目录不可访问。通过dmesg查看，发现ext4文件系统出现了大量的I/O错误。我的第一反应是硬盘坏了，但是用smartctl检查发现SMART数据正常。后来经过排查，发现是机房的UPS出了问题，导致服务器异常断电。虽然文件系统有journal保护，但还是出现了一些元数据不一致的问题。我先用fsck -n检查了一下，发现有几十个inode的链接计数不正确，还有一些orphaned inodes。然后切换到单用户模式，运行fsck -y修复。整个过程花了大概40分钟，修复完成后数据库正常启动，数据没有丢失。这个事件让我深刻认识到了UPS和定期备份的重要性，后来我们升级了UPS系统，还增加了实时备份。预防措施既然说到了预防，我就多分享几个经验：合理配置文件系统在创建文件系统时，可以调整一些参数来提高稳定性：# 创建ext4时设置更频繁的自检 sudo mkfs.ext4 -c -c /dev/sdXn使用合适的挂载选项在 /etc/fstab 中配置合适的挂载选项：/dev/sdXn /data ext4 defaults,errors=remount-ro 0 1errors=remount-ro 选项会在发现错误时自动以只读方式重新挂载，避免进一步的数据损坏。总结文件系统错误虽然听起来很可怕，但只要掌握了正确的方法，大部分问题都是可以解决的。关键是要保持冷静，按照正确的步骤操作，千万不要病急乱投医。记住几个要点：修复前一定要卸载文件系统先用只读模式检查，了解问题的严重程度重要数据一定要有备份平时要注意监控硬盘健康状态遇到复杂问题不要硬来，寻求专业帮助我这些年处理过各种各样的文件系统问题，从简单的inode错误到复杂的分区表损坏，基本上都能搞定。但是说实话，最好的修复就是不需要修复，做好预防工作才是王道。希望这篇文章能帮到遇到类似问题的朋友们。如果你觉得有用的话，记得点个赞转发一下，让更多的人看到。有什么问题也欢迎在评论区讨论，我会尽力回答的。运维路上我们一起成长，遇到问题不要慌，办法总比困难多！公众号：耕云躬行录个人博客：躬行笔记
Puppet配置管理实战：让1000台服务器听话的秘密武器
今天就来分享一下我这几个月使用Puppet管理多服务器配置的实战经验，包括一些踩过的坑和解决方案。Puppet基础概念快速入门在开始实战之前，先简单说说Puppet的核心概念，这些理解了后面的操作就很容易了。Puppet采用的是Master-Agent架构，Master节点存储配置清单（manifest），Agent节点定期向Master请求配置并应用。整个过程是声明式的，你只需要描述想要的最终状态，Puppet会自动计算如何达到这个状态。几个重要概念：Manifest：用Puppet DSL语言编写的配置文件，描述系统应该是什么样子Module：可复用的配置单元，包含manifest、文件、模板等Node：被管理的服务器节点Catalog：Master为每个节点编译生成的具体配置指令环境搭建实战我的测试环境是3台CentOS 7服务器，1台Master，2台Agent。生产环境类似，只是Agent数量更多。Master节点安装配置# 安装Puppet Server rpm -Uvh https://yum.puppet.com/puppet-release-el-7.noarch.rpm yum install -y puppetserver # 修改内存配置，默认2G太大了 vim /etc/sysconfig/puppetserver JAVA_ARGS="-Xms512m -Xmx512m" # 启动服务 systemctl enable puppetserver systemctl start puppetserver这里有个坑，Puppet Server默认内存配置是2G，小环境根本用不了这么多，而且会导致启动失败。我把它调到512M，测试环境够用了。Agent节点配置# 安装Puppet Agent rpm -Uvh https://yum.puppet.com/puppet-release-el-7.noarch.rpm yum install -y puppet-agent # 配置Master地址 vim /etc/puppetlabs/puppet/puppet.conf [main] server = puppet-master.example.com # 启动服务 systemctl enable puppet systemctl start puppetAgent第一次连接Master时需要证书认证，在Master上执行：/opt/puppetlabs/bin/puppetserver ca list /opt/puppetlabs/bin/puppetserver ca sign --all编写第一个配置管理模块我们从一个简单的nginx模块开始。在生产环境中，nginx的配置管理是个头疼的问题，不同环境的配置文件经常不一致。创建nginx模块结构cd /etc/puppetlabs/code/environments/production/modules mkdir -p nginx/{manifests,files,templates,tests}编写主配置文件# nginx/manifests/init.pp class nginx { package { 'nginx': ensure => installed, } service { 'nginx': ensure => running, enable => true, require => Package['nginx'], } file { '/etc/nginx/nginx.conf': ensure => present, source => 'puppet:///modules/nginx/nginx.conf', owner => 'root', group => 'root', mode => '0644', notify => Service['nginx'], require => Package['nginx'], } }这个配置很简单，确保nginx包安装、服务运行、配置文件正确。notify参数很重要，当配置文件变化时会自动重启服务。准备配置文件模板# 将标准的nginx配置放到files目录 cp /etc/nginx/nginx.conf nginx/files/应用到节点# site.pp node 'web-server-01.example.com' { include nginx } node 'web-server-02.example.com' { include nginx }这样两台web服务器就会自动安装和配置nginx了。每次Agent运行时（默认30分钟），都会检查配置是否符合要求，不符合就自动修复。高级配置管理技巧使用变量和条件判断实际环境中，不同服务器的配置往往有差异。比如内存大小不同，nginx worker进程数就应该不同：class nginx { $worker_processes = $facts['processors']['count'] package { 'nginx': ensure => installed, } file { '/etc/nginx/nginx.conf': ensure => present, content => template('nginx/nginx.conf.erb'), owner => 'root', group => 'root', mode => '0644', notify => Service['nginx'], require => Package['nginx'], } service { 'nginx': ensure => running, enable => true, require => [Package['nginx'], File['/etc/nginx/nginx.conf']], } }对应的ERB模板文件：# nginx/templates/nginx.conf.erb worker_processes <%= @worker_processes %>; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; <% if @facts['memorysize_mb'].to_i > 4096 %> # 大内存服务器使用更多连接 worker_connections 2048; <% end %> }参数化模块为了让模块更灵活，可以使用参数：class nginx ( String $version = 'installed', Integer $worker_processes = $facts['processors']['count'], Boolean $enable_ssl = false, ) { package { 'nginx': ensure => $version, } if $enable_ssl { package { 'nginx-mod-ssl': ensure => installed, } } # 其他配置... }在site.pp中这样使用：node 'web-server-01.example.com' { class { 'nginx': worker_processes => 8, enable_ssl => true, } }使用Hiera进行数据分离Hiera是Puppet的数据查找系统，可以把配置数据从代码中分离出来。这在管理多环境时特别有用。# /etc/puppetlabs/code/environments/production/data/common.yaml nginx::worker_processes: 4 nginx::enable_ssl: false # /etc/puppetlabs/code/environments/production/data/nodes/web-server-01.yaml nginx::worker_processes: 8 nginx::enable_ssl: true模块中使用lookup函数获取数据：class nginx { $worker_processes = lookup('nginx::worker_processes', Integer, 'first', $facts['processors']['count']) $enable_ssl = lookup('nginx::enable_ssl', Boolean, 'first', false) # 配置逻辑... }管理复杂应用配置单个服务的配置相对简单，但实际生产环境往往需要管理整个应用栈。我们来看一个更复杂的例子：LAMP环境的配置。创建profile模块# profiles/manifests/lamp.pp class profiles::lamp { include apache include mysql include php # 确保安装顺序 Class['mysql'] -> Class['apache'] -> Class['php'] }Apache模块class apache ( String $document_root = '/var/www/html', Array[String] $modules = ['rewrite', 'ssl'], ) { package { 'httpd': ensure => installed, } service { 'httpd': ensure => running, enable => true, } file { '/etc/httpd/conf/httpd.conf': ensure => present, content => template('apache/httpd.conf.erb'), notify => Service['httpd'], require => Package['httpd'], } $modules.each |$module| { exec { "enable-${module}-module": command => "/usr/bin/a2enmod ${module}", unless => "/usr/bin/apache2ctl -M | grep ${module}", notify => Service['httpd'], require => Package['httpd'], } } }使用角色分离在大型环境中，不同服务器承担不同角色。可以创建role模块：# roles/manifests/webserver.pp class roles::webserver { include profiles::lamp include profiles::monitoring include profiles::backup } # roles/manifests/database.pp class roles::database { include profiles::mysql include profiles::monitoring include profiles::backup }然后在site.pp中按角色分配：node /^web-\d+\.example\.com$/ { include roles::webserver } node /^db-\d+\.example\.com$/ { include roles::database }配置文件管理最佳实践使用文件片段拼接有时候配置文件需要从多个模块贡献内容，比如nginx的虚拟主机配置：# 主配置 concat { '/etc/nginx/nginx.conf': owner => 'root', group => 'root', mode => '0644', notify => Service['nginx'], } concat::fragment { 'nginx-main': target => '/etc/nginx/nginx.conf', content => template('nginx/nginx-main.conf.erb'), order => '01', } # 虚拟主机配置 define nginx::vhost ( String $document_root, String $server_name = $title, ) { concat::fragment { "nginx-vhost-${title}": target => '/etc/nginx/nginx.conf', content => template('nginx/vhost.conf.erb'), order => '10', } }敏感数据处理生产环境中经常需要处理密码等敏感信息。Puppet支持加密的eyaml：# 安装hiera-eyaml /opt/puppetlabs/puppet/bin/gem install hiera-eyaml创建加密的配置：eyaml encrypt -s 'mysecretpassword'在Hiera中使用：mysql::root_password: > ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAE...]大规模部署策略管理几百台服务器时，需要考虑部署策略，避免同时更新所有服务器导致服务中断。使用环境分离# 创建不同环境 mkdir -p /etc/puppetlabs/code/environments/{development,staging,production}Agent可以指定环境：# puppet.conf [agent] environment = staging分批次部署可以使用Puppet的runinterval和splay参数控制Agent运行时间：[agent] runinterval = 1800 # 30分钟运行一次 splay = true # 随机延迟，避免同时运行也可以手动控制部署：# 禁用自动运行 puppet agent --disable "maintenance window" # 手动触发特定节点 mco puppet runonce -I web-server-01配置验证部署前最好验证配置语法：# 验证语法 puppet parser validate manifests/init.pp # 模拟运行 puppet agent --test --noop监控和故障排除生产环境中，监控Puppet运行状态很重要。我们可以从多个维度监控：日志分析Puppet的日志很详细，但需要重点关注几个方面：# Agent日志 tail -f /var/log/puppetlabs/puppet/puppet.log # Master日志 tail -f /var/log/puppetlabs/puppetserver/puppetserver.log关键指标包括：配置编译时间资源应用成功率证书问题依赖关系错误使用PuppetDBPuppetDB可以存储所有节点的状态信息，方便查询和监控：# 安装PuppetDB yum install -y puppetdb puppetdb-termini # 配置Master连接PuppetDB vim /etc/puppetlabs/puppet/puppetdb.conf [main] server_urls = https://puppetdb.example.com:8081查询节点状态：# 查看失败的节点 curl -X GET http://puppetdb:8080/pdb/query/v4/reports \ --data-urlencode 'query=["=", "status", "failed"]'常见问题排查我在使用过程中遇到过几个典型问题：证书过期：Puppet证书默认5年有效期，过期后Agent无法连接Master。需要重新签发证书。依赖关系循环：资源之间的依赖关系形成循环，导致配置无法应用。可以通过puppet agent --graph生成依赖图分析。文件权限问题：特别是SELinux环境下，需要注意文件的安全上下文。内存不足：Master编译大量配置时可能内存不足，需要调整JVM参数。与其他工具集成Puppet可以和很多运维工具集成，形成完整的自动化体系。与Git集成使用r10k或Code Manager可以实现Git驱动的部署：# Puppetfile mod 'puppetlabs-stdlib' mod 'puppetlabs-concat' mod 'nginx', :git => 'https://github.com/company/puppet-nginx.git', :branch => 'production'与监控系统集成可以在Puppet配置中自动部署监控：class profiles::monitoring { package { 'zabbix-agent': ensure => installed, } file { '/etc/zabbix/zabbix_agentd.conf': ensure => present, content => template('monitoring/zabbix_agentd.conf.erb'), notify => Service['zabbix-agent'], } service { 'zabbix-agent': ensure => running, enable => true, } }性能优化经验管理大量服务器时，性能优化很重要。我总结了几个优化点：Master端优化# /etc/puppetlabs/puppetserver/conf.d/puppetserver.conf jruby-puppet: { max-active-instances: 4 max-requests-per-instance: 100000 }Agent端优化[agent] usecacheonfailure = false report = false pluginsync = false对于只读节点，可以禁用一些不必要的功能。网络优化使用本地Package仓库可以大幅提升安装速度：yumrepo { 'local-repo': baseurl => 'http://repo.internal.com/centos/7/', enabled => 1, gpgcheck => 0, }经过几个月的实践，我们的服务器配置管理效率提升了至少10倍，配置漂移问题基本消失。虽然初期学习成本比较高，但是长期收益非常明显。特别是在云环境下，服务器经常弹性伸缩，Puppet的自动化配置管理能力显得更加重要。新服务器启动后几分钟就能自动完成所有配置，大大提升了运维效率。当然Puppet也不是万能的，对于一些复杂的业务逻辑，还是需要结合其他工具。但作为基础设施配置管理，Puppet确实是个不错的选择。如果你也在管理大量服务器，强烈建议试试Puppet。虽然学习曲线比较陡峭，但掌握后真的会让你的运维工作轻松很多。记住，好的工具能让运维工作事半功倍，而不是增加复杂度。希望这篇文章对正在做配置管理的朋友有所帮助。如果你在使用Puppet过程中遇到问题，欢迎留言讨论。觉得有用的话，别忘了点赞转发哦~关注@运维躬行录，获取更多实战干货！公众号：耕云躬行录个人博客：躬行笔记
互联网的幕后英雄：深度解析BGP协议，看看网络是如何连接全世界的
前几天在公司部署多线BGP的时候，突然想起刚入行那会儿，对BGP这个协议完全是一头雾水。当时配置BGP，我就像个无头苍蝇一样到处查资料，结果越看越懵。现在回想起来，其实BGP这个东西说复杂也复杂，说简单也简单，关键是要理解它到底在干什么。今天就和大家聊聊BGP这个协议，它可是互联网能够正常运转的核心技术之一。不夸张地说，没有BGP，我们现在刷抖音、看视频、逛淘宝这些日常操作都会变得困难重重。BGP到底是个什么东西？BGP全称是Border Gateway Protocol，中文叫边界网关协议。听起来很高大上，但其实你可以把它理解成互联网上的"导航系统"。想象一下，你要从北京开车去上海，你需要知道走哪条路对吧？可能有好几条路线可以选择：走京沪高速、走国道、或者绕道其他城市。BGP就是帮助网络数据包选择最优路径的协议。我记得有一次，我们公司的网络出现了奇怪的问题：访问某个网站特别慢，但是ping延迟看起来还行。后来排查发现，是BGP路由出了问题，数据包绕了一大圈才到达目标服务器。这就像你本来可以直接从北京到上海，结果导航让你先去了趟新疆，然后再回到上海，能不慢吗？BGP是一个路径向量协议，它不像OSPF或者RIP那样只关心距离，而是关心整个路径。每个BGP路由器都会维护一张路由表，记录着到达各个网络的最佳路径。BGP的工作原理BGP的工作机制其实挺有意思的。它基于TCP连接，端口号是179。BGP路由器之间会建立邻居关系，然后互相交换路由信息。AS（自治系统）的概念在讲BGP之前，必须先理解AS的概念。AS就是Autonomous System，自治系统。你可以把它理解成互联网上的一个"国家"或者"省份"。每个AS都有自己的编号，比如中国电信的AS号是4134，中国联通是4837。我之前在一家IDC公司工作的时候，我们申请了自己的AS号。申请AS号的过程还挺复杂的，需要证明你有足够的网络资源和技术能力来管理一个自治系统。BGP的分类BGP分为两种：eBGP（External BGP）：不同AS之间的BGPiBGP（Internal BGP）：同一个AS内部的BGPeBGP就像国际贸易，不同国家之间的商品流通；iBGP就像国内贸易，一个国家内部的商品流通。路由选择过程BGP选择路由的时候有一套复杂的算法，主要考虑这些因素：Weight（权重）：这是思科设备特有的属性，数值越大越优先Local Preference（本地优先级）：在AS内部使用，数值越大越优先AS Path长度：经过的AS数量越少越好Origin类型：IGP > EGP > IncompleteMED值：Multi-Exit Discriminator，数值越小越优先我记得有一次配置BGP的时候，就是因为没有正确设置Local Preference，导致流量全部走了备用链路，主链路闲置。当时还被老板批评了一顿，说浪费带宽资源。BGP在互联网中的作用1. 实现全球互联BGP最重要的作用就是让全世界的网络能够互相连通。想想看，全球有几万个AS，如果没有一个统一的协议来协调路由，互联网根本无法正常工作。比如说，当你在中国访问美国的一个网站时，数据包可能需要经过十几个不同的AS。每个AS都需要知道如何将数据包转发到下一跳，这就需要BGP来协调。2. 负载均衡和冗余BGP还能实现负载均衡。比如一个大型网站可能在多个地方都有服务器，BGP可以根据网络状况将用户的请求分配到最合适的服务器上。我们公司之前做过一个项目，客户要求在北京、上海、广州三地都部署服务器，然后通过BGP实现智能调度。用户访问的时候，会自动选择最近的服务器，大大提升了用户体验。3. 故障恢复当网络出现故障时，BGP能够快速重新计算路由，选择备用路径。虽然BGP的收敛速度不是最快的（通常需要几分钟），但它的稳定性很好。有一次，某个运营商的主干网络出现故障，我们监控到BGP路由表发生了大量变化。虽然用户感觉到了一些延迟，但网络服务并没有完全中断，这就是BGP的功劳。BGP的实际应用场景多线BGP机房现在很多IDC都提供多线BGP服务。什么意思呢？就是一台服务器可以同时接入电信、联通、移动等多个运营商的网络，用户无论使用哪个运营商的网络，都能获得比较好的访问速度。我记得刚开始接触多线BGP的时候，觉得这个技术特别神奇。一个IP地址，怎么能在不同的运营商网络中都能正常访问呢？后来才明白，这就是BGP路由宣告的作用。配置多线BGP的时候，需要向每个运营商宣告相同的IP段，但是可以通过调整AS Path、MED等属性来控制流量走向。比如：# 向电信宣告路由 router bgp 65001 neighbor 202.96.1.1 remote-as 4134 network 1.2.3.0 mask 255.255.255.0 # 向联通宣告路由 neighbor 221.12.1.1 remote-as 4837 network 1.2.3.0 mask 255.255.255.0CDN网络CDN（内容分发网络）也大量使用BGP技术。CDN服务商会在全国各地部署缓存服务器，然后通过BGP将相同的IP地址在不同地区宣告，实现就近访问。比如说，当你在北京访问某个网站时，DNS解析出来的IP地址可能指向北京的CDN节点；当你在上海访问同一个网站时，相同的IP地址可能指向上海的CDN节点。这就是BGP Anycast技术的应用。云服务提供商现在的云服务提供商，比如阿里云、腾讯云、AWS等，都大量使用BGP技术。他们会申请自己的AS号，然后与各大运营商建立BGP连接，为用户提供高质量的网络服务。BGP配置实战虽然BGP配置比较复杂，但基本的配置还是有套路可循的。我以思科设备为例，给大家展示一下基本的BGP配置：# 启用BGP进程 router bgp 65001 # 配置BGP邻居 neighbor 192.168.1.1 remote-as 65002 neighbor 192.168.1.1 description "To ISP-A" # 宣告本地网络 network 10.1.1.0 mask 255.255.255.0 # 配置路由策略 route-map SET-LP permit 10 set local-preference 200 neighbor 192.168.1.1 route-map SET-LP in配置BGP的时候，有几个坑需要注意：邻居状态：BGP邻居建立需要时间，不要着急。可以用show ip bgp summary命令查看邻居状态。路由宣告：确保你有权限宣告相应的IP段，否则可能被上游过滤。AS Path：注意AS Path的长度，过长的路径可能不会被选择。我记得第一次配置BGP的时候，邻居怎么都建立不起来。折腾了半天才发现，是因为防火墙阻止了TCP 179端口。这种低级错误现在想起来还挺尴尬的。BGP的一些有趣现象BGP劫持BGP劫持是一个比较严重的安全问题。由于BGP协议本身缺乏有效的验证机制，恶意的AS可能会宣告不属于自己的IP段，从而劫持流量。2008年，YouTube就遭遇过一次著名的BGP劫持事件。巴基斯坦的一个ISP错误地宣告了YouTube的IP段，导致全球用户都无法正常访问YouTube。这个事件持续了几个小时才得到解决。路由泄漏路由泄漏也是BGP常见的问题。有时候，某个AS会错误地将从一个邻居学到的路由转发给另一个邻居，导致路由环路或者次优路径。我遇到过一次路由泄漏事件，当时我们公司的网络突然变得很慢。通过traceroute发现，数据包在几个AS之间来回跳转，明显是出现了路由环路。联系上游ISP后，他们很快修复了配置错误。BGP收敛BGP的收敛速度相对较慢，这是它的一个特点。当网络拓扑发生变化时，BGP需要一定时间来重新计算最优路径。这个过程可能需要几分钟甚至更长时间。为了加快BGP收敛，可以调整一些定时器参数，比如：router bgp 65001 timers bgp 10 30但是要注意，过于激进的定时器设置可能会导致网络不稳定。实际运维中的BGP监控在生产环境中，BGP的监控非常重要。我们通常会关注这些指标：邻居状态监控BGP邻居的状态变化是最需要关注的。正常情况下，邻居应该处于Established状态。如果出现Active、Connect等状态，就说明有问题了。我们会用SNMP或者API定期查询BGP邻居状态：# 查看BGP邻居状态 show ip bgp summary # 查看特定邻居的详细信息 show ip bgp neighbors 192.168.1.1路由表监控路由表的变化也需要密切关注。突然增加或减少大量路由，可能意味着网络拓扑发生了重大变化。我们会定期备份BGP路由表，并设置阈值告警。比如路由数量变化超过10%就发送告警。流量监控虽然BGP本身不直接处理流量，但BGP路由的变化会影响流量走向。我们会结合流量监控来判断BGP配置是否合理。有一次，我发现某条链路的流量突然暴增，原来是因为BGP路由变化，导致原本分散的流量都集中到了这条链路上。及时调整BGP策略后，流量分布就恢复正常了。BGP故障排查经验在实际工作中，BGP故障是比较常见的。我总结了一些排查经验：邻居建立不起来这是最常见的问题。可能的原因包括：网络连通性问题AS号配置错误认证配置不匹配防火墙阻止TCP 179端口排查的时候，可以先ping对方IP，再telnet 179端口，逐步缩小问题范围。路由接收不到有时候邻居建立了，但是路由收不到。可能的原因：对方没有宣告路由被路由策略过滤了路由属性不符合选择条件可以用show ip bgp neighbors x.x.x.x received-routes命令查看从邻居接收到的所有路由。路由选择不当BGP选择了不期望的路径，可能是因为路由属性配置不当。这时候需要仔细检查Weight、Local Preference、AS Path等属性的配置。我遇到过一个案例，客户反映访问速度慢。检查后发现，BGP选择了一条AS Path很长的路径，原因是Local Preference配置错误。调整后问题立即解决。总结BGP虽然是一个相对复杂的协议，但它是互联网基础设施的重要组成部分。理解BGP的工作原理，对于网络工程师来说是必备技能。在实际工作中，BGP的应用场景非常广泛，从多线机房到CDN网络，从云服务到企业网络，都能看到BGP的身影。虽然配置和管理BGP需要一定的技术水平，但掌握了基本原理后，实际操作并不困难。当然，BGP也有它的局限性，比如收敛速度慢、安全性不足等问题。但随着技术的发展，这些问题正在逐步得到解决。对于想要深入学习BGP的朋友，我建议多动手实践，搭建实验环境来验证理论知识。只有在实际操作中遇到问题、解决问题，才能真正掌握这个协议的精髓。希望这篇文章能够帮助大家更好地理解BGP协议。网络技术博大精深，我们都在学习的路上。如果你觉得这篇文章对你有帮助，不妨点个赞、转发一下，让更多的朋友看到。也欢迎在评论区分享你的BGP实战经验，大家一起交流学习。关注@运维躬行录，获取更多实用的运维技术干货！公众号：耕云躬行录个人博客：躬行笔记
生产环境救急指南：Linux服务器忘记密码的N种解法，覆盖主流系统和云厂商
说实话，做运维这么多年，密码忘记这事儿我自己都干过好几次，更别提帮别人处理的次数了，有时候是接手别人的机器没交接清楚，有时候是自己太久没登录给忘了，还有的时候是同事离职了账号没留……反正各种奇葩情况都遇到过。今天就把这些年的经验整理一下，把主流Linux发行版和云厂商的密码重置方法都捋一遍，希望能帮到有需要的朋友。为啥密码会忘？先聊聊原因吧，这个其实挺重要的，知道原因才能避免下次再犯。我见过最多的几种情况：一是服务器太多，密码策略又要求定期更换，换着换着就乱了；二是接手别人的机器，文档没留或者留错了；三是测试环境随手设了个简单密码，结果上线后找不到记录；还有一种就是纯碎自己脑子短路，设了个自认为能记住的密码，过两天就忘得一干二净。不管啥原因吧，反正密码忘了就是忘了，得想办法解决。传统物理服务器/虚拟机的密码重置2.1 Ubuntu/Debian 系列的单用户模式这个方法算是比较经典的了，适用于你能接触到服务器控制台的情况，不管是物理机的显示器键盘，还是虚拟机的VNC控制台。操作步骤其实不难，但不同版本有点小区别。Ubuntu 20.04/22.04/24.04 的操作：重启机器，在GRUB启动界面出现的时候，赶紧按住Shift键（有些机器是ESC），让GRUB菜单显示出来。选中第一项，也就是默认的启动项，按键盘上的e键进入编辑模式。在编辑界面里往下找，找到以linux开头的那一行，挺长的，一般在中间位置。找到ro quiet splash这部分，把ro改成rw，然后在后面加上init=/bin/bash。改完之后按Ctrl + X或者F10启动，这时候系统会进入一个简单的shell环境，不需要密码。然后就是重置密码了：passwd root # 输入新密码两次如果系统提示Authentication token manipulation error，可能是文件系统只读挂载了，先执行：mount -o remount,rw / passwd root密码改完之后，重启系统：exec /sbin/init # 或者直接强制重启 reboot -fDebian 12/13 的操作：Debian的操作跟Ubuntu差不多，但Debian 13稍微有点变化，它的GRUB配置界面可能不太一样。同样是重启进GRUB，按e编辑，找到linux那行，在行尾加上init=/bin/bash，然后按Ctrl+X启动。进入shell后：# 先确保根目录可写 mount -o remount,rw / # 修改密码 passwd root # 重启 exec /sbin/init这里有个坑，Debian 13有时候默认不安装/sbin/init这个路径，如果执行报错，可以试试：/sbin/reboot -f或者直接在虚拟化管理界面强制重启。2.2 RedHat/CentOS/Rocky/Alma 系列的救援模式红帽系的系统操作稍微不太一样，它有个专门的救援模式可以用。CentOS 7/8/9、Rocky Linux 8/9、AlmaLinux 8/9 的操作：重启系统，在GRUB菜单选择内核的时候，按e进入编辑模式。找到以linux16或linux开头的那一行，在末尾加上rd.break。按Ctrl+X启动，系统会进入紧急模式。这时候系统会挂载/sysroot目录，但是只读的，要先重新挂载：mount -o remount,rw /sysroot然后切换到chroot环境：chroot /sysroot修改密码：passwd root这里有个重要步骤，SELinux的标签要重置，否则重启后可能登录不了：touch /.autorelabel最后退出并重启：exit exit系统会自动重启，第一次启动可能会花点时间重置SELinux标签，耐心等待就好。RHEL 8/9 的操作：RHEL的操作跟CentOS基本一致，但如果你有订阅账号，还可以用官方的救援镜像。通过订阅管理的Red Hat Customer Portal可以下载救援ISO，挂载启动后选择Rescue installed system，它会自动找到你的系统并挂载，然后你chroot进去改密码就行。不过说实话，大多数情况下用GRUB编辑的方法就够了，不需要专门去搞救援镜像。2.3 使用Live CD/USB重置密码如果单用户模式进不去，或者GRUB被加密了，可以用Live CD/USB启动系统，然后手动挂载磁盘改密码。找个同架构的Linux Live CD，比如Ubuntu Live或者SystemRescueCd，启动后进入shell。查看磁盘分区：fdisk -l # 或者 lsblk找到原来的根分区，挂载：mkdir /mnt/root mount /dev/sda2 /mnt/root # 假设根分区是sda2切换到chroot环境：chroot /mnt/root passwd root exit重启后用新密码登录即可。云服务器密码重置云服务器的情况稍微复杂一点，因为你看不到真正的控制台，但云厂商一般都提供了密码重置功能，而且操作起来比物理机方便多了。3.1 阿里云ECS阿里云的密码重置算是比较方便的，有两种方式。方式一：控制台重置登录阿里云控制台，进入ECS实例列表，找到目标实例。如果实例是运行状态，可以直接点击「更多」→「密码/密钥」→「重置实例密码」。输入新密码，确认后，如果是运行中的实例，需要重启才能生效。如果实例是停止状态，密码会立即生效。这里有个细节要注意，如果你的实例是经典网络类型，重置密码后可能需要重启才能生效；如果是VPC类型，有些情况下不需要重启。方式二：VNC远程连接如果控制台重置密码的功能用不了（比如实例状态异常），可以用VNC远程连接。在实例详情页，点击「远程连接」，选择VNC方式。如果是第一次使用，会提示设置VNC密码，这个密码跟系统密码不是一回事，要区分开。连接上去后，就跟操作本地虚拟机一样了，可以用前面说的单用户模式重置密码。不过说实话，阿里云的密码重置功能已经很完善了，大多数情况下用控制台直接重置就行，没必要折腾VNC。3.2 腾讯云CVM腾讯云的操作跟阿里云差不多，也是控制台直接重置。登录腾讯云控制台，进入云服务器CVM列表，找到目标实例。点击「更多」→「密码/密钥」→「重置密码」。输入新密码，确认后，如果实例在运行，系统会提示需要重启才能生效。腾讯云有个地方要注意，如果你设置的是密钥对登录，密码登录可能会被禁用，这时候需要先把密码登录开启。在「登录设置」里可以修改。另外，腾讯云的VNC连接叫「标准登录」或「OrcaTerm」，在实例列表点击「登录」就能选择。3.3 华为云ECS华为云的密码重置也差不多，控制台找到实例，「更多」→「重置密码」。不过华为云有个特色功能叫「一键重置密码插件」，需要在创建实例时安装。如果创建时没装这个插件，控制台的重置密码功能可能用不了，需要用其他方式。如果没有安装一键重置密码插件，可以：关机实例进入「镜像服务」，使用该实例创建镜像用新镜像创建实例时设置新密码或者更简单的方法，使用云服务器控制台的「远程登录」功能，通过VNC连接进去，用单用户模式重置。3.4 AWS EC2AWS的密码重置方式有点不太一样，因为EC2默认是用密钥对登录的，密码登录默认是关闭的。如果是Amazon Linux或者RHEL之类的系统，可以用以下方式：方式一：EC2 Serial Console如果实例支持Serial Console访问，可以在控制台启用。EC2 Serial Console允许你通过串口连接到实例，然后可以用单用户模式重置密码。不过这个功能需要在实例上启用，而且需要实例支持。查看实例类型是否支持Nitro系统，大多数新实例都支持。方式二：使用用户数据脚本停止实例，修改用户数据，添加以下脚本：Content-Type: text/cloud-config #cloud-config password: YourNewPassword chpasswd: { expire: False } ssh_pwauth: True启动实例后，密码就会生效。但这种方式只在实例首次启动或者用户数据变化后首次启动时执行。方式三：挂载根卷到另一个实例停止目标实例，在控制台分离根卷（通常是/dev/xvda或/dev/sda1）。把分离出来的卷挂载到另一个可以访问的EC2实例上，挂载后chroot进去改密码，或者直接修改/etc/shadow文件。改完后，重新挂载回原实例，启动即可。这个方法比较麻烦，但是最可靠，几乎所有Linux发行版都适用。3.5 Azure虚拟机Azure VM的密码重置也有几种方式。方式一：Azure Portal重置登录Azure Portal，找到目标虚拟机，在左侧菜单找到「重置密码」。输入新用户名和密码，点击「更新」即可。这个功能底层是使用VM Agent执行的，如果VM Agent没装或者不工作，这个方法就没法用了。方式二：使用Run CommandAzure有个功能叫Run Command，可以在不登录的情况下执行命令。在虚拟机页面，找到「运行命令」，选择RunShellScript，然后输入：echo "root:YourNewPassword" | chpasswd点击运行，等待执行完成。方式三：使用Serial ConsoleAzure也支持Serial Console访问，在虚拟机页面找到「串行控制台」，连接后就可以像本地控制台一样操作了。进入GRUB后，用单用户模式重置密码即可。3.6 其他云厂商天翼云、移动云、联通云等运营商云的操作大同小异，都是控制台找到实例，点击重置密码，输入新密码，重启生效。京东云、百度云、金山云等也是类似的操作流程，就不一一赘述了。容器环境的密码重置？有人可能会问，Docker容器里的Linux密码忘了怎么办？这个其实不是问题，容器本身就不应该有密码登录的概念。容器的正确使用方式是通过docker exec或者kubectl exec进入容器，不需要密码。如果真要在容器里设置密码，也是改镜像或者持久化存储里的配置，而不是在运行中的容器里改。预防措施说完了怎么解决问题，再聊聊怎么预防。首先，密码管理工具用起来。不管是公司里的密码管理系统，还是个人的1Password、Bitwarden之类的，都比记在脑子里或者写在便签纸上靠谱。其次，SSH密钥对登录优先。密码容易忘，密钥不会忘，而且更安全。但要注意私钥要备份好，丢了比密码忘了还麻烦。第三，堡垒机或者跳板机用起来。通过堡垒机登录服务器，堡垒机上可以设置密码代填，不用记住每台服务器的密码。第四，文档要跟上。新服务器上线，密码要及时记录；交接的时候，文档要完整。这些都是运维的基本功。第五，定期巡检。时不时登录一下不常用的服务器，确认账号密码是否正确，有问题早发现早解决。一些踩过的坑最后分享几个我遇到过的坑。一次是给客户重置密码，改完之后死活登录不上，排查了半天发现是SELinux标签没重置，重启后验证通不过，登录被拒绝。从此以后，红帽系的系统改密码必执行touch /.autorelabel。还有一次，云服务器控制台重置密码，等了半天没生效，结果发现实例是停止状态下的重置，密码设置了但没重启，等了半天白等。所以现在如果实例是停止的，先启动，再确认密码是否生效。最坑的一次是接手一台老服务器，用各种方法都进不去，最后发现GRUB被加密了，密码不知道。这种情况下只能用Live CD启动，挂载磁盘改/etc/shadow文件。从那以后，我都习惯问一下之前的运维有没有给GRUB加密。总结密码忘记这事儿，说大不大，说小不小，关键是要知道怎么处理。物理机和虚拟机，单用户模式和Live CD是两大法宝；云服务器，控制台重置密码是最方便的方式；实在不行，VNC和Serial Console能救急。当然，最好的办法还是做好密码管理，别让自己陷入这种窘境。希望这篇文章能帮到有需要的朋友，有问题可以在评论区留言交流。公众号：耕云躬行录个人博客：躬行笔记如果这篇文章对你有帮助，欢迎关注转发，你的支持是我持续输出的动力！
一口气搞懂 MySQL MVCC：从隐藏字段到生产“背刺”的那些坑
我直接开干，不啰嗦背景，不讲 ACID 那些教科书话，咱就盯着一个点聊：MySQL 里的 MVCC 到底是个啥，底层咋实现，生产环境里它怎么背刺过我。整篇文章会有点长，我尽量用“人话”说清楚，顺手把我踩过的几个大坑拎出来，很多问题，说白了都是对 MVCC 理解不透彻导致的。MVCC 在 MySQL 里到底干啥用的先说结论：在 InnoDB 里，MVCC 负责普通 SELECT（快照读）的“读什么版本的数据”这个问题，目标只有一个：在读多写多的场景里，让“读”和“写”别老互相上锁卡着。感受一下下面两个场景：有个长事务在跑各种 SELECT，另一个事务在疯狂 UPDATE、你希望：写的事务可以顺利提交读的事务能看到一个自洽的历史画面，别一会儿多一条记录、一会儿少一条，自己都说不清刚才看到啥MVCC 就是干这个的。它不负责锁，锁是行锁、间隙锁那一挂的事；它只负责一个问题：给你一个“看起来稳定”的世界观。这个“世界观”怎么构出来的？靠三样东西：行记录上的隐藏字段undo 日志（回滚日志）Read View（读视图）下面一个个拆。InnoDB 行记录里那些你看不到的字段你在建表的时候写的字段是这样的：CREATE TABLE user ( id BIGINT PRIMARY KEY, name VARCHAR(50), balance INT ) ENGINE=InnoDB;但 InnoDB 真正存的时候，每一行后面还悄悄藏了几个字段（简化说法）：DB_TRX_ID：最近一次修改这行的事务 IDDB_ROLL_PTR：指向 undo 日志的指针（上一版本在哪里）DB_ROW_ID：如果你没定义主键，InnoDB 自己搞一个自增的隐藏 row id你可以把一行记录想象成这样一坨：(id, name, balance, DB_TRX_ID, DB_ROLL_PTR, DB_ROW_ID)重点是前两个：DB_TRX_ID 告诉你：这个版本是谁改的DB_ROLL_PTR 告诉你：上一版本在哪有这俩字段，才有可能把“多个版本”串成一条链。undo 日志：版本链是怎么长出来的很多人一听 undo，脑子里第一反应是“回滚用的”。其实在 InnoDB 里，MVCC 用的历史版本，全部躺在 undo 里。undo 大致分两种：insert undo：插入新行，用来回滚；事务提交后基本就没用了，很快就清理掉update undo：更新/删除用的，既服务回滚，也服务 MVCC 的历史版本你可以这么理解一个 UPDATE 的过程：UPDATE user SET balance = balance + 100 WHERE id = 1;InnoDB 干的事大致是：把当前行拷贝一份写到 undo 里，记上当前旧值、旧 DB_TRX_ID 等把主记录上的 balance 改成新值把主记录上的 DB_TRX_ID 改成当前事务 ID把主记录上的 DB_ROLL_PTR 指向刚刚那条 undo 记录这样一来，这条记录在物理上变成一条版本链：当前记录（最新版本） | v undo 版本（上一个） | v 更老的 undo 版本 | v ...普通 SELECT（快照读）其实读的不是“最新记录”，而是顺着这条链往后翻，翻到一个“按规则可见”的版本。这也是为啥：undo 清不掉，表数据明明不大，undo 表空间狂涨；一个表被频繁更新，又有长事务，版本链会越来越长，某个 select 会莫名其妙变慢。这两件事我后面单独说坑。Read View：这个事务眼里世界长啥样版本链有了，还少个东西：啥叫“对我可见”？InnoDB 的做法是：每次做快照读的时候，生成一个 Read View（读视图）。你可以粗暴理解成这个事务眼里的“活着的事务列表 + 水位线”。Read View 里主要关心这么几项（简化说法）：m_ids：生成视图时，系统里所有活跃（未提交）的事务 ID 列表min_trx_id：上面这个列表里最小的事务 ID（低水位）max_trx_id：当时系统分配给下一个事务的 ID（高水位）creator_trx_id：当前这个事务自己的 ID然后，InnoDB 就用这几个东西去判断一条记录的某个版本能不能被你看见。判断规则粗暴版（记住这个就够用了）：假设某个版本的 DB_TRX_ID = X，当前事务的 Read View 里有 min_trx_id, max_trx_id, m_ids, creator_trx_id：如果 X == creator_trx_id：这是你自己改出来的，必须能看到如果 X < min_trx_id：太老了，在你视图生成之前就已经提交了，可以看如果 X >= max_trx_id：太新了，在你视图之后才开始的事务写的，看不到如果 min_trx_id <= X < max_trx_id：如果 X 在 m_ids 里：说明这个事务当时还是活跃的，没提交，看不到不在 m_ids：说明当时已经提交了，可以看如果最新版本看不到，就顺着 DB_ROLL_PTR 去上一版本，重复上述步骤，直到找到一个可见的版本，或者链走完（说明对你来说这行压根不存在）。你可以想象成：当前事务拿着一张“世界事务快照名单”，对着每个版本的事务 ID 做筛选。RC 和 RR 两个隔离级别下，MVCC 的行为差异（这是坑源头之一）很多线上问题，根本原因其实就一句话：你以为它一直用的是“同一张快照”，结果它每次查询都换了一张。关键点在于：Read View 生成的时机不一样。1）RR（REPEATABLE READ，可重复读）下InnoDB 默认隔离级别就是 RR。在 RR 隔离级别下：一个事务里，第一次做快照读时创建 Read View后面的快照读都复用同一个 Read View也就是说，同一个事务 REPEATABLE READ 隔离级别下的普通 SELECT，一直用的是同一份“世界观”。这就保证了所谓的“可重复读”。简单的时间线示意一下：事务 A（RR）事务 B（RR）T1: A 开启事务 T2: A 执行 SELECT（快照读） —— 生成 Read View1，看到了某个版本 T3: B 开启事务，UPDATE 某行并 COMMIT T4: A 再次 SELECT（快照读） —— 仍然用 Read View1，看不到 B 的修改所以：事务内多次读，结果一致但会跟“当前真实数据”有偏差，你会疑惑：我明明刚改完，怎么这个 select 还看不到这不是 bug，是 MVCC 故意设计的。2）RC（READ COMMITTED，读已提交）下RC 的逻辑是：事务里每一次快照读都会重新生成 Read View这就意味着：T1: A 开启事务 T2: A 执行 SELECT —— 生成 Read View1，看到了老版本 T3: B 开启事务，UPDATE 并 COMMIT T4: A 再次 SELECT —— 生成 Read View2，这次能看到 B 的新版本于是就有了所谓的“不可重复读”。但是很多业务觉得 RC 比 RR“更符合直觉”：我更新完提交了，别人马上就能读到不会出现“明明提交了，别人事务里还看不到”的情况所以你会看到有的人把 MySQL 的隔离级别从 RR 改成 RC，一不小心又引出一堆新的坑。一个简单例子，走一遍 MVCC 的“选版本”过程我随手造个例子，不搞太多字段，就一条记录：CREATE TABLE account ( id BIGINT PRIMARY KEY, balance INT ) ENGINE=InnoDB; INSERT INTO account(id, balance) VALUES (1, 100);假设系统已经有事务 ID 1 的初始插入完成了，现在开始：时间线：事务 10 开启，执行：START TRANSACTION; -- trx_id = 10 UPDATE account SET balance = 200 WHERE id = 1; -- 还没 commit此时版本链大致如下：当前记录：balance = 200, DB_TRX_ID = 10, DB_ROLL_PTR -> v0undo v0：balance = 100, DB_TRX_ID = 1, DB_ROLL_PTR = NULL事务 20 开启，在 RR 隔离级别下，执行第一次 SELECT：START TRANSACTION; -- trx_id = 20 SELECT * FROM account WHERE id = 1;这时候生成一个 Read View：m_ids = [10, 20]（10、20 在跑）min_trx_id = 10max_trx_id = 21creator_trx_id = 20现在事务 20 看这条记录：当前版本 DB_TRX_ID = 10在 m_ids 里，而且不是自己 —— 说明事务 10 还活着，版本不可见顺着 DB_ROLL_PTR 去 undo v0：DB_TRX_ID = 11 < min_trx_id(10) —— 老事务版本，可见所以事务 20 读到的是 balance = 100，也就是更新前的值。事务 10 这时候 commit 了：COMMIT;事务 20 在 RR 下，同一个事务里再次 SELECT：SELECT * FROM account WHERE id = 1;仍然用刚才那个 Read View（RR 特性），再判一遍：当前版本 DB_TRX_ID = 10Read View 里 m_ids 记录的是视图生成时活跃事务，当时 10 还活着，所以这版仍不可见顺链到 undo v0：DB_TRX_ID = 1 < min_trx_id(10) —— 可见结果还是 balance = 100。这就是 RR 下“可重复读”的本质：只要你事务不结束，你看到的数据就固定在第一次快照读那一张“世界相片”上，不会更新。如果同样的过程发生在 RC 下，因为第二次 SELECT 会重新创建 Read View，那事务 10 的修改就会被看见，这里就不重复推演了。生产环境里我遇到的几个 MVCC 坑上面都是理论，下面聊点实战里真遇到的坑，很多人都是在这些地方被干懵的。坑 1：线上表查着查着变慢，后台 undo 表空间猛涨有一次一个账务类系统，业务反馈一个简单的查询：SELECT * FROM orders WHERE user_id = ? AND status = 1 ORDER BY create_time DESC LIMIT 20;平时 10ms 左右，某天开始慢慢爬升到 100ms+，然后越来越慢。服务器 CPU、IO 压力看着都还行，explain 也没啥问题（走了索引）。最后抓了半天，发现两个问题：innodb_undo_tablespaces 里的空间在持续增长有个连接挂了个长事务，一直没提交具体表现是 information_schema.innodb_trx 里能看到一个活了几十分钟的事务结合 MVCC 原理就很清楚了：这个长事务刚开始时创建了一个 Read View后面其他事务不停对订单表做 UPDATE / DELETE但 这些更新产生的 undo 版本，对这个长事务来说可能仍然“有用”purge 线程不能清掉这些 undo，版本链越拉越长当前某些查询要找到一个可见版本，得在链上一路往后翻版本越多，快照读越慢解决方式也很土：先让那个长事务正常结束（或者干脆 kill）观察一段时间 undo 空间，确认 purge 慢慢回收掉一部分再根据业务改代码，避免无意义的长事务这里有点反直觉——只是一个普通 SELECT 没有 commit，就能把整个表的 undo 空间拖死。这就是 MVCC 带来的副作用之一。坑 2：你以为“我刚更新，马上能查到”，结果 RR 下查不到这个坑非常常见。当时有个服务逻辑很简单：事务里先执行一个 UPDATE紧接着在同一个事务里 SELECT ...，期待能读到“我刚刚更新后的数据 + 别人最新提交的数据”代码差不多这样：@Transactional public void doSomething(Long id) { jdbcTemplate.update("UPDATE t SET status=1 WHERE id=?", id); // 期望这里可以看到所有最新的状态 List<T> list = jdbcTemplate.query("SELECT * FROM t WHERE status=1", ...); ... }结果在压测时发现一个诡异现象：别的事务刚刚提交的一些记录，在当前事务的 select 里看不到但在另一个新的连接里执行同样的 select，又能看到当时业务直接怀疑 MySQL 有缓存……实际上就是上面讲的：RR 下，第一次快照读的视图就冻住了。稍微回忆下过程：这个 @Transactional 方法一开始执行时：第一个 SQL 是 UPDATE —— 这是当前读（加锁），不生成 Read View紧接着的 SELECT 是这个事务的第一次快照读：这时候会生成 Read View，并固定下来后面只要是快照读（普通 SELECT），看世界都用这张 View就导致：刚刚在事务外提交的更新可能不被看到刚刚在本事务里做的 UPDATE 自己是能看到的（当前读+自已事务的版本）当时我们最后给出的方案是：对业务做约束：事务里别混合复杂的“统计类查询 + 修改”，要么拆分事务，要么隔离级别切到 RC或者：某些读必须看到最新数据，就改用 SELECT ... LOCK IN SHARE MODE 或 FOR UPDATE 这种当前读（加锁），但这又会引入锁竞争。坑 3：“幻读”你以为 MVCC 能解决，其实要靠间隙锁配合这个坑很细，很多人被“网上资料”误导。网上很多说法是：InnoDB 通过 MVCC 解决了可重复读和幻读。实际情况是：MVCC 解决的是快照读场景的“不可重复读”和“部分幻读感知”真正避免当前读场景下的幻读（比如 SELECT ... FOR UPDATE），靠的是行锁 + 间隙锁组合举个非常典型的业务写法：START TRANSACTION; SELECT * FROM coupon WHERE user_id = 123 AND status = 'unused'; -- 根据查询结果决定是否 INSERT 一条新记录 INSERT INTO coupon (user_id, status, ...) VALUES (...); COMMIT;你要保证的是：同一个用户在某个时间段只能有一条 unused 的记录。很多人天真地以为：在 RR + MVCC 下，这个 SELECT 是可重复读的，就不会有并发问题。结果压测一跑：两个事务几乎同时进来都看不到别人的 INSERT（各自的快照里对方没提交）于是都认为“没有 unused”，都 INSERT 成功最终一人拿两张券这就是幻读的典型表现。在 InnoDB 里，想解决这种问题，要明确用当前读 + 合理的索引 + 间隙锁。类似：START TRANSACTION; -- 用 FOR UPDATE 显式加锁，让 InnoDB 对满足条件的记录区间加行锁/间隙锁 SELECT * FROM coupon WHERE user_id = 123 AND status = 'unused' FOR UPDATE; -- 根据结果决定是否 insert ... COMMIT;这里就不是 MVCC 能解决的问题了，MVCC 只管快照读的版本可见性，不管写写冲突、不管间隙加锁。坑 4：RC 环境下的“统计结果忽上忽下”这个是某次报表服务改隔离级别的时候遇到的。为了让“改完数据马上能在读请求里看到”，我们把某个服务的隔离级别改成了 RC。改完没几天，运营那边问：为啥同一秒钟刷的报表统计数，会出现前后查询不一致？比如：一次查出来订单数 1001紧接着再查一次变 998过 1 秒又变 1005…看上去好像“数据自己在跳”，心理压力很大。其实 MVCC 视角看就很正常：RC 下，每次快照读都是重新生成 Read View这两次查询之间，可能有其他事务提交了 insert / delete所以每次看到的都是“那一刻已提交”的数据状态这就会带来一种“滑动的世界线”的感觉在统计类业务里，容易让人觉得“不可信”。我们最后的做法：用于“强一致统计”的逻辑，改回 RR 隔离级别或者给查询加事务边界，用一个固定 Read View 完成整个批量统计用于纯在线展示的、对一致性要求不敏感的查询，才跑在 RC 上MVCC 本身的几个限制，别指望太多MVCC 不是万能药，也有它的天生短板，这些你不提前心里有数，容易写出“以为没问题其实漏洞百出”的逻辑。几个我常给同事强调的点：MVCC 只解决读写并发，不解决写写冲突两个事务同时更新同一行：T1: UPDATE account SET balance = balance + 100 WHERE id = 1; T2: UPDATE account SET balance = balance + 200 WHERE id = 1;这时候靠的是行锁，不是 MVCC。MVCC 维护的多版本只是让快照读还能继续，但写写冲突还是要排队。快照读不加锁，但不是“读到的一定就是最新的”RR 下可能落后真实数据好几轮提交RC 下也只保证看到的是“某一刻之前已提交的”，期间别人还在提交你要的是“我一定看到当前最新”，就别指望快照读，要用当前读（锁）。长事务 + 高频更新 = undo 撑爆 + 查询变慢这个前面已经举过例子。只要有事务没结束，它视图时间点之后产生的所有版本，都有可能被它需要，purge 不敢乱删。MVCC 对“范围级别的一致性”依赖 Read View + 间隙锁快照读层面确实能保证某个事务内两次同样的 SELECT 返回同样的版本集合（RR）。但你要的是“边界不被别人插入新数据破坏”，那就得靠间隙锁，不是 MVCC。回到原点：一句话总结 MySQL 里的 MVCC 实现把前面的碎碎念压缩成一个稍微长一点的句子：在 InnoDB 里，MVCC 是通过在每行记录上加隐藏字段记录最近修改事务 ID 和回滚指针，所有历史版本存在 undo 日志中；普通 SELECT（快照读）时，InnoDB 生成一个 Read View，里面记录当时系统里活跃事务的 ID 和高低水位，然后顺着版本链往回翻，对每个版本的事务 ID 做可见性判断，找到对当前视图可见的那个版本返回；RR 和 RC 的差异就在于 Read View 是“事务级”还是“语句级”。你理解了这句话里的每个点，基本就能把 MVCC 玩明白。收个尾：怎么把 MVCC 这玩意用舒服？给个比较接地气的建议清单，都是踩过坑换来的：事务边界别乱画减少那种“在大事务里混合一堆读写、还长时间不提交”的写法明确区分两类读：对一致性特别敏感（资金、状态机、幂等控制）的，用当前读、必要时手动加锁对实时性要求高但对一点点抖动无所谓的，用快照读清楚自己的隔离级别：RR 下：可重复读 + 可能看不到别人刚提交的更新RC 下：每次查询都看一眼最新已提交世界，别指望“事务内读值不变”遇到那种“记录莫名重复插入/扣减次数不准”的场景，不要先怀疑 MySQL，优先怀疑自己是不是误用了快照读去做强一致判断就先聊到这，MySQL 里 MVCC 其实没那么玄乎，搞清楚“隐藏字段 + undo 版本链 + Read View”这三件事，再反过来回头看你线上那些诡异现象，八成都能对得上。如果你们线上也遇到过什么因 MVCC + 隔离级别引发的奇奇怪怪问题，也可以在评论区丢给我，后面有机会挑典型场景再写一篇专门拆坑的。想持续看这类偏“实战拆解”的内容，可以关注我的公众号 @耕云躬行录，也欢迎来博客翻一翻我平时的运维和排障记录：公众号：耕云躬行录个人博客：躬行笔记
一条 SQL 从敲下回车，到在 MySQL 里“跑完一生”，中间到底经历了啥？
很多人写 SQL 写了好几年，EXPLAIN 也看过，慢查询也调过，但真要问一句：“我在客户端敲下 SELECT ... 回车后，MySQL 里面具体一步一步干了啥？”大概率只能说个连接 → 解析 → 优化 → 执行这种级别的回答。看个面试还能糊弄一下，上了生产就完全不够用了。我这篇就干一件事：以一条 SQL 为线，把它从网卡一直送到磁盘，路上遇到的每一个“角色”，具体做了什么、怎么做、有什么坑，都掰开揉碎说清楚。不讲历史故事，不聊产品背景，纯过程、纯细节、纯生产视角。为了方便，你脑子里先立个场景：SELECT id, name, age FROM user WHERE phone = '13800001234' AND status = 1;user 表用 InnoDB，引擎里有这些东西：PRIMARY KEY (id)二级索引 idx_phone (phone)status 上没有单独索引下面就按它的真实“生命轨迹”往下走。从客户端到 MySQL：连接那一瞬间其实已经有坑了SQL 想进 MySQL，先得有连接，这个在 Server 层里是连接器干的事。1.1 TCP 建链 + 握手不管你用的是 Navicat、JDBC、Go 客户端还是命令行，底层都是 TCP 连接。典型流程：客户端发起 TCP 三次握手MySQL 这边的 mysqld 进程接收连接，把它交给一个线程（或者线程池里的线程）连接建立后，进入 MySQL 的认证阶段1.2 身份认证 + 权限快照握手完成，MySQL 要做两件事：校验账号密码（mysql.user 等系统表）把当前用户的权限读出来，在这个连接上缓存一份快照这一步非常关键，有两个细节很多人没意识到：之后你所有执行的 SQL 权限判断，都是基于这份快照即使 DBA 后面改了你这个账号的权限，只要连接不断开，你的权限依然按老的来生产上见过好几次：DBA 改了权限说“已经禁止这个账号 DROP 了”，结果业务连接一直没断，照样能删表，删完大家面面相觑。1.3 连接数和连接复用这又跟 SQL 执行没那么直接，但会决定你能不能发出那条 SQL。max_connections 控制最大连接数连接池（比如 Druid、HikariCP）会复用连接，不会每次都重新握手连接器这块看似简单，其实夯住了整个 SQL 生命周期的入口：有连接，才有后面的故事。SQL 到达 Server 层：老版本还有查询缓存这一关连接建立后，客户端把 SQL 文本发给 MySQL。这时候 SQL 命令进了 Server 层，没开头说的那些架构图，我们直接走流程。说明一下：MySQL 8.0 之后已经没查询缓存了，如果你还在用 5.6/5.7，可以顺便带着看这个坑。2.1 查询缓存的“鸡肋人生”（老版本）在老版本里（5.6/5.7 部分版本没关）：SELECT 语句进来后，Server 层会先用 SQL 文本（+当前数据库、某些环境）作为 key，到查询缓存里查一把。命中：直接把结果集从内存拿出来返回给客户端没命中：SQL 继续往下走听起来很美好，但有两个现实问题：命中率极低生产里 SQL 条件各种拼，哪怕一个空格不一样、一个 LIMIT 不一样，都算完全不同的 key。失效极其粗暴某个表被 INSERT/UPDATE/DELETE 一下，这个表相关的缓存统统失效，维护成本巨大。所以真实情况就是：几乎所有 SQL 都得进缓存查一圈，然后发现没有，用了一堆 CPU 做无用功。这也是为啥 8.0 干脆直接把它删了。2.2 没缓存，那就老老实实走解析器我们的这条 SQL，在现在主流的 8.0 里，就不会走查询缓存，直接丢给解析器。解析器：SQL 文本被拆成“语法树”的全过程很多人以为解析就是“看下有没有语法错误”。MySQL 这里做得比你想象复杂不少。3.1 词法分析：从字符到 Token从左到右，一个字符一个字符扫过去，按规则切成一个个 Token：关键字：SELECT、FROM、WHERE…标识符：user、id、name运算符：=、AND常量：'13800001234'、1这一步就能抓出一部分低级错误，比如你写了个 SELEC id FROM user;，SELEC 不认，就直接在这一步报错了。3.2 语法分析：给你拼成一棵树Token 流出来之后，解析器会根据 MySQL 的语法规则，去组装一个语法树（Parse Tree / AST）。形象点说，你刚才那条 SQL，在解析器眼里，差不多就变成这样一种树状结构（概念上）：SELECTfields: id, name, ageFROMtable: userWHEREAND= (phone, '13800001234')= (status, 1)这一步会检查语法级别的错误，比如：SELECT id FROM（缺表名）SELECT FROM user（缺字段）WHERE phone = AND status = 1（条件表达式错误）但要注意：表存在不存在、字段对不对、有没有权限，这些都还没检查，它只是从语言角度看你写得合不合法。3.3 预处理：校验表、字段、别名、权限解析完 AST 之后，就进入一个容易被忽略的阶段：预处理（preprocessor）。这里会干几件关键的事：把表名解析成具体的表对象（比如 db_name.user 映射到真正的元数据）检查列名是否存在（user 表到底有没有 phone、status 这些列）处理别名、子查询、视图等的字段绑定（有点类似编译器里的“符号绑定”）校验权限：当前用户对这个表有没有 SELECT 权限你平时遇到的很多错误，其实是在这一步报的，比如：Unknown column 'xxx' in 'field list'Table 'db.abc' doesn't existSELECT command denied to user 'xxx' for table 'user'还有一个经常被用来提升性能的点：预处理 + 预编译语句（Prepared Statement）。大家都知道预编译能省解析的成本，这个就是背后那部分逻辑：1准备语句的时候：PREPARE stmt FROM 'SELECT id, name, age FROM user WHERE phone = ? AND status = ?';解析、预处理、权限校验在 PREPARE 时做真正执行时只换参数，提高了重复执行场景下的性能优化器：SQL 怎么“走”，在这里被决定语法树通过预处理之后，就算是“合法”了。但怎么从磁盘把数据高效搜出来，是优化器（Optimizer）的工作。你可以理解为：这一步把“你写的 SQL”翻译成“数据库要跑的一套步骤”，也就是执行计划（Execution Plan）。4.1 这条查询大概要面临哪些选择？我们的示例 SQL：SELECT id, name, age FROM user WHERE phone = '13800001234' AND status = 1;在 InnoDB + 一主键一二级索引的场景下，优化器至少要考虑这些问题：用不用索引？直接全表扫描（不走索引）使用 idx_phone 二级索引如果用索引，以什么顺序过滤？先用 phone 定位，再回表拿 status 判断或者（如果有联合索引）利用联合索引范围查询多表 join 的话，该先驱动谁？这条 SQL 没 join，但多表场景下优化器会做关联重排，比如 A join B 还是 B join A。有没有必要排序、建临时表？比如 SQL 里有 ORDER BY、GROUP BY、DISTINCT 等时，需要考虑排序方式、是否使用临时表。4.2 基于代价的优化（Cost-Based）MySQL 的优化器本质是一个基于代价（CBO）的系统。每种可能的执行方案，都会估算一个“成本”（大体是：要扫描多少行、随机 IO 次数、CPU 运算量等），然后挑一个“看起来最便宜”的方案来用。1[4]这就涉及几个输入：统计信息：索引基数（Cardinality）、数据分布、大概行数语句条件：WHERE、ORDER BY、GROUP BY 等举个简单例子：如果 phone 列基数非常高（几乎唯一），那 WHERE phone = '13800001234' 预估结果就是极少量行，走 idx_phone 几乎一定划算。如果 status = 1 这个条件能匹配大部分行，而 phone 用的是 LIKE '%xxx' 这种后缀匹配，优化器层面可能宁可全表扫描（不过这条 SQL 里 phone 是等值，情况很好）。当然，这个“估计”可能不准，因为统计信息不新、采样不够精细，这也是为什么有时候你会发现：EXPLAIN 出来的计划明显不是你想要的，但它就坚决要走那个索引。4.3 执行计划长啥样？执行计划在内部是个树，Server 层会按这棵树和存储引擎交互。从我们这条 SQL 的维度，你用 EXPLAIN 看大概是这个样子：EXPLAIN SELECT id, name, age FROM user WHERE phone = '13800001234' AND status = 1\G可能会看到：type = refkey = idx_phonerows = 1（估算）Extra = Using where大概可以翻译为：先用 idx_phone 找到匹配的记录指针再回表到聚簇索引里取出整行数据然后再用 status = 1 过滤一次注意：执行计划不会生成类似 Java 字节码那种东西，它是一棵“指令树”，执行器会按照这棵树一步步调用存储引擎。1[4]执行器：一边叫存储引擎干活，一边把结果往外推执行计划准备好了，轮到执行器（Executor）上场。你可以把它理解成一个调度器：向存储引擎发起“读某个索引的下一行”“按主键读这一行”这种调用把返回的行数据套上 Server 层的各种逻辑（权限、触发器、函数等）最后封装协议，发回给客户端5.1 执行过程的“伪代码感”按我们的执行计划，大概会是这样的流程（逻辑上）：open_table(user) use index idx_phone for each record where phone = '13800001234': row = read_row_from_clustered_index(pk) if row.status == 1: send_to_client(row.id, row.name, row.age) close_table(user)在这个过程中，执行器做几件事：再次做权限校验（有些版本/配置会在执行前再兜一层）构造行数据（包括函数计算，比如你 SELECT UPPER(name)）按协议格式写入网络 buffer，发给客户端从客户端看，就是你在终端里一行一行看到结果返回。对执行器来说，是一边从 InnoDB 拿、一边往 socket 里塞。存储引擎：InnoDB 里面到底怎么“读”很多人讲到这里就停了：“执行器调用存储引擎，存储引擎从磁盘读数据”。但真正的性能瓶颈、锁问题、事务隔离，基本都藏在 InnoDB 这一层。6.1 Buffer Pool：内存是第一现场InnoDB 把数据页按块（一般 16KB）缓存到内存里，这块就是Buffer Pool。[1]SQL 来了以后：先看这次要读的索引页在不在 Buffer Pool 里在：内存直接读，很快不在：触发一次磁盘 IO，把那一页读进来，再从中取记录读到的页，如果是热点，会长期呆在 Buffer Pool 里，后续读几乎不需要磁盘所以：同一条 SQL，在数据全在 Buffer Pool 的情况下，耗时是一个数量级冷启 / 大量淘汰页之后，再跑一次，耗时可能翻好几倍6.2 二级索引 + 聚簇索引：你那次“回表”其实在干嘛idx_phone 是一个二级索引，B+ 树结构，叶子节点存的是：phone 的值对应主键 id执行的时候会这样走：在 idx_phone 树上，用 '13800001234' 做等值查找得到一堆主键值，比如 id = 123456用这个主键到聚簇索引（主键 B+ 树）上再查一次，把整行数据读出来从行里取出 status、name、age 等字段这一步就是常说的回表。如果你的 SQL 只需要的字段都在二级索引里，那就可以做到覆盖索引，连回表都免了，速度快一大截。6.3 MVCC、可见性、快照读我们的这个 SELECT 在默认隔离级别（InnoDB，REPEATABLE READ）下，是一个快照读。也就是说：你开始执行这条 SQL 时，会拿到一个一致性视图（Read View）之后即使别人更新了这行数据，你看到的还是事务开始时“那一刻”的数据InnoDB 是通过 undo log + 隐藏列（trx_id 等） 实现 MVCC 的。逻辑大概是：行记录里有一个事务 ID（最后修改它的事务编号）每次更新时，旧值写入 undo log，行记录更新为新值 + 新事务 ID当你读一行时，InnoDB 看这行当前版本是否对你可见可见：直接返回不可见：沿着 undo 链往前翻，找到一个对你可见的版本返回所以你看到的“结果集”，未必是当前磁盘上的“最新版本”，而是对你这个事务而言的一致版本。这个细节在排查数据不一致、幻读、锁等待时非常关键，不展开讲，不然篇幅直接爆炸。更新语句比查询多干了哪些事（redo/binglog 两阶段）虽然题目说的是“执行一条 SQL 的过程”，一般都会拿 SELECT 讲。但很多人的痛苦，都是被 UPDATE / DELETE 折磨出来的，所以顺带把更新的执行路径也理一下。2换一条 SQL：UPDATE user SET status = 2 WHERE phone = '13800001234';大体流程前半段跟查询一模一样：连接器 / 解析器 / 优化器 / 执行器 / 存储引擎也是先用 idx_phone 找到主键，再回表不一样的是：开始修改数据 + 写日志。7.1 InnoDB 内部更新顺序（逻辑）从 InnoDB 的视角，大概是这样：在 Buffer Pool 里找到那条记录所在的数据页（不在就从磁盘读入）加行锁 / gap 锁，确保并发安全记录一条 undo log（用于回滚，MVCC 读旧版本也靠它）在内存页里把 status 改成 2写 redo log（重做日志），标记为 prepare告诉 Server 层：“我这边 OK 了，可以提交”接下来轮到 Server 层：写 binlog（归档日志）再回去通知 InnoDB：“可以 commit 了”InnoDB 把 redo log 标记为 commit这就是常说的：InnoDB redo log + binlog 两阶段提交。1[4]7.2 为什么要搞这么复杂的两阶段？原因非常现实：要保证崩溃恢复后，本地数据和 binlog 里的数据一致。简单想象几种错误顺序：如果先写 redo 并直接 commit，再写 binlog那写完 redo commit 后宕机、binlog 还没写，这条记录本地已经算提交，但 binlog 里啥都没有，后面做主从复制、基于 binlog 的增量备份，全都少这条变更。如果先写 binlog，再写 redo commit写完 binlog 崩了，本地 redo 没 commit，这条数据恢复后回滚，但 binlog 里面却有这条语句，从库和备份都会应用，数据就彻底不一致。两阶段提交的大致意思就是：redo prepare：我先记一下这事要做写 binlog：把变更记录发到归档/复制系统redo commit：我确认这事已经和外界（binlog）对齐了崩溃恢复时：redo 是 commit：认为事务已完成redo 是 prepare：再去检查 binlog 里有没有对应事务有：补上 commit没：回滚这就是二阶段提交的那套逻辑，靠这个保证了事务的 crash-safe + 主从一致。再把这条 SQL 的“一生”串一下把所有细节都过了一遍，我们回头再用一段话，串起这条查询 SQL 在 MySQL 里的整体过程：客户端通过 TCP 和 MySQL 建立连接，连接器认证账号密码，加载权限快照客户端把 SQL 文本丢过来，Server 层接收在 5.7 之前可能会查一眼查询缓存（8.0 已没有）解析器对 SQL 做词法、语法分析，生成语法树预处理阶段绑定表、列、检查权限，生成更“语义完整”的内部结构优化器基于统计信息，生成执行计划：决定用哪个索引、怎么回表、过滤顺序执行器根据执行计划，逐步调用 InnoDB 的 handler 接口：打开表、定位索引、读记录InnoDB 在 Buffer Pool 里查找数据页，不在就触发磁盘 IO 读入InnoDB 按 MVCC 规则选出对当前事务可见的版本（快照读）执行器对每条返回的记录做 WHERE 条件过滤、函数计算等符合条件的行被封装进协议，顺序写入网络 buffer，回给客户端客户端一点点从 socket 里收到结果集，打印在控制台/返回给业务代码如果是 UPDATE：前半程一样在引擎层多了：写 undo + 修改内存 + redo(prepare)在 Server 层多了：写 binlog + 通知 redo(commit)崩溃恢复时靠 redo + binlog 做最终判决这堆细节在生产里到底有什么用？讲这么多，看似“底层原理”，但这些对线上问题排查、SQL 优化真的很直接。随手举几个非常接地气的用法：为什么单条 SELECT 有时突然变很慢？你就可以按“是否命中 Buffer Pool”、“优化器是否选错索引”、“执行计划里 rows 预估错误”等路径一步步排查。为什么权限改了，老连接还按老权限来？知道权限在连接建立时就缓存，你就不会一改权限就指望马上生效，而是会安排连接重建。慢 UPDATE/DELETE 到底是在写磁盘慢，还是锁冲突？你知道 InnoDB 是“先改内存+写日志，再刷盘”，你会优先看锁等待、redo 写入情况，而不是一上来就怀疑磁盘坏了。主从数据偶尔不一致怎么起手？搞清楚二阶段提交和 redo/binlog 的协同逻辑，你排查时会顺着事务 ID 去看 redo 状态 + binlog 记录。说白了：你对“一条 SQL 在 MySQL 里跑过的每一步”越熟，生产里的各种奇怪现象越不容易把你整懵。就写到这，算是把一条 SQL 的“生命周期”尽量从工程实践的角度摊开了一遍。如果你觉得这篇对你有点帮助，可以先收藏起来，有空再配合 EXPLAIN、SHOW ENGINE INNODB STATUS 多练几次，很多之前看不懂的现象都会慢慢串起来。如果你对 MySQL 这一块还想看更多类似这种“偏实战、少废话”的拆解，可以关注我，后面会继续写：锁、MVCC、死锁排查磁盘 IO 抖动下 InnoDB 的表现慢查询从 EXPLAIN 到现场复盘的完整过程也欢迎把这篇文章转发给身边正在被 MySQL 折腾的同学。公众号：耕云躬行录个人博客：躬行笔记
Jenkins 对接 Docker 做 CI/CD，我是怎么一步步跑通的，踩坑也一起说清楚
很多人一提到 CI/CD，就容易说得很玄，什么自动化交付、持续集成、持续部署，词都没毛病，但真到机器上落地，往往不是那么回事。页面点半天、插件装一堆、脚本复制来复制去，最后构建一跑就红，控制台一片报错，最常见的还是 Jenkins 找不到 Docker，或者镜像打出来了却推不上仓库。我自己在项目里折腾 Jenkins 和 Docker 集成，这种场景不算少。小一点的项目，目标很简单，代码提交后自动拉代码、自动构建、自动打镜像、自动推仓库、自动部署。听起来很顺，但中间每一步都可能掉坑。今天就把这个过程掰开了讲，不讲虚的，就按实际怎么做、怎么排错、怎么让它稳定跑起来来写。这篇内容不绕弯，直接从环境开始说。一、Jenkins 和 Docker 到底是怎么配合的这个事如果一句话说透，其实就是：Jenkins 负责“调度流程”，Docker 负责“打包和运行环境”。Jenkins 像个流程总控，代码一变更，它就去拉代码、执行构建命令、做测试、生成镜像、推送镜像、再触发部署。Docker 呢，负责把应用和运行环境一起封进镜像里，部署时不用担心“你机器能跑，我机器不能跑”。很多团队刚开始时，是 Jenkins 直接在服务器上执行 jar 包替换、重启进程。这种方式不是不能用，但时间一长就乱。依赖版本、运行参数、服务启动方式，全靠人工记忆，机器一多就更难控。后面引入 Docker 以后，镜像就成了交付标准件，部署会顺很多。说白了，Jenkins 是干活的，Docker 是装货的，镜像仓库就是中转站。二、先把基础环境弄对，不然后面都是白搭我这里以 Linux 服务器为例，比较常见，很多公司的 Jenkins 也是跑在 Linux 上。1）安装 Docker先确认 Docker 正常安装并能启动。docker --version systemctl status docker如果没装，CentOS 系可以这样：yum install -y yum-utils yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo yum install -y docker-ce docker-ce-cli containerd.io systemctl enable docker systemctl start dockerUbuntu 就换成 apt，思路差不多。装完别急着走，先跑个 hello-world 试一下：docker run hello-world能正常输出说明 Docker 基本没问题。2）安装 JenkinsJenkins 安装方式很多，war 包、yum、docker 跑都可以。我平时更偏向用宿主机安装，图个直观，排查问题也方便。比如在 CentOS 上：yum install -y java-11-openjdk yum install -y wget wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io-2023.key yum install -y jenkins systemctl enable jenkins systemctl start jenkins确认端口监听：ss -lntp | grep 8080首次登录要输初始化密码：cat /var/lib/jenkins/secrets/initialAdminPasswordJenkins 能打开只是第一步，很多人到这就以为差不多了，其实真正的坑从这里才开始。三、Jenkins 想调用 Docker，权限必须打通这个问题太常见了，几乎可以说是“新手必踩”。Jenkins 服务一般是 jenkins 用户在跑，而 Docker 命令默认只有 root 或 docker 组用户能执行。所以你在终端里手工敲 docker ps 没问题，不代表 Jenkins 任务里也能用。先看 Jenkins 是哪个用户：ps -ef | grep jenkins通常就是 jenkins。把它加入 docker 组：usermod -aG docker jenkins然后重启服务：systemctl restart docker systemctl restart jenkins这一步很多人做了还是不生效，我也碰到过。原因也不复杂，服务进程没重新拿到组权限，或者压根 Jenkins 不是用这个用户启动的。最直接的办法，是在 Jenkins 里新建一个任务执行：whoami id docker ps看它到底是不是 jenkins，有没有 docker 组。如果还报类似这种错：Got permission denied while trying to connect to the Docker daemon socket就继续查 Docker socket 权限：ls -l /var/run/docker.sock一般会看到属组是 docker，权限大概这样：srw-rw---- 1 root docker ...那就回到前面那步，把 Jenkins 用户和 docker 组关系确认清楚。有时候我图省事，也会在测试环境里把 Jenkins 直接用 root 跑，但生产不太建议，后面安全审计会盯你，别问我怎么知道的。四、Jenkins 里该装哪些插件，不用一股脑全装插件这个东西，很容易装上瘾。看着像有用，全点了，最后 Jenkins 越来越臃肿，升级还容易出兼容问题。做 Jenkins 和 Docker 集成，常用的几个就够了：PipelineGitDocker PipelineCredentials BindingSSH AgentPublish Over SSH（如果你要远程部署）Blue Ocean（可装可不装，界面好看点）进入：Manage Jenkins -> Plugins搜插件安装就行。其实很多场景，不装 Docker 插件也能做。只要 Jenkins 能执行 shell，直接在 Pipeline 里写 docker build、docker push 也可以。我自己的习惯反而更偏这个，简单粗暴，好排查。插件太多，有时候反而多一层抽象，出了问题看着更烦。五、准备一个最常见的 CI/CD 流程咱们不要一上来就整特别复杂的那种，先用一个最典型的 Java 项目来举例，流程差不多是这样：开发提交代码到 GitJenkins 拉取代码Maven 编译打包Docker 构建镜像推送到镜像仓库登录目标服务器拉取新镜像停旧容器，起新容器你看，逻辑并不花哨。真正难的是把每一个环节串起来，还要考虑凭据、权限、失败回滚、日志可追踪。六、代码仓库里要准备好 Dockerfile 和 Jenkinsfile这个习惯特别重要。不要把构建逻辑全写在 Jenkins 页面里，后面迁移、维护、协作都会比较难。最好把流程脚本跟代码一起版本化管理。1）Dockerfile 示例假设是 Spring Boot 项目，打包后得到 app.jar。FROM openjdk:8-jre-alpine WORKDIR /app COPY target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","app.jar"]这个文件看着简单，但已经够用了。有些项目镜像特别大，动不动 700MB、1GB，多半是基础镜像没选好，或者把一些没必要的文件也复制进去了。生产环境里，镜像大小真的会影响推送速度和部署效率，尤其网络一般的时候，构建完看它传半天，人会有点烦。2）Jenkinsfile 示例下面给一个比较实用的 Jenkinsfile，包含拉代码、打包、构建镜像、推镜像、远程部署。pipeline { agent any environment { APP_NAME = "my-app" IMAGE_REPO = "registry.example.com/project/my-app" IMAGE_TAG = "${BUILD_NUMBER}" CONTAINER_NAME = "my-app" DEPLOY_HOST = "192.168.1.100" } stages { stage('Checkout') { steps { git branch: 'main', url: 'https://git.example.com/project/my-app.git' } } stage('Build Jar') { steps { sh 'mvn clean package -DskipTests' } } stage('Build Docker Image') { steps { sh 'docker build -t ${IMAGE_REPO}:${IMAGE_TAG} .' sh 'docker tag ${IMAGE_REPO}:${IMAGE_TAG} ${IMAGE_REPO}:latest' } } stage('Push Docker Image') { steps { withCredentials([usernamePassword(credentialsId: 'docker-registry', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { sh 'echo "$DOCKER_PASS" | docker login registry.example.com -u "$DOCKER_USER" --password-stdin' sh 'docker push ${IMAGE_REPO}:${IMAGE_TAG}' sh 'docker push ${IMAGE_REPO}:latest' } } } stage('Deploy') { steps { withCredentials([sshUserPrivateKey(credentialsId: 'deploy-ssh', keyFileVariable: 'SSH_KEY')]) { sh ''' ssh -i $SSH_KEY -o StrictHostKeyChecking=no root@${DEPLOY_HOST} " docker pull ${IMAGE_REPO}:${IMAGE_TAG} && docker stop ${CONTAINER_NAME} || true && docker rm ${CONTAINER_NAME} || true && docker run -d --name ${CONTAINER_NAME} -p 8080:8080 ${IMAGE_REPO}:${IMAGE_TAG} " ''' } } } } post { success { echo '部署成功' } failure { echo '部署失败' } } }这个脚本不算特别复杂，但已经能跑通基本流程。有个细节要说一下，latest 标签虽然常用，但生产里我更建议一定保留一个明确版本号，比如这里的 ${BUILD_NUMBER}。不然你后面回滚的时候会很难受。镜像仓库里一堆 latest，你根本分不清到底是哪一版。到最后大家嘴里都是“就上一版那个 latest”，这就有点玄学了。七、镜像仓库这块别忽略，很多故障都出在这里Jenkins 构建完镜像，通常不会直接拿本地镜像部署，而是先推送到镜像仓库。常见的有：Docker HubHarbor阿里云容器镜像服务腾讯云、华为云的镜像仓库公司自建私有仓库我个人更推荐用 Harbor，自建环境里比较常见，权限控制、项目分组、镜像管理都还不错。在 Jenkins 里，要提前把镜像仓库账号密码配置好：Manage Jenkins -> Credentials新增用户名密码类型，比如：ID：docker-registryUsername：仓库用户名Password：仓库密码这样在 Pipeline 里就可以引用，不用明文写死。有一次我接手别人的 Jenkins，脚本里直接写了：docker login -u admin -p 123456 registry.xxx.com我看到的时候人都沉默了。脚本在仓库里，仓库很多人都能看，这种事不能说绝对危险，只能说迟早出事。八、部署机器怎么接镜像，怎么起容器，也要考虑清楚很多文章写到这里就结束了，好像 Jenkins 能 docker build、docker push 就叫完成集成了。实际上部署才是最后那一截，也是最容易出线上问题的地方。我一般会在部署机上做好以下准备：1）提前能拉镜像先在部署机手工测一下：docker login registry.example.com docker pull registry.example.com/project/my-app:latest如果这里都拉不下来，那 Jenkins 再怎么自动化也没意义。有些公司内网环境，Jenkins 能访问仓库，业务服务器不能访问，或者 DNS 没配好，这种情况特别真实。你看流水线前面一片绿，到了部署阶段突然超时，然后大家就开始怀疑 Jenkins，其实问题根本不在 Jenkins。2）旧容器替换逻辑别写得太莽最基础的替换命令就是：docker stop my-app || true docker rm my-app || true docker run -d --name my-app -p 8080:8080 registry.example.com/project/my-app:100能用，但比较硬。如果你项目稍微正式一点，最好加上挂载日志目录、环境变量、重启策略这些：docker run -d \ --name my-app \ -p 8080:8080 \ --restart=always \ -e SPRING_PROFILES_ACTIVE=prod \ -v /data/logs/my-app:/app/logs \ registry.example.com/project/my-app:100再往前一步，其实可以用 docker-compose 或者现在的 docker compose 管理。尤其一个服务依赖 redis、mysql、nginx 之类的时候，单独写一堆 docker run 不太优雅，改起来也麻烦。比如一个简单的 docker-compose.yml：version: '3' services: web: image: registry.example.com/project/my-app:latest container_name: my-app ports: - "8080:8080" restart: always environment: - SPRING_PROFILES_ACTIVE=prod部署时：docker compose pull docker compose up -d这个方式我挺常用，维护起来比直接一长串命令舒服。九、Jenkins 里创建 Pipeline 项目，别把配置搞散了Jenkins 页面里新建任务时，选 Pipeline。然后有两种方式：直接在 Jenkins 页面写 Pipeline Script从 SCM 拉 Jenkinsfile我基本都选第二种。也就是：Pipeline script from SCM配置 Git 地址、分支、Jenkinsfile 路径。这样做的好处很明显，流程变更有记录，谁改了什么一目了然。要是你把脚本全堆在 Jenkins 页面里，改几次以后连自己都记不清上次改了哪儿。更别说多环境、多项目共存的时候，那画面就有点不忍直视。十、一次真实的报错排查过程，基本都绕不开这几类光讲流程容易显得太顺了，现实里哪有这么顺。下面说几个我实际遇到过的坑，挺典型的。1）Jenkins 里执行 docker 命令报权限错误报错内容：permission denied while trying to connect to the Docker daemon socket处理思路：检查 Jenkins 运行用户检查是否加入 docker 组检查 /var/run/docker.sock 权限重启 Jenkins 和 Docker这类错误最常见，也最容易让人误判。很多人以为是 Docker 没装好，其实本质是权限问题。2）构建镜像时报找不到 jar 包报错像这样：COPY target/app.jar app.jar failed: no such file or directory这个问题一般不是 Docker 有毛病，是你前面的 Maven 打包产物路径不对。有些项目打出来的文件名不是 app.jar，而是：target/demo-1.0.0.jar那 Dockerfile 里就得改，或者在打包后做一个重命名：cp target/*.jar target/app.jar这类问题特别像生活里的那种“快递明明到了楼下，但地址写成隔壁单元”。东西是有的，就是没对上。3）推送镜像失败，提示 no basic auth credentials这基本就是登录仓库没成功，或者凭据没传进去。先在 Jenkins 构建日志里看 docker login 是否成功，再确认凭据 ID 有没有写对，仓库地址有没有写错。有一次我就遇到少写了端口号，折腾了半小时，最后看日志才反应过来，嗯，确实挺像自己给自己挖坑。4）部署成功了，但服务访问不了这个也不少见。Jenkins 页面显示全绿，容器也启动了，就是浏览器打不开。这时候别盯着 Jenkins 了，直接去部署机排查：docker ps docker logs -f my-app ss -lntp | grep 8080 curl 127.0.0.1:8080看看是：容器没起来程序启动失败端口映射不对防火墙没放行应用监听地址有问题依赖服务没连上尤其 Java 项目，容器起了不代表服务就 ready 了。有时候启动类报错，容器退出又起来，配了 restart=always 看起来像“它活着”，其实在反复去世。十一、流水线稳定以后，建议再补几件事到这里，其实 Jenkins 和 Docker 已经能配合起来了。但如果真想在项目里长期跑，还是建议再补一些细节。1）加构建清理Jenkins 节点如果长期不清理，磁盘迟早爆。尤其 Docker 镜像和中间层，很占空间。可以在流水线末尾加：docker image prune -f docker system prune -f当然别太激进，生产节点要谨慎。有些共享环境乱删镜像，会影响别的任务。Jenkins 工作空间也建议定期清理，不然 workspace 目录越堆越大，最后报警一来，又是一阵找。2）加通知构建失败没人知道，那自动化就自动了个寂寞。常见的通知方式：企业微信钉钉飞书邮件至少做到构建失败、部署失败时能及时通知相关人。别等业务说“怎么接口挂了”，你再打开 Jenkins 看，才发现昨天晚上流水线就红了。3）给不同环境分开测试环境、预发环境、生产环境，不建议全写在一个硬编码脚本里。可以用参数化构建，或者不同分支对应不同环境。比如：develop -> 测试环境release -> 预发环境main -> 生产环境再搭配不同的镜像 tag 和部署目标机器，整体会清楚很多。4）考虑回滚这个真的很重要。部署不怕失败，怕的是失败后没法迅速回去。如果你镜像 tag 规范，比如保留 build number、git commit id，那回滚就很简单：docker run -d registry.example.com/project/my-app:98如果你只用 latest，那回滚全靠运气和记忆力。说实话，这两样东西在故障现场都不太稳定。十二、如果你想再进一层，可以这么做Jenkins 加 Docker 只是第一步，后面如果项目规模上来，一般还会继续演进：Jenkins + Docker + HarborJenkins + Docker ComposeJenkins + KubernetesGitLab CI / GitHub Actions 替代部分 Jenkins 能力但这不是说 Jenkins 过时了。很多公司里 Jenkins 依然很稳，插件多，生态成熟，尤其历史项目多的时候，改造成本也没那么低。你说它老派也行，但它确实能干活。而且我一直有个感觉，运维工具这东西，不是新就一定好。能不能稳稳跑住、出了问题能不能查、团队会不会用，这些更重要。花里胡哨整一大套，结果每次发布都要祈祷，那就有点本末倒置了。十三、最后把完整思路再捋一下如果你现在要做 Jenkins 和 Docker 集成，实际落地时就盯住这几个关键点：Jenkins 能正常跑Docker 能正常跑Jenkins 用户能执行 Docker 命令Git 凭据、镜像仓库凭据、部署 SSH 凭据都提前配好代码仓库里准备好 Dockerfile 和 JenkinsfilePipeline 完成拉代码、构建、打镜像、推镜像、部署部署机能拉镜像，能起容器，端口和依赖没问题最后再补通知、清理、回滚这些增强项你会发现，这套东西并不神秘。难点不是“概念理解”，难点是把环境、权限、脚本、仓库、部署这些零碎东西拼起来，而且每个点都别掉链子。运维很多时候就是这样，听起来像搭积木，做起来像拆炸弹，尤其是晚上发版的时候，那种感觉更明显。总结Jenkins 和 Docker 集成，说到底就是让构建和部署流程标准化、自动化。Jenkins 负责编排流程，Docker 负责封装运行环境，两者配合起来，能把“手工发布”这件事尽量收敛成固定动作。只要前期把权限、插件、镜像仓库、部署脚本这些细节打磨好，后面整体会省心不少。这篇我尽量按实际落地的思路写，没有故意讲太虚的东西。真正在项目里用的时候，你大概率还是会遇到一些奇奇怪怪的小问题，不过别慌，很多问题最后看下来，无非就是权限、路径、网络、凭据这几类，经验多了，定位会越来越快。如果你也在折腾 Jenkins、Docker、CI/CD 这类内容，或者最近刚好在做发布流程改造，欢迎把这篇先收藏着，哪天报错了回来翻一眼，可能还真能省点时间。觉得这篇有点用的话，也欢迎转发给正在折腾自动化发布的朋友，一起少踩几个坑。关注我，后面我会继续把生产里常见的 Jenkins、Docker、K8s、Nginx、Linux 排障和落地实践慢慢写出来，尽量都写成能直接拿去用的东西。公众号：运维躬行录个人博客：躬行笔记
面试必问！MySQL 事务到底是怎么实现的？这篇文章讲透了
说实话，这个问题我被问过不止一次。每次有人来问我 MySQL 事务是怎么回事，我都发现大家普遍停留在「ACID 四个特性」这个层面，背得挺溜，但真要问你 MySQL 底层是怎么实现原子性的，怎么保证崩了数据不丢，怎么做到多个事务并发跑还互不干扰——很多人就开始含糊了。这篇文章我就把这块彻底说清楚。不搞那些花里胡哨的，直接从底层机制讲起，生产上遇到过的坑也会顺带提一嘴。先说说事务是什么事务这个概念说白了就是：把一组操作捆绑成一个整体，要么全部成功，要么全部失败，不允许中间状态存在。举个最经典的例子，转账。A 给 B 转 500 块，数据库层面是两步操作：A 的账户减 500，B 的账户加 500。这两步必须同时成功或者同时失败，不然 A 扣了钱 B 没收到，或者 B 收到了 A 没扣，这都是灾难性的数据错误。这就是事务要解决的核心问题。MySQL 里事务主要是 InnoDB 引擎实现的，MyISAM 不支持事务，这个要先知道。ACID 大家都背过：原子性（Atomicity）、一致性（Consistency）、隔离性（Isolation）、持久性（Durability）。但背概念没用，我们要知道 MySQL 是用什么技术手段实现这四个特性的。原子性 → 靠 undo log持久性 → 靠 redo log隔离性 → 靠 锁 + MVCC一致性 → 是上面三个共同作用的结果下面一个一个展开说。Undo Log：原子性的保障undo log 翻译过来叫回滚日志。它的核心思想很简单：在你修改数据之前，先把原来的数据记下来，万一事务失败了，就拿这个日志把数据恢复回去。你执行了一条 INSERT，undo log 里就记一条 DELETE；你执行了 UPDATE，undo log 里就记一条把数据改回去的 UPDATE；你执行了 DELETE，undo log 里就记一条 INSERT。这样，当事务需要回滚的时候，MySQL 就把 undo log 里的操作反向执行一遍，数据就回到了事务开始之前的状态。有一点要注意：undo log 一定是优先于数据修改落盘的，这个顺序不能乱。如果数据先改了，undo log 还没写，这时候崩了，你连回滚的依据都没有了。实际上 undo log 不只是用来回滚，它还承担着 MVCC 的职责，后面会说到。Redo Log：持久性的保障redo log 这块是我觉得 MySQL 设计里最精妙的地方之一。先说问题背景。MySQL 的数据最终是存在磁盘上的，但读写操作都是在内存里的 Buffer Pool 里进行的，不是每次改完数据都立刻写磁盘。这样做是为了性能，磁盘随机 IO 太慢了。但这就带来了一个风险：数据在内存里改了，还没来得及刷到磁盘，MySQL 突然崩了，数据就丢了。怎么解决？redo log 就是答案。redo log 记录的是数据页的物理修改，每次事务提交的时候，不需要立刻把数据页刷到磁盘，但必须先把 redo log 写到磁盘。redo log 是顺序写的，顺序写磁盘的速度比随机写快很多，这个性能差距在机械硬盘时代尤其明显。这个机制有个专业名字叫 WAL（Write-Ahead Logging），意思就是先写日志再写数据。MySQL 崩溃重启之后，会扫描 redo log，把已经提交但还没来得及刷盘的数据重新应用一遍，数据就恢复了。这就是为什么事务一旦提交，就算服务器崩了，数据也不会丢。redo log 有个重要的参数：innodb_flush_log_at_trx_commit，这个参数控制 redo log 的刷盘策略：设置为 1：每次事务提交都强制刷盘，最安全，但性能最差设置为 2：提交时写到操作系统的缓存，每秒刷一次盘，折中方案设置为 0：每秒刷一次盘，性能最好，但崩溃可能丢 1 秒数据生产环境一般金融类业务设置 1，对数据安全性要求没那么高的业务可以设置 2。设置 0 风险比较大，不建议。-- 查看当前配置 SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; -- 查看 redo log 相关配置 SHOW VARIABLES LIKE 'innodb_log%';MVCC：隔离性的核心机制这块是整个事务机制里最复杂的，也是面试最爱考的。MVCC 全称 Multi-Version Concurrency Control，多版本并发控制。它解决的核心问题是：怎么让读操作不加锁，同时还能保证数据的隔离性？传统的做法是读写都加锁，读的时候其他人不能写，写的时候其他人不能读，这样数据是安全了，但并发性能很差。MVCC 的思路是给数据维护多个版本，不同的事务看到不同版本的数据，读操作基本不需要加锁。InnoDB 是怎么实现多版本的？InnoDB 在每行数据上隐式地加了几个字段：DB_TRX_ID：最后一次修改这行数据的事务 IDDB_ROLL_PTR：指向 undo log 的指针，通过这个可以找到这行数据的历史版本DB_ROW_ID：隐式的行 ID每次事务修改一行数据，不会直接覆盖原来的数据，而是创建一个新版本，旧版本通过 DB_ROLL_PTR 串起来，形成一个版本链。举个例子，一行数据初始值 age = 20，事务 A（ID=100）把它改成了 25，事务 B（ID=101）又把它改成了 30，这行数据就有了三个版本，通过版本链串联在一起。Read View 是什么？光有版本链还不够，还需要一个机制来决定：当前事务应该看哪个版本的数据？这就是 Read View（读视图）的作用。Read View 里记录了几个关键信息：当前活跃的事务 ID 列表（m_ids）最小活跃事务 ID（min_trx_id）下一个待分配的事务 ID（max_trx_id）创建这个 Read View 的事务 ID判断一个数据版本是否对当前事务可见，规则大概是这样：如果这个版本的 DB_TRX_ID 小于 min_trx_id，说明这个版本是在 Read View 创建之前就已经提交的，可见如果 DB_TRX_ID 大于等于 max_trx_id，说明这个版本是在 Read View 创建之后才开始的事务改的，不可见如果在这两者之间，就看 DB_TRX_ID 是不是在活跃事务列表里，在的话说明这个事务还没提交，不可见；不在的话说明已经提交了，可见如果当前版本不可见，就顺着版本链往前找，直到找到一个可见的版本。隔离级别和 MVCC 的关系MySQL 有四个隔离级别：读未提交、读已提交、可重复读、串行化。读未提交基本不用 MVCC，直接读最新版本，啥都不管，脏读问题很严重，生产上几乎不用。读已提交（Read Committed）：每次执行 SELECT 都重新创建一个 Read View，所以每次都能读到其他事务最新提交的数据。这个级别会有不可重复读的问题——同一个事务里，两次查询同一行数据，结果可能不一样，因为中间有其他事务提交了修改。可重复读（Repeatable Read）：这是 MySQL 的默认隔离级别。它在事务第一次执行 SELECT 的时候创建 Read View，整个事务期间都复用这个 Read View，所以不管其他事务怎么改，你每次查到的都是一样的数据。这就是"可重复读"的含义。来看个具体的场景：-- 事务 A 开始 START TRANSACTION; SELECT age FROM user WHERE id = 1; -- 读到 age = 20 -- 此时事务 B 把 age 改成了 25 并提交 -- UPDATE user SET age = 25 WHERE id = 1; COMMIT; -- 事务 A 再次查询 SELECT age FROM user WHERE id = 1; -- 在 RR 级别下，仍然读到 age = 20 COMMIT;在可重复读级别下，事务 A 两次查询结果是一样的，事务 B 的修改对 A 不可见，因为 A 的 Read View 是在 B 提交之前创建的。串行化（Serializable）：所有事务串行执行，完全不存在并发问题，但性能最差，基本只在极端场景下用。幻读问题和间隙锁说到这里要提一个经典问题：MVCC 能解决幻读吗？答案是：不能完全解决。幻读是指：同一个事务内，两次范围查询，第二次查到了第一次没有的记录（通常是其他事务插入了新数据）。MVCC 通过 Read View 可以解决快照读（普通 SELECT）的幻读问题，但如果你用的是当前读（SELECT ... FOR UPDATE 或者 UPDATE、DELETE），那就不走 MVCC 了，是直接读最新数据，这时候幻读就可能出现。InnoDB 解决这个问题用的是 间隙锁（Gap Lock） 和 Next-Key Lock。Next-Key Lock = 行锁 + 间隙锁，它不只锁住符合条件的行，还会锁住这些行之间的"间隙"，防止其他事务在这个范围内插入新数据。-- 这条语句会加 Next-Key Lock SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE; -- 锁住了 age 在 20-30 范围内的所有行，以及这个范围内的间隙 -- 其他事务无法在这个范围内插入新记录这就是为什么 MySQL 在可重复读级别下能基本解决幻读问题，但要注意，这是在当前读的场景下，靠锁来保证的，不是靠 MVCC。事务提交和崩溃恢复的完整流程把前面说的串起来，看看一个事务从开始到提交，底层到底发生了什么：事务开始，分配事务 ID执行 SQL 操作，修改 Buffer Pool 里的内存数据每次修改，先写 undo log（记录原始数据，用于回滚和 MVCC）再把修改记录到 redo log buffer事务提交时，把 redo log buffer 里的内容刷到磁盘（这步完成，事务就算持久化了）释放事务持有的锁后台线程异步把 Buffer Pool 里的脏页刷到磁盘数据文件崩溃恢复时：扫描 redo log，找到已提交但没有刷盘的事务，重新应用找到未提交的事务，用 undo log 回滚这个设计保证了数据既不会因为崩溃而丢失已提交的数据，也不会因为崩溃而保留未提交的数据。生产上几个要注意的坑长事务是大忌。 事务越长，持有锁的时间就越长，其他事务等待的时间就越长，系统并发能力就越差。而且长事务会导致 undo log 不能被清理（因为可能还有其他事务需要读历史版本），undo log 会一直膨胀，磁盘空间会被大量占用。我之前处理过一个案例，一个业务同学写了个数据迁移脚本，在一个事务里处理了几十万条数据，跑了将近一个小时，这期间 undo log 撑到了几十 GB，把磁盘快打满了，差点影响整个数据库服务。-- 查看当前活跃事务，找出长事务 SELECT * FROM information_schema.INNODB_TRX ORDER BY trx_started; -- 看看有没有跑了很久的事务 SELECT trx_id, trx_started, trx_state, trx_query FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;不要在事务里做外部调用。 比如在事务里调用第三方接口、发消息队列，这些操作耗时不可控，会导致事务时间变长，锁持有时间变长。正确的做法是先提交事务，再做外部调用，或者用异步的方式处理。隔离级别不是越高越好。 串行化虽然安全，但并发性能极差。大多数业务用可重复读就够了，某些读多写少且对一致性要求不那么严格的场景，用读已提交性能更好。autocommit 要注意。 MySQL 默认开启自动提交，每条 SQL 都是一个独立的事务。如果你用 BEGIN 或者 START TRANSACTION 开启了事务，记得要 COMMIT 或者 ROLLBACK，不然事务会一直挂着。总结MySQL 事务的实现，核心就是三个东西：undo log、redo log、MVCC。undo log 保证了原子性，事务失败可以回滚，同时也是 MVCC 多版本数据的存储基础。redo log 保证了持久性，通过 WAL 机制，事务提交后数据不会因为崩溃而丢失。MVCC 加上锁机制保证了隔离性，让并发事务之间互不干扰，同时尽量减少锁的使用，提升并发性能。这几个机制不是独立运作的，它们相互配合，共同构成了 InnoDB 事务体系。理解了这些，再去看各种隔离级别的行为、幻读的成因、长事务的危害，就都能说清楚了。下次面试官再问你 MySQL 事务是怎么实现的，希望你不只是背 ACID，而是能说出 undo log 怎么保证原子性，redo log 为什么能保证持久性，MVCC 的 Read View 是怎么判断版本可见性的。这才是真正理解了事务机制。如果觉得这篇文章对你有帮助，欢迎点赞转发，让更多人看到。我会持续分享生产环境中的运维实战经验，MySQL 优化、故障排查、架构设计这些内容后续都会出，关注不迷路。公众号：运维躬行录个人博客：躬行笔记