Alerting: Add jitter support for periodic alert state storage to reduce database load spikes (#111357)

What is this feature?

This PR implements a jitter mechanism for periodic alert state storage to distribute database load over time instead of processing all alert instances simultaneously. When enabled via the state_periodic_save_jitter_enabled configuration option, the system spreads batch write operations across 85% of the save interval window, preventing database load spikes in high-cardinality alerting environments.

Why do we need this feature?

In production environments with high alert cardinality, the current periodic batch storage can cause database performance issues by processing all alert instances simultaneously at fixed intervals. Even when using periodic batch storage to improve performance, concentrating all database operations at a single point in time can overwhelm database resources, especially in resource-constrained environments.

Rather than performing all INSERT operations at once during the periodic save, distributing these operations across the time window until the next save cycle can maintain more stable service operation within limited database resources. This approach prevents resource saturation by spreading the database load over the available time interval, allowing the system to operate more gracefully within existing resource constraints.

For example, with 200,000 alert instances using a 5-minute interval and 4,000 batch size, instead of executing 50 batch operations simultaneously, the jitter mechanism distributes these operations across approximately 4.25 minutes (85% of 5 minutes), with each batch executed roughly every 5.2 seconds.

This PR provides system-level protection against such load spikes by distributing operations across time, reducing peak resource usage while maintaining the benefits of periodic batch storage. The jitter mechanism is particularly valuable in resource-constrained environments where maintaining consistent database performance is more critical than precise timing of state updates.
This commit is contained in:
Seunghun Shin
2025-09-29 11:22:36 +02:00
committed by GitHub
parent 310c83531c
commit 512c292e04
12 changed files with 367 additions and 44 deletions
+104 -1
View File
@@ -14,6 +14,42 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore"
)
// jitteredBatch represents a batch of alert instances with associated jitter delay
type jitteredBatch struct {
index int
instances []models.AlertInstance
delay time.Duration
}
// createJitteredBatches splits instances into batches and calculates jitter delays
func createJitteredBatches(instances []models.AlertInstance, batchSize int, jitterFunc func(int) time.Duration, logger log.Logger) []jitteredBatch {
if len(instances) == 0 {
return nil
}
var batches []jitteredBatch
totalInstances := len(instances)
for start := 0; start < totalInstances; start += batchSize {
end := start + batchSize
if end > totalInstances {
end = totalInstances
}
batchIndex := start / batchSize
batch := instances[start:end]
delay := jitterFunc(batchIndex)
batches = append(batches, jitteredBatch{
index: batchIndex,
instances: batch,
delay: delay,
})
}
return batches
}
type InstanceDBStore struct {
SQLStore db.DB
Logger log.Logger
@@ -211,7 +247,10 @@ func (st InstanceDBStore) DeleteAlertInstancesByRule(ctx context.Context, key mo
//
// The batchSize parameter controls how many instances are inserted per batch. Increasing batchSize can improve
// performance for large datasets, but can also increase load on the database.
func (st InstanceDBStore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int) error {
//
// If jitterFunc is provided, applies jitter delays between batches to distribute database load over time.
// If jitterFunc is nil, executes batches without delays for standard behavior.
func (st InstanceDBStore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int, jitterFunc func(int) time.Duration) error {
if len(instances) == 0 {
return nil
}
@@ -220,6 +259,12 @@ func (st InstanceDBStore) FullSync(ctx context.Context, instances []models.Alert
batchSize = 1
}
// If jitter is enabled, use the jittered approach
if jitterFunc != nil {
return st.fullSyncWithJitter(ctx, instances, batchSize, jitterFunc)
}
// Otherwise, use the standard approach without jitter
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
// First we delete all records from the table
if _, err := sess.Exec("DELETE FROM alert_instance"); err != nil {
@@ -240,6 +285,64 @@ func (st InstanceDBStore) FullSync(ctx context.Context, instances []models.Alert
}
}
return nil
})
}
// fullSyncWithJitter performs a full synchronization with jitter delays between batches.
//
// This method maintains atomicity by performing all operations within a single transaction,
// while distributing the INSERT operations over time to reduce database load spikes.
//
// The instances parameter should be a flat list of all alert instances.
// The jitterFunc should return the delay duration for a given batch index.
func (st InstanceDBStore) fullSyncWithJitter(ctx context.Context, instances []models.AlertInstance, batchSize int, jitterFunc func(int) time.Duration) error {
if len(instances) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 1
}
// Prepare all batches and sorting OUTSIDE the transaction
batches := createJitteredBatches(instances, batchSize, jitterFunc, st.Logger)
// Sort batches by delay time (ascending)
sort.Slice(batches, func(i, j int) bool {
return batches[i].delay < batches[j].delay
})
// Execute the optimized transaction with pre-calculated batches
return st.executeJitteredBatchesInTransaction(ctx, batches)
}
// executeJitteredBatchesInTransaction executes pre-calculated batches within a single transaction
// with jitter delays. All preparation work should be done before calling this method.
func (st InstanceDBStore) executeJitteredBatchesInTransaction(ctx context.Context, batches []jitteredBatch) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
// Capture the actual transaction start time for accurate delay calculations
transactionStartTime := time.Now()
// First we delete all records from the table
if _, err := sess.Exec("DELETE FROM alert_instance"); err != nil {
return fmt.Errorf("failed to delete alert_instance table: %w", err)
}
// Execute batches in order with absolute time-based delays using transaction start time
for _, batch := range batches {
// Calculate target time and wait until then
targetTime := transactionStartTime.Add(batch.delay)
if sleepDuration := time.Until(targetTime); sleepDuration > 0 {
time.Sleep(sleepDuration)
}
// Insert this batch
if err := st.insertInstancesBatch(sess, batch.instances); err != nil {
return fmt.Errorf("failed to insert batch %d [%d instances]: %w", batch.index, len(batch.instances), err)
}
}
if err := sess.Commit(); err != nil {
return fmt.Errorf("failed to commit alert_instance table: %w", err)
}