Query rentang

time_bucket, window functions, dan predikat waktu terindeks.

Daftar isi

Query rentang adalah pola pembacaan yang paling khas pada data time-series, dan TimescaleDB menyediakan fungsi time_bucket untuk mengelompokkan baris ke dalam bucket waktu dengan interval sembarang. Topik ini menjelaskan time_bucket, window function yang berpasangan dengannya, serta peran predikat waktu terindeks dalam menentukan kecepatan query. Setelah membaca topik ini, sebuah query agregasi atas jutaan baris dapat ditulis dengan sintaksis yang ringkas dan predikat yang dapat dieliminasi oleh planner.

time_bucket: GROUP BY berbasis waktu#

Fungsi time_bucket adalah padanan TimescaleDB untuk date_trunc, tetapi menerima interval sembarang, bukan hanya satuan kalender seperti hour atau day. Fungsi ini menerima interval seperti '5 minutes', '1 hour', atau '2 days', lalu mengembalikan cap waktu awal bucket yang memuat nilai masukan. Karena itu, time_bucket mengubah deret cap waktu mentah menjadi deret bucket berurutan, sehingga GROUP BY pada hasil fungsi itu menghasilkan agregasi per bucket. Fungsi ini menjadi tulang punggung hampir semua query analitik pada data time-series, mulai dari dashboard hingga laporan harian.

Perintah berikut tidak dijalankan di sandbox karena memerlukan Postgres dengan TimescaleDB aktif, sehingga hanya menjadi referensi sintaksis. Query itu mengelompokkan 30 menit telemetri menjadi enam bucket lima menit, lalu menghitung min, max, avg, dan count per bucket.

postgres Agregasi 5 menit dengan time_bucket
SELECT
    time_bucket('5 minutes', ts) AS bucket,
    min(suhu_c)  AS suhu_min,
    max(suhu_c)  AS suhu_max,
    avg(suhu_c)  AS suhu_avg,
    count(*)     AS titik
FROM suhu_sensor
WHERE ts >= '2026-11-01 00:00:00+00'
  AND ts <  '2026-11-01 00:30:00+00'
  AND sensor_id = 'S01'
GROUP BY bucket
ORDER BY bucket;
text Keluaran
bucket|suhu_min|suhu_max|suhu_avg|titik
2026-11-01 00:00:00+00|24|24.4|24.2|5
2026-11-01 00:05:00+00|24|24.6|24.279999999999998|5
2026-11-01 00:10:00+00|24|24.6|24.360000000000003|5
2026-11-01 00:15:00+00|24.1|24.5|24.3|5
2026-11-01 00:20:00+00|24|24.6|24.240000000000002|5
2026-11-01 00:25:00+00|24|24.6|24.32|5

Ilustrasi time_bucket dengan pandas#

Untuk memahami time_bucket tanpa server Postgres, padanan paling dekat pada Python adalah operasi resample di pandas. Skrip berikut membangkitkan 30 titik telemetri dengan interval satu menit, lalu mengelompokkan menjadi bucket lima menit dengan agregat min, max, mean, dan count. Hasilnya menunjukkan bahwa 30 titik mentah terkompresi menjadi enam bucket, dengan rasio kompresi baris lima kali. Skrip juga mencetak padanan SQL TimescaleDB, sehingga korespondensi antara resample dan time_bucket menjadi jelas.

python ilustrasi_time_bucket.py
"""Ilustrasi time_bucket dengan pandas resample.

Skrip ini tidak menjalankan SQL. Tujuannya menggambarkan apa yang dilakukan
fungsi time_bucket pada TimescaleDB dengan padanan operasi resample pada
pandas. Skrip membangkitkan 30 titik telemetri interval 1 menit, lalu
mengelompokkan menjadi bucket 5 menit dengan agregat min, max, dan avg.
"""
from __future__ import annotations

import numpy as np
import pandas as pd

AWAL = "2026-11-01 00:00:00"
JUMLAH_TITIK = 30
BUCKET = "5min"


def bangun_telemetri() -> pd.DataFrame:
    rng = np.random.default_rng(42)
    ts = pd.date_range(AWAL, periods=JUMLAH_TITIK, freq="1min")
    suhu = 22.0 + np.sin(np.arange(JUMLAH_TITIK) / 5.0) + rng.normal(0, 0.3, JUMLAH_TITIK)
    return pd.DataFrame({"ts": ts, "sensor_id": "S01", "suhu_c": np.round(suhu, 2)})


def main() -> None:
    df = bangun_telemetri()
    print("telemetri mentah (5 baris pertama):")
    print(df.head().to_string(index=False))
    print(f"... total {len(df)} titik")
    print()

    bucket = (
        df.set_index("ts")
        .resample(BUCKET)["suhu_c"]
        .agg(["min", "max", "mean", "count"])
        .reset_index()
    )
    bucket["mean"] = bucket["mean"].round(2)

    print(f"hasil time_bucket(interval '{BUCKET}', ts):")
    print(bucket.to_string(index=False))
    print()
    print("padanan SQL TimescaleDB:")
    print("  SELECT time_bucket('5 minutes', ts) AS bucket,")
    print("         min(suhu_c), max(suhu_c), avg(suhu_c), count(*)")
    print("  FROM suhu_sensor GROUP BY bucket ORDER BY bucket;")
    print()
    print(f"baris mentah: {len(df)}, baris bucket: {len(bucket)}, "
          f"rasio kompresi baris: {len(df)/len(bucket):.1f}x")


if __name__ == "__main__":
    main()
text Keluaran
telemetri mentah (5 baris pertama):
                 ts sensor_id  suhu_c
2026-11-01 00:00:00       S01   22.09
2026-11-01 00:01:00       S01   21.89
2026-11-01 00:02:00       S01   22.61
2026-11-01 00:03:00       S01   22.85
2026-11-01 00:04:00       S01   22.13
... total 30 titik

hasil time_bucket(interval '5min', ts):
                 ts   min   max  mean  count
2026-11-01 00:00:00 21.89 22.85 22.31      5
2026-11-01 00:05:00 22.45 22.99 22.80      5
2026-11-01 00:10:00 22.48 23.17 22.85      5
2026-11-01 00:15:00 21.37 22.05 21.72      5
2026-11-01 00:20:00 20.88 21.42 21.07      5
2026-11-01 00:25:00 20.94 21.66 21.34      5

padanan SQL TimescaleDB:
  SELECT time_bucket('5 minutes', ts) AS bucket,
         min(suhu_c), max(suhu_c), avg(suhu_c), count(*)
  FROM suhu_sensor GROUP BY bucket ORDER BY bucket;

baris mentah: 30, baris bucket: 6, rasio kompresi baris: 5.0x

Keluaran di atas memperlihatkan tiga hal. Pertama, 30 titik mentah dikompresi menjadi enam bucket, masing-masing memuat lima titik. Kedua, agregat min, max, mean, dan count per bucket memberi ringkasan statistik yang cukup untuk dashboard. Ketiga, padanan SQL TimescaleDB berbentuk GROUP BY bucket yang biasa, sehingga query plan-nya pun biasa, dengan tambahan eliminasi chunk yang telah dijelaskan pada bagian terdahulu. Inilah alasan time_bucket menjadi fungsi yang paling sering digunakan pada beban analitik time-series.

Window function di atas time_bucket#

time_bucket sering digunakan bersama window function untuk menghitung perubahan antar bucket. Fungsi lag(avg_suhu) OVER (ORDER BY bucket) memberi nilai rata-rata bucket sebelumnya, sehingga selisih antar bucket dapat dihitung dengan avg_suhu - lag(avg_suhu) OVER (ORDER BY bucket). Pola ini menghasilkan deret perubahan, bukan deret nilai absolut, yang sering menjadi lebih informatif untuk deteksi anomali. Window function lain, seperti first_value dan last_value, juga berguna untuk mengambil titik pertama atau terakhir pada setiap bucket.

Perintah berikut tidak dijalankan di sandbox, tetapi memperlihatkan kombinasi time_bucket dengan lag untuk menghitung selisih rata-rata suhu antar bucket lima menit. Pola ini menjadi dasar banyak query deteksi anomali, sebab perubahan tajam antar bucket sering menjadi indikator masalah pada sistem yang dipantau.

postgres time_bucket dengan window function lag
WITH bucketed AS (
    SELECT
        time_bucket('5 minutes', ts) AS bucket,
        avg(suhu_c) AS suhu_avg
    FROM suhu_sensor
    WHERE ts >= '2026-11-01 00:00:00+00'
      AND ts <  '2026-11-01 01:00:00+00'
      AND sensor_id = 'S01'
    GROUP BY bucket
)
SELECT
    bucket,
    suhu_avg,
    suhu_avg - lag(suhu_avg) OVER (ORDER BY bucket) AS delta
FROM bucketed
ORDER BY bucket;
text Keluaran
bucket|suhu_avg|delta
2026-11-01 00:00:00+00|24.2|
2026-11-01 00:05:00+00|24.279999999999998|0.0799999999999983
2026-11-01 00:10:00+00|24.360000000000003|0.0800000000000054
2026-11-01 00:15:00+00|24.3|-0.060000000000002274
2026-11-01 00:20:00+00|24.240000000000002|-0.05999999999999872
2026-11-01 00:25:00+00|24.32|0.0799999999999983
2026-11-01 00:30:00+00|24.4|0.0799999999999983
2026-11-01 00:35:00+00|24.2|-0.1999999999999993
2026-11-01 00:40:00+00|24.279999999999998|0.0799999999999983
2026-11-01 00:45:00+00|24.360000000000003|0.0800000000000054
2026-11-01 00:50:00+00|24.3|-0.060000000000002274
2026-11-01 00:55:00+00|24.240000000000002|-0.05999999999999872

Predikat waktu terindeks#

Kecepatan query rentang ditentukan oleh dua hal, yaitu eliminasi chunk oleh planner dan ketersediaan indeks pada kolom waktu di dalam chunk yang dipindai. Eliminasi chunk memutuskan chunk mana yang disentuh, sedangkan indeks B-tree pada (sensor_id, ts) memutuskan bagaimana baris di dalam chunk itu dicari. Predikat ts >= '...' AND ts < '...' menjadi sinyal bagi planner untuk menggunakan indeks itu, sehingga pemindaian menjadi index scan atau bitmap index scan, bukan sequential scan.

Bentuk predikat memengaruhi apakah indeks digunakan. Predikat berbentuk rentang, yaitu ts >= '...' AND ts < '...' atau ts BETWEEN '...' AND '...', cocok untuk indeks B-tree. Predikat berbentuk fungsi pada kolom waktu, misalnya date_trunc('day', ts) = '...', sering kali tidak dapat menggunakan indeks biasa, sebab planner harus memeriksa seluruh rentang hari yang bersangkutan. Untuk kasus itu, expression index pada date_trunc('day', ts) atau penggunaan time_bucket pada klausa WHERE dapat dipertimbangkan, tetapi pendekatan yang lebih sederhana adalah menulis ulang predikat menjadi rentang eksplisit.

Demo interaktif time_bucket#

Untuk merasakan bagaimana ukuran bucket mengubah tampilan deret, jalankan demo berikut. Tombol mengubah interval bucket antara satu menit (raw), lima menit, dan lima belas menit, sedangkan grafik menampilkan titik mentah sebagai garis merah tipis dan nilai rata-rata bucket sebagai titik biru. Pita biru transparan menyatakan rentang min hingga max per bucket, sehingga dispersi dalam bucket tetap terlihat. Tabel di bawah grafik menampilkan agregat numerik tiap bucket.

Kode sumber demo
<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8" />
  <title>Ilustrasi time_bucket</title>
  <style>
    body { font-family: system-ui, -apple-system, sans-serif; margin: 16px; color: #1f2937; }
    h2 { font-size: 16px; margin: 0 0 8px 0; font-weight: 600; }
    .kontrol { display: flex; gap: 8px; margin-bottom: 8px; font-size: 13px; }
    .kontrol button { padding: 4px 10px; font-size: 13px; cursor: pointer; border: 1px solid #d1d5db; background: #fff; border-radius: 4px; }
    .kontrol button.aktif { background: #1e3a8a; color: #fff; border-color: #1e3a8a; }
    .legend { display: flex; gap: 16px; margin-bottom: 8px; font-size: 12px; }
    .legend span { display: inline-flex; align-items: center; gap: 6px; }
    .swatch { width: 12px; height: 12px; border-radius: 2px; }
    svg { width: 100%; height: 260px; background: #fafafa; border: 1px solid #e5e7eb; }
    .grid { stroke: #e5e7eb; stroke-width: 1; }
    .axis { font-size: 10px; fill: #6b7280; }
    .line-raw { fill: none; stroke: #ef4444; stroke-width: 1; opacity: 0.55; }
    .dot-raw { fill: #ef4444; opacity: 0.6; }
    .line-bucket { fill: none; stroke: #1d4ed8; stroke-width: 2; }
    .dot-bucket { fill: #1d4ed8; }
    .bucket-band { fill: #1d4ed8; opacity: 0.12; }
    .stat { margin-top: 8px; font-size: 12px; color: #374151; }
    table { border-collapse: collapse; margin-top: 8px; font-size: 12px; width: 100%; }
    th, td { border: 1px solid #e5e7eb; padding: 4px 8px; text-align: left; }
    th { background: #f3f4f6; }
  </style>
</head>
<body>
  <h2>Telemetri 30 menit: raw vs time_bucket</h2>
  <div class="kontrol">
    <button data-bucket="1min">1 menit (raw)</button>
    <button data-bucket="5min">5 menit</button>
    <button data-bucket="15min">15 menit</button>
  </div>
  <div class="legend">
    <span><span class="swatch" style="background:#ef4444"></span> Titik raw</span>
    <span><span class="swatch" style="background:#1d4ed8"></span> Avg bucket</span>
    <span><span class="swatch" style="background:#1d4ed8;opacity:0.25"></span> Min-max bucket</span>
  </div>
  <svg id="chart" viewBox="0 0 480 260" preserveAspectRatio="none">
    <line class="grid" x1="0" y1="65" x2="480" y2="65" />
    <line class="grid" x1="0" y1="130" x2="480" y2="130" />
    <line class="grid" x1="0" y1="195" x2="480" y2="195" />
    <polyline id="band" class="bucket-band" />
    <polyline id="line-raw" class="line-raw" />
    <polyline id="line-bucket" class="line-bucket" />
    <g id="dots-raw"></g>
    <g id="dots-bucket"></g>
    <text class="axis" x="6" y="14">23.5</text>
    <text class="axis" x="6" y="134">22.0</text>
    <text class="axis" x="6" y="254">20.5</text>
    <text class="axis" x="6" y="254">20.5</text>
    <text class="axis" x="220" y="254">00:00</text>
    <text class="axis" x="440" y="254">00:29</text>
  </svg>
  <div class="stat" id="statistik">resolusi: 1 menit (raw), 30 titik</div>
  <table id="tabel-bucket">
    <thead><tr><th>bucket</th><th>min</th><th>max</th><th>avg</th><th>count</th></tr></thead>
    <tbody></tbody>
  </table>

  <script>
    // 30 titik raw dengan interval 1 menit.
    const raw = [22.09, 21.89, 22.61, 22.85, 22.13,
                 22.45, 22.99, 22.95, 22.55, 22.78,
                 22.48, 22.97, 23.17, 22.71, 22.66,
                 21.37, 21.78, 21.95, 22.05, 21.51,
                 20.88, 21.42, 21.27, 20.95, 21.02,
                 20.94, 21.21, 21.66, 21.42, 21.13];

    const W = 480, H = 260, padX = 14, padY = 14;
    const yMin = 20.5, yMax = 23.5;

    function xUntuk(i, total) {
      const step = (W - 2 * padX) / Math.max(total - 1, 1);
      return padX + i * step;
    }
    function yUntuk(v) {
      return H - padY - ((v - yMin) / (yMax - yMin)) * (H - 2 * padY);
    }
    function polylineStr(data, total) {
      return data.map((v, i) => xUntuk(i, total).toFixed(1) + "," + yUntuk(v).toFixed(1)).join(" ");
    }

    function bucketize(menit) {
      const out = [];
      const step = menit;
      for (let i = 0; i < raw.length; i += step) {
        const slice = raw.slice(i, i + step);
        const min = Math.min(...slice);
        const max = Math.max(...slice);
        const mean = slice.reduce((a, b) => a + b, 0) / slice.length;
        out.push({ mulai: i, min, max, mean, count: slice.length });
      }
      return out;
    }

    const lineRaw = document.getElementById("line-raw");
    const lineBucket = document.getElementById("line-bucket");
    const band = document.getElementById("band");
    const dotsRaw = document.getElementById("dots-raw");
    const dotsBucket = document.getElementById("dots-bucket");
    const statistik = document.getElementById("statistik");
    const tbody = document.querySelector("#tabel-bucket tbody");

    function perbarui(bucketMenit) {
      const label = bucketMenit === 1 ? "1 menit (raw)" : bucketMenit + " menit";
      const total = raw.length;

      // titik raw selalu ditampilkan
      lineRaw.setAttribute("points", polylineStr(raw, total));
      dotsRaw.innerHTML = raw.map((v, i) => {
        const x = xUntuk(i, total);
        const y = yUntuk(v);
        return `<circle class="dot-raw" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="2"/>`;
      }).join("");

      if (bucketMenit === 1) {
        lineBucket.setAttribute("points", "");
        band.setAttribute("points", "");
        dotsBucket.innerHTML = "";
        tbody.innerHTML = "<tr><td colspan=\"5\">(raw, tanpa agregasi)</td></tr>";
        statistik.textContent = "resolusi: 1 menit (raw), 30 titik";
        return;
      }

      const buckets = bucketize(bucketMenit);
      const means = buckets.map(b => b.mean);
      const xStep = (W - 2 * padX) / total * bucketMenit;

      lineBucket.setAttribute("points", buckets.map((b, i) => {
        const x = padX + b.mulai * (W - 2 * padX) / total + xStep / 2;
        return x.toFixed(1) + "," + yUntuk(b.mean).toFixed(1);
      }).join(" "));

      dotsBucket.innerHTML = buckets.map((b, i) => {
        const x = padX + b.mulai * (W - 2 * padX) / total + xStep / 2;
        return `<circle class="dot-bucket" cx="${x.toFixed(1)}" cy="${yUntuk(b.mean).toFixed(1)}" r="3.5"/>`;
      }).join("");

      // pita min-max
      const atas = buckets.map(b => {
        const x = padX + b.mulai * (W - 2 * padX) / total + xStep / 2;
        return x.toFixed(1) + "," + yUntuk(b.max).toFixed(1);
      });
      const bawah = buckets.slice().reverse().map(b => {
        const x = padX + b.mulai * (W - 2 * padX) / total + xStep / 2;
        return x.toFixed(1) + "," + yUntuk(b.min).toFixed(1);
      });
      band.setAttribute("points", atas.concat(bawah).join(" "));

      tbody.innerHTML = buckets.map(b => {
        const m = Math.floor(b.mulai / 1);
        const hh = String(Math.floor(m / 60)).padStart(2, "0");
        const mm = String(m % 60).padStart(2, "0");
        return `<tr><td>00:${mm}</td><td>${b.min.toFixed(2)}</td><td>${b.max.toFixed(2)}</td><td>${b.mean.toFixed(2)}</td><td>${b.count}</td></tr>`;
      }).join("");

      statistik.textContent = "resolusi: " + label + ", " + buckets.length + " bucket dari " + total + " titik raw";
    }

    document.querySelectorAll(".kontrol button").forEach(btn => {
      btn.addEventListener("click", () => {
        document.querySelectorAll(".kontrol button").forEach(b => b.classList.remove("aktif"));
        btn.classList.add("aktif");
        const menit = parseInt(btn.dataset.bucket, 10);
        perbarui(menit);
      });
    });

    const awal = document.querySelector('.kontrol button[data-bucket="5min"]');
    awal.classList.add("aktif");
    perbarui(5);
  </script>
</body>
</html>

Demo di atas memperlihatkan tradeoff klasik downsampling. Bucket satu menit mempertahankan resolusi penuh, tetapi memuat 30 titik yang sulit dibaca pada layar kecil. Bucket lima menit mengkompresi 30 titik menjadi enam bucket, yang masih cukup untuk melihat tren, sedangkan Bucket lima belas menit mengkompresi menjadi dua bucket, yang mungkin terlalu kasar untuk deteksi anomali jangka pendek. Pemilihan ukuran bucket pada query produksi mengikuti pertimbangan yang sama, yaitu keseimbangan antara resolusi dan keterbacaan, dengan tambahan kebijakan retensi yang menjadi pertimbangan terpisah.

Kesalahan umum#

  • Menggunakan date_trunc sebagai ganti time_bucket untuk interval bukan satuan kalender. date_trunc tidak menerima interval seperti 13 minutes, sehingga pemakaiannya membatasi pilihan bucket.
  • Menulis predikat berbentuk fungsi pada kolom waktu, misalnya WHERE date_trunc('day', ts) = '...'. Predikat ini sering tidak menggunakan indeks B-tree, sehingga query menjadi sequential scan pada chunk.
  • Lupa menambahkan indeks pada (sensor_id, ts) atau (ts, sensor_id). Tanpa indeks itu, setelah chunk dipilih, planner tidak punya cara cepat untuk mencari baris di dalamnya.

Ringkasan#

  • time_bucket mengelompokkan baris ke dalam bucket waktu dengan interval sembarang, dan padanannya pada pandas adalah operasi resample.
  • Window function seperti lag dan first_value bekerja di atas time_bucket untuk menghitung perubahan antar bucket.
  • Kecepatan query rentang ditentukan oleh eliminasi chunk dan ketersediaan indeks B-tree pada kolom waktu, sehingga predikat sebaiknya ditulis sebagai rentang eksplisit.

Referensi#