个人理解

感觉就是三种数据结构,让你的key能够存一些东西。Map、List、Value就是字面意思,没别的。
此外,我想知道是不是 “每个key对应一个state实例”,文章末尾也有一个很有意思的gpt4对话。

ValueState性能问题

另:Flink 提供了为 RocksDB 优化的 MapState 和 ListState 类型。 相对于 ValueState,更建议使用 MapState 和 ListState,因为使用 RocksDBStateBackend 的情况下, MapState 和 ListState 比 ValueState 性能更好。 RocksDBStateBackend 可以附加到 ListState,而无需进行(反)序列化, 对于 MapState,每个 key/value 都是一个单独的 RocksDB 对象,因此可以有效地访问和更新 MapState。

为什么?下面是一些回答:


这个貌似是指不要用ValueState存Map和List来充当MapState/ListState

gpt4

🤖:当使用 RocksDBStateBackend 时,Flink推荐使用MapState 和 ListState 而不是 ValueState,原因如下:

  1. 与值状态相比,MapState 和 ListState的访问和更新效率更高。这是因为在使用RocksDB存储时,MapState里的每个键值对都会被单独存储成一个RocksDB对象。这使得它们可以进行高效的增量更新,而不需要在每次处理时将整个状态加载到内存中。这是ValueState相比较之下的一个优势,因为ValueState并非基于键值对的存储和访问。
  2. 对于ListState,RocksDBStateBackend允许附加新状态到现有状态,而无需对整个状态列表进行反序列化和序列化。这降低了每次状态更新时的计算开销,从而提高了性能。

综上所述,在使用RocksDBStateBackend时,MapState和ListState比ValueState更具性能优势。它们的设计使得它们可以应对大量状态数据的情况,同时实现有效的访问、更新和扩展性。因此,在使用RocksDBStateBackend的情况下,官方建议使用MapState和ListState来获得性能优势。

😀:如果我不用RocksDBStateBackend呢?比如FsStateBackend

🤖:如果您选择使用FsStateBackend(FileSystem State Backend),则性能差异可能不那么明显。FsStateBackend将数据存储在文件系统上(例如HDFS),适用于较小的状态和能够容忍较高延迟的场景。但是,选择使用MapState和ListState还是有一些优势的:

  1. MapState和ListState为用户提供了更高级和灵活的抽象。这使得它们对于复杂的应用程序和场景更具可读性和可维护性。例如,对于MapState,您可以使用键值对结构存储和访问数据,更方便地对数据进行更新和管理;对于ListState,则可以直接附加到现有状态,而无需序列化和反序列化整个状态列表。
  2. 当您可能需要在后续迭代中将FsStateBackend更改为RocksDBStateBackend(例如,当应用程序的状态数据量变大时),使用MapState和ListState具有良好的迁移通道。由于它们在RocksDBStateBackend下已具有性能优势,因此可以确保未来迁移时的低更改成本。

总之,即使使用FsStateBackend,选择MapState和ListState仍然是一个好的做法,因为它们在抽象和可维护性方面具有优点。

KeyState的案例

Keyed state是指在Flink中与一个特定key相关联的状态。在Flink中,数据被分区并按key分组。当数据流被分区和分组后,每个key都有一个对应的状态,这就是Keyed state。它可以用于计算窗口、聚合操作和连续查询等。Keyed state通常用于在流处理中跟踪关键得分、计数或其他与特定数据点相关的值。

MapState、ListState和ValueState都属于Keyed state的不同类型,它们的作用如下:

  1. MapState:MapState是一种Key-Value集合状态,它允许用户存储和访问与特定key相关联的值。

场景:假设我们要计算每个用户访问不同类型的网页的次数。我们希望实时统计每个用户在过去一个小时内访问每种类型网页的次数。

首先定义一个 Event 类,包含用户 ID,访问类型和访问时间:

public class Event {
    private Integer userId;
    private String accessType;
    private long accessTime;

    // 构造方法,get 和 set 方法省略
}

现在,在 UserCountByTypeFlatMapFunction 中,我们将使用一个 MapState 保存每个用户的点击次数,其中键是访问类型,值是点击次数:

MapState<String, Integer> userCountsByTypeMap;

接着初始化这个 MapState:

@Override
public void open(Configuration parameters) throws Exception {
    MapStateDescriptor<String, Integer> userCountsMapDescriptor = new MapStateDescriptor<>(
        "userCountsByTypeMap", String.class, Integer.class);
    userCountsByTypeMap = getRuntimeContext().getMapState(userCountsMapDescriptor);
}

然后针对每个流入的 Event 对象,我们更新 MapState 中的相应键值对:

@Override
public void flatMap(Event event, Collector<Tuple3<Integer, String, Integer>> out) throws Exception {
    long currentTimeMillis = System.currentTimeMillis();
    if (Math.abs(currentTimeMillis - event.getAccessTime()) <= Duration.ofHours(1).toMillis()) {
        Integer count = userCountsByTypeMap.get(event.getAccessType());
        if (count == null) {
            userCountsByTypeMap.put(event.getAccessType(), 1);
        } else {
            userCountsByTypeMap.put(event.getAccessType(), count + 1);
        }
        out.collect(Tuple3.of(event.getUserId(), event.getAccessType(), userCountsByTypeMap.get(event.getAccessType())));
    }
}

主程序中的流处理逻辑:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<Event> events = // 创建输入流
DataStream<Tuple3<Integer, String, Integer>> userCountsByType = 
        events
        .keyBy(Event::getUserId)
        .flatMap(new UserCountByTypeFlatMapFunction());
userCountsByType.print();
env.execute("MapState Example");

我们使用 MapState 来存储每个用户过去一小时内访问各种类型的网页的次数。MapState 的键是网页,值是具体的次数。

  1. ListState:ListState是一种列表状态,它允许用户将多个值存储在一个state单元里。ListState通常用于在某些场景中存储状态,例如窗口聚合中的中间结果。

假设有一个统计每个小时内用户访问网站的用户列表,我们可以使用ListState来实现:

// 创建一个 ListState 用于存储用户列表
ListState<String> userListState = getRuntimeContext().getListState(
    new ListStateDescriptor<String>("userListState", Types.STRING));

/**
 * 处理每个用户访问事件的方法,将用户添加到用户列表中
 * @param event 表示要被处理的用户访问事件对象
 * @throws Exception
 */
public void processElement(UserAccessEvent event) throws Exception {
    long currentTime = event.getTimestamp(); // 获取事件的时间戳
    DateTimeZone timeZone = DateTimeZone.forID("Asia/Shanghai"); // 设置时区
    DateTime dateTime = new DateTime(currentTime, timeZone); // 根据时间戳创建 DateTime 对象
    String hourKey = dateTime.toString("YYYYMMddHH"); // 根据时间戳创建按小时分组的 Key
    String userName = event.getUserName(); // 获取访问用户的用户名
    Iterable<String> userList = userListState.get(); // 获取当前存储的用户列表
    if (Iterables.size(userList) == 0) { // 如果列表为空,则直接添加用户
        userListState.add(userName);
    } else { // 否则,复制一份列表,并在其中查找是否已经存在该用户
        List<String> userListCopy = Lists.newArrayList(userList);
        if (!userListCopy.contains(userName)) { // 如果不存在,则添加该用户
            userListCopy.add(userName);
            userListState.update(userListCopy); // 更新存储的用户列表
        }
    }
}
  1. ValueState:ValueState是一种单值状态,允许用户存储和更新与特定key相关联的单个值。ValueState通常用于记录特定key的最新状态或跟踪特定key的计数器状态。

假设有一个网站流,记录了每次浏览的页面和用户ID,我们想要在特定的时间段内统计用户浏览不同页面的次数。在这种情况下,我们可以使用ValueState来实现:

// 创建一个 ValueState 用于存储当前页面的访问次数
ValueState<Integer> visitCountState = getRuntimeContext().getState(
    new ValueStateDescriptor<>("visitCount", Types.INT));

/**
 * 处理每个页面访问事件的方法,更新该页面的访问次数
 * @param event 表示要被处理的页面访问事件对象
 * @throws Exception
 */
public void processElement(PageVisitEvent event) throws Exception {
    int visitCountSoFar = visitCountState.value() == null ? 0 : visitCountState.value(); // 获取当前页面的访问次数
    visitCountSoFar += 1; // 增加访问次数
    visitCountState.update(visitCountSoFar); // 更新当前页面的访问次数
    output.collect(event); // 发送处理后的事件对象到下游算子
}
  1. ReducingState:用于存储可合并(reducible)的数据类型。它将所有具有相同 key 的元素通过给定的 ReduceFunction 聚合在一起。

示例:计算每个用户的购物车中商品的总价格。

public class CartItem {
    private String userId;
    private double price;
}

public class SumReduceFunction implements ReduceFunction<CartItem> {
    @Override
    public CartItem reduce(CartItem cartItem1, CartItem cartItem2) {
        return new CartItem(
            cartItem1.getUserId(),
            cartItem1.getPrice() + cartItem2.getPrice()
        );
    }
}

// 在 Flink 应用程序中为每个 userId 计算购物车总价。
public class CartPriceSumFunction extends KeyedProcessFunction<String, CartItem, CartItem> {
    private transient ReducingState<CartItem> sumState;

    @Override
    public void open(Configuration parameters) {
        sumState = getRuntimeContext().getReducingState(
            new ReducingStateDescriptor<>("sum", new SumReduceFunction(), CartItem.class)
        );
    }

    @Override
    public void processElement(CartItem cartItem, Context ctx, Collector<CartItem> out) throws Exception {
        sumState.add(cartItem);
        CartItem currentSum = sumState.get();
        out.collect(currentSum); // 输出当前购物车总价。
    }
}

在这个例子中,我们定义了一个 CartItem 类,包含了 userId 和商品价格 price。然后我们定义了一个 SumReduceFunction,用于将两个 CartItem 对象的价格相加。在我们的 CartPriceSumFunction 中,我们使用了 ReducingState 存储每个用户的购物车中商品的总价格,并在元素处理过程中将新的 CartItem 对象添加到聚合状态中,然后收集并输出购物车当前的总价格。

😀:无论什么数据都会调用同一个sumState.add(cartItem),我怎么知道这个cartItem应该被加到哪个key里面?

🤖:KeyedState 是用于处理 keyed stream 的状态存储,keyed stream 的每个 key 都有自己独立的状态。如果你使用的是 KeyedProcessFunction,则传递给状态的 key 会根据输入数据的 key 自动设置。

在这个具体的例子中,我们可以在创建 keyed stream 之前,通过 keyBy() 函数指定输入数据的 key 字段。例如:

KeyedStream<CartItem, String> keyedStream = inputStream.keyBy(cartItem -> cartItem.getUserId());
keyedStream.process(new CartPriceSumFunction());

这个代码段将输入数据根据 CartItem 对象的 userId 字段进行分组,因此在 CartPriceSumFunction 中的状态会针对每个 userId 的购物项分别处理。当调用 sumState.add(cartItem) 时,Flink 会自动将 cartItem 添加到与当前处理的 CartItem 对象相同 userId 的 key 下。

换句话说,不需要显式地指定 cartItem 应该加到哪个 key 里,Flink 已经根据 keyBy() 函数设置好了 key。在 KeyedProcessFunctionprocessElement() 方法中,当前处理的 key 可以通过 Context 参数的 ctx.getCurrentKey() 方法获得。

😀:我是不是可以理解为:有多少种不同的key,就有多少个ReducingState对象?

🤖:是的,你的理解是正确的。有多少种不同的 key,就有多少个对应的 ReducingState 对象。每个唯一的 key 都有一个与之对应的状态实例。这些状态由 Flink 的状态后端负责管理和存储。

对于 KeyedProcessFunction,每个 ReducingState 的创建和访问是按键分区的,即使这些状态是通过同一个状态描述符 ReducingStateDescriptor 定义的。这意味着在处理 keyed stream 时,每个 key 的值都将被单独处理,互不影响。这在处理具有多个 key 的大型数据时非常有用,因为 Flink 可以并行处理这些键的状态,从而提高处理性能。

举个例子,对于上面购物车总价的例子,假设有 100 个不同的用户(即 100 个不同的 userId),Flink 会为每个用户创建一个独立的 ReducingState 对象来存储购物车的总价格。这些状态由 Flink 的状态后端负责管理和存储,不需要我们自己去显式创建和处理这些状态对象。

😀:那是不是有多少种不同的key,就有多少个MapState对象?

🤖:

这里gpt4先给了肯定的回答,在我追问后,又给出了否定的回答,不可轻信,仅供参考。至于真实答案是什么,我后面得继续学习
我倾向于gpt4的修正答案是正确的,即每个key就是一个MapState实例,有人可能会认为这有什么会想不通的,我只能说有一个MapState代码误导了我,那个代码中每个key的MapState只存了一个值,让我误以为MapState的键就是keyBy(key)的key。实际上,一个合适的场景是:“统计用户观看不同网页的次数”。这里的用户就是keyBy(key)的那个key,也对应着一个MapState,而不同的网址就对应着MapState不同的键。

让我们来梳理一下各种 Keyed State 的行为。

实际上,对于所有的 Keyed State 类型(ValueState、ListState、ReducingState 和 MapState),Flink 都会为 keyed stream 中的每个 key 维护一个单独的状态实例。这些状态实例由 Flink 的状态后端负责管理和存储。

对于每一种 Keyed State:

  1. ValueState:每个 key 都有一个单独的 ValueState 实例,用于存储单一 value。
  2. ListState:每个 key 都有一个单独的 ListState 实例,用于存储一个元素列表。
  3. ReducingState:每个 key 都有一个单独的 ReducingState 实例,用于存储聚合后的结果。
  4. MapState:每个 key 都有一个单独的 MapState 实例,用于存储键值对映射。

再次澄清,这些 Keyed State 在 keyed stream 的不同 key 之间是分开存储的,每个 key 都有自己的实例。在之前关于 MapState 的回答中,我没有表述清楚。实际上,MapState 也是针对每个 keyed stream 的 key 有一个单独的映射实例。

这样就能确保每个 Keyed State 处理 keyed stream 时,各个 key 的状态不会相互影响。希望这次的解释能更清晰。

😀:那你再次告诉我,我这个理解对吗:我的理解是对于MapState,所有的key都是这一个MapState对象(相当与一个HashMap,它的键就是keyby的那个key),然后通过put/get来对每个key进行设置value。我的理解对吗

🤖:你的理解是错误的,请允许我解释一下:

在 Keyed State 中,每个 key 都有自己的状态实例。但你确实可以将它们视为一个更大的数据结构(如 Map 或 HashMap)的一部分,其中 keyed stream 中的每个 key 都对应于这个数据结构中的一个键。实际上,Flink 的状态后端确实可以把这些状态存储为类似于 Map 或 HashMap 的数据结构。但是,当我们在编写 Flink 代码时,需要将这些状态视为每个 key 的独立实例。

对于 MapState,我们可以认为它是一个 "二级映射"。即第一级映射是 keyed stream 中的 key,然后每个 key 对应一个 MapState 实例。在每个 MapState 实例中,可以存储第二级映射,即一组内部的 key-value 对。这两个层级的映射结构是分开管理的。

在处理 keyed stream 时,当前处理元素的 keyed stream 的 key 对应的第一级映射中的一个键,会关联到一个特定的 MapState 实例。然后,在这个 MapState 实例内部,你可以通过 putget 方法来操作不同的内部 key-value 对。

希望这次的解释能够帮助你更好地理解 Keyed State 和 MapState。