Make WordPress Core

Changeset 59631


Ignore:
Timestamp:
01/15/2025 10:11:15 PM (4 weeks ago)
Author:
peterwilsoncc
Message:

Options/Meta APIs: Optimize cache hits for non-existent options.

Optimize the order of checking the various options caches in get_option() to prevent hitting external caches each time it is called for a known non-existent option.

The caches are checked in the following order when getting an option:

  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.

Follow up to [56595].

Props adamsilverstein, flixos90, ivankristianto, joemcgill, rmccue, siliconforks, spacedmonkey.
Fixes #62692.
See #58277.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/option.php

    r59373 r59631  
    163163    if ( ! wp_installing() ) {
    164164        $alloptions = wp_load_alloptions();
    165 
     165        /*
     166         * When getting an option value, we check in the following order for performance:
     167         *
     168         * 1. Check the 'alloptions' cache first to prioritize existing loaded options.
     169         * 2. Check the 'notoptions' cache before a cache lookup or DB hit.
     170         * 3. Check the 'options' cache prior to a DB hit.
     171         * 4. Check the DB for the option and cache it in either the 'options' or 'notoptions' cache.
     172         */
    166173        if ( isset( $alloptions[ $option ] ) ) {
    167174            $value = $alloptions[ $option ];
    168175        } else {
     176            // Check for non-existent options first to avoid unnecessary object cache lookups and DB hits.
     177            $notoptions = wp_cache_get( 'notoptions', 'options' );
     178
     179            if ( ! is_array( $notoptions ) ) {
     180                $notoptions = array();
     181                wp_cache_set( 'notoptions', $notoptions, 'options' );
     182            }
     183
     184            if ( isset( $notoptions[ $option ] ) ) {
     185                /**
     186                 * Filters the default value for an option.
     187                 *
     188                 * The dynamic portion of the hook name, `$option`, refers to the option name.
     189                 *
     190                 * @since 3.4.0
     191                 * @since 4.4.0 The `$option` parameter was added.
     192                 * @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value.
     193                 *
     194                 * @param mixed  $default_value  The default value to return if the option does not exist
     195                 *                               in the database.
     196                 * @param string $option         Option name.
     197                 * @param bool   $passed_default Was `get_option()` passed a default value?
     198                 */
     199                return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default );
     200            }
     201
    169202            $value = wp_cache_get( $option, 'options' );
    170203
    171204            if ( false === $value ) {
    172                 // Prevent non-existent options from triggering multiple queries.
    173                 $notoptions = wp_cache_get( 'notoptions', 'options' );
    174 
    175                 // Prevent non-existent `notoptions` key from triggering multiple key lookups.
    176                 if ( ! is_array( $notoptions ) ) {
    177                     $notoptions = array();
    178                     wp_cache_set( 'notoptions', $notoptions, 'options' );
    179                 } elseif ( isset( $notoptions[ $option ] ) ) {
    180                     /**
    181                      * Filters the default value for an option.
    182                      *
    183                      * The dynamic portion of the hook name, `$option`, refers to the option name.
    184                      *
    185                      * @since 3.4.0
    186                      * @since 4.4.0 The `$option` parameter was added.
    187                      * @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value.
    188                      *
    189                      * @param mixed  $default_value  The default value to return if the option does not exist
    190                      *                               in the database.
    191                      * @param string $option         Option name.
    192                      * @param bool   $passed_default Was `get_option()` passed a default value?
    193                      */
    194                     return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default );
    195                 }
    196205
    197206                $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", $option ) );
  • trunk/tests/phpunit/tests/option/option.php

    r58945 r59631  
    113113
    114114        $before = get_num_queries();
    115         $value  = get_option( 'invalid' );
    116         $after  = get_num_queries();
     115        get_option( 'invalid' );
     116        $after = get_num_queries();
    117117
    118118        $this->assertSame( 0, $after - $before );
     
    128128
    129129        $before = get_num_queries();
    130         $value  = get_option( 'invalid' );
    131         $after  = get_num_queries();
     130        get_option( 'invalid' );
     131        $after = get_num_queries();
    132132
    133133        $notoptions = wp_cache_get( 'notoptions', 'options' );
     
    136136        $this->assertIsArray( $notoptions, 'The notoptions cache should be set.' );
    137137        $this->assertArrayHasKey( 'invalid', $notoptions, 'The "invalid" option should be in the notoptions cache.' );
    138     }
    139 
    140     /**
    141      * @ticket 58277
    142      *
    143      * @covers ::get_option
    144      */
    145     public function test_get_option_notoptions_do_not_load_cache() {
    146         add_option( 'foo', 'bar', '', false );
    147         wp_cache_delete( 'notoptions', 'options' );
    148 
    149         $before = get_num_queries();
    150         $value  = get_option( 'foo' );
    151         $after  = get_num_queries();
    152 
    153         $notoptions = wp_cache_get( 'notoptions', 'options' );
    154 
    155         $this->assertSame( 0, $after - $before, 'The options cache was not hit on the second call to `get_option()`.' );
    156         $this->assertFalse( $notoptions, 'The notoptions cache should not be set.' );
    157138    }
    158139
     
    549530        $this->assertArrayNotHasKey( $option_name, $updated_notoptions, 'The "foobar" option should not be in the notoptions cache after adding it.' );
    550531    }
     532
     533    /**
     534     * Test that get_option() does not hit the external cache multiple times for the same option.
     535     *
     536     * @ticket 62692
     537     *
     538     * @covers ::get_option
     539     *
     540     * @dataProvider data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option
     541     *
     542     * @param int    $expected_connections Expected number of connections to the memcached server.
     543     * @param bool   $option_exists        Whether the option should be set. Default true.
     544     * @param string $autoload             Whether the option should be auto loaded. Default true.
     545     */
     546    public function test_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option( $expected_connections, $option_exists = true, $autoload = true ) {
     547        if ( ! wp_using_ext_object_cache() ) {
     548            $this->markTestSkipped( 'This test requires an external object cache.' );
     549        }
     550
     551        if ( false === $this->helper_object_cache_stats_cmd_get() ) {
     552            $this->markTestSkipped( 'This test requires access to the number of get requests to the external object cache.' );
     553        }
     554
     555        if ( $option_exists ) {
     556            add_option( 'ticket-62692', 'value', '', $autoload );
     557        }
     558
     559        wp_cache_delete_multiple( array( 'ticket-62692', 'notoptions', 'alloptions' ), 'options' );
     560
     561        $connections_start = $this->helper_object_cache_stats_cmd_get();
     562
     563        $call_getter = 10;
     564        while ( $call_getter-- ) {
     565            get_option( 'ticket-62692' );
     566        }
     567
     568        $connections_end = $this->helper_object_cache_stats_cmd_get();
     569
     570        $this->assertSame( $expected_connections, $connections_end - $connections_start );
     571    }
     572
     573    /**
     574     * Data provider.
     575     *
     576     * @return array[]
     577     */
     578    public function data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option() {
     579        return array(
     580            'exists, autoload'       => array( 1, true, true ),
     581            'exists, not autoloaded' => array( 3, true, false ),
     582            'does not exist'         => array( 3, false ),
     583        );
     584    }
     585
     586    /**
     587     * Helper function to get the number of get commands from the external object cache.
     588     *
     589     * @return int|false Number of get command calls, false if unavailable.
     590     */
     591    public function helper_object_cache_stats_cmd_get() {
     592        if ( ! wp_using_ext_object_cache() || ! function_exists( 'wp_cache_get_stats' ) ) {
     593            return false;
     594        }
     595
     596        $stats = wp_cache_get_stats();
     597
     598        // Check the shape of the stats.
     599        if ( ! is_array( $stats ) ) {
     600            return false;
     601        }
     602
     603        // Get the first server's stats.
     604        $stats = array_shift( $stats );
     605
     606        if ( ! is_array( $stats ) ) {
     607            return false;
     608        }
     609
     610        if ( ! array_key_exists( 'cmd_get', $stats ) ) {
     611            return false;
     612        }
     613
     614        return $stats['cmd_get'];
     615    }
    551616}
Note: See TracChangeset for help on using the changeset viewer.