diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index c84f1660f931b..4b26504b76356 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -162,37 +162,46 @@ function get_option( $option, $default_value = false ) { if ( ! wp_installing() ) { $alloptions = wp_load_alloptions(); - + /* + * When getting an option value, we check in the following order for performance: + * + * 1. Check the 'alloptions' cache first to prioritize existing loaded options. + * 2. Check the 'notoptions' cache before a cache lookup or DB hit. + * 3. Check the 'options' cache prior to a DB hit. + * 4. Check the DB for the option and cache it in either the 'options' or 'notoptions' cache. + */ if ( isset( $alloptions[ $option ] ) ) { $value = $alloptions[ $option ]; } else { + // Check for non-existent options first to avoid unnecessary object cache lookups and DB hits. + $notoptions = wp_cache_get( 'notoptions', 'options' ); + + if ( ! is_array( $notoptions ) ) { + $notoptions = array(); + wp_cache_set( 'notoptions', $notoptions, 'options' ); + } + + if ( isset( $notoptions[ $option ] ) ) { + /** + * Filters the default value for an option. + * + * The dynamic portion of the hook name, `$option`, refers to the option name. + * + * @since 3.4.0 + * @since 4.4.0 The `$option` parameter was added. + * @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value. + * + * @param mixed $default_value The default value to return if the option does not exist + * in the database. + * @param string $option Option name. + * @param bool $passed_default Was `get_option()` passed a default value? + */ + return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default ); + } + $value = wp_cache_get( $option, 'options' ); if ( false === $value ) { - // Prevent non-existent options from triggering multiple queries. - $notoptions = wp_cache_get( 'notoptions', 'options' ); - - // Prevent non-existent `notoptions` key from triggering multiple key lookups. - if ( ! is_array( $notoptions ) ) { - $notoptions = array(); - wp_cache_set( 'notoptions', $notoptions, 'options' ); - } elseif ( isset( $notoptions[ $option ] ) ) { - /** - * Filters the default value for an option. - * - * The dynamic portion of the hook name, `$option`, refers to the option name. - * - * @since 3.4.0 - * @since 4.4.0 The `$option` parameter was added. - * @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value. - * - * @param mixed $default_value The default value to return if the option does not exist - * in the database. - * @param string $option Option name. - * @param bool $passed_default Was `get_option()` passed a default value? - */ - return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default ); - } $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", $option ) ); diff --git a/tests/phpunit/tests/option/option.php b/tests/phpunit/tests/option/option.php index 36a40d9a2f495..a3d3684d74568 100644 --- a/tests/phpunit/tests/option/option.php +++ b/tests/phpunit/tests/option/option.php @@ -548,4 +548,86 @@ public function test_add_option_clears_the_notoptions_cache() { $updated_notoptions = wp_cache_get( 'notoptions', 'options' ); $this->assertArrayNotHasKey( $option_name, $updated_notoptions, 'The "foobar" option should not be in the notoptions cache after adding it.' ); } + + /** + * Test that get_option() does not hit the external cache multiple times for the same option. + * + * @ticket 62692 + * + * @dataProvider data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option + * + * @param int $expected_connections Expected number of connections to the memcached server. + * @param bool $option_exists Whether the option should be set. Default true. + * @param string $autoload Whether the option should be auto loaded. Default true. + */ + public function test_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option( $expected_connections, $option_exists = true, $autoload = true ) { + if ( ! wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires an external object cache.' ); + } + + if ( false === $this->helper_object_cache_stats_cmd_get() ) { + $this->markTestSkipped( 'This test requires access to the number of get requests to the external object cache.' ); + } + + if ( $option_exists ) { + add_option( 'ticket-62692', 'value', '', $autoload ); + } + + wp_cache_delete_multiple( array( 'ticket-62692', 'notoptions', 'alloptions' ), 'options' ); + + $connections_start = $this->helper_object_cache_stats_cmd_get(); + + $call_getter = 10; + while ( $call_getter-- ) { + get_option( 'ticket-62692' ); + } + + $connections_end = $this->helper_object_cache_stats_cmd_get(); + + $this->assertSame( $expected_connections, $connections_end - $connections_start ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option() { + return array( + 'exists, autoload' => array( 1, true, true ), + 'exists, not autoloaded' => array( 3, true, false ), + 'does not exist' => array( 3, false ), + ); + } + + /** + * Helper function to get the number of get commands from the external object cache. + * + * @return int|false Number of get command calls, false if unavailable. + */ + public function helper_object_cache_stats_cmd_get() { + if ( ! wp_using_ext_object_cache() || ! function_exists( 'wp_cache_get_stats' ) ) { + return false; + } + + $stats = wp_cache_get_stats(); + + // Check the shape of the stats. + if ( ! is_array( $stats ) ) { + return false; + } + + // Get the first server's stats. + $stats = array_shift( $stats ); + + if ( ! is_array( $stats ) ) { + return false; + } + + if ( ! array_key_exists( 'cmd_get', $stats ) ) { + return false; + } + + return $stats['cmd_get']; + } }