Skip to content

Commit d74d6c6

Browse files
PXB-3862 : Combine scattered changed-page reads in page-tracking backups
https://perconadev.atlassian.net/browse/PXB-3862 Problem ------- A page-tracking incremental reads only the pages the server marked as changed. Consecutive changed pages are already read together in one request; the problem is scattered changes. If the changed pages are 1, 3, 5, 7, 9 xtrabackup issues five single-page reads. Every read request costs a full I/O round trip, so with scattered changes the copy phase is bound by the number of requests instead of the amount of data, and an incremental can take several times longer than a full scan of the same file. Fix --- Read the whole range 1-9 in one request. The unchanged pages 2, 4, 6, 8 are read as filler and discarded by the existing incremental write filter, which drops every page whose FIL_PAGE_LSN is older than incremental_lsn. The backup's content and size are byte-for-byte unchanged; only the read pattern changes. A gap is worth combining across exactly when its bytes cost less than one read request. That cost, in bytes of sequential transfer, is round_trip * bandwidth - and no fixed number fits both a local NVMe and a network volume, so it is measured at backup start (probe_storage, xb_io_probe.h): twelve scattered single-page reads give the round trip, 16MB of sequential reads the bandwidth, on the largest changed data file (at least 64MB). Then read_request_cost = round_trip * bandwidth / 1.5, clamped to [64KB, 1MB], 512KB if unmeasurable max_gap = read_request_cost / physical_page_size The /1.5 margin absorbs copy-pipeline overhead and measurement noise, erring toward reading less; it was calibrated on two instrumented machines whose break-evens bound it from both sides. The per-tablespace conversion lets compressed tablespaces combine across the same byte cost. Filler bytes are always the actual gap sizes present in the data, never the limit, so a generous limit reads nothing extra. --page-tracking-max-gap exposes the behaviour: "auto" (default) as described; a page count pins one value for all tables and skips the probe; 0 keeps the previous strict-consecutive reads. Log messages ------------ Once per backup, the measurement: pagetracking: calibrated storage (./test/t1.ibd): request round trip 124 us, sequential read 984 MB/s -> one read request costs ~83KB of sequential transfer; gaps cheaper than this are combined Per table with at least 1000 changed pages, when its copy finishes, accumulated from what was actually read: pagetracking: test/t1.ibd: 3196 changed pages in 3196 ranges (avg gap 2.0 pages); max-gap=4 (auto) combined them into 2 reads: request reduction 1598.0x, read amplification 2.98x; issued 16 read batches "ranges" is the requests max-gap=0 would issue; "request reduction" is the benefit and "read amplification" its price (bytes read divided by changed bytes - read volume only, backup size is unaffected); "issued" exceeds the group count only when a group is larger than --read-buffer-size and is read in buffer-sized pieces. When the typical gap costs more than one read request, an extra line names both numbers and the pinned value to try, so a boundary case is diagnosable from the log alone: pagetracking: test/t1.ibd: typical gap 8.9 pages (143KB) costs more than one read request (83KB); reads stay individual - if sequential read throughput is high, --page-tracking-max-gap=9 may be faster Testing: unit tests (xb_page_group-t) cover the read request cost model across device classes and the storage probe's failure modes; a framework testcase sweeps change densities and asserts only hardware-independent invariants - ranges vs combined reads vs issued requests, auto vs strict, the cost floor and ceiling, and restore correctness - and was verified to fail against a build with the combining silently disabled.
1 parent 54be4a7 commit d74d6c6

12 files changed

Lines changed: 968 additions & 23 deletions

File tree

storage/innobase/xtrabackup/CMakeLists.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,25 @@ ENDIF()
4444

4545
ADD_CUSTOM_TARGET(link_test_dir ALL
4646
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/test" "${CMAKE_CURRENT_BINARY_DIR}/test")
47+
48+
# PXB-3862: the server's unittest tree is disabled in XtraBackup builds
49+
# (top-level gates on "WITH_UNIT_TESTS AND NOT WITH_XTRABACKUP"), so build
50+
# the page-grouping / storage-probe unit tests here, self-contained
51+
# against the bundled googletest. The tested code is header-only
52+
# (xb_page_group.h, xb_io_probe.h), so no server libraries are needed.
53+
IF(WITH_UNIT_TESTS)
54+
FILE(GLOB XB_GTEST_ROOT
55+
${CMAKE_SOURCE_DIR}/extra/googletest/googletest-release-*/googletest)
56+
ADD_EXECUTABLE(xb_page_group-t
57+
${CMAKE_SOURCE_DIR}/unittest/gunit/innodb/xb_page_group-t.cc
58+
${XB_GTEST_ROOT}/src/gtest-all.cc
59+
${XB_GTEST_ROOT}/src/gtest_main.cc)
60+
TARGET_INCLUDE_DIRECTORIES(xb_page_group-t PRIVATE
61+
${CMAKE_SOURCE_DIR} ${XB_GTEST_ROOT}/include ${XB_GTEST_ROOT})
62+
SET_TARGET_PROPERTIES(xb_page_group-t PROPERTIES
63+
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/runtime_output_directory)
64+
TARGET_COMPILE_OPTIONS(xb_page_group-t PRIVATE -Wno-undef)
65+
FIND_PACKAGE(Threads REQUIRED)
66+
TARGET_LINK_LIBRARIES(xb_page_group-t Threads::Threads)
67+
ADD_TEST(NAME xb_page_group COMMAND xb_page_group-t)
68+
ENDIF()

storage/innobase/xtrabackup/src/changed_page_tracking.cc

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,40 @@ bool is_component_installed(MYSQL *connection) {
268268
return mysql_component == 0 ? (false) : (true);
269269
}
270270

271-
void range_get_next_page(xb_page_set *page_set) {
271+
/* Move current_page_it from the first changed page of the current read
272+
range to its last: the caller then issues one pread covering the whole
273+
range (sliced into --read-buffer-size pieces when larger). A gap is
274+
the count of unchanged pages strictly BETWEEN two changed pages, not
275+
their id difference: between changed pages 1 and 4 the gap is 2
276+
(pages 2 and 3). Gaps <= max_gap are combined across, so nearby
277+
changed pages form one larger sequential read instead of one read
278+
request each; the gap pages become filler - read, then dropped by the
279+
incremental write filter's LSN check: extra read volume, never backup
280+
content. A gap > max_gap ends the range; runs of consecutive changed
281+
pages (gap 0) always stay whole.
282+
283+
changed pages [1,3,6,9] (gaps 1,2,2):
284+
max_gap=0: 4 reads: [1] [3] [6] [9]
285+
max_gap=2: 1 read: [1-9] (filler 2,4,5,7,8 read, dropped)
286+
287+
Counting: the loop inspects one neighbouring pair per iteration and
288+
refuses (breaks) BEFORE the counting code, so reaching that code means
289+
the pair's gap was just combined: its pages are added to filler_pages
290+
right there. A refused gap becomes the space between two ranges and is
291+
counted as skipped pages by the caller (rf_page_tracking_get_next_batch)
292+
instead - each gap lands in exactly one of the two. Worked trace on the
293+
shared example in read_filt.h (changed pages 1,2,3,4,7,20,21,
294+
max_gap=4): (4,7) gap 2 -> filler_pages += 2, combined_gaps = 1;
295+
(7,20) gap 12 -> park on 7, range [1-7] ends; the caller then counts
296+
20 - 8 = 12 skipped when the next call builds [20-21].
297+
298+
max_gap comes from --page-tracking-max-gap: by default ("auto") the
299+
storage's measured read request cost converted to pages of this
300+
tablespace's physical page size (see xb_io_probe.h). */
301+
void range_get_next_page(xb_page_set *page_set, ulint max_gap,
302+
ulint *filler_pages, ulint *combined_gaps) {
272303
ut_ad(page_set->current_page_it != page_set->pages.end());
273304

274-
/* loop to find the non continuous page id or end of block */
275305
while (true) {
276306
auto current_page = *page_set->current_page_it;
277307
page_set->current_page_it++;
@@ -280,18 +310,19 @@ void range_get_next_page(xb_page_set *page_set) {
280310
break;
281311
}
282312
auto next_page = *page_set->current_page_it;
283-
if (next_page != current_page + 1) {
284-
/* The next changed page is not adjacent to the current block. Step
285-
back so that the iterator points to the last page of the current
286-
contiguous block, as documented in the function comment. Leaving the
287-
iterator on the next (non-adjacent) changed page makes the caller
288-
extend the read batch up to that page, silently reading all the
289-
unmodified pages in between. For large tablespaces with sparsely
290-
distributed changed pages this degenerates into reading almost the
291-
entire data file, defeating the purpose of page tracking. (PXB-3853) */
313+
ut_ad(next_page > current_page);
314+
if (next_page > current_page + 1 + max_gap) {
315+
/* gap too large to combine across: park the iterator on the last
316+
page of the current range so the read batch ends there */
292317
--page_set->current_page_it;
293318
break;
294319
}
320+
if (next_page > current_page + 1) {
321+
/* past the refusal check, so this gap of >= 1 unchanged pages was
322+
just combined into the range: its pages are filler */
323+
*filler_pages += next_page - current_page - 1;
324+
++*combined_gaps;
325+
}
295326
}
296327
}
297328

storage/innobase/xtrabackup/src/changed_page_tracking.h

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,20 @@ void deinit(xb_space_map *space_map);
6363
return true if installed */
6464
bool is_component_installed(MYSQL *connection);
6565

66-
/** Move the current_page_it iterator to poin the last page id in current block
67-
@param[in/out] page_set page_set */
68-
void range_get_next_page(xb_page_set *page_set);
66+
/** Move the current_page_it iterator to point to the last page id of the
67+
current block. Changed pages separated by gaps of at most max_gap
68+
unchanged pages belong to the same block, so that they are read with one
69+
sequential read.
70+
@param[in/out] page_set page_set
71+
@param[in] max_gap largest gap that may be combined across,
72+
in pages of the tablespace's physical
73+
page size
74+
@param[in/out] filler_pages incremented by the unchanged pages inside
75+
every gap combined into this block
76+
@param[in/out] combined_gaps incremented by the number of gaps combined
77+
into this block */
78+
void range_get_next_page(xb_page_set *page_set, ulint max_gap,
79+
ulint *filler_pages, ulint *combined_gaps);
6980

7081
/** Set the backupid
7182
@param[in] connection MySQL connection handler

storage/innobase/xtrabackup/src/fil_cur.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ void xb_fil_cur_close(
430430
xb_fil_cur_t *cursor) /*!< in/out: source file cursor */
431431
{
432432
if (cursor->read_filter) {
433-
cursor->read_filter->deinit(&cursor->read_filter_ctxt);
433+
cursor->read_filter->deinit(cursor);
434434
}
435435

436436
ut::free(cursor->scratch);

storage/innobase/xtrabackup/src/read_filt.cc

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
2323
/* Data file read filter implementation */
2424

2525
#include "read_filt.h"
26+
27+
#include <algorithm>
28+
#include <iomanip>
2629
#include "common.h"
2730
#include "dict0dict.h"
2831
#include "fil_cur.h"
2932
#include "xb0xb.h"
33+
#include "xb_io_probe.h"
3034
#include "xtrabackup.h"
3135

3236
/****************************************************************/ /**
@@ -42,6 +46,14 @@ static void common_init(
4246
ctxt->buffer_capacity = cursor->buf_size;
4347
ctxt->page_size = cursor->page_size;
4448
ctxt->space_id = cursor->space_id;
49+
ctxt->max_gap = 0;
50+
ctxt->stat_batches = 0;
51+
ctxt->stat_total_changed_pages = 0;
52+
ctxt->stat_groups = 0;
53+
ctxt->stat_combined_gaps = 0;
54+
ctxt->stat_filler_pages = 0;
55+
ctxt->stat_skipped_pages = 0;
56+
ctxt->log_stats = false;
4557
}
4658

4759
/****************************************************************/ /**
@@ -99,8 +111,8 @@ static void rf_pass_through_get_next_batch(
99111
Deinitialize the pass-through read filter. */
100112
static void rf_pass_through_deinit(
101113
/*===================*/
102-
xb_read_filt_ctxt_t *ctxt __attribute__((unused)))
103-
/*!<in: read filter context */
114+
xb_fil_cur_t *cursor __attribute__((unused)))
115+
/*!<in: file cursor being closed */
104116
{}
105117

106118
/** Initialize the page tracking based read filter. Assumes that
@@ -112,6 +124,37 @@ static void rf_page_tracking_init(xb_read_filt_ctxt_t *ctxt,
112124
const xb_fil_cur_t *cursor, ulint space_id) {
113125
common_init(ctxt, cursor);
114126
ctxt->filter_batch_end = 0;
127+
128+
/* Full-scan spaces never consult max_gap; spaces without changed pages
129+
are not read at all. */
130+
if (space_id == dict_sys_t::s_dict_space_id ||
131+
full_scan_tables.find(space_id) != full_scan_tables.end() ||
132+
changed_page_tracking == nullptr ||
133+
changed_page_tracking->count(space_id) == 0) {
134+
return;
135+
}
136+
137+
/* A pinned --page-tracking-max-gap is expressed in innodb_page_size
138+
pages; scale it by the physical page size so compressed tablespaces
139+
(zip size 1K-8K) combine across the same byte limit instead of a
140+
proportionally smaller one. In auto mode (the default) the limit is
141+
the read request cost - measured single-threaded at backup start, or
142+
the conservative fallback - converted to pages of this tablespace's
143+
physical page size: combine every gap cheaper than one saved read. */
144+
ut_ad(UNIV_PAGE_SIZE % ctxt->page_size == 0);
145+
ctxt->max_gap = static_cast<ulint>(
146+
opt_page_tracking_max_gap_auto ? xb_read_request_cost / ctxt->page_size
147+
: uint64_t{opt_page_tracking_max_gap} *
148+
(UNIV_PAGE_SIZE / ctxt->page_size));
149+
150+
/* grouping statistics accumulate while the file is read and are
151+
logged when it closes; below ~16MB of changed pages even fully
152+
scattered reads cost a fraction of a second, so such tables only
153+
add noise */
154+
constexpr ulint LOG_MIN_CHANGED_PAGES = 1000;
155+
ctxt->stat_total_changed_pages =
156+
changed_page_tracking->at(space_id).pages.size();
157+
ctxt->log_stats = (ctxt->stat_total_changed_pages >= LOG_MIN_CHANGED_PAGES);
115158
}
116159

117160
/** Get the next batch of pages for the page tracking based filter.
@@ -210,9 +253,26 @@ static void rf_page_tracking_get_next_batch(xb_fil_cur_t *cursor,
210253
verify_skipped_pages();
211254
#endif
212255

256+
/* stats: one more read range. The gap between the previous
257+
range's end and this one (if any) is exactly a gap the walker
258+
refused (that refusal is what ended the previous range), so its
259+
pages were seeked past, never read: skipped. Gaps the walker
260+
combines lie inside a range and are counted as filler by the
261+
walker itself - each gap lands in exactly one of the two. */
262+
ctxt->stat_groups++;
263+
if (ctxt->filter_batch_end != 0) {
264+
ctxt->stat_skipped_pages += next_page_id - ctxt->filter_batch_end;
265+
}
266+
213267
ctxt->offset = next_page_id * ctxt->page_size;
214268
/* Find the end of the current page tracking block */
215-
pagetracking::range_get_next_page(space);
269+
{
270+
ulint filler = 0, combined = 0;
271+
pagetracking::range_get_next_page(space, ctxt->max_gap, &filler,
272+
&combined);
273+
ctxt->stat_filler_pages += filler;
274+
ctxt->stat_combined_gaps += combined;
275+
}
216276
ut_ad(space->current_page_it != space->pages.end());
217277

218278
ctxt->filter_batch_end = (*space->current_page_it) + 1;
@@ -236,15 +296,72 @@ static void rf_page_tracking_get_next_batch(xb_fil_cur_t *cursor,
236296
*read_batch_len = ctxt->buffer_capacity;
237297
}
238298

299+
if (*read_batch_len > 0) {
300+
ctxt->stat_batches++;
301+
}
302+
239303
ut_ad(ctxt->offset % ctxt->page_size == 0);
240304
ut_ad(*read_batch_start % ctxt->page_size == 0);
241305
ut_ad(*read_batch_len % ctxt->page_size == 0);
242306
}
243307

244-
/** Deinitialize the page tracking based read filter.
245-
@param[in] ctxt read filtr context */
246-
static void rf_page_tracking_deinit(xb_read_filt_ctxt_t *ctxt
247-
__attribute__((unused))) {}
308+
/** Deinitialize the page tracking based read filter: log the grouping
309+
statistics accumulated while the file was read. Everything reported
310+
here describes what actually happened - no prediction. */
311+
static void rf_page_tracking_deinit(xb_fil_cur_t *cursor) {
312+
const xb_read_filt_ctxt_t *ctxt = &cursor->read_filter_ctxt;
313+
if (!ctxt->log_stats || ctxt->stat_groups == 0) {
314+
return;
315+
}
316+
317+
/* ranges = runs of consecutive changed pages: every combined gap
318+
joined two of them into one read */
319+
const ulint ranges = ctxt->stat_groups + ctxt->stat_combined_gaps;
320+
const ulint boundaries = ranges - 1;
321+
const double avg_gap = boundaries == 0
322+
? 0.0
323+
: static_cast<double>(ctxt->stat_filler_pages +
324+
ctxt->stat_skipped_pages) /
325+
static_cast<double>(boundaries);
326+
/* the benefit and its price, as parallel ratios: how many times fewer
327+
read requests, bought at how many times the changed read volume */
328+
const double reduction =
329+
static_cast<double>(ranges) / static_cast<double>(ctxt->stat_groups);
330+
const double amplification =
331+
static_cast<double>(ctxt->stat_total_changed_pages +
332+
ctxt->stat_filler_pages) /
333+
static_cast<double>(ctxt->stat_total_changed_pages);
334+
xb::info() << std::fixed << "pagetracking: " << cursor->rel_path << ": "
335+
<< ctxt->stat_total_changed_pages << " changed pages in " << ranges
336+
<< " ranges (avg gap " << std::setprecision(1) << avg_gap
337+
<< " pages); max-gap=" << ctxt->max_gap
338+
<< (opt_page_tracking_max_gap_auto ? " (auto)" : "")
339+
<< " combined them into " << ctxt->stat_groups
340+
<< " reads: request reduction " << reduction
341+
<< "x, read amplification " << std::setprecision(2)
342+
<< amplification << "x; issued " << ctxt->stat_batches
343+
<< " read batches";
344+
345+
/* Make an ineffective auto decision self-explanatory: when the
346+
typical gap costs more than one read request, combining achieves
347+
little and the reads stay individual. Only when the gap is within
348+
reach of a plausible cost: past the clamp ceiling no calibration
349+
could ever combine across it - that is genuinely sparse data. */
350+
const uint64_t avg_gap_bytes =
351+
static_cast<uint64_t>(avg_gap * static_cast<double>(ctxt->page_size));
352+
if (opt_page_tracking_max_gap_auto && reduction < 1.5 &&
353+
avg_gap_bytes > xb_read_request_cost &&
354+
avg_gap_bytes <= pagetracking::READ_REQUEST_COST_MAX_BYTES) {
355+
xb::info() << std::fixed << std::setprecision(1)
356+
<< "pagetracking: " << cursor->rel_path << ": typical gap "
357+
<< avg_gap << " pages (" << avg_gap_bytes / 1024
358+
<< "KB) costs more than one read request ("
359+
<< xb_read_request_cost / 1024
360+
<< "KB); reads stay individual - if sequential read "
361+
"throughput is high, --page-tracking-max-gap="
362+
<< static_cast<uint64_t>(avg_gap + 1.0) << " may be faster";
363+
}
364+
}
248365

249366
/* The pass-through read filter */
250367
xb_read_filt_t rf_pass_through = {&rf_pass_through_init,

storage/innobase/xtrabackup/src/read_filt.h

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,65 @@ struct xb_read_filt_ctxt_t {
3939
ulint filter_batch_end; /*!< the ending page id of the
4040
current changed page block in
4141
the page tracking */
42+
ulint max_gap; /*!< largest changed-page gap to
43+
combine into one read, in pages of
44+
page_size (--page-tracking-max-gap
45+
scaled for compressed tablespaces,
46+
or the read request cost in
47+
pages) */
48+
/* Statistics accumulated while the file is read, reported by the
49+
filter's deinit in one log line; they never influence any decision.
50+
51+
Shared example used in the field comments below: changed pages
52+
53+
1,2,3,4 7 20,21 (3 runs of consecutive pages)
54+
55+
with max_gap = 4. A gap counts the unchanged pages BETWEEN two
56+
changed pages (between 4 and 7 it is 2: pages 5,6 - not the id
57+
difference 3). The gap of 2 (pages 5,6) is combined across, so
58+
[1-7] becomes one read group; the gap of 12 (pages 8..19) is not,
59+
so [20-21] starts a second group. Every unchanged page between two
60+
changed pages ends up in exactly one of stat_filler_pages (its gap
61+
was combined: the page was read) or stat_skipped_pages (its gap was
62+
refused: the page was seeked past). */
63+
ulint stat_batches; /*!< pread requests actually issued.
64+
Example: 2 - one per group; exceeds
65+
stat_groups only when a group is
66+
larger than --read-buffer-size and is
67+
read in buffer-sized sequential
68+
pieces */
69+
ulint stat_total_changed_pages; /*!< pages the server tracked as
70+
changed for this file: the work that
71+
must be copied regardless of
72+
grouping. Example: 7
73+
(1,2,3,4,7,20,21) */
74+
ulint stat_groups; /*!< read groups formed = distinct
75+
disk locations read = seeks.
76+
Example: 2 ([1-7] and [20-21]). The
77+
log line's "ranges" = stat_groups +
78+
stat_combined_gaps is what max-gap=0
79+
would have read: here 3, so combining
80+
saved one request */
81+
ulint stat_combined_gaps; /*!< gaps of unchanged pages combined
82+
across (gap <= max_gap), each joining
83+
two ranges into one read. Example: 1
84+
(the 2-page gap between 4 and 7) */
85+
ulint stat_filler_pages; /*!< unchanged pages read only as
86+
filler inside combined gaps, then
87+
dropped by the incremental write
88+
filter's LSN check - they cost read
89+
volume only, never backup size.
90+
Example: 2 (pages 5,6). read
91+
amplification = (total_changed +
92+
filler) / total_changed */
93+
ulint stat_skipped_pages; /*!< unchanged pages in refused gaps
94+
(gap > max_gap): never read, seeked
95+
past. Example: 12 (pages 8..19).
96+
avg gap = (filler + skipped) /
97+
(ranges - 1) describes how scattered
98+
the changes are */
99+
bool log_stats; /*!< log grouping and batch stats
100+
for this file */
42101
};
43102

44103
/* The read filter */
@@ -47,7 +106,9 @@ struct xb_read_filt_t {
47106
ulint space_id);
48107
void (*get_next_batch)(xb_fil_cur_t *ctxt, uint64_t *read_batch_start,
49108
uint64_t *read_batch_len);
50-
void (*deinit)(xb_read_filt_ctxt_t *ctxt);
109+
/* called from xb_fil_cur_close with the owning cursor, so the
110+
filter can report per-file statistics with full cursor context */
111+
void (*deinit)(xb_fil_cur_t *cursor);
51112
void (*update)(xb_read_filt_ctxt_t *ctxt, uint64_t len,
52113
const xb_fil_cur_t *cursor);
53114
};

0 commit comments

Comments
 (0)