Java · Backend · AI

后端还是那套,
模型要接得住。

我做 Java 后端,也把大模型嵌进已有业务。这个站只记跑通过的做法:接口怎么拆、缓存和事务怎么放、模型超时了系统怎么活下去。

最近在写

短文,偏排查和取舍

订单服务里接大模型:超时、重试和降级

同步调用把线程池打满之后,改成限时 + 熔断。模型挂了,下单主链路照常走。

LLM

一次缓存穿透:空值、布隆过滤器和真正的热点

先分清是不存在的 key,还是突然变热的 key。两种打法不一样,别一上来就加锁。

Redis

RAG 切完文档只是开始

召回要带业务过滤,回答要能指回原文。评测集比换 embedding 模型更先见效。

RAG

Controller 变薄之后,事务边界放哪

一个用例一个应用服务。跨聚合的写操作收进同一事务,查询不要顺便改状态。

Java

平时怎么做

关于这个站

白天写业务系统,接口、表结构、消息和权限;晚上把接入模型时踩过的坑整理成短文。内容以个人实践为主,不追求框架全貌。

  • 后端:Spring Boot、MyBatis、MySQL、Redis、Kafka
  • 工程:接口幂等、灰度、日志与链路、慢 SQL
  • AI:提示词放配置、工具调用受权限约束、输出要可追溯

近期关注

模型能力在变,系统约束没变:延迟、成本、权限和可回放。

Spring Boot 事务与锁 缓存一致性 结构化输出 评估集 可观测性
全部文章 LLM

订单服务里接大模型:超时、重试和降级

下单接口里同步要了一句发货说明。模型一慢,请求线程和数据库连接一起被占住。后来把文案生成移出事务,加上短超时、并发上限和熔断,模型不可用时订单仍然能提交。

当时的链路

最初的实现把整件事放在一个方法里:校验参数、扣库存、插入订单、调用模型生成一句发货说明、把说明写回订单,然后返回。模型客户端是阻塞的 HTTP 调用,读超时配成 30 秒。平时两三百毫秒就回来,联调和压测的样例里看不出问题。

模型侧有一次抖动,单次调用经常超过十秒。下单请求停在工作线程上,Tomcat 线程逐渐占满,健康检查失败,旁边不调模型的查询接口也开始超时。数据库更早告警:这段调用写在带事务的方法里,事务不提交,连接就不还回池子。连接池先耗尽,线程池跟着满。

先把订单和文案拆开

订单是否创建,和说明文字是否生成,是两件可以分开成功的事。库存扣减、订单插入、发件箱记录留在一个短事务里,提交之后再去要文案。文案失败,订单保持已创建。事务里不再做网络调用,具体的边界我另外写在 事务边界那篇。

@Transactional
public Order place(CreateOrderCommand command) {
    Order order = orderRepository.insert(command);
    stockRepository.deduct(command.skuId(), command.qty());
    outbox.append("OrderPlaced", order.orderNo());
    return order;
}

应用服务在事务外面补文案。拿不到模型结果时用模板,接口仍然把订单号返回给调用方。

public OrderView create(CreateOrderCommand command) {
    Order order = orderService.place(command);
    String tip = tips.tryTip(order).orElse(DEFAULT_TIP);
    return OrderView.accepted(order.orderNo(), tip);
}

超时加在客户端上

业务侧如果只用 Future.get 限时,底层 HTTP 调用不一定会停。 cancel(true) 只是给执行线程一个中断,客户端不响应中断的话,连接会一直占到它自己的读超时。所以读超时要配在 HTTP 客户端上,时间按这条链路还能等多久来定。我这里把读超时设为 800 毫秒,连接超时更短,单独设为 200 毫秒。

外面的 Future.get 比客户端读超时略长一点,用来兜住线程调度。真正把一次调用掐掉的,是客户端超时。

并发上限要比超时先挡住流量

每次只堵 800 毫秒,请求量一大,请求线程仍然会堆起来。模型调用放进单独的有界线程池,队列也设上限,满了就拒绝并走模板。池子里面再用信号量限制真正打到模型的请求,例如 20 个。任务开始时拿不到许可,直接返回空,不等。

许可只在任务内部获取,并在模型调用的 finally 里释放。调用方超时只取消任务,自己不还许可:任务还没开始就取消,它不会去拿许可;任务已经在生成,许可会占到这次调用结束。并发上限数的就是正在进行的生成。

public Optional<String> tryTip(Order order) {
    Future<String> future;
    try {
        future = pool.submit(() -> {
            if (!permits.tryAcquire()) {
                return null;
            }
            try {
                return llm.complete(promptOf(order));
            } finally {
                permits.release();
            }
        });
    } catch (RejectedExecutionException ex) {
        return Optional.empty();
    }
    try {
        return Optional.ofNullable(future.get(900, TimeUnit.MILLISECONDS));
    } catch (TimeoutException ex) {
        future.cancel(true);
        return Optional.empty();
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        future.cancel(true);
        return Optional.empty();
    } catch (ExecutionException ex) {
        return Optional.empty();
    }
}

重试什么时候才会帮上忙

连接还没建立就失败,例如连接被拒绝,可以再试一次,间隔加上少量随机抖动,避免所有调用方在同一毫秒重打。读超时不重试。对方已经慢了,再打一次通常更慢,生成费用也照样计算。返回 4xx 不重试。5xx 只有在确认请求没有产生有效生成时才考虑一次重试,默认仍当作失败。

模型如果吐出半截内容,也当作失败,用模板代替。残句不写进订单说明。

熔断放在调用入口。连续超时或连续失败到达阈值后,接下来一小段时间不再访问模型,直接降级。打开期间用少量请求探测,成功后再恢复。熔断状态和在飞请求数做成指标,比单看接口总耗时更容易发现是模型拖垮了下单。

降级之后用户看到什么

模板要事先和业务对过。模型不可用时,用户得到的是一句确定的话,而不是一个 500,也不是一段不完整的生成结果。

上线前对一下

  1. 模型调用不在数据库事务里,连接不会跟着生成耗时一起占用。
  2. HTTP 客户端有连接超时和读超时,业务等待时间略大于读超时。
  3. 有独立线程池和在飞请求上限,额度用尽时直接降级。
  4. 读超时路径没有重试,熔断打开时主链路仍能返回订单号。
  5. 降级文案带有来源标记,不会被当成模型原文。
全部文章 Redis

一次缓存穿透:空值、布隆过滤器和真正的热点

商品详情的数据库 CPU 先报警,Redis 命中率还在九成以上。打到库上的语句大多是主键查询,而这些 sku 在表里并不存在。空值缓存、布隆过滤器和热点重建解决的是三种不同的 key。

先看打到数据库上的 key

缓存对已经存在的商品是生效的,所以整体命中率仍然好看。它挡不住库里根本没有的 id:缓存没有这项,数据库也没有这行,每个新 id 都会打到存储。那天的访问是一段连续数字在扫 sku,同一个不存在的 id 还会被重复请求。

我现在先把现象分成三类,再决定用哪一招。

重复的不存在 key,用空值缓存

查库确认没有这行之后,写入一个短 TTL 的占位。我用 60 秒,值用单独的标记 NULL,不和正常的 JSON 混在同一个格式里。下一次读到占位,就直接返回不存在,不再查库。

TTL 要短。商品如果刚好在这一分钟内创建,空值会把新数据挡住,所以创建成功时要删掉对应的占位 key。同一个非法 id 在占位写上之前,仍可能有一小段并发打到库;这个 id 如果本身也很热,可以再加一把几秒的短锁,只放一个请求去确认。

public Product find(long id) {
    String key = "product:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return "NULL".equals(cached) ? null : decode(cached);
    }
    if (!bloom.mightContain(id)) {
        return null;
    }
    Product row = db.findById(id);
    if (row == null) {
        redis.setex(key, 60, "NULL");
        return null;
    }
    redis.setex(key, ttlSeconds(id), encode(row));
    return row;
}

每次都换新 id 的扫描,用布隆过滤器

空值挡得住重复 id,挡不住永远在变的扫描。这种流量用布隆过滤器接在缓存之前。启动时把已经存在的商品 id 装进去,写入新商品时同步把 id 加进去。请求先问过滤器:它说一定不存在,就直接返回;它说可能存在,再走缓存和数据库。

布隆的判断是不对称的。说不存在,就一定不存在,前提是这个 id 曾经被装进过滤器。说可能存在,仍然可能是误报,所以还要查库。误报的代价是多几次查询,不会把真实存在的商品滤掉。

会出错的是漏装。新商品没有推进过滤器时,过滤器会说不存在,详情就一直找不到。经典布隆也不适合删除。如果过滤器是定时全量重建的,重建完成前的新 id 要另走一条「最近写入」的小集合,不能只信那份旧过滤器。

热点过期要留旧值

热门商品的 key 到点失效,和库里没有这个 id,是两件事。几千个请求同时发现缓存空了,一起查库,这是击穿。布隆过滤器这时候帮不上,因为这个 id 明明存在。

值里面放一个逻辑过期时间,物理 TTL 设得更长。逻辑时间到了,只放一个请求重建,用带令牌的 SETNX 占一把几秒的锁。没抢到锁的请求继续读旧值。用户拿到的是一份稍旧的详情,数据库不会被同时打穿。锁值写成随机令牌,释放时先比对再删除,避免重建超过锁的 TTL 后,把下一个请求刚加上的锁删掉。

缓存里连旧值都没有时,不能把所有请求都放进数据库。拿不到锁就返回「正在刷新」,或者等一小会儿再读一次缓存。

public Product loadHot(long id) {
    String key = "product:" + id;
    String lockKey = key + ":lock";
    CacheEntry entry = redis.getEntry(key);
    if (entry != null) {
        if (!entry.expired()) {
            return entry.value();
        }
    }
    String token = UUID.randomUUID().toString();
    if (!redis.setnx(lockKey, token, 5)) {
        if (entry != null) {
            return entry.value();
        }
        throw new BusyException("商品信息正在刷新");
    }
    try {
        Product row = db.findById(id);
        redis.setEntry(key, row, Duration.ofMinutes(30));
        return row;
    } finally {
        redis.unlock(lockKey, token);
    }
}

一起过期则把 TTL 打散

雪崩是一批 key 的问题。同一次预热写进去的缓存,如果 TTL 完全一样,会在同一秒失效。写入时加上一个随机偏移,过期时间就岔开了。Redis 本身不可用时,要有限流和兜底页面,那条预案不靠空值缓存来完成。

对照日志再选办法

  1. 打到数据库的 id 在不在表里。不在,而且很分散,做空值,再用布隆过滤器挡住新 id。
  2. 是不是集中在少数几个 key 上。是,而且这些商品存在,做逻辑过期和单请求重建。
  3. 是不是整点或同一次发布之后一起出现。是,把 TTL 打散,并看 Redis 当时是否可用。

三种现象可以同时存在,手段也按 key 分开加。先给所有不存在的 id 加空值,再给扫号加布隆,最后只对访问最高的那几个商品做逻辑过期。

全部文章 RAG

RAG 切完文档只是开始

第一版把文档按字数切开、向量入库、取最相近的几段交给模型,演示问题都能答。业务一用,就出现串租户、拼错版本、回答很顺但原文里没有那句话。后来先补的是过滤、引用和评测,而不是继续改切块。

切块只决定一段有多长

固定长度切块解决的是上下文放得下、相邻句子还留在一起。找错段落、找错租户、用了过期制度,都不是把 500 字改成 800 字能处理的。块切得再整齐,检索条件如果只有向量相似度,模型看到的就是全库里表面最像的几段。

入库时我给每块留下这些字段:租户、目录、文档编号、版本、发布状态、原文位置。原文位置至少要能还原标题和章节,后面做引用要用。切分策略可以以后再调,这些字段从第一天就写上,避免整库重做。

过滤发生在召回的时候

检索条件除了向量相近,还带上当前租户、文档已发布、版本未过期,以及调用方有权阅读的目录。过滤放在查询里。提示词里写「请不要使用无权限的内容」,只是在材料已经进了上下文之后再拜托模型自觉,隔离并没有发生。

候选 = 检索(
    向量相近(question),
    tenant = 当前租户,
    status = published,
    catalog in 当前用户可读目录,
    version 未过期
)
取前 20 条
若评测显示排序不稳,再重排,留下 5 条
每条带上 chunkId、标题、章节

权限变更之后,索引里的授权字段要跟着改,或者检索时现查一次可读目录,再作为过滤条件传进去。两种做法都可以,不能一份索引建完就再也不动。

模型上下文里只放这 5 条已经过滤过的材料。调用方无权阅读的段落,不进入提示词,也不出现在调试接口的默认输出里。

回答要能指回原文

提示词里每段材料带一个编号,要求模型只根据这些材料作答,并在对应句子末尾标出编号。接口把编号还原成文档标题、章节和链接,跟答案一起返回。前端默认展示引用,用户点开就能看到那一段原文。

一条材料都没用上时,返回「库里没有找到依据」,不让模型用自身知识补一段。这样回答会变短,制度问答和客服场景里这是需要的结果。库外常识如果业务确实要,就单独开一个明确标注的兜底,不和引用回答混在同一段话里。

先留一份评测集

我留了大约四十个从真实提问里抽出来的问题。每个问题标好期望命中的文档,以及一句必须能在原文找到的话。每次改切分、改召回条数、换 embedding,都跑同一份,记下两件事:期望文档有没有进前五,最终回答有没有包含那句关键原话。一次只改一个因素。

没有这张表时,换模型往往只是感觉顺了一点,过几天又改回去。有了这张表,才能看出问题落在召回还是生成。

混合检索和重排排在评测之后

向量检索不擅长编号、错误码和很短的专名。关键词检索可以补这一块,两边的候选合在一起,再交给重排模型看这二十条,挑出真正送给大模型的五条。重排多一次调用,延迟和费用都会上去。评测已经说明「文档找得到、次序不稳」之后再加,第一天不必上。

切块仍然要调。标题、表格和步骤列表往往不该从中间断开,相邻块保留一小段重叠也有用。这些调整用同一份评测集比较,改完能看到前五命中率有没有动。

现在的最小流程

  1. 写入时记录租户、目录、版本和原文位置。
  2. 检索时按权限和发布状态过滤,先取候选。
  3. 重排只在评测证明排序不稳时打开。
  4. 提示词强制引用编号,没有材料就回答没有依据。
  5. 上线后看两个数:「无依据」的比例,以及引用被点开的次数。

切块、embedding 和重排都是这个流程里的旋钮。权限、引用和评测先固定下来,后面的旋钮才知道往哪边转。

全部文章 Java

Controller 变薄之后,事务边界放哪

接口层收干净以后,事务注解还散在几个私有方法上。有的查询顺手改状态,有的事务里还在调别的服务。一个用例收成一个应用服务方法,一次事务只包住同一个库里必须一起成功的写入。

薄 Controller 管的是协议

Controller 负责路径、状态码,以及把请求体变成一条命令。它不决定哪些表要一起提交。事务是一致性边界,放在应用服务上:下单是一个方法,取消订单是另一个方法。不要做成一个什么都装的 OrderService,外面再随便拼查询和修改。

@RestController
@RequiredArgsConstructor
public class OrderController {
    private final CreateOrderAppService app;

    @PostMapping("/orders")
    public OrderView create(@Valid @RequestBody CreateOrderRequest request) {
        return app.create(request.toCommand());
    }
}

@Valid 解决的是字段缺不缺、格式对不对。库存够不够、状态能不能从待支付走到已取消,放在应用服务里判断,失败时抛业务异常,由统一异常处理转成对应的状态码。

一次事务包住必须一起成功的写

应用服务这个方法上的 @Transactional,包住的是这次要么一起成功、要么一起失败的写入。下单时插入订单、扣减本库库存、写一条发件箱,三张表在同一个库,就放在同一个事务里。任何一步抛异常,三件一起回滚。

@Service
public class CreateOrderAppService {
    @Transactional
    public OrderView create(CreateOrderCommand command) {
        orderRepository.insert(command);
        stockRepository.deduct(command.skuId(), command.qty());
        outbox.append("OrderPlaced", command.orderNo());
        return OrderView.accepted(command.orderNo());
    }
}

这些写如果拆成三次提交,再用 catch 去手工补,中间会留下订单已插入、库存没扣上的窗口。同一个库、业务又要求同时成功时,就放在这一个方法里,不必为了「一个聚合一个事务」主动拆开。

查询不改业务状态

商品详情和订单列表读完就返回。缓存没有命中时回填缓存,可以,那是基础设施。在查询里把待支付改成已关闭,这种状态变化要挪到明确的命令上,由用户动作或定时任务触发,并留下日志。

纯查询如果需要事务,加上 readOnly。这个标记告诉事务管理器尽量走读连接,方法里也就不该再发消息或改订单状态。

远程调用和消息留在事务外面

HTTP、大模型、直接发消息,都可能慢,也会在数据库还没提交时就把外面的动作做掉。库存如果在另一个服务,本事务只改本库;跨服务用预留、补偿或后续任务,不把行锁挂在网络等待上。模型调用怎么从下单事务里移出去,写在 超时和降级那篇。

发给消息队列的事件走发件箱。业务行和待发送记录一起提交,事务成功后,再由转发任务投递。这样不会出现消息已经发出、订单却回滚了,也不会出现订单提交完、进程马上退出、消息却丢了。转发任务按消息编号去重,消费方再按业务编号做一次幂等。

事务方法里只留本库写入。连接占用的时间就是这几条 SQL 的时间,慢调用不再把连接池一起拖死。

同一个类里的自调用不会开事务

Spring 的声明式事务靠代理。调用从别的 Bean 进来,才会经过代理,注解才生效。同一个类里面直接调用自己的方法,进的是目标对象,代理被绕开,@Transactional 不会生效,异常也不会按预期回滚。

@Service
public class OrderAppService {
    public void create(CreateOrderCommand command) {
        saveOrder(command); // 自调用,事务注解不会生效
    }

    @Transactional
    public void saveOrder(CreateOrderCommand command) {
        orderRepository.insert(command);
    }
}

用例方法本身带上事务注解,从 Controller 或其他 Bean 调进来。需要拆开时,把带事务的方法放到另一个 Bean,注入后再调用。不要在同一个类里指望内部调用帮你开启事务。

一个新接口过这四条

  1. 写操作集中在一个应用服务方法上,Controller 只做协议转换。
  2. 这个方法的事务只包住同一个库里必须一起成功的写入。
  3. 方法里没有 HTTP、模型调用,也没有直接往队列发送消息。
  4. 查询路径没有顺便修改订单或库存这类业务状态。

四条都过了,再看隔离级别、锁范围和慢 SQL。事务边界定错时,后面的锁优化解决不了连接被远程调用占住的问题。