系列内核:代码规范的意义,不是约束人,而是实打实的减少踩坑几率。 一条规范值不值得写进红线,取决于一件事——程序员能不能只看眼前这个方法,就判定自己有没有违反它。
三处代码,单独看都没错
三段代码,散落在三个模块,出自不同作者、不同时期,每一处单独 review 都挑不出毛病。
第一处,批量核销模块:每张券丢进线程池并发核销,正常写法。
@Async("batchRedeemThreadPoolExecutor")
public Future<IStatusCode> doSingleRedeem(String partnerOrderId, RedeemDetail redeemDetail, CouponRequestVO requestVO) {
…
}
第二处,渠道对接模块:拼装上游请求时,过滤掉没选中的商品。removeIf 删集合,没什么问题。
List<PayProducts> products = payBaseRequestVo.getProducts();
if (isallRedeem == null || !isallRedeem) {
products.removeIf(p -> p.getSelected() == null || !"1".equals(p.getSelected()));
}
第三处,公共缓存组件:把交易对象序列化后写进 Redis。一个「只是存一下」的工具方法。
public void saveVlue(String key, Object value) {
try {
template.boundValueOps(key).set(JSON.toJSONString(value), EXPIRE_TIME, timeUnit);
} catch (Exception e) {
RedisLog.redisErrorAlert(this.getClass(), key, e);
}
}
三处代码互不相识,谁有错?
引爆它们的,是一行最无辜的胶水代码
异步方法里,构造单券请求时要把字段从总请求搬到子请求:
singleRequestVO.setCode(redeemDetail.getCode());
singleRequestVO.setActId(redeemDetail.getActId());
singleRequestVO.setProducts(requestVO.getProducts()); // ← 就是这行
前面都是 String,怎么 set 都安全;唯独 products 是个 List——这一行 set 过去的是引用。被 @Async 派发出去的所有线程,共享了同一个 List<PayProducts> 实例。三处无辜代码,就这样被串上同一根引线:
-
线程 A 跑到第二处,removeIf 对这个 List 做结构性删除;
-
同一毫秒,线程 B 把交易对象(字段里挂着同一个 List)交给第三处存缓存。
这里藏着全案最容易被忽略的认知盲区:
JSON.toJSONString 不是「存储」,是「遍历读」。
它看起来只是把对象「放进 Redis」,但序列化底层要把每个字段、每个集合元素完整读一遍——fastjson 序列化 List 用的是 for (int i = 0; i < size; ++i) list.get(i) 的下标遍历。size 在循环开始时定格,线程 A 一删元素,线程 B 的 get(i) 就读越界:IndexOutOfBoundsException;走迭代器的路径则是 ConcurrentModificationException。一边删、一边读,怎么都是炸。
这不是必现 bug,是概率 bug:两个线程要在一个极窄的时间窗口里,恰好一个在删、一个在读同一个 List。开发自测单线程跑一万遍也不会出错;功能测试一单一单点,撞不上这个窗口;压测只要没盖住「批量多券并发」这个具体场景,同样安然无恙。它只在生产流量堆到一定量之后,才按概率出现——而等它第一次暴露时,这三处代码已经平稳运行了很久,没人会怀疑「一直好好的」代码。
三处代码各自无辜,被一行引用赋值串在一起才致命,而且不到一定的量,它根本不出现。
从这个坑里,能定出什么规范
谨慎使用集合的 remove 系列方法(removeIf / remove / clear)——它们是原地修改,会直接改掉调用方手里的数据。尽量用 filter 产出新集合:
// ✗ 原地删,改了别人的 List
products.removeIf(p -> !"1".equals(p.getSelected()));
// ✓ 过滤出新 List,原集合不动
List<PayProducts> selected = products.stream()
.filter(p -> "1".equals(p.getSelected()))
.collect(Collectors.toList());
这条规范能落地,因为它不需要任何上下文:不管上游有没有并发、下游有没有序列化,写这行代码的人当场就能判断自己有没有违反。而且成本几乎为零——stream 过滤和 removeIf 就是一行换一行。
第二个案例:不需要并发,一次重试就现形
第一个坑要靠并发和流量才能引爆,容易得出一个危险的推论:「我这段代码没有并发,改改收到的对象没事」。第二个案例专门粉碎它——单线程运行,靠一次普通的失败重试就把数据搞错。
场景:MQ 消费者处理支付回调。商品要按数量拆开(consumeNum=3 的商品拆成 3 个数量为 1 的)再通知第三方;通知失败由重试注解自动重跑整个消费方法。
入口处的防御意识很到位——先拷一份再处理:
CashierNotifyEntity bizData = new CashierNotifyEntity();
BeanUtils.copyProperties(bizMessage.getBody(), bizData); // 浅拷贝:只拷了一层
但 copyProperties 是浅拷贝:bizData 是新对象,里面的商品元素还是原来那批——每个商品对象同时被 bizData 和源消息 bizMessage.getBody() 引用着。
拆商品时改了数量:
for (PayProduct product : products) {
Integer consumenum = product.getConsumeNum();
product.setConsumeNum(1); // ← 改的这个商品,也是外面消息里的商品
for (int i = 0; i < consumenum; i++) {
PayProduct copy = new PayProduct();
BeanUtils.copyProperties(product, copy);
copy.setSubProducts(handProducts(product.getSubProducts()));
result.add(copy);
}
}
写这行的人以为自己改的是「拷贝出来的对象」,但这个商品对象还被外面的源消息引用着——改它,就是改外面的消息。
两条错误链,都和并发无关:
当场就错。 拆分是递归的,子商品按同样逻辑拆。父商品数量 2、子商品数量 3 时:第一轮递归把原始子商品改成 1 并拆出 3 件;第二轮递归读到的已经是改过的 1,只拆出 1 件。两个父副本,一个挂 3 件子商品、一个挂 1 件,本该 6 件只得 4 件——前一轮循环对原件的篡改,污染了后一轮。
重试必错。 通知失败,重试注解重跑整个方法,用的正是外面那个消息对象——它的商品数量已被上一次执行改成了 1,原本 3 件的商品,重试时只拆出 1 件。「这个商品有 3 件」的唯一记录就是 consumeNum=3,上一次执行把它销毁了——重试拿到的世界里,这个商品从来就是 1 件。更隐蔽的是,异常日志里打出的 consumeNum 也是 1,一切「自洽」,排查时根本想不到它被改过。
修复是同一行代码换个位置:
BeanUtils.copyProperties(product, copy);
copy.setConsumeNum(1); // 改自己 new 的副本;外面的消息全程只读,「3」永远安全
这个案例还回答了一个常见的辩解:「原件后面又没人用,改它无所谓。」——「没人用」是个要枚举完所有读者才能成立的断言:本方法的、调用方的、重试框架的、异常日志的。这里恰好漏了两个最不显眼的:重试注解和 catch 里的日志。而改副本不需要证明任何东西——刚 new 出来的对象没人认识,这是构造保证的,不是排查保证的。
两个案例,同一条病根
| 写了谁的数据 | 收到的集合(removeIf) | 收到的元素(setConsumeNum) |
| 引爆机制 | 并发读写,概率触发 | 重试重放,条件命中必现 |
| 隐蔽性来源 | 要有量才出现 | 重试的输入被上次执行改过,日志现场也是改后的值 |
并发只是让这类错误更难复现,不是它存在的前提。写了不属于自己的数据,单线程加一次重试,一样出事。
启发
把这两个坑内化成几个写代码时的条件反射:
1. 入参里的集合,默认当只读。 不是自己 new 出来的集合,就不原地改。不需要判断场景、不需要权衡,永远这么做。
2. 写 remove 系列方法前,先问一句:这个集合是谁的? 自己方法里刚创建的,随便改;参数传进来的、从别的对象 get 出来的,换成产出新集合。
3. 写异步代码时,关注三件事。
-
参数:提交之后,主流程还会改这个对象吗?多个任务共享同一个可变对象吗?本案的引线就是一行 setProducts(requestVO.getProducts())。最稳的写法是参数自包含——传 ID、传值,让任务自己查数据。
-
上下文:ThreadLocal 里的东西(traceId、登录态)不会跟着任务进新线程,@Transactional 的事务也不会。
-
异常:返回 void 的 @Async 方法,异常不会传回调用方;要感知失败,返回 Future 再 get。
4. 记住「序列化 = 遍历读」。 JSON.toJSONString 不是无害的「存一下」,它要把集合每个元素读一遍。任何遍历可变集合的操作,都有参与并发读写冲突的资格。
前两条是零成本的肌肉记忆,后两条是场景触发的检查动作。共同点是:都只依赖你眼前看得到的代码,不要求你预见整个系统。
组合型 bug 的残酷之处在于,它靠「小心」防不住——没人能在写一行 removeIf 时预见另外两个模块的行为。能防住它的,只有把「只看眼前就能执行」的规则变成习惯。这就是规范的意义:不是让你更小心,而是让你不需要那么小心。
网硕互联帮助中心


评论前必须登录!
注册