现在写代码这件事,门槛被 AI 拉得很低。你描述一句需求,它几秒钟给你一屏代码,看着还挺像样。
但真正的分水岭不在"写得出来",而在另一件事------代码上线之后,会发生什么。
这篇文章不教你写代码,教你审代码。我们会看四段真实得不能再真实的 Java 后端代码,每一段都是先给你看"待审查的内容",再带你走进审查者的脑子里,看看那几秒钟里他到底在想什么。看完你会发现,所谓审查思维,不是天赋,是一套可以练出来的提问习惯。
开场:为什么这个时代,反而缺"审查思维"
先讲个身边的场景。
以前写个下单接口,你得自己从 Controller 写到 Mapper,写的时候自然会想"这里并发怎么办、那里事务怎么开"。代码是你一笔一笔写出来的,坑基本都在脑子里。
现在不一样了。AI 把代码送到你面前,你大概率是"看一眼,能用,合了"。AI 写代码很快,可它不会替你想"这段代码上线后,两个用户同时下单会不会出事"------你让它想,它也能说出一堆,但没有人真的去追问、去验证、去负责。于是审查这个环节,反而成了 AI 时代最稀缺的东西。
更麻烦的是,审查思维和架构思维是一体的。你审一段代码,其实是在审它的边界:这个事务覆盖了哪些资源?这个接口一次要拉多少数据?这个第三方回调会来几次?这些问题想不清楚,代码再漂亮,上线也是事故。
所以今天咱们用四段代码,把这件事从头练一遍。每一段都不难,难的是你拿到手之后,脑子里有没有那张"攻击清单"。
第一段代码:下单扣库存,两个用户同时买最后一件
先看代码
一个很普通的电商下单服务,逻辑就三步:查库存、扣库存、建订单。
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(Long userId, Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("商品不存在"));
if (product.getStock() < quantity) {
throw new IllegalStateException("库存不足");
}
product.setStock(product.getStock() - quantity);
productRepository.save(product);
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setQuantity(quantity);
order.setStatus("CREATED");
return orderRepository.save(order);
}
}
Repository 长这样:
java
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
代码读起来顺不顺?顺。有 @Transactional,有库存校验,有异常抛出,格式也干净。如果这就是你 PR 的全部内容,很多团队可能就点了合并。
但先别急。审查者的第一句话永远是那句:
我不找语法错误,我只判断:这代码上线后,可能出什么事?
现在,我们走进审查者的脑子。
审查者的脑子是怎么转的
第一步:把这段代码翻译成"数据库里发生了什么"。
别盯着 Java 看,盯着 SQL 看。这段代码翻译成数据库操作,是这个样子:
text
SELECT stock FROM product WHERE id = ?; -- 第一次读
-- (业务代码里判断 stock >= quantity)
UPDATE product SET stock = stock - ? WHERE id = ?; -- 然后写
INSERT INTO orders ...; -- 然后建单
发现没有,这是教科书里最经典的 read-check-write 模式:先读,再判断,再写。而"先读再写"这四个字,在并发世界里几乎是事故的预报。
第二步:问那个最关键的问题------@Transactional 到底保证了什么?
很多人看到 @Transactional 就心安了,觉得"有事务,所以安全"。这是今天要纠正的第一个大误会。
事务保证的是原子性 :这一个方法里的数据库操作,要么全成功,要么全回滚。它不保证别的。它不会阻止另一个事务同时读到同一条数据,也不会替你排队。
换句话说,@Transactional 管的是"我自己这一份操作",管不住"别人也来操作"。
第三步:想象数据库里实际发生了什么。
假设商品库存就 1 件。用户 A 和用户 B 几乎同时点了下单,两个请求落到了两个线程上,各自开了一个事务:
text
线程 A:SELECT stock -> 1 (事务 A 开始)
线程 B:SELECT stock -> 1 (事务 B 开始,两个事务互不知情)
线程 A:1 >= 1,可以买,stock = 1 - 1 = 0
线程 B:1 >= 1,可以买,stock = 1 - 1 = 0
线程 A:UPDATE stock = 0
线程 B:UPDATE stock = 0
结果:A 下单成功,B 下单成功,库存变成 0
表面看"库存扣到了 0,没扣成负数",好像没问题?不,问题大了------两个人各买了一件,可库存只有 1 件。这不是超卖成负数才叫超卖,卖出超过实际库存,就是超卖。
在数据库术语里,这叫 Lost Update(丢失更新):两个写者都基于同一个旧值(1)计算,后写的人覆盖了先写的人的结果。A 的扣减其实丢了。
第四步:把四种方案挨个过一遍。
现在知道问题了,但审查的价值在于选对方案。我们把候选方案摆上桌:
方案一:synchronized。
java
public synchronized Order createOrder(...) { ... }
能挡住吗?单实例下能。但别忘了,服务不可能只有一个实例------现在随便一个服务都部署三四台。synchronized 锁的是当前 JVM 里的对象,五台机器五个 JVM,A 的锁根本管不住 B 机器上的请求。而且就算单机,把整个下单流程锁起来,吞吐也难看。这个方案只能算"单机玩具"。
方案二:悲观锁,SELECT ... FOR UPDATE。
java
Product product = productRepository.findByIdForUpdate(productId)...
这能在数据库层面把这一行锁住,事务 A 没提交前,事务 B 读这一行会被阻塞。能解决问题,代价是锁持有时间长------从查到改到建单提交,整行都锁着,并发一高就是排队。适合低频、强一致场景,不是所有库存场景都需要。
方案三:乐观锁,加 version 字段。
java
UPDATE product
SET stock = stock - ?, version = version + 1
WHERE id = ? AND version = ?
用一个版本号做条件,更新影响行数为 0 说明被别人改过了,重试即可。可行,但每次冲突都要重试,高并发下重试率不低,而且订单创建这类操作重试要小心重复建单。
方案四:SQL 条件更新(原子扣减)。
java
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :qty WHERE p.id = :id AND p.stock >= :qty")
int decreaseStock(@Param("id") Long id, @Param("qty") int qty);
java
int affected = productRepository.decreaseStock(productId, quantity);
if (affected == 0) {
throw new IllegalStateException("库存不足");
}
这里的关键是,"判断库存够不够"和"扣减"被合并成了一个数据库原子操作 。数据库保证这条 UPDATE 要么执行、要么不执行,不会有"两个事务同时通过判断"的窗口。WHERE stock >= quantity 天然把"不够就不扣"写进了 SQL,affected == 0 就是"没抢到"。
第五步:回到那个灵魂问题------服务部署 5 个实例,方案还成立吗?
方案四依然成立。因为竞争发生的位置根本不在 JVM,而在数据库:
text
Database
↓
JVM A ───────────┤
JVM B ───────────┤
JVM C ───────────┘
原子 UPDATE 是数据库自己保证的,跟你有几台机器、几个 JVM 一点关系都没有。所以别一听"多实例"就往分布式锁(Redisson)上冲------只有当你需要跨数据库、跨资源做互斥,数据库事务覆盖不了的时候,才轮到分布式锁上场。库存扣减这种"一个库一行记录"的事,数据库自己就能管好。
第六步:别急着收工,再看一眼------还有第二个坑。
仔细再看一遍代码:同一个用户,能不能对同一个商品买两单?
text
用户 A 买 1 件 -> 成功
用户 A 再买 1 件 -> 又成功
如果业务规则是"每人限购一件",那这就是 P0/P1 。而更常见的是"每人限购 N 件"这类唯一性约束,本质都一样:业务的唯一性,最终得靠数据库兜底。
有人会想,那我用个 Map 记一下:
java
Map<Long, Boolean> purchasedUsers;
两个问题马上暴露:第一,多实例下 A 机器的 Map 和 B 机器的 Map 互不知情,请求 1 打到 A、请求 2 打到 B,两个 Map 都说"没买过";第二,就算单实例,应用一重启,Map 里的记录全没了。内存判断只能当"提前失败的体验优化",当不了防线。
真正的兜底是数据库唯一约束:
sql
CREATE UNIQUE INDEX uk_order_user_product
ON orders(user_id, product_id);
java
try {
orderRepository.save(order);
} catch (DuplicateKeyException e) {
throw new AlreadyPurchasedException();
}
应用层可以先用 existsByUserIdAndProductId 提前拦截,给用户一个友好的提示;但最终防线必须是数据库的 UNIQUE。这个分工要刻进脑子里:
text
应用层判断 = 快速失败,改善体验
数据库 UNIQUE = 最终一致性防线
第七步:补什么测试?
- 并发测试:用两个线程同时抢最后一个库存,断言只有一个成功。
- 重复购买测试:同一用户对同一商品连续下单,断言第二次被拒。
- 多实例模拟:哪怕本地只跑一个实例,也要在测试里模拟"两个并发事务"(开两个线程走同一方法即可模拟竞争窗口)。
这段代码留下的审查范式
以后看到任何"先查、再判断、再改"的代码------查库存、查余额、查状态再更新------脑子里自动弹一句话:
范式一:read-check-write 中间的那个"检查",是并发事故的温床。凡是"判断"和"写入"分开的,就要把判断写进原子操作里(
WHERE stock >= ?/WHERE version = ?/FOR UPDATE),再靠影响行数判断成败。
以及第二句:
范式二:业务的唯一性,最终由数据库约束兜底;内存里的 Map、Set 只是体验优化,不是防线。
顺带说一句,这段代码的标准审查结论,写进 PR 评论大概是这样:
P0|库存扣减存在并发超卖。 当前实现是"查询库存 → 判断库存 → 修改库存"的 read-check-write 模式,
@Transactional只能保证单事务内的原子性,无法阻止多个并发事务同时读到相同库存。库存为 1 时两个请求可能同时通过校验,导致卖出 2 件。建议改为原子条件更新UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?,以affectedRows判断成败。该方案天然支持多实例,无需因此引入分布式锁。P0/P1|未可靠保证"一人一单"。 建议在
orders表建立UNIQUE(user_id, product_id)作为最终防线,应用层重复查询仅作快速失败。
第二段代码:用户列表,怎么越查越慢
先看代码
产品最近反馈:用户列表接口越来越慢。
java
@GetMapping("/users")
public List<UserVO> users() {
return userService.findAll();
}
java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public List<UserVO> findAll() {
List<User> users = userRepository.findAll();
return users.stream()
.map(user -> new UserVO(
user.getId(),
user.getName(),
user.getEmail(),
user.getDepartment().getName(),
user.getOrders().size()
))
.toList();
}
}
实体:
java
@Entity
public class User {
@Id
private Long id;
private String name;
private String email;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
}
现在的数据量:
text
User: 100,000
Order: 4,800,000
Department: 120
开发环境只有 50 个用户,所以一直没暴露问题。
审查者的脑子是怎么转的
第一步:先问,慢在哪一层?
"接口慢"是个很模糊的反馈,审查的第一步是把"慢"拆开。这一段 findAll() 慢,不是慢在一个点,而是整条链路被同时放大:
text
数据库层:SELECT 全表,扫描 10 万行,读取大量数据
↓
JVM 层:创建 10 万个 User 对象 + 关联对象,占内存,触发 GC
↓
JSON 层:把 10 万条记录序列化成 JSON,CPU 消耗
↓
网络层:把几十 MB 的 JSON 传回前端
↓
浏览器层:前端拿到几万条数据,根本没法渲染
任何一层都可能成为瓶颈,但根源只有一个:一次查询,要的数据太多了。
第二步:第一反应别是 Redis。
很多人看到"数据多、查询慢",第一反应是"加缓存,上 Redis"。这里要拦一下。
假设你把 100 万用户全塞进 Redis,然后 redis.get("all_users")------你只是把"从 MySQL 取 100 万条"变成了"从 Redis 取 100 万条"。序列化、反序列化、网络传输、前端渲染的成本,一个都没少。Redis 快是快,但它解决不了"数据规模本身不合理"的问题。
Redis 是优化手段,不是数据设计问题的遮羞布。
第三步:正确的第一方案是分页。
接口改成:
http
GET /users?page=0&size=20
Repository 用 Spring Data 自带的分页:
java
Page<User> findAll(Pageable pageable);
同时限制 size 的合理范围,比如 1 <= size <= 100,防止有人传个 size=999999 把分页变成全表。
分页的意义不只是"少查点数据",它是把"无界查询"变成"有界查询"------把问题规模先约束住,后面的优化才有意义。
第四步:N+1 问题,藏得很深的一个坑。
再细看 findAll() 里的两行:
java
user.getDepartment().getName() // 每个用户查一次部门
user.getOrders().size() // 每个用户查一次订单
department 和 orders 都是懒加载。意味着对每个用户,代码走到这里时都会再发一条 SQL。10 万用户,就是 10 万条查部门的 SQL + 10 万条查订单的 SQL。
text
1 次主查询 + 10 万次关联查询 = N+1 问题
这个在开发环境 50 个用户时完全无感,数据一上去就是灾难。修法是:
- 改成一次性的 join fetch / entity graph,把需要的关联一次性查出来;
- 或者,干脆换一个思路,见下一步。
第五步:回答那个工程问题------"用户订单数量"到底该怎么设计?
产品要展示"每个用户的订单数"。这不是一个"在列表里实时数一下"的需求,这是一个数据模型问题。把几种做法摆出来:
- 实时关联查询(现在这样):10 万用户 × 每次数订单,最不可取。
- 列表接口不做实时统计,单独提供"订单数"接口 :比如
/users/{id}/orders/count,谁要看谁查,列表接口只返回基础字段。列表是列表,统计是统计,别混在一个接口里。 - 冗余计数 :在 User 表加一列
order_count,下单/取消订单时同步维护(或异步对账)。列表查询直接读这一列,一条 SQL 完事。代价是要保证计数和真实订单数的一致性。 - 聚合查询 :如果真要全量统计,用 SQL 的
GROUP BY user_id做一次聚合,而不是逐行数。
工程上通常的组合是:列表接口只返回基础信息 + 分页;"订单数"这种衍生数据,要么冗余列,要么单独接口,绝不在列表里逐个实时数。这背后是一个更通用的原则:接口返回什么,由"这个接口的消费者到底需要什么"决定,而不是由"ORM 能方便地拿到什么"决定。DTO 的职责就是把"实体长什么样"和"接口返回什么"切开。
第六步:定一下级别,P1 还是 P0?
这里给个实用经验:看到性能问题别一律 P0,级别要跟着后果走。
- 如果这个接口是登录用户才能调、数据量在可控范围,那它是 P1------该修,但不用半夜爬起来。
- 但如果它是匿名可调用 + 数据量几百万 + 攻击者可以反复刷 ,那分页缺失就变成了一个 DoS 漏洞 ------别人循环请求就能把你的服务和数据库打挂,这就能升到 P0 / 安全风险。
同样的代码,不同的暴露面,级别完全不同。审查级别不是给代码打标签,是给事故后果定价。
这段代码留下的审查范式
范式三:看到"全量查询 / 无界查询",先别急着上缓存。先问一句:为什么一次要这么多数据?第一刀永远是砍规模------分页、limit、索引;等数据规模合理了,再根据真实的访问模式决定要不要缓存。缓存是优化手段,不是数据设计问题的遮羞布。
另外记住那个链路感:"慢"是整条链路的事。数据库 → JVM → JSON → 网络 → 前端,每一层都问一遍"这一层被放大了多少",你就不会只盯着一个 Redis 看了。
第三段代码:支付回调,被重复调用了
先看代码
第三方支付平台回调我们的接口,而且人家文档里写得明明白白:
网络异常时,同一支付通知可能重复发送多次。
当前代码:
java
@RestController
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
@PostMapping("/api/payment/callback")
public String callback(@RequestBody PaymentCallback callback) {
paymentService.handle(callback);
return "SUCCESS";
}
}
java
@Service
@RequiredArgsConstructor
public class PaymentService {
private final OrderRepository orderRepository;
private final CouponRepository couponRepository;
private final PointService pointService;
private final EmailService emailService;
@Transactional
public void handle(PaymentCallback callback) {
Order order = orderRepository
.findByOrderNo(callback.orderNo())
.orElseThrow();
order.setStatus(OrderStatus.PAID);
couponRepository.markUsed(
order.getCouponId()
);
pointService.addPoints(
order.getUserId(),
order.getAmount()
);
emailService.sendPaymentSuccess(
order.getUserId(),
order.getId()
);
}
}
线上偶尔有人投诉:
text
买了一笔订单
积分增加了两次
收到两封支付成功邮件
开发同学提出了一个修改:
java
if (order.getStatus() == OrderStatus.PAID) {
return;
}
审查者的脑子是怎么转的
第一步:把"会重复"当成需求,而不是意外。
第三方已经白纸黑字告诉你"会重复发送"。这不是一个 bug,是系统设计的前提。处理回调的代码,第一性原理就是幂等:同一通知来两次、来十次,业务结果只能发生一次。
第二步:评估开发同学的修复------if (status == PAID) return;
这个判断有用吗?有用。它挡得住"串行重复":第一次执行完,订单状态变成 PAID,第二次回调进来,看到 PAID,直接返回。
但它挡不住一个更阴险的场景------两个回调几乎同时到达:
text
回调 1 进入事务:SELECT order -> status = NOT_PAID
回调 2 进入事务:SELECT order -> status = NOT_PAID (两个事务都读到旧状态)
回调 1:status = PAID,加积分,发邮件
回调 2:status = PAID,加积分,发邮件
结果:积分 +2,邮件 ×2,订单状态依然是 PAID
看出来了吗,if (status == PAID) return 本质还是 read-check-write------判断和写入之间留了窗口,两个并发事务都能通过检查。用状态检查防幂等,防得住先后重复,防不住并发重复。
第三步:攻击那个最经典的场景------"执行成功,但响应丢了"。
审幂等,永远要攻击这个场景:
text
第一次请求到达,服务器全部执行成功:订单 PAID、积分 +1、邮件发出
↓
但响应的包在网络上丢了
↓
客户端(或网关)不知道成功,超时重试
↓
第二次请求又来了
注意,这不是"第一次失败所以重试",而是第一次成功了,只是没人知道。所以"前端按钮禁用"、"用户不会重复点"这些想法统统不成立------重试发生在你控制不了的地方:网关、第三方、运维脚本。
第四步:设计真正的防线------三层组合拳。
单靠任何一层都不够,标准做法是三层一起上:
第一层:幂等键(Idempotency-Key)。 客户端为这次业务生成一个唯一键:
text
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
服务端处理流程变成:
text
收到请求
↓
查幂等键是否处理过
↓
没处理过 → 执行业务 → 保存结果
↓
处理过 → 直接返回第一次的结果
第二层:数据库唯一约束(最终防线)。 幂等键查重本身也有并发窗口,所以还要在数据库层面兜底:
sql
ALTER TABLE payment_record ADD CONSTRAINT uk_order UNIQUE(order_id);
java
try {
// 写入支付记录 / 更新订单
} catch (DuplicateKeyException e) {
// 说明之前已经处理过了,直接返回成功
}
唯一约束不怕并发,数据库替你挡。
第三层:状态机。 给订单状态定义明确的流转,只允许单向推进:
text
NOT_PAID ──► PAID
PAID 之后任何回调都视为"已处理",返回成功即可。状态机配合幂等键,重复请求连业务代码都不用进。
所以那个 if (status == PAID) return; 不是不能用,而是只能作为三层防线里的一层,不能单独成立。
第五步:回答那个额外问题------@Transactional 能不能让邮件一起回滚?
题目给了一个残酷的场景:
text
订单已经更新为 PAID
积分增加成功
发送邮件时,程序崩溃
事务最终会怎样?如果抛出的异常导致事务回滚,那么订单状态和积分会被回滚------数据库层面是干净了。
但邮件呢?邮件已经发出去了。@Transactional 管不到 EmailService ,它只管理数据库事务。你可以在同一个事务里调 emailService.sendPaymentSuccess(),但邮件服务不参与数据库事务,它发出去就发出去了,回滚不可能把邮件收回来。
这才是这段代码里更深的问题:事务里混进了"不可回滚的外部副作用" 。发邮件这种操作,要么挪到事务提交之后(TransactionSynchronization.afterCommit),要么放到 MQ 里异步发,绝不应该放在事务中间------否则就会出现"数据库回滚了,邮件却发了"这种比没回滚还难收拾的不一致。
同样道理,pointService.addPoints 如果只是数据库写操作,可以跟着事务回滚;但如果它是调用外部积分服务的 RPC,那同样不受事务控制。判断一段代码能不能放事务里,先问:这个操作回滚得了吗?
这段代码留下的审查范式
范式四:凡是带副作用的写接口------支付、下单、发券、提现、注册------先问一句"执行两次会怎样"。而审幂等,永远攻击那个最阴险的场景:第一次执行成功,但响应丢失。
标准结论可以这么写:
P0|回调接口缺乏幂等保护。 该接口属有副作用写操作,但当前实现依赖"状态检查"防重,在回调并发到达时存在竞态窗口,会导致重复加积分、重复发邮件。建议:① 建立幂等键并落库;② 增加
UNIQUE(order_id)作为最终防线,捕获DuplicateKeyException返回成功;③ 用状态机(NOT_PAID → PAID)约束重复回调;④ 将邮件等不可回滚的外部副作用移出数据库事务,改到事务提交后或异步执行。
第四段代码:知识库发布,"事务"管不住的边界
先看代码
这道题接近真实的大型后端系统了。业务流程:
text
用户发布笔记
↓
生成 KnowledgeDocument
↓
切 Chunk
↓
调用 Embedding API
↓
写入 Vector DB
↓
标记 INDEXED
开发者提交了下面这个 PR:
java
@Service
@RequiredArgsConstructor
public class KnowledgePublishService {
private final NoteRepository noteRepository;
private final KnowledgeRepository knowledgeRepository;
private final EmbeddingClient embeddingClient;
private final VectorStore vectorStore;
@Transactional
public void publish(UUID noteId) {
Note note = noteRepository.findById(noteId)
.orElseThrow();
KnowledgeDocument document =
KnowledgeDocument.from(note);
knowledgeRepository.save(document);
List<Chunk> chunks =
ChunkUtils.split(note.getContent());
for (Chunk chunk : chunks) {
float[] embedding =
embeddingClient.embed(
chunk.getContent()
);
vectorStore.save(
document.getId(),
chunk.getId(),
embedding
);
}
document.setStatus(
KnowledgeStatus.INDEXED
);
}
}
Embedding 服务的现实表现:
text
平均响应:300ms
P95:1.5s
偶尔 timeout
一篇笔记平均 20 个 chunk,最多 300 个。
线上已经出过一次事:
text
KnowledgeDocument.status = DRAFT
Vector DB 里存在前 17 个 chunk
然后第 18 个 chunk 的 embedding 超时了
开发同学说:没关系,有 @Transactional,失败后数据库会回滚。
审查者的脑子是怎么转的
第一步:问那个决定性问题------这个 @Transactional 到底覆盖了哪些资源?
这是今天最重要的一句话,值得单独拎出来:
拿到
@Transactional,先问:这个事务覆盖哪些资源?MySQL?Redis?Kafka?Vector DB?HTTP API?文件系统?它们真的是一个事务吗?
逐个数一下这段代码里的资源:
knowledgeRepository.save(document)→ MySQL,在事务里,能回滚;embeddingClient.embed(...)→ HTTP 调用外部服务,事务管不到;vectorStore.save(...)→ Vector DB 写入,事务管不到;document.setStatus(INDEXED)→ MySQL,在事务里。
结论很清楚:这个"事务"实际只覆盖了 MySQL 那两张表。Embedding 服务和 Vector DB 根本不在它的控制范围内。 开发同学那句"失败后数据库会回滚",只说对了一半------数据库确实会回滚,可 Vector DB 里已经写进去的向量,一分钱都滚不回来。
第二步:想象第 18 个 chunk 失败时,系统处于什么状态。
回忆一下线上那个事故:文档状态停在 DRAFT,Vector DB 里躺着前 17 个 chunk 的向量。
这是什么?这是部分成功 :数据库说"我没成功"(回滚了),Vector DB 却说"我这儿已经有 17 条了"。两边对不上。更麻烦的是,这 17 条是孤儿数据------它们对应的 document 在数据库里已经不存在了,谁也找不到它们,但它们占着 Vector DB 的空间,还会被检索到,污染搜索结果。
第三步:再看 retry 会发生什么。
假设给 embedding 加了个重试,第 18 个 chunk 重试后成功了。但重试的时候,前 17 个的向量已经在 Vector DB 里了(数据库回滚了,可向量没有)。于是:
text
第一次执行:Vector DB 写入 1..17,第 18 个失败
重试执行:重新生成 document(新 ID?还是旧 ID?),重新切 chunk
如果重试时 document 生成了新 ID ,那 Vector DB 里会留下 1...17 条旧 ID 的孤儿向量,还有新 ID 的 20 条------重复数据。如果复用旧 ID,那至少要保证"按 chunk 覆盖写",否则向量会越积越多。
第四步:长事务的代价,算一笔账。
平均 20 个 chunk × 平均 300ms = 6 秒,一个事务要捏 6 秒;最多 300 个 chunk × P95 1.5s = 450 秒,一个事务捏七个半小时。
事务捏着什么?捏着一个数据库连接。连接池一共就那么几十个连接,几个发布请求进来,连接全被占住,后面正常的读写全部排队------你用一个 450 秒的事务,把整个服务的数据库连接池拖死了。这就是长事务最经典的杀伤力:不是它自己挂了,是它把别人都堵死了。
第五步:那真正的事务边界应该在哪?
把"要保证一致的东西"和"管不住的东西"分开:
text
本地数据库这一摊:文档记录、chunk 记录、状态流转 ------ 可以用一个事务管好
外部世界那一摊:Embedding HTTP 调用、Vector DB 写入 ------ 事务管不了,必须异步化
正确形状大致是这样:
text
发布请求
↓
事务①:保存 document(状态 DRAFT)→ 提交
↓
异步 Job / MQ 消费:
切 chunk → 逐个调 embedding → 写 vector store
(每个 chunk 写成功就标记,可重试、可续传)
↓
全部完成 → 事务②:状态置 INDEXED
某个 chunk 反复失败 → 状态置 FAILED,进死信队列
要点拆开说:
- 文档状态机:DRAFT → PROCESSING → INDEXED / FAILED。把"处理中"明确表达出来,而不是要么没建要么 INDEXED。
- 异步化:发布接口只做"接受任务",立刻返回;真正耗时且不可控的 embedding、向量写入交给 Job 或队列慢慢做。这也顺带解决了长事务------数据库事务只包住本地两笔快操作。
- Outbox 模式:如果依赖 MQ 通知下游,为了保证"数据库状态变更"和"消息发出"一致(否则又会出现"状态改了但消息没发"的部分成功),可以先把"待发送事件"和业务数据写进同一个本地事务,再由一个后台任务把事件发出去。这是分布式一致性的经典解法。
- 重试与幂等:每个 chunk 的写入要能安全重试------用 chunk 的唯一 ID 做幂等,重复写就覆盖,不产生重复向量。
- 死信队列:重试 N 次还失败的,别无限重试,丢进 DLQ 等人来处理。
- API 该返回什么 :显然不能再同步返回
{"status": "INDEXED"}------你根本不知道什么时候能索引完。诚实的答案是:
json
{
"status": "PROCESSING"
}
这段代码留下的审查范式
范式五:审到
@Transactional时,永远追问------这个事务到底覆盖哪些资源?MySQL、Redis、Kafka、Vector DB、HTTP API、文件系统......它们真的能组成一个事务吗?凡是外部资源,一律默认"事务管不住",然后重新设计边界:本地一致性用事务,外部不可控的操作异步化,用状态机 + 重试 + 幂等 + 死信队列把它们拼起来。
这道题不再是一个"代码级 bug"了,它训练的是系统边界审查------判断哪些东西该归一个事务管、哪些不该,这是架构思维最朴素的样子。
收尾:把"攻击清单"装进脑子
最后回到开头那个问题:为什么 AI 时代缺审查思维?
因为 AI 能给你代码,但它不会替你负责。审查的本质是"上线前替未来的事故想一想",这个动作需要人来做。好消息是,审查不是天赋,是一张可以背下来的清单。
以后拿到任何一个 Java 后端方法,别凭感觉看,按固定顺序扫一遍:
text
① 输入 参数合法吗?null 呢?超长呢?
② 权限 这个用户有资格操作吗?能不能动别人的数据?
③ 状态 当前状态允许这个操作吗?(PAID 的订单能删吗?)
④ 数据一致性 多表之间会不一致吗?部分成功怎么办?
⑤ 并发 两个请求同时来会怎样?read-check-write 有窗口吗?
⑥ 幂等 同一个请求执行两次,结果一样吗?
⑦ 事务 这个事务覆盖了哪些资源?边界合理吗?
⑧ 外部调用 第三方会重复吗?超时了会怎样?能回滚吗?
⑨ 异常 异常被吞了吗?回滚了吗?用户看到什么?
⑩ 性能 数据量涨十倍还撑得住吗?有 N+1 吗?有全表吗?
⑪ 可观测性 出事了能查吗?有日志吗?有 trace 吗?
⑫ 安全 能被攻击者利用吗?注入?越权?DoS?
拿一段代码试一下。比如看到一个 deleteOrder(orderId),开始攻击:
text
orderId 是 null 怎么办?
订单存在吗?
用户 A 能删用户 B 的订单吗?
PAID 状态的订单允许删吗?
两个请求同时删呢?
删两次结果一样吗?
删除订单和退款在同一个事务里吗?
退款 API 成功了但数据库回滚了怎么办?
异常有没有被吞?
是不是先 SELECT 再 DELETE?
有没有审计日志?
你会发现,不是你能力突然变强了,是你手里有清单了。所谓审查经验,就是把这张清单越用越熟,直到它变成条件反射。
最后,把今天最容易被带偏的四个认知,摆正一下:
text
❌ 多 JVM → 数据库乐观锁/条件更新会失效
✅ 数据库并发控制天然跨 JVM,竞争发生在数据库,不在 JVM
❌ 一人一单 → 用 ConcurrentHashMap 记一下
✅ 数据库 UNIQUE 才是最终防线,内存容器多实例不共享、重启就丢
❌ 查询慢 / 数据多 → 上 Redis
✅ 先控制查询规模:分页、limit、索引;Redis 是优化手段,不是遮羞布
❌ 有 @Transactional → 并发安全
✅ Transaction ≠ concurrency control,事务管原子性,管不住并发和外部资源
而四类高频事故,值得刻在工位上:并发、唯一性、性能、幂等。这四件事,正好也是今天四段代码的主线。
写在最后
AI 时代,写代码的边际成本趋近于零,但**"判断这段代码上线后会出什么事"的能力,一点都没有贬值**------它反而成了区分"会调 AI 的人"和"能对系统负责的人"的那道线。
你不需要一次就看出所有问题。第一道题你能看出并发,就已经是很好的开始;第四道题一时看不出边界,太正常了,它本来就是靠"固定扫描顺序"练出来的。先把攻击清单背下来,再用它一遍遍扫真实的代码,扫得多了,你自然就从一个"能看出问题的人",长成"能写出工程级方案的人"。
明天,还有四道题等着你。到时候我们再看,你的清单有没有更熟一点。