原本代码
@Override
@Transactional(rollbackFor = Exception.class)
public ResponseResult<Void> scrapFromLocations(Long equipmentId, BizScrapDetailDto bizScrapDetailDto, List<BizLocationDto> bizLocationDtoList) {
if (equipmentId == null) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "设备ID不能为空");
}
if (bizScrapDetailDto == null || bizScrapDetailDto.getQuantity() == null || bizScrapDetailDto.getQuantity() <= 0) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "报废明细无效:总报废数量必须大于0");
}
Integer totalScrapQty = bizScrapDetailDto.getQuantity();
if (CollUtil.isEmpty(bizLocationDtoList)) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置列表不能为空");
}
Long userId = Objects.requireNonNull(TokenData.takeFromRequest()).getUserId();
String userName = Objects.requireNonNull(TokenData.takeFromRequest()).getShowName();
try {
// 1. 查询库存设备
BizInventoryEquipment inventory = bizInventoryEquipmentService.getById(equipmentId);
if (inventory == null) {
return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "设备不存在");
}
if (totalScrapQty > inventory.getAvailableQuantity()) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "总报废数量超过设备可用库存数量");
}
// 2. 提取位置ID列表并查询位置记录
List<Long> locationIds = bizLocationDtoList.stream().map(BizLocationDto::getId).filter(Objects::nonNull).collect(Collectors.toList());
if (CollUtil.isEmpty(locationIds)) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置ID列表不能为空");
}
List<BizLocation> bizLocations = bizLocationService.listByIds(locationIds);
if (CollUtil.isEmpty(bizLocations) || bizLocations.size() != locationIds.size()) {
return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置记录无效或不存在");
}
// 构建位置Map (id -> BizLocation)
Map<Long, BizLocation> locationMap = bizLocations.stream()
.collect(Collectors.toMap(BizLocation::getId, Function.identity()));
// 验证每个位置的报废数量,并更新位置库存
List<BizLocation> locationsToUpdate = new ArrayList<>();
int sumScrapQty = 0;
for (BizLocationDto locDto : bizLocationDtoList) {
Long locId = locDto.getId();
Integer scrapQty = locDto.getQuantity();
if (locId == null || scrapQty == null || scrapQty < 0) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废参数无效:ID或数量不能为空或负数");
}
BizLocation loc = locationMap.get(locId);
if (loc == null) {
return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置记录不存在");
}
if (scrapQty > loc.getQuantity()) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "某位置报废数量超过该位置库存数量");
}
sumScrapQty += scrapQty;
// 更新位置库存:减去报废数量
loc.setQuantity(loc.getQuantity() - scrapQty);
locationsToUpdate.add(loc);
}
if (sumScrapQty != totalScrapQty) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废数量总和与总报废数量不匹配");
}
// 3. 查询项目设备(基于plmNo + partNo匹配)
BizProjectEquipment projectEquipment = bizProjectEquipmentService.getOne(
new QueryWrapper<BizProjectEquipment>()
.eq("plm_no", inventory.getPlmNo())
.eq("part_no", inventory.getPartNo())
);
String projectName = null;
if (projectEquipment != null) {
// 检查设备状态
if (BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(projectEquipment.getStatus())) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "请将设备归还后再执行报废操作");
}
// 查询项目名称
BizCommissioningProject project = bizCommissioningProjectService.getById(projectEquipment.getProjectId());
projectName = project != null ? project.getProjectName() : null;
}
// 4. 创建报废明细(总报废记录)
BizScrapDetail scrapDetail = new BizScrapDetail();
scrapDetail.setEquipmentId(equipmentId);
scrapDetail.setProjectName(projectName);
scrapDetail.setScrapPerson(userName);
scrapDetail.setScrapTime(MyDateUtil.nowDateTime());
scrapDetail.setQuantity(totalScrapQty);
String remark = bizLocationDtoList.stream()
.filter(dto -> dto.getLocation() != null && dto.getQuantity() != null && dto.getQuantity() > 0) // 过滤无效项
.map(dto -> dto.getLocation() + "报废" + dto.getQuantity() + "个") // 拼接单个字符串
.collect(Collectors.joining("、"));
scrapDetail.setRemark(remark);
scrapDetail.setCreator(userName);
scrapDetail.setCreateUserId(userId);
scrapDetail.setCreateTime(MyDateUtil.nowDateTime());
scrapDetail.setDelFlag(0);
// 5. 更新位置库存(新增空检查)
if (CollUtil.isNotEmpty(locationsToUpdate)) {
boolean updated = bizLocationService.updateBatchById(locationsToUpdate);
if (!updated) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置库存更新失败");
}
}
// 6. 更新库存设备可用数量和状态
int newAvailableQty = inventory.getAvailableQuantity() - totalScrapQty;
int newTotalQty = inventory.getTotalQuantity() - totalScrapQty;
inventory.setAvailableQuantity(newAvailableQty);
inventory.setTotalQuantity(newTotalQty);
inventory.setStatus(newAvailableQty == 0
? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
: BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);
boolean inventoryUpdated = bizInventoryEquipmentService.updateById(inventory);
if (!inventoryUpdated) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "库存设备更新失败");
}
// 7. 更新项目设备状态(如果存在)
List<BizProjectEquipment> projectEquipmentsToUpdate = new ArrayList<>();
if (projectEquipment != null) {
projectEquipment.setStatus(newAvailableQty == 0
? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
: BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);
projectEquipmentsToUpdate.add(projectEquipment);
}
if (CollUtil.isNotEmpty(projectEquipmentsToUpdate)) {
boolean projectUpdated = bizProjectEquipmentService.updateBatchById(projectEquipmentsToUpdate);
if (!projectUpdated) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "项目设备更新失败");
}
}
// 8. 保存报废明细
bizScrapDetailMapper.insert(scrapDetail);
} catch (Exception e) {
log.error("位置报废操作异常,设备ID: {}", equipmentId, e);
return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "报废操作失败");
}
return ResponseResult.success();
}
问题分析
这段代码整体逻辑是“从多个位置报废某设备的部分库存”,看起来功能是想实现的,但确实存在不少严重问题,导致它在实际生产环境大概率会出数据不一致、事务回滚不彻底、甚至库存变成负数的情况。
下面我把所有致命/高危问题按严重程度逐一列出,并给出正确的改法建议:
1. 最严重的事务安全问题(数据可能不一致)
当前代码的执行顺序是:
先更新位置库存(bizLocationService.updateBatchById)
→ 再更新库存设备总可用量(bizInventoryEquipmentService.updateById)
→ 再更新项目设备状态
→ 最后才 insert 报废明细
如果在第 6 或第 7 步抛异常(比如数据库死锁、网络抖动、唯一索引冲突等),事务会回滚,但第 5 步的位置库存已经真实扣减了,导致:
- 位置库存被扣掉了
- 总库存没扣(回滚了)
- 没有报废记录
结果:位置库存永远少了一块钱,找都找不回来,典型的“幽灵扣库存”。
正确做法:所有写操作(包含 insert 报废记录)必须放在一起完成,建议顺序改为:
// 1. 先只计算和校验,不做任何写操作
// 2. 开始真正写数据库,按这个顺序:
a. insert 报废主表 biz_scrap_detail
b. 更新所有 BizLocation(扣位置库存)
c. 更新 BizInventoryEquipment(扣总库存、改状态)
d. 更新 BizProjectEquipment(如果存在)
// 只要有一步失败,整个事务全部回滚,这样数据永远一致
2. 缺少对并发库存超卖的控制
多个用户同时对同一设备做“从位置报废”,即使总和没超,位置可能被扣成负数。
现在只在 Java 层判断 scrapQty > loc.getQuantity(),这在高并发下完全挡不住超卖。
正确做法(任选其一):
- 使用数据库行锁:select … for update 查询位置和库存表
- 使用 MySQL 原子扣减:UPDATE biz_location SET quantity = quantity - ? WHERE id = ? AND quantity >= ?
- 使用 Redis 分布式锁或 Redisson(如果并发很高)
最低成本方案(推荐):
// 在事务最开始就带悲观锁查一次
bizLocationService.listByIdsForUpdate(locationIds); // 底层 select ... for update
inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);
3. 项目设备查询逻辑有 bug
现在是:
.eq("plm_no", inventory.getPlmNo())
.eq("part_no", inventory.getPartNo())
一个项目里可能有多个相同的 plmNo + partNo(比如批次不同),应该再加一个唯一条件,或者改成一对一关系。
更严重的是:即使查出来多条,也只取了第一个(getOne),后面状态更新只更新这一条,其他的就漏了。
4. remark 拼接在校验阶段就做了,但没用到
你先 stream 拼 remark,但如果前面校验失败,这个 remark 白拼了,无所谓,但更合理的做法是校验全部通过后再拼一次。
5. 部分状态判断逻辑不合理
if (BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(projectEquipment.getStatus())) {
return "请将设备归还后再执行报废操作";
}
报废应该是针对库存设备的操作,只要不在“已领取”状态就行,但这里只判断了项目设备状态,库存设备本身的状态没判断。
6. 建议的完整重构顺序(推荐最终版)
@Override
@Transactional(rollbackFor = Exception.class)
public ResponseResult<Void> scrapFromLocations(...) {
// 1. 所有入参校验(保持不变)
// 2. 带悲观锁查询核心数据(关键!)
BizInventoryEquipment inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);
List<BizLocation> bizLocations = bizLocationService.listByIdsForUpdate(locationIds);
// 3. 再次做所有数量校验(因为可能被别的线程改了)
// 4. 组装 remark(此时一定成功)
// 5. 插入报废主记录(先插,方便审计)
bizScrapDetailMapper.insert(scrapDetail);
// 6. 批量更新位置库存(可以用 UPDATE ... CASE WHEN 原子批量扣减更安全)
// 7. 更新总库存
inventory.setAvailableQuantity(...);
inventory.setTotalQuantity(...);
inventory.setStatus(...);
bizInventoryEquipmentService.updateById(inventory);
// 8. 更新关联的项目设备状态(如果有多条要全部更新)
return ResponseResult.success();
}
总结:当前代码的最大问题排名
- 写操作顺序错误 → 会导致位置库存永久丢失(致命)
- 无并发控制 → 高并发下库存变负数(致命)
- 项目设备查询不准确 + 只更新一条(严重)
- 部分校验可以更严谨(中度)
重构之后的代码
已解决原来所有致命问题:
- 使用悲观锁(SELECT … FOR UPDATE)防止并发超卖
- 所有写操作都在锁保护下进行,任意一步失败整个事务回滚
- 先插入报废记录,再扣库存(审计优先,数据永远可追溯)
- 项目设备按 plmNo + partNo 可能多条全部更新状态
- 位置库存扣减支持批量原子操作(可选两种实现,任选其一)
- 代码结构清晰、易于维护
@Override
@Transactional(rollbackFor = Exception.class)
public ResponseResult<Void> scrapFromLocations(Long equipmentId,
BizScrapDetailDto bizScrapDetailDto,
List<BizLocationDto> bizLocationDtoList) {
// ==================== 1. 基础参数校验 ====================
if (equipmentId == null) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "设备ID不能为空");
}
if (bizScrapDetailDto == null || bizScrapDetailDto.getQuantity() == null || bizScrapDetailDto.getQuantity() <= 0) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "报废明细无效:总报废数量必须大于0");
}
if (CollUtil.isEmpty(bizLocationDtoList)) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置列表不能为空");
}
Integer totalScrapQty = bizScrapDetailDto.getQuantity();
Long userId = Objects.requireNonNull(TokenData.takeFromRequest()).getUserId();
String userName = Objects.requireNonNull(TokenData.takeFromRequest()).getShowName();
// ==================== 2. 提取并校验位置ID ====================
List<Long> locationIds = bizLocationDtoList.stream()
.map(BizLocationDto::getId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (CollUtil.isEmpty(locationIds)) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置ID列表不能为空");
}
// ==================== 3. 悲观锁加载核心数据(关键!防止并发超卖) ====================
// 库存设备加行锁
BizInventoryEquipment inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);
if (inventory == null) {
return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "设备不存在");
}
// 所有位置加行锁
List<BizLocation> bizLocations = bizLocationService.listByIdsForUpdate(locationIds);
if (CollUtil.isEmpty(bizLocations) || bizLocations.size() != locationIds.size()) {
return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "部分位置记录不存在");
}
Map<Long, BizLocation> locationMap = bizLocations.stream()
.collect(Collectors.toMap(BizLocation::getId, Function.identity()));
// ==================== 4. 再次校验库存(锁内第二次校验,防止并发修改) ====================
if (totalScrapQty > inventory.getAvailableQuantity()) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "总报废数量超过当前可用库存");
}
int sumFromLocation = 0;
StringBuilder remarkBuilder = new StringBuilder();
for (BizLocationDto dto : bizLocationDtoList) {
Long locId = dto.getId();
Integer scrapQty = dto.getQuantity();
if (locId == null || scrapQty == null || scrapQty <= 0) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废数量必须大于0");
}
BizLocation loc = locationMap.get(locId);
if (loc == null) {
return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置ID=" + locId + " 不存在");
}
if (scrapQty > loc.getQuantity()) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED,
String.format("位置[%s]报废数量(%d)超过当前库存(%d)", loc.getLocation(), scrapQty, loc.getQuantity()));
}
sumFromLocation += scrapQty;
if (remarkBuilder.length() > 0) remarkBuilder.append("、");
remarkBuilder.append(loc.getLocation()).append("报废").append(scrapQty).append("个");
}
if (sumFromLocation != totalScrapQty) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废总数与明细总数不一致");
}
// ==================== 5. 查询关联的项目设备(可能有多条) ====================
List<BizProjectEquipment> projectEquipments = bizProjectEquipmentService.list(
new QueryWrapper<BizProjectEquipment>()
.eq("plm_no", inventory.getPlmNo())
.eq("part_no", inventory.getPartNo())
);
// 如果有已领取的,禁止报废(业务要求)
if (CollUtil.isNotEmpty(projectEquipments)) {
boolean hasReceived = projectEquipments.stream()
.anyMatch(pe -> BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(pe.getStatus()));
if (hasReceived) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "存在已领取的项目设备,请先归还后再报废");
}
}
// ==================== 6. 插入报废主记录(先插,便于审计) ====================
BizScrapDetail scrapDetail = new BizScrapDetail();
scrapDetail.setEquipmentId(equipmentId);
scrapDetail.setProjectName(getProjectNameIfExist(projectEquipments)); // 可自行实现
scrapDetail.setScrapPerson(userName);
scrapDetail.setScrapTime(MyDateUtil.nowDateTime());
scrapDetail.setQuantity(totalScrapQty);
scrapDetail.setRemark(remarkBuilder.toString());
scrapDetail.setCreator(userName);
scrapDetail.setCreateUserId(userId);
scrapDetail.setCreateTime(MyDateUtil.nowDateTime());
scrapDetail.setDelFlag(0);
bizScrapDetailMapper.insert(scrapDetail);
// ==================== 7. 扣减位置库存(推荐方式二:批量原子更新,更高效) ====================
// 方式一:普通对象更新(简单)
// List<BizLocation> toUpdateLocations = new ArrayList<>();
// for (BizLocationDto dto : bizLocationDtoList) {
// BizLocation loc = locationMap.get(dto.getId());
// loc.setQuantity(loc.getQuantity() - dto.getQuantity());
// toUpdateLocations.add(loc);
// }
// bizLocationService.updateBatchById(toUpdateLocations);
// 方式二:一条 SQL 批量原子扣减(推荐,性能更好且绝对安全)
bizLocationService.batchSubtractQuantity(bizLocationDtoList);
// ==================== 8. 更新总库存 ====================
int newAvailableQty = inventory.getAvailableQuantity() - totalScrapQty;
int newTotalQty = inventory.getTotalQuantity() - totalScrapQty;
inventory.setAvailableQuantity(newAvailableQty);
inventory.setTotalQuantity(newTotalQty);
inventory.setStatus(newAvailableQty == 0
? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
: BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);
bizInventoryEquipmentService.updateById(inventory);
// ==================== 9. 更新所有关联项目设备状态 ====================
if (CollUtil.isNotEmpty(projectEquipments)) {
projectEquipments.forEach(pe -> pe.setStatus(newAvailableQty == 0
? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
: BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED));
bizProjectEquipmentService.updateBatchById(projectEquipments);
}
return ResponseResult.success();
}
需要额外实现的方法(推荐放在对应 Service 中)
// BizLocationService
@Select("SELECT * FROM biz_location WHERE id = #{id} FOR UPDATE")
BizLocation getByIdForUpdate(Long id);
@Select("SELECT * FROM biz_location WHERE id IN <foreach>...</foreach> FOR UPDATE")
List<BizLocation> listByIdsForUpdate(Collection<Long> ids);
// 批量原子扣减(最推荐)
@Update("<script>" +
"UPDATE biz_location SET quantity = quantity - #{quantity}, update_time = NOW() " +
"WHERE id = #{id} AND quantity >= #{quantity} " +
"<foreach collection='list' item='item' separator=';'> " +
"WHEN id = #{item.id} THEN #{item.quantity} " +
"</foreach>" +
"</script>")
void batchSubtractQuantity(@Param("list") List<BizLocationDto> dtoList);
// BizInventoryEquipmentService
@Select("SELECT * FROM biz_inventory_equipment WHERE id = #{id} FOR UPDATE")
BizInventoryEquipment getByIdForUpdate(Long id);
995

被折叠的 条评论
为什么被折叠?



