☰
Spring Boot 事务实战:从失效场景到传播行为选型
2026/10/15 1:35:38 网站建设 项目流程

1. 从一次数据不一致的线上问题说起

很多开发者对 Spring Boot 里事务的理解,停留在“在方法上加个@Transactional注解就完事了”这个层面。我刚开始写业务代码那几年也是这么想的,直到有一次遇到一个线上问题:用户下单扣了库存,订单却因为后续某个环节抛异常没生成,库存白白少了一件。排查了半天才发现,问题出在事务根本没生效——方法被同类内部调用,注解形同虚设。

这件事之后我才认真把 Spring Boot 的事务机制从头到尾捋了一遍。事务这件事,说简单也简单,一个注解就能用;说复杂也复杂,传播行为、隔离级别、失效场景、回滚规则,每一个点踩过坑才知道疼。这篇内容就是把我这些年积累的实战经验整理出来,从最基础的注解用法,到传播行为的选型逻辑,再到那些“注解加了但事务没生效”的经典坑,尽量讲透。

不管你是刚接触 Spring Boot 的新手,还是已经用了几年但总觉得事务这块“心里没底”的开发者,这篇内容应该都能帮你把知识体系补完整。我会尽量用实际代码和场景来讲,而不是干巴巴地列 API 文档。

2. @Transactional 注解到底做了什么

2.1 注解背后的代理机制

要理解事务为什么有时候会失效,首先得知道@Transactional是怎么工作的。Spring 的事务管理本质上是 AOP(面向切面编程)的一个应用。当你在一个 Bean 的方法上标注@Transactional时,Spring 容器在启动阶段会检测到这个注解,然后为这个 Bean 生成一个代理对象。你从容器里拿到的实际上是代理对象,而不是原始对象。

调用流程大致是这样的:外部代码调用代理对象的方法,代理对象在真正执行目标方法之前,通过PlatformTransactionManager开启一个事务;然后执行你的业务代码;如果一切正常就提交事务,如果抛出异常就根据规则回滚。整个过程对你写的业务代码是透明的,你感知不到事务的开启和提交。

这里有一个关键点:代理对象才能拦截到方法调用。如果你在同一个类里面,一个方法直接调用另一个带@Transactional的方法,那这个调用是不经过代理的,事务自然就不会生效。这是最经典的失效场景,后面我会专门展开讲。

Spring 默认使用 CGLIB 动态代理(Spring Boot 2.x 之后默认就是 CGLIB),也就是说它通过继承你的类来生成代理。这意味着你的类和方法不能是final的,否则代理生成会失败。这一点在写工具类或者基类的时候要特别注意。

2.2 事务管理器的自动配置

在 Spring Boot 中,只要你引入了spring-boot-starter-jdbc或者spring-boot-starter-data-jpa这类依赖,并且配置了数据源,Spring Boot 就会自动为你配置一个DataSourceTransactionManager。你不需要手动去声明事务管理器的 Bean,除非你有多个数据源需要区分。

自动配置的逻辑在DataSourceTransactionManagerAutoConfiguration这个类里。它会检查容器中是否已经存在PlatformTransactionManager类型的 Bean,如果没有,就根据数据源创建一个。如果你用的是 JPA,那自动配置的会是JpaTransactionManager,它同样实现了PlatformTransactionManager接口。

这里有一个实际开发中容易忽略的细节:如果你在项目里同时引入了 JDBC 和 JPA 的依赖,Spring Boot 会优先使用JpaTransactionManager,因为它同时也能处理 JDBC 的连接。但如果你手动声明了DataSourceTransactionManager,那就会覆盖自动配置。多数据源场景下,你需要为每个数据源分别配置事务管理器,并且在@Transactional注解上通过transactionManager属性指定用哪个。

@Configuration public class TransactionConfig { @Bean public PlatformTransactionManager primaryTransactionManager( @Qualifier("primaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean public PlatformTransactionManager secondaryTransactionManager( @Qualifier("secondaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

上面这段配置展示了多数据源下事务管理器的声明方式。用的时候在注解里指定名称即可:@Transactional(transactionManager = "primaryTransactionManager")。如果不指定,Spring 会按类型查找,多个同类型 Bean 存在时就会报NoUniqueBeanDefinitionException。

2.3 编程式事务与声明式事务的取舍

@Transactional属于声明式事务,用起来最方便。但在某些场景下,编程式事务反而更合适。比如你需要在方法内部只对某几行代码做事务控制,而不是整个方法;或者你需要根据运行时的条件动态决定是否开启事务。

Spring 提供了TransactionTemplate来简化编程式事务的写法:

@Service public class OrderService { private final TransactionTemplate transactionTemplate; private final OrderMapper orderMapper; private final StockMapper stockMapper; public OrderService(TransactionTemplate transactionTemplate, OrderMapper orderMapper, StockMapper stockMapper) { this.transactionTemplate = transactionTemplate; this.orderMapper = orderMapper; this.stockMapper = stockMapper; } public void createOrder(Order order) { // 事务外的操作,比如参数校验、日志记录 validateOrder(order); transactionTemplate.execute(status -> { try { orderMapper.insert(order); stockMapper.decrease(order.getProductId(), order.getQuantity()); return null; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }

编程式事务的好处是控制粒度更细,事务边界一目了然,不会出现“注解加了但没生效”的困惑。缺点是代码侵入性强,业务逻辑和事务管理代码混在一起。我的经验是:核心的、复杂的业务链路用编程式事务,简单的 CRUD 用声明式事务。这样既保证了关键路径的可靠性,又不会让代码变得过于冗长。

3. 传播行为:七种模式的实际选型逻辑

3.1 先搞清楚“传播”到底在传播什么

传播行为(Propagation)解决的是一个核心问题:当一个带事务的方法被另一个带事务的方法调用时,事务该怎么处理?是新开一个事务,还是加入已有的事务,还是干脆不用事务?Spring 定义了七种传播行为,但实际开发中常用的就那么三四种。

先看一张对照表,把七种行为的关键差异列清楚:

传播行为当前有事务时当前无事务时典型场景
REQUIRED加入当前事务新建事务默认值,绝大多数业务方法
REQUIRES_NEW挂起当前事务,新建独立事务新建事务日志记录、消息发送等独立操作
SUPPORTS加入当前事务以非事务方式执行查询方法
NOT_SUPPORTED挂起当前事务,非事务执行非事务执行大批量查询、报表导出
MANDATORY加入当前事务抛出异常强制要求调用方开启事务
NEVER抛出异常非事务执行明确禁止事务的场景
NESTED创建嵌套事务(保存点)新建事务部分回滚场景

REQUIRED是默认值,也是用得最多的。它的逻辑很直观:有事务就加入,没有就新建。大部分业务方法用它就够了。

3.2 REQUIRES_NEW 的挂起机制与连接池隐患

REQUIRES_NEW是我在实际项目里用得第二多的传播行为,通常用在日志记录、审计、消息投递这类“不管主业务成不成功,我都要独立提交”的场景。它的行为是:如果当前已经有一个事务,先把当前事务挂起,然后开启一个全新的事务,两个事务互不影响。

但这里有一个非常容易踩的坑:连接池耗尽。因为REQUIRES_NEW会新开一个数据库连接,而原来的事务还占着一个连接(只是被挂起了,并没有释放)。如果外层事务里循环调用REQUIRES_NEW方法,或者嵌套层级很深,就可能导致连接池里的连接被耗尽,程序卡死。

@Transactional(propagation = Propagation.REQUIRED) public void batchProcess(List<Item> items) { for (Item item : items) { // 每次调用都会新开一个事务,占用一个新连接 // 如果 items 很大,连接池很快就会被耗尽 processSingleItem(item); } } @Transactional(propagation = Propagation.REQUIRES_NEW) public void processSingleItem(Item item) { // 业务逻辑 }

上面这段代码就是典型的反面教材。如果items有几百条,而连接池最大连接数只有 10,那程序跑到第 11 条就会卡住等待连接。正确的做法是把REQUIRES_NEW用在真正需要独立事务的少量操作上,而不是放在循环里批量调用。

提示:使用REQUIRES_NEW时,务必确认连接池的最大连接数足够支撑“外层事务数 + 内层独立事务数”的总和。可以通过HikariCP的maximumPoolSize参数来调整。

3.3 NESTED 与保存点:部分回滚的实现方式

NESTED传播行为比较特殊,它使用的是 JDBC 的保存点(Savepoint)机制。当外层事务回滚时,嵌套事务也会回滚;但嵌套事务回滚时,外层事务可以选择继续执行,不一定跟着回滚。这就实现了“部分回滚”的效果。

举个例子:批量导入数据时,每一条数据的导入都用一个嵌套事务。如果第 5 条数据格式有问题导致失败,我可以只回滚第 5 条,前面 4 条已经导入的保留,后面的继续尝试。这种场景用REQUIRED做不到(一荣俱荣一损俱损),用REQUIRES_NEW又太重(每条都新开连接)。

@Transactional public void batchImport(List<DataRow> rows) { for (DataRow row : rows) { try { importSingleRow(row); } catch (Exception e) { // 单条失败不影响整体,记录日志后继续 log.warn("导入失败: {}", row, e); } } } @Transactional(propagation = Propagation.NESTED) public void importSingleRow(DataRow row) { // 插入数据,如果失败只回滚这一条 }

不过NESTED有一个限制:它依赖于底层数据库对保存点的支持。MySQL 的 InnoDB 引擎是支持的,但某些数据库或者某些 JDBC 驱动可能不支持。另外,NESTED和REQUIRES_NEW在异常处理上有一个关键区别:NESTED内层抛异常被外层 catch 住之后,外层事务仍然可以提交(因为只是回滚到保存点);而REQUIRES_NEW内层抛异常被外层 catch 住之后,外层事务也可以提交,但内层事务已经独立回滚了。

3.4 传播行为选型的决策路径

面对一个具体场景,怎么快速确定用哪种传播行为?我总结了一个简单的决策路径:

  1. 这个方法是不是必须和调用方在同一个事务里?如果是,用REQUIRED。
  2. 这个方法是不是必须独立提交,不受调用方事务影响?如果是,用REQUIRES_NEW。
  3. 这个方法是不是只读查询,有没有事务都行?如果是,用SUPPORTS。
  4. 这个方法是不是需要部分回滚能力?如果是,用NESTED。
  5. 其他情况,优先考虑REQUIRED。

大部分业务代码用REQUIRED就够了。真正需要用到其他传播行为的场景并不多,一旦用到,往往意味着业务逻辑有特殊要求,这时候一定要想清楚事务边界和异常处理策略。

4. 那些“注解加了但事务没生效”的经典坑

4.1 同类内部调用:最隐蔽的失效场景

这是事务失效场景里出现频率最高的一个。看下面这段代码:

@Service public class UserService { @Autowired private UserMapper userMapper; public void register(User user) { // 这里直接调用同类的方法,不经过代理 saveUser(user); } @Transactional public void saveUser(User user) { userMapper.insert(user); // 如果这里抛异常,事务不会回滚 throw new RuntimeException("模拟异常"); } }

register方法调用saveUser时,用的是this.saveUser(),也就是原始对象的方法调用,而不是代理对象。代理对象根本没有机会拦截这个调用,所以@Transactional完全不生效。异常抛出后,insert的数据不会回滚。

解决办法有三种:

  • 把saveUser拆到另一个 Bean 里,通过依赖注入调用。这是最推荐的做法,职责也更清晰。
  • 注入自身代理:通过AopContext.currentProxy()获取当前代理对象,但需要开启@EnableAspectJAutoProxy(exposeProxy = true)。
  • 用编程式事务替代声明式事务,在register方法里手动控制事务边界。

我个人的偏好是第一种,把事务方法拆到独立的 Service 里。这样不仅解决了代理问题,代码结构也更合理。

4.2 异常类型不对导致回滚失败

@Transactional默认只对RuntimeException和Error回滚,对受检异常(Exception及其非运行时子类)不回滚。这个规则很多人知道,但在实际写代码时还是会忘。

@Transactional public void process() throws IOException { // 业务操作 throw new IOException("文件读取失败"); // IOException 是受检异常,默认不会触发回滚 }

如果确实需要对受检异常也回滚,有两种方式:

// 方式一:指定 rollbackFor @Transactional(rollbackFor = Exception.class) public void process() throws IOException { // ... } // 方式二:在方法内捕获受检异常,包装成运行时异常抛出 @Transactional public void process() { try { // 可能抛出 IOException 的操作 } catch (IOException e) { throw new RuntimeException("处理失败", e); } }

我一般推荐方式一,直接在注解上写rollbackFor = Exception.class,简单直接。但要注意,一旦指定了rollbackFor = Exception.class,所有异常都会触发回滚,包括那些你本来想忽略的异常。所以更精细的做法是明确列出需要回滚的异常类型。

还有一个反向的坑:如果你在事务方法里把异常 catch 住了,没有往外抛,那 Spring 根本感知不到异常,事务会正常提交。这种情况需要手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记回滚。

4.3 方法权限修饰符与 final 的隐形限制

Spring 的 CGLIB 代理是通过继承目标类来实现的,所以下面这些情况会导致代理失败或事务失效:

  • private 方法:CGLIB 无法重写 private 方法,注解无效。
  • final 方法:无法被重写,代理拦截不到。
  • static 方法:静态方法不属于对象,代理机制不适用。
  • final 类:无法被继承,CGLIB 直接报错。
@Service public class PaymentService { // private 方法上的 @Transactional 不会生效 @Transactional private void doPay() { // ... } // final 方法上的 @Transactional 也不会生效 @Transactional public final void refund() { // ... } }

这两个方法上的注解都是摆设。正确的做法是把需要事务控制的方法声明为public且非final。如果确实需要保护方法不被子类重写,可以考虑用编程式事务来替代。

4.4 多数据源下的事务管理器错配

在多数据源项目里,如果你没有明确指定事务管理器,Spring 会按类型查找PlatformTransactionManager。如果容器里有多个同类型的 Bean,就会抛出NoUniqueBeanDefinitionException。更隐蔽的情况是:容器里恰好有一个被标记为@Primary的事务管理器,Spring 默认用了它,但你的方法操作的是另一个数据源,结果事务管理器管不到那个数据源的连接,事务自然失效。

@Service public class MultiDsService { @Autowired @Qualifier("secondaryDataSource") private DataSource secondaryDataSource; // 这里没有指定 transactionManager,可能用错了 @Transactional public void operateOnSecondary() { // 操作 secondary 数据源 } // 正确做法:明确指定 @Transactional(transactionManager = "secondaryTransactionManager") public void operateOnSecondaryCorrectly() { // 操作 secondary 数据源 } }

多数据源场景下,我的建议是永远显式指定事务管理器,不要依赖自动推断。虽然多写几个字,但能避免很多莫名其妙的“事务不生效”问题。

5. 隔离级别与只读事务的实战考量

5.1 四种隔离级别的行为差异

Spring 的@Transactional支持设置隔离级别,对应数据库的四种标准隔离级别:

隔离级别脏读不可重复读幻读说明
READ_UNCOMMITTED可能可能可能几乎不用
READ_COMMITTED不可能可能可能Oracle 默认
REPEATABLE_READ不可能不可能可能MySQL 默认
SERIALIZABLE不可能不可能不可能性能最差

实际开发中,绝大多数情况用数据库默认的隔离级别就够了。MySQL 默认是REPEATABLE_READ,Oracle 默认是READ_COMMITTED。Spring 的Isolation.DEFAULT就是让数据库自己决定。

只有在特定场景下才需要手动指定隔离级别。比如做对账或者报表统计时,为了避免统计过程中数据被修改导致前后不一致,可以临时提升到REPEATABLE_READ或SERIALIZABLE。但要注意,隔离级别越高,并发性能越差,锁竞争越激烈。

@Transactional(isolation = Isolation.REPEATABLE_READ) public BigDecimal calculateTotal(Long accountId) { // 多次查询,需要保证前后读到的数据一致 BigDecimal income = incomeMapper.sumByAccount(accountId); BigDecimal expense = expenseMapper.sumByAccount(accountId); return income.subtract(expense); }

5.2 readOnly 不只是“优化提示”

@Transactional(readOnly = true)这个设置很多人以为只是个“提示”,实际上它是有实际作用的。对于某些数据库和 JDBC 驱动来说,readOnly会影响到连接的设置,比如 MySQL 会据此优化查询计划,Hibernate 也会跳过脏检查(Dirty Checking),减少不必要的性能开销。

@Transactional(readOnly = true) public List<User> listUsers() { return userMapper.selectAll(); }

但要注意,readOnly = true的事务里如果执行了写操作,不同数据库的表现不一样。MySQL 会直接报错,而有些数据库可能只是忽略这个设置。所以不要把它当成“写了也不会生效”的保险,它更多是一个语义声明和性能优化手段。

我的习惯是:所有查询方法都加上readOnly = true。一方面语义清晰,另一方面确实能带来一些性能收益。特别是用 JPA/Hibernate 的时候,跳过脏检查对大批量查询的性能提升很明显。

5.3 超时设置与长事务的治理

@Transactional支持timeout属性,单位是秒。超过指定时间后,事务会自动回滚。这个设置对于防止长事务拖垮数据库很有用。

@Transactional(timeout = 30) public void batchUpdate(List<Record> records) { // 如果 30 秒内没执行完,事务自动回滚 }

但timeout的生效依赖于底层 JDBC 驱动的支持。有些驱动可能不精确,或者只在特定阶段检查超时。所以不能完全依赖它来治理长事务,更根本的做法还是优化 SQL、减少事务范围、避免在事务里做远程调用。

长事务是数据库性能的隐形杀手。一个事务持有锁的时间越长,其他事务等待的时间就越久,并发能力就越差。我在实际项目里会定期检查慢查询日志和事务执行时间,把超过 1 秒的事务都揪出来分析。常见的长事务原因包括:事务里包含了 HTTP 调用、事务里做了大批量数据处理、事务里等待用户输入等。这些都应该拆到事务外面去。

6. 事务与异步、缓存的协作陷阱

6.1 @Async 与 @Transactional 同时使用的问题

在一个方法上同时标注@Async和@Transactional,看起来是“异步执行且带事务”,但实际上事务很可能不生效。原因在于@Async的执行线程和调用线程不是同一个,事务的上下文(TransactionSynchronizationManager里的 ThreadLocal)不会传递到异步线程里。

@Service public class NotifyService { // 这样写事务不会生效 @Async @Transactional public void asyncProcess() { // 这里的数据库操作不在事务中 } }

正确的做法是把异步和事务拆开:外层方法负责异步调度,内层方法负责事务执行,通过注入独立的 Bean 来调用。

@Service public class AsyncWrapper { @Autowired private TransactionalService transactionalService; @Async public void asyncProcess() { // 异步线程里调用事务方法 transactionalService.process(); } } @Service public class TransactionalService { @Transactional public void process() { // 这里的数据库操作在事务中 } }

6.2 事务提交前缓存已更新导致的数据不一致

在事务方法里更新数据库后,如果同时更新了缓存(比如 Redis),而事务最终回滚了,就会出现“缓存里是新数据,数据库里是旧数据”的不一致。这个问题的根源是:缓存的更新不受数据库事务管理,事务回滚不会让缓存回滚。

解决办法有几种:

  • 事务提交后再更新缓存:用TransactionSynchronizationManager.registerSynchronization注册回调,在afterCommit里更新缓存。
  • 先更新数据库,事务提交后再删除缓存:这是缓存一致性常用的“Cache Aside”模式的变种。
  • 用事务消息或事件机制:把缓存更新作为事务提交后的事件来处理。
@Transactional public void updateUser(User user) { userMapper.update(user); // 注册事务提交后的回调 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { redisTemplate.delete("user:" + user.getId()); } } ); }

这段代码保证了只有事务真正提交成功后,才会去删除缓存。如果事务回滚了,缓存不会被误删,下次查询时仍然能读到旧缓存(和数据库一致)。

6.3 事务事件监听的实际应用

Spring 提供了@TransactionalEventListener注解,可以监听事务的不同阶段(提交前、提交后、回滚后)。这在处理“业务完成后发消息”“事务成功后清理资源”这类需求时非常有用。

@Component public class OrderEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { // 只有订单创建事务提交成功后才会执行 // 适合做消息发送、缓存更新等操作 messageProducer.send(event.getOrderId()); } @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) public void onOrderRollback(OrderCreatedEvent event) { // 事务回滚后的补偿逻辑 log.warn("订单创建失败,执行补偿: {}", event.getOrderId()); } }

AFTER_COMMIT是最常用的阶段,它保证了事件处理逻辑只在事务成功提交后执行。如果事务回滚了,事件不会被处理。这比直接在事务方法里发消息要可靠得多,避免了“消息发了但事务回滚了”的尴尬。

7. 一些零散但重要的实战经验

7.1 事务方法里不要做远程调用

这是我最想强调的一条经验。事务方法里如果包含了 HTTP 调用、RPC 调用、消息发送等远程操作,会带来两个问题:一是远程调用的耗时不可控,容易导致事务超时;二是远程调用成功但事务回滚时,会产生数据不一致。

正确的做法是把远程调用移到事务外面。比如先执行远程调用,拿到结果后再开启事务写数据库;或者先写数据库,事务提交后再发消息。

7.2 大事务拆小事务的实践

一个事务里做了太多事情,不仅锁竞争严重,回滚代价也大。我通常会把一个大的业务操作拆成几个小事务,每个小事务只负责一个独立的步骤。比如“创建订单”可以拆成“扣库存”“生成订单”“记录流水”三个独立事务,通过状态机来保证最终一致性。

当然,拆事务会带来一致性问题,需要配合补偿机制或者对账机制。这属于分布式事务的范畴了,不在本篇展开。但核心思路是:能不用大事务就不用,事务范围越小越好。

7.3 测试事务回滚的正确姿势

写单元测试时,如果测试方法上加了@Transactional,测试结束后事务会自动回滚,不会污染数据库。但要注意,如果测试方法里调用了带REQUIRES_NEW的方法,那个新事务是独立提交的,不会跟着测试事务回滚。这时候需要在测试结束后手动清理数据。

@SpringBootTest @Transactional class UserServiceTest { @Autowired private UserService userService; @Test void testRegister() { userService.register(new User("test")); // 测试结束后自动回滚,数据库不会留下测试数据 } }

但如果register内部调用了REQUIRES_NEW的方法,那部分数据就会留在数据库里。这种情况我一般会在测试类上加@Sql注解,在测试前后执行清理脚本。

7.4 日志与监控:让事务问题可观测

事务出问题的时候,如果没有日志,排查起来非常痛苦。我习惯在事务管理器的层面加一些日志,记录事务的开启、提交、回滚事件。Spring 本身提供了TransactionSynchronization接口,可以注册回调来记录这些信息。

@Component public class TransactionLogger { @TransactionalEventListener public void logTransaction(TransactionEvent event) { // 记录事务事件 } }

另外,可以通过配置日志级别来观察事务的详细执行过程:

logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

开启 DEBUG 日志后,控制台会输出事务的创建、提交、回滚等详细信息,对于排查“事务到底有没有生效”这类问题非常有帮助。不过生产环境不建议长期开启,日志量会比较大。

7.5 关于事务失效的自查清单

最后整理一份我在实际工作中总结的事务失效自查清单,遇到“事务没生效”的问题时,可以按这个顺序逐一排查:

  1. 方法是不是public的?private、protected、final、static都不行。
  2. 调用是不是来自外部 Bean?同类内部调用不经过代理。
  3. 异常类型对不对?默认只回滚RuntimeException和Error。
  4. 异常有没有被 catch 住没往外抛?catch 住了要手动setRollbackOnly。
  5. 事务管理器是不是配对了?多数据源下要显式指定。
  6. 数据库引擎支持事务吗?MySQL 的 MyISAM 引擎不支持事务。
  7. 有没有同时用了@Async?异步线程里事务上下文不传递。
  8. 事务方法是不是被final类包裹了?CGLIB 无法代理 final 类。

这份清单基本覆盖了 90% 以上的事务失效场景。每次遇到问题,从头到尾过一遍,通常都能找到原因。

事务这个东西,入门容易精通难。注解谁都会加,但真正理解它背后的代理机制、传播行为、失效场景,需要在实际项目中不断踩坑和总结。我写这篇内容的目的,就是希望把我踩过的坑分享出来,让后来的人少走一些弯路。如果你在事务这块有其他的踩坑经历,也欢迎一起交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询