古人学问无遗力，少壮工夫老始成。 纸上得来终觉浅，绝知此事要躬行。
生产环境救急指南：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 优化、故障排查、架构设计这些内容后续都会出，关注不迷路。公众号：运维躬行录个人博客：躬行笔记
从零到一搭建AI智能体：hello-agents项目实战部署全记录
最近在GitHub上发现了一个非常不错的AI Agent项目——hello-agents，这个项目是由Datawhale团队开源的，专门用来帮助大家快速上手AI智能体的开发。作为一个天天和各种开源项目打交道的运维人员，我觉得这个项目特别适合想要入门AI Agent开发的同学。说实话，之前我对AI Agent这块了解得不多，主要还是专注在传统的运维工作上。但是最近公司开始讨论要不要引入一些AI工具来提升运维效率，我就开始关注这个领域了。正好碰到这个hello-agents项目，就想着自己动手试试看。项目地址：https://github.com/datawhalechina/hello-agents文档地址：https://hello-agents.datawhale.cc/#/README.md项目初体验首先说说这个项目给我的第一印象吧。整个项目的文档写得挺清楚的，不像有些开源项目，README写得跟天书一样。hello-agents的目录结构也比较清晰，主要包含了几个核心模块：agents/ - 智能体的核心实现examples/ - 各种示例代码docs/ - 详细的文档说明tests/ - 测试用例我记得第一次clone这个项目的时候，整个过程还是比较顺利的：git clone https://github.com/datawhalechina/hello-agents.git cd hello-agents不过说实话，刚开始看代码的时候还是有点懵的，毕竟AI Agent这个概念对我来说还比较新。但是项目里的examples目录真的帮了大忙，里面有很多现成的例子可以参考。环境搭建的那些坑按照项目文档的说明，环境搭建这块主要是Python环境的配置。我在公司的测试服务器上试了一下，系统是CentOS 7.9，Python版本是3.8.10。首先是安装依赖包：pip install -r requirements.txt这里遇到了第一个问题，有几个包的版本冲突了。特别是transformers和torch这两个包，版本要求比较严格。我花了不少时间在这上面，最后发现还是用虚拟环境比较好：python -m venv hello-agents-env source hello-agents-env/bin/activate pip install -r requirements.txt虚拟环境这个东西真的是好用，避免了很多版本冲突的问题。不过在生产环境中，我一般还是倾向于用Docker容器来部署，这样更加稳定一些。安装过程中还遇到了一个小插曲，就是网络问题。你知道的，有些Python包的下载速度特别慢，我就换了一下pip源：pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple/这样速度就快多了。第一个智能体的诞生环境搭建好之后，我就迫不及待地想要跑一个简单的例子。项目里有一个基础的聊天机器人示例，我觉得这个比较适合入门。看了一下代码结构，主要是这样的：from hello_agents import Agent, LLMConfig # 配置大语言模型 llm_config = LLMConfig( model_name="gpt-3.5-turbo", api_key="your-api-key", base_url="https://api.openai.com/v1" ) # 创建智能体 agent = Agent( name="助手小王", description="一个友好的AI助手", llm_config=llm_config ) # 开始对话 response = agent.chat("你好，请介绍一下自己") print(response)不过这里有个问题，就是API Key的配置。项目文档里建议使用环境变量来存储敏感信息，这个做法我是赞同的。在生产环境中，绝对不能把API Key直接写在代码里。export OPENAI_API_KEY="your-actual-api-key" export OPENAI_BASE_URL="https://api.openai.com/v1"然后在代码中这样读取：import os llm_config = LLMConfig( model_name="gpt-3.5-turbo", api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") )第一次运行的时候，我用的是OpenAI的API，但是你知道的，网络访问有时候不太稳定。后来我改用了国内的一些兼容OpenAI API的服务，效果还不错。深入了解Agent架构在实际使用过程中，我发现这个项目的架构设计还是挺有意思的。不同于传统的单纯聊天机器人，hello-agents引入了智能体的概念，每个Agent都有自己的角色设定、记忆能力和工具使用能力。比如说，我可以创建一个专门负责系统监控的Agent：monitoring_agent = Agent( name="监控专家", description="负责系统监控和告警处理的专业AI助手", system_prompt=""" 你是一名经验丰富的系统运维专家，擅长： 1. 分析系统性能指标 2. 处理各种告警信息 3. 提供运维建议和解决方案 """, llm_config=llm_config )这样设计的好处是，不同的Agent可以专注于不同的领域，提供更加专业的服务。项目中还支持多Agent协作，这个功能我觉得特别有用。比如可以让一个Agent负责收集信息，另一个Agent负责分析，最后由第三个Agent来生成报告。这种工作流程在实际的运维工作中很常见。工具集成实战hello-agents最吸引我的一个功能就是工具集成。Agent不仅可以聊天，还可以调用各种外部工具来完成具体的任务。项目中提供了一些基础的工具示例，比如文件操作、网络请求、数据库查询等。我试着集成了一个系统监控的工具：import psutil def get_system_info(): """获取系统基本信息""" return { "cpu_percent": psutil.cpu_percent(interval=1), "memory_percent": psutil.virtual_memory().percent, "disk_usage": psutil.disk_usage('/').percent, "load_average": psutil.getloadavg() } # 将工具注册到Agent monitoring_agent.register_tool("get_system_info", get_system_info)这样，当用户询问系统状态时，Agent就可以自动调用这个工具来获取实时的系统信息。实际使用下来，效果还是很不错的。不过在工具集成这块，我也遇到了一些问题。主要是安全性方面的考虑，毕竟Agent可能会执行一些系统命令，如果不加限制的话，风险还是比较大的。项目中提供了一些安全机制，比如工具权限控制、命令白名单等，这些在生产环境中是必须要考虑的。记忆管理的巧妙设计在使用过程中，我发现hello-agents的记忆管理设计得挺巧妙的。不像简单的聊天机器人只能记住当前对话，这个项目中的Agent可以维护长期记忆和短期记忆。短期记忆主要是当前对话的上下文，这个比较好理解。长期记忆就比较有意思了，Agent可以记住之前的交互历史，甚至可以学习用户的偏好和习惯。# 配置记忆管理 memory_config = { "short_term_size": 10, # 短期记忆保留最近10轮对话 "long_term_storage": "sqlite", # 长期记忆存储方式 "memory_retrieval": True # 启用记忆检索 } agent = Agent( name="记忆助手", llm_config=llm_config, memory_config=memory_config )在实际测试中，我发现这个记忆功能确实很有用。比如我之前问过某个服务器的配置信息，过了几天再问相关问题时，Agent还能记得之前的内容，这样就不用重复输入了。不过记忆管理也带来了一些新的问题，主要是存储空间和隐私保护。长期记忆会占用不少存储空间，而且用户的对话历史也涉及到隐私问题。项目中提供了一些配置选项来控制这些，但在实际部署时还是需要根据具体情况来调整。性能优化的一些心得在测试过程中，我也关注了一下性能方面的问题。毕竟作为运维人员，系统性能是我们最关心的事情之一。首先是响应速度。AI Agent的响应速度主要取决于大语言模型的推理速度。如果使用云端API，网络延迟也是一个重要因素。我测试了几种不同的配置：使用OpenAI GPT-3.5：响应速度一般，但质量较高使用本地部署的开源模型：响应更快，但需要较多的硬件资源使用国内的API服务：网络延迟较小，性价比不错对于我们的使用场景，我最终选择了国内的API服务，主要考虑的是稳定性和成本。另外一个性能相关的问题是并发处理。如果有多个用户同时使用Agent，系统的并发处理能力就很重要了。项目中提供了一些异步处理的机制，可以提高系统的吞吐量：import asyncio async def handle_multiple_requests(): tasks = [] for user_input in user_inputs: task = agent.chat_async(user_input) tasks.append(task) responses = await asyncio.gather(*tasks) return responses这种异步处理方式在高并发场景下效果还是很明显的。部署到生产环境测试得差不多了，我就开始考虑怎么把这个项目部署到生产环境。作为运维人员，部署这块我还是比较有经验的。首先是容器化。我写了一个Dockerfile：FROM python:3.8-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "app.py"]然后是服务编排。我用docker-compose来管理整个服务栈：version: '3.8' services: hello-agents: build: . ports: - "8000:8000" environment: - OPENAI_API_KEY=${OPENAI_API_KEY} - DATABASE_URL=${DATABASE_URL} volumes: - ./data:/app/data restart: unless-stopped redis: image: redis:alpine ports: - "6379:6379" restart: unless-stopped nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf depends_on: - hello-agents restart: unless-stopped部署过程中还遇到了一些小问题，主要是权限和网络配置方面的。不过这些都是常见问题，很快就解决了。监控和日志也是必不可少的。我配置了一些基本的监控指标，比如响应时间、错误率、资源使用情况等。这样可以及时发现和处理问题。实际应用场景探索部署完成后，我开始探索一些实际的应用场景。作为运维人员，我首先想到的就是把Agent集成到我们的运维工作流程中。比如说，我创建了一个专门处理告警的Agent。当监控系统产生告警时，这个Agent可以自动分析告警信息，查询相关的历史数据，然后生成初步的处理建议。虽然不能完全替代人工处理，但确实可以提高效率。alert_agent = Agent( name="告警处理专家", system_prompt=""" 你是一名专业的运维工程师，负责处理各种系统告警。 当收到告警信息时，你需要： 1. 分析告警的严重程度 2. 查询相关的历史数据 3. 提供可能的解决方案 4. 如果需要人工介入，明确指出需要关注的重点 """, tools=["query_metrics", "check_logs", "get_system_status"] )另外一个应用场景是文档管理。我们运维团队有很多技术文档，平时查找起来比较麻烦。我用hello-agents搭建了一个文档助手，可以根据用户的问题快速找到相关的文档内容。这个功能的实现主要是基于向量检索，先把文档内容转换成向量，然后根据用户问题进行相似度匹配。项目中提供了相关的工具，使用起来还是比较方便的。遇到的一些坑和解决方案在实际使用过程中，我也遇到了不少问题，这里分享一些主要的坑和解决方案。第一个大坑是token消耗。AI Agent在处理复杂任务时，会消耗大量的token，成本很快就上去了。我的解决方案是：优化prompt，减少不必要的输出使用更便宜的模型处理简单任务实现token使用量监控和限制第二个问题是模型幻觉。有时候Agent会给出看起来很合理但实际上错误的信息，这在运维场景下是很危险的。我的应对策略是：对关键操作增加人工确认环节使用多个Agent交叉验证重要信息建立知识库，让Agent基于已知信息回答问题第三个问题是稳定性。API服务偶尔会出现故障或限流，影响Agent的正常工作。我的解决方案包括：配置多个API服务作为备份实现请求重试机制添加降级处理逻辑未来的一些想法使用hello-agents这段时间下来，我对AI Agent的应用前景还是很看好的。特别是在运维领域，有很多重复性的工作可以通过Agent来自动化处理。我现在在考虑的几个方向：与现有的监控系统深度集成，实现更智能的告警处理开发专门的运维知识库，让Agent能够提供更准确的技术支持探索多Agent协作，处理更复杂的运维场景当然，这些都还在规划阶段，需要更多的实践验证。hello-agents这个项目给我最大的感受就是，AI Agent技术已经比较成熟了，完全可以在生产环境中使用。虽然还有一些限制和问题，但总体来说是一个很有价值的工具。对于想要入门AI Agent开发的同学，我强烈推荐试试这个项目。文档写得不错，示例代码也很丰富，上手难度不大。而且作为开源项目，可以根据自己的需求进行定制开发。总结这次hello-agents项目的实践让我对AI Agent有了更深入的了解。从环境搭建到功能测试，再到生产部署，整个过程虽然遇到了一些问题，但总体还是比较顺利的。最重要的是，我看到了AI Agent在实际工作中的应用潜力。虽然现阶段还不能完全替代人工，但作为辅助工具，确实可以大大提高工作效率。如果你也对AI Agent感兴趣，不妨试试这个项目。记住，最好的学习方式就是动手实践。遇到问题不要怕，多看文档，多试试，总能找到解决方案的。好了，今天的分享就到这里。如果你觉得这篇文章对你有帮助，欢迎点赞转发，让更多的朋友看到。有什么问题或者想法，也欢迎在评论区交流讨论。公众号：运维躬行录个人博客：躬行笔记
一封邮件的「江湖」：我在生产环境踩出来的 SMTP 与 IMAP 实战笔记
SMTP 与 IMAP ，这俩协议天天见，结果很多朋友只知道“收件服务器、发件服务器”那两栏要填啥，真正出问题了，全是一脸懵。既然问得多，我干脆把这几年在生产环境里折腾邮件系统的经验捋一遍，写成一篇文章。别讲那些教科书式的定义了，咱就从“问题怎么排”和“系统怎么设计”角度，把 SMTP 和 IMAP 的区别、组合方式、以及常见坑说清楚，保证你看完之后，再碰到邮件系统告警，心里不慌。先把话说白：SMTP 干送，IMAP 管收我自己脑子里一直有个特别土的比喻：SMTP = 快递公司IMAP = 你小区门口的快递柜 + 你手机上的那个取件小程序SMTP 把邮件从发件人一路「运」到收件人的邮箱服务器，IMAP 让收件人从服务器上「取」邮件、整理邮件、同步状态。用一句话总结：SMTP 负责把邮件丢进对方的邮箱，IMAP 负责让你舒服地翻、看、删这些邮件。这俩协议一个负责“上行投递”，一个负责“下行访问”。搞邮件系统的时候，脑子里先把这个定位弄明白，后面很多设计就顺了。SMTP：邮件世界的物流总线1. SMTP 的“线路”到底长啥样我们平时说的“发件服务器”就是 SMTP 服务器。一封邮件从你的电脑发出去，大致会经历这样一条路线：客户端（Outlook、Foxmail、手机邮箱 App）连接企业 SMTP 服务器（通常 25 / 587 / 465 端口）企业 SMTP 把邮件转给对方邮箱的 MX 服务器对方的 SMTP/MDA 把邮件写入对方的邮箱存储这条线中间可能会跳过好多个中转 SMTP，就跟快递中转站一样。日志里经常能看到一长串 Received: 头，就是一路走过来的“盖章记录”。我做日志排查的时候，最常用的就是沿着 Received 头一点点往前追，看哪一段抛的错、哪一段延迟高。2. 端口和加密，那点绕人的细节很多人被“端口”坑过。常见组合是这么几个：25：传统 SMTP 明文端口，现在经常被运营商限速、封禁587：提交端口（submission），配合 STARTTLS，用得比较多465：SMTPS，直接加密，很多客户端默认用这个在企业里，我一般这么配置：对内客户端：开放 587 + 强制 STARTTLS对外互联：25 端口继续保留，但只允许运营商出口465 是否开，看客户端兼容性，有些旧客户端只认 465真实场景里我踩过一个坑：某次我们把 25 端口只保留在外联网，内网都改 587，结果一堆老脚本和老系统里写死了 25，SMTP 直连，全部挂掉。后来我干脆在 25 上做了一个“内部访问自动 301 提示”的 banner，提示改 587，不然问题单会被淹死。3. SMTP 的三件“大事”：认证、队列、反垃圾3.1 发邮件必须先“报个到”现代 SMTP 基本都要求认证：AUTH LOGIN / PLAIN / CRAM-MD5 等机制配合 TLS 保证密码不明文传输不认证会怎样？两种情况：对内：防止内部被植入木马变成垃圾邮件“肉鸡”对外：很多对方 SMTP 直接把匿名邮件拒掉在公司里，我一般会配：不同系统用不同 SMTP 账号，方便追踪邮件头里加 X-App-ID 之类的字段，遇到问题可以快速定位哪个系统发的3.2 队列系统，SMTP 的“缓冲胃”邮件有个特点：不能保证实时可达，只能保证“尽力而为 + 重试”。所以 SMTP 必须要有一套队列机制：对方服务器暂时不可达 → 暂存队列，按策略重试对方返回 4xx（临时错误）→ 延迟重投对方返回 5xx（永久错误）→ 退信给发件人我实战里遇到过一个典型问题：某天监控提示队列数暴涨，mailq 一看堆了几万封日志里全是 451 4.7.1 Service unavailable - try again later一查，对方某运营商的邮箱服务器在限流最后只好：调整该域名的重试间隔，防止队列被塞满对内部用户明显错误的收件地址，提前做格式校验，少无效投递这类队列问题，在 SMTP 层是常态，没监控就等着挨打。3.3 反垃圾：SPF、DKIM、DMARC 那些事现实世界里，SMTP 最大的痛就是垃圾邮件。我们在线上搭 SMTP，绕不过这三兄弟：SPF：发件 IP 是否在域名授权列表里DKIM：邮件内容是否被发件域名用私钥签名DMARC：发件域在收件方那边的策略（拒收 / 隔离 / 报告）我有一次帮一个客户排查：他们从企业邮箱发给某大厂邮箱，总是进“垃圾邮件”查收件方反馈的报告，发现 SPF 记录里少了一条新上线的网关 IP加上 SPF 再测试，投递成功率直接上去了。这里的经验就是：但凡你有多个出口 IP 做 SMTP，一定要统一维护 SPF，不然就会出现“有时候正常、有时候进垃圾”的玄学问题。IMAP：让你随时随地翻邮箱的幕后功臣1. IMAP 的「访问模式」到底差在哪IMAP 和 POP3 最大的区别，归结到两个字：同步。POP3 更像“把邮件拉到本地，服务器删不删看配置”IMAP 是“所有操作都在服务器上，客户端只是一个视图”所以 IMAP 支持：多终端同步：手机、电脑、Webmail 看的是同一个状态服务器端文件夹：分类整理都在服务器标记同步：已读 / 未读 / 星标 / 标签 等各种状态我们运维日常遇到用户抱怨：“为什么手机上删了邮件，电脑上还在？”十有八九就是 POP3，且客户端设置成“保留服务器副本 + 本地删除”。换成 IMAP，就没这个问题了。2. 端口、加密和实际部署的小细节默认端口：143：IMAP 明文993：IMAPS（IMAP over TLS）我一般在生产里这么搞：对内：143 + 强制 STARTTLS对外：只开 993，直接 TLS，省去各种降级风险明文登录一律禁止，除非是测试环境实际踩坑经验：某次我们对 IMAP 服务器做证书更新，忘了把中间证书链传完结果一堆 Apple 设备连不上，一直提示“无法验证身份”最后发现是 iOS 对证书链要求更严格，补上中间证书后恢复这个坑提醒我：邮件协议虽然老，但 TLS 这一层的现代要求一点不低，证书、协议栈、加密套件全都要盯着。3. IMAP 的几个「易忽视」能力3.1 服务器端搜索很多人觉得 IMAP 慢，其实问题在服务器实现上。IMAP 原生就支持：按发件人、主题、日期、关键字搜索按“未读”、“带附件”这些条件过滤如果后端存储和索引没做好：一旦用户邮箱几万封，搜索就会卡死客户端请求一条 SEARCH，服务器 CPU 飙升我在一个项目里，把 IMAP 后端从简单的 Maildir 换成了带索引的存储（例如 dovecot + fts），搜索性能从“几秒”级降到“几百毫秒”，用户体验差距挺明显。3.2 多终端并发访问IMAP 允许一个账号同时在多个终端登录，但是后台要考虑的点就多了：并发连接限制（防止一个用户占满整个进程）带宽和 IO 压力（多终端同步大附件）IDLE / PUSH 通知（长连接保持）我踩过一个很现实的坑：某次我们在 IMAP 前加了一个反向代理结果 proxy 的 timeout 太短，IDLE 连接老被断用户就抱怨“客户端老提示重新连接”最后只好对 IMAP 端口做专门的负载，保证 IDLE/长连接不卡住。SMTP + IMAP：一个完整邮件系统是怎么落地的光知道“谁发谁收”还不够，真正搭个邮件系统，需要考虑一堆实际问题。我以一个典型的中小企业邮件系统为例，把架构和过程说一说。1. 一个常见的企业邮件系统架构大体会这么几层：入口层：MX 记录指向的外网 SMTP负载均衡 + 防火墙 + WAF（可选）网关层：反垃圾 / 反病毒（如 rspamd / amavis）灰名单、白名单、RBL 黑名单查询业务层：SMTP 服务（发送、转发）IMAP 服务（访问）Webmail（给没客户端的人用）存储层：邮箱存储（本地磁盘 / 分布式存储）备份与归档认证与目录：LDAP / AD 做账号管理单点登录 / OAuth 等这种结构在实际部署中，最容易被忽略的是网关层和存储层。很多人一开始用一台服务器搞定 SMTP+IMAP+存储，等用户一多，磁盘满了，性能扛不住，再拆已经来不及了。我踩坑就是这么来的，后来干脆：一开始就把存储挂在独立 NAS/对象存储上邮件头部统一经过网关清洗，再进内部系统2. 常见业务场景下的协议选择我按几个实际遇到的场景，聊聊 SMTP + IMAP 的玩法。2.1 企业办公邮件这个不用多说，基本标配：发件：客户端配置 SMTP（587/465），启用 AUTH + TLS对外的所有发件 IP 做 SPF + DKIM收件：客户端配置 IMAP（993）对外 MX 记录指向网关 → 内网 SMTP → 存储细节上，我会：为不同部门配置不同的发件限制（附件大小、收件人数量）对 IMAP 做连接数限制，防止某些客户端疯狂开连接2.2 系统通知邮件（不需要收件箱）比如：Web 系统注册验证码业务系统告警邮件账单通知等这类场景，只需要 SMTP 发件，常见做法：配置一个或多个专用发件账号，例如 noreply@xxx.comSMTP 发件走单独的 IP 和队列，避免和正常办公邮件混在一起对退信地址做集中收集，便于监控投递情况有一次我们遇到，业务系统的验证码邮件大批进垃圾箱，最后发现：这条业务线的 noreply 账号走的是第二出口 IP但 DKIM 还没在那条 IP 出口网关上配置收件方看到签名不一致，给了较低评分调好 DKIM 与 SPF 后，退信率明显下降。2.3 多设备访问 + 大附件环境比如高管、销售，手机 + 电脑 + iPad 三端都要收邮件，而且动不动发 PPT、PDF 五六十兆。这类需求里，IMAP 就是核心：邮件统一存储在服务器多终端只是“窗口”，不用担心本地丢失后端需要考虑大附件的分片存储、备份、归档我曾经帮一个销售团队把邮箱从 POP3 切到 IMAP：切之前：每个人电脑上都有自己的 .pst、.dbx 文件某些电脑坏了，历史邮件找不回切之后：统一存储在服务器甚至可以给他们开共享邮箱，团队协作方便很多这件事让我越发觉得：邮件协议看着老，但 IMAP 在“协同”这块还挺香。运维视角下的常见故障与排查思路写到这里，如果不聊聊故障排查，就太虚了。我挑几个日常经常遇到的问题，分享一下我自己的排查路径。1. 用户说“发不出去邮件”这句投诉太常见了，背后可能是：客户端配置错误SMTP 被运营商拦截被垃圾网关拒绝认证失败…我每次处理，都习惯先问三句话：是所有人都发不出去，还是你一个？是对所有域名都不行，还是某个特定域？客户端有没有看到错误代码或提示？然后对应去看 SMTP 日志，典型错误码有：5xx：永久错误（账号不存在、拒收、黑名单）4xx：临时错误（对方服务器忙、限流）530/550 之类，很多是认证相关有一次，用户说“发不出去”，日志里全是：554 5.7.1 Relay access denied原因是：该用户在外网直接连的 SMTP，没有认证我们服务器不允许未认证的 relay最后给他解释：外网访问必须先输账号密码，不然你被盗用发垃圾邮件，我们也会被拉黑。2. 用户说“收不到某个域的邮件”这种一听就知道是“收件路径”问题。排查步骤差不多是这样：先用 nslookup -type=mx 看对方是否有 MX 记录在自己网关上抓取 postfix / exim 日志，看有没有相关连接如果连日志都没有，要考虑是不是被上游拦截 / DNS 配错我遇到一个特别坑的：某客户把自己的 MX 记录指向了一个外部安全网关安全网关再转发到我们的邮件系统结果他们在网关上加了一个“某域黑名单”，忘了告诉我们导致我们这边怎么查都看不到对方邮件，最后还是通过对方提供的网关日志才定位。经验就是：邮件路径一旦有多跳，日志一定要能串起来，不然排查就会很难受。3. IMAP 同步异常、邮件状态乱掉这类问题一般表现为：某终端看不到新邮件已读 / 未读状态不同步文件夹结构错乱我习惯的做法是：让用户先在 Webmail 上确认邮件是否存在如果 Webmail 正常 → 问题在客户端Webmail 都看不到 → 可能是投递/存储问题对 IMAP 日志开启 debug，看客户端发了哪些命令检查后端存储是否有损坏，必要时跑一遍 fsck 或索引重建有一次，一个用户邮箱经常莫名其妙少邮件，最后发现：他用某国产客户端，默认配置是“只保留 30 天邮件”客户端会主动执行删除操作，IMAP 后端就跟着删了我们只好给所有用户发通知，建议使用官方推荐客户端，并且在服务器上加了一个“邮箱回收站”，人为提高容错。协议只是基础，策略才是生产的灵魂讲了这么多，其实 SMTP 和 IMAP 本身就那点东西，真正决定一个邮件系统好不好用、稳不稳定的，是你怎么设计策略。我最后再拉几个实践中比较关键的点，给你做个“备忘单”。1. 带宽和存储规划别小看邮件：每个用户每天几十封邮件附件越来越大，各种扫描件、PPT、视频多终端 IMAP 同步，后台 IO 压力很大我个人经验：用户数 < 500 的小公司：单机 + 备份够用，但要提前规划磁盘扩展用户数 500～2000：SMTP/IMAP 分离，存储挂 NAS/分布式文件系统更大规模：考虑专业邮件系统或 SaaS，自己搞成本高2. 安全策略：既要防垃圾，也别误伤自己发件侧：SPF、DKIM、DMARC 必配限制单日/单小时发件量对异常 IP（国外、云主机）多加一层验证收件侧：白名单（合作伙伴、内部系统）黑名单（已知垃圾源 IP / 域）灰名单（小批量试投，效果挺好）我有个习惯：所有安全规则上线前，一定先开“仅打分不拦截”模式至少一周，看日志、看评分，防止误伤正常邮件。3. 监控与告警邮件系统不怕小问题，就怕“悄悄挂了”。可以重点监几个指标：SMTP 队列长度每分钟投递成功/失败数量IMAP 并发连接数、响应时间关键进程存活、端口监听有一次凌晨，我们的队列突然暴涨，但 SMTP 服务还活着，监控没细到队列长度，结果第二天早上上百个用户投诉收不到邮件。从那以后，我直接把“队列长度”做成了红色大屏：一旦超过阈值，短信 + 电话轮番上。写在最后：把邮件系统当成一个“老但认真”的同事说句实在话，在现在这个时代，大家都在聊 IM、企业微信、各种协同工具，邮件看起来有点“老旧”。但从运维的视角看，邮件系统的价值一点都没降：关键通知、正式文件，依然靠邮件很多自动化系统（告警、报表）还是用邮件做兜底跨公司、跨组织的沟通，邮件依然是默认通道SMTP 和 IMAP 这两个协议，就像两个老同事：一个负责把信件送到每个人的桌上一个负责帮你把桌上的信收拾得整整齐齐你把他们伺候好了，整套信息系统就有了一个稳定可靠的“消息骨干”。写这篇文章，也算是我这几年踩坑的小结。希望你下次在配置邮件客户端、设计通知系统、排查投递问题的时候，脑子里能自然地把“SMTP 干送、IMAP 管收”这根线串起来，再往下细分认证、队列、反垃圾、安全、监控这些点，就不会被各种莫名其妙的问题搞得焦头烂额。如果你那边正好在搭邮件系统，或者被某个奇怪的退信码折磨，可以把问题整理一下，后面我可以专门写一篇“常见退信码实战排查”的文章，把那些年栽过的跟头再翻一遍。如果这篇实战笔记对你有点帮助，欢迎转发给同事一起看，也可以在评论区把你遇到过的奇葩邮件问题说出来，大家一起交流，互相少踩点坑。更多运维实践，我会持续更新在：个人博客：躬行笔记
生产环境翻过车之后，我才真正搞懂了 Nginx 金丝雀发布
说起金丝雀发布，我自己也是吃过亏的。之前在一家电商公司，有一次大促前夜做版本更新，直接全量切换，结果新版本有个缓存的 bug，商品价格显示异常，虽然只持续了十几分钟就回滚了，但那十几分钟的订单全部要人工核对，运营那边差点没把我们骂死。后来我们就开始研究灰度发布的方案，金丝雀发布就是其中最经典的一种。金丝雀发布到底是个啥这个名字听起来挺文艺的，其实来源很朴素。早年间煤矿工人下矿井之前，会带一只金丝雀进去，因为金丝雀对有毒气体特别敏感，如果金丝雀没事，说明矿井安全；如果金丝雀出了问题，矿工就赶紧撤。放到软件发布的场景里，道理是一样的。你不是一次性把新版本推给所有用户，而是先放一小部分流量（比如 5%、10%）到新版本上，观察一段时间，看看有没有报错、性能有没有下降、用户反馈怎么样。如果一切正常，再逐步扩大流量比例，最终完成全量切换。如果新版本有问题，影响的也只是那一小部分用户，及时回滚就行了，损失可控。跟全量发布比起来，金丝雀发布的好处很明显：风险可控，出了问题影响面小可以拿真实流量验证新版本，比测试环境靠谱多了回滚方便，改个配置 reload 一下就行不需要额外的硬件资源，Nginx 本身就能搞定当然它也不是万能的，比如涉及到数据库结构变更的发布，单靠流量层面的灰度是不够的，还需要配合数据层面的兼容方案。不过大多数场景下，金丝雀发布已经够用了。实现原理，其实不复杂用 Nginx 做金丝雀发布，核心就是利用 Nginx 的反向代理和负载均衡能力，把流量按照一定的规则分发到不同的后端服务上。简单来说就是你有两组后端服务：一组跑的是稳定的老版本（我们叫它 stable），一组跑的是待验证的新版本（我们叫它 canary）。Nginx 作为入口，根据你定义的规则，决定每个请求应该转发到哪一组。规则可以有很多种玩法：按权重分配：比如 90% 的流量走老版本，10% 走新版本按特定用户分配：比如某些测试账号、内部员工的请求走新版本按 Cookie 分配：带有特定 Cookie 的请求走新版本按 Header 分配：请求头里带了特定标记的走新版本按 IP 分配：某些 IP 段的请求走新版本实际生产中，这几种方式经常组合使用。比如先让内部员工全部走新版本测试一轮，没问题了再按权重放 5% 的外部流量进来。环境准备我这里用一个比较贴近真实场景的例子来演示。假设我们有一个 Web 应用，当前稳定版本跑在 8080 端口，新版本跑在 8081 端口。Nginx 监听 80 端口作为入口。服务器环境：CentOS 7 / Rocky Linux 8（Ubuntu 也一样，路径稍有不同）Nginx 1.20+（更低版本也行，基本功能都支持）两个后端应用实例先确认 Nginx 安装好了：nginx -v # nginx version: nginx/1.20.1如果没装的话：# CentOS/Rocky yum install -y nginx # Ubuntu/Debian apt install -y nginx为了演示方便，我用两个简单的后端来模拟。实际生产中这两个就是你的应用服务，可能是 Tomcat、Node.js、Go 服务之类的。用 Python 快速起两个模拟服务：# 终端1 - 模拟稳定版本（v1） mkdir -p /tmp/stable && echo "This is STABLE version v1.0" > /tmp/stable/index.html cd /tmp/stable && python3 -m http.server 8080 # 终端2 - 模拟金丝雀版本（v2） mkdir -p /tmp/canary && echo "This is CANARY version v2.0" > /tmp/canary/index.html cd /tmp/canary && python3 -m http.server 8081方案一：基于权重的流量分配这是最简单也是最常用的方式。通过 Nginx 的 upstream 模块，给不同的后端设置不同的权重，实现按比例分流。编辑 Nginx 配置文件：# /etc/nginx/conf.d/canary.conf upstream backend { # 稳定版本，权重 9，大约承担 90% 的流量 server 127.0.0.1:8080 weight=9; # 金丝雀版本，权重 1，大约承担 10% 的流量 server 127.0.0.1:8081 weight=1; } server { listen 80; server_name your-domain.com; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }检查配置并重载：nginx -t nginx -s reload验证一下效果，多请求几次看看分布：for i in $(seq 1 20); do curl -s http://localhost; done你会发现大概每 10 次请求里有 1 次会返回 canary 版本的内容，其余都是 stable 版本。这种方式的好处是简单粗暴，配置改起来也方便。想调整比例的时候，改一下 weight 值，reload 就生效了。比如观察了一段时间没问题，想把金丝雀流量提到 30%：upstream backend { server 127.0.0.1:8080 weight=7; server 127.0.0.1:8081 weight=3; }不过这种方式有个问题，就是同一个用户可能一会儿访问到老版本，一会儿访问到新版本，体验不太一致。如果你的应用对这个比较敏感，就需要用下面的方案了。方案二：基于 Cookie 的精准分流这个方案可以保证同一个用户始终访问同一个版本，体验更一致。思路是通过 Cookie 来标记用户应该走哪个版本。# /etc/nginx/conf.d/canary_cookie.conf upstream stable { server 127.0.0.1:8080; } upstream canary { server 127.0.0.1:8081; } server { listen 80; server_name your-domain.com; # 根据 cookie 判断走哪个版本 set $backend "stable"; if ($http_cookie ~* "canary=true") { set $backend "canary"; } location / { proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这样配置之后，默认所有请求都走 stable 版本。只有当请求的 Cookie 里带了 canary=true 的时候，才会转发到 canary 版本。怎么给用户种上这个 Cookie 呢？有几种方式：1、手动在浏览器里加，适合内部测试：打开浏览器开发者工具，在 Console 里执行：document.cookie = "canary=true; path=/";2、通过后端接口设置，适合灰度平台对接：后端提供一个接口，根据用户 ID 或者其他规则，决定是否给用户种上 canary Cookie。3、通过 Nginx 自身来做随机分配并种 Cookie：# /etc/nginx/conf.d/canary_split.conf upstream stable { server 127.0.0.1:8080; } upstream canary { server 127.0.0.1:8081; } # 利用 split_clients 模块按比例分配 split_clients "${remote_addr}${remote_port}" $variant { 10% canary; * stable; } server { listen 80; server_name your-domain.com; # 如果已经有 cookie 了，就按 cookie 走 set $target $variant; if ($http_cookie ~* "canary_version=canary") { set $target "canary"; } if ($http_cookie ~* "canary_version=stable") { set $target "stable"; } location / { # 给用户种上 cookie，下次来就固定走同一个版本了 add_header Set-Cookie "canary_version=$target; path=/; max-age=3600"; proxy_pass http://$target; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这个配置用了 split_clients 模块，它会根据你指定的变量（这里用的是客户端 IP 和端口的组合）做哈希，然后按比例分配。第一次访问的时候，10% 的用户会被分到 canary，同时种上一个 Cookie。后续再访问的时候，就直接按 Cookie 走了，不会再重新分配。split_clients 这个模块是 Nginx 自带的，不需要额外安装，很多人不知道有这个东西，其实挺好用的。方案三：基于请求头的分流这种方式在 API 网关场景下用得比较多。前端或者调用方在请求头里带上一个标记，Nginx 根据这个标记来决定路由。# /etc/nginx/conf.d/canary_header.conf upstream stable { server 127.0.0.1:8080; } upstream canary { server 127.0.0.1:8081; } server { listen 80; server_name your-domain.com; set $backend "stable"; # 根据自定义请求头判断 if ($http_x_canary = "true") { set $backend "canary"; } # 也可以根据 User-Agent 判断，比如特定版本的 App if ($http_user_agent ~* "MyApp/2.0") { set $backend "canary"; } location / { proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }测试的时候带上自定义 Header：# 走金丝雀版本 curl -H "X-Canary: true" http://localhost # 走稳定版本（不带 header） curl http://localhost这种方式的好处是控制粒度很细，而且不依赖 Cookie，适合服务间调用的场景。我们之前做微服务灰度的时候就是用的这种方式，在网关层根据请求头做路由，Header 可以在整个调用链路上透传。方案四：基于 IP 的分流有时候你想让特定网段的用户（比如公司内网）先体验新版本，可以基于 IP 来做分流。# /etc/nginx/conf.d/canary_ip.conf upstream stable { server 127.0.0.1:8080; } upstream canary { server 127.0.0.1:8081; } # 定义一个 geo 变量，根据客户端 IP 来赋值 geo $canary_ip { default 0; 192.168.1.0/24 1; # 内网测试网段 10.0.100.0/24 1; # 办公网络 # 也可以指定单个 IP 203.0.113.50 1; } server { listen 80; server_name your-domain.com; set $backend "stable"; if ($canary_ip = "1") { set $backend "canary"; } location / { proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }geo 模块也是 Nginx 内置的，不需要额外安装。它可以根据客户端 IP 地址来设置变量的值，支持 CIDR 格式的网段，用起来很方便。不过要注意一点，如果你的 Nginx 前面还有一层负载均衡（比如云厂商的 SLB），那拿到的 $remote_addr 可能是 SLB 的 IP 而不是真实客户端 IP。这时候需要用 $http_x_forwarded_for 或者配置 set_real_ip_from 来获取真实 IP：# 信任前置负载均衡的 IP set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on;方案五：组合方案（生产推荐）实际生产中，我一般会把上面几种方式组合起来用。比如下面这个配置，优先级是：请求头 > Cookie > IP > 默认权重分配。# /etc/nginx/conf.d/canary_production.conf upstream stable { server 127.0.0.1:8080; } upstream canary { server 127.0.0.1:8081; } geo $canary_by_ip { default 0; 192.168.1.0/24 1; } split_clients "${remote_addr}" $canary_by_weight { 5% 1; * 0; } map $http_x_canary $canary_by_header { "true" 1; default 0; } map $cookie_canary_version $canary_by_cookie { "canary" 1; default 0; } # 综合判断：任意一个条件命中就走 canary map "$canary_by_header:$canary_by_cookie:$canary_by_ip:$canary_by_weight" $target_backend { "~1" canary; # 只要有一个是 1 就走 canary default stable; } server { listen 80; server_name your-domain.com; # 添加响应头，方便排查问题（生产环境可以去掉） add_header X-Backend $target_backend; location / { proxy_pass http://$target_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 超时设置 proxy_connect_timeout 5s; proxy_read_timeout 30s; proxy_send_timeout 30s; } # 健康检查接口（可选） location /health { access_log off; return 200 "OK"; } }这个配置里有个小技巧，就是用 map 指令做多条件的组合判断。"~1" 是正则匹配，只要拼接出来的字符串里包含 1，就走 canary。这样写比嵌套一堆 if 要优雅得多，而且 Nginx 官方也不推荐在 location 里大量使用 if，容易出一些奇怪的问题。另外我加了一个 X-Backend 的响应头，这个在调试的时候特别有用，你可以通过浏览器开发者工具或者 curl 看到当前请求走的是哪个版本：curl -I http://localhost # 看响应头里的 X-Backend 字段生产环境上线之后记得把这个响应头去掉，或者改成只在内网可见，别把内部信息暴露出去了。发布流程和注意事项光有配置还不够，金丝雀发布是一个完整的流程，我把我们团队实际在用的流程分享一下。发布前的准备工作：确认新版本在测试环境已经充分验证过准备好回滚方案，确认回滚操作步骤通知相关人员（开发、测试、产品），约定观察时间确认监控告警已经配置好，能及时发现问题发布过程：第一步，部署新版本到 canary 节点，但先不接入流量：# 先把 canary 的权重设为 0 或者注释掉 upstream backend { server 127.0.0.1:8080 weight=10; server 127.0.0.1:8081 down; # 先标记为 down }第二步，部署完成后，先让内部人员通过 Header 或 Cookie 的方式访问 canary 版本，做一轮冒烟测试。第三步，冒烟测试通过后，开放小比例流量（比如 5%）：# 修改配置，开放 5% 流量 split_clients "${remote_addr}" $canary_by_weight { 5% 1; * 0; }nginx -t && nginx -s reload第四步，观察监控指标，重点关注：错误率：新版本的 5xx 错误率是否正常响应时间：P99、P95 延迟有没有明显上升业务指标：订单量、转化率等核心指标有没有异常日志：有没有异常的错误日志第五步，如果一切正常，逐步扩大流量比例：5% → 10% → 30% → 50% → 100%。每次调整后至少观察 15-30 分钟。如果发现问题需要回滚，操作也很简单：# 方式一：把 canary 标记为 down upstream backend { server 127.0.0.1:8080 weight=10; server 127.0.0.1:8081 down; } # 方式二：直接把 canary 的权重设为 0 split_clients "${remote_addr}" $canary_by_weight { 0% 1; * 0; }nginx -t && nginx -s reloadreload 是平滑重载，不会中断现有连接，所以不用担心回滚操作本身会造成影响。还有几个容易踩坑的地方说一下：1、Session 一致性问题。如果你的应用是有状态的（比如 Session 存在本地），用户从 stable 切到 canary 的时候可能会丢失登录状态。解决办法是把 Session 放到 Redis 之类的外部存储里，或者用 JWT 这种无状态的认证方式。2、数据库兼容性。如果新版本涉及数据库表结构变更，一定要确保新老版本都能兼容当前的数据库结构。一般的做法是先做数据库变更（要向后兼容），再发布新版本代码。3、静态资源的问题。如果前端静态资源也有变更，要注意 CDN 缓存的影响。建议静态资源用带 hash 的文件名，避免缓存导致新老版本的资源混用。4、WebSocket 连接。如果你的应用用了 WebSocket，金丝雀发布的时候要注意长连接的处理。Nginx reload 不会断开已有的 WebSocket 连接，但新连接会按新的规则分配。监控配置参考做金丝雀发布，监控是必不可少的。这里给一个简单的 Nginx 日志配置，方便你区分不同版本的请求：# 自定义日志格式，加上 backend 标记 log_format canary_log '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'backend=$target_backend rt=$request_time'; server { # ... access_log /var/log/nginx/canary_access.log canary_log; }这样在日志里就能看到每个请求走的是哪个后端，配合 ELK 或者 Grafana + Loki 之类的日志分析工具，可以很方便地对比两个版本的各项指标。用 awk 简单统计一下各版本的请求量和错误率：# 统计各版本请求量 awk '{for(i=1;i<=NF;i++) if($i ~ /^backend=/) print $i}' /var/log/nginx/canary_access.log | sort | uniq -c # 统计各版本的 5xx 错误 awk '$9 >= 500 {for(i=1;i<=NF;i++) if($i ~ /^backend=/) print $i}' /var/log/nginx/canary_access.log | sort | uniq -c写在最后金丝雀发布说白了就是一个"小心试探"的过程，核心思想就是用最小的代价去验证新版本的稳定性。Nginx 作为实现工具，配置灵活，性能也好，基本上能覆盖大多数场景。当然，如果你的业务规模比较大，服务数量比较多，可能需要更专业的灰度发布平台，比如结合 Kubernetes 的 Istio、或者用 OpenResty 做更复杂的流量治理。但对于大多数中小团队来说，Nginx 原生的这些能力已经足够了，不需要引入太多额外的复杂度。我个人的建议是，不管你用什么方案，关键是要把发布流程规范化，每次发布都按照固定的步骤来，观察该观察的指标，该回滚的时候果断回滚。工具只是手段，流程和意识才是核心。如果这篇文章对你有帮助，欢迎转发给身边的运维和开发同学，大家一起少踩坑。也欢迎关注我，后续会继续分享更多生产实践相关的内容。公众号：运维躬行录个人博客：躬行笔记
线上系统出问题了？strace工具让你分分钟定位根因！
说到线上排查问题，我想起前段时间遇到的一个头疼事。有个服务突然变得特别慢，用户投诉一大堆，领导也在催。各种监控指标看起来都正常，CPU、内存、网络都没啥异常，但就是慢得要死。这种时候你说慌不慌？表面上风平浪静，实际上程序内部不知道在搞什么鬼。传统的监控工具告诉你的信息有限，日志也没发现啥异常。这时候就需要深入到系统调用层面去看看到底发生了什么。今天我就来聊聊strace这个神器，它可以说是我们运维人员手里的"透视镜"，能够清楚地看到程序和操作系统之间的每一次交互。什么是strace？strace就是system call trace的缩写，顾名思义就是用来跟踪系统调用的工具。你可以把它理解成一个"窃听器"，专门监听程序和内核之间的对话。每当程序需要做一些操作，比如读写文件、网络通信、创建进程等等，都需要通过系统调用来请求内核帮忙。而strace就能把这些"请求"全部记录下来，包括调用了什么函数、传递了什么参数、返回了什么结果，甚至连耗时多长都能告诉你。我记得刚开始接触strace的时候，看到那密密麻麻的输出还挺懵的。但用熟了之后真的是爱不释手，很多看似复杂的问题都能通过它快速定位。基础使用方法最简单的用法就是直接跟踪一个程序的执行过程：strace ./my_program这样就能看到my_program运行过程中的所有系统调用了。不过输出信息会很多，刷屏速度特别快，建议把结果重定向到文件里：strace -o output.txt ./my_program这样所有的跟踪信息就保存到output.txt文件中了，可以慢慢分析。有时候我们不关心所有的系统调用，只想看特定的几种，比如只关心文件操作相关的调用：strace -e open,read,write ./my_program这个参数特别有用，能够过滤掉很多无关的噪音，让你专注于关心的部分。实战案例：排查文件访问问题我来分享一个真实的案例。之前有个应用启动特别慢，用户反馈说要等好几分钟才能正常使用。一开始我们怀疑是数据库连接的问题，各种调优数据库配置，结果没什么效果。后来想到用strace来看看启动过程中到底在干什么：strace -o startup.log -e trace=file ./myapp这里我用了trace=file参数，只关注文件相关的系统调用。分析日志后发现了问题所在：openat(AT_FDCWD, "/usr/share/fonts/font1.ttf", O_RDONLY) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/share/fonts/font2.ttf", O_RDONLY) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/usr/share/fonts/font3.ttf", O_RDONLY) = -1 ENOENT (No such file or directory) ...原来程序在启动时要加载几百个字体文件，但这些文件在服务器上根本不存在！每次尝试打开文件都会失败，然后程序还会重试，导致启动时间被大大延长。找到根因后解决就简单了，要么安装对应的字体包，要么修改程序配置不加载这些字体。最终选择了后者，启动时间从几分钟缩短到了几秒钟。这个案例说明了strace的威力，有时候问题的根因可能完全超出你的想象。跟踪运行中的进程除了跟踪新启动的程序，strace还能attach到已经运行的进程上：strace -p <PID>这个功能在生产环境中特别有用。比如某个服务突然CPU使用率飙高，但又不能重启，这时候就可以用strace attach上去看看它在干什么。我遇到过一个Java应用突然CPU占用100%的情况，用strace attach上去后发现：futex(0x7f8a4c000b54, FUTEX_WAKE_PRIVATE, 1) = 0 futex(0x7f8a4c000b54, FUTEX_WAKE_PRIVATE, 1) = 0 futex(0x7f8a4c000b54, FUTEX_WAKE_PRIVATE, 1) = 0 ...大量的futex调用在疯狂执行，这通常表示线程间的锁竞争非常激烈。后来配合jstack等工具进一步分析，发现是代码中的一个死锁导致的。分析网络相关问题strace在排查网络问题时也很给力。比如程序连接数据库很慢，可以这样跟踪：strace -e trace=network ./myapp或者更具体一点：strace -e connect,send,recv ./myapp我曾经遇到过一个奇怪的问题，应用连接Redis总是超时。从监控看Redis服务器完全正常，网络也没问题。用strace跟踪后发现：socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3 connect(3, {sa_family=AF_INET, sin_port=htons(6379), sin_addr=inet_addr("192.168.1.100")}, 16) = -1 EINPROGRESS (Operation now in progress) poll([{fd=3, events=POLLOUT}], 1, 5000) = 0 (Timeout)发现程序确实在尝试连接Redis，但connect调用返回了EINPROGRESS，然后poll等待5秒钟后超时。这说明网络层面的连接建立就有问题。进一步排查发现是防火墙规则的问题，某个安全策略阻止了到Redis服务器的连接。修改防火墙规则后问题立即解决。性能分析技巧strace还有一个很实用的功能就是统计系统调用的耗时：strace -c ./myapp这会在程序结束后显示一个统计表格，告诉你哪些系统调用耗时最多、调用次数最频繁等信息。对于性能优化来说特别有价值。比如某次分析一个批处理程序时，发现write调用占用了大部分时间：% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 89.23 2.456780 2457 1000 write 5.12 0.141023 141 1000 read 3.45 0.094856 95 1000 open 2.20 0.060541 61 1000 close这说明程序在I/O写入上花费了太多时间。进一步分析发现程序每次只写很少的数据，但调用次数很频繁。后来通过缓冲机制批量写入，性能提升了好几倍。过滤和定制输出实际使用中，strace的输出往往非常多，需要合理过滤才能找到有用信息。除了前面提到的-e参数，还有一些其他的过滤选项：只看失败的系统调用：strace -e trace=all -e status=failed ./myapp只看特定的信号：strace -e signal=all ./myapp显示时间戳：strace -t ./myapp显示相对时间：strace -r ./myapp这些选项可以组合使用，根据具体需求来定制输出格式。注意事项和最佳实践使用strace时有几个需要注意的地方：性能影响：strace会显著降低程序的执行速度，因为每个系统调用都需要被拦截和记录。在生产环境使用时要谨慎，最好在低峰期或者测试环境中使用。权限问题：要跟踪其他用户的进程，通常需要root权限。而且有些系统调用可能涉及敏感信息，使用时要注意安全性。输出量大：对于长时间运行的程序，strace的输出可能会非常庞大。建议使用-o参数将输出重定向到文件，并且合理使用过滤选项。我个人的经验是，刚开始使用strace时不要被复杂的输出吓到。多看几次就会发现规律，很多系统调用的含义其实很直观。比如open就是打开文件，read就是读取数据，connect就是建立连接等等。结合其他工具使用strace虽然强大，但单独使用有时候还不够。我通常会结合其他工具一起使用：配合ps、top查看进程状态配合netstat、ss查看网络连接配合lsof查看打开的文件配合tcpdump分析网络包这样能够从多个角度全面分析问题，提高排查效率。比如某次排查一个内存泄漏问题时，先用top发现内存使用率持续增长，然后用strace跟踪发现程序在不停地分配内存但很少释放，最后配合valgrind等内存分析工具定位到了具体的代码位置。实战演练我建议大家平时多练练手，可以用strace跟踪一些常见的命令看看它们的行为：strace ls /tmp strace cat /etc/passwd strace ping -c 3 baidu.com通过观察这些简单命令的系统调用，能够加深对Linux系统工作原理的理解。还可以写一些简单的测试程序，故意制造一些问题，然后用strace来排查。比如：#include <stdio.h> #include <unistd.h> int main() { FILE *fp = fopen("/nonexistent/file.txt", "r"); if (fp == NULL) { perror("fopen failed"); return 1; } fclose(fp); return 0; }编译运行这个程序，然后用strace跟踪：gcc test.c -o test strace ./test你会看到程序尝试打开一个不存在的文件，系统调用返回错误，这样就能直观地理解程序的执行流程。总的来说，strace是每个运维人员都应该掌握的重要工具。它能够帮助我们深入理解程序的行为，快速定位各种奇怪的问题。虽然刚开始可能觉得输出信息有点复杂，但只要多练习几次，很快就能上手。在实际工作中，我经常把strace和其他监控工具结合起来使用，形成一套完整的问题排查流程。遇到问题时不要慌，先用基础监控工具看看大概情况，然后用strace深入到系统调用层面分析，基本上大部分问题都能找到根因。记住，工具只是手段，关键还是要理解背后的原理。多实践、多思考，相信大家都能成为排查问题的高手！如果觉得这篇文章对你有帮助，欢迎转发分享给更多的小伙伴。我们一起在运维的道路上互相学习，共同进步！公众号：运维躬行录个人博客：躬行笔记
手把手教你落地网络入侵检测系统（NIDS），从选型到上线全记录
说实话，搞安全这块东西，很多运维同学是又爱又恨。爱的是确实能帮你挡住不少事儿，恨的是部署起来各种坑，配不好还一堆误报，搞得你天天看告警看到想吐。我之前在客户公司负责过一套NIDS的完整落地，从前期调研到最后稳定运行，前前后后折腾了差不多两个月。今天就把这个过程掏出来跟大家聊聊，尽量把踩过的坑和一些实际经验都说清楚。先搞清楚你到底要监控什么很多人上来就想装Snort、装Suricata，工具先搞起来再说。我一开始也是这个思路，后来发现这样搞完全是在浪费时间。你得先坐下来想清楚几个问题：你的网络环境里，哪些流量是需要重点盯着的？敏感数据在哪？常见的威胁类型是什么？拿我们当时的情况来说，公司有一套对外的Web业务系统，还有内部的OA和数据库集群。对外的业务系统天天被扫描、被尝试SQL注入、XSS什么的，这块肯定是重点。内部的话，主要担心的是横向移动，就是万一有人进来了，在内网里到处乱窜。我当时列了一个简单的表格：监控区域流量类型关注的威胁优先级DMZ区HTTP/HTTPSWeb攻击、扫描探测高核心交换全流量横向移动、C2通信高数据库段DB协议流量异常查询、数据外泄中办公网混合流量恶意软件、钓鱼中这个表看起来简单，但真的很有用。后面选工具、写规则、部署探针的时候，都是围绕这个来的。不然你部署完了发现，关键的地方没覆盖到，不关键的地方一堆告警，那就白忙活了。工具选型：Snort还是Suricata？开源的NIDS工具，绕不开这两个。商业的像Palo Alto、FireEye那些，预算够当然好，但大多数公司的安全预算你懂的...所以我们还是聊开源方案。Snort 是老牌子了，社区大，规则多，文档也全。但它有个问题，单线程。在流量大的环境里，性能瓶颈很明显。我之前在测试环境里跑过，跑到2Gbps左右的流量就开始丢包了。Suricata 是后来者，天然支持多线程，性能上确实比Snort强不少。而且它兼容Snort的规则格式，也就是说Snort的规则你拿过来基本能直接用。另外Suricata原生支持输出EVE JSON格式的日志，对接ELK那套东西非常方便。我们最后选的是Suricata，原因很直接：核心交换那个位置的流量峰值能到5Gbps，Snort扛不住。不过也不是说Snort就不好。如果你的网络规模不大，流量也就几百兆，Snort完全够用，而且上手更简单一些。还有一个值得提的是 Zeek（以前叫Bro），严格来说它不算传统的IDS，更偏向于网络流量分析和日志生成。它不做实时告警，但生成的连接日志、DNS日志、HTTP日志这些，拿来做事后分析和威胁狩猎非常好用。我们后来也部署了Zeek，和Suricata配合着用，一个管实时告警，一个管深度分析。硬件和网络架构怎么搞工具选好了，接下来就是怎么把流量喂给它。NIDS是旁路部署的，不像防火墙那样串在链路上。它需要的是流量的镜像副本。一般有两种方式：1. 交换机端口镜像（SPAN/Mirror Port）这是最常见的方式。在核心交换机上配一个镜像端口，把需要监控的VLAN流量或者特定端口的流量镜像出来，接到NIDS传感器上。我们用的是华为的交换机，配置大概长这样：# 创建观察端口（连接NIDS传感器的端口） observe-port 1 interface GigabitEthernet 0/0/24 # 配置镜像源端口 interface GigabitEthernet 0/0/1 port-mirroring to observe-port 1 both如果是思科的设备：monitor session 1 source interface Gi0/1 both monitor session 1 destination interface Gi0/24这里有个坑要注意：镜像端口的带宽要够。如果你镜像的源端口是10G的，但目标端口只有1G，那流量大的时候肯定丢包。我们当时就吃了这个亏，一开始用的1G口做镜像，结果高峰期丢了将近30%的包，好多攻击流量直接漏掉了。后来换成了10G的口才解决。2. TAP设备如果预算允许，用网络TAP（Test Access Point）是更好的选择。TAP是一个硬件设备，直接接在链路上，把流量复制一份出来。它的好处是不依赖交换机的镜像功能，也不会因为交换机负载高而丢包。我们在DMZ区的出口就用了一个TAP，大概花了几千块钱，买的国产的，效果还行。传感器的硬件配置跑Suricata的服务器，配置不能太寒酸。我们用的配置是：CPU：Intel Xeon E5-2680 v4（14核28线程）内存：64GB DDR4网卡：Intel X710 10GbE（支持多队列）硬盘：500GB SSD（主要存日志，日志量大的话还得考虑外挂存储）网卡这块特别说一下，一定要选支持多队列的网卡，这样才能充分利用Suricata的多线程能力。Intel的X710和X520系列都可以。另外，接收镜像流量的网卡不要配IP地址，就让它处于混杂模式（promisc mode），只收不发。# 设置网卡为混杂模式 ip link set eth1 promisc on # 关闭网卡的一些offload功能，避免影响抓包 ethtool -K eth1 rx off tx off gro off lro off关闭offload这一步很重要，不然网卡会在硬件层面对数据包做合并处理，Suricata拿到的就不是原始的包了，可能导致检测失败。Suricata安装和基础配置以CentOS 7为例，安装过程记录一下：# 安装EPEL和依赖 yum install epel-release -y yum install gcc gcc-c++ make automake autoconf libtool pcre-devel \ libpcap-devel libyaml-devel file-devel zlib-devel jansson-devel \ nss-devel libcap-ng-devel libnet-devel lua-devel -y # 安装Rust（Suricata 6.0+需要） curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env # 下载并编译Suricata wget https://www.openinfosecfoundation.org/download/suricata-6.0.13.tar.gz tar xzf suricata-6.0.13.tar.gz cd suricata-6.0.13 ./configure --prefix=/usr --sysconfdir=/etc --localstatedir=/var \ --enable-nfqueue --enable-lua make -j$(nproc) make install make install-conf当然你也可以直接用包管理器装，省事儿：# 添加OISF的源 yum install https://copr.fedorainfracloud.org/coprs/jasonish/suricata-stable/repo/epel-7/jasonish-suricata-stable-epel-7.repo yum install suricata -y装完之后，主配置文件在 /etc/suricata/suricata.yaml，这个文件巨长，有好几千行。但核心要改的地方其实没几个。# 定义你的内网地址段 vars: address-groups: HOME_NET: "[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]" EXTERNAL_NET: "!$HOME_NET" port-groups: HTTP_PORTS: "80" SHELLCODE_PORTS: "!80" SSH_PORTS: "22" DNS_PORTS: "53" # 配置监听接口 af-packet: - interface: eth1 cluster-id: 99 cluster-type: cluster_flow defrag: yes use-mmap: yes tpacket-v3: yes ring-size: 2048 block-size: 32768 # 配置规则文件路径 default-rule-path: /var/lib/suricata/rules rule-files: - suricata.rules # EVE日志输出配置 outputs: - eve-log: enabled: yes filetype: regular filename: eve.json types: - alert: tagged-packets: yes - http: extended: yes - dns: - tls: extended: yes - files: force-magic: yes - flow这里 HOME_NET 的配置特别重要，一定要设成你实际的内网地址段。Suricata的很多规则都是基于 "从外到内" 或者 "从内到外" 的方向来判断的，如果HOME_NET配错了，很多规则直接就不生效。af-packet 是Linux上推荐的抓包方式，性能比libpcap好很多。cluster_flow 模式会把同一个连接的数据包分配到同一个线程处理，避免乱序。规则管理这块水很深Suricata自带一个规则更新工具 suricata-update，用起来还挺方便的：# 更新规则 suricata-update # 查看可用的规则源 suricata-update list-sources # 启用ET Open规则集（免费的） suricata-update enable-source et/open # 启用后重新更新 suricata-updateET Open是Emerging Threats提供的免费规则集，质量还可以，覆盖面也比较广。如果你有预算，可以买ET Pro的订阅，规则更全更新也更快。但说实话，默认的规则集直接用，你会被告警淹没。我们刚上线的时候，一天产生了将近10万条告警，根本看不过来。大部分都是误报或者低价值的告警。所以规则调优是必须要做的事情。我的做法是分几步走：第一步：先跑几天，收集数据不要急着处理告警，先让它跑着，看看告警的分布情况。# 统计告警按signature排序 cat /var/log/suricata/eve.json | jq -r 'select(.event_type=="alert") | .alert.signature' | sort | uniq -c | sort -rn | head -30你会发现排在前面的往往就那么几条规则，贡献了80%以上的告警量。第二步：逐条分析，该禁的禁比如我们当时排在第一的是一条 ET POLICY GNU/Linux APT User-Agent Outbound likely related to package management 的规则。这条规则检测的是Linux服务器用apt下载软件包的行为，在我们的环境里完全是正常操作，直接禁掉。# 创建禁用规则列表 echo "2013504" >> /etc/suricata/disable.conf # 重新更新规则（会自动应用禁用列表） suricata-update第三步：针对业务写自定义规则通用规则集不可能覆盖你所有的业务场景。比如我们有个内部系统，正常情况下只有特定的几台服务器会访问数据库的3306端口，如果其他IP去访问了，那就很可疑。这种就得自己写规则：alert tcp ![$DB_CLIENT_1,$DB_CLIENT_2,$DB_CLIENT_3] any -> $DB_SERVER 3306 (msg:"Unauthorized MySQL Access Attempt"; flow:to_server,established; sid:1000001; rev:1; classtype:policy-violation;)写自定义规则的时候有个技巧，sid从1000001开始编号，这样不会和官方规则集冲突。还有一个我觉得特别实用的规则，检测内网机器对外发起DNS查询到非公司DNS服务器的情况。因为很多恶意软件会用DNS隧道来做C2通信，而且它们通常不会用你公司内部的DNS服务器：alert udp $HOME_NET any -> !$COMPANY_DNS 53 (msg:"DNS query to non-corporate DNS server"; sid:1000002; rev:1; classtype:policy-violation;)跟ELK对接，让告警可视化Suricata产生的eve.json日志，直接看JSON文件肯定不现实。我们用的是经典的ELK方案来做日志收集和可视化。在Suricata服务器上装Filebeat：# /etc/filebeat/filebeat.yml filebeat.inputs: - type: log enabled: true paths: - /var/log/suricata/eve.json json.keys_under_root: true json.add_error_key: true output.elasticsearch: hosts: ["10.0.0.100:9200"] index: "suricata-%{+yyyy.MM.dd}" setup.template.name: "suricata" setup.template.pattern: "suricata-*"然后在Kibana里建个Index Pattern，就能看到所有的告警了。我建了几个比较实用的Dashboard面板：告警总览：按时间线展示告警数量趋势，能快速发现异常的告警峰值Top 10告警规则：看哪些规则触发最多，方便持续调优Top 10源IP/目的IP：快速定位可疑的IP地址告警严重等级分布：饼图展示高中低告警的比例DNS查询Top 20：配合Zeek的DNS日志，看有没有奇怪的域名查询有了这些面板，每天早上花10分钟扫一眼，心里就有数了。别忘了HIDS，两者配合才完整NIDS看的是网络层面的流量，但有些东西它看不到。比如加密流量（HTTPS），NIDS只能看到TLS握手的信息，里面的内容是看不到的。再比如主机上的文件被篡改了、有人用sudo提权了，这些都是网络层面发现不了的。所以NIDS和HIDS（主机入侵检测系统）应该配合着用。HIDS推荐OSSEC或者它的分支Wazuh，能监控文件完整性、系统日志、rootkit检测这些。我们的做法是：NIDS负责盯网络流量，发现扫描、攻击尝试、异常连接这些；HIDS负责盯主机层面，发现文件变更、异常登录、可疑进程这些。两边的告警都汇总到ELK里面，做关联分析。比如NIDS告警说某个外部IP在对Web服务器做SQL注入尝试，然后HIDS告警说同一台Web服务器上的数据库配置文件被修改了，这两个事件关联起来看，那基本就可以确认是被打进来了。单看任何一边都不一定能确定。威胁情报集成光靠规则匹配其实是不够的，因为规则都是已知的模式。如果攻击者用了新的手法，规则库里没有，那就检测不到。引入威胁情报可以弥补一部分。简单来说就是把已知的恶意IP、恶意域名、恶意文件Hash这些信息喂给NIDS，让它去匹配。Suricata支持加载IP黑名单，你可以从一些免费的威胁情报源获取数据：Abuse.ch 的各种黑名单（SSL黑名单、恶意URL、Botnet C2等）AlienVault OTX 的开放威胁情报CIRCL 的被动DNS数据我写了个简单的脚本，每天自动拉取这些情报源，转换成Suricata的规则格式，然后reload：#!/bin/bash # 下载Abuse.ch的Botnet C2 IP黑名单 wget -q https://feodotracker.abuse.ch/downloads/ipblocklist_recommended.txt -O /tmp/c2_ips.txt # 转换成Suricata规则 > /var/lib/suricata/rules/threat_intel.rules while read ip; do "$ip" =~ ^#.*$ && continue -z "$ip" && continue echo "alert ip $ip any -> \$HOME_NET any (msg:\"TI - Known Botnet C2 IP: $ip\"; sid:$((2000000 + RANDOM)); rev:1; classtype:trojan-activity;)" >> /var/lib/suricata/rules/threat_intel.rules done < /tmp/c2_ips.txt # 热重载规则 suricatasc -c reload-rulessuricatasc -c reload-rules 这个命令可以在不重启Suricata的情况下重新加载规则，不会中断检测，很实用。日常运维和持续优化系统上线之后，不是说就不用管了。NIDS的运维其实是个持续的过程。规则更新我设了个crontab，每天凌晨3点自动更新规则：0 3 * * * /usr/bin/suricata-update && /usr/bin/suricatasc -c reload-rules性能监控Suricata自身的性能也要关注。它有个stats日志，记录了丢包率、内存使用等信息：# suricata.yaml中开启stats stats: enabled: yes interval: 30重点关注 capture.kernel_drops 这个指标，如果丢包率持续上升，说明要么是硬件性能不够了，要么是规则太多导致处理不过来。我们之前遇到过一次丢包率突然飙升的情况，排查下来发现是有人在内网做大文件传输，流量瞬间冲到了8Gbps，超过了传感器的处理能力。后来的解决办法是在镜像端口上做了过滤，只镜像需要监控的VLAN流量，不相关的大流量传输就不镜像了。误报处理这个是永远干不完的活儿。我的经验是建一个误报处理的文档或者wiki，每次处理完一个误报，就记录下来：哪条规则、什么情况触发的、怎么处理的（是禁用规则还是加了例外）。时间长了这就是一份很有价值的知识库。定期review每个月抽一天时间，把这个月的告警数据拉出来看一看。哪些规则从来没触发过？是因为确实没有这种威胁，还是因为部署位置不对导致看不到？哪些规则触发太多？是不是需要进一步调优？网络架构有没有变化，需不需要调整探针位置？关于合规和隐私这个点很多人会忽略，但其实挺重要的。NIDS抓的是网络流量，里面可能包含用户的个人信息、通信内容这些敏感数据。我们当时做了几件事：明确了NIDS只用于安全检测目的，不会用来监控员工的上网行为日志的访问权限做了严格控制，只有安全团队的人能看日志保留周期设为90天，过期自动删除在员工手册里加了一条关于网络安全监控的说明这些东西看起来是走形式，但万一真出了事，有没有这些合规措施，差别还是很大的。写到这里差不多了。整个NIDS的落地过程，说白了就是：想清楚要监控什么 → 选对工具 → 把流量引过来 → 装好配好 → 写规则调规则 → 接上日志平台 → 持续运营优化。每一步都不难，但每一步都有坑。最关键的其实是最后一步，持续运营。我见过不少公司花了大价钱买了商业IDS/IPS，装上之后就没人管了，告警从来不看，规则从来不更新，跟没装一样。工具再好，没有人去运营，就是个摆设。希望这篇文章对正在考虑部署NIDS的朋友有些帮助。如果你在部署过程中遇到什么问题，欢迎留言交流。觉得有用的话，帮忙点个赞转发一下，让更多人看到，感谢！公众号：运维躬行录个人博客：躬行笔记