TCC + Saga——补偿型分布式事务

TCC + Saga 📖 前置阅读:本文假设读者已理解 Seata AT 模式的原理和局限。如果还不熟悉,建议先阅读 Seata AT 模式——undo_log 与二阶段原理。 一、⚡ AT 能回滚库存——但能回滚一条"已发出的短信"吗? AT 模式的局限——上一篇说了: AT 的自动回滚依赖 undo_log——生成反向 SQL INSERT → DELETE(undo_log 记录自增 ID——反向就是 DELETE) UPDATE → UPDATE(undo_log 记录前置镜像——反向就是把值改回去) 但以下操作——数据库回滚不了: ① 发了优惠券——HTTP POST 到营销系统的 API——数据库回滚不了 HTTP 调用 ② 发了短信——调了阿里云短信 API——阿里云不会因为你的"反向 SQL"就收回短信 ③ 调了第三方支付——Payment API 已经扣了钱——不能"生成反向 HTTP"退钱 ④ 给 Redis 写了一个计数器——Redis 没有 undo_log——AT 管不了 TCC 和 Saga 就是为这而生的——手动补偿——操作本身和撤回操作都由你写代码实现。 二、🔄 TCC——Try / Confirm / Cancel——你自己管理回滚 2.1 TCC 的本质——每个操作配一个"撤销操作" TCC 把每个业务操作拆成三个方法: Try(尝试) —— 预留资源——但不真正执行 Confirm(确认) —— 真正执行——Try 预留的资源生效 Cancel(取消) —— 释放 Try 预留的资源——回滚 和 AT 的区别: AT:你写一套代码——Seata 自动生成"撤销操作"(反向 SQL) TCC:你写三套代码——Try(正向)、Confirm(确认)、Cancel(撤销) → 写了三套代码——能处理任何类型的操作——不再局限于数据库 2.2 示例——“创建订单 + 发优惠券 + 扣积分”——用 TCC // ===== 场景:下单时——创建订单 + 发优惠券 + 扣积分 ===== // 订单是 DB 操作——但发优惠券是 HTTP API——扣积分也是 HTTP API // AT 回滚不了 HTTP API——用 TCC // ===== 订单服务——TCC 接口 ===== public interface OrderTccAction { /** * Try:预创建订单——状态为 PENDING——库存还没扣——订单还不能支付 * @param businessContext 在 TM 端传入的参数——和 @BusinessActionContextParameter 对应 */ @TwoPhaseBusinessAction( name = "order-create", // TCC 资源名 commitMethod = "confirmCreateOrder", // Confirm 方法 rollbackMethod = "cancelCreateOrder" // Cancel 方法 ) boolean tryCreateOrder( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "items") List<OrderItemDto> items, @BusinessActionContextParameter(paramName = "totalAmount") BigDecimal totalAmount ); /** * Confirm:把订单从 PENDING 变为 CREATED——正式生效 */ boolean confirmCreateOrder(BusinessActionContext context); /** * Cancel:把 PENDING 的订单变为 CANCELLED——释放预占 */ boolean cancelCreateOrder(BusinessActionContext context); } // ===== 订单服务——TCC 实现 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private OrderMapper orderMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { // ① 预创建订单——状态为 PENDING——不是正式订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING); // ← PENDING——不是正式订单——不可支付 order.setCreatedAt(LocalDateTime.now()); orderMapper.insert(order); // ② 把 orderId 存入 BusinessActionContext——Confirm/Cancel 会用到 // Seata 自动把方法返回值之外的参数存入 Context // 这里通过 RootContext 手动放 RootContext.bind("orderId_" + RootContext.getXID(), order.getId()); return true; // Try 成功——等待 TC 通知 Confirm 或 Cancel } @Override @Transactional public boolean confirmCreateOrder(BusinessActionContext context) { // ① 从 Context 中取出 orderId Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); // ② 把订单状态从 PENDING → CREATED——正式生效 Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.PENDING) { // 幂等——如果已经 Confirm 过了——不再处理 return true; } order.setStatus(OrderStatus.CREATED); orderMapper.updateById(order); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); Order order = orderMapper.selectById(orderId); if (order == null) { // 空回滚——Try 还没执行——Cancel 先到了——不做处理 return true; } if (order.getStatus() == OrderStatus.CANCELLED) { // 幂等——已经取消过了 return true; } order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } // ===== 优惠券服务——TCC 接口(HTTP API——AT 回滚不了)===== public interface CouponTccAction { @TwoPhaseBusinessAction( name = "coupon-grant", commitMethod = "confirmGrantCoupon", rollbackMethod = "cancelGrantCoupon" ) boolean tryGrantCoupon( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "couponType") String couponType ); boolean confirmGrantCoupon(BusinessActionContext context); boolean cancelGrantCoupon(BusinessActionContext context); } @Service public class CouponTccActionImpl implements CouponTccAction { @Autowired private CouponService couponService; // 这个 Service 调外部营销 API @Override public boolean tryGrantCoupon(Long userId, String couponType) { // Try:预占优惠券——调营销 API——标记为用户——但未激活 Coupon coupon = couponService.reserveCoupon(userId, couponType); // 外部 API 返回了 couponId RootContext.bind("couponId_" + RootContext.getXID(), coupon.getId()); return true; } @Override public boolean confirmGrantCoupon(BusinessActionContext context) { // Confirm:激活优惠券——用户可用 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); couponService.activateCoupon(couponId); // HTTP PUT /coupons/{id}/activate return true; } @Override public boolean cancelGrantCoupon(BusinessActionContext context) { // Cancel:回收优惠券——把预留的优惠券放回库存 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); if (couponId == null) { return true; // 空回滚——Try 还没执行完 } couponService.recycleCoupon(couponId); // HTTP DELETE /coupons/{id} return true; } } // ===== TM——全局事务发起方——调各个 TCC 接口 ===== @Service public class OrderApplicationService { @Autowired private OrderTccAction orderTccAction; @Autowired private CouponTccAction couponTccAction; @Autowired private PointTccAction pointTccAction; @GlobalTransactional public Order createOrderWithCoupon(CreateOrderRequest request) { // ① Try:预创建订单 boolean orderTry = orderTccAction.tryCreateOrder( request.getUserId(), request.getItems(), request.getTotalAmount()); if (!orderTry) throw new BusinessException("预创建订单失败"); // ② Try:预发优惠券——不是数据库操作——是 HTTP 调外部 API boolean couponTry = couponTccAction.tryGrantCoupon( request.getUserId(), "FIRST_ORDER"); if (!couponTry) throw new BusinessException("预发优惠券失败"); // ③ Try:预扣积分——也是 HTTP 调外部 API boolean pointTry = pointTccAction.tryDeductPoints( request.getUserId(), 100); if (!pointTry) throw new BusinessException("预扣积分失败"); // ④ 所有 Try 成功——TM 通知 TC 进 Confirm // TC 依次调每个 RM 的 confirmXxx() // → orderTccAction.confirmCreateOrder() ——订单 PENDING→CREATED // → couponTccAction.confirmGrantCoupon() ——优惠券激活 // → pointTccAction.confirmDeductPoints() ——积分确认扣除 return ...; // 返回订单信息 } // 如果任何一个 Try 抛异常——TC 依次调每个 RM 的 cancelXxx() // → orderTccAction.cancelCreateOrder() ——订单 PENDING→CANCELLED // → couponTccAction.cancelGrantCoupon() ——优惠券回收 // → pointTccAction.cancelDeductPoints() ——积分退回 } 2.3 TCC 的两个致命陷阱——空回滚与悬挂 陷阱一:空回滚——Try 没执行——Cancel 先到了 时间线: ① TM 调 Order TCC 的 Try——网络超时——TM 不知道 Try 成功了没有 ② TM 决定回滚——发起 Cancel ③ Cancel 到达 order-service——但此时 Try 还没收到(网络延迟)——或者 Try 正在执行 ④ Cancel 执行时——订单不存在(Try 还没创建)——Cancel 失败 这叫"空回滚"——Cancel 先于 Try 到达 解决——控制记录表: 在 Cancel 中——如果查不到订单——不能报错——记录一条"Cancel 已执行"的空记录 当 Try 终于到达时——先查"Cancel 是否已执行"——如果是——Try 不再执行 陷阱二:悬挂——Try 超时后——Cancel 执行了——Try 又到了 时间线: ① TM 调 Try——Try 执行中——卡住了(GC 停顿——网络延迟) ② TM 等 10 秒超时——发起 Cancel ③ Cancel 到达——顺利执行——订单状态改为 CANCELLED ④ 第 30 秒——Try 终于执行完了——订单 INSERT 进去了——状态是 PENDING ⑤ 结果:Cancel 已经执行了——但 Try 把数据又写进去了——这个 Try"悬挂"了 这叫"悬挂"——Try 在 Cancel 之后到达——Cancel 的撤销效果被 Try 覆盖了 解决——同样用控制记录表: Cancel 执行时——记录一条"xid=xxx 已 Cancel" Try 执行前——先查"xid=xxx 是否已 Cancel"——如果是——拒绝执行 -- ===== TCC 防悬挂 + 空回滚控制表——每个参与 TCC 的服务都建一张 ===== CREATE TABLE tcc_operation_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, xid VARCHAR(128) NOT NULL COMMENT '全局事务 ID', branch_id BIGINT NOT NULL COMMENT '分支事务 ID', action_name VARCHAR(64) NOT NULL COMMENT 'TCC 资源名——order-create/coupon-grant', status TINYINT NOT NULL COMMENT '1-Try 2-Confirm 3-Cancel', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_xid_branch_action (xid, branch_id, action_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; // ===== 改进后的 TCC 实现——带防悬挂 + 空回滚 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private TccOperationRecordMapper recordMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { String xid = RootContext.getXID(); Long branchId = RootContext.getBranchId(); // ① 防悬挂——检查 Cancel 是否已执行 TccOperationRecord cancelRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); // status=3 = Cancel if (cancelRecord != null) { // Cancel 先到了——Try 不能再执行——这就是"悬挂"——拒绝 return false; } // ② 记录 Try TccOperationRecord tryRecord = new TccOperationRecord(); tryRecord.setXid(xid); tryRecord.setBranchId(branchId); tryRecord.setActionName("order-create"); tryRecord.setStatus(1); // Try recordMapper.insert(tryRecord); // ③ 执行业务逻辑 Order order = new Order(); // ... 创建订单——状态 PENDING orderMapper.insert(order); RootContext.bind("orderId_" + xid, order.getId()); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { String xid = context.getXid(); Long branchId = context.getBranchId(); // ① 幂等——检查 Cancel 是否已执行 TccOperationRecord existingRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); if (existingRecord != null) { return true; // Cancel 已经执行过了——幂等——直接返回 } // ② 记录 Cancel——在查订单之前——防止空回滚 TccOperationRecord cancelRecord = new TccOperationRecord(); cancelRecord.setXid(xid); cancelRecord.setBranchId(branchId); cancelRecord.setActionName("order-create"); cancelRecord.setStatus(3); // Cancel recordMapper.insert(cancelRecord); // ③ 空回滚处理——查不到订单——不能报错 Long orderId = (Long) context.getActionContext().get("orderId_" + xid); if (orderId == null) { return true; // Try 没执行——空回滚——正常 } Order order = orderMapper.selectById(orderId); if (order == null) { return true; // Try 没执行完——空回滚——正常 } if (order.getStatus() == OrderStatus.CANCELLED) { return true; // 幂等 } // ④ 执行业务撤销 order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } ⚠️ 新手提示:空回滚和悬挂是 TCC 的两个经典坑——90% 的 TCC 实现都有这两个问题。解决方案就是一张操作记录表——在 Cancel 执行前先记一笔"Cancel 已执行"——在 Try 执行前先查"Cancel 是否已执行"。记录表的唯一键 (xid, branch_id, action_name) 天然防并发——并发的 Try 和 Cancel 只有一个能插入成功。 ...

十二月 29, 2022 · 8 分钟 · 1671 字 · yaomingye

Seata AT 模式——undo_log 与二阶段原理

Seata AT 模式 📖 前置阅读:本文假设读者已理解分布式事务的核心问题(多数据库操作一致性)和 BASE 最终一致性概念。如果还不熟悉,建议先阅读 分布式事务本质——CAP、BASE 与四大方案。 一、⚡ Seata AT 一句话——你写你的 SQL——它自动生成反向 SQL 回想 XA 2PC 的问题——锁住数据库行等协调者——性能黑洞。Seata AT 是怎么解决的? XA 2PC 的做法(性能黑洞): ① Prepare:执行 SQL——不提交——锁住行 ② 等协调者——这期间这些行都是锁着的——其他事务不能动 ③ Commit/Rollback:提交或回滚——释放锁 Seata AT 的做法(攒反向 SQL——事后再补): ① 一阶段:执行 SQL——立即提交——释放锁——同时记录 undo_log(反向 SQL) ② 如果全局事务成功:删掉 undo_log——完事 ③ 如果全局事务失败:根据 undo_log 执行反向 SQL——把数据改回去 核心区别:XA 是锁住行等结果——Seata 是先把活干了——记下 undo_log——失败了逆向执行。 二、🏗️ Seata 架构——TC / TM / RM 三角 flowchart LR TM["TM(Transaction Manager)\n全局事务管理者\n-- 标注 @GlobalTransactional"] RM1["RM(Resource Manager)\norder-service\n-- 操作 order 数据库"] RM2["RM(Resource Manager)\nproduct-service\n-- 操作 product 数据库"] RM3["RM(Resource Manager)\naccount-service\n-- 操作 account 数据库"] TC["TC(Transaction Coordinator)\nSeata Server\n-- 协调全局事务——管理全局锁"] TM -->|"① 开启全局事务"| TC TM -->|"② 调用 order-service"| RM1 RM1 -->|"③ 一阶段:执行业务 SQL + 记录 undo_log + 向 TC 注册分支事务"| TC TM -->|"④ 调用 product-service"| RM2 RM2 -->|"⑤ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑥ 调用 account-service"| RM3 RM3 -->|"⑦ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑧ 全局事务成功 → 通知 TC 提交"| TC TC -->|"⑨ 二阶段:通知所有 RM 删除 undo_log"| RM1 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM2 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM3 classDef style_TM fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa; classDef style_TC fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca; class TM style_TM; class TC style_TC;``` | 角色 | 全称 | 作用 | 在哪里 | |------|------|------|------| | TC | Transaction Coordinator | 协调全局事务——管理全局锁——决定提交还是回滚 | Seata Server——独立部署 | | TM | Transaction Manager | 定义全局事务边界——标 `@GlobalTransactional` 的方法 | 发起方服务(order-service) | | RM | Resource Manager | 管理分支事务——执行 undo_log 记录——向 TC 注册 | 每个参与方服务(product/account) | ## 三、🔍 undo_log 的核心原理——Seata AT 的灵魂 ### 3.1 undo_log 表结构 ```sql -- 每个参与分布式事务的数据库都需要一张 undo_log 表 -- Seata 提供了建表 SQL——直接执行即可 CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL COMMENT '分支事务 ID', xid VARCHAR(100) NOT NULL COMMENT '全局事务 ID', context VARCHAR(128) NOT NULL COMMENT '上下文', rollback_info LONGBLOB NOT NULL COMMENT '回滚信息——记录前置镜像和后置镜像', log_status INT(11) NOT NULL COMMENT '状态:0-正常 1-全局事务已完成', log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 3.2 undo_log 的工作原理——前置镜像 + 后置镜像 以"扣库存"为例——product 服务执行:UPDATE product SET stock = stock - 5 WHERE id = 1 一阶段——执行 SQL + 记录 undo_log: ① Seata 拦截 SQL——先查一下当前数据: SELECT stock FROM product WHERE id = 1 → stock = 10 ② 执行你的业务 SQL: UPDATE product SET stock = stock - 5 WHERE id = 1 → stock = 5 (后置镜像) ③ 立即提交——不锁行——释放数据库锁 ④ 记录 undo_log: 前置镜像:stock = 10 (SQL 执行前的值) 后置镜像:stock = 5 (SQL 执行后的值) 反向 SQL:UPDATE product SET stock = 10 WHERE id = 1 ⑤ 向 TC 注册:我的分支事务完成了——xid=xxx——undo_log 已记录 二阶段——提交: 全局事务成功 → TC 通知所有 RM 提交 → 删掉 undo_log 记录 → 完事 二阶段——回滚: 全局事务失败 → TC 通知所有 RM 回滚 → 读 undo_log 中的反向 SQL → 执行: UPDATE product SET stock = 10 WHERE id = 1 然后把数据改回去了 → 删掉 undo_log 记录 关键——为什么 AT 比 XA 快: ...

十二月 28, 2022 · 8 分钟 · 1567 字 · yaomingye

GitLab CI/CD 多环境部署与生产实践

从 dev 一路跑到 prod——点个按钮就上线 📖 前置阅读:本文假设读者已搭建 GitLab CI/CD 流水线(编译 → 测试 → 扫描 → 构建镜像 → 推送 Harbor),并已将微服务部署在 Kubernetes 上。如果还不熟悉,建议先阅读 搭建与 Pipeline 语法精讲 和 流水线实战。 一、⚡ 镜像推到 Harbor 了——但你还得手动 SSH 上去 kubectl apply——这叫啥 CI/CD? 前两篇搭好了 CI/CD Pipeline——代码 push → 编译 → 测试 → 扫描 → 构建镜像 → 推送到 Harbor。 但 Pipeline 到这里就停了——后面的部署还是人来操作: 当前状态(半自动): ✅ 代码 push → 自动编译、测试、扫描、构建镜像、推送 Harbor ❌ 然后——SSH 到跳板机 → kubectl set image → 看有没有报错 ❌ 然后——curl 验证——发现不对——kubectl rollout undo ❌ 然后——staging 和 prod 没有隔离——改了什么全凭记忆力 → CI 有了——CD 没做——半吊子自动化 真正的 CD——镜像推送到 Harbor 后——自动部署到 dev——验证通过——自动部署到 staging——人工审批——部署到 prod。人对生产的操作只剩下"点一个按钮"。 ...

十二月 26, 2022 · 9 分钟 · 1903 字 · yaomingye

GitLab CI/CD 流水线实战——编译、扫描、构建镜像、推送仓库

代码 push 之后——五道关卡自动跑完 📖 前置阅读:本文假设读者已搭建 GitLab + Runner 并理解 .gitlab-ci.yml 基础语法(stages/jobs/artifacts/cache/rules)。如果还不熟悉,建议先阅读 GitLab CI/CD 搭建与 Pipeline 语法精讲。 一、⚡ 编译过了——但你敢直接部署吗?代码质量谁保证? 上一篇文章的 Pipeline 只做了编译和测试——但真正的 CI/CD 不止这些: 真正的 CI/CD 流水线要回答 5 个问题: ① 编译成功了吗? → mvn compile ② 测试通过了吗? → mvn test + 覆盖率报告 ③ 代码质量合格吗? → SonarQube 扫描 + Quality Gate ④ 镜像构建成功了吗? → docker build ⑤ 镜像推送到仓库了吗? → docker push → Harbor 这 5 步全自动——缺一步都不能算 CI/CD 这篇的目标——搭一条完整的流水线:代码 push → 自动跑完上述 5 步——任何一个环节失败——Pipeline 变红——阻止部署。 二、🏗️ 完整的 Pipeline 架构 flowchart LR Push["git push"] --> Compile["① 编译\nmvn compile"] Compile --> Test["② 单元测试\nmvn test\n+ 覆盖率报告"] Test --> SonarQube["③ 代码扫描\nSonarQube\n+ Quality Gate"] SonarQube --> Package["④ 打包\nmvn package"] Package --> DockerBuild["⑤ 构建镜像\ndocker build"] DockerBuild --> HarborPush["⑥ 推送仓库\ndocker push\n→ Harbor"] HarborPush --> Notify["⑦ 通知\n企业微信/钉钉"] classDef style_SonarQube fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca; classDef style_HarborPush fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe; class SonarQube style_SonarQube; class HarborPush style_HarborPush;``` ## 三、🔧 基础设施——SonarQube + Harbor 搭建 ### 3.1 Docker Compose——加 SonarQube 和 Harbor ```yaml # 在上一篇文章的 docker-compose.yml 基础上加两个服务 version: '3.8' services: # ===== GitLab + Runner(同上一篇——省略)===== # ... # ===== SonarQube——代码质量扫描 ===== sonarqube: image: sonarqube:10.3.0-community container_name: sonarqube environment: SONAR_JDBC_URL: jdbc:postgresql://sonarqube-db:5432/sonarqube SONAR_JDBC_USERNAME: sonar SONAR_JDBC_PASSWORD: sonar123 ports: - "9000:9000" volumes: - sonarqube-data:/opt/sonarqube/data - sonarqube-extensions:/opt/sonarqube/extensions depends_on: - sonarqube-db sonarqube-db: image: postgres:15-alpine container_name: sonarqube-db environment: POSTGRES_USER: sonar POSTGRES_PASSWORD: sonar123 POSTGRES_DB: sonarqube volumes: - sonarqube-db-data:/var/lib/postgresql/data # ===== Harbor——私有 Docker 镜像仓库 ===== # Harbor 官方推荐用 docker-compose 独立部署——这里简化 # 生产环境参考 https://goharbor.io/docs harbor: image: goharbor/registry-photon:v2.9.0 container_name: harbor-registry ports: - "5000:5000" volumes: - harbor-data:/var/lib/registry volumes: sonarqube-data: sonarqube-extensions: sonarqube-db-data: harbor-data: 3.2 SonarQube 初始化——创建项目 Token ① 浏览器打开 http://gitlab.local:9000 ② 默认登录:admin / admin——首次强制修改密码 ③ Administration → Projects → Create Project → Project key: order-service → Project name: order-service → 创建 ④ 创建 Token:My Account → Security → Generate Token → Token name: gitlab-ci → 复制 Token——后续要放在 GitLab CI/CD 变量中 ⑤ 在 GitLab 中配置 SonarQube 变量: GitLab → 项目 → Settings → CI/CD → Variables 添加: SONAR_HOST_URL = http://sonarqube:9000 SONAR_TOKEN = squ_xxxxxxxxxxxxxxxxxxxxxxxxxx ← 刚才复制的 Token 3.3 Maven 项目的 SonarQube 配置 <!-- pom.xml——加 JaCoCo 覆盖率插件 + SonarQube 插件 --> <build> <plugins> <!-- JaCoCo——代码覆盖率 --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> </plugins> </build> <properties> <!-- SonarQube 配置 --> <sonar.host.url>${env.SONAR_HOST_URL}</sonar.host.url> <sonar.login>${env.SONAR_TOKEN}</sonar.login> <sonar.projectKey>order-service</sonar.projectKey> <sonar.projectName>order-service</sonar.projectName> <sonar.java.binaries>target/classes</sonar.java.binaries> <sonar.coverage.jacoco.xmlReportPaths>target/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths> </properties> 四、📝 完整的 .gitlab-ci.yml——从编译到推送镜像 4.1 完整 Pipeline 定义 # order-service/.gitlab-ci.yml # 完整的 CI/CD Pipeline——5 个阶段 stages: - compile # ① 编译 - test # ② 测试 + 覆盖率 - quality # ③ SonarQube 扫描 - package # ④ 打包 + 构建镜像 - push # ⑤ 推送镜像到 Harbor # ===== 全局变量 ===== variables: MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" MAVEN_CLI_OPTS: "-B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN" # Harbor 地址——在 GitLab CI/CD Variables 中配置 HARBOR_URL: "harbor.local:5000" IMAGE_NAME: "$HARBOR_URL/order-service" # ===== 全局缓存——Maven 依赖 ===== cache: key: maven-${CI_COMMIT_REF_SLUG} paths: - .m2/repository/ policy: pull-push # ===== Stage 1: 编译 ===== compile: stage: compile image: maven:3.9-eclipse-temurin-17 script: - mvn $MAVEN_CLI_OPTS compile artifacts: paths: - target/classes/ expire_in: 1 hour tags: - docker # ===== Stage 2: 单元测试 + 覆盖率 ===== unit-test: stage: test image: maven:3.9-eclipse-temurin-17 script: - mvn $MAVEN_CLI_OPTS test jacoco:report artifacts: when: always paths: - target/surefire-reports/ - target/site/jacoco/ # ← JaCoCo 报告——给 SonarQube 用 expire_in: 7 days reports: junit: target/surefire-reports/TEST-*.xml # ← GitLab 自动展示测试结果 coverage: '/Total.*?([0-9]{1,3})%/' # ← GitLab 自动展示覆盖率百分比 tags: - docker # ===== Stage 3: SonarQube 代码扫描 ===== sonarqube-check: stage: quality image: maven:3.9-eclipse-temurin-17 script: - mvn $MAVEN_CLI_OPTS sonar:sonar -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN # 只在 MR 或 main 分支扫描——feature 分支不扫(浪费 SonarQube 资源) rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" tags: - docker # ===== Stage 4: 打包 ===== package: stage: package image: maven:3.9-eclipse-temurin-17 script: - mvn $MAVEN_CLI_OPTS package -DskipTests artifacts: paths: - target/*.jar expire_in: 1 hour tags: - docker # ===== Stage 5: 构建 Docker 镜像并推送到 Harbor ===== docker-build-push: stage: push image: docker:24-dind # ← Docker-in-Docker 镜像——在容器内跑 Docker services: - docker:24-dind # ← 启动 Docker daemon sidecar before_script: - apk add --no-cache bash # Alpine 需要 bash # 等待 Docker daemon 启动 - until docker info > /dev/null 2>&1; do sleep 1; done script: # ① 构建镜像——用 commit SHA 作为 tag - docker build -t $IMAGE_NAME:$CI_COMMIT_SHORT_SHA . # ② 打标签——如果 main 分支——打 latest;如果有 tag——打 release 版本 - | if [ "$CI_COMMIT_BRANCH" = "main" ]; then docker tag $IMAGE_NAME:$CI_COMMIT_SHORT_SHA $IMAGE_NAME:latest fi - | if [ -n "$CI_COMMIT_TAG" ]; then docker tag $IMAGE_NAME:$CI_COMMIT_SHORT_SHA $IMAGE_NAME:$CI_COMMIT_TAG fi # ③ 登录 Harbor——用户名密码配在 GitLab CI/CD Variables 中 - echo "$HARBOR_PASSWORD" | docker login $HARBOR_URL -u "$HARBOR_USERNAME" --password-stdin # ④ 推送所有标签 - docker push $IMAGE_NAME:$CI_COMMIT_SHORT_SHA - | if [ "$CI_COMMIT_BRANCH" = "main" ]; then docker push $IMAGE_NAME:latest fi - | if [ -n "$CI_COMMIT_TAG" ]; then docker push $IMAGE_NAME:$CI_COMMIT_TAG fi tags: - docker 4.2 Dockerfile——配合 CI/CD 的镜像构建 # order-service/Dockerfile # 多阶段构建——分离构建和运行——最终镜像只含 JRE # ===== Stage 1: 构建——用 Maven 编译 ===== FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . # 先下载依赖——利用 Docker 缓存层——pom.xml 不变就不重新下载 RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B # ===== Stage 2: 运行——只含 JRE——镜像小 ===== FROM eclipse-temurin:17-jre-alpine WORKDIR /app # 创建非 root 用户——安全最佳实践 RUN addgroup -S appgroup && adduser -S appuser -G appgroup # 从构建阶段复制 jar COPY --from=builder /build/target/*.jar app.jar # 切换到非 root 用户 USER appuser # Health check——K8s 会调用这个 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD wget -qO- http://localhost:8081/actuator/health || exit 1 EXPOSE 8081 ENTRYPOINT ["java", "-jar", "app.jar"] ⚠️ 新手提示:上面的 Dockerfile 是"CI 内编译"的方式——jar 包在 CI Pipeline 中由 Maven 打好——Dockerfile 只需要 COPY jar。还有一种方式是"Dockerfile 内编译"——CI 不编译——Dockerfile 用多阶段构建完成编译。两种方式的区别: ...

十二月 25, 2022 · 8 分钟 · 1615 字 · yaomingye

DDD 重构实战——什么时候该用 DDD?什么时候 MVC 就够了?

DDD vs MVC:如何选择? 📖 前置阅读:本文假设读者已理解 DDD 的核心概念(实体/值对象/聚合根/限界上下文)和战术代码模板(四层架构/Repository/Domain Service)。如果还不熟悉,建议先阅读 DDD 本质 和 DDD 战术落地。 一、⚡ DDD 这么好——是不是所有服务都要重构一遍? 看完前两篇——概念清楚了——代码模板也有了——冲动上来了: "先把所有微服务用 DDD 重构一遍!" ① user-service → DDD ② order-service → DDD ③ product-service → DDD ④ account-service → DDD ⑤ inventory-service → DDD → 加班 2 个月——重构了一堆——代码没更好——反而更复杂了 DDD 不是银弹——不是所有代码都值得用 DDD。这篇的核心就是告诉你:什么该改、什么不改、改到什么程度。 二、🔍 诊断——我们现有的三个服务——各自是什么情况 2.1 user-service——经典 MVC——不改 // user-service——现有结构 controller/ └─ UserController.java @RestController——GET/POST/PUT service/ └─ UserService.java 简单的增删改查 + 缓存操作 mapper/ └─ UserMapper.java MyBatis——selectById/insert/update model/ └─ User.java 15 个字段——getter/setter // UserService 最复杂的方法——也就 20 行 @Service public class UserService { @Autowired private UserMapper userMapper; @Autowired private RedisTemplate<String, User> redisTemplate; public User getUser(Long userId) { String cacheKey = "user:" + userId; User cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) return cached; User user = userMapper.selectById(userId); if (user != null) redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); return user; } public void updateUser(User user) { user.setUpdatedAt(LocalDateTime.now()); userMapper.updateById(user); redisTemplate.delete("user:" + user.getId()); // 失效缓存 } } 判断——不需要 DDD: ...

十二月 23, 2022 · 13 分钟 · 2614 字 · yaomingye

DDD 战术落地——代码怎么写

DDD 代码怎么写? 📖 前置阅读:本文假设读者已理解实体、值对象、聚合根、限界上下文、领域事件的核心概念。如果还不熟悉,建议先阅读 DDD 本质——领域驱动设计的核心概念。 一、⚡ 概念都懂了——但代码从哪个 package 开始建? 上一篇搞清楚了实体和值对象的区别、聚合根是"一致性边界"——但回到 IDE 中: 现有项目结构(MVC——三层): controller/ ├─ OrderController.java service/ ├─ OrderService.java (3000 行——上帝类) mapper/ ├─ OrderMapper.java ├─ UserMapper.java ← 跨表调用——OrderMapper 也调 UserMapper ├─ ProductMapper.java ← 跨表调用 model/ ├─ Order.java ← 只有 getter/setter——贫血 ├─ User.java ├─ Product.java 问题——现在要改成 DDD——应该怎么建目录?Repository 放哪?Domain Service 放哪? 这篇就是答案——从目录结构开始——到每一层的代码——完整的落地模板。 二、📂 项目结构——DDD 四层架构 2.1 四层——不是"三层 + 一层" 传统 MVC 三层: Controller → Service → Mapper → Service 层无限膨胀——3000 行——什么都往里塞 DDD 四层: interfaces(接口层) → 接收请求、返回响应——薄薄一层 application(应用层) → 编排业务流程——调 Repository、发事件——没有业务逻辑 domain(领域层) → 业务逻辑——聚合根、值对象、Repository 接口、领域事件 infrastructure(基础设施层)→ 技术实现——Repository 实现、数据库访问、MQ 发送 order-service/ ├── interfaces/ ← ① 接口层 │ ├── rest/ │ │ └── OrderController.java # HTTP 接口——接受请求——转给 application 层 │ ├── dto/ │ │ ├── CreateOrderRequest.java # 入参 DTO │ │ └── OrderResponse.java # 出参 DTO │ └── mq/ │ └── OrderEventListener.java # MQ 消息消费——转到 application 层 │ ├── application/ ← ② 应用层 │ ├── OrderApplicationService.java # 编排——调 Repository + 发事件——不包含业务逻辑 │ ├── command/ │ │ └── CreateOrderCommand.java # 应用层自己的命令对象——DTO 转换后的内部对象 │ └── event/ │ └── OrderEventPublisher.java # 事件发布接口——实现在 infrastructure │ ├── domain/ ← ③ 领域层——核心——不依赖任何外部框架 │ ├── model/ │ │ ├── aggregate/ │ │ │ └── Order.java # 聚合根 │ │ ├── entity/ │ │ │ └── OrderItem.java # 聚合内部实体 │ │ ├── valueobject/ │ │ │ ├── Money.java # 值对象——金额 │ │ │ ├── Address.java # 值对象——地址 │ │ │ └── OrderStatus.java # 枚举——订单状态 │ │ └── event/ │ │ ├── OrderCreatedEvent.java # 领域事件 │ │ └── OrderPaidEvent.java │ ├── repository/ │ │ └── OrderRepository.java # Repository 接口——只有接口——没有实现 │ └── service/ │ ├── OrderDomainService.java # 领域服务——跨聚合的逻辑 │ └── PricingService.java # 领域服务——价格计算策略 │ └── infrastructure/ ← ④ 基础设施层 ├── persistence/ │ ├── OrderRepositoryImpl.java # Repository 实现——调 JPA/MyBatis │ ├── mapper/ │ │ ├── OrderMapper.java # MyBatis Mapper │ │ └── OrderItemMapper.java │ └── converter/ │ └── OrderConverter.java # DO ↔ Domain 对象转换 ├── messaging/ │ └── RocketMQEventPublisher.java # 事件发布实现——发到 RocketMQ └── external/ └── UserServiceAdapter.java # 防腐层——隔离外部 User 服务 依赖方向——只能是单向的: ...

十二月 22, 2022 · 12 分钟 · 2499 字 · yaomingye

SkyWalking 中间件集成与链路分析实战

SkyWalking 中间件集成 📖 前置阅读:本文假设读者已搭建 SkyWalking 并了解 Trace/Span/Segment 概念。如果还不熟悉,建议先阅读 SkyWalking 分布式链路追踪——从零搭建 APM 平台。 一、⚡ 全链路通了——但只有 HTTP 调用——Dubbo 和 gRPC 看不到 上一篇搭好了 SkyWalking——/api/orders 的调用链能看到了——HTTP → Feign → MySQL 都有。 但我们的系统不止 HTTP: 真实调用链路: Browser → Gateway → order-service ├─ Feign → user-service (HTTP) ✅ SkyWalking 自动追踪 ├─ Dubbo → account-service (RPC) ❌ 看不到——Dubbo Span 没出来 ├─ gRPC → inventory-service (RPC) ❌ 看不到——gRPC Span 没出来 ├─ Sentinel → 限流熔断 ❌ 看不到——被限流的请求没有标记 ├─ RocketMQ → payment-service (异步) ❌ 看不到——MQ 跨进程 Trace 断了 └─ @Async → sendEmail (异步) ❌ 看不到——异步线程 Trace 丢了 Agent 不是万能的——不同中间件需要不同配置——有些还需要手动埋点。 ...

十二月 20, 2022 · 15 分钟 · 3027 字 · yaomingye

所有中间件指标接入 Prometheus——统一仪表盘实战

所有中间件指标接入 Prometheus 📖 前置阅读:本文假设读者已掌握 Prometheus + Grafana 的基础搭建和 PromQL 语法。如果还不熟悉,建议先阅读 Prometheus + Grafana 环境搭建与指标采集。 一、⚡ 你有 6 种中间件——但你知道一个请求穿过它们时发生了什么吗? 一个请求穿过整个微服务体系要走多少中间件? 浏览器请求 GET /api/orders/100: ① Gateway 收到请求——鉴权、路由匹配 ② Gateway 转发到 order-service(OpenFeign 或 Dubbo) ③ order-service 调 user-service(OpenFeign) ④ order-service 调 product-service(Dubbo) ⑤ 所有服务都在 Nacos 中发现对方 ⑥ Sentinel 在整个过程中限流/熔断 这 6 步中——任何一步慢了——整个请求就慢了 没有统一监控时——你不知道是 Gateway 慢了、OpenFeign 慢了、还是 Dubbo 慢了 这篇的目标——把每种中间件的指标接入 Prometheus,在一张 Grafana 仪表盘上看到全貌。 二、🏗️ 搭建教程——完整的 Docker Compose + Prometheus 配置 上一篇讲了 Prometheus + Grafana 的基础搭建——但那只是两个容器。这篇要接入 6 种中间件——需要一个完整的 Docker Compose 把 Prometheus、Grafana 和所有微服务编排在一起。 ...

十二月 18, 2022 · 9 分钟 · 1867 字 · yaomingye

Prometheus + Grafana 环境搭建与指标采集

Prometheus + Grafana 环境搭建 一、⚡ 微服务上线了——但你知道它现在是死是活吗? 前面写了 6 种中间件、拆了 5 个微服务、配了限流熔断、布了集群——一切看起来很完美。 凌晨 3 点,电话响了:“用户说下单超时——你看一下”。你打开电脑——但你能看什么? 没有监控时: ① SSH 到服务器——tail -f 看日志——满屏 WARN——不知道哪个先出问题 ② 查数据库——慢查询一大堆——不知道是不是今天的查询就变慢了 ③ 调 JVM 看线程——200 个线程在 BLOCKED——不知道是哪个接口引起的 → 30 分钟过去了——你在猜问题在哪 有了 Prometheus + Grafana: ① 打开 Grafana 看板——QPS 正常——但 RT 从 50ms 涨到 3s ② 看 JVM 仪表盘——线程数飚到 500——GC 频繁 ③ 看中间件面板——Dubbo 线程池满了——Sentinel 开始熔断 → 2 分钟定位——是商品服务的 Dubbo 线程池被打满了 监控不是运维的事——是每个后端开发必须掌握的技能。 二、🧩 Prometheus 是什么——拉模型 + 时序数据库 + PromQL 2.1 Prometheus 的 pull model——和传统监控的区别 大多数监控系统是push model——应用主动把指标推给监控 server。Prometheus 是pull model——它定期去应用那里"拉"指标: ...

十二月 17, 2022 · 5 分钟 · 1061 字 · yaomingye

Nacos 集群与生产部署——中间件整合总览

集群与生产部署——中间件整合总览 📖 前置阅读:本文假设读者已掌握 Nacos 的服务发现和配置中心机制。如果还不熟悉,建议先阅读前三篇:核心概念、服务发现、配置中心。 一、⚡ 单机 Nacos 挂了——整个微服务体系全部变瞎子 单机 Nacos 开发环境跑得挺好——但生产环境下,Nacos 是整个微服务体系的命脉: Nacos 挂了 → 后果: ① 新服务无法注册——滚动更新时新实例注册不上 ② 新调用无法发现——Consumer 拿不到最新实例列表(本地缓存还能撑一会) ③ 配置改不了——Sentinel 规则、Gateway 路由、业务开关全改不了 ④ Dashboard 看不了——不知道哪些服务在线、哪些不在线 虽然本地缓存能兜底——但这是"缓兵之计"不是"长久之计" Nacos 集群是必须的——而且要做高可用 二、🏗️ Nacos 集群架构——三个节点 + MySQL 2.1 为什么需要 MySQL? Nacos 内嵌了一个 Derby 数据库(单机默认)。集群模式下 必须用外部 MySQL——所有节点共享同一个数据源: Nacos 集群架构: ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Nacos 1 │ │ Nacos 2 │ │ Nacos 3 │ ← Nacos 节点(无状态) │ :8848 │ │ :8848 │ │ :8848 │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ └───────────────┼───────────────┘ │ ┌──────▼──────┐ │ MySQL │ ← 共享数据库——存注册信息和配置 │ (主从/集群) │ └─────────────┘ Nacos 节点之间用 Raft 协议选举 Leader → Leader 负责写 MySQL → Follower 从 MySQL 读数据并同步到内存 → 任何节点收到客户端请求——都能处理读请求 2.2 配置 MySQL -- 初始化 Nacos 数据库 -- 执行 Nacos 安装包中 conf/mysql-schema.sql -- 创建数据库 CREATE DATABASE IF NOT EXISTS nacos_config DEFAULT CHARACTER SET utf8mb4; -- 配置 Nacos 数据源 # nacos/conf/application.properties # ① MySQL 配置 spring.datasource.platform=mysql db.num=1 db.url.0=jdbc:mysql://mysql-cluster:3306/nacos_config?useSSL=false&allowPublicKeyRetrieval=true db.user.0=nacos db.password.0=nacos_password_123 # ② 切换为集群模式 nacos.core.auth.enabled=true 2.3 集群节点配置 # nacos/conf/cluster.conf——每个节点一行 IP:Port # 注意:端口是 Raft 通信端口——默认 7848(不是 8848!) 10.0.1.11:7848 10.0.1.12:7848 10.0.1.13:7848 三、🐳 Docker Compose——一键启动 Nacos 集群 version: '3.8' services: # ===== MySQL——Nacos 共享存储 ===== mysql: image: mysql:8.0 container_name: nacos-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: nacos_config MYSQL_USER: nacos MYSQL_PASSWORD: nacos_password_123 ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./mysql-schema.sql:/docker-entrypoint-initdb.d/01-schema.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s retries: 5 # ===== Nacos 集群——3 节点 ===== nacos1: image: nacos/nacos-server:v2.3.0 container_name: nacos1 depends_on: mysql: condition: service_healthy environment: - MODE=cluster - NACOS_SERVERS=nacos1:7848,nacos2:7848,nacos3:7848 - SPRING_DATASOURCE_PLATFORM=mysql - MYSQL_SERVICE_HOST=mysql - MYSQL_SERVICE_PORT=3306 - MYSQL_SERVICE_DB_NAME=nacos_config - MYSQL_SERVICE_USER=nacos - MYSQL_SERVICE_PASSWORD=nacos_password_123 - NACOS_AUTH_ENABLE=true - NACOS_AUTH_IDENTITY_KEY=nacos - NACOS_AUTH_IDENTITY_VALUE=nacos - NACOS_AUTH_TOKEN=SecretKey012345678901234567890123456789012345678901234567890123456789 ports: - "8848:8848" - "7848:7848" volumes: - nacos1-logs:/home/nacos/logs nacos2: image: nacos/nacos-server:v2.3.0 container_name: nacos2 depends_on: mysql: condition: service_healthy environment: - MODE=cluster - NACOS_SERVERS=nacos1:7848,nacos2:7848,nacos3:7848 - SPRING_DATASOURCE_PLATFORM=mysql - MYSQL_SERVICE_HOST=mysql - MYSQL_SERVICE_PORT=3306 - MYSQL_SERVICE_DB_NAME=nacos_config - MYSQL_SERVICE_USER=nacos - MYSQL_SERVICE_PASSWORD=nacos_password_123 - NACOS_AUTH_ENABLE=true - NACOS_AUTH_IDENTITY_KEY=nacos - NACOS_AUTH_IDENTITY_VALUE=nacos - NACOS_AUTH_TOKEN=SecretKey012345678901234567890123456789012345678901234567890123456789 ports: - "8849:8848" - "7849:7848" nacos3: image: nacos/nacos-server:v2.3.0 container_name: nacos3 depends_on: mysql: condition: service_healthy environment: - MODE=cluster - NACOS_SERVERS=nacos1:7848,nacos2:7848,nacos3:7848 - SPRING_DATASOURCE_PLATFORM=mysql - MYSQL_SERVICE_HOST=mysql - MYSQL_SERVICE_PORT=3306 - MYSQL_SERVICE_DB_NAME=nacos_config - MYSQL_SERVICE_USER=nacos - MYSQL_SERVICE_PASSWORD=nacos_password_123 - NACOS_AUTH_ENABLE=true ports: - "8850:8848" - "7850:7848" # ===== Prometheus——采集 Nacos 指标 ===== prometheus: image: prom/prometheus:v2.48.0 ports: - "9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml volumes: mysql-data: nacos1-logs: prometheus.yml global: scrape_interval: 15s scrape_configs: - job_name: 'nacos' metrics_path: '/nacos/actuator/prometheus' static_configs: - targets: - 'nacos1:8848' - 'nacos2:8848' - 'nacos3:8848' 四、📊 Nacos 监控——Prometheus + Grafana Nacos 内置了 Prometheus 指标暴露——访问 http://nacos:8848/nacos/actuator/prometheus 即可获取。 ...

十二月 16, 2022 · 5 分钟 · 951 字 · yaomingye
Cat Radio