
1. 为什么说“最小服务”恰恰最需要认真对待数据库和事务去年我接了一个内部组件需求听起来特别简单提供一张表的增删改查给前端几个 REST 接口再配一个后台任务定时扫数据。按照常规套路这大概是三天工作量。真正动手之后我发现所谓“最小后端服务”根本用不着只做增删改查真正难缠的是隐藏在几个接口背后的数据库交互和事务边界。如果一个服务小到只有一张表那事务逻辑尚且可以直接压在 Service 里可一旦涉及订单、库存、支付多个表联动连接池怎么配、隔离级别怎么选、幂等怎么做这些问题的复杂度不会因为你服务小就自动降低反而会因为“看起来很简单”而被无限忽视。这也是我写这篇内容的原因。我想以一个真实可落地的“最小后端服务”为骨架把数据库设计、事务边界、连接池、并发控制、幂等处理和故障排查完整串起来。它不是一个通用教程更像是把我在几个项目里踩过的坑、验证过的方案、删掉过的无用代码整理成一份工程级执行手册。适合刚接触后端、想把 CRUD 项目推向生产环境的人也适合团队里正要把单体大服务拆小、需要重新面对数据库事务边界的人。所谓的“最小”不是方案最小而是业务范围最小。我希望你把它当成一个能够落地的最小可行闭环一个后端服务一个 MySQL 数据库几个互相约束的表结构若干事务接口在并发和重复请求下还能保证数据正确。这样带着约束做设计很多平时被忽略的问题会被逼出来这比空谈架构要实用得多。2. 数据库选型与表结构设计的全局考量2.1 选型为什么我把 MySQL 作为默认方案很多人会纠结 MySQL 还是 PostgreSQL。这个纠结在小项目里其实没有意义。只要你不涉及 PostGIS、数组类型、部分索引这类强依赖 PG 特性的功能MySQL 8.0 在绝大多数后端服务里都够用而且它作为预设基础设施在云上的运维成本相对稳定团队接手成本也低。我这边最终定的是 MySQL 8.0存储引擎走 InnoDB字符集按 utf8mb4排序规则用 utf8mb4_0900_ai_ci。这里有两个容易踩的坑一是旧库导出结构时字段可能是 latin1中文写入乱码很难排查二是排序规则如果不统一联表查询时索引失效原本跑 1ms 的 JOIN 会变成全表扫描。字符集只是一个开始。真正的工程级设计要考虑的是这张表未来会被哪些查询路径命中。最小服务不代表数据量小更不代表索引可以乱建。我见过一个内部工具表只有 20 万行因为所有字段都建了索引导致插入慢得离谱。所以我在设计表结构时给自己立了几条规矩单表索引不超过 5 个避免冗余联合索引所有业务表必须有主键且主键不承载业务语义金额用DECIMAL(18,2)不用 FLOAT。2.2 表结构一张事务表模型才配叫数据库设计我这次的最小服务模拟的是一个很常见的业务场景用户下单买东西系统扣库存、生成订单、记一条支付流水。为了把事务设计讲透我只保留四张表t_inventory商品库存表包含product_id、stock、version字段。t_order订单主表保存用户 ID、商品 ID、数量、订单状态。t_order_item订单明细表理论上可拆可不拆这里拆出来是为了模拟主从表结构。t_payment_record支付流水表记录每一次支付动作包含order_id、amount、status。这四张表互相有外键关系吗我的答案是我故意不建物理外键。外键在 demo 项目里是很好的教学工具但在高并发工程里物理外键会让锁范围扩大并且导致链式级联删除很难控制。我保留的是逻辑外键和索引用应用层保证引用完整性。数据一致性靠事务去兜底而不是靠数据库级的约束去强制。这是工程实战里常见的取舍。订单状态字段我用的是TINYINT从 0 到 6 映射不同状态比如 0 表示已创建、1 表示已支付、2 表示已发货。状态流转只在应用层判断数据库只负责存当前值。原因是业务状态可能在未来拆分到状态机引擎里那时候硬编码的枚举反而成了迁移障碍。我会在一开始就为这种扩展留好空间哪怕是再小的服务。2.3 基础字段每一张业务表都该有的谦卑设计每张表我必加四个公共字段id、created_at、updated_at、deleted_at。其中id用BIGINT UNSIGNED AUTO_INCREMENTcreated_at和updated_at用DATETIME(3)保存毫秒精度deleted_at是软删除标记默认 NULL。软删除这个决策是有争议的有人觉得日志表和历史表要硬删有人觉得业务表必须软删。我的原则是凡是前端可能在过去时间维度上查看的数据一律软删除凡是长时间无意义的数据物理删除由定时任务去处理。字段类型也需要统一。时间彻底用 DATETIME不要用 TIMESTAMP省得在时区转换上反复踩坑金额用 DECIMAL数量用 INT订单号用 VARCHAR(64)并且是应用层生成的业务主键数据库只加唯一索引。订单号不用数据库自增的原因很简单订单号要暴露给外部自增 ID 会泄露业务量而且并发下撞号很难规避。我在应用层用一个“日期 随机数 业务编码”的方式生成虽然丑了一点但保证全局唯一性。2.4 迁移管理没有 Flyway 就别说自己是工程级表结构不是写一次就永远不变的。工程级服务必须把“数据库结构变更”当作代码提交进版本库。我用的迁移工具是 Flyway引入成本极低Spring Boot 里加一个依赖启动时自动扫描classpath:db/migration下的 SQL 脚本执行。脚本命名我按照官方规范走V1__init_schema.sql、V2__add_order_pay_status.sql这样排。绝对禁止修改已经提交并执行过的迁移脚本后续变更一律新增版本脚本。这样做的好处是不同环境的库结构可以一键对齐新同事拉代码起本地环境也不会缺表。有一点特别重要不要把 Flyway 当成唯一的数据库真相我自己是把它和 CI 流程绑定在一起的。每次变更数据库结构除了写迁移 SQL还会单独准备一条回滚脚本虽然 Flyway 不支持自动回滚但手动写下行撤销逻辑在紧急发布失败时能救你一命。最小服务没有专门的 DBA这一点只能靠开发自己兜底。3. 连接池——最小服务里最容易暴露出来的第一个工程坑3.1 为什么连接数不是越多越好也不该默认保持零连接很多从 CRUD 项目起步的人会有个错觉连接池最大连接数越多越好性能就越强。实际相反。数据库连接是一个有限资源每一条连接在 MySQL 端都对应一个线程都会消费内存、信号量、锁资源。如果你把最大连接数设成 200后端一启动就先干掉数据库一大半的连接配额线上稍微抖动一下其他服务连数据都连不上了。这就像一家银行营业厅只有四个柜员结果你一次性放进来两百个客户抢窗口大家卡在排队里谁也办不成业务。我用的是 HikariCPSpring Boot 默认连接池性能稳定配置参数也足够细。在一个最小服务、两个 Web 实例的部署规模下我通常把连接池参数配成下面这样然后根据监控指标微调spring: datasource: url: jdbc:mysql://mysql-primary:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: demo_user password: demo_password hikari: poolName: DemoHikariPool maximumPoolSize: 10 minimumIdle: 5 idleTimeout: 600000 maxLifetime: 1800000 connectionTimeout: 3000 validationTimeout: 1000 leakDetectionThreshold: 60000maximumPoolSize10是核心。一个 Spring Boot 实例里面还要跑各种异步任务真正同时在执行的事务请求并没有那么多10 条连接足够撑住每秒几千次简单查询。如果业务量冲到需要更多连接优先考虑加实例而不是无脑调大连接数。minimumIdle5是为了避免突刺流量来时连接还没建好造成请求排队。connectionTimeout3000是排队上限超过 3 秒直接报错防止前端请求无限挂起。maxLifetime1800000是 30 分钟比 MySQL 的 wait_timeout 短防止服务端断连后客户端还在用半死连接。leakDetectionThreshold60000是连接泄漏检测阈值如果某段代码拿连接超过 60 秒不归还日志里会打警告这个参数在定位连接池耗尽时非常管用。3.2 连接池参数背后的道理到底谁在决定并发上限连接池不是越大越好它应该和数据库的 CPU 内核数、磁盘 IO 能力、SQL 平均耗时强相关。一个粗略的经验公式是连接数约等于 (CPU 核心数 × 2 磁盘数)如果业务基本都是 IO 密集型的可以把磁盘数忽略。我们压测过这样一组数据4 核 8G 的 MySQL 实例连接池从 10 调到 30吞吐量几乎不变等待 SQL 锁的线程反而变多了。这说明你的服务瓶颈不在连接数而在单条 SQL 和事务的复杂度上。另外注意minimumIdle并不是越大越好。维护空闲连接本身在 MySQL 端是要开销的。设置成和maximumPoolSize一样适合对延迟极其敏感的流量如果只是普通后台服务空闲连接保持一半就足够了。这些参数属于那种“看起来谁都能配、实测下来返工无数”的细节建议任何服务上线前都跑一轮压测把连接池活跃数、等待线程数、事务平均耗时拉出来对齐比拍脑袋调参可靠得多。3.3 连接泄漏最小服务最容易犯的隐身错误连接池耗尽的问题八成不是连接数不够而是连接泄漏。我见过一个非常隐蔽的例子代码里用try { getConnection() } finally { statement.close() }但忘了connection.close()。服务平时没什么流量问题根本暴露不出来。等到活动大促一来数据库连接被全部占满日志里开始刷Connection is not available, request timed out after 3000ms。最麻烦的是这种问题不是必现的压测断开连接后服务可能又能稳定运行一段时间排查起来极其折磨。所以工程级代码必须形成肌肉记忆连接获取和释放必须出现在同一个作用域里用 try-with-resources 或者框架行为来保证绝不裸写 JDBC 的 close 逻辑。另外可以开着leakDetectionThreshold报警如果你用的是 MyBatis 或 Spring Data JPA还要注意事务边界是否过长。事务没结束连接就不会归还即使代码层面没有泄漏长事务一样会吃满连接池。4. 事务边界设计从给业务代码“画圈”开始4.1 事务不是用来包裹整个接口的而是用来圈住业务一致性的很多刚从单体开发转过来的同事最常见的操作就是在 Controller 方法上加一个Transactional觉得这样就能保证接口操作全部成功、全部失败。这是典型的“用事务当接口守护”的误区。事务的本质是把一组数据库读写捆绑成一个原子单位但 Controller 层不仅要处理业务逻辑还要处理参数校验、接口鉴权、远程调用。把网络调用和数据库事务绑在一起事务时间会被无限拉长连接池很快就被占满还会因为外部服务超时导致数据库事务迟迟不能提交。我自己定的规矩是事务边界放在 Service 层切在真正需要原子操作的业务方法上Controller 只做协议处理和校验。而且Transactional(rollbackFor Exception.class)必须显式写出来。为什么因为 Spring 默认只在运行时异常时回滚受检异常不会触发回滚。如果你在事务方法里抛了一个自定义的BizException假设它继承了Exception而不是RuntimeException事务会正常提交数据直接错掉。这个坑死过不少刚入门的人现在我在代码评审里看到任何一个Transactional上面没写rollbackFor基本都直接打回。4.2 隔离级别看懂四种级别才知道为什么默认值不一定适合你隔离级别解决的是并发事务之间互相看到多少数据的问题。四种标准级别分别是读未提交、读已提交、可重复读、串行化。读未提交因为能看到其他事务未提交的数据正常工程里没人会用串行化性能代价太大也不是默认选择。真正需要权衡的是“读已提交”和“可重复读”。MySQL InnoDB 默认是可重复读PostgreSQL 默认是读已提交。可重复读强调的是“同一个事务内多次读出的数据是一致的”但它靠间隙锁实现锁范围可能覆盖一个索引区间在高并发下更容易死锁。最小服务里如果核心业务的账目和库存只需要读取已提交的数据把隔离级别调成读已提交是更务实的选择。我这里结合 MySQL 的默认行为保留可重复读但在所有查询都会命中唯一索引的前提下把锁粒度控制在行级不让间隙锁扩大。如果你用的是 PostgreSQL读已提交往往是更稳的起点不用盲目模仿。隔离级别不是越严格越好而是要在一致性和并发之间找平衡。你在设计阶段就要明确这个事务里要防止的是脏读、不可重复读还是幻读比如“订单支付后发放积分”这个场景防止的是重复发放而不是防止积分值在事务期间变化那么用读已提交加上幂等约束就够了。4.3 传播行为别被 REQUIRED 和 REQUIRES_NEW 搞晕事务传播行为描述的是“一个事务方法调用另一个事务方法时后者怎么加入前者的事务”。最小服务里可能只会用到两个值REQUIRED和REQUIRES_NEW。REQUIRED的含义是如果有事务就用当前事务没有就新建一个。这是默认行为适合绝大多数场景。REQUIRES_NEW的含义是无论外层有没有事务都新开一个独立事务。这种场景什么时候用比如主业务是“创建订单”同时要写一条操作日志。如果日志写入失败导致整个订单一并回滚这个设计就是有问题的日志失败不应该影响主流程但日志又要在独立事务里自己保证写入。于是我把logOperation()方法标记为REQUIRES_NEW它和主事务各自提交互相不干扰。这个过程中要特别小心如果外层事务回滚了内层REQUIRES_NEW已经提交的日志不会跟着回滚这种“故意隔离”的效果必须自己清楚不然就是数据事故。4.4 自调用陷阱同一个类里 this.xxx() 是走不进代理的Spring 的事务是通过 AOP 代理实现的。如果你在OrderServiceImpl里写一个公开方法createOrder()然后同一个类内部直接调用this.updateStock()此时updateStock()上的Transactional是完全不会生效的。原因是 AOP 代理拦截的是外部注入的这个对象的方法调用而this调用绕过了代理对象等于裸奔。很多人遇到“方法明明加了事务但异常就是没回滚”的问题查来查去结果都是自调用导致。解决办法有三个把需要事务的方法放到另一个 Bean 中通过注入调用或者在自己的类里注入自身代理对象Lazy self再或者直接用TransactionTemplate编程式事务在方法内手动execute。最小服务里我推荐先用TransactionTemplate它最直白不存在代理失效问题也方便在圈定事务范围时精确控制代码块。缺点是代码稍啰嗦但对工程级调优而言冗长一点好过魔法失效。5. 核心实操一个带下单、扣库存、记账的事务服务完整落地方案5.1 技术栈和项目结构我这次的最小服务最终用的技术栈是 Spring Boot 3.2 Java 17 MyBatis MySQL 8.0 Flyway。为什么选 MyBatis 而不是 JPA因为我要精确控制 SQL尤其在UPDATE语句里做条件和锁控制时MyBatis 的 XML 可以把一句 SQL 写得明明白白。项目结构按功能包划分不搞复杂的分层com.demo.minimal ├── controller # 对外 HTTP 接口 ├── service # 事务边界所在 ├── mapper # MyBatis 数据访问层 ├── entity # 实体类 ├── common # 返回结果封装、异常定义、幂等工具 └── config # 数据源、CORS、Web 配置这样一个结构适合 1 到 5 个人协作也方便以后拆模块。不要为了“工程感”引入过多依赖项目大小和框架复杂度必须匹配否则维护成本比业务成本还高。5.2 核心业务创建订单时如何同时扣库存和记账订单创建的伪代码如下Transactional(rollbackFor Exception.class) public OrderResult createOrder(CreateOrderRequest request) { String idempotencyKey request.getIdempotencyKey(); if (idempotencyService.isAlreadyProcessed(idempotencyKey)) { return idempotencyService.getPreviousResult(idempotencyKey); } Inventory inventory inventoryMapper.selectByProductIdForUpdate(request.getProductId()); if (inventory null || inventory.getStock() request.getQuantity()) { throw new InsufficientStockException(request.getProductId()); } int effected inventoryMapper.decreaseStockByCondition( request.getProductId(), request.getQuantity(), inventory.getVersion()); if (effected 0) { throw new OptimisticLockException(库存更新冲突); } Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setQuantity(request.getQuantity()); order.setAmount(request.getAmount()); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); PaymentRecord payment new PaymentRecord(); payment.setOrderId(order.getId()); payment.setAmount(request.getAmount()); payment.setStatus(PaymentStatus.PENDING); paymentMapper.insert(payment); idempotencyService.saveResult(idempotencyKey, OrderResult.from(order)); return OrderResult.from(order); }这段代码里有两个关键点。第一是selectByProductIdForUpdate用了悲观锁在事务内锁住目标库存行防止两个并发请求同时读到同一份库存总量。第二是decreaseStockByCondition通过带版本号的 UPDATE 做了一套乐观锁兜底即使悲观锁没有覆盖住极端路径最后的 UPDATE 也会因版本号不匹配而失败。两种锁并存的方式会稍微增加一点复杂度但我实测下来在库存类场景里这是最稳的悲观锁保证“读的阶段不冲突”乐观锁保证“写的阶段不脏改”。为什么整个方法必须放在同一个事务里因为如果库存更新成功、订单插入失败事务回滚后库存不变如果订单插入成功、支付记录插入失败订单也会一并回滚。这三张表的状态必须同时可见、同时不存在这就是事务的核心价值。代码里没有理想化地捕获异常再“补偿”因为同一个数据库里的事务就是最强的一致性保障不需要应用层补偿。5.3 库存扣减 SQL 的细节条件更新才是工程级写法库存扣减这个动作千万不要在代码里写成“先查出来、算好新值、再 update”。正确写法是在 UPDATE 语句里用条件防止超卖update iddecreaseStockByCondition UPDATE t_inventory SET stock stock - #{quantity}, version version 1 WHERE product_id #{productId} AND stock #{quantity} AND version #{version} /update这条 SQL 的意思是只有当前库存足够且版本号等于我读取时的版本号才允许扣减。stock #{quantity}这个条件直接把“库存不足”挡在数据库层面从根上杜绝超卖version #{version}则实现了乐观锁。返回的受影响行数为 0 时说明更新冲突或者库存不够应用层拿到这个结果就能决定是重试还是报错。相比“SELECT 然后 UPDATE”这种写法少了一次网络往返也少了中间窗口期的竞态问题。5.4 幂等控制这才是前端按钮重复提交的标准解法前端做按钮防重复提交是常见的交互手段比如提交后立即禁用按钮、或者做请求中 loading 遮罩。但工程级服务绝不能只依赖前端。网络超时后用户点了一次重试、MQ 消息被重复消费、巡检脚本重复调用这些情况下后端必须在接口层面感知到“这个请求我是不是处理过了”。我实现的幂等方案是引入一张t_idempotency_record表字段包括idempotency_key、request_hash、response_code、response_body、expire_at。前端每次提交时生成一个全局唯一的Idempotency-Key后端先根据这个 key 查表发现已经存在就直接返回上次的结果不存在就继续执行并落表。这个 key 可以理解为“一次业务操作的身份证”同一个身份证只允许办理一次业务。这里有个并发细节两个相同 key 的请求同时进来都查到没有记录然后都会去执行下单逻辑。为了防止这种情况幂等表中idempotency_key必须加唯一索引并且写操作要走INSERT IGNORE或者INSERT ... ON DUPLICATE KEY UPDATE的方式谁先插入成功谁就获得执行权另一个请求插入时因唯一键冲突直接走重复分支。当一个服务被多个实例部署时这种基于数据库唯一索引的幂等方案依然可靠不需要引入分布式锁。5.5 跨域与接口封装前后端分离下的工程配套最小服务一旦走前后端分离跨域就是绕不开的事。后端在 Spring 里统一配置 CORS允许的源、方法和请求头都需要收敛不要直接放开*。接口返回结构我用统一信封code、message、data三段式所有异常在全局异常处理器里转换成固定结构。这样前端可以对重复提交、库存不足、参数错误做统一处理不需要在接口文档里记一堆特例。我还会在接口文档里明确约定幂等 key 放在请求头订单号由后端返回给前端前端不在业务层生成任何涉及数据一致性的编号。6. 工程级问题速查连接耗尽、死锁、超长事务的排查案例6.1 连接池耗尽典型症状和三条排查路径连接耗尽时的表现通常是接口偶发超时监控里看到 P99 延迟飙升后端日志出现HikariPool-1 - Connection is not available, request timed out after 3000msMySQL 侧Threads_connected接近 max_connections。排查路径我一般按三条线走。第一是看 HikariCP 的活跃连接数曲线如果持续接近maximumPoolSize说明并发瓶颈确实在连接数如果活跃连接很低但请求仍然拿不到连接大概率是连接泄漏。第二是看 MySQL 的SHOW PROCESSLIST如果出现大量Sleep状态的连接说明连接池里的连接被拿出后长时间闲置未归还。第三是看业务线程栈抓一份 jstack找到业务线程卡在哪个 SQL、哪个事务上。很多时候“连接池耗尽”只是表象背后是某条慢 SQL 或者某个长事务把连接占住了。6.2 死锁日志里的 Deadlock 其实给了你两条线索死锁日志出现时MySQL 会打印类似Deadlock found when trying to get lock; try restarting transaction的报错。很多人一看到“try restarting transaction”就直接在代码里加重试这治标不治本。死锁的根源往往是两个事务以不同顺序获取锁。举例来说事务 A 先更新库存再插入订单事务 B 先插入订单再更新库存两边互相等待对方释放锁就死锁了。我解决死锁的第一原则是所有事务必须按同一顺序访问资源。在这个最小服务里所有涉及库存和订单的操作一律先锁定库存行再操作订单表。第二原则是缩小锁范围能用唯一索引定位行就不要用范围条件可重复读隔离级别下范围查询还容易产生间隙锁进一步扩大死锁概率。第三原则是合理设置事务超时时间让长时间等待的锁能迅速放弃避免堆积。最后一步才是应用层捕获死锁异常做少量重试而且重试要带指数退避不要无脑循环。6.3 超长事务为什么一个接口慢会拖垮所有接口事务没提交前它持有的锁不会释放连接也不会归还。如果一个事务要等远程接口返回、或者在循环里反复查询这个事务就可能被拉长到几秒甚至几十秒。拖垮的不只是这个请求其他请求如果也要操作被锁的行全部会卡在锁等待上。排查长事务在 MySQL 里直接看information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;trx_started很早的事务就是长事务嫌疑对象。找到对应的线程 ID 后再通过SHOW PROCESSLIST看它当前在执行的 SQL基本就能定位到是哪个接口、哪个逻辑没有及时提交。为了避免长事务我规定事务内不做远程调用、不做批量遍历、不做慢查询如果某段逻辑只是读数据就放到事务外面等数据准备好了再开事务。6.4 锁等待超时innodb_lock_wait_timeout 不是救命稻草MySQL 默认的innodb_lock_wait_timeout是 50 秒也就是说一个事务等待另一个事务释放锁最多等 50 秒才报错。这个默认值太长了前端用户根本等不了这么久而且长锁等待会占住线程池和连接池。我一般把它调小到 5 秒或者 3 秒让系统尽早失败返回给用户“系统繁忙请稍后重试”的错误而不是让请求一直挂在那里。同时我会单独监控lock_wait_timeout这类计数器一旦某个时间段锁等待次数飙升就说明有长事务或者热点行冲突。热点行冲突的典型案例是“秒杀场景所有请求都去更新同一行库存”这时单纯调隔离级别和超时都没用更有效的办法是预扣库存、异步扣减或者把库存拆成多个子账户分散热点。最小服务如果遇到热点行别急着上缓存和消息队列先检查是不是所有流量都打在同一行上。6.5 数据迁移和表结构版本不对齐靠 Flyway 和 CI 兜底我遇到过开发环境跑得好好的测试环境一部署报表查询直接报“字段不存在”。查下来发现是有人直接在测试库手动执行了 ALTER TABLE而没有提交 Flyway 迁移脚本。这问题看起来很小但在团队协作里很容易发生。工程级的解决方式是所有库表变更必须提交为迁移脚本并且 CI 流程里加一个“从零构建数据库”的步骤保证每个新增环境都能完整执行所有迁移脚本。另外还要在发布流程中检查flyway_schema_history表里的版本和代码里脚本版本是否一致不一致就直接阻止发布。7. 扩展方向从最小服务走向真实业务时的几点提醒7.1 不要急于上分布式事务很多人在最小服务没过多久就遇到“一个操作要同时写两个服务的数据”的困境然后开始寻找分布式事务方案。我的建议是先把本地事务边界用好把幂等表、本地消息表、状态流转做好。如果确实需要跨服务保证最终一致性可以通过本地事务表 消息队列的方案实现“事务发件箱”模式在本地业务事务里写一条待发送事件事务提交后再把事件发布到 MQ消费方处理时通过业务唯一键做幂等。这种方案比直接上分布式事务中间件简单得多也更容易排查问题。分布式事务中间件无论两阶段提交还是 Saga 框架都会带来额外的部署、监控和调试成本。最小服务阶段最忌引入“为了分布式而分布式”的架构。先把单库事务边界做对、把幂等做扎实等到业务真的拆分到多个服务时底子越稳迁移越顺。7.2 可观测性事务不是写对了就行还得看得见工程级项目里任何一个事务接口上线前都要有基本的监控指标QPS、成功率、平均耗时、P99 耗时、数据库活跃连接数、事务平均时长。Spring Boot 原生支持 Actuator PrometheusHikariCP 的指标能直接暴露比如hikaricp_connections_active、hikaricp_connections_pending、hikaricp_connections_timeout。我把这些指标接到告警里一旦连接池等待数超过阈值立刻触发通知比用户先反馈要快得多。慢 SQL 日志也建议全量开启阈值设在 500ms。最小服务虽然小但慢 SQL 出现一次就值得关注。我见过很多问题是接口性能劣化到不可收拾才被发现就是因为没有在“刚变慢”的时候就抓到证据。事务和数据库设计最终是为业务服务的在监控这一块多花半小时能省下后面三天的救火时间。7.3 保持简单最小服务的“最小”本身就是可维护性做工程化设计的时候很容易陷入“每件事都要做到最完备”的执念结果项目复杂度比业务本身膨胀得还快。我复盘这个最小服务时发现真正有用的核心就是四张表、一个事务方法、一张幂等表、一套连接池参数。所有复杂机制都是围绕这四件事展开的。所以它才是工程级——不是因为用了多少框架而是每一层设计都能说清楚“为什么”。以后再有人跟你说“这只是个最小服务数据库随便搞搞”你可以拿这篇内容去回应服务的规模可以小但数据一致性的原则一次都不能松。真正的最小服务应该在保持代码量精简的同时把事务边界、幂等控制、连接池、可观测性全部做到生产可用。做到这一步它已经不是一个 demo而是一个能交付的工程。