Spring Boot - 如何避免控制器的并发访问

16
我们有一个Spring Boot应用程序,与现场的各种客户端链接。此应用程序具有一个控制器,从客户端调用并与数据库和物理开关交互,以打开或关闭灯。
问题在于当两个或多个客户端同时访问服务器上的API时,方法会检查灯是否打开或关闭(在数据库中)以更改其状态。如果灯是关闭的,并且同时有两个客户端调用服务,则第一个客户端打开灯并在数据库中更改状态,但第二个客户端也访问了该灯,此时数据库中的状态为关闭,但是第一个客户端已经打开了它,所以第二个客户端最终会误认为它需要将它关闭而不是打开...也许我的解释有点不太清楚,问题是:我能告诉Spring一次只访问一个控制器吗?
感谢下面的答案,我们在切换开关的方法上引入了悲观锁定,但我们仍然从客户端获得200的状态...
我们正在使用Spring Boot + Hibernate 现在该控制器具有悲观锁定异常。
  try {

                                String pinName = interruttore.getPinName();
                                // logger.debug("Sono nel nuovo ciclo di
                                // gestione interruttore");
                                if (!interruttore.isStato()) { // solo se
                                                                // l'interruttore
                                                                // è
                                                                // spento

                                    GpioPinDigitalOutput relePin = interruttore.getGpio()
                                            .provisionDigitalOutputPin(RaspiPin.getPinByName(pinName));
                                    interruttoreService.toggleSwitchNew(relePin, interruttore, lit);                                                            // accendo
                                    interruttore.getGpio().unprovisionPin(relePin);
                                }



                        } catch (GpioPinExistsException ge) {
                            logger.error("Gpio già esistente");
                        } catch (PessimisticLockingFailureException pe){
                            logger.error("Pessimistic Lock conflict", pe);
                            return new ResponseEntity<Sensoristica>(sensoristica, HttpStatus.CONFLICT);
                        }

toggleSwitchNew 如下所示

@Override
@Transactional(isolation=Isolation.REPEATABLE_READ)
public void toggleSwitchNew(GpioPinDigitalOutput relePin, Interruttore interruttore, boolean on) {
    Date date = new Date();
    interruttore.setDateTime(new Timestamp(date.getTime()));
    interruttore.setStato(on);

    String log = getLogStatus(on) + interruttore.getNomeInterruttore();
    logger.debug(log);
    relePin.high();
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        logger.error("Errore sleep ", e);
    }
    relePin.low();
    updateInterruttore(interruttore);
    illuminazioneService.createIlluminazione(interruttore, on);


}

然后我们在客户端记录请求的状态码,即使它们是并发的,它们始终会得到200

6个回答

35
这是一个经典的锁定问题。你可以使用悲观锁定:只允许一个客户端操作数据(互斥),或者乐观锁定:允许多个并发客户端操作数据,但只允许第一个提交者成功。
根据你使用的技术,有许多不同的方法来解决这个问题。例如,解决它的另一种方法是使用正确的数据库隔离级别。在你的情况下,似乎至少需要"可重复读"的隔离级别。
可重复读将确保如果两个并发事务同时读取和更改同一条记录,只有其中一个会成功。
在你的情况下,你可以使用适当的隔离级别标记你的Spring事务。
@Transactional(isolation=REPEATABLE_READ)
public void toggleSwitch() {
    String status = readSwithStatus();
    if(status.equals("on") {
         updateStatus("off");
    } else {
         updateStatus("on");
    }
}

如果两个并发的客户端尝试更新交换机的状态,先提交的将获胜,而第二个客户端将始终失败。你只需要准备好告诉第二个客户端,它的事务由于并发失败而未成功。这个第二个事务会自动回滚。你或者你的客户端可以决定是否重试。
@Autowire
LightService lightService;

@GET
public ResponseEntity<String> toggleLight(){
   try {
       lightService.toggleSwitch();
       //send a 200 OK
   }catch(OptimisticLockingFailureException e) {
      //send a Http status 409 Conflict!
   }
}

但正如我所说的,根据你使用的是什么(例如JPA、Hibernate、纯JDBC),有多种方法可以使用悲观或乐观的锁定策略来实现这一点。
为什么不只使用线程同步呢?
迄今为止,其他答案建议使用Java的线程级互斥来实现悲观锁定,使用同步块可能会起作用,如果你有一个单一的JVM运行你的代码。但是,如果你有多个JVM运行你的代码,或者如果你最终进行水平扩展并在负载均衡器后面添加更多的JVM节点,那么这种策略可能会变得无效,因为线程锁定将无法解决你的问题。
但是,你仍然可以在数据库级别实现悲观锁定,通过在更改之前强制进程锁定数据库记录,并在数据库级别创建互斥区域(例如,select for update)。
因此,重要的是理解锁定原则,然后找到适合你特定场景和技术栈的策略。很可能,在你的情况下,它将涉及在某个时候在数据库级别进行某种形式的锁定。

2
谢谢,这个解释非常清晰,我认为在这种情况下悲观的数据库锁定是最好的策略。 - MarioC
顺便说一下,我正在使用Hibernate。 - MarioC
很棒的答案。涵盖了最初提出的问题,然后又扩展了这个话题。 - Will

2
我认为这个API违反了PUT API应该是幂等的规则。最好拥有单独的turnOn和turnOff API,以避免这个问题。

1

其他人的回答对我来说过于复杂... 保持简单。

不要使用切换,使请求具有新值。在控制器内放置一个synchronized块。仅当新值与当前值不同时,在同步块中执行操作。

Object lock = new Object();
Boolean currentValue = Boolean.FALSE;
void ligthsChange(Boolean newValue) {
  synchronized(lock) {
    if (!currentValue.equals(newValue)) {
      doTheSwitch();
      currentValue = newValue;
    }
  }
}

1
可以使用ReentrantLock,或者使用synchronized。
public class TestClass {

private static Lock lock = new ReentrantLock();

 public void testMethod() {
        lock.lock();
        try {         
            //your codes here...
        } finally {
            lock.unlock();
        }
    }
}

0

使用synchronized - 但是如果您的用户点击得足够快,那么仍然会出现问题,因为一个命令将立即在另一个命令之后执行。

同步将确保只有一个线程在块中执行

synchronized(this) { ... } 

一次只能执行一个。

您可能还想引入延迟和拒绝快速连续的命令。

try {
    synchronized(this) {
        String pinName = interruttore.getPinName();                     
            if (!interruttore.isStato()) { // switch is off
            GpioPinDigitalOutput relePin = interruttore.getGpio()
                .provisionDigitalOutputPin(RaspiPin.getPinByName(pinName));
            interruttoreService.toggleSwitchNew(relePin, interruttore, lit); // turn it on
            interruttore.getGpio().unprovisionPin(relePin);
       }
   }
} catch (GpioPinExistsException ge) {
    logger.error("Gpio già esistente");
}

2
如果服务有多个副本呢? - cecunami

0

您也可以使用基于缓存的锁定。以下是使用Redis缓存的示例。 如果需要并发但有限制的访问,甚至可以允许幂等性键。

@Autowired
CacheManager cacheManager;

@PostMapping(path = "/upload", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
public void uploadFile(@RequestPart String idempotencyKey,
                       HttpServletResponse response) {

    Cache cache = cacheManager.getCache(CacheConfig.GIFT_REDEEM_IDEMPOTENCY);
    if (cache.get(idempotencyKey) != null) {
        throw new ForbiddenException("request being processed with the idempotencyKey = " + idempotencyKey);
    }
    cache.put(idempotencyKey, "");
    try {...
        }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接