diff --git a/core/modules/help_topics/src/Plugin/Search/HelpSearch.php b/core/modules/help_topics/src/Plugin/Search/HelpSearch.php
index fc5daa2e8830a2902eb4cf96d699bfea5740dc18..dfd781735ae3ecda296ee0cd2473ebdd461ec400 100644
--- a/core/modules/help_topics/src/Plugin/Search/HelpSearch.php
+++ b/core/modules/help_topics/src/Plugin/Search/HelpSearch.php
@@ -18,6 +18,7 @@
 use Drupal\help_topics\SearchableHelpInterface;
 use Drupal\search\Plugin\SearchIndexingInterface;
 use Drupal\search\Plugin\SearchPluginBase;
+use Drupal\search\SearchIndexInterface;
 use Drupal\search\SearchQuery;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -91,6 +92,13 @@ class HelpSearch extends SearchPluginBase implements AccessibleInterface, Search
    */
   protected $helpSectionManager;
 
+  /**
+   * The search index.
+   *
+   * @var \Drupal\search\SearchIndexInterface
+   */
+  protected $searchIndex;
+
   /**
    * {@inheritdoc}
    */
@@ -105,7 +113,8 @@ public static function create(ContainerInterface $container, array $configuratio
       $container->get('messenger'),
       $container->get('current_user'),
       $container->get('state'),
-      $container->get('plugin.manager.help_section')
+      $container->get('plugin.manager.help_section'),
+      $container->get('search.index')
     );
   }
 
@@ -132,8 +141,10 @@ public static function create(ContainerInterface $container, array $configuratio
    *   The state object.
    * @param \Drupal\help\HelpSectionManager $help_section_manager
    *   The help section manager.
+   * @param \Drupal\search\SearchIndexInterface $search_index
+   *   The search index.
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, Config $search_settings, LanguageManagerInterface $language_manager, MessengerInterface $messenger, AccountInterface $account, StateInterface $state, HelpSectionManager $help_section_manager) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, Config $search_settings, LanguageManagerInterface $language_manager, MessengerInterface $messenger, AccountInterface $account, StateInterface $state, HelpSectionManager $help_section_manager, SearchIndexInterface $search_index) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->database = $database;
     $this->searchSettings = $search_settings;
@@ -142,6 +153,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     $this->account = $account;
     $this->state = $state;
     $this->helpSectionManager = $help_section_manager;
+    $this->searchIndex = $search_index;
   }
 
   /**
@@ -327,35 +339,41 @@ public function updateIndex() {
     $language_list = $this->languageManager->getLanguages(LanguageInterface::STATE_CONFIGURABLE);
     $section_plugins = [];
 
-    foreach ($items as $item) {
-      $section_plugin_id = $item->section_plugin_id;
-      if (!isset($section_plugins[$section_plugin_id])) {
-        $section_plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id);
-      }
+    $words = [];
+    try {
+      foreach ($items as $item) {
+        $section_plugin_id = $item->section_plugin_id;
+        if (!isset($section_plugins[$section_plugin_id])) {
+          $section_plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id);
+        }
 
-      if (!$section_plugins[$section_plugin_id]) {
-        $this->removeItemsFromIndex($item->sid);
-        continue;
-      }
+        if (!$section_plugins[$section_plugin_id]) {
+          $this->removeItemsFromIndex($item->sid);
+          continue;
+        }
 
-      $section_plugin = $section_plugins[$section_plugin_id];
-      search_index_clear($this->getType(), $item->sid);
-      foreach ($language_list as $langcode => $language) {
-        $topic = $section_plugin->renderTopicForSearch($item->topic_id, $language);
-        if ($topic) {
-          // Index the title plus body text.
-          $text = '<h1>' . $topic['title'] . '</h1>' . "\n" . $topic['text'];
-          search_index($this->getType(), $item->sid, $langcode, $text);
+        $section_plugin = $section_plugins[$section_plugin_id];
+        $this->searchIndex->clear($this->getType(), $item->sid);
+        foreach ($language_list as $langcode => $language) {
+          $topic = $section_plugin->renderTopicForSearch($item->topic_id, $language);
+          if ($topic) {
+            // Index the title plus body text.
+            $text = '<h1>' . $topic['title'] . '</h1>' . "\n" . $topic['text'];
+            $words += $this->searchIndex->index($this->getType(), $item->sid, $langcode, $text);
+          }
         }
       }
     }
+    finally {
+      $this->searchIndex->updateWordWeights($words);
+    }
   }
 
   /**
    * {@inheritdoc}
    */
   public function indexClear() {
-    search_index_clear($this->getType());
+    $this->searchIndex->clear($this->getType());
   }
 
   /**
@@ -419,7 +437,7 @@ public function updateTopicList() {
    */
   public function markForReindex() {
     $this->updateTopicList();
-    search_mark_for_reindex($this->getType());
+    $this->searchIndex->markForReindex($this->getType());
   }
 
   /**
@@ -466,7 +484,7 @@ protected function removeItemsFromIndex($sids) {
     // Remove items from the search tables individually, as there is no bulk
     // function to delete items from the search index.
     foreach ($sids as $sid) {
-      search_index_clear($this->getType(), $sid);
+      $this->searchIndex->clear($this->getType(), $sid);
     }
   }
 
diff --git a/core/modules/node/node.module b/core/modules/node/node.module
index d6e7f2e25cc117387004f6de0b9d923895abc956..9a8d8c999cf66be86da9f7e9241e8854a6f18604 100644
--- a/core/modules/node/node.module
+++ b/core/modules/node/node.module
@@ -1421,7 +1421,7 @@ function node_configurable_language_delete(ConfigurableLanguageInterface $langua
 function node_reindex_node_search($nid) {
   if (\Drupal::moduleHandler()->moduleExists('search')) {
     // Reindex node context indexed by the node module search plugin.
-    search_mark_for_reindex('node_search', $nid);
+    \Drupal::service('search.index')->markForReindex('node_search', $nid);
   }
 }
 
diff --git a/core/modules/node/src/Entity/Node.php b/core/modules/node/src/Entity/Node.php
index d6c64e2ea3bc360da779de30877435a85ad3da86..fb54e1fb8dbc359378d23a40c010b64a3f1bc744 100644
--- a/core/modules/node/src/Entity/Node.php
+++ b/core/modules/node/src/Entity/Node.php
@@ -163,9 +163,11 @@ public static function preDelete(EntityStorageInterface $storage, array $entitie
     parent::preDelete($storage, $entities);
 
     // Ensure that all nodes deleted are removed from the search index.
-    if (\Drupal::moduleHandler()->moduleExists('search')) {
+    if (\Drupal::hasService('search.index')) {
+      /** @var \Drupal\search\SearchIndexInterface $search_index */
+      $search_index = \Drupal::service('search.index');
       foreach ($entities as $entity) {
-        search_index_clear('node_search', $entity->nid->value);
+        $search_index->clear('node_search', $entity->nid->value);
       }
     }
   }
diff --git a/core/modules/node/src/Plugin/Search/NodeSearch.php b/core/modules/node/src/Plugin/Search/NodeSearch.php
index 777d2b8b7a4650903f1afdb0c63ae3cc66429bc5..af4b109d1ddb80fb857b0a9af8f086dd49ca12b0 100644
--- a/core/modules/node/src/Plugin/Search/NodeSearch.php
+++ b/core/modules/node/src/Plugin/Search/NodeSearch.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\node\Plugin\Search;
 
+use Drupal\Core\Access\AccessibleInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Config\Config;
 use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Query\Condition;
 use Drupal\Core\Database\Query\SelectExtender;
 use Drupal\Core\Database\StatementInterface;
 use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
@@ -15,14 +17,13 @@
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Language\LanguageManagerInterface;
 use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Core\Session\AccountInterface;
-use Drupal\Core\Access\AccessibleInterface;
-use Drupal\Core\Database\Query\Condition;
-use Drupal\Core\Render\RendererInterface;
 use Drupal\node\NodeInterface;
 use Drupal\search\Plugin\ConfigurableSearchPluginBase;
 use Drupal\search\Plugin\SearchIndexingInterface;
+use Drupal\search\SearchIndexInterface;
 use Drupal\Search\SearchQuery;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -98,6 +99,13 @@ class NodeSearch extends ConfigurableSearchPluginBase implements AccessibleInter
    */
   protected $renderer;
 
+  /**
+   * The search index.
+   *
+   * @var \Drupal\search\SearchIndexInterface
+   */
+  protected $searchIndex;
+
   /**
    * An array of additional rankings from hook_ranking().
    *
@@ -153,7 +161,8 @@ public static function create(ContainerInterface $container, array $configuratio
       $container->get('renderer'),
       $container->get('messenger'),
       $container->get('current_user'),
-      $container->get('database.replica')
+      $container->get('database.replica'),
+      $container->get('search.index')
     );
   }
 
@@ -184,8 +193,10 @@ public static function create(ContainerInterface $container, array $configuratio
    *   The $account object to use for checking for access to advanced search.
    * @param \Drupal\Core\Database\Connection|null $database_replica
    *   (Optional) the replica database connection.
+   * @param \Drupal\search\SearchIndexInterface $search_index
+   *   The search index.
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, Config $search_settings, LanguageManagerInterface $language_manager, RendererInterface $renderer, MessengerInterface $messenger, AccountInterface $account = NULL, Connection $database_replica = NULL) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, Config $search_settings, LanguageManagerInterface $language_manager, RendererInterface $renderer, MessengerInterface $messenger, AccountInterface $account = NULL, Connection $database_replica = NULL, SearchIndexInterface $search_index = NULL) {
     $this->database = $database;
     $this->databaseReplica = $database_replica ?: $database;
     $this->entityTypeManager = $entity_type_manager;
@@ -198,6 +209,11 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     parent::__construct($configuration, $plugin_id, $plugin_definition);
 
     $this->addCacheTags(['node_list']);
+    if (!$search_index) {
+      @trigger_error('Calling NodeSearch::__construct() without the $search_index argument is deprecated in drupal:8.8.0 and is required in drupal:9.0.0. See https://www.drupal.org/node/3075696', E_USER_DEPRECATED);
+      $search_index = \Drupal::service('search.index');
+    }
+    $this->searchIndex = $search_index;
   }
 
   /**
@@ -478,8 +494,14 @@ public function updateIndex() {
     }
 
     $node_storage = $this->entityTypeManager->getStorage('node');
-    foreach ($node_storage->loadMultiple($nids) as $node) {
-      $this->indexNode($node);
+    $words = [];
+    try {
+      foreach ($node_storage->loadMultiple($nids) as $node) {
+        $this->indexNode($node, $words);
+      }
+    }
+    finally {
+      $this->searchIndex->updateWordWeights($words);
     }
   }
 
@@ -488,8 +510,10 @@ public function updateIndex() {
    *
    * @param \Drupal\node\NodeInterface $node
    *   The node to index.
+   * @param array $words
+   *   Words that need updating after the index run.
    */
-  protected function indexNode(NodeInterface $node) {
+  protected function indexNode(NodeInterface $node, array &$words) {
     $languages = $node->getTranslationLanguages();
     $node_render = $this->entityTypeManager->getViewBuilder('node');
 
@@ -516,7 +540,7 @@ protected function indexNode(NodeInterface $node) {
       }
 
       // Update index, using search index "type" equal to the plugin ID.
-      search_index($this->getPluginId(), $node->id(), $language->getId(), $text);
+      $words += $this->searchIndex->index($this->getPluginId(), $node->id(), $language->getId(), $text);
     }
   }
 
@@ -526,7 +550,7 @@ protected function indexNode(NodeInterface $node) {
   public function indexClear() {
     // All NodeSearch pages share a common search index "type" equal to
     // the plugin ID.
-    search_index_clear($this->getPluginId());
+    $this->searchIndex->clear($this->getPluginId());
   }
 
   /**
@@ -535,7 +559,7 @@ public function indexClear() {
   public function markForReindex() {
     // All NodeSearch pages share a common search index "type" equal to
     // the plugin ID.
-    search_mark_for_reindex($this->getPluginId());
+    $this->searchIndex->markForReindex($this->getPluginId());
   }
 
   /**
diff --git a/core/modules/search/search.module b/core/modules/search/search.module
index 5818f2e80b9b626c06074cc66a416491e35206e6..af1d827b2983ec5df28c3a28d7923cc3d712372f 100644
--- a/core/modules/search/search.module
+++ b/core/modules/search/search.module
@@ -5,13 +5,11 @@
  * Enables site-wide keyword searching.
  */
 
-use Drupal\Core\Url;
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\Unicode;
-use Drupal\Core\Cache\Cache;
-use Drupal\Core\Database\Query\Condition;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Url;
 
 /**
  * Matches all 'N' Unicode character classes (numbers)
@@ -140,35 +138,15 @@ function search_preprocess_block(&$variables) {
  * @param string|null $langcode
  *   (optional) Language code of the item to remove from the search index. If
  *   omitted, all items matching $sid and $type are cleared.
+ *
+ * @deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use
+ *   \Drupal\search\SearchIndex::clear() instead.
+ *
+ * @see https://www.drupal.org/node/3075696
  */
 function search_index_clear($type = NULL, $sid = NULL, $langcode = NULL) {
-  $connection = \Drupal::database();
-  $query_index = $connection->delete('search_index');
-  $query_dataset = $connection->delete('search_dataset');
-  if ($type) {
-    $query_index->condition('type', $type);
-    $query_dataset->condition('type', $type);
-    if ($sid) {
-      $query_index->condition('sid', $sid);
-      $query_dataset->condition('sid', $sid);
-      if ($langcode) {
-        $query_index->condition('langcode', $langcode);
-        $query_dataset->condition('langcode', $langcode);
-      }
-    }
-  }
-
-  $query_index->execute();
-  $query_dataset->execute();
-
-  if ($type) {
-    // Invalidate all render cache items that contain data from this index.
-    Cache::invalidateTags(['search_index:' . $type]);
-  }
-  else {
-    // Invalidate all render cache items that contain data from any index.
-    Cache::invalidateTags(['search_index']);
-  }
+  @trigger_error("search_index_clear() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::clear() instead. See https://www.drupal.org/node/3075696", E_USER_DEPRECATED);
+  \Drupal::service('search.index')->clear($type, $sid, $langcode);
 }
 
 /**
@@ -176,14 +154,17 @@ function search_index_clear($type = NULL, $sid = NULL, $langcode = NULL) {
  *
  * This is used during indexing (cron). Words that are dirty have outdated
  * total counts in the search_total table, and need to be recounted.
+ *
+ * @deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom
+ *   implementation of \Drupal\search\SearchIndexInterface instead.
+ *
+ * @see https://www.drupal.org/node/3075696
  */
 function search_dirty($word = NULL) {
-  $dirty = &drupal_static(__FUNCTION__, []);
-  if ($word !== NULL) {
-    $dirty[$word] = TRUE;
-  }
-  else {
-    return $dirty;
+  @trigger_error("search_dirty() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom implementation of \Drupal\search\SearchIndexInterface instead. See https://www.drupal.org/node/3075696", E_USER_DEPRECATED);
+  // Keep return result type for backward compatibility.
+  if ($word === NULL) {
+    return [];
   }
 }
 
@@ -192,14 +173,8 @@ function search_dirty($word = NULL) {
  *
  * Fires updateIndex() in the plugins for all indexable active search pages,
  * and cleans up dirty words.
- *
- * @see search_dirty()
  */
 function search_cron() {
-  // We register a shutdown function to ensure that search_total is always up
-  // to date.
-  drupal_register_shutdown_function('search_update_totals');
-
   /** @var $search_page_repository \Drupal\search\SearchPageRepositoryInterface */
   $search_page_repository = \Drupal::service('search.search_page_repository');
   foreach ($search_page_repository->getIndexableSearchPages() as $entity) {
@@ -212,34 +187,14 @@ function search_cron() {
  *
  * This function is called on shutdown to ensure that {search_total} is always
  * up to date (even if cron times out or otherwise fails).
+ *
+ * @deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom
+ *   implementation of \Drupal\search\SearchIndexInterface instead.
+ *
+ * @see https://www.drupal.org/node/3075696
  */
 function search_update_totals() {
-  $connection = \Drupal::database();
-  $replica = \Drupal::service('database.replica');
-  // Update word IDF (Inverse Document Frequency) counts for new/changed words.
-  foreach (search_dirty() as $word => $dummy) {
-    // Get total count
-    $total = $replica->query("SELECT SUM(score) FROM {search_index} WHERE word = :word", [':word' => $word])->fetchField();
-    // Apply Zipf's law to equalize the probability distribution.
-    $total = log10(1 + 1 / (max(1, $total)));
-    $connection->merge('search_total')
-      ->key('word', $word)
-      ->fields(['count' => $total])
-      ->execute();
-  }
-  // Find words that were deleted from search_index, but are still in
-  // search_total. We use a LEFT JOIN between the two tables and keep only the
-  // rows which fail to join.
-  $result = $replica->query("SELECT t.word AS realword, i.word FROM {search_total} t LEFT JOIN {search_index} i ON t.word = i.word WHERE i.word IS NULL");
-  $or = new Condition('OR');
-  foreach ($result as $word) {
-    $or->condition('word', $word->realword);
-  }
-  if (count($or) > 0) {
-    $connection->delete('search_total')
-      ->condition($or)
-      ->execute();
-  }
+  @trigger_error("search_update_totals() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom implementation of \Drupal\search\SearchIndexInterface instead. See https://www.drupal.org/node/3075696", E_USER_DEPRECATED);
 }
 
 /**
@@ -439,137 +394,15 @@ function search_invoke_preprocess(&$text, $langcode = NULL) {
  *   The content of this item. Must be a piece of HTML or plain text.
  *
  * @ingroup search
+ *
+ * @deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use
+ *   \Drupal\search\SearchIndex::index() instead.
+ *
+ * @see https://www.drupal.org/node/3075696
  */
 function search_index($type, $sid, $langcode, $text) {
-  $minimum_word_size = \Drupal::config('search.settings')->get('index.minimum_word_size');
-
-  // Multipliers for scores of words inside certain HTML tags. The weights are
-  // stored in config so that modules can overwrite the default weights.
-  // Note: 'a' must be included for link ranking to work.
-  $tags = \Drupal::config('search.settings')->get('index.tag_weights');
-
-  // Strip off all ignored tags to speed up processing, but insert space before
-  // and after them to keep word boundaries.
-  $text = str_replace(['<', '>'], [' <', '> '], $text);
-  $text = strip_tags($text, '<' . implode('><', array_keys($tags)) . '>');
-
-  // Split HTML tags from plain text.
-  $split = preg_split('/\s*<([^>]+?)>\s*/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-  // Note: PHP ensures the array consists of alternating delimiters and literals
-  // and begins and ends with a literal (inserting $null as required).
-
-  // Odd/even counter. Tag or no tag.
-  $tag = FALSE;
-  // Starting score per word.
-  $score = 1;
-  // Accumulator for cleaned up data.
-  $accum = ' ';
-  // Stack with open tags.
-  $tagstack = [];
-  // Counter for consecutive words.
-  $tagwords = 0;
-  // Focus state.
-  $focus = 1;
-
-  // Accumulator for words for index.
-  $scored_words = [];
-
-  foreach ($split as $value) {
-    if ($tag) {
-      // Increase or decrease score per word based on tag
-      list($tagname) = explode(' ', $value, 2);
-      $tagname = mb_strtolower($tagname);
-      // Closing or opening tag?
-      if ($tagname[0] == '/') {
-        $tagname = substr($tagname, 1);
-        // If we encounter unexpected tags, reset score to avoid incorrect boosting.
-        if (!count($tagstack) || $tagstack[0] != $tagname) {
-          $tagstack = [];
-          $score = 1;
-        }
-        else {
-          // Remove from tag stack and decrement score
-          $score = max(1, $score - $tags[array_shift($tagstack)]);
-        }
-      }
-      else {
-        if (isset($tagstack[0]) && $tagstack[0] == $tagname) {
-          // None of the tags we look for make sense when nested identically.
-          // If they are, it's probably broken HTML.
-          $tagstack = [];
-          $score = 1;
-        }
-        else {
-          // Add to open tag stack and increment score
-          array_unshift($tagstack, $tagname);
-          $score += $tags[$tagname];
-        }
-      }
-      // A tag change occurred, reset counter.
-      $tagwords = 0;
-    }
-    else {
-      // Note: use of PREG_SPLIT_DELIM_CAPTURE above will introduce empty values
-      if ($value != '') {
-        $words = search_index_split($value, $langcode);
-        foreach ($words as $word) {
-          // Add word to accumulator
-          $accum .= $word . ' ';
-          // Check word length.
-          if (is_numeric($word) || mb_strlen($word) >= $minimum_word_size) {
-            if (!isset($scored_words[$word])) {
-              $scored_words[$word] = 0;
-            }
-            $scored_words[$word] += $score * $focus;
-            // Focus is a decaying value in terms of the amount of unique words up to this point.
-            // From 100 words and more, it decays, to e.g. 0.5 at 500 words and 0.3 at 1000 words.
-            $focus = min(1, .01 + 3.5 / (2 + count($scored_words) * .015));
-          }
-          $tagwords++;
-          // Too many words inside a single tag probably mean a tag was accidentally left open.
-          if (count($tagstack) && $tagwords >= 15) {
-            $tagstack = [];
-            $score = 1;
-          }
-        }
-      }
-    }
-    $tag = !$tag;
-  }
-
-  // Remove the item $sid from the search index, and invalidate the relevant
-  // cache tags.
-  search_index_clear($type, $sid, $langcode);
-
-  $connection = \Drupal::database();
-  // Insert cleaned up data into dataset
-  $connection->insert('search_dataset')
-    ->fields([
-      'sid' => $sid,
-      'langcode' => $langcode,
-      'type' => $type,
-      'data' => $accum,
-      'reindex' => 0,
-    ])
-    ->execute();
-
-  // Insert results into search index
-  foreach ($scored_words as $word => $score) {
-    // If a word already exists in the database, its score gets increased
-    // appropriately. If not, we create a new record with the appropriate
-    // starting score.
-    $connection->merge('search_index')
-      ->keys([
-        'word' => $word,
-        'sid' => $sid,
-        'langcode' => $langcode,
-        'type' => $type,
-      ])
-      ->fields(['score' => $score])
-      ->expression('score', 'score + :score', [':score' => $score])
-      ->execute();
-    search_dirty($word);
-  }
+  @trigger_error("search_index() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::index() instead. See https://www.drupal.org/node/3075696", E_USER_DEPRECATED);
+  \Drupal::service('search.index')->index($type, $sid, $langcode, $text);
 }
 
 /**
@@ -589,25 +422,15 @@ function search_index($type, $sid, $langcode, $text) {
  * @param string $langcode
  *   (optional) The language code to clear. If omitted, everything matching
  *   $type and $sid is marked.
+ *
+ * @deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use
+ *   \Drupal\search\SearchIndex::markForReindex() instead.
+ *
+ * @see https://www.drupal.org/node/3075696
  */
 function search_mark_for_reindex($type = NULL, $sid = NULL, $langcode = NULL) {
-  $query = \Drupal::database()->update('search_dataset')
-    ->fields(['reindex' => REQUEST_TIME])
-    // Only mark items that were not previously marked for reindex, so that
-    // marked items maintain their priority by request time.
-    ->condition('reindex', 0);
-
-  if ($type) {
-    $query->condition('type', $type);
-    if ($sid) {
-      $query->condition('sid', $sid);
-      if ($langcode) {
-        $query->condition('langcode', $langcode);
-      }
-    }
-  }
-
-  $query->execute();
+  @trigger_error("search_mark_for_reindex() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::markForReindex() instead. See https://www.drupal.org/node/3075696", E_USER_DEPRECATED);
+  \Drupal::service('search.index')->markForReindex($type, $sid, $langcode);
 }
 
 /**
diff --git a/core/modules/search/search.services.yml b/core/modules/search/search.services.yml
index 80dfd8fcea19f3d1d645879b98b6137763acebfc..49ec2acb7bdd6f9755efdce735406edfd9312c32 100644
--- a/core/modules/search/search.services.yml
+++ b/core/modules/search/search.services.yml
@@ -6,3 +6,7 @@ services:
   search.search_page_repository:
     class: Drupal\search\SearchPageRepository
     arguments: ['@config.factory', '@entity_type.manager']
+
+  search.index:
+    class: Drupal\search\SearchIndex
+    arguments: ['@config.factory', '@database','@database.replica', '@cache_tags.invalidator']
diff --git a/core/modules/search/src/Exception/SearchIndexException.php b/core/modules/search/src/Exception/SearchIndexException.php
new file mode 100644
index 0000000000000000000000000000000000000000..35d9ae9bfedeac2868bb0a1c3cd6445511d7ebe4
--- /dev/null
+++ b/core/modules/search/src/Exception/SearchIndexException.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Drupal\search\Exception;
+
+/**
+ * Exception thrown for search index errors.
+ */
+class SearchIndexException extends \RuntimeException {}
diff --git a/core/modules/search/src/Plugin/SearchIndexingInterface.php b/core/modules/search/src/Plugin/SearchIndexingInterface.php
index b35bdbf02cab48faee7c66caa707849ec187bafd..7b92f39c7dc5e218f9643ae3a39c0ce16d61e7eb 100644
--- a/core/modules/search/src/Plugin/SearchIndexingInterface.php
+++ b/core/modules/search/src/Plugin/SearchIndexingInterface.php
@@ -30,8 +30,8 @@ interface SearchIndexingInterface {
    * This method is called every cron run if the plugin has been set as
    * an active search module on the Search settings page
    * (admin/config/search/pages). It allows your module to add items to the
-   * built-in search index using search_index(), or to add them to your module's
-   * own indexing mechanism.
+   * built-in search index by calling the index() method on the search.index
+   * service class, or to add them to your module's own indexing mechanism.
    *
    * When implementing this method, your module should index content items that
    * were modified or added since the last run. There is a time limit for cron,
@@ -49,10 +49,10 @@ public function updateIndex();
    *
    * When a request is made to clear all items from the search index related to
    * this plugin, this method will be called. If this plugin uses the default
-   * search index, this method can call search_index_clear($type) to remove
-   * indexed items from the search database.
+   * search index, this method can call clear($type) method on the search.index
+   * service class to remove indexed items from the search database.
    *
-   * @see search_index_clear()
+   * @see \Drupal\search\SearchIndexInterface::clear()
    */
   public function indexClear();
 
@@ -61,11 +61,11 @@ public function indexClear();
    *
    * When a request is made to mark all items from the search index related to
    * this plugin for reindexing, this method will be called. If this plugin uses
-   * the default search index, this method can call
-   * search_mark_for_reindex($type) to mark the items in the search database for
-   * reindexing.
+   * the default search index, this method can call markForReindex($type) method
+   * on the search.index service class to mark the items in the search database
+   * for reindexing.
    *
-   * @see search_mark_for_reindex()
+   * @see \Drupal\search\SearchIndexInterface::markForReindex()
    */
   public function markForReindex();
 
diff --git a/core/modules/search/src/Plugin/SearchInterface.php b/core/modules/search/src/Plugin/SearchInterface.php
index b1421ff0c3f750f6782aa739f4117c934c4670d3..09d1fff41d1a2eae74fbac3c3a3fae3b513cda9f 100644
--- a/core/modules/search/src/Plugin/SearchInterface.php
+++ b/core/modules/search/src/Plugin/SearchInterface.php
@@ -66,8 +66,8 @@ public function isSearchExecutable();
    *   The type used by this search plugin in the search index, or NULL if this
    *   plugin does not use the search index.
    *
-   * @see search_index()
-   * @see search_index_clear()
+   * @see \Drupal\search\SearchIndexInterface::index()
+   * @see \Drupal\search\SearchIndexInterface::clear()
    */
   public function getType();
 
diff --git a/core/modules/search/src/SearchIndex.php b/core/modules/search/src/SearchIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..e5655d4974189473883a7e900d2ad6c15f66551b
--- /dev/null
+++ b/core/modules/search/src/SearchIndex.php
@@ -0,0 +1,317 @@
+<?php
+
+namespace Drupal\search;
+
+use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Query\Condition;
+use Drupal\search\Exception\SearchIndexException;
+
+/**
+ * Provides search index management functions.
+ */
+class SearchIndex implements SearchIndexInterface {
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * The database replica connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $replica;
+
+  /**
+   * The cache tags invalidator.
+   *
+   * @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
+   */
+  protected $cacheTagsInvalidator;
+
+  /**
+   * SearchIndex constructor.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection.
+   * @param \Drupal\Core\Database\Connection $replica
+   *   The database replica connection.
+   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
+   *   The cache tags invalidator.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, Connection $connection, Connection $replica, CacheTagsInvalidatorInterface $cache_tags_invalidator) {
+    $this->configFactory = $config_factory;
+    $this->connection = $connection;
+    $this->replica = $replica;
+    $this->cacheTagsInvalidator = $cache_tags_invalidator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function index($type, $sid, $langcode, $text, $update_weights = TRUE) {
+    $settings = $this->configFactory->get('search.settings');
+    $minimum_word_size = $settings->get('index.minimum_word_size');
+
+    // Keep track of the words that need to have their weights updated.
+    $current_words = [];
+
+    // Multipliers for scores of words inside certain HTML tags. The weights are
+    // stored in config so that modules can overwrite the default weights.
+    // Note: 'a' must be included for link ranking to work.
+    $tags = $settings->get('index.tag_weights');
+
+    // Strip off all ignored tags to speed up processing, but insert space
+    // before and after them to keep word boundaries.
+    $text = str_replace(['<', '>'], [' <', '> '], $text);
+    $text = strip_tags($text, '<' . implode('><', array_keys($tags)) . '>');
+
+    // Split HTML tags from plain text.
+    $split = preg_split('/\s*<([^>]+?)>\s*/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+    // Note: PHP ensures the array consists of alternating delimiters and
+    // literals and begins and ends with a literal (inserting $null as
+    // required).
+    // Odd/even counter. Tag or no tag.
+    $tag = FALSE;
+    // Starting score per word.
+    $score = 1;
+    // Accumulator for cleaned up data.
+    $accum = ' ';
+    // Stack with open tags.
+    $tagstack = [];
+    // Counter for consecutive words.
+    $tagwords = 0;
+    // Focus state.
+    $focus = 1;
+
+    // Accumulator for words for index.
+    $scored_words = [];
+
+    foreach ($split as $value) {
+      if ($tag) {
+        // Increase or decrease score per word based on tag.
+        list($tagname) = explode(' ', $value, 2);
+        $tagname = mb_strtolower($tagname);
+        // Closing or opening tag?
+        if ($tagname[0] == '/') {
+          $tagname = substr($tagname, 1);
+          // If we encounter unexpected tags, reset score to avoid incorrect
+          // boosting.
+          if (!count($tagstack) || $tagstack[0] != $tagname) {
+            $tagstack = [];
+            $score = 1;
+          }
+          else {
+            // Remove from tag stack and decrement score.
+            $score = max(1, $score - $tags[array_shift($tagstack)]);
+          }
+        }
+        else {
+          if (isset($tagstack[0]) && $tagstack[0] == $tagname) {
+            // None of the tags we look for make sense when nested identically.
+            // If they are, it's probably broken HTML.
+            $tagstack = [];
+            $score = 1;
+          }
+          else {
+            // Add to open tag stack and increment score.
+            array_unshift($tagstack, $tagname);
+            $score += $tags[$tagname];
+          }
+        }
+        // A tag change occurred, reset counter.
+        $tagwords = 0;
+      }
+      else {
+        // Note: use of PREG_SPLIT_DELIM_CAPTURE above will introduce empty
+        // values.
+        if ($value != '') {
+          $words = search_index_split($value, $langcode);
+          foreach ($words as $word) {
+            // Add word to accumulator.
+            $accum .= $word . ' ';
+            // Check word length.
+            if (is_numeric($word) || mb_strlen($word) >= $minimum_word_size) {
+              if (!isset($scored_words[$word])) {
+                $scored_words[$word] = 0;
+              }
+              $scored_words[$word] += $score * $focus;
+              // Focus is a decaying value in terms of the amount of unique
+              // words up to this point. From 100 words and more, it decays, to
+              // e.g. 0.5 at 500 words and 0.3 at 1000 words.
+              $focus = min(1, .01 + 3.5 / (2 + count($scored_words) * .015));
+            }
+            $tagwords++;
+            // Too many words inside a single tag probably mean a tag was
+            // accidentally left open.
+            if (count($tagstack) && $tagwords >= 15) {
+              $tagstack = [];
+              $score = 1;
+            }
+          }
+        }
+      }
+      $tag = !$tag;
+    }
+
+    // Remove the item $sid from the search index, and invalidate the relevant
+    // cache tags.
+    $this->clear($type, $sid, $langcode);
+
+    try {
+      // Insert cleaned up data into dataset.
+      $this->connection->insert('search_dataset')
+        ->fields([
+          'sid' => $sid,
+          'langcode' => $langcode,
+          'type' => $type,
+          'data' => $accum,
+          'reindex' => 0,
+        ])
+        ->execute();
+
+      // Insert results into search index.
+      foreach ($scored_words as $word => $score) {
+        // If a word already exists in the database, its score gets increased
+        // appropriately. If not, we create a new record with the appropriate
+        // starting score.
+        $this->connection->merge('search_index')
+          ->keys([
+            'word' => $word,
+            'sid' => $sid,
+            'langcode' => $langcode,
+            'type' => $type,
+          ])
+          ->fields(['score' => $score])
+          ->expression('score', 'score + :score', [':score' => $score])
+          ->execute();
+        $current_words[$word] = TRUE;
+      }
+    }
+    catch (\Exception $e) {
+      throw new SearchIndexException("Failed to insert dataset in index for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
+    }
+    finally {
+      if ($update_weights) {
+        $this->updateWordWeights($current_words);
+      }
+    }
+    return $current_words;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clear($type = NULL, $sid = NULL, $langcode = NULL) {
+
+    try {
+      $query_index = $this->connection->delete('search_index');
+      $query_dataset = $this->connection->delete('search_dataset');
+      if ($type) {
+        $query_index->condition('type', $type);
+        $query_dataset->condition('type', $type);
+        if ($sid) {
+          $query_index->condition('sid', $sid);
+          $query_dataset->condition('sid', $sid);
+          if ($langcode) {
+            $query_index->condition('langcode', $langcode);
+            $query_dataset->condition('langcode', $langcode);
+          }
+        }
+      }
+      $query_index->execute();
+      $query_dataset->execute();
+    }
+    catch (\Exception $e) {
+      throw new SearchIndexException("Failed to clear index for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
+    }
+    if ($type) {
+      // Invalidate all render cache items that contain data from this index.
+      $this->cacheTagsInvalidator->invalidateTags(['search_index:' . $type]);
+    }
+    else {
+      // Invalidate all render cache items that contain data from any index.
+      $this->cacheTagsInvalidator->invalidateTags(['search_index']);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function markForReindex($type = NULL, $sid = NULL, $langcode = NULL) {
+
+    try {
+      $query = $this->connection->update('search_dataset')
+        ->fields(['reindex' => REQUEST_TIME])
+        // Only mark items that were not previously marked for reindex, so that
+        // marked items maintain their priority by request time.
+        ->condition('reindex', 0);
+      if ($type) {
+        $query->condition('type', $type);
+        if ($sid) {
+          $query->condition('sid', $sid);
+          if ($langcode) {
+            $query->condition('langcode', $langcode);
+          }
+        }
+      }
+      $query->execute();
+    }
+    catch (\Exception $e) {
+      throw new SearchIndexException("Failed to mark index for re-indexing for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function updateWordWeights(array $words) {
+    try {
+      // Update word IDF (Inverse Document Frequency) counts for new/changed
+      // words.
+      $words = array_keys($words);
+      foreach ($words as $word) {
+        // Get total count.
+        $total = $this->replica->query("SELECT SUM(score) FROM {search_index} WHERE word = :word", [':word' => $word])
+          ->fetchField();
+        // Apply Zipf's law to equalize the probability distribution.
+        $total = log10(1 + 1 / (max(1, $total)));
+        $this->connection->merge('search_total')
+          ->key('word', $word)
+          ->fields(['count' => $total])
+          ->execute();
+      }
+      // Find words that were deleted from search_index, but are still in
+      // search_total. We use a LEFT JOIN between the two tables and keep only
+      // the rows which fail to join.
+      $result = $this->replica->query("SELECT t.word AS realword, i.word FROM {search_total} t LEFT JOIN {search_index} i ON t.word = i.word WHERE i.word IS NULL");
+      $or = new Condition('OR');
+      foreach ($result as $word) {
+        $or->condition('word', $word->realword);
+      }
+      if (count($or) > 0) {
+        $this->connection->delete('search_total')
+          ->condition($or)
+          ->execute();
+      }
+    }
+    catch (\Exception $e) {
+      throw new SearchIndexException("Failed to update totals for index words.", 0, $e);
+    }
+  }
+
+}
diff --git a/core/modules/search/src/SearchIndexInterface.php b/core/modules/search/src/SearchIndexInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..6b28d2e1c0eae1bdb26cdd772ae211d5e5b8304e
--- /dev/null
+++ b/core/modules/search/src/SearchIndexInterface.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Drupal\search;
+
+/**
+ * Provides search index management functions.
+ *
+ * @ingroup search
+ */
+interface SearchIndexInterface {
+
+  /**
+   * Updates the full-text search index for a particular item.
+   *
+   * @param string $type
+   *   The plugin ID or other machine-readable type of this item,
+   *   which should be less than 64 bytes.
+   * @param int $sid
+   *   An ID number identifying this particular item (e.g., node ID).
+   * @param string $langcode
+   *   Language code for the language of the text being indexed.
+   * @param string $text
+   *   The content of this item. Must be a piece of HTML or plain text.
+   * @param bool $update_weights
+   *   (optional) TRUE if word weights should be updated. FALSE otherwise.
+   *
+   * @return string[]
+   *   The words to be updated.
+   *
+   * @throws \Drupal\search\Exception\SearchIndexException
+   *   If there is an error indexing the text.
+   */
+  public function index($type, $sid, $langcode, $text, $update_weights = TRUE);
+
+  /**
+   * Clears either a part of, or the entire search index.
+   *
+   * This function is meant for use by search page plugins, or for building a
+   * user interface that lets users clear all or parts of the search index.
+   *
+   * @param string|null $type
+   *   (optional) The plugin ID or other machine-readable type for the items to
+   *   remove from the search index. If omitted, $sid and $langcode are ignored
+   *   and the entire search index is cleared.
+   * @param int|array|null $sid
+   *   (optional) The ID or array of IDs of the items to remove from the search
+   *   index. If omitted, all items matching $type are cleared, and $langcode
+   *   is ignored.
+   * @param string|null $langcode
+   *   (optional) Language code of the item to remove from the search index. If
+   *   omitted, all items matching $sid and $type are cleared.
+   *
+   * @throws \Drupal\search\Exception\SearchIndexException
+   *   If there is an error clearing the index.
+   */
+  public function clear($type = NULL, $sid = NULL, $langcode = NULL);
+
+  /**
+   * Changes the timestamp on indexed items to 'now' to force reindexing.
+   *
+   * This function is meant for use by search page plugins, or for building a
+   * user interface that lets users mark all or parts of the search index for
+   * reindexing.
+   *
+   * @param string $type
+   *   (optional) The plugin ID or other machine-readable type of this item. If
+   *   omitted, the entire search index is marked for reindexing, and $sid and
+   *   $langcode are ignored.
+   * @param int $sid
+   *   (optional) An ID number identifying this particular item (e.g., node ID).
+   *   If omitted, everything matching $type is marked, and $langcode is
+   *   ignored.
+   * @param string $langcode
+   *   (optional) The language code to mark. If omitted, everything matching
+   *   $type and $sid is marked.
+   *
+   * @throws \Drupal\search\Exception\SearchIndexException
+   *   If there is an error marking the index for re-indexing.
+   */
+  public function markForReindex($type = NULL, $sid = NULL, $langcode = NULL);
+
+  /**
+   * Updates the {search_total} database table.
+   *
+   * @param array $words
+   *   An array whose keys are words from self::index() whose total weights
+   *   need to be updated.
+   *
+   * @throws \Drupal\search\Exception\SearchIndexException
+   *   If there is an error updating the totals.
+   */
+  public function updateWordWeights(array $words);
+
+}
diff --git a/core/modules/search/src/SearchPageListBuilder.php b/core/modules/search/src/SearchPageListBuilder.php
index 95e82ee68dbec4439f059ef55d8c390baf06bda0..c0c0258f2c8203e7b5c5fc83a00051c595d30ea4 100644
--- a/core/modules/search/src/SearchPageListBuilder.php
+++ b/core/modules/search/src/SearchPageListBuilder.php
@@ -43,6 +43,13 @@ class SearchPageListBuilder extends DraggableListBuilder implements FormInterfac
    */
   protected $searchManager;
 
+  /**
+   * The search index.
+   *
+   * @var \Drupal\search\SearchIndexInterface
+   */
+  protected $searchIndex;
+
   /**
    * The messenger.
    *
@@ -63,12 +70,19 @@ class SearchPageListBuilder extends DraggableListBuilder implements FormInterfac
    *   The factory for configuration objects.
    * @param \Drupal\Core\Messenger\MessengerInterface $messenger
    *   The messenger.
+   * @param \Drupal\search\SearchIndexInterface $search_index
+   *   The search index.
    */
-  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, SearchPluginManager $search_manager, ConfigFactoryInterface $config_factory, MessengerInterface $messenger) {
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, SearchPluginManager $search_manager, ConfigFactoryInterface $config_factory, MessengerInterface $messenger, SearchIndexInterface $search_index = NULL) {
     parent::__construct($entity_type, $storage);
     $this->configFactory = $config_factory;
     $this->searchManager = $search_manager;
     $this->messenger = $messenger;
+    if (!$search_index) {
+      @trigger_error('Calling SearchPageListBuilder::__construct() without the $search_index argument is deprecated in drupal:8.8.0 and is required in drupal:9.0.0. See https://www.drupal.org/node/3075696', E_USER_DEPRECATED);
+      $search_index = \Drupal::service('search.index');
+    }
+    $this->searchIndex = $search_index;
   }
 
   /**
@@ -80,7 +94,8 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
       $container->get('entity_type.manager')->getStorage($entity_type->id()),
       $container->get('plugin.manager.search'),
       $container->get('config.factory'),
-      $container->get('messenger')
+      $container->get('messenger'),
+      $container->get('search.index')
     );
   }
 
@@ -344,9 +359,9 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       $search_settings->set('index.minimum_word_size', $form_state->getValue('minimum_word_size'));
       $search_settings->set('index.overlap_cjk', $form_state->getValue('overlap_cjk'));
       // Specifically mark items in the default index for reindexing, since
-      // these settings are used in the search_index() function.
+      // these settings are used in the SearchIndex::index() function.
       $this->messenger->addStatus($this->t('The default search index will be rebuilt.'));
-      search_mark_for_reindex();
+      $this->searchIndex->markForReindex();
     }
 
     $search_settings
diff --git a/core/modules/search/tests/src/Functional/SearchAdvancedSearchFormTest.php b/core/modules/search/tests/src/Functional/SearchAdvancedSearchFormTest.php
index 03f0ff75cc6e22affb14379c30539d7a33493aad..7933dfd9b96572d31062c46c042c940e816530a3 100644
--- a/core/modules/search/tests/src/Functional/SearchAdvancedSearchFormTest.php
+++ b/core/modules/search/tests/src/Functional/SearchAdvancedSearchFormTest.php
@@ -38,11 +38,6 @@ protected function setUp() {
 
     // First update the index. This does the initial processing.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-
-    // Then, run the shutdown function. Testing is a unique case where indexing
-    // and searching has to happen in the same request, so running the shutdown
-    // function manually is needed to finish the indexing process.
-    search_update_totals();
   }
 
   /**
diff --git a/core/modules/search/tests/src/Functional/SearchCommentCountToggleTest.php b/core/modules/search/tests/src/Functional/SearchCommentCountToggleTest.php
index 2d7811576507c613286a5e3b9779ed5a5cd4cbf1..43f2a311343d9be773b56cff02c7b50ab2fd4d4d 100644
--- a/core/modules/search/tests/src/Functional/SearchCommentCountToggleTest.php
+++ b/core/modules/search/tests/src/Functional/SearchCommentCountToggleTest.php
@@ -70,11 +70,6 @@ protected function setUp() {
 
     // First update the index. This does the initial processing.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-
-    // Then, run the shutdown function. Testing is a unique case where indexing
-    // and searching has to happen in the same request, so running the shutdown
-    // function manually is needed to finish the indexing process.
-    search_update_totals();
   }
 
   /**
diff --git a/core/modules/search/tests/src/Functional/SearchCommentTest.php b/core/modules/search/tests/src/Functional/SearchCommentTest.php
index 8be5cd8e4a47beebb0b357397758d690dab9716c..3c57db0d06c5f3e43df10e687ae114b9dec56a1e 100644
--- a/core/modules/search/tests/src/Functional/SearchCommentTest.php
+++ b/core/modules/search/tests/src/Functional/SearchCommentTest.php
@@ -294,7 +294,7 @@ public function setRolePermissions($rid, $access_comments = FALSE, $search_conte
    */
   public function assertCommentAccess($assume_access, $message) {
     // Invoke search index update.
-    search_mark_for_reindex('node_search', $this->node->id());
+    \Drupal::service('search.index')->markForReindex('node_search', $this->node->id());
     $this->cronRun();
 
     // Search for the comment subject.
diff --git a/core/modules/search/tests/src/Functional/SearchConfigSettingsFormTest.php b/core/modules/search/tests/src/Functional/SearchConfigSettingsFormTest.php
index ad25e63f76268b60cc33422fdd82ca7a5d19428c..f0c9422d6a80ddd978395fe80823b85252a86fcf 100644
--- a/core/modules/search/tests/src/Functional/SearchConfigSettingsFormTest.php
+++ b/core/modules/search/tests/src/Functional/SearchConfigSettingsFormTest.php
@@ -53,7 +53,6 @@ protected function setUp() {
     $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, t('Save'));
 
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Enable the search block.
     $this->drupalPlaceBlock('search_form_block');
diff --git a/core/modules/search/tests/src/Functional/SearchDateIntervalTest.php b/core/modules/search/tests/src/Functional/SearchDateIntervalTest.php
index 46edc3bb081f23385702b673e6d6d79248e00443..84c2d38f00c94dc584f01cb5bffdb8f0bf9d48d5 100644
--- a/core/modules/search/tests/src/Functional/SearchDateIntervalTest.php
+++ b/core/modules/search/tests/src/Functional/SearchDateIntervalTest.php
@@ -56,7 +56,6 @@ protected function setUp() {
     // Update the index.
     $plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
     $plugin->updateIndex();
-    search_update_totals();
   }
 
   /**
diff --git a/core/modules/search/tests/src/Functional/SearchEmbedFormTest.php b/core/modules/search/tests/src/Functional/SearchEmbedFormTest.php
index af0d9b7b61a56cda490bb31a215dbc49f0cfb9bd..c134093df6f2d408b012cf057633becefa0b9a6e 100644
--- a/core/modules/search/tests/src/Functional/SearchEmbedFormTest.php
+++ b/core/modules/search/tests/src/Functional/SearchEmbedFormTest.php
@@ -42,7 +42,6 @@ protected function setUp() {
     $this->node = $this->drupalCreateNode();
 
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Set up a dummy initial count of times the form has been submitted.
     $this->submitCount = \Drupal::state()->get('search_embedded_form.submit_count');
diff --git a/core/modules/search/tests/src/Functional/SearchExactTest.php b/core/modules/search/tests/src/Functional/SearchExactTest.php
index 94de349ebde54ae9619e0d269e9448302e6cbdda..05ad58cff976ddb195ec544f50e41f77bd8dab24 100644
--- a/core/modules/search/tests/src/Functional/SearchExactTest.php
+++ b/core/modules/search/tests/src/Functional/SearchExactTest.php
@@ -46,7 +46,6 @@ public function testExactQuery() {
 
     // Update the search index.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Refresh variables after the treatment.
     $this->refreshVariables();
diff --git a/core/modules/search/tests/src/Functional/SearchLanguageTest.php b/core/modules/search/tests/src/Functional/SearchLanguageTest.php
index c874df7f66dc794559003f00e34f24c8dea4a99e..d884a4bbc4b11f91a87ffa7f741cd389cadbb08d 100644
--- a/core/modules/search/tests/src/Functional/SearchLanguageTest.php
+++ b/core/modules/search/tests/src/Functional/SearchLanguageTest.php
@@ -85,7 +85,6 @@ protected function setUp() {
     // Update the index and then run the shutdown method.
     $plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
     $plugin->updateIndex();
-    search_update_totals();
   }
 
   public function testLanguages() {
diff --git a/core/modules/search/tests/src/Functional/SearchMultilingualEntityTest.php b/core/modules/search/tests/src/Functional/SearchMultilingualEntityTest.php
index 17b3f06afdb67f087e5ed869480dca5c9932add2..d823b2e63279281d0868216d0916ca5098601b91 100644
--- a/core/modules/search/tests/src/Functional/SearchMultilingualEntityTest.php
+++ b/core/modules/search/tests/src/Functional/SearchMultilingualEntityTest.php
@@ -5,6 +5,7 @@
 use Drupal\Core\Database\Database;
 use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\search\SearchIndexInterface;
 use Drupal\Tests\BrowserTestBase;
 
 /**
@@ -128,7 +129,8 @@ public function testMultilingualSearch() {
     // Run the shutdown function. Testing is a unique case where indexing
     // and searching has to happen in the same request, so running the shutdown
     // function manually is needed to finish the indexing process.
-    search_update_totals();
+    $search_index = \Drupal::service('search.index');
+    assert($search_index instanceof SearchIndexInterface);
     $this->assertIndexCounts(6, 8, 'after updating partially');
     $this->assertDatabaseCounts(2, 0, 'after updating partially');
 
@@ -140,7 +142,6 @@ public function testMultilingualSearch() {
     $this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
 
     $this->plugin->updateIndex();
-    search_update_totals();
     $this->assertIndexCounts(0, 8, 'after updating fully');
     $this->assertDatabaseCounts(8, 0, 'after updating fully');
 
@@ -150,7 +151,6 @@ public function testMultilingualSearch() {
     $this->assertIndexCounts(8, 8, 'after reindex');
     $this->assertDatabaseCounts(8, 0, 'after reindex');
     $this->plugin->updateIndex();
-    search_update_totals();
 
     // Test search results.
 
@@ -190,13 +190,12 @@ public function testMultilingualSearch() {
 
     // Mark one of the nodes for reindexing, using the API function, and
     // verify indexing status.
-    search_mark_for_reindex('node_search', $this->searchableNodes[0]->id());
+    $search_index->markForReindex('node_search', $this->searchableNodes[0]->id());
     $this->assertIndexCounts(1, 8, 'after marking one node to reindex via API function');
 
     // Update the index and verify the totals again.
     $this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
     $this->plugin->updateIndex();
-    search_update_totals();
     $this->assertIndexCounts(0, 8, 'after indexing again');
 
     // Mark one node for reindexing by saving it, and verify indexing status.
@@ -227,32 +226,32 @@ public function testMultilingualSearch() {
     // Add a bogus entry to the search index table using a different search
     // type. This will not appear in the index status, because it is not
     // managed by a plugin.
-    search_index('foo', $this->searchableNodes[0]->id(), 'en', 'some text');
+    $search_index->index('foo', $this->searchableNodes[0]->id(), 'en', 'some text');
     $this->assertIndexCounts(1, 8, 'after adding a different index item');
 
     // Mark just this "foo" index for reindexing.
-    search_mark_for_reindex('foo');
+    $search_index->markForReindex('foo');
     $this->assertIndexCounts(1, 8, 'after reindexing the other search type');
 
     // Mark everything for reindexing.
-    search_mark_for_reindex();
+    $search_index->markForReindex();
     $this->assertIndexCounts(8, 8, 'after reindexing everything');
 
     // Clear one item from the index, but with wrong language.
     $this->assertDatabaseCounts(8, 1, 'before clear');
-    search_index_clear('node_search', $this->searchableNodes[0]->id(), 'hu');
+    $search_index->clear('node_search', $this->searchableNodes[0]->id(), 'hu');
     $this->assertDatabaseCounts(8, 1, 'after clear with wrong language');
     // Clear using correct language.
-    search_index_clear('node_search', $this->searchableNodes[0]->id(), 'en');
+    $search_index->clear('node_search', $this->searchableNodes[0]->id(), 'en');
     $this->assertDatabaseCounts(7, 1, 'after clear with right language');
     // Don't specify language.
-    search_index_clear('node_search', $this->searchableNodes[1]->id());
+    $search_index->clear('node_search', $this->searchableNodes[1]->id());
     $this->assertDatabaseCounts(6, 1, 'unspecified language clear');
     // Clear everything in 'foo'.
-    search_index_clear('foo');
+    $search_index->clear('foo');
     $this->assertDatabaseCounts(6, 0, 'other index clear');
     // Clear everything.
-    search_index_clear();
+    $search_index->clear();
     $this->assertDatabaseCounts(0, 0, 'complete clear');
   }
 
diff --git a/core/modules/search/tests/src/Functional/SearchNodeDiacriticsTest.php b/core/modules/search/tests/src/Functional/SearchNodeDiacriticsTest.php
index a529cea9b1192066bc3454be5bb774a051f9dcdb..e83d84b4ad72e3294d840d1723f57f37dc8eca16 100644
--- a/core/modules/search/tests/src/Functional/SearchNodeDiacriticsTest.php
+++ b/core/modules/search/tests/src/Functional/SearchNodeDiacriticsTest.php
@@ -45,7 +45,6 @@ public function testPhraseSearchPunctuation() {
 
     // Update the search index.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Refresh variables after the treatment.
     $this->refreshVariables();
diff --git a/core/modules/search/tests/src/Functional/SearchNodePunctuationTest.php b/core/modules/search/tests/src/Functional/SearchNodePunctuationTest.php
index 0c85714921c537414bda0cbf01d65dfe956f25c5..c0c597ecc5f22f1186487409ad8492cdf72dc52c 100644
--- a/core/modules/search/tests/src/Functional/SearchNodePunctuationTest.php
+++ b/core/modules/search/tests/src/Functional/SearchNodePunctuationTest.php
@@ -43,7 +43,6 @@ public function testPhraseSearchPunctuation() {
 
     // Update the search index.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Refresh variables after the treatment.
     $this->refreshVariables();
diff --git a/core/modules/search/tests/src/Functional/SearchNodeUpdateAndDeletionTest.php b/core/modules/search/tests/src/Functional/SearchNodeUpdateAndDeletionTest.php
index 3ebc584bd393561c17e5079ab382ba13df2a49d1..54095bd636257d352afec1a8a6a5d33970357f1f 100644
--- a/core/modules/search/tests/src/Functional/SearchNodeUpdateAndDeletionTest.php
+++ b/core/modules/search/tests/src/Functional/SearchNodeUpdateAndDeletionTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\search\Functional;
 
 use Drupal\Core\Database\Database;
+use Drupal\search\SearchIndexInterface;
 use Drupal\Tests\BrowserTestBase;
 
 /**
@@ -48,7 +49,8 @@ public function testSearchIndexUpdateOnNodeChange() {
     $node_search_plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
     // Update the search index.
     $node_search_plugin->updateIndex();
-    search_update_totals();
+    $search_index = \Drupal::service('search.index');
+    assert($search_index instanceof SearchIndexInterface);
 
     // Search the node to verify it appears in search results
     $edit = ['keys' => 'knights'];
@@ -61,7 +63,6 @@ public function testSearchIndexUpdateOnNodeChange() {
 
     // Run indexer again
     $node_search_plugin->updateIndex();
-    search_update_totals();
 
     // Search again to verify the new text appears in test results.
     $edit = ['keys' => 'shrubbery'];
@@ -83,7 +84,6 @@ public function testSearchIndexUpdateOnNodeDeletion() {
     $node_search_plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
     // Update the search index.
     $node_search_plugin->updateIndex();
-    search_update_totals();
 
     // Search the node to verify it appears in search results
     $edit = ['keys' => 'dragons'];
diff --git a/core/modules/search/tests/src/Functional/SearchPageCacheTagsTest.php b/core/modules/search/tests/src/Functional/SearchPageCacheTagsTest.php
index 2b36a33c4fa8659817495f0807903685e75c61ea..320833e51fa66989dd3b35dc1c6a721a372300bb 100644
--- a/core/modules/search/tests/src/Functional/SearchPageCacheTagsTest.php
+++ b/core/modules/search/tests/src/Functional/SearchPageCacheTagsTest.php
@@ -55,7 +55,6 @@ protected function setUp() {
     $this->node->setOwner($this->searchingUser);
     $this->node->save();
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
   }
 
   /**
@@ -174,7 +173,6 @@ public function testSearchTagsBubbling() {
 
     // Refresh the search index.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Log in with searching user again.
     $this->drupalLogin($this->searchingUser);
diff --git a/core/modules/search/tests/src/Functional/SearchPreprocessLangcodeTest.php b/core/modules/search/tests/src/Functional/SearchPreprocessLangcodeTest.php
index 459c1baccfd0eb097ef31d7d394176b574fb6c69..9f0b991484e05a1e398e7183e15fe489e5df45f6 100644
--- a/core/modules/search/tests/src/Functional/SearchPreprocessLangcodeTest.php
+++ b/core/modules/search/tests/src/Functional/SearchPreprocessLangcodeTest.php
@@ -47,11 +47,6 @@ public function testPreprocessLangcode() {
     // First update the index. This does the initial processing.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
 
-    // Then, run the shutdown function. Testing is a unique case where indexing
-    // and searching has to happen in the same request, so running the shutdown
-    // function manually is needed to finish the indexing process.
-    search_update_totals();
-
     // Search for the additional text that is added by the preprocess
     // function. If you search for text that is in the node, preprocess is
     // not invoked on the node during the search excerpt generation.
@@ -76,11 +71,6 @@ public function testPreprocessStemming() {
     // First update the index. This does the initial processing.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
 
-    // Then, run the shutdown function. Testing is a unique case where indexing
-    // and searching has to happen in the same request, so running the shutdown
-    // function manually is needed to finish the indexing process.
-    search_update_totals();
-
     // Search for the title of the node with a POST query.
     $edit = ['or' => 'testing'];
     $this->drupalPostForm('search/node', $edit, 'edit-submit--2');
diff --git a/core/modules/search/tests/src/Functional/SearchQueryAlterTest.php b/core/modules/search/tests/src/Functional/SearchQueryAlterTest.php
index 12cb1d7d866bc83d02f5911dd757387cb1d628c1..e28d63d4aff8aae91a9670c7eae1eb7796ed28e1 100644
--- a/core/modules/search/tests/src/Functional/SearchQueryAlterTest.php
+++ b/core/modules/search/tests/src/Functional/SearchQueryAlterTest.php
@@ -41,7 +41,6 @@ public function testQueryAlter() {
 
     // Update the search index.
     $this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
-    search_update_totals();
 
     // Search for the body keyword 'pizza'.
     $this->drupalPostForm('search/node', ['keys' => 'pizza'], t('Search'));
diff --git a/core/modules/search/tests/src/Functional/SearchRankingTest.php b/core/modules/search/tests/src/Functional/SearchRankingTest.php
index f793232fc3df122c0bbbe8a652f779c3f21fa373..86c5ff656807fe913c59cd3d0d9aa1960be6a76f 100644
--- a/core/modules/search/tests/src/Functional/SearchRankingTest.php
+++ b/core/modules/search/tests/src/Functional/SearchRankingTest.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Url;
 use Drupal\filter\Entity\FilterFormat;
 use Drupal\search\Entity\SearchPage;
+use Drupal\search\SearchIndexInterface;
 use Drupal\Tests\BrowserTestBase;
 use Drupal\Tests\Traits\Core\CronRunTrait;
 
@@ -239,7 +240,8 @@ public function testHTMLRankings() {
 
     // Update the search index.
     $this->nodeSearch->getPlugin()->updateIndex();
-    search_update_totals();
+    $search_index = \Drupal::service('search.index');
+    assert($search_index instanceof SearchIndexInterface);
 
     $this->nodeSearch->getPlugin()->setSearch('rocks', [], []);
     // Do the search and assert the results.
@@ -264,7 +266,6 @@ public function testHTMLRankings() {
 
       // Update the search index.
       $this->nodeSearch->getPlugin()->updateIndex();
-      search_update_totals();
 
       $this->nodeSearch->getPlugin()->setSearch('rocks', [], []);
       // Do the search and assert the results.
diff --git a/core/modules/search/tests/src/Functional/SearchSetLocaleTest.php b/core/modules/search/tests/src/Functional/SearchSetLocaleTest.php
index bca3421169fdb4767ce9a44936ff5b0c373c99c9..5dee65879f12172f3594a01d423e880cf42b250b 100644
--- a/core/modules/search/tests/src/Functional/SearchSetLocaleTest.php
+++ b/core/modules/search/tests/src/Functional/SearchSetLocaleTest.php
@@ -34,7 +34,6 @@ protected function setUp() {
     $this->drupalCreateNode(['body' => [['value' => 'tapir']]]);
     // Update the search index.
     $this->nodeSearchPlugin->updateIndex();
-    search_update_totals();
   }
 
   /**
diff --git a/core/modules/search/tests/src/Kernel/SearchDeprecationTest.php b/core/modules/search/tests/src/Kernel/SearchDeprecationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a34633c39a54360d1e756b069b28e81502cb061f
--- /dev/null
+++ b/core/modules/search/tests/src/Kernel/SearchDeprecationTest.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Drupal\Tests\search\Kernel;
+
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests deprecated search methods.
+ *
+ * @group legacy
+ * @group search
+ */
+class SearchDeprecationTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['search'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installSchema('search', [
+      'search_index',
+      'search_dataset',
+      'search_total',
+    ]);
+    $this->installConfig(['search']);
+  }
+
+  /**
+   * @expectedDeprecation search_index() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::index() instead. See https://www.drupal.org/node/3075696
+   */
+  public function testIndex() {
+    $this->assertNull(search_index('_test_', 1, LanguageInterface::LANGCODE_NOT_SPECIFIED, "foo"));
+  }
+
+  /**
+   * @expectedDeprecation search_index_clear() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::clear() instead. See https://www.drupal.org/node/3075696
+   */
+  public function testClear() {
+    $this->assertNull(search_index_clear());
+  }
+
+  /**
+   * @expectedDeprecation search_dirty() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom implementation of \Drupal\search\SearchIndexInterface instead. See https://www.drupal.org/node/3075696
+   */
+  public function testDirty() {
+    $this->assertNull(search_dirty("foo"));
+    $this->assertEqual([], search_dirty());
+  }
+
+  /**
+   * @expectedDeprecation search_update_totals() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use custom implementation of \Drupal\search\SearchIndexInterface instead. See https://www.drupal.org/node/3075696
+   */
+  public function testUpdateTotals() {
+    $this->assertNull(search_update_totals());
+  }
+
+  /**
+   * @expectedDeprecation search_mark_for_reindex() is deprecated in drupal:8.8.0 and is removed in drupal:9.0.0. Use \Drupal\search\SearchIndex::markForReindex() instead. See https://www.drupal.org/node/3075696
+   */
+  public function testMarkForReindex() {
+    $this->assertNull(search_mark_for_reindex('_test_', 1, LanguageInterface::LANGCODE_NOT_SPECIFIED));
+  }
+
+}
diff --git a/core/modules/search/tests/src/Kernel/SearchMatchTest.php b/core/modules/search/tests/src/Kernel/SearchMatchTest.php
index 577028bd48eec21064d57c083245bb5a48f12447..f0de1ec8c018eb1006e2531603b64c40106391de 100644
--- a/core/modules/search/tests/src/Kernel/SearchMatchTest.php
+++ b/core/modules/search/tests/src/Kernel/SearchMatchTest.php
@@ -5,6 +5,7 @@
 use Drupal\Core\Database\Database;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\search\SearchIndexInterface;
 
 /**
  * Indexes content and queries it.
@@ -49,11 +50,13 @@ public function testMatching() {
   public function _setup() {
     $this->config('search.settings')->set('index.minimum_word_size', 3)->save();
 
+    $search_index = \Drupal::service('search.index');
+    assert($search_index instanceof SearchIndexInterface);
     for ($i = 1; $i <= 7; ++$i) {
-      search_index(static::SEARCH_TYPE, $i, LanguageInterface::LANGCODE_NOT_SPECIFIED, $this->getText($i));
+      $search_index->index(static::SEARCH_TYPE, $i, LanguageInterface::LANGCODE_NOT_SPECIFIED, $this->getText($i));
     }
     for ($i = 1; $i <= 5; ++$i) {
-      search_index(static::SEARCH_TYPE_2, $i + 7, LanguageInterface::LANGCODE_NOT_SPECIFIED, $this->getText2($i));
+      $search_index->index(static::SEARCH_TYPE_2, $i + 7, LanguageInterface::LANGCODE_NOT_SPECIFIED, $this->getText2($i));
     }
     // No getText builder function for Japanese text; just a simple array.
     foreach ([
@@ -61,9 +64,8 @@ public function _setup() {
       14 => 'ドルーパルが大好きよ!',
       15 => 'コーヒーとケーキ',
     ] as $i => $jpn) {
-      search_index(static::SEARCH_TYPE_JPN, $i, LanguageInterface::LANGCODE_NOT_SPECIFIED, $jpn);
+      $search_index->index(static::SEARCH_TYPE_JPN, $i, LanguageInterface::LANGCODE_NOT_SPECIFIED, $jpn);
     }
-    search_update_totals();
   }
 
   /**