基于Flink的AB测试系统实现:从理论到生产实践

基于Flink的AB测试系统实现:从理论到生产实践

AB测试理论基础

什么是AB测试?

AB测试是一种统计假设测试方法,通过将用户随机分配到不同版本(A版和B版)的页面或功能,收集用户行为数据,从而确定哪个版本在预设指标上表现更优。

核心概念

  • 分组策略:用户随机分配到对照组和实验组
  • 样本量计算:确保结果统计显著性
  • 假设检验:使用t检验、z检验等统计方法
  • 置信区间:结果的可信程度评估
  • 多重检验校正:避免多次测试导致的假阳性

统计原理

AB测试基于中心极限定理和假设检验理论,通过比较两组指标的差异是否具有统计显著性,来判断实验版本是否真正优于原始版本。

基于Flink的AB测试系统实现

下面我们实现一个完整的AB测试系统,包含流量分配、事件处理、指标计算和结果分析。

系统整体架构设计

基于Flink的AB测试系统整体处理流程如下:

复制代码
用户请求 → 流量分配服务 → 曝光事件 → Kafka
                                     ↓
                                 Flink实时处理
                                     ↓
                         指标计算(曝光/转化/收益) → 结果存储
                                     ↓
                               显著性检验 → 可视化展示

核心处理逻辑

  1. 流量分配层:将用户请求按照预设比例分配到不同实验组
  2. 数据采集层:收集曝光事件和转化事件,发送到Kafka
  3. 实时处理层:Flink作业消费事件流,计算关键指标
  4. 统计分析层:进行统计显著性检验,计算置信区间
  5. 结果展示层:通过API和看板展示实验结果

1. 数据模型定义

java 复制代码
/**
 * AB测试事件基类
 * @param userId 用户唯一标识
 * @param experimentId 实验ID
 * @param variant 实验变体 "A" 或 "B"
 * @param timestamp 事件时间戳
 */
public abstract class ABTestEvent {
    private String userId;
    private String experimentId;
    private String variant; // "A" 或 "B"
    private Long timestamp;
    
    // 构造函数、getter、setter
    public ABTestEvent(String userId, String experimentId, String variant, Long timestamp) {
        this.userId = userId;
        this.experimentId = experimentId;
        this.variant = variant;
        this.timestamp = timestamp;
    }
    
    // getters and setters
    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }
    
    public String getExperimentId() { return experimentId; }
    public void setExperimentId(String experimentId) { this.experimentId = experimentId; }
    
    public String getVariant() { return variant; }
    public void setVariant(String variant) { this.variant = variant; }
    
    public Long getTimestamp() { return timestamp; }
    public void setTimestamp(Long timestamp) { this.timestamp = timestamp; }
}

/**
 * 曝光事件 - 用户进入实验
 * @param pageId 页面ID
 * @param deviceType 设备类型
 */
public class ExposureEvent extends ABTestEvent {
    private String pageId;
    private String deviceType;
    
    public ExposureEvent(String userId, String experimentId, String variant, 
                        Long timestamp, String pageId, String deviceType) {
        super(userId, experimentId, variant, timestamp);
        this.pageId = pageId;
        this.deviceType = deviceType;
    }
    
    // getters and setters
    public String getPageId() { return pageId; }
    public void setPageId(String pageId) { this.pageId = pageId; }
    
    public String getDeviceType() { return deviceType; }
    public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
}

/**
 * 转化事件 - 用户完成目标行为
 * @param conversionType 转化类型
 * @param revenue 收益金额
 */
public class ConversionEvent extends ABTestEvent {
    private String conversionType;
    private Double revenue;
    
    public ConversionEvent(String userId, String experimentId, String variant, 
                          Long timestamp, String conversionType, Double revenue) {
        super(userId, experimentId, variant, timestamp);
        this.conversionType = conversionType;
        this.revenue = revenue;
    }
    
    // getters and setters
    public String getConversionType() { return conversionType; }
    public void setConversionType(String conversionType) { this.conversionType = conversionType; }
    
    public Double getRevenue() { return revenue; }
    public void setRevenue(Double revenue) { this.revenue = revenue; }
}

/**
 * AB测试结果指标
 * @param experimentId 实验ID
 * @param variant 实验变体
 * @param exposureCount 曝光用户数
 * @param conversionCount 转化用户数
 * @param conversionRate 转化率
 * @param totalRevenue 总收益
 * @param avgRevenue 平均收益
 * @param windowStart 窗口开始时间
 * @param windowEnd 窗口结束时间
 * @param confidence 置信度
 */
public class ExperimentResult {
    private String experimentId;
    private String variant;
    private Long exposureCount;      // 曝光用户数
    private Long conversionCount;    // 转化用户数
    private Double conversionRate;   // 转化率
    private Double totalRevenue;     // 总收益
    private Double avgRevenue;       // 平均收益
    private Long windowStart;
    private Long windowEnd;
    private Double confidence;       // 置信度
    
    // 构造函数、getter、setter
    public ExperimentResult(String experimentId, String variant, Long exposureCount, 
                           Long conversionCount, Double conversionRate, Double totalRevenue,
                           Double avgRevenue, Long windowStart, Long windowEnd, Double confidence) {
        this.experimentId = experimentId;
        this.variant = variant;
        this.exposureCount = exposureCount;
        this.conversionCount = conversionCount;
        this.conversionRate = conversionRate;
        this.totalRevenue = totalRevenue;
        this.avgRevenue = avgRevenue;
        this.windowStart = windowStart;
        this.windowEnd = windowEnd;
        this.confidence = confidence;
    }
    
    // getters and setters
    public String getExperimentId() { return experimentId; }
    public void setExperimentId(String experimentId) { this.experimentId = experimentId; }
    
    public String getVariant() { return variant; }
    public void setVariant(String variant) { this.variant = variant; }
    
    public Long getExposureCount() { return exposureCount; }
    public void setExposureCount(Long exposureCount) { this.exposureCount = exposureCount; }
    
    public Long getConversionCount() { return conversionCount; }
    public void setConversionCount(Long conversionCount) { this.conversionCount = conversionCount; }
    
    public Double getConversionRate() { return conversionRate; }
    public void setConversionRate(Double conversionRate) { this.conversionRate = conversionRate; }
    
    public Double getTotalRevenue() { return totalRevenue; }
    public void setTotalRevenue(Double totalRevenue) { this.totalRevenue = totalRevenue; }
    
    public Double getAvgRevenue() { return avgRevenue; }
    public void setAvgRevenue(Double avgRevenue) { this.avgRevenue = avgRevenue; }
    
    public Long getWindowStart() { return windowStart; }
    public void setWindowStart(Long windowStart) { this.windowStart = windowStart; }
    
    public Long getWindowEnd() { return windowEnd; }
    public void setWindowEnd(Long windowEnd) { this.windowEnd = windowEnd; }
    
    public Double getConfidence() { return confidence; }
    public void setConfidence(Double confidence) { this.confidence = confidence; }
}

2. 流量分配服务

java 复制代码
/**
 * AB测试流量分配服务
 * 负责将用户随机分配到不同的实验组
 */
public class TrafficAllocationService {
    
    private static final String[] VARIANTS = {"A", "B"};
    
    /**
     * 根据用户ID和实验ID分配流量
     * 使用一致性哈希确保同一用户始终进入同一分组
     * 
     * @param userId 用户ID
     * @param experimentId 实验ID
     * @param trafficSplit 流量分配比例,如{"A": 0.5, "B": 0.5}
     * @return 分配的实验变体 "A" 或 "B"
     */
    public String assignVariant(String userId, String experimentId, 
                               Map<String, Double> trafficSplit) {
        // 如果未提供流量分配配置,使用默认的50/50分配
        if (trafficSplit == null || trafficSplit.isEmpty()) {
            trafficSplit = getDefaultTrafficSplit();
        }
        
        String key = userId + "_" + experimentId;
        int hash = Math.abs(key.hashCode());
        double trafficPercentage = (hash % 10000) / 10000.0; // 转换为0-1之间的值
        
        double accumulated = 0.0;
        for (Map.Entry<String, Double> entry : trafficSplit.entrySet()) {
            accumulated += entry.getValue();
            if (trafficPercentage <= accumulated) {
                return entry.getKey();
            }
        }
        
        return "A"; // 默认返回对照组
    }
    
    /**
     * 获取默认流量分配配置 - A/B各50%
     * @return 默认流量分配配置
     */
    public Map<String, Double> getDefaultTrafficSplit() {
        Map<String, Double> split = new HashMap<>();
        split.put("A", 0.5); // 50% 流量到对照组
        split.put("B", 0.5); // 50% 流量到实验组
        return split;
    }
}

3. Flink数据处理流程

java 复制代码
/**
 * AB测试实时分析主程序
 * 核心功能:
 * 1. 消费Kafka中的曝光和转化事件
 * 2. 基于事件时间窗口进行指标聚合
 * 3. 计算转化率、收益等关键指标
 * 4. 输出实验结果到Kafka供下游消费
 */
public class ABTestAnalysisJob {
    
    /**
     * Flink作业主入口
     * @param args 命令行参数
     */
    public static void main(String[] args) throws Exception {
        // 设置Flink执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(4);
        
        // 从Kafka读取曝光事件
        DataStream<ExposureEvent> exposureStream = env
            .addSource(createKafkaSource("exposure-topic", ExposureEvent.class))
            .name("exposure-kafka-source")
            .uid("exposure-kafka-source");
        
        // 从Kafka读取转化事件  
        DataStream<ConversionEvent> conversionStream = env
            .addSource(createKafkaSource("conversion-topic", ConversionEvent.class))
            .name("conversion-kafka-source")
            .uid("conversion-kafka-source");
        
        // 处理曝光事件流:分配时间戳和水位线,过滤无效数据
        DataStream<ExposureEvent> processedExposureStream = exposureStream
            .assignTimestampsAndWatermarks(
                WatermarkStrategy.<ExposureEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                    .withTimestampAssigner((event, timestamp) -> event.getTimestamp())
            )
            .filter(event -> event.getVariant() != null) // 过滤掉变体为空的事件
            .name("filter-valid-exposure")
            .uid("filter-valid-exposure");
        
        // 处理转化事件流:分配时间戳和水位线,过滤无效数据
        DataStream<ConversionEvent> processedConversionStream = conversionStream
            .assignTimestampsAndWatermarks(
                WatermarkStrategy.<ConversionEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                    .withTimestampAssigner((event, timestamp) -> event.getTimestamp())
            )
            .filter(event -> event.getVariant() != null) // 过滤掉变体为空的事件
            .name("filter-valid-conversion")
            .uid("filter-valid-conversion");
        
        // 关键指标计算:关联曝光和转化事件,计算各项实验指标
        DataStream<ExperimentResult> experimentResults = 
            calculateExperimentMetrics(processedExposureStream, processedConversionStream);
        
        // 输出结果到Kafka,供下游数据仓库或可视化系统使用
        experimentResults
            .addSink(createKafkaSink("abtest-results-topic"))
            .name("results-kafka-sink")
            .uid("results-kafka-sink");
        
        // 执行Flink作业
        env.execute("AB-Test-RealTime-Analysis");
    }
    
    /**
     * 计算实验关键指标
     * 处理流程:
     * 1. 分别计算曝光用户数和转化用户数(去重)
     * 2. 计算收益相关指标
     * 3. 关联所有指标生成最终实验结果
     * 
     * @param exposureStream 曝光事件流
     * @param conversionStream 转化事件流
     * @return 实验结果流
     */
    private static DataStream<ExperimentResult> calculateExperimentMetrics(
        DataStream<ExposureEvent> exposureStream,
        DataStream<ConversionEvent> conversionStream) {
        
        // 为曝光事件设置键:实验ID + 变体,用于分组聚合
        KeyedStream<ExposureEvent, Tuple2<String, String>> keyedExposures = exposureStream
            .keyBy(event -> Tuple2.of(event.getExperimentId(), event.getVariant()));
        
        // 为转化事件设置键:实验ID + 变体,用于分组聚合  
        KeyedStream<ConversionEvent, Tuple2<String, String>> keyedConversions = conversionStream
            .keyBy(event -> Tuple2.of(event.getExperimentId(), event.getVariant()));
        
        // 计算曝光用户数(使用5分钟滚动窗口去重)
        DataStream<Tuple3<String, String, Long>> exposureCounts = keyedExposures
            .window(TumblingEventTimeWindows.of(Time.minutes(5)))
            .process(new DistinctUserCountProcessFunction())
            .name("exposure-user-count")
            .uid("exposure-user-count");
        
        // 计算转化用户数(使用5分钟滚动窗口去重)
        DataStream<Tuple3<String, String, Long>> conversionCounts = keyedConversions
            .window(TumblingEventTimeWindows.of(Time.minutes(5)))
            .process(new DistinctUserCountProcessFunction())
            .name("conversion-user-count")
            .uid("conversion-user-count");
        
        // 计算收益指标:总收益、平均收益等
        DataStream<Tuple4<String, String, Long, Double>> revenueMetrics = keyedConversions
            .window(TumblingEventTimeWindows.of(Time.minutes(5)))
            .aggregate(new RevenueAggregateFunction())
            .name("revenue-metrics")
            .uid("revenue-metrics");
        
        // 关联所有指标并计算最终结果:将曝光、转化、收益指标关联起来
        return exposureCounts
            .connect(conversionCounts)
            .keyBy(data -> Tuple2.of(data.f0, data.f1), data -> Tuple2.of(data.f0, data.f1))
            .process(new MetricsCoProcessFunction())
            .connect(revenueMetrics)
            .keyBy(result -> Tuple2.of(result.getExperimentId(), result.getVariant()), 
                   data -> Tuple2.of(data.f0, data.f1))
            .process(new FinalResultProcessFunction())
            .name("final-metrics-calculation")
            .uid("final-metrics-calculation");
    }
    
    /**
     * 创建Kafka数据源
     * @param topic Kafka主题
     * @param clazz 数据类型的Class对象
     * @return Kafka SourceFunction
     */
    private static <T> SourceFunction<T> createKafkaSource(String topic, Class<T> clazz) {
        // 实际实现中配置Kafka消费者
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers", "localhost:9092");
        properties.setProperty("group.id", "abtest-flink-consumer");
        
        // 这里简化实现,实际项目中需要配置具体的Kafka反序列化器等
        // return new FlinkKafkaConsumer<>(topic, new JSONDeserializationSchema<>(clazz), properties);
        return null; // 简化实现
    }
    
    /**
     * 创建Kafka数据输出
     * @param topic Kafka主题
     * @return Kafka SinkFunction
     */
    private static SinkFunction<ExperimentResult> createKafkaSink(String topic) {
        // 实际实现中配置Kafka生产者
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers", "localhost:9092");
        
        // 这里简化实现,实际项目中需要配置具体的Kafka序列化器等
        // return new FlinkKafkaProducer<>(topic, new JSONSerializationSchema<>(), properties);
        return null; // 简化实现
    }
}

4. 关键处理函数实现

java 复制代码
/**
 * 去重用户计数处理函数
 * 功能:在时间窗口内对用户进行去重计数
 */
public class DistinctUserCountProcessFunction 
    extends ProcessWindowFunction<ABTestEvent, Tuple3<String, String, Long>, 
                                Tuple2<String, String>, TimeWindow> {
    
    /**
     * 处理窗口元素,计算去重用户数
     * @param key 窗口键,包含experimentId和variant
     * @param context 窗口上下文
     * @param elements 窗口内的事件元素
     * @param out 结果收集器
     */
    @Override
    public void process(Tuple2<String, String> key, 
                       Context context,
                       Iterable<ABTestEvent> elements,
                       Collector<Tuple3<String, String, Long>> out) {
        
        String experimentId = key.f0;
        String variant = key.f1;
        
        // 使用HashSet进行用户去重,确保同一用户只计数一次
        Set<String> distinctUsers = new HashSet<>();
        for (ABTestEvent event : elements) {
            distinctUsers.add(event.getUserId());
        }
        
        Long userCount = (long) distinctUsers.size();
        // 输出结果:实验ID, 变体, 去重用户数
        out.collect(Tuple3.of(experimentId, variant, userCount));
    }
}

/**
 * 收益聚合函数
 * 功能:累加计算转化用户数和总收益
 */
public class RevenueAggregateFunction 
    implements AggregateFunction<ConversionEvent, 
                                Tuple4<String, String, Long, Double>, 
                                Tuple4<String, String, Long, Double>> {
    
    /**
     * 创建初始累加器
     * @return 初始累加器 (experimentId, variant, userCount, totalRevenue)
     */
    @Override
    public Tuple4<String, String, Long, Double> createAccumulator() {
        return Tuple4.of("", "", 0L, 0.0);
    }
    
    /**
     * 将事件添加到累加器
     * @param event 转化事件
     * @param accumulator 当前累加器状态
     * @return 更新后的累加器
     */
    @Override
    public Tuple4<String, String, Long, Double> add(ConversionEvent event, 
                                                   Tuple4<String, String, Long, Double> accumulator) {
        String experimentId = event.getExperimentId();
        String variant = event.getVariant();
        Long userCount = accumulator.f2 + 1; // 用户数+1
        Double totalRevenue = accumulator.f3 + (event.getRevenue() != null ? event.getRevenue() : 0.0);
        
        return Tuple4.of(experimentId, variant, userCount, totalRevenue);
    }
    
    /**
     * 获取聚合结果
     * @param accumulator 最终累加器状态
     * @return 聚合结果
     */
    @Override
    public Tuple4<String, String, Long, Double> getResult(Tuple4<String, String, Long, Double> accumulator) {
        return accumulator;
    }
    
    /**
     * 合并两个累加器(在会话窗口或合并窗口时使用)
     * @param a 第一个累加器
     * @param b 第二个累加器
     * @return 合并后的累加器
     */
    @Override
    public Tuple4<String, String, Long, Double> merge(Tuple4<String, String, Long, Double> a, 
                                                     Tuple4<String, String, Long, Double> b) {
        return Tuple4.of(a.f0, a.f1, a.f2 + b.f2, a.f3 + b.f3);
    }
}

/**
 * 指标关联处理函数
 * 功能:将曝光指标和转化指标关联起来,计算转化率
 */
public class MetricsCoProcessFunction 
    extends CoProcessFunction<Tuple3<String, String, Long>, 
                             Tuple3<String, String, Long>, 
                             ExperimentResult> {
    
    private ValueState<Tuple3<String, String, Long>> exposureState;
    private ValueState<Tuple3<String, String, Long>> conversionState;
    
    /**
     * 初始化状态变量
     * @param parameters 配置参数
     */
    @Override
    public void open(Configuration parameters) {
        // 定义曝光指标状态描述符
        ValueStateDescriptor<Tuple3<String, String, Long>> exposureDescriptor = 
            new ValueStateDescriptor<>("exposure-state", TypeInformation.of(new TypeHint<Tuple3<String, String, Long>>() {}));
        // 定义转化指标状态描述符
        ValueStateDescriptor<Tuple3<String, String, Long>> conversionDescriptor = 
            new ValueStateDescriptor<>("conversion-state", TypeInformation.of(new TypeHint<Tuple3<String, String, Long>>() {}));
        
        exposureState = getRuntimeContext().getState(exposureDescriptor);
        conversionState = getRuntimeContext().getState(conversionDescriptor);
    }
    
    /**
     * 处理曝光指标数据
     * @param exposureData 曝光数据 (experimentId, variant, exposureCount)
     * @param ctx 上下文
     * @param out 结果收集器
     */
    @Override
    public void processElement1(Tuple3<String, String, Long> exposureData,
                               Context ctx, 
                               Collector<ExperimentResult> out) throws Exception {
        exposureState.update(exposureData);
        emitResultIfReady(out);
    }
    
    /**
     * 处理转化指标数据
     * @param conversionData 转化数据 (experimentId, variant, conversionCount)
     * @param ctx 上下文
     * @param out 结果收集器
     */
    @Override
    public void processElement2(Tuple3<String, String, Long> conversionData,
                               Context ctx, 
                               Collector<ExperimentResult> out) throws Exception {
        conversionState.update(conversionData);
        emitResultIfReady(out);
    }
    
    /**
     * 当曝光和转化数据都准备好时,计算转化率并发出结果
     * @param out 结果收集器
     */
    private void emitResultIfReady(Collector<ExperimentResult> out) throws Exception {
        Tuple3<String, String, Long> exposure = exposureState.value();
        Tuple3<String, String, Long> conversion = conversionState.value();
        
        // 只有当曝光和转化数据都存在时才计算转化率
        if (exposure != null && conversion != null) {
            Long exposureCount = exposure.f2;
            Long conversionCount = conversion.f2;
            Double conversionRate = exposureCount > 0 ? 
                (double) conversionCount / exposureCount : 0.0;
            
            // 构建实验结果对象
            ExperimentResult result = new ExperimentResult(
                exposure.f0, exposure.f1, exposureCount, conversionCount,
                conversionRate, 0.0, 0.0, System.currentTimeMillis() - 300000, // 窗口开始时间(当前时间-5分钟)
                System.currentTimeMillis(), 0.0 // 置信度暂设为0,后续计算
            );
            
            out.collect(result);
        }
    }
}

5. 统计显著性检验

java 复制代码
/**
 * 统计显著性检验工具类
 * 使用z检验比较两个比例的差异
 */
public class StatisticalSignificanceCalculator {
    
    /**
     * 计算两个比例的z检验统计量
     * 用于检验两个独立样本比例的差异是否显著
     * 
     * @param p1 第一组的比例
     * @param n1 第一组的样本量
     * @param p2 第二组的比例  
     * @param n2 第二组的样本量
     * @return z统计量
     */
    public static double calculateZTest(double p1, double n1, double p2, double n2) {
        // 计算合并比例
        double pooledProportion = (p1 * n1 + p2 * n2) / (n1 + n2);
        // 计算标准误
        double standardError = Math.sqrt(
            pooledProportion * (1 - pooledProportion) * (1/n1 + 1/n2)
        );
        
        if (standardError == 0) {
            return 0;
        }
        
        return (p1 - p2) / standardError;
    }
    
    /**
     * 计算p-value(双尾检验)
     * p-value表示观察到的差异由随机性导致的概率
     * 
     * @param zScore z统计量
     * @return p-value
     */
    public static double calculatePValue(double zScore) {
        // 使用标准正态分布计算p-value
        return 2 * (1 - cumulativeDistribution(Math.abs(zScore)));
    }
    
    /**
     * 计算置信区间
     * 
     * @param proportion 样本比例
     * @param sampleSize 样本量
     * @param confidenceLevel 置信水平,如0.95表示95%置信水平
     * @return 置信区间 [下限, 上限]
     */
    public static double[] calculateConfidenceInterval(double proportion, double sampleSize, double confidenceLevel) {
        double z = getZValue(confidenceLevel);
        // 计算边际误差
        double marginOfError = z * Math.sqrt(proportion * (1 - proportion) / sampleSize);
        
        return new double[] {
            Math.max(0, proportion - marginOfError), // 置信区间下限
            Math.min(1, proportion + marginOfError)  // 置信区间上限
        };
    }
    
    /**
     * 标准正态分布累积分布函数(简化实现)
     * @param x 输入值
     * @return 累积概率
     */
    private static double cumulativeDistribution(double x) {
        // 简化实现,实际应使用更精确的算法如误差函数erf
        return 1 - Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math.PI);
    }
    
    /**
     * 根据置信水平获取对应的z值
     * @param confidenceLevel 置信水平
     * @return z值
     */
    private static double getZValue(double confidenceLevel) {
        // 常用置信水平对应的z值
        switch (Double.toString(confidenceLevel)) {
            case "0.90": return 1.645;  // 90%置信水平
            case "0.95": return 1.96;   // 95%置信水平
            case "0.99": return 2.576;  // 99%置信水平
            default: return 1.96;       // 默认95%置信水平
        }
    }
}

6. 实验结果可视化API

java 复制代码
/**
 * 实验结果REST API服务
 * 提供实验结果查询和显著性检验接口
 */
@RestController
@RequestMapping("/api/abtest")
public class ExperimentResultController {
    
    @Autowired
    private ExperimentResultService resultService;
    
    /**
     * 获取实验总体结果
     * @param experimentId 实验ID
     * @param days 查询天数
     * @return 实验摘要信息
     */
    @GetMapping("/experiment/{experimentId}")
    public ResponseEntity<ExperimentSummary> getExperimentSummary(
            @PathVariable String experimentId,
            @RequestParam(defaultValue = "1") int days) {
        
        ExperimentSummary summary = resultService.getExperimentSummary(experimentId, days);
        return ResponseEntity.ok(summary);
    }
    
    /**
     * 获取实验显著性检验结果
     * @param experimentId 实验ID
     * @return 显著性检验结果
     */
    @GetMapping("/experiment/{experimentId}/significance")
    public ResponseEntity<SignificanceResult> getSignificanceTest(
            @PathVariable String experimentId) {
        
        SignificanceResult result = resultService.calculateSignificance(experimentId);
        return ResponseEntity.ok(result);
    }
    
    /**
     * 获取实验趋势数据
     * @param experimentId 实验ID
     * @param granularity 时间粒度 hour/day
     * @return 实验趋势数据列表
     */
    @GetMapping("/experiment/{experimentId}/trend")
    public ResponseEntity<List<ExperimentResult>> getExperimentTrend(
            @PathVariable String experimentId,
            @RequestParam String granularity) {
        
        List<ExperimentResult> trend = resultService.getExperimentTrend(experimentId, granularity);
        return ResponseEntity.ok(trend);
    }
}

系统架构优势

实时性优势

  • 秒级延迟:Flink的流处理能力确保指标计算在秒级内完成
  • 实时监控:实验效果可实时监控,及时发现问题
  • 动态调整:基于实时结果可动态调整流量分配

扩展性设计

  • 水平扩展:Flink作业可轻松水平扩展处理更大流量
  • 多维度分析:支持按设备、地域、用户标签等多维度分析
  • 插件化指标:支持自定义指标计算逻辑

数据一致性保障

  • 精确一次语义:Flink Checkpoint机制保障数据处理一致性
  • 事件时间处理:基于事件时间处理,解决乱序问题
  • 状态管理:内置状态管理,支持故障恢复

生产环境部署建议

资源配置

yaml 复制代码
# Flink作业资源配置
taskmanager.numberOfTaskSlots: 4
jobmanager.memory.process.size: 2g
taskmanager.memory.process.size: 4g
parallelism.default: 8

监控告警

  • 设置Flink作业监控,关注反压、延迟指标
  • 配置实验指标异常告警
  • 建立数据质量监控体系

📌 关注「跑享网」,获取更多大数据架构设计和实战调优干货!

🚀 精选内容推荐:

💥 【本期热议话题】

"AB测试平台技术选型:自研 vs 开源SaaS,哪个才是业务高速增长下的最优解?"

AB测试已成为数据驱动决策的标准配置,但技术选型却让众多团队陷入纠结!

  • 自研派认为:深度定制满足业务特异性,数据安全可控,长期成本更低,但面临技术门槛高、迭代速度慢的挑战?
  • SaaS派认为:开箱即用快速上线,专业统计分析功能完善,专注业务而非技术,但担心数据出境、定制受限和长期费用攀升?
  • 混合派崛起:核心业务自研保障安全,边缘实验采用SaaS追求效率,这种"两条腿走路"真的能兼顾安全与敏捷吗?

这场技术路线之争,你怎么看?欢迎在评论区留下你的:

  1. 最终选型决策和关键考量因素
  2. 在数据安全、功能定制、迭代速度上的权衡经验
  3. 对下一代AB测试平台最核心的技术期待

觉得这篇深度干货对你有帮助?点赞、收藏、转发三连,帮助更多技术小伙伴!

#AB测试 #数据驱动 #Flink #实时计算 #增长黑客 #数据平台 #架构设计 #统计显著性 #SaaSvs自研 #技术选型 #大数据 #数据分析

相关推荐
Jolie_Liang4 小时前
保险业多模态数据融合与智能化运营架构:技术演进、应用实践与发展趋势
大数据·人工智能·架构
武子康5 小时前
大数据-118 - Flink 批处理 DataSet API 全面解析:应用场景、代码示例与优化机制
大数据·后端·flink
文火冰糖的硅基工坊6 小时前
《投资-78》价值投资者的认知升级与交易规则重构 - 架构
大数据·人工智能·重构
卡拉叽里呱啦8 小时前
Apache Iceberg介绍、原理与性能优化
大数据·数据仓库
笨蛋少年派8 小时前
大数据集群环境搭建(Ubantu)
大数据
Elastic 中国社区官方博客8 小时前
在 Elasticsearch 中改进 Agentic AI 工具的实验
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
云雾J视界8 小时前
Flink Checkpoint与反压问题排查手册:从日志分析到根因定位
大数据·阿里云·flink·linq·checkpoint·反压
AI数据皮皮侠8 小时前
中国地级市旅游人数、收入数据(2000-2023年)
大数据·人工智能·python·深度学习·机器学习·旅游
2301_772093569 小时前
tuchuang_myfiles&&share文件列表_共享文件
大数据·前端·javascript·数据库·redis·分布式·缓存