news 2026/8/7 13:23:07

MyBatis三级缓存机制深度解析:从原理到实战避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis三级缓存机制深度解析:从原理到实战避坑

1. 项目概述:MyBatis三级缓存机制深度剖析

如果你用过MyBatis,肯定对“缓存”这个词不陌生。面试的时候,面试官也总爱问:“聊聊MyBatis的一级缓存和二级缓存?” 但很多人可能不知道,或者只是模糊地听说过,MyBatis其实存在一个“三级缓存”的概念。这并不是官方文档里明确划分的一个独立层级,而是我们开发者根据其缓存的作用域和生命周期,结合Spring等框架的集成,在实践中总结出来的一套完整的缓存视图。今天,我们就来彻底拆解这个“三级缓存”体系,搞明白每一级缓存是什么、怎么工作、以及最关键的——它们在实际项目中是怎么相互配合,又是怎么给我们“挖坑”的。

简单来说,MyBatis的三级缓存可以理解为:

  • 一级缓存(SqlSession级别):默认开启,作用域最小,生命周期与一次数据库会话绑定。
  • 二级缓存(Mapper/Namespace级别):需要手动配置开启,作用域跨SqlSession,可以被多个会话共享。
  • 三级缓存(应用/框架集成级别):这通常指的是集成如Spring Cache、Redis等外部缓存框架后形成的应用级缓存,其作用域最大,可以跨应用实例共享。

理解这套机制,不仅能帮你解决“为什么我查的数据是旧的?”、“开了事务怎么查不到最新数据?”这类诡异问题,更是优化应用性能、设计高可用数据访问层的必备知识。无论你是正在被MyBatis缓存问题困扰的开发者,还是准备面试想深入原理的求职者,这篇文章都将带你从使用到源码,从配置到避坑,完整地走一遍。

2. 三级缓存整体架构与核心设计思想

在深入每一级缓存之前,我们得先站在高处,看看MyBatis设计这套缓存机制的初衷和整体蓝图。MyBatis作为一个半自动化的ORM框架,其核心价值之一就是在对象和关系数据库之间提供灵活、高效的映射。缓存,就是为了减少对数据库的直接访问,这个目标服务的。

2.1 为什么需要多级缓存?

想象一下图书馆借书。一级缓存就像你手边正在看的这本书,取用最快,但离开座位(关闭SqlSession)就得还回去。二级缓存像是这个阅览室的书架,同一个房间(同一个Mapper)的人都能看,但别的阅览室(别的Mapper)的人拿不到。三级缓存则像是图书馆的中央书库,所有读者(甚至其他分馆的应用)在权限内都可以借阅。

MyBatis采用这种分层设计,核心思想是在数据一致性、性能与资源消耗之间取得平衡

  1. 性能与速度:一级缓存速度最快,因为数据就在内存中的SqlSession对象里。二级缓存次之,需要序列化/反序列化和跨会话查找。三级缓存(如Redis)可能涉及网络IO,速度最慢,但共享能力最强。
  2. 数据一致性:缓存层级越高,数据共享范围越广,保持一致性就越复杂。一级缓存只影响自己,很容易通过关闭会话来清空。二级缓存需要处理多个会话的并发更新。三级缓存则要面对分布式环境下的数据同步难题。
  3. 作用域与生命周期:这是分级的关键。从一次请求(SqlSession)、到一个业务模块(Mapper)、再到整个应用乃至集群,缓存的生命周期和作用域逐级扩大,应对不同的场景需求。

2.2 各级缓存的核心职责与交互关系

三级缓存并非完全独立,它们在执行一次查询时,遵循着一个清晰的查询链,这通常被称为缓存查询顺序

当执行一条查询语句时(例如select * from user where id = #{id}),MyBatis的Executor执行器会按以下顺序查找数据:

  1. 首先查询一级缓存:检查当前SqlSession中是否存在该查询的缓存结果。如果命中,直接返回,不会执行后续步骤。这是最快的一条路径。
  2. 然后查询二级缓存:如果一级缓存未命中,且当前Mapper配置启用了二级缓存,则查询二级缓存。二级缓存底层是一个PerpetualCache对象,但被各种装饰器(如序列化、LRU淘汰、同步锁等)包装,存储的是序列化后的数据。如果命中,将数据反序列化后返回,同时放入当前SqlSession的一级缓存中(注意这点,很重要)。
  3. 最后查询数据库:如果一、二级缓存均未命中,则执行JDBC操作,访问数据库获取数据。拿到数据后:
    • 将结果放入当前SqlSession的一级缓存
    • 如果启用了二级缓存,同时会将结果放入二级缓存(同样需要序列化)。

至于三级缓存,它通常不在MyBatis这个默认查询链里。它更像是一个“旁路缓存”,由开发者通过Spring的@Cacheable注解或手动操作Redis客户端来实现。它的优先级和集成方式由应用架构决定,可能在一、二级缓存之前拦截,也可能作为二级缓存的分布式存储后端。

注意:这个顺序解释了为什么有时你开启了二级缓存,感觉效果却不明显。因为如果你的操作都在同一个SqlSession内(比如一个事务方法中多次查询同一数据),请求会被一级缓存拦截,根本走不到二级缓存那一步。

3. 一级缓存:SqlSession级别的“私人工作区”

一级缓存是MyBatis中最基础、最直接的缓存,理解它是理解所有缓存问题的起点。

3.1 工作原理与生命周期

一级缓存本质上是一个HashMap,它的键是CacheKey。这个CacheKey由多个要素共同决定,确保查询的唯一性:

  • Mapper的Id(即命名空间+方法名)
  • 查询的偏移量(分页参数)
  • 查询的SQL语句本身
  • 传递给SQL的实际参数值
  • 环境Id(比如你配置的多数据源)

只要这些要素完全相同,MyBatis就认为这是同一次查询,会尝试从一级缓存中直接返回结果。

它的生命周期与SqlSession绑定:

  • 创建:当调用SqlSessionFactory.openSession()方法时,一个新的SqlSession被创建,同时一个全新的一级缓存(一个PerpetualCache对象)也随之创建。
  • 存活:在该SqlSession存活期间,所有查询的结果(除非配置了flushCache=true)都会被存入这个HashMap。
  • 销毁:调用SqlSession.close()方法,或者这个SqlSession对象被垃圾回收时,一级缓存随之销毁。这也是为什么我们常说“一级缓存默认是开启的”,因为它就是SqlSession的一个固有属性。

3.2 经典问题:一级缓存与事务的“爱恨纠缠”

这是面试高频题,也是实际开发中最容易踩的坑。问题通常表现为:在同一个事务方法中,我先更新了一条数据,然后立刻查询它,却发现查询到的还是旧数据。

@Transactional public void updateAndQuery(User user) { // 1. 执行更新操作 userMapper.updateById(user); // 2. 执行查询操作 User queriedUser = userMapper.selectById(user.getId()); // 此时,queriedUser 可能不是更新后的数据! }

为什么会出现这种情况?根本原因在于SqlSession的复用和一级缓存的失效机制

  1. 在Spring管理的事务中,一个事务方法通常会使用同一个SqlSession(通过SqlSessionTemplate管理)。
  2. 当你执行updateById时,MyBatis不仅会执行UPDATE语句,还会清空当前SqlSession的一级缓存(执行了clearLocalCache())。这是为了确保后续查询能拿到最新数据。
  3. 但是,关键点来了:清空缓存发生在update操作执行之后、提交之前。而你的selectById查询,发生在同一个事务、同一个SqlSession内。
  4. 如果selectByIdupdate清空缓存之后执行,它会去数据库查,拿到新数据。这看起来没问题。
  5. 坑点在于:如果数据库隔离级别是“可重复读”(MySQL默认级别)或以上,在一个事务内,多次读取同一行数据会看到相同的快照。虽然update清空了MyBatis的一级缓存,但select查询数据库时,数据库引擎返回的仍然是事务开始时的快照数据(除非你使用了FOR UPDATE这样的锁)。更常见的情况是,update操作更新了数据库,但事务尚未提交,此时select查询可能因为数据库的隔离级别而读不到未提交的数据。

如何避免和解决?

  1. 最直接的方法:在查询语句上配置flushCache=true。这会使该查询在执行前强制清空一级缓存(和二级缓存),迫使它去数据库查询。但这样会失去缓存的意义,需谨慎使用。
    <select id="selectById" resultType="User" flushCache="true"> select * from user where id = #{id} </select>
  2. 理解事务边界:考虑将更新和查询拆分成两个独立的事务(使用@Transactional(propagation = Propagation.REQUIRES_NEW)),但这会带来事务管理的复杂性。
  3. 使用二级缓存:二级缓存的生命周期不依赖于SqlSession,更新操作会失效二级缓存,另一个SqlSession的查询会直接命中已失效的缓存从而访问数据库。但这引入了数据一致性的新问题。
  4. 实战心得:对于这类“先改后查”的场景,最简单的做法是从业务逻辑上避免。如果业务允许,可以先查询出最新数据再操作;或者,直接使用更新后返回的实体对象(有些ORM或MyBatis插件支持),而不是重新查询。

4. 二级缓存:Mapper级别的“共享会议室”

二级缓存将缓存的作用域从会话提升到了命名空间(Mapper),允许多个SqlSession共享缓存数据,适用于查询远多于修改、且数据对实时性要求不高的场景。

4.1 配置与启用详解

启用二级缓存需要两步:

第一步:在MyBatis全局配置文件中声明开启缓存(默认就是true,通常不用改)

<settings> <setting name="cacheEnabled" value="true"/> </settings>

第二步:在需要使用二级缓存的Mapper XML文件中,添加<cache/>标签

<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <!-- 声明启用本Mapper的二级缓存 --> <cache/> <!-- 或者使用更详细的配置 --> <!-- <cache eviction="LRU" flushInterval="60000" size="1024" readOnly="true"/> --> <select id="selectById" resultType="User"> select * from user where id = #{id} </select> </mapper>
  • eviction:缓存回收策略,常用LRU(最近最少使用)。
  • flushInterval:缓存刷新间隔(毫秒),不设置则不清刷。
  • size:最多缓存的对象数。
  • readOnly:是否为只读。如果为true,则返回相同的缓存对象实例,性能好但不安全;如果为false,则会返回缓存对象的拷贝(通过序列化/反序列化),安全但性能有损耗。

还有一个至关重要的点所有在同一个Mapper中的操作,默认会共享同一个缓存区域。这意味着,如果你在UserMapper.xml中配置了<cache/>,那么selectByIdselectAll等所有查询的结果,都会放入名为com.example.mapper.UserMapper的缓存空间中。同时,任何insertupdatedelete操作执行时,都会清空整个UserMapper命名空间下的二级缓存。这是保证数据一致性的最基本手段,但也意味着更新操作频繁时,二级缓存命中率会很低。

4.2 序列化陷阱与跨会话共享机制

二级缓存之所以能跨SqlSession共享,是因为缓存的对象需要被序列化和反序列化。MyBatis默认使用JVM序列化。这就带来了一个经典问题:

问题:查询返回了一个User对象,其中包含一个List<Order>属性。这个Order对象可能来自另一个Mapper(比如OrderMapper)。如果OrderMapper也开启了二级缓存,并且User被序列化缓存了,那么反序列化时,Order对象是从UserMapper的缓存里反序列化出来的,还是重新去OrderMapper的缓存里找?

答案:这取决于User对象里的Order属性是“引用”还是“嵌套查询结果”。

  • 如果是通过<association><collection>进行的嵌套查询(即执行了另一条SQL),那么默认情况下,Order数据会作为User对象的一部分被整体序列化到UserMapper的缓存中。反序列化时直接还原,不会再去触发OrderMapper的查询和缓存。
  • 如果你想利用OrderMapper自己的二级缓存,可以考虑使用cache-ref来让UserMapper引用OrderMapper的缓存空间,但这会将两个Mapper的缓存耦合,清空一个会导致另一个也被清空,需谨慎设计。

实操心得

  1. 实体类必须实现Serializable接口:这是最基本的要求,否则启用二级缓存时会报错。
  2. 小心“深拷贝”开销:如果配置readOnly="false",每次从缓存取数据都会反序列化一个新对象。对于大对象或集合,这会有性能开销。
  3. 测试序列化兼容性:如果你修改了实体类的字段(增删改),之前序列化到缓存里的数据可能无法正确反序列化,导致奇怪的错误。这时需要手动清空缓存(或等待过期)。

4.3 失效策略与脏读风险

二级缓存最大的挑战是数据一致性。因为它是跨会话共享的,一个会话更新了数据,必须让其他会话的缓存失效。

MyBatis的机制是:任何 insert、update、delete 语句执行后,都会清空其所属Mapper命名空间下的整个二级缓存。这个机制简单粗暴,能保证强一致性,但代价是缓存粒度太粗。比如你只更新了ID为1的用户,但缓存里所有用户的查询结果都被清空了。

脏读风险场景: 假设有两个服务A和B,都连接同一个数据库,并且都使用了MyBatis二级缓存。

  1. 服务A更新了用户1的信息,并提交了事务。服务A的MyBatis清空了UserMapper的二级缓存。
  2. 但是,服务B的MyBatis实例并不知道这个更新,它本地UserMapper的二级缓存里还有旧数据。
  3. 服务B接下来查询用户1,命中了本地旧的二级缓存,读到了脏数据。

解决方案: 这就是为什么单纯的MyBatis二级缓存不适合分布式场景。通常的解决方案是引入三级缓存(分布式缓存),例如Redis。

  1. 放弃使用MyBatis自带的二级缓存,或者仅将其用作只读的本地缓存(如Ehcache配置为本地内存)。
  2. 在Service层,使用Spring Cache抽象,并配置其缓存管理器(CacheManager)为Redis。这样,所有应用实例都共享同一个Redis缓存,一个实例的更新操作会清除或更新Redis中的缓存条目,其他实例在下一次查询时就能从Redis获取到最新数据(或发现缓存失效去查库)。

5. 三级缓存:应用级别的“分布式缓存中心”

如前所述,三级缓存并非MyBatis原生概念,而是我们整合外部分布式缓存解决方案(如Redis、Memcached)或更强大的本地缓存(如Caffeine、Ehcache集群)后形成的架构层。它的目标是解决二级缓存无法跨JVM共享的问题,实现真正意义上的应用级甚至服务级缓存。

5.1 与Spring Cache的集成实践

Spring Cache提供了一套优雅的缓存抽象,让我们可以用注解的方式管理缓存,而无需关心底层是Guava、Ehcache还是Redis。与MyBatis整合,通常是在Service层使用。

第一步:引入依赖与配置

<!-- Spring Boot Redis Starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
# application.yml spring: redis: host: localhost port: 6379 # password: your-password database: 0

第二步:配置CacheManager

@Configuration @EnableCaching // 启用缓存注解 public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // 默认过期时间10分钟 .disableCachingNullValues() // 不缓存null值 .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 使用JSON序列化 return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .build(); } }

第三步:在Service层使用缓存注解

@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @Cacheable(value = "user", key = "#id") // 缓存键为 `user::123` public User getUserById(Long id) { // 这个方法内部会调用 userMapper.selectById(id) // 只有当缓存中没有 key=`user::#{id}` 的数据时,才会执行方法体 return userMapper.selectById(id); } @Override @CachePut(value = "user", key = "#user.id") // 更新缓存 public User updateUser(User user) { userMapper.updateById(user); return user; // 注意:返回的对象会被更新到缓存 } @Override @CacheEvict(value = "user", key = "#id") // 删除指定键缓存 public void deleteUserById(Long id) { userMapper.deleteById(id); } @Override @CacheEvict(value = "user", allEntries = true) // 清空user缓存空间所有条目 public void clearAllUserCache() { // 可能不需要执行具体的数据库操作,仅用于触发缓存清除 } }

5.2 三级缓存下的数据一致性挑战

引入Redis后,一致性问题的范围从单个JVM扩大到了整个分布式系统。挑战主要来自两方面:

  1. 缓存与数据库的一致性:这是经典问题。当更新数据库后,是“先更新数据库,再删除缓存”(Cache-Aside Pattern),还是“先删除缓存,再更新数据库”?每种策略在并发下都可能产生脏数据。通常推荐“先更新数据库,再删除缓存”,虽然也不是百分百安全,但概率较低。对于强一致性要求极高的场景,可能需要引入分布式锁或使用数据库binlog监听(如Canal)来同步失效缓存。
  2. 多级缓存之间的一致性:如果你的系统同时使用了一级缓存(MyBatis)、二级缓存(MyBatis)和三级缓存(Redis),情况会非常复杂。一个更新操作,需要同时失效或更新这三层缓存,顺序和原子性很难保证。

实操建议: 对于大多数Web应用,一个务实且清晰的架构是:

  • 禁用或谨慎使用MyBatis二级缓存:因为其粗粒度的清空策略和分布式环境下的不一致问题,很多团队选择直接关闭它。可以通过在MyBatis配置文件中设置<setting name="cacheEnabled" value="false"/>全局关闭,或者在Mapper的<cache/>标签上设置readOnly="true"和极短的flushInterval,将其退化为一个“会话间只读副本”,减轻数据库压力。
  • 将Redis作为主要的共享缓存层:在Service层使用Spring Cache,所有缓存逻辑集中在这里管理。这样,MyBatis的一级缓存仍然工作,提供最快的会话内响应;而跨会话、跨实例的数据共享,则由Redis负责。更新操作通过@CacheEvict@CachePut来保证Redis缓存的一致性。
  • 考虑使用Caffeine等本地缓存作为Redis的前置缓存:对于极热的数据,可以在应用内使用Caffeine做一层本地缓存,并设置很短的过期时间(如1秒),进一步降低Redis的访问压力和响应延迟。这需要处理本地缓存与Redis之间的一致性,可以通过发布订阅机制,在数据更新时广播消息让所有实例失效本地缓存。

6. 性能调优与实战避坑指南

了解了原理,最终还是要落到实战。下面是一些关键的调优参数和避坑经验。

6.1 关键配置参数解析

一级缓存相关

  • 本质上无法配置。但可以通过localCacheScope设置来改变其行为。
    <settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>
    • SESSION(默认):缓存对整个SqlSession有效。
    • STATEMENT:缓存仅对当前执行的语句有效,语句执行完毕即清空。这个设置可以彻底解决一级缓存带来的数据一致性问题,但会完全失去一级缓存的收益,通常用于调试或对一致性要求极高的只读场景。

二级缓存相关(在<cache>标签中配置):

  • eviction:回收算法。LRU(最近最少使用)是最常用的。还有FIFO(先进先出)、SOFT(软引用)、WEAK(弱引用)。
  • flushInterval:自动刷新间隔(毫秒)。例如设置为600000(10分钟),则缓存每10分钟清空一次,不管有没有更新操作。适用于可以接受一定时间数据延迟的场景。
  • size:缓存引用的对象数量。不是字节数。根据业务数据量设置。
  • readOnly:如前所述,true性能好,false安全。如果缓存对象是安全的(不可变),或者你愿意承担并发修改的风险,可以设为true

6.2 监控与诊断:你的缓存真的生效了吗?

怀疑缓存没起作用?可以通过以下方式验证:

  1. 开启MyBatis日志:在配置文件中设置日志级别为DEBUG。

    logging: level: com.example.mapper: debug # 你的Mapper包路径

    观察日志输出。如果看到Cache Hit Ratio字样,后面跟着一个比例(如[com.example.mapper.UserMapper]:0.5),这表示二级缓存的命中率。如果一直是0,说明缓存没命中。

  2. 使用“MyBatis Log Free”这类插件:这类插件可以很好地格式化控制台输出的SQL,并能直观地显示某条SQL是否从缓存中获取了结果。

  3. 检查Redis:如果使用了Redis,通过redis-cli使用keys user:*命令(生产环境慎用)或scan命令查看缓存键是否存在,以及其TTL。

6.3 常见陷阱与解决方案速查表

陷阱现象可能原因解决方案
同一事务内更新后查询不到最新数据1. 一级缓存未失效(极少见)。
2. 数据库隔离级别(如RR)导致事务内读快照。
1. 在查询语句配置flushCache=true
2. 将查询方法置于新事务中。
3. 调整数据库隔离级别(需评估影响)。
4.推荐:从业务逻辑上规避,使用更新返回的对象。
开启了二级缓存但命中率始终为01. 实体类未实现Serializable
2. 查询语句设置了useCache="false"
3. 在同一个SqlSession内,请求被一级缓存拦截。
4. 更新操作频繁,缓存被不断清空。
1. 检查实体类。
2. 检查Mapper XML配置。
3. 这是正常现象,二级缓存旨在跨会话共享。
4. 评估业务场景是否适合二级缓存,或调整flushInterval
分布式环境下,不同实例读到不同数据MyBatis二级缓存是JVM级别的,无法跨实例同步。弃用或改造二级缓存,使用Redis等分布式缓存作为共享缓存层。
使用Redis后,缓存数据序列化错误Java对象与Redis中存储的序列化格式不匹配。确保所有服务使用相同的序列化方式(如Jackson JSON)。在CacheManager中统一配置GenericJackson2JsonRedisSerializer
缓存击穿/雪崩热点Key失效瞬间大量请求直达数据库。1. 永不过期Key+后台更新。
2. 互斥锁(RedisSETNX)。
3. 缓存预热。
4. 设置不同的过期时间。

6.4 版本升级与框架整合注意事项

从热词中看到有关于MyBatis 3.5.3.1升级到3.7.x的问题。版本升级通常是为了获取性能提升、新特性或安全修复。在升级涉及缓存的版本时,需要注意:

  • API变更:检查Cache接口及其实现类是否有不兼容的改动。如果你有自定义的Cache实现,需要适配。
  • 行为变化:仔细阅读官方发布说明,看是否有关于缓存失效逻辑、序列化方式等方面的行为调整。
  • 与MyBatis-Plus整合:MyBatis-Plus对其缓存有自己的增强和支持。升级MyBatis核心库时,务必对应升级MyBatis-Plus到兼容的版本,并测试其内置的缓存功能(如@CacheNamespace注解)是否正常工作。
  • 与Spring Boot整合:Spring Boot的mybatis-spring-boot-starter会自动管理版本依赖。通常只需升级Boot版本或覆盖mybatis.version属性即可,但升级后务必进行全面的功能测试,特别是涉及缓存和事务的部分。

最后,关于“若依框架不分离版4.8.3版本想将mybatis改为mybatis-plus”,这不仅仅是改个依赖。MyBatis-Plus提供了更强大的CRUD封装、条件构造器、分页插件等。在迁移时,除了处理实体类注解(如@TableName,@TableField)、Mapper接口继承BaseMapper外,需要特别注意缓存配置。MyBatis-Plus默认可能使用自己的缓存实现或配置,你需要明确是继续使用原MyBatis的缓存配置,还是采用MyBatis-Plus推荐的方案,并重新测试所有涉及数据查询和更新的场景。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/7 13:22:45

数据库时间数据处理:从MIN()函数到健壮查询的工程化实践

那天下午&#xff0c;我在整理一个旧项目的数据库&#xff0c;试图从一堆杂乱无章的用户记录里&#xff0c;找出那些因为数据录入不规范而导致的“幽灵用户”。翻着翻着&#xff0c;一条记录让我停了下来&#xff1a;出生日期字段里&#xff0c;赫然写着“2025-01-01”。这显然…

作者头像 李华
网站建设 2026/8/7 13:22:15

终极指南:如何在普通电脑上安装macOS系统

终极指南&#xff1a;如何在普通电脑上安装macOS系统 【免费下载链接】Hackintosh 国光的黑苹果安装教程&#xff1a;手把手教你配置 OpenCore 项目地址: https://gitcode.com/gh_mirrors/hac/Hackintosh 想要在普通PC上体验macOS的流畅与优雅吗&#xff1f;国光的黑苹果…

作者头像 李华
网站建设 2026/8/7 13:21:05

如何在PC上玩转Switch游戏:Ryujinx模拟器终极指南

如何在PC上玩转Switch游戏&#xff1a;Ryujinx模拟器终极指南 【免费下载链接】Ryujinx 用 C# 编写的实验性 Nintendo Switch 模拟器 项目地址: https://gitcode.com/GitHub_Trending/ry/Ryujinx 想要在电脑上畅玩任天堂Switch游戏吗&#xff1f;Ryujinx这款完全免费的S…

作者头像 李华
网站建设 2026/8/7 13:21:03

Unity调用外部EXE实战:进程管理、路径处理与异步通信全解析

1. 项目概述&#xff1a;为什么Unity需要调用外部EXE&#xff1f; 在Unity项目的开发过程中&#xff0c;我们常常会遇到一个看似简单却暗藏玄机的需求&#xff1a;让游戏或应用去启动并控制一个外部的可执行程序&#xff08;EXE&#xff09;。你可能觉得这不就是一句 System.D…

作者头像 李华
网站建设 2026/8/7 13:20:19

m3u8视频下载终极指南:3步轻松保存加密在线视频

m3u8视频下载终极指南&#xff1a;3步轻松保存加密在线视频 【免费下载链接】m3u8_downloader m3u8&#xff08;HLS流&#xff09;下载&#xff0c;实现了AES解密、合并、多线程、批量下载 项目地址: https://gitcode.com/gh_mirrors/m3/m3u8_downloader 你是否曾经遇到…

作者头像 李华