关于这个站
白天写业务系统,接口、表结构、消息和权限;晚上把接入模型时踩过的坑整理成短文。内容以个人实践为主,不追求框架全貌。
- 后端:Spring Boot、MyBatis、MySQL、Redis、Kafka
- 工程:接口幂等、灰度、日志与链路、慢 SQL
- AI:提示词放配置、工具调用受权限约束、输出要可追溯
Java · Backend · AI
我做 Java 后端,也把大模型嵌进已有业务。这个站只记跑通过的做法:接口怎么拆、缓存和事务怎么放、模型超时了系统怎么活下去。
短文,偏排查和取舍
同步调用把线程池打满之后,改成限时 + 熔断。模型挂了,下单主链路照常走。
先分清是不存在的 key,还是突然变热的 key。两种打法不一样,别一上来就加锁。
召回要带业务过滤,回答要能指回原文。评测集比换 embedding 模型更先见效。
一个用例一个应用服务。跨聚合的写操作收进同一事务,查询不要顺便改状态。
白天写业务系统,接口、表结构、消息和权限;晚上把接入模型时踩过的坑整理成短文。内容以个人实践为主,不追求框架全貌。
模型能力在变,系统约束没变:延迟、成本、权限和可回放。
下单接口里同步要了一句发货说明。模型一慢,请求线程和数据库连接一起被占住。后来把文案生成移出事务,加上短超时、并发上限和熔断,模型不可用时订单仍然能提交。
最初的实现把整件事放在一个方法里:校验参数、扣库存、插入订单、调用模型生成一句发货说明、把说明写回订单,然后返回。模型客户端是阻塞的 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,也不是一段不完整的生成结果。
商品详情的数据库 CPU 先报警,Redis 命中率还在九成以上。打到库上的语句大多是主键查询,而这些 sku 在表里并不存在。空值缓存、布隆过滤器和热点重建解决的是三种不同的 key。
缓存对已经存在的商品是生效的,所以整体命中率仍然好看。它挡不住库里根本没有的 id:缓存没有这项,数据库也没有这行,每个新 id 都会打到存储。那天的访问是一段连续数字在扫 sku,同一个不存在的 id 还会被重复请求。
我现在先把现象分成三类,再决定用哪一招。
查库确认没有这行之后,写入一个短 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 要另走一条「最近写入」的小集合,不能只信那份旧过滤器。
热门商品的 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);
}
}
雪崩是一批 key 的问题。同一次预热写进去的缓存,如果 TTL 完全一样,会在同一秒失效。写入时加上一个随机偏移,过期时间就岔开了。Redis 本身不可用时,要有限流和兜底页面,那条预案不靠空值缓存来完成。
三种现象可以同时存在,手段也按 key 分开加。先给所有不存在的 id 加空值,再给扫号加布隆,最后只对访问最高的那几个商品做逻辑过期。
第一版把文档按字数切开、向量入库、取最相近的几段交给模型,演示问题都能答。业务一用,就出现串租户、拼错版本、回答很顺但原文里没有那句话。后来先补的是过滤、引用和评测,而不是继续改切块。
固定长度切块解决的是上下文放得下、相邻句子还留在一起。找错段落、找错租户、用了过期制度,都不是把 500 字改成 800 字能处理的。块切得再整齐,检索条件如果只有向量相似度,模型看到的就是全库里表面最像的几段。
入库时我给每块留下这些字段:租户、目录、文档编号、版本、发布状态、原文位置。原文位置至少要能还原标题和章节,后面做引用要用。切分策略可以以后再调,这些字段从第一天就写上,避免整库重做。
检索条件除了向量相近,还带上当前租户、文档已发布、版本未过期,以及调用方有权阅读的目录。过滤放在查询里。提示词里写「请不要使用无权限的内容」,只是在材料已经进了上下文之后再拜托模型自觉,隔离并没有发生。
候选 = 检索(
向量相近(question),
tenant = 当前租户,
status = published,
catalog in 当前用户可读目录,
version 未过期
)
取前 20 条
若评测显示排序不稳,再重排,留下 5 条
每条带上 chunkId、标题、章节
权限变更之后,索引里的授权字段要跟着改,或者检索时现查一次可读目录,再作为过滤条件传进去。两种做法都可以,不能一份索引建完就再也不动。
模型上下文里只放这 5 条已经过滤过的材料。调用方无权阅读的段落,不进入提示词,也不出现在调试接口的默认输出里。
提示词里每段材料带一个编号,要求模型只根据这些材料作答,并在对应句子末尾标出编号。接口把编号还原成文档标题、章节和链接,跟答案一起返回。前端默认展示引用,用户点开就能看到那一段原文。
一条材料都没用上时,返回「库里没有找到依据」,不让模型用自身知识补一段。这样回答会变短,制度问答和客服场景里这是需要的结果。库外常识如果业务确实要,就单独开一个明确标注的兜底,不和引用回答混在同一段话里。
我留了大约四十个从真实提问里抽出来的问题。每个问题标好期望命中的文档,以及一句必须能在原文找到的话。每次改切分、改召回条数、换 embedding,都跑同一份,记下两件事:期望文档有没有进前五,最终回答有没有包含那句关键原话。一次只改一个因素。
没有这张表时,换模型往往只是感觉顺了一点,过几天又改回去。有了这张表,才能看出问题落在召回还是生成。
向量检索不擅长编号、错误码和很短的专名。关键词检索可以补这一块,两边的候选合在一起,再交给重排模型看这二十条,挑出真正送给大模型的五条。重排多一次调用,延迟和费用都会上去。评测已经说明「文档找得到、次序不稳」之后再加,第一天不必上。
切块仍然要调。标题、表格和步骤列表往往不该从中间断开,相邻块保留一小段重叠也有用。这些调整用同一份评测集比较,改完能看到前五命中率有没有动。
切块、embedding 和重排都是这个流程里的旋钮。权限、引用和评测先固定下来,后面的旋钮才知道往哪边转。
接口层收干净以后,事务注解还散在几个私有方法上。有的查询顺手改状态,有的事务里还在调别的服务。一个用例收成一个应用服务方法,一次事务只包住同一个库里必须一起成功的写入。
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,注入后再调用。不要在同一个类里指望内部调用帮你开启事务。
四条都过了,再看隔离级别、锁范围和慢 SQL。事务边界定错时,后面的锁优化解决不了连接被远程调用占住的问题。