月度记录-2025-6月

56 阅读11分钟

1. 事务嵌套和mybatis一级缓存

某个方法,事务进行了嵌套,发现怎么也查不出来数据,最后发现是mybatis一级缓存的问题。尽量不要嵌套事务。

@Override
@Transactional(rollbackFor = Exception.class)
public void test() {
    String mob = "123456789";
    // 查询数据
    Resume exist = repository.findByMobileNumber(mob);
    // 插入数据
    if (exist == null) {
        ((ResumeCommandApiImpl) AopContext.currentProxy()).insert(mob);
    }
    // 再次查询数据
    exist = repository.findByMobileNumber(mob);
    if (exist == null) {
        System.out.println("giao 总是查不到");
    } else {
        System.out.println("giao 不可能查得到,因为这时候走的是mybatis一级缓存");
    }
}

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
protected void insert(String mob) {
    // 插入数据
}

2. mybatis一级缓存和二级缓存

一级缓存(Local Cache)

  • 作用域:SqlSession 级别
  • 默认开启
  • 同一个 SqlSession 中,相同的查询语句(包括参数),第二次不会访问数据库,而是从缓存中拿。

失效条件

  1. 执行 update、insert、delete
  2. 手动调用 clearCache()
  3. SqlSession 关闭
  4. 查询参数不同(哪怕只是分页参数)

实际开发中的注意事项

  • 多次查询相同数据时,应复用同一个 SqlSession(比如 Spring 管理下的事务环境)

  • MyBatis 在 Spring 中默认每个 Mapper 方法就是一个新的 SqlSession,如果你希望利用一级缓存,需要显式设置事务传播(比如用 @Transactional 包住多次查询)

    @Service public class UserService {

    @Autowired
    private UserMapper userMapper;
    
    @Transactional // 这个注解很关键!!没有 @Transactional 的话,u1 和 u2 是两个 SqlSession,各查一次数据库。
    public void doSomething() {
        // 这两个调用共用一个 SqlSession,第二次查询会命中一级缓存
        User u1 = userMapper.getUserById(123);
        User u2 = userMapper.getUserById(123);
    }
    

    }

二级缓存(Global Cache)

  • 作用域:Mapper namespace 级别
  • 需要显式开启,默认关闭
  • 跨 SqlSession 生效,缓存数据存在于 Mapper 级别(namespace)
  • 支持自定义缓存实现(如使用 Redis、Ehcache)

总之一句话,没啥卵用。真有那种固定的查询,你直接放redis里面多好。

3. 执行save,一级缓存会失效,那么,save报错了呢?还会失效吗?

只要执行了 insert、update、delete 语句,无论成功还是失败、是否回滚,MyBatis 都会立即清空当前 SqlSession 的一级缓存。

源码如下,先clearLocalCache,再doUpdate。

@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
  ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
  if (closed) {
    throw new ExecutorException("Executor was closed.");
  }
  clearLocalCache();
  return doUpdate(ms, parameter);
}

4. MyBatis的Executor.update()

executor.update(ms, wrapCollection(parameter));

观察到MyBatis 中,insert/update/delete 都是“更新型”操作,底层统一走 Executor.update() 方法。

三类操作统一通过update() 方法执行,是一种统一写操作处理逻辑的架构设计,不影响语义,只是内部实现策略。简化架构。

5. MVP版本

“打 MVP”这个说法,源于互联网/软件开发行业中“MVP(Minimum Viable Product)”的概念,即“最小可行产品”。

但是:MVP ≠ 半成品,不是说能跑就完事,而是:质量要可用、数据要真实、用户要能完成核心目标。

6. 一次字段命名带来的感悟

场景:有个字段,业务方叫做交付群编号,我这边在表结构中要不要体现交付群这个概念?比如我可以直接用number表示,也可以用delivery_group_number。

ai答:实话实说:建议用 delivery_group_number(或 delivery_group_id)明确体现“交付群”这个业务概念,而不是模糊地用 number。

原因如下:

1. 表达清晰,增强语义可读性

2. 便于多人协作与维护

3. 领域语言与数据库设计一致

第三点我认为是最关键的一点,即不丢失上下文信息,这对于DDD可太重要了,如果只有一个number在DDD中代码就没什么语义了,但是用上delivery_group_number,对于写DDD代码,简直太爽了,特别清晰,可以明确表达业务含义。

7. 还有表名称

city_company_number 还是 city_company_delivery_group_number?

city_company_number

语义不清:number 是什么编号?城市公司编号还是交付群编号?容易误导未来维护者,尤其是和其它“number”字段混用时。

city_company_delivery_group_number

啰嗦 + 模糊:既长又含糊,number 没说明是 ID 还是编码,结构表达不清晰。

推荐命名:city_company_delivery_group

实体之间的关系!!!!!!!!!!!!!实体表和关系表。

8. 离线数仓 和 实时数仓。

数仓体系结构:离线 vs 实时

维度

离线数仓

实时数仓

核心处理方式

批处理(Batch)

流处理(Stream)

数据延迟

小时级、天级(T+1、T+0)

秒级、亚秒级

技术代表

Hive、Spark、Presto、Azkaban

Kafka、Flink、ClickHouse、Doris

存储介质

HDFS、Object Storage(如 S3)

ClickHouse、Doris、HBase、Redis

典型用途

报表、分析、模型训练、归档

看板、风控、推荐、告警

调度方式

定时触发(crontab、调度器)

实时触发(数据到达即处理)

9. Outbox Pattern(发件箱模式)

“消息落库 + 异步投递 + 定时补偿”方案:

  1. 数据库表中新增 outbox_message 表,记录待发送消息
  2. 本地事务中同时写订单表和消息表(强一致)
  3. 独立线程异步轮询发送消息表中的消息(失败重试 + 状态标记)
  4. 可加定时任务定期补偿未成功投递的消息

这种模式叫 Outbox Pattern,牺牲实时性但易维护、高可用。做开发票的时候,用的就是这种方法,有两个定时任务。一个扫描等待开票的数据,另一个扫描开票失败的数据。我说怎么这么好使!原来这条路前人都走过了。

10. rocketmq事务消息和Outbox比

Outbox Pattern 更稳、更可控

RocketMQ 原生事务消息的问题:

缺点

说明

高耦合性

Producer 要实现事务回查接口,逻辑和 MQ 耦死

难扩展

未来想换 MQ(如 Kafka)等于推倒重来

无法批量

一次只能一条事务消息,吞吐受限

Broker 回查机制不稳定

Producer 宕机会导致回查失败,消息“悬挂”

11. DeferredResult 失效

DeferredResult 是 Spring MVC 提供的异步请求处理机制之一,适用于服务端需要“延迟返回结果”的场景,让当前线程释放,避免 Tomcat 请求线程阻塞。做扫码登录这种需求的时候可以用。相当于提前告诉浏览器:“我收到请求了,但结果我晚点再告诉你。”。

但是,遇到了失效的情况。经过排查,发现和下面这个过滤器有关。

public class WebReqAndResLogFilterConfig extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 普通的 requestresponse 的输入输出流一旦读取/写入就不能再读/写,包装后通过缓存可以多次读取内容。
        ContentCachingRequestWrapper cachingRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper cachingResponse = new ContentCachingResponseWrapper(response);

        // 执行过滤链(让请求继续向下走)
        filterChain.doFilter(cachingRequest, cachingResponse);

        // 打印请求日志
        String method = request.getMethod();
        String uri = request.getRequestURI();
        byte[] requestContent = cachingRequest.getContentAsByteArray();
        log.info("Request: {}, {}, {}", method, uri, new String(requestContent, StandardCharsets.UTF_8));
        
        // 打印响应日志
        int status = response.getStatus();
        byte[] responseContent = cachingResponse.getContentAsByteArray();
        log.info("Response: {}, {}", status, new String(responseContent, StandardCharsets.UTF_8));
        
        // 响应内容重新写回(关键)
        // 由于我们读取了 response 内容(getContentAsByteArray()),它已经“被消费”了,必须写回原始输出流。
        // copyBodyToResponse() 就是把缓存的 response 数据再写一遍到真实响应流。
        cachingResponse.copyBodyToResponse();
    }
}正确的解决方向:在过滤器中增加判断,只对非异步请求做日志处理。// 加到那该死的doFilter后面!!
// request.isAsyncStarted() 返回 true 的前提是:
// 在后面的 Controller 或业务代码中调用了 startAsync(),比如通过 DeferredResult、Callable 启用了异步处理。

// 但你调用 filterChain.doFilter() 之前,请求还没有进入 Controller,isAsyncStarted()自然不会返回trueif (request.isAsyncStarted()) {
    filterChain.doFilter(request, response); // 直接走原始 request/response
    return;
}

12. ApplicationRunner

简单说,ApplicationRunner 是 Spring Boot 提供的一个接口,用来在 Spring 容器启动完成、所有Bean创建和初始化完毕后,执行一些代码。比如:初始化缓存/字典、打印启动信息、校验关键配置、启动定时任务、预热数据等。

什么时候用它?当你做的事需要:所有 Bean 已准备好,可以注入使用的时候,就用它。

13. 比如:查看spring 有多少个filter,刚好就可以用ApplicationRunner

注意:beanFactory.getBeansOfType(),这句只能拿到,通过显式注册的 FilterRegistrationBean 类型的 Bean(也就是显式用 Java Config 注册过的过滤器),如果你的某些过滤器没有显式注册,也拿不到。从工程实践角度看建议都注册上,方便管理。

@Component
public class FilterAsyncChecker implements ApplicationRunner {
    @Autowired
    private ListableBeanFactory beanFactory;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        Map<String, FilterRegistrationBean> filters = beanFactory.getBeansOfType(FilterRegistrationBean.class);
        for (Map.Entry<String, FilterRegistrationBean> entry : filters.entrySet()) {
            entry.getValue().setAsyncSupported(true);
            System.out.println("Filter bean: " + entry.getKey() + ", asyncSupported: " + entry.getValue().isAsyncSupported());
        }
    }
}

14. 查看有多少aop

和查看filter数量一样,查看项目里面aop的数量,能够更好的把握项目运行情况。

@Component
public class AopScanner implements ApplicationRunner {

    @Autowired
    private ListableBeanFactory beanFactory;

    @Override
    public void run(ApplicationArguments args) {
        Map<String, Object> aspectBeans = beanFactory.getBeansWithAnnotation(Aspect.class);
        System.out.println("🔍 自定义 AOP 切面数量:" + aspectBeans.size());
        aspectBeans.forEach((name, bean) -> {
            System.out.println("切面Bean: " + name + ", 类型: " + bean.getClass().getName());
        });
    }
}

没想到没过几天就有用武之地了。

15. 用archunit检测返回值是否含有Long 或 long

人都会有失误。有些事不应该告诉别人怎么做,然后靠自觉遵守。而应该是:你不能这么做,你一旦这么做了,你会无法继续前进。

好的架构不是出一堆规范,然后让人自觉遵守,而是应该在设计层面就不允许违反规范的情况出现。出现就噶。约束优于约定。

想了想还是不做了,项目启动已经很慢了,再加这么个扫描,还干不干活了。。。就这么个返回值,idea搜索 *VO.java 全出来了,闲了搜一搜得了。

16. 循环分页工具类

项目中有很多循环分页查询的代码,比如给职位过期。你不可能一下把项目里面的数据全查出来,或者直接执行个update语句。一般都是分页查询,然后循环这个分页,去执行过期操作。这就导致很多模板代码,看着不爽。于是优化了一版。真是泰裤辣。

public class PageLoopHelper {
    public static <T> void scrollByPage(long startPage, long pageSize, PageFetcher<T> fetcher, PageProcessor<T> processor) {
        long current = startPage;
        IAScrollPage<T> page = fetcher.fetch(current, pageSize);
        while (CollectionUtils.isNotEmpty(page.getRecords())) {
            processor.process(page.getRecords());
            if (!page.isHasMore()) {
                break;
            }
            current++;
            page = fetcher.fetch(current, pageSize);
        }
    }

    // 重载(默认 1L 和 100L)
    public static <T> void scrollByPage(PageFetcher<T> fetcher, PageProcessor<T> processor) {
        scrollByPage(1L, 100L, fetcher, processor);
    }
}

@FunctionalInterface
public interface PageFetcher<T> {
    // 框架固定参数签名(current, size),如果有额外参数,调用方通过 lambda 捕获额外参数。
    IAScrollPage<T> fetch(long current, long size);
}

@FunctionalInterface
public interface PageProcessor<T> {
    void process(List<T> records);
}

// 使用例子。最骚的是,这玩意可以固定俩参数。然后后面你爱传什么就传什么,实用性拉满。优雅~ ~
PageLoopHelper.scrollByPage(
        (current, size) -> xxxRepository.findPage(current, size, year, month),
        this::saveOrUpdateBatch);

17. 统计报表

做统计,有些数据需要落表,有些需要实时查询和计算。

18. 做需求之前,讲明白边界

比如做导入,是把数据导入就行?还是也要校验模板。

失败怎么办?要返回为何失败原因吗?

开始做的时候就讲清楚,避免后续扯皮。边界要清晰,讲清楚再做!磨刀不误砍柴工。

19. AopContext.currentProxy())获取失败

Spring 并不会默认给所有类创建代理对象,只有当你使用了 AOP 相关功能时,才会为命中的 Bean 创建代理对象。

所以,使用的时候要注意观察,看看当前调用方是否有代理对象。

20. 关于文件下载的方案

方案1:直接返回给前端流

实现简单,不需要额外存储空间,适合小文件或临时文件。

缺点是大文件或者耗时久的文件,返回慢。本质上是个同步的操作,用户点击下载就要等待返回。

方案2:将文件上传到OSS,返回链接

实现稍微麻烦,需要额外存储空间。但是文件的下载会由oss负责。

这是异步的,部分耗时场景或者大文件,可以做到先让用户返回,等下载好了再让用户来拿链接就行了。

21. 关于前后端交互-百分比

"rate": 0.66,

"rateDisplay": "66%"

百分比字段,后端应该给前端两个字段。

好处1:保留信息,如果这个接口还有图标展示需求,可以直接用rate

好处2:后端如果有导出excel功能,也可以直接用rateDisplay,有计算就用rate

22. new BigDecimal 和 BigDecimal.valueOf

public static BigDecimal valueOf(double val) { return new BigDecimal(Double.toString(val)); }

其实就是里面包了一层,不同的类型会有不同的处理,切记,用valueOf,永远不要用new BigDecimal,不小心new 到double和float类型,你会哭的。

23. 好像就不应该分页去查数据库

前几天写的那个PageLoopHelper,好像是在一坨shit上面优化。

分页去查询数据库,然后执行一些操作。为什么我们不能一次把所有的要操作的数据的d都查出来呢?就算是千万数据,拿出来一千万个id能占用多少资源?

nonono,还是得分页。更好的做法是:按主键 ID 扫描。

SELECT id FROM your_table WHERE expire_time < NOW() AND status != 2 AND id > ? ORDER BY id LIMIT 1000;

24. VarHandle

VarHandle handle = MethodHandles.lookup()
    .in(MyClass.class)
    .findVarHandle(MyClass.class, "field", int.class);

handle.set(myObject, 42); // 相当于 myObject.field = 42
  • VarHandle(变量句柄)是 Java 9 引入的一个类,位于 java.lang.invoke 包。

  • 它类似于一种安全的指针(handle),可以用来访问类字段、数组元素、静态变量等的变量。

  • 它是 Unsafe 的官方替代,提供更安全和规范的方式来做底层内存访问。

  • 允许执行各种内存访问操作,包括:

  • 读写(get/set)

  • 原子操作(CAS、getAndSet 等)

  • 有序访问(volatile、lazySet)

方法名

作用

get(Object...)

读取变量值

set(Object..., V)

设置变量值

compareAndSet(...)

原子比较并设置

getAndSet(...)

原子获取旧值并设置新值

getVolatile(...)

以 volatile 语义读取

setVolatile(...)

以 volatile 语义写入

lazySet(...)

有序写入(性能更优但不是强同步)