在 Spring Boot 后端上工作,该后端有一个连接到前端按钮的端点。该端点对输入进行一些重构,然后调用两个单独的 JPA 存储库方法。第一个更新表中的记录,第二个插入一条新记录。有时似乎有多个请求到达该端点,并且这些方法没有被正确处理。这可能是由于用户向按钮发送垃圾邮件,或多个用户发出相同的请求。为了进行调试,我创建了生成唯一 UUID 的日志,并在每个线程进入端点、调用服务以及随后调用存储库方法时跟踪每个线程。据我所知,更新和插入并不是作为单个操作完成的,并且一些交错导致更多更新在其他更新之前运行,而不是它们的“匹配”插入。
这张桌子是已经存在很久的桌子了,所以暂时无法修改它的设计。
它的设计方式是它有一个 IS_LATEST 列,基本上跟踪项目的最新状态,但是当应用程序需要更新该项目的状态时,需要发生两件事:
@Override
@PostMapping("/updateMyDataTrackerLog/{dataLevel}/{targetMonth}/{screen}")
public ResponseEntity<ResponseVO> updateCompletionStatus(@RequestBody final List<MyDataTrackerLogVo> request,
final HttpServletRequest httpServletRequest,
@PathVariable(value = "dataLevel") final String dataLevel,
@PathVariable(value = "targetMonth") final String targetMonth,
@PathVariable(value = "screen") final Long screen) {
try {
final String userName = helper.getUserID(httpServletRequest);
service.updateCompletionStatus(dataLevel, targetMonth, screen, request, userName);
return new ResponseEntity<>(new ResponseVO<>("Success"), HttpStatus.CREATED);
} catch (Exception e) {
return new ResponseEntity<>(new ResponseVO("Error"), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Override
public void updateCompletionStatus(final String dataLevel, final String targetMonth,
final Long screen, final List<MyDataTrackerLogVo> request, String username) {
String uuid = String.valueOf(UUID.randomUUID()); // used for logging, removed logs for brevity
Map<String, Object> preparedInputsForUpdate = prepareMyDataTrackerLogForUpdate(dataLevel, targetMonth,
screen, request, username, uuid);
updateAndInsertMyDataTrackerLogs(preparedInputsForUpdate, uuid, username);
}
@Override
public Map<String, Object> prepareMyDataTrackerLogForUpdate(final String dataLevel, final String targetMonth,
final Long screen,
final List<MyDataTrackerLogVo> request,
final String username, String uuid) {
List<MyDataTrackerLog> batchedMyDataTrackerLogs = new ArrayList<>();
final Map<String, Object> inputs = new HashMap<>();
if (request != null && !request.isEmpty()) {
request.forEach(log -> {
final MyDataTrackerLog myDataTrackerLog = DataSetTrackerMapper.INSTANCE.toDataSetTrackerToDomain(log);
myDataTrackerLog.setLatest(true);
// sets a bunch of properties
batchedMyDataTrackerLogs.add(myDataTrackerLog);
});
List<String> possibleNestedCollectionOfStrings = request.stream()
.map(MyDataTrackerLogVo::getPossibleNestedCollectionOfStrings)
.collect(toList());
inputs.put("possibleNestedCollectionOfStrings", possibleNestedCollectionOfStrings);
inputs.put("myDataTrackerLogsBatch", batchedMyDataTrackerLogs);
}
return inputs;
}
protected void updateAndInsertMyDataTrackerLogs(Map<String, Object> inputs, String uuid, String username) {
List<MyDataTrackerLog> batch = (List<MyDataTrackerLog>) inputs.get("myDataTrackerLogsBatch");
List<String> possibleNestedCollectionOfStrings = (List<String>) inputs.get("possibleNestedCollectionOfStrings");
MyDataTrackerLog logMetaData = batch.get(0);
myDataTrackerLogRepository.updateIsLatestBatchedByPossibleNestedCollectionOfStrings(
false,
logMetaData.getBusinessYearMonth(),
logMetaData.getLevel2Bu(),
logMetaData.getScreenId(),
possibleNestedCollectionOfStrings
);
myDataTrackerLogRepository.saveAll(batch);
}
@Modifying
@Transactional
@Query("Update MyDataTrackerLog d SET "
+ "d.isLatest = :isLatest "
+ "where d.targetMonth = :targetMonth "
+ "AND d.level = :level AND d.screen = :screen "
+ "AND d.possibleNestedCollectionOfStrings in (:possibleNestedCollectionOfStrings) AND d.isLatest = true")
void updateIsLatestBatchedByPossibleNestedCollectionOfStrings(
final @Param("isLatest") Boolean isLatest, final @Param("targetMonth") Date targetMonth,
final @Param("level") String level, final @Param("screen") Long screen,
final @Param("possibleNestedCollectionOfStrings") List<String> possibleNestedCollectionOfStrings
);
请求一个
请求两个
第二个属性正在使用
IN
SQL 语法进行批处理和调用,以一次性处理所有属性,而不是循环(这是之前尝试解决此并发问题的建议)
因此,理想情况下,更新将清除所有匹配的项目记录并将其 IS_LATEST 设置为 false,然后立即进行插入,这将为数据库提供项目记录的最新表示。不幸的是,我看到这样的处理:
主题 1 | 主题 2 |
---|---|
开始更新 | |
开始更新 | |
完成更新 | |
开始插入 | |
完成更新 | |
开始插入 | |
完成插入 | |
完成插入 |
我认为正在发生的是 T2 完成更新没有捕获第一个插入,导致有两个“当前”项目记录。
我怀疑这里的解决方案是进行更新并插入单个原子化操作,而我能想到的唯一方法是锁定表,但我没有成功地尝试使用注释手动实现此操作,然后通过冬眠。我一定做错了什么。有没有其他方法可以从服务器端解决这个并发问题?
您想在一个原子事务中执行此操作,这是正确的。并发很容易出错,但谨慎起见,下面的方法会犯错误。
下面是 Transact-SQL 语句,但您可以使用 JPA 注释、jdbc 调用等执行其中一些步骤。
SERIALIZABLE 隔离模式导致第一个 SELECT 充当互斥锁,阻塞其他行,直到事务提交(或回滚)。
您可以使用查询提示(SELECT 上的 UPLOCK、HOLDLOCK)实现相同的效果。使用查询提示的好处是您无需事后重置隔离模式。 (JDBC 连接池并不总是在借用之间将事物重置为干净的状态,并且在任何地方使用 SERIALIZABLE 可能不是您想要的)。