文档目录

5.5 配套代码:数据集生成与分布验证

对应小节:5.5 数据集设计 两件事:① 生成幂律分布的数据;② 验证你的数据集是否真的符合预期分布。

一、幂律(齐夫)数据生成

// src/main/kotlin/dataset/ZipfGenerator.kt
package dataset

import kotlin.math.pow
import kotlin.random.Random

/**
 * 近似齐夫分布采样器。
 *
 * alpha 的含义:
 *   alpha = 0    → 退化为均匀分布
 *   alpha ≈ 1    → 接近真实齐夫
 *   alpha = 3    → 更集中(前 1% 承担绝大多数访问)
 *
 * 真实业务的 alpha 通常通过生产访问日志的 log-log 图斜率估算。
 */
class ZipfSampler(
    private val n: Int,
    private val alpha: Double = 1.2,
    seed: Int = 42,
) {
    private val rnd = Random(seed)

    fun next(): Int {
        val u = rnd.nextDouble()
        return (1 + (n * u.pow(alpha)).toInt()).coerceIn(1, n)
    }
}

/**
 * 让数据分布可控的生成器:可以指定「前 x% 的 key 承担 y% 的访问」。
 * 比直接用 alpha 更直观——因为业务方通常用这种方式描述热点。
 */
class HotspotSampler(
    private val totalKeys: Int,
    private val hotKeyRatio: Double = 0.01,      // 前 1% 的 key
    private val hotTrafficRatio: Double = 0.40,  // 承担 40% 的访问
    seed: Int = 42,
) {
    private val rnd = Random(seed)
    private val hotKeyCount = (totalKeys * hotKeyRatio).toInt().coerceAtLeast(1)

    fun next(): Int = if (rnd.nextDouble() < hotTrafficRatio) {
        // 热点区(前 hotKeyRatio 的 key)
        rnd.nextInt(1, hotKeyCount + 1)
    } else {
        // 长尾区(其余 key)
        rnd.nextInt(hotKeyCount + 1, totalKeys + 1)
    }
}

fun main() {
    val N = 1_000_000
    val SAMPLES = 200_000

    println("═══ 分布对比(%d 个 key,%d 次采样)═══".format(N, SAMPLES))
    println()

    for ((label, sampler) in listOf(
        "均匀分布(alpha=0)" to ZipfSampler(N, alpha = 0.0),
        "轻微集中(alpha=1.0)" to ZipfSampler(N, alpha = 1.0),
        "强热点(alpha=3.0)" to ZipfSampler(N, alpha = 3.0),
    )) {
        val counts = IntArray(10)      // 把 key 空间分成 10 段,统计各段访问占比
        repeat(SAMPLES) {
            val key = sampler.next()
            val segment = ((key - 1).toLong() * 10 / N).toInt().coerceIn(0, 9)
            counts[segment]++
        }
        println("%-22s 前10%%的key承担 %5.1f%% 的访问".format(
            label, counts[0] * 100.0 / SAMPLES))
        println("  各段分布: " + counts.joinToString(" ") { "%5.1f%%".format(it * 100.0 / SAMPLES) })
        println()
    }

    // 用 HotspotSampler 精确控制热点
    println("═══ 精确控制热点 ═══")
    val hs = HotspotSampler(totalKeys = N, hotKeyRatio = 0.01, hotTrafficRatio = 0.40)
    val hot = IntArray(1)
    val cold = IntArray(1)
    repeat(SAMPLES) {
        if (hs.next() <= N / 100) hot[0]++ else cold[0]++
    }
    println("HotspotSampler(前1%%的key, 40%%流量):")
    println("  实测:前 1%% 的 key 承担 %.1f%% 的访问(目标 40%%)".format(hot[0] * 100.0 / SAMPLES))
}

二、分布验证(Python)

# tools/verify_distribution.py <access_log.csv> [--top-pct 1]
"""
验证数据集的分布是否符合预期。

输入格式:CSV,每行一个被访问的 key(或 key 的哈希)
输出:热点集中度报告 + log-log 斜率估计(alpha)
"""
import csv
import math
import sys
from collections import Counter


def load_keys(path):
    keys = []
    with open(path) as f:
        reader = csv.reader(f)
        for row in reader:
            if row:
                keys.append(row[0].strip())
    return keys


def concentration_report(counter, top_pcts=(0.001, 0.01, 0.1)):
    total = sum(counter.values())
    distinct = len(counter)
    print(f"总访问次数 : {total:,}")
    print(f"不同 key 数 : {distinct:,}")
    print()

    print("热点集中度:")
    sorted_counts = sorted(counter.values(), reverse=True)
    for pct in top_pcts:
        k = max(1, int(distinct * pct))
        top_sum = sum(sorted_counts[:k])
        print(f"  前 {pct*100:5.1f}% 的 key 承担 {top_sum/total*100:5.1f}% 的访问"
              f"  ({k:,} 个 key)")
    print()


def estimate_alpha(counter):
    """
    用 log-log 回归估计齐夫指数 alpha。
    齐夫分布:freq(rank) ∝ rank^(-alpha)
    取对数后是直线,斜率 = -alpha
    """
    sorted_counts = sorted(counter.values(), reverse=True)
    # 取前 1000 个(尾部噪声大)
    points = [(math.log(i + 1), math.log(c)) for i, c in enumerate(sorted_counts[:1000]) if c > 0]
    if len(points) < 10:
        return None

    n = len(points)
    mx = sum(p[0] for p in points) / n
    my = sum(p[1] for p in points) / n
    num = sum((p[0] - mx) * (p[1] - my) for p in points)
    den = sum((p[0] - mx) ** 2 for p in points)
    slope = num / den if den else 0
    return -slope


def main():
    if len(sys.argv) < 2:
        print("usage: verify_distribution.py <access_log.csv> [--top-pct N]")
        sys.exit(1)

    path = sys.argv[1]
    keys = load_keys(path)
    if not keys:
        print("❌ 没有读到数据")
        return

    counter = Counter(keys)

    print("═" * 70)
    print(f"分布验证:{path}")
    print("═" * 70)
    print()

    concentration_report(counter)

    alpha = estimate_alpha(counter)
    print("齐夫指数估计:")
    if alpha:
        print(f"  alpha ≈ {alpha:.2f}")
        if alpha < 0.3:
            print("  → 接近均匀分布(不像真实业务)")
        elif alpha < 0.8:
            print("  → 轻微集中")
        elif alpha < 1.5:
            print("  → 接近真实齐夫分布 ✅")
        else:
            print("  → 高度集中(热点极强)")
    else:
        print("  样本不足,无法估计")
    print()

    print("判读要点:")
    print("  ① 前 1% 的 key 承担 30%~50% 的访问 → 接近真实业务")
    print("  ② 如果前 1% 只承担 1%~2% → 这是均匀分布,不真实")
    print("  ③ 用这个 alpha 配置你的压测脚本(不要用均匀随机)")
    print("═" * 70)


if __name__ == "__main__":
    main()

三、从生产日志提取真实分布(最推荐的做法)

# ① 从 PostgreSQL 统计真实的 ID 访问分布(如果有访问日志表)
psql -c "
SELECT user_id, count(*) AS freq
FROM request_log
WHERE created_at > now() - interval '1 day'
GROUP BY user_id
ORDER BY freq DESC
LIMIT 1000;" > docs/experiments/E0x/results/top-keys.csv

# ② 直接导出为压测脚本用的 ID 列表(含重复,保持真实频率)
psql -tAc "
SELECT user_id FROM request_log
WHERE created_at > now() - interval '1 day'
ORDER BY random()
LIMIT 100000;" > docs/experiments/E0x/results/sampled-ids.txt

# ③ 在 k6 里从这个列表里随机取(保持真实分布)
# export default function () {
#   const ids = open('./sampled-ids.txt').split('\n');
#   const id = ids[Math.floor(Math.random() * ids.length)];
#   http.get(`${__BASE_URL}/users/${id}`);
# }

这是最真实的做法:直接从生产访问日志里采样,而不是用公式模拟。

四、数据规模验证(确认执行计划未变)

#!/usr/bin/env bash
# tools/verify-execution-plan.sh <QUERY> <SMALL_TABLE> <LARGE_TABLE>
#
# 对比同一查询在不同数据量下的执行计划,确认规模足够大。
set -uo pipefail

QUERY="${1:?usage: verify-execution-plan.sh <query> <table>}"
PG_CONN="${PG_CONN:-}"

echo "═══ 执行计划对比 ═══"
echo "查询: $QUERY"
echo

for SCALE in "10%" "50%" "100%"; do
  echo "── 数据量 $SCALE ──"

  # 用一个临时表模拟不同规模(或者用 LIMIT + 子查询,但注意这会改变计划)
  # 更可靠的做法是针对不同规模的测试库分别测
  psql "$PG_CONN" -c "EXPLAIN (ANALYZE, BUFFERS) $QUERY" 2>&1 | \
    grep -E "Seq Scan|Index Scan|Bitmap|actual time|rows=|Execution Time" | head -8

  echo
done

echo "判读:"
echo "  如果三个规模的计划【相同】→ 规模足够,结论可迁移"
echo "  如果计划发生变化(如 10% 走索引、100% 走全表扫描)→ 规模不够,需要加大"
echo
echo "⚠️  严格做法:准备 10% / 50% / 100% 三份数据分别测试"
echo "    (本脚本的简化版只演示流程,实际需要多份数据)"

五、动手改造

改动 观察什么
把 alpha 从 1.0 改成 3.0 前 10% 的 key 承担的比例从 ~30% 涨到 ~90%
用 HotspotSampler 精确设定「前 1% 承担 40%」 验证生成器是否准确(应该接近 40%)
用真实日志跑 verify_distribution.py 得到你业务的真实 alpha,写进实验元数据
对比均匀 vs 幂律分布下的缓存命中率 这就是数据集设计影响结论的直接证据
在 10 万 / 100 万 / 1000 万三个规模上跑同一查询 观察执行计划与 P99 的变化

六、这段代码的局限

  • alpha 与「前 x% 承担 y%」不是一一对应:同一个 alpha 在不同 key 总数下对应的热点比例不同。业务方更容易理解后者,所以 HotspotSampler 更实用。
  • 真实分布往往不是纯齐夫:可能有多个热点层(超级热点 + 次热点 + 长尾),需要更复杂的模型。
  • verify_distribution.py 的 alpha 估计用前 1000 个点做回归:对超大规模 key 空间可能不准。
  • 从生产日志采样要考虑时间窗口:大促期间的分布和平时差别很大,需要明确你要模拟哪个场景。
  • 数据规模验证比较麻烦:真正严谨的做法是准备多份不同规模的数据,而不是在同一份数据上做近似。