JPA条件插入

时间:2015-10-07 12:47:43

标签: java mysql spring hibernate jpa

我有一个基于Java Spring的Web应用程序,我想只在表中没有任何"类似"的行时才将记录插入到表中。 (根据一些具体的,不相关的标准)到新的一行。

因为这是一个多线程环境,所以我不能使用SELECT + INSERT两步组合,因为它会让我遇到竞争条件。

几年前,同样的问题首先被问及并回答herehere。不幸的是,问题只得到了一点关注,所提供的答案不足以满足我的需求。

这是我目前拥有的代码,但它无效:

@Component("userActionsManager")
@Transactional
public class UserActionsManager implements UserActionsManagerInterface {

    @PersistenceContext(unitName = "itsadDB")
    private EntityManager manager;

    @Resource(name = "databaseManager")
    private DB db;

    ...

    @SuppressWarnings("unchecked")
    @Override
    @PreAuthorize("hasRole('ROLE_USER') && #username == authentication.name")
    public String giveAnswer(String username, String courseCode, String missionName, String taskCode, String answer) {
        ...

        List<Submission> submissions = getAllCorrectSubmissions(newSubmission);
        List<Result>     results     = getAllCorrectResults(result);

        if (submissions.size() > 0
        ||  results.size()     > 0) throw new SessionAuthenticationException("foo");

        manager.persist(newSubmission);
        manager.persist(result);

        submissions = getAllCorrectSubmissions(newSubmission);
        results     = getAllCorrectResults(result);

        for (Submission s : submissions) manager.lock(s, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        for (Result     r : results    ) manager.lock(r, LockModeType.OPTIMISTIC_FORCE_INCREMENT);

        manager.flush();

        ...
    }

    @SuppressWarnings("unchecked")
    private List<Submission> getAllCorrectSubmissions(Submission newSubmission) {
        Query q = manager.createQuery("SELECT s FROM Submission AS s WHERE s.missionTask = ?1 AND s.course = ?2 AND s.user = ?3 AND s.correct = true");
        q.setParameter(1, newSubmission.getMissionTask());
        q.setParameter(2, newSubmission.getCourse());
        q.setParameter(3, newSubmission.getUser());
        return (List<Submission>) q.getResultList();
    }

    @SuppressWarnings("unchecked")
    private List<Result> getAllCorrectResults(Result result) {
        Query q = manager.createQuery("SELECT r FROM Result AS r WHERE r.missionTask = ?1 AND r.course = ?2 AND r.user = ?3");
        q.setParameter(1, result.getMissionTask());
        q.setParameter(2, result.getCourse());
        q.setParameter(3, result.getUser());
        return (List<Result>) q.getResultList();
    }

...

}

根据提供的答案here我应该以某种方式使用OPTIMISTIC_FORCE_INCREMENT,但它不起作用。我怀疑提供的答案是错误的,所以我需要一个更好的答案。

修改

添加了更多与上下文相关的代码。现在这段代码仍有竞争条件。当我同时发出10个HTTP POST请求时,将错误地插入大约5行。其他5个请求被HTTP错误代码409拒绝(冲突)。正确的代码将保证无论我发出多少并发请求,只会将1行插入数据库。使方法同步不是一个解决方案,因为竞争条件仍然表现出一些未知的原因(我测试过它)。

1 个答案:

答案 0 :(得分:0)

不幸的是,经过几天的研究,我无法找到解决问题的简短解决方案。由于我的时间预算不是无限制,我不得不想出一个解决方法。如果可以的话,把它称为kludge。

由于整个HTTP请求是一个事务,因此会在发生任何冲突时回滚。我通过在整个HTTP请求的上下文中锁定一个特殊实体来利用这个优势。如果同时收到多个HTTP请求,除了一个以外的所有请求都会产生一些PersistenceException

在交易开始时,我正在检查是否还没有提交其他正确答案。在该检查期间,锁已经有效,因此不会发生竞争条件。在提交答案之前锁定有效。这基本上模拟了一个关键部分作为应用程序级别的SELECT + INSERT两步查询(在纯MySQL中我将使用INSERT IF NOT EXISTS构造)。

这种方法有一些缺点。每当两个学生同时提交答案时,其中一个将被抛出异常。这对性能和带宽有害,因为收到HTTP STATUS 409的学生必须重新提交答案。

为了补偿后者,我会在随机选择的时间间隔之间自动重试在服务器端提交几次答案。请参阅相应的HTTP请求控制器代码如下:

@Controller
@RequestMapping("/users")
public class UserActionsController {
    @Autowired
    private SessionRegistry sessionRegistry;

    @Autowired
    @Qualifier("authenticationManager")
    private AuthenticationManager authenticationManager;    
    @Resource(name = "userActionsManager")
    private UserActionsManagerInterface userManager;
    @Resource(name = "databaseManager")
    private DB db;

    .
    .
    .

    @RequestMapping(value = "/{username}/{courseCode}/missions/{missionName}/tasks/{taskCode}/submitAnswer", method = RequestMethod.POST)
    public @ResponseBody
    Map<String, Object> giveAnswer(@PathVariable String username,
            @PathVariable String courseCode, @PathVariable String missionName,
            @PathVariable String taskCode, @RequestParam("answer") String answer, HttpServletRequest request) {
        init(request);
        db.log("Submitting an answer to task `"+taskCode+"` of mission `"+missionName+
               "` in course `"+courseCode+"` as student `"+username+"`.");
        String str = null;
        boolean conflict = true;

        for (int i=0; i<10; i++) {
            Random rand = new Random();
            int ms = rand.nextInt(1000);

            try {
                str = userManager.giveAnswer(username, courseCode, missionName, taskCode, answer);
                conflict = false;
                break;
            }
            catch (EntityExistsException e) {throw new EntityExistsException();}
            catch (PersistenceException e) {}
            catch (UnexpectedRollbackException e) {}

            try {
                Thread.sleep(ms);
            } catch(InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
        if (conflict) str = userManager.giveAnswer(username, courseCode, missionName, taskCode, answer);

        if (str == null) db.log("Answer accepted: `"+answer+"`.");
        else             db.log("Answer rejected: `"+answer+"`.");

        Map<String, Object> hm = new HashMap<String, Object>();

        hm.put("success", str == null);
        hm.put("message", str);

        return hm;
    }
}

如果由于某种原因控制器无法连续10次提交事务,那么它将再次尝试但不会尝试捕获可能的异常。当第11次抛出异常时,它将由全局异常控制器处理,客户端将接收HTTP STATUS 409.全局异常控制器定义如下。

@ControllerAdvice
public class GlobalExceptionController {
    @Resource(name = "staticDatabaseManager")
    private StaticDB db;

    @ExceptionHandler(SessionAuthenticationException.class)
    @ResponseStatus(value=HttpStatus.FORBIDDEN, reason="session has expired") //403
    public ModelAndView expiredException(HttpServletRequest request, Exception e) {
        ModelAndView mav = new ModelAndView("exception");
        mav.addObject("name", e.getClass().getSimpleName());
        mav.addObject("message", e.getMessage());
        return mav;
    }

    @ExceptionHandler({UnexpectedRollbackException.class, 
                       EntityExistsException.class,
                       OptimisticLockException.class,
                       PersistenceException.class})
    @ResponseStatus(value=HttpStatus.CONFLICT, reason="conflicting requests") //409
    public ModelAndView conflictException(HttpServletRequest request, Exception e) {
        ModelAndView mav = new ModelAndView("exception");
        mav.addObject("name", e.getClass().getSimpleName());
        mav.addObject("message", e.getMessage());

        synchronized (db) {
            db.setUserInfo(request);
            db.log("Conflicting "+request.getMethod()+" request to "+request.getRequestURI()+" ("+e.getClass().getSimpleName()+").", Log.LVL_SECURITY);
        }        

        return mav;
    }

    //ResponseEntity<String> customHandler(Exception ex) {
    //    return new ResponseEntity<String>("Conflicting requests, try again.", HttpStatus.CONFLICT);
    //}
}

最后,giveAnswer方法本身使用具有主键lock_addCorrectAnswer的特殊实体。我使用OPTIMISTIC_FORCE_INCREMENT标志锁定该特殊实体,这确保没有两个事务可以具有giveAnswer方法的重叠执行时间。相应的代码如下所示:

@Component("userActionsManager")
@Transactional
public class UserActionsManager implements UserActionsManagerInterface {

    @PersistenceContext(unitName = "itsadDB")
    private EntityManager manager;

    @Resource(name = "databaseManager")
    private DB db;

    .
    .
    .

    @SuppressWarnings("unchecked")
    @Override
    @PreAuthorize("hasRole('ROLE_USER') && #username == authentication.name")
    public String giveAnswer(String username, String courseCode, String missionName, String taskCode, String answer) {
        .
        .
        .
        if (!userCanGiveAnswer(user, course, missionTask)) {
            error = "It is forbidden to submit an answer to this task.";
            db.log(error, Log.LVL_MAJOR);
            return error;
        }
        .
        .
        .
        if (correctAnswer) {
            .
            .
            .          
            addCorrectAnswer(newSubmission, result);
            return null;
        }

        newSubmission = new Submission(user, course, missionTask, answer, false);
        manager.persist(newSubmission);
        return error;
    }

    private void addCorrectAnswer(Submission submission, Result result) {
        String var = "lock_addCorrectAnswer";
        Global global = manager.find(Global.class, var);

        if (global == null) { 
            global = new Global(var, 0);
            manager.persist(global);
            manager.flush();
        }
        manager.lock(global, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        manager.persist(submission);
        manager.persist(result);
        manager.flush();

        long submissions = getCorrectSubmissionCount(submission);
        long results     = getResultCount(result);
        if (submissions > 1 || results > 1) throw new EntityExistsException();
    }

    private long getCorrectSubmissionCount(Submission newSubmission) {
        Query q = manager.createQuery("SELECT count(s) FROM Submission AS s WHERE s.missionTask = ?1 AND s.course = ?2 AND s.user = ?3 AND s.correct = true");
        q.setParameter(1, newSubmission.getMissionTask());
        q.setParameter(2, newSubmission.getCourse());
        q.setParameter(3, newSubmission.getUser());
        return (Long) q.getSingleResult();
    }

    private long getResultCount(Result result) {
        Query q = manager.createQuery("SELECT count(r) FROM Result AS r WHERE r.missionTask = ?1 AND r.course = ?2 AND r.user = ?3");
        q.setParameter(1, result.getMissionTask());
        q.setParameter(2, result.getCourse());
        q.setParameter(3, result.getUser());
        return (Long) q.getSingleResult();
    }
}

请务必注意,实体Global必须在其类中包含版本注释才能使OPTIMISTIC_FORCE_INCREMENT生效(请参阅下面的代码)。

@Entity
@Table(name = "GLOBALS")
public class Global implements Serializable {
    .
    .
    .
    @Id
    @Column(name = "NAME", length = 32)
    private String key;
    @Column(name = "INTVAL")
    private int intVal;
    @Column(name = "STRVAL", length = 4096)
    private String strVal;
    @Version
    private Long version;
    .
    .
    .
}

这种方法可以进一步优化。我可以从提交用户的名称确定性地生成锁名称,而不是对所有giveAnswer调用使用相同的锁名lock_addCorrectAnswer。例如,如果学生的用户名是Hyena,那么锁定实体的主键将是lock_Hyena_addCorrectAnswer。这样多个学生可以同时提交答案而不会收到任何冲突。但是,如果恶意用户并行绑定submitAnswer 10x的HTTP POST方法,则此锁定机制将阻止它们。