1
0
mirror of https://github.com/facebook/rocksdb.git synced 2026-10-23 08:09:02 +03:00
Files
rocksdb/db/periodic_compaction_phaser.cc
Peter Dillinger e4a7136d3e Preferred-phase scheduling for periodic (time-based) compaction
Summary:
`periodic_compaction_seconds` softly guarantees every SST is rewritten at least
once per interval. Across a fleet of similarly-configured DBs, many events line
those intervals up in lockstep -- a shared enable, a coordinated restart (e.g.
DC power-outage recovery), or just similar write history -- so the periodic
compactions all come due together, a recurring "thundering herd" that is
especially painful with remote compaction (SEV S698635).

The obvious fixes trade one problem for another. Spreading the herd out means
some files get compacted off their natural schedule, which is wasted work; and a
clumsy spread just relocates the herd or spawns a new one. So the real goal is
narrow: **de-herd while keeping "badness" -- extra and/or late compaction --
small, and without inducing fresh herding at any step.**

This change adds an EXPERIMENTAL, opt-in mechanism that gives each `(DB, column
family)` a stable, well-spread *preferred phase* within the interval and steers
periodic compactions toward it. It is randomized and *uncoordinated* (DBs never
talk to each other), which both keeps it simple and, as it turns out, de-herds
far more cheaply than adding jitter to the interval. It is controlled by a single
mutable DBOption and is **off by default**:

- `periodic_compaction_phase_recovery_percent` (default `0` = disabled / kill
  switch; recommended `33`; clamped to `[0, 100]`) -- how aggressively each
  trigger converges toward the phase, as a percent of the remaining gap, only
  ever pulling the trigger *earlier*. `0` reproduces the classic scheduler
  exactly; `100` jumps to the phase in one cycle; lower values approach gradually
  with much less extra work.

The preferred phase itself needs no tuning: it is derived from the DB ID (stable
across re-open and physical cloning/migration, but distinct across DBs) and each
column family is offset from the DB's base phase, so the CFs of one DB don't fire
together. (An earlier draft exposed a `compaction_schedule_seed` string option
for choosing the phase source; it was over-engineered for the one behavior anyone
needed, and as a mutable string it also broke OPTIONS-file round-trip
verification in crash testing. It has been removed in favor of always using the
DB ID.)

## The two kinds of badness

"Badness" here is the fraction of compaction work that is ideally unnecessary:
over-compaction (rewriting a file earlier than the interval required) plus a
penalty for lateness (going overdue past the interval). Steady state is 0; both
"never compact" and "compact twice as often as needed" approach 1. De-herding
inherently spends a little badness to spread load out; every design choice below
exists to bound one of those two costs *without* re-clustering the fleet.

The scenario names cited in the design bullets below (`upgrade_dynamic_r33`,
`naive_upgrade_r25`, `burst_r*`, `turndown_r33`, `turnup_r33`, ...) all come from
the out-of-tree simulator; see its results page (the "Simulator and
visualization" section below, with an interactive plot per scenario) for the
evidence behind each claim.

## Design

- **A stable per-(DB, CF) preferred phase = one fixed attractor per DB.** Each DB
  gets a base phase from its DB ID; each CF is offset from it. Because the target
  is fixed (not re-rolled each cycle) and unique per DB, the fleet converges to a
  uniform spread and then compacts *on time* -- badness decays toward ~0. This is
  the crucial contrast with naive per-cycle jitter, which de-herds but never stops
  jittering and so pays a permanent over-compaction cost (see
  `naive_upgrade_r25`: flat ~12-14%, only diffusive spreading) vs.
  `upgrade_dynamic_r33`, whose badness decays to ~0 for the same anti-herding.

- **Converge gradually, and only ever earlier.** Each trigger closes a
  `recovery_percent` *fraction* of the gap to the phase, reaching it over a few
  cycles rather than in one burst, and never later than
  `periodic_compaction_seconds` in steady state. A lower rate does less extra
  work, which matters when the phase keeps moving (write-driven / bursty
  workloads), where a high rate wastefully "chases" it -- the burst sweep shows
  `burst_r25 < burst_r33 < burst_r50 < burst_r100` in sustained badness. `33` is
  the default balance: quick spreading, low extra work.

- **Fractional recovery (not "snap") is latency-tolerant and doesn't re-herd.**
  Closing a fraction of the gap gives a single stable convergence point, so
  real-world scheduling/queueing latency -- the delay between a file becoming due
  and its compaction actually running -- merely shifts the whole fleet uniformly
  instead of re-clustering it. A tried "snap onto the phase when in range" variant
  converged a bit faster with no latency but re-herded around the snap
  threshold under latency; it was rejected. Graceful degradation beat peak speed.

- **Spread a suddenly-due cohort instead of bursting it (bounded lateness).**
  Enabling the feature, turning `periodic_compaction_seconds` down, or catching up
  after downtime can leave a whole cohort past due at the same instant. Firing it
  all at once would just be the herd again, so such a cohort is spread over the
  first quarter-interval (~N/4) after the phasing anchor on an *absolute*,
  epoch-aligned per-(DB, CF) grid. This trades a bounded lateness (<= ~N/4 past
  the deadline, so the interval is a *soft* upper bound during these transients)
  for anti-herding, and the absolute grid keeps a rapidly restarting DB from being
  perpetually re-deferred and starved. `turndown_r33` shows the past-due cohort
  smeared over N/4 rather than bursting; `turnup_r33` confirms a turn-up creates
  no past-due cohort (deadlines only grow) and so no burst. For immediate
  enforcement at such a transition, trigger a manual compaction.

- **Off by default, with a kill switch.** Inert unless
  `periodic_compaction_phase_recovery_percent > 0`; at `0` behavior is identical
  to the classic scheduler.

## Implementation notes

- Phase math is integer-only (`Hash64` + `FastRange64`); floating point is avoided
  for its pitfalls. The per-CF offset is a golden-ratio (Weyl/Kronecker) additive
  sequence -- low-discrepancy for any number of CFs.
- Logic lives in `db/periodic_compaction_phaser.{h,cc}`
  (`PeriodicCompactionPhaser`), which owns the recovery percent and the phasing
  anchor and exposes the stateless trigger-time math; `VersionSet` holds one and
  caches the per-CF result on each `Version`. The anchor is (re)set to now on DB
  open, on a recovery-percent change, and on any `periodic_compaction_seconds`
  change -- the three events that can create a past-due cohort.
- Plumbed through the C API, `db_bench`, `db_stress`, and `db_crashtest`.

## Simulator and visualization (out-of-tree, not part of this change)

A standalone simulator drives 40 real RocksDB instances through a real
periodic-task scheduler, records every compaction (periodic vs write-driven), and
scores the badness metric above across ~17 scenarios: dynamic/restart enable,
interval turn-down/turn-up via `SetOptions`, on-cadence writes, random write
bursts with a recovery-rate sweep, and the naive-jitter baseline. It is the basis
for the numbers cited above. Interactive HTML (**NOTE** fb-internal URL sanitization might require you to copy-paste the URL yourself):
5759c6a62d/pc_phase_sim.html

Reviewed By: jaykorean

Differential Revision: D117487278

fbshipit-source-id: a62439548b7dd6fe7dfdbbe21b69531d8b57a1e8
2026-08-26 20:14:26 -07:00

109 lines
5.3 KiB
C++

// Copyright (c) Meta Platforms, Inc. and affiliates.
// This source code is licensed under both the GPLv2 (found in the
// COPYING file in the root directory) and Apache 2.0 License
// (found in the LICENSE.Apache file in the root directory).
#include "db/periodic_compaction_phaser.h"
#include <algorithm>
#include "util/fastrange.h"
#include "util/hash.h"
namespace ROCKSDB_NAMESPACE {
bool PeriodicCompactionPhaser::SetConfig(int recovery_percent) {
const bool changed = recovery_percent != recovery_percent_;
recovery_percent_ = recovery_percent;
return changed;
}
PeriodicCompactionPhaseParams PeriodicCompactionPhaser::ParamsForCf(
uint32_t cf_id) const {
PeriodicCompactionPhaseParams params;
if (recovery_percent_ <= 0) {
// Phasing disabled; return default (disabled) params.
return params;
}
// DB-level base phase (a fixed-point fraction * 2^64), hashed from the DB ID.
// The DB ID is stable across re-open and physical cloning/migration but
// distinct across DBs, so DBs configured alike do not herd. db_id_ is read
// live because it is populated after construction (during DB::Open): versions
// built before it is known hash an empty id and self-heal on later versions.
const uint64_t db_base_phase = Hash64(db_id_.data(), db_id_.size());
// Spread this CF quasi-uniformly around the DB base phase (golden ratio) so
// CFs of one DB do not share a phase.
params.seed_hash = CfPhaseSeedHash(db_base_phase, cf_id);
params.anchor_time = anchor_time_;
params.recovery_percent = std::min(recovery_percent_, 100);
return params;
}
uint64_t PeriodicCompactionPhaser::TriggerTime(
uint64_t file_modification_time, uint64_t periodic_compaction_seconds,
const PeriodicCompactionPhaseParams& params) {
const uint64_t n = periodic_compaction_seconds;
// Unphased deadline: file age reaches periodic_compaction_seconds. This is a
// hard upper bound on age; phasing only ever triggers earlier.
const uint64_t natural_trigger = file_modification_time + n;
const int recovery_percent = params.recovery_percent;
if (recovery_percent <= 0 || n == 0) {
return natural_trigger;
}
// Preferred-phase time within each period, floor(p*n) with p == seed / 2^64.
const uint64_t target = FastRange64(params.seed_hash, n);
// Distance (in [0, n)) from the natural deadline back to the nearest
// preferred-phase time at or before it.
const uint64_t offset = (file_modification_time % n + n - target) % n;
// Close recovery_percent% of that gap, triggering earlier. Over successive
// re-stamped cycles the phase error decays geometrically toward the phase.
// (A "snap to the exact phase" refinement was considered and rejected: under
// unpredictable scheduling/queueing latency -- the delay between a file
// becoming due and its periodic compaction actually running -- a snap can
// split the fleet between the phase and a latency-induced fixed point
// (re-herding, the opposite of the goal), whereas the plain geometric pull
// degrades gracefully to a uniform, still well-spread shift.)
const uint64_t early_pull =
offset * static_cast<uint64_t>(std::min(recovery_percent, 100)) / 100;
const uint64_t recovery_trigger = natural_trigger - early_pull;
const uint64_t anchor = params.anchor_time;
if (natural_trigger <= anchor) {
// A whole cohort can be past its deadline when phasing took effect: after
// the DB was down (e.g. DC power-outage recovery) or after
// periodic_compaction_seconds was turned down. Instead of firing the cohort
// immediately (a herd), spread it over the first quarter-period after the
// anchor, on an absolute (epoch-aligned) grid of period n/4 with a stable
// per-(DB, CF) phase: bounded lateness (<= ~n/4) in exchange for
// anti-herding. The grid is absolute (not anchor + offset) so a rapidly
// restarting DB is not perpetually re-anchored just short of its trigger
// and thereby starved -- the grid point is a fixed wall-clock time that
// arrives regardless of restarts.
const uint64_t grid = n / 4;
if (grid == 0) {
return anchor;
}
const uint64_t phase = FastRange64(params.seed_hash, grid);
// Smallest t >= anchor with t % grid == phase.
return anchor + (phase + grid - anchor % grid) % grid;
} else if (recovery_trigger >= anchor) {
return recovery_trigger;
}
// Not yet past due, but the recovery target lands before the anchor: spread
// the catch-up across [anchor, natural deadline] using the phase (no
// recovery), so enabling phasing fleet-wide does not fire everything at once,
// and never later than the natural deadline.
return anchor + FastRange64(params.seed_hash, natural_trigger - anchor);
}
uint64_t PeriodicCompactionPhaser::CfPhaseSeedHash(uint64_t db_base_phase,
uint32_t cf_id) {
// Golden-ratio conjugate ((sqrt(5)-1)/2) as a fixed-point fraction * 2^64.
// The additive recurrence db_base + cf_id * kGolden (wrapping mod 2^64, i.e.
// mod 1) is a low-discrepancy Weyl/Kronecker sequence, so a DB's CFs are
// spread quasi-uniformly around the base phase for any number of CFs.
constexpr uint64_t kGoldenRatioU64 = 0x9E3779B97F4A7C15ULL;
return db_base_phase + static_cast<uint64_t>(cf_id) * kGoldenRatioU64;
}
} // namespace ROCKSDB_NAMESPACE