幂等与时间解耦之旅

HTTP中的幂等性意味着相同的请求可以执行多次,并且效果与仅执行一次一样。 如果用新资源替换某个资源的当前状态,则无论您执行多少次,最终状态都将与您仅执行一次相同。 举一个更具体的例子:删除用户是幂等的,因为无论您通过唯一标识符删除给定用户多少次,最终都会删除该用户。 另一方面,创建新用户不是幂等的,因为两次请求该操作将创建两个用户。 用HTTP术语来说是RFC 2616:9.1.2等幂方法必须说的:

9.1.2等幂方法

方法还可以具有“ 幂等 ”的特性,因为[…] N> 0个相同请求的副作用与单个请求的副作用相同。 GET,HEAD,PUT和DELETE方法共享此属性。 同样,方法OPTIONS和TRACE不应有副作用,因此本质上是幂等的。

时间耦合是系统的不良特性,其中正确的行为隐含地取决于时间维度。 用简单的英语来说,这可能意味着例如系统仅在所有组件同时存在时才起作用。 阻塞请求-响应通信(ReST,SOAP或任何其他形式的RPC)要求客户端和服务器同时可用,这就是这种效果的一个例子。

基本了解这些概念的含义后,我们来看一个简单的案例研究- 大型多人在线角色扮演游戏 。 我们的人工用例如下:玩家发送高级短信以在游戏内购买虚拟剑。 交付SMS时将调用我们的HTTP网关,我们需要通知部署在另一台计算机上的InventoryService 。 当前的API涉及ReST,其外观如下:

@Slf4j
@RestController
class SmsController {
 
    private final RestOperations restOperations;
 
    @Autowired
    public SmsController(RestOperations restOperations) {
        this.restOperations = restOperations;
    }
 
    @RequestMapping(value = "/sms/{phoneNumber}", method = POST)
    public void handleSms(@PathVariable String phoneNumber) {
        Optional<Player> maybePlayer = phoneNumberToPlayer(phoneNumber);
        maybePlayer
                .map(Player::getId)
                .map(this::purchaseSword)
                .orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));
    }
 
    private long purchaseSword(long playerId) {
        Sword sword = new Sword();
        HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());
        restOperations.postForObject(
            "http://inventory:8080/player/{playerId}/inventory",
            entity, Object.class, playerId);
        return playerId;
    }
 
    private HttpHeaders jsonHeaders() {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        return headers;
    }
 
    private Optional<Player> phoneNumberToPlayer(String phoneNumber) {
        //...
    }
}

依次产生类似于以下内容的请求:

> POST /player/123123/inventory HTTP/1.1
> Host: inventory:8080
> Content-type: application/json
>
> {"type": "sword", "strength": 100, ...}
 
< HTTP/1.1 201 Created
< Content-Length: 75
< Content-Type: application/json;charset=UTF-8
< Location: http://inventory:8080/player/123123/inventory/1

这很简单。 SmsController只需通过发布购买的剑SmsController适当的数据转发到SmsController inventory:8080服务。 该服务立即或201 Created返回201 Created HTTP响应,确认操作成功。 此外,还会创建并返回到资源的链接,因此您可以对其进行查询。 有人会说:ReST是最新技术。 但是,如果您至少关心客户的钱并了解什么是ACID(比特币交易所仍需学习的知识:请参阅[1][2][3][4] )–该API也是易碎,容易出错。 想象所有这些类型的错误:

  1. 您的请求从未到达inventory服务器
  2. 您的请求已到达服务器,但被拒绝
  3. 服务器接受连接,但无法读取请求
  4. 服务器读取请求但挂起
  5. 服务器处理了请求,但发送响应失败
  6. 服务器发送了200 OK响应,但丢失了,您再也没有收到
  7. 收到服务器的响应,但客户端无法处理它
  8. 服务器的响应已发送,但客户端更早超时

在所有这些情况下,您仅在客户端获得一个异常,而您不知道服务器的状态是什么。 从技术上讲,您应该重试失败的请求,但是由于POST不是幂等的,您最终可能会用一把以上的剑来奖励玩家(在5-8情况下)。 但是,如果不重试,您可能会失去游戏玩家的金钱而又不给他宝贵的神器。 肯定有更好的办法。

将POST转换为幂等PUT

在某些情况下,通过将ID生成基本上从服务器转移到客户端,从POST转换为幂等PUT会非常简单。 使用POST时,是服务器生成剑的ID并将其发送到Location标头中的客户端。 事实证明,在客户端急切地生成UUID并稍稍更改语义加上在服务器端强制执行一些约束就足够了:

private long purchaseSword(long playerId) {
    Sword sword = new Sword();
    UUID uuid = sword.getUuid();
    HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());
    asyncRetryExecutor
            .withMaxRetries(10)
            .withExponentialBackoff(100, 2.0)
            .doWithRetry(ctx ->
                    restOperations.put(
                            "http://inventory:8080/player/{playerId}/inventory/{uuid}",
                            entity, playerId, uuid));
    return playerId;
}

该API如下所示:

> PUT /player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66 HTTP/1.1
> Host: inventory:8080
> Content-type: application/json;charset=UTF-8
>
> {"type": "sword", "strength": 100, ...}
 
< HTTP/1.1 201 Created
< Content-Length: 75
< Content-Type: application/json;charset=UTF-8
< Location: http://inventory:8080/player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66

为什么这么重要? 简单地说(不需要双关语),客户端现在可以根据需要重试PUT请求多次。 服务器首次收到PUT时,会将剑以客户端生成的UUID( 45e74f80-b2fb-11e4-ab27-0800200c9a66 )作为主键45e74f80-b2fb-11e4-ab27-0800200c9a66在数据库中。 在第二次尝试PUT的情况下,我们可以更新或拒绝该请求。 使用POST不可能,因为每个请求都被当作购买新剑–现在我们可以跟踪是否已经有这样的PUT。 我们只需要记住,后续的PUT并不是错误,而是更新请求:

@RestController
@Slf4j
public class InventoryController {
 
    private final PlayerRepository playerRepository;
 
    @Autowired
    public InventoryController(PlayerRepository playerRepository) {
        this.playerRepository = playerRepository;
    }
 
    @RequestMapping(value = "/player/{playerId}/inventory/{invId}", method = PUT)
    @Transactional
    public void addSword(@PathVariable UUID playerId, @PathVariable UUID invId) {
        playerRepository.findOne(playerId).addSwordWithId(invId);
    }
 
}
 
interface PlayerRepository extends JpaRepository<Player, UUID> {}
 
@lombok.Data
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
@Entity
class Sword {
 
    @Id
    @Convert(converter = UuidConverter.class)
    UUID id;
    int strength;
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Sword)) return false;
        Sword sword = (Sword) o;
        return id.equals(sword.id);
 
    }
 
    @Override
    public int hashCode() {
        return id.hashCode();
    }
}
 
@Data
@Entity
class Player {
 
    @Id
    @Convert(converter = UuidConverter.class)
    UUID id = UUID.randomUUID();
 
    @OneToMany(cascade = ALL, fetch = EAGER)
    @JoinColumn(name="player_id")
    Set<Sword> swords = new HashSet<>();
 
    public Player addSwordWithId(UUID id) {
        swords.add(new Sword(id, 100));
        return this;
    }
 
}

上面的代码片段中很少有快捷方式,例如直接将存储库注入控制器,以及使用@Transactional注释。 但是你明白了。 还要注意,假设没有完全同时插入两个具有相同UUID的剑,此代码相当乐观。 否则将发生约束违例异常。

旁注1:我在控制器和JPA模型中都使用UUID类型。 开箱即用不支持它们,对于JPA,您需要自定义转换器:

public class UuidConverter implements AttributeConverter<UUID, String> {
    @Override
    public String convertToDatabaseColumn(UUID attribute) {
        return attribute.toString();
    }
 
    @Override
    public UUID convertToEntityAttribute(String dbData) {
        return UUID.fromString(dbData);
    }
}

对于Spring MVC同样(仅单向):

@Bean
GenericConverter uuidConverter() {
    return new GenericConverter() {
        @Override
        public Set<ConvertiblePair> getConvertibleTypes() {
            return Collections.singleton(new ConvertiblePair(String.class, UUID.class));
        }
 
        @Override
        public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
            return UUID.fromString(source.toString());
        }
    };
}

旁注2:如果无法更改客户端,则可以通过将每个请求的哈希存储在服务器端来跟踪重复项。 这样,当多次发送同一请求(客户端重试)时,它将被忽略。 但是有时我们可能会有合法的用例,可以两次发送完全相同的请求(例如,在短时间内购买两把剑)。

时间耦合–客户不可用

您认为自己很聪明,但是仅重试就不够了。 首先,客户端可以在重新尝试失败的请求时死亡。 如果服务器严重损坏或关闭,重试可能要花费几分钟甚至几小时。 您不能仅仅因为下游依赖项之一关闭而就阻止了传入的HTTP请求-如果可能,您必须在后台异步处理此类请求。 但是,延长重试时间会增加客户端死亡或重新启动的可能性,这可能会使我们的请求松动。 想象一下,我们收到了优质的SMS,但是InventoryService目前处于关闭状态。 我们可以在第二,第二,第四等之后重试,但是如果InventoryService停机了几个小时,又碰巧我们的服务也重新启动了怎么办? 我们只是失去了短信和剑从未被提供给玩家的机会。

解决此问题的方法是先保留未决请求,然后在后台处理它。 收到SMS消息后,我们几乎没有将玩家ID存储在名为“ pending_purchases数据库表中。 后台调度程序或事件将唤醒异步线程,该线程将收集所有未完成的购买并将尝试将其发送到InventoryService (甚至可能以批处理方式发送)。每隔一分钟甚至一秒钟运行一次的定期批处理线程,并收集所有未完成的请求将不可避免地导致延迟和不必要数据库流量。 因此,我打算使用Quartz调度程序,它将为每个待处理的请求调度重试作业:

@Slf4j
@RestController
class SmsController {
 
    private Scheduler scheduler;
 
    @Autowired
    public SmsController(Scheduler scheduler) {
        this.scheduler = scheduler;
    }
 
    @RequestMapping(value = "/sms/{phoneNumber}", method = POST)
    public void handleSms(@PathVariable String phoneNumber) {
        phoneNumberToPlayer(phoneNumber)
                .map(Player::getId)
                .map(this::purchaseSword)
                .orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));
    }
 
    private UUID purchaseSword(UUID playerId) {
        UUID swordId = UUID.randomUUID();
        InventoryAddJob.scheduleOn(scheduler, Duration.ZERO, playerId, swordId);
        return swordId;
    }
 
    //...
 
}

和工作本身:

@Slf4j
public class InventoryAddJob implements Job {
 
    @Autowired private RestOperations restOperations;
    @lombok.Setter private UUID invId;
    @lombok.Setter private UUID playerId;
 
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        try {
            tryPurchase();
        } catch (Exception e) {
            Duration delay = Duration.ofSeconds(5);
            log.error("Can't add to inventory, will retry in {}", delay, e);
            scheduleOn(context.getScheduler(), delay, playerId, invId);
        }
    }
 
    private void tryPurchase() {
        restOperations.put(/*...*/);
    }
 
    public static void scheduleOn(Scheduler scheduler, Duration delay, UUID playerId, UUID invId) {
        try {
            JobDetail job = newJob()
                    .ofType(InventoryAddJob.class)
                    .usingJobData("playerId", playerId.toString())
                    .usingJobData("invId", invId.toString())
                    .build();
            Date runTimestamp = Date.from(Instant.now().plus(delay));
            Trigger trigger = newTrigger().startAt(runTimestamp).build();
            scheduler.scheduleJob(job, trigger);
        } catch (SchedulerException e) {
            throw new RuntimeException(e);
        }
    }
 
}

每当我们收到优质的SMS时,我们都会安排异步作业立即执行。 Quartz将负责持久性(如果应用程序关闭,则在重启后将尽快执行作业)。 此外,如果该特定实例发生故障,则另一个可以承担这项工作–或我们可以形成集群并在它们之间进行负载平衡请求:一个实例接收SMS,另一个实例在InventoryService请求剑。 显然,如果HTTP调用失败,则稍后重新安排重试时间,一切都是事务性的且具有故障保护功能。 在实际代码中,您可能会添加最大重试限制以及指数延迟,但是您了解了。

时间耦合–客户端和服务器无法满足

我们为正确执行重试所做的努力是客户端与服务器之间模糊的时间耦合的标志-它们必须同时生活在一起。 从技术上讲,这不是必需的。 想象玩家在48小时内将一封包含订单的电子邮件发送给他们处理的客户服务,并手动更改其库存。 同样的情况也适用于我们的情况,但是用某种消息代理(例如JMS)替换电子邮件服务器:

@Bean
ActiveMQConnectionFactory activeMQConnectionFactory() {
    return new ActiveMQConnectionFactory("tcp://localhost:61616");
}
 
@Bean
JmsTemplate jmsTemplate(ConnectionFactory connectionFactory) {
    return new JmsTemplate(connectionFactory);
}

建立ActiveMQ连接后,我们可以简单地将购买请求发送给经纪人:

private UUID purchaseSword(UUID playerId) {
    final Sword sword = new Sword(playerId);
    jmsTemplate.send("purchases", session -> {
        TextMessage textMessage = session.createTextMessage();
        textMessage.setText(sword.toJson());
        return textMessage;
    });
    return sword.getUuid();
}

通过用JMS主题上的消息传递完全替代同步请求-响应协议,我们暂时将客户端与服务器分离。 他们不再需要同时生活。 此外,不止一个生产者和消费者可以相互交流。 例如,您可以有多个购买渠道,更重要的是:多个利益相关方,而不仅仅是InventoryService 。 更好的是,如果使用像Kafka这样的专用消息传递系统, 则从技术上讲,您可以保留数天(数月)的消息而不会降低性能。 好处是,如果将另一个购买事件的使用者添加到InventoryService旁边的系统,它将立即收到许多历史数据。 而且,现在您的应用程序在时间上与代理耦合,因此,由于Kafka是分布式和复制的,因此在这种情况下它可以更好地工作。

异步消息传递的缺点

在ReST,SOAP或任何形式的RPC中使用的同步数据交换很容易理解和实现。 从延迟的角度来看,谁在乎这种抽象会疯狂地泄漏(本地方法调用通常比远程方法快几个数量级,更不用说它可能由于许多本地未知的原因而失败),因此开发起来很快。 消息传递的一个真正警告是反馈渠道。 由于没有响应管道,因此您可以不再只是“ 发送 ”(“ return ”)消息而已。 您要么需要带有一些相关性ID的响应队列,要么需要每个请求临时的一次性响应队列。 我们还撒谎了一点,声称在两个系统之间放置消息代理可以修复时间耦合。 确实如此,但是现在我们耦合到了消息传递总线,它也可能会崩溃,尤其是因为它经常处于高负载下,有时无法正确复制。

本文展示了在分布式系统中提供保证的一些挑战和部分解决方案。 但是,归根结底,请记住,“ 仅一次 ”语义几乎不可能轻松实现,因此仔细检查您确实需要它们。

翻译自: https://www.javacodegeeks.com/2015/02/journey-to-idempotency-and-temporal-decoupling.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值