风控需求的系统设计与响应式编程集成Redis stream实践

380 阅读9分钟

需求背景

最近接到一个功能要求,风控要求交易服务对用户和市场做限制,而具体是哪些用户和哪些市场由网关下发给多个交易服务;在此过程中用户名单和市场名单可能会更新,每次更新时需要多个下游交易服务也跟着更新。

这个需求可以拆解为两部分:
第一部分是需要对已存在的用户黑名单和市场黑名单做下单限制,那么就由此引申出一些问题,黑名单数据存放在哪里,如何获取已存在的用户黑名单和市场黑名单,获取的时机是什么等;
第二部分是这个用户黑名单和市场黑名单是会更新的,意思即 不是获取了黑名单数据之后就不用管了,因为黑名单是会不定时更新,我们也需要跟着更新。

需求分析和系统设计

第一个问题:对已存在的用户黑名单和市场黑名单做下单限制

假设现在有多个交易服务A,B,C,我们负责的是A交易服务,需要按照风控要求根据用户黑名单和市场黑名单对下单进行限制。那么用户黑名单和市场黑名单这部分数据可以放在redis缓存里,这样可以确保不同的交易服务都是从同个地方拿到的数据。

  • 使用本地缓存代替redis缓存

按照上面的需求分析,先解决第一个问题:如何对对已存在的用户黑名单和市场黑名单做下单限制
我们已知道数据是放在redis缓存里的,那么我们可以从redis里获取全部用户黑名单和市场黑名单。我们可以在用户调下单接口时去做参数校验,判断用户是否在黑名单里。但是,因为交易服务的下单时间要求很高,每次下单都去连接redis拿到缓存数据再判断用户是否在黑名单里会增加下单时间,这是不可接受的,所以为了减少RT(request time),我们应该把这部分数据放在本地缓存里,这样每次下单只需要去本地缓存中取出用户黑名单有哪些,判断用户是否在黑名单里就可以了,相当于减少了一次网络IO。但这种做法会引申出另一个问题,如何将redis缓存同步到本地缓存,以及如何保证本地缓存与redis缓存的数据一致

  • 本地缓存与redis缓存的数据一致

上面的问题本质都是一个问题,即本地缓存与redis缓存的数据一致性问题。 redis缓存同步到本地缓存肯定不能等接口请求下单来到时再去做,需要提前同步好。我们可以在项目启动时就去触发这个任务。又因为用户黑名单和市场黑名单会改动,redis缓存会更新,所以可以做成一个定时任务去做redis缓存和本地缓存的同步。所以总结下,在项目启动初始化时就去触发定时任务直到启动成功,做到在项目启动成功前就把redis缓存同步到本地缓存,并且定时执行更新数据保证redis缓存与本地缓存一致。

第二个问题:当数据更新时如何将更新这个动作通知到其他服务

当redis缓存数据更新时,必须通知到下游服务,告知下游服务数据已更新,需要重新获取,不然的话下游服务拿到的还是旧数据。虽然在上面我们已经有准备一个定时任务去执行redis缓存数据和本地缓存数据的同步,但毕竟是定时任务,无法在redis数据一更新就触发执行。

这时这个功能的实现就需要用到发布订阅机制,每次更新时发布消息,各个订阅者都能收到消息,消息只是起到一个触发作用,根据消息便去获取redis缓存然后对本地缓存进行更新,举个例子:

假设用户名单和市场名单的redis缓存key和value如下,value用set存储:

  • key = users, value = 011,012,013
  • key = markets, value = A,B,C

每次名单更新有变动,只需要发个消息key就可以,这样下游服务就可以知道具体是哪些key的缓存更新,然后去重新获取redis缓存。

我们来看下整体的系统设计,发布订阅使用消息队列实现:

image.png

Redis Pub Sub 和 Redis stream技术选型对比

Redis的发布/订阅(Pub/Sub)机制是一种消息传递模式,用于实现消息的发布和订阅。发布者和订阅者之间是通过channel(频道)解耦的,发布者不需要知道订阅者的身份,只需将消息发布到指定的频道即可。

但遗憾的是,发布订阅 (pub/sub) 消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃,分发消息,无法记住历史消息。所以决定使用redis stream。

Redis stream介绍

Redis stream 是 Redis 5 引入的一种新的数据结构,它是一个高性能、高可靠性的消息队列,主要用于异步消息处理和流式数据处理。
Redis的Stream数据结构由两个部分组成,一个是消息ID的有序集合,另一个是消息的哈希表。 Stream的消息ID有序集合中,每个元素都是一个消息的ID,按照递增的顺序排列。有序集合中的分值为消息的ID,成员为NULL。 Stream的消息哈希表中,每个消息都用一个哈希表表示,哈希表的键是消息的ID,值是一个由多个键值对数据组成的字典。

常用命令

  • XADD mystream * user joke
    往name为mystream的Stream中插入一条消息,* 表示message id根据时间戳自动生成,消息的key为user,value为joke
  • XLEN mystream
    XLEN 用于获取mystream流中消息的数量 image.png
  • XDEL mystream message-id 从流中删除消息
  • XDEL mystream 删除整个stream
  • XREAD[COUNT count][BLOCK milliseconds] STREAMS key[key ...]id[id ...]
    从流中读取消息,独立消费,不定义消费组,有阻塞读和非阻塞读两种模式,当传BLOCK milliseconds(阻塞毫秒数)时为阻塞读取 image.png
  • XGROUP CREATE stream group id
  • stream:要关联的流的键
  • group:消费组的名称
  • id:开始读取消息的起始 ID。通常使用 $ 表示仅消费新消息,或者使用 0 表示消费流中的所有消息
  • XREADGROUP GROUP mygroup myconsumer STREAMS mystream
    以 myconsumer 消费者身份从 mystream 中读取分配给 mygroup 的消息,读取所有最新的消息
  • XINFO CONSUMERS mystream mygroup
    用于获取消费组中消费者的列表及其相关信息

功能设计

虽然已经决定了使用redis stream,但作为一种消息队列,仍然有些问题需要解决

  • redis stream的size大小需要多大
  • 因为使用了stream,而stream的消息只是发布需要更新的key,具体value内容仍然需要订阅者自己再去拉取,就会有一个问题:项目启动时,是需要先订阅stream还是先拉取redis缓存;如果是先订阅再拉取,则如果项目启动时没有新消息就不会去拉取缓存;如果是先拉取缓存再订阅stream,则最好作为不同线程分开操作
  • stream有两种读取消息的方式,Xread和Xgroup,但因为生产服务是部署到多台机器,如果用Xgroup读取的话,那处于同个group的消费者一旦消费到某个消息,那这个消息就不会被这个group下的其他消费者消费到了

为了保证读取消息的可靠性,我们使用同时轮询redis缓存和订阅stream的方式来保证服务启动时即能更新到本地缓存。
订阅stream使用Xread方式读取最新消息,这样就避免了Xgroup读取消息时遇到的问题,同时在项目启动时发布一个事件通知启动redis缓存轮询,这样就可以项目启动时虽然没有消息正好发布,但依然能拿到redis缓存数据。

代码如下:

启动时订阅stream

@PostConstruct
public void subscribeStream() {
    log.info("启动时订阅stream...");

    StreamReceiver.StreamReceiverOptions<String, MapRecord<String, String, String>> options =
            StreamReceiver.StreamReceiverOptions.builder()
                    .build();

    StreamReceiver<String, MapRecord<String, String, String>> receiver =
            StreamReceiver.create(connectionFactory, options);

    // read message from stream, start at latest
    Flux<MapRecord<String, String, String>> messages =
            receiver.receive(StreamOffset.latest(STREAM_KEY));
    messages.flatMap(message -> {
                log.info("Stream: " + message.getStream() +
                        " ID: " + message.getId() +
                        " Body: " + message.getValue());
                Map<String,String> messageValue = message.getValue();
                if (messageValue.containsKey(USER) ||
                    messageValue.containsKey(MARKET)) {
                    // 获取redis cache并且保存在本地缓存
                }else {
                    return Mono.just(message);
                }
            })
            .subscribe();
}

项目启动时发布事件通知启动轮询

@EventListener
public void SyncCache(ApplicationReadyEvent event) {
    // 启动定时任务
    //定时任务为获取redis缓存并保存在本地缓存
}

image.png

遗留问题

可以想想这样的设计方式有什么问题。

  1. 上面事件通知的代码是在服务启动时去触发一个事件通知执行redis数据同步到本地,但这并不能保证数据的同步在服务启动完成前就执行完成,这会导致在服务启动刚开始一段时间内会有下单流量没有经过风控校验

  2. 还要考虑异常处理,订阅和拉取异常,流断了能不能自己恢复

  • 这个可以在服务启动时同步调一次,能拿到数据就继续启动, 拿不到就重试, 重试不行就根据你的配置决定继续启动还是抛异常退出启动。可以放到一个Spring bean的初始化方法

总结

  • 拿到需求时要先分析好,考虑全面才动手写代码
  • 只有对中间件足够熟悉才能自然而然的想到会遇到哪些问题