Реализация интерфейса подкачки на основе курсора

задняя часть база данных API прямая трансляция

Реализация интерфейса подкачки должна быть очень распространена в развитии серверной части бизнеса, различных формах в эпоху ПК, различных формах в эпоху мобильных устройств.feedпоток,timeline.

Для управления трафиком или взаимодействия с пользователем большие пакеты данных не возвращаются клиенту напрямую, а возвращаются через несколько запросов через интерфейс подкачки.

Наиболее часто используемое определение интерфейса подкачки, вероятно, выглядит следующим образом:

router.get('/list', async ctx => {
  const { page, size } = this.query

  // ...

  ctx.body = {
    data: []
  }
})

// > curl /list?page=1&size=10

Номер страницы входящего запроса, и количество предметов, которые необходимо запросить на странице, лично я предполагаю, что это может быть связано с базой данных, с которой все соприкасались, когда были новичками - - среди знакомых мне людей обращаться первыйMySQL,SQL Serverнечто большее и подобноеSQLоператор, который в основном является условием пейджинга, подобным этому при запросе:

SELECT <column> FROM <table> LIMIT <offset>, <rows>

или похожиеRedisсреда дляzsetОперация аналогична:

> ZRANGE <key> <start> <stop>

Таким образом, может оказаться привычным создавать интерфейс запроса пейджинга аналогичным образом и позволить клиенту предоставлятьpage,sizeдва параметра.
В этом нет ничего плохого,PCТаблицы и списки на мобильном терминале могут аккуратно отображать данные.

Но это относительно традиционный метод постраничной обработки данных, который подходит для данных без каких-либо условий динамической фильтрации.
Однако, если данные относятся к типу с очень высокими требованиями к реальному времени, имеется большое количество условий фильтрации или их необходимо фильтровать по сравнению с другими источниками данных, использование такой обработки покажется немного странным. метод.

Проблема подкачки интерфейса номер страницы + количество записей

Чтобы привести простой пример, наша компания занимается прямыми трансляциями, и должен быть такой интерфейс, как список прямых трансляций.
Такие данные, как прямые трансляции, очень чувствительны ко времени, как популярные списки и списки новичков.Источником этих данных являются автономные расчетные данные, но такие данные обычно хранят только идентификатор пользователя или идентификатор комнаты прямой трансляции, например комната прямого эфира.Количество зрителей, продолжительность прямого эфира, популярность, такие данные должны иметь высокие требования к своевременности и не могут быть обработаны в офлайн-скриптах, поэтому их нужно получать только при выполнении интерфейсных запросов.

И требуется некоторая проверка, когда клиент запрашивает, например, какие-то простые условия:

  • Убедитесь, что стример ведет трансляцию
  • Обеспечение соответствия живого контента
  • Проверьте блокирующие отношения между пользователями и якорями

Нет возможности сделать это при запущенном автономном скрипте, потому что он все время меняется, и данные могут храниться не в одном и том же месте, а данные списка могут поступать изMySQL, необходимо использовать отфильтрованные данныеRedisДанные, относящиеся к пользовательской информации, полученной от Zhonglai, находятся вXXXБаза данных, поэтому эти операции не могут быть решены с помощью запроса к связанной таблице, их необходимо выполнять на уровне интерфейса, и для синтеза получают несколько фрагментов данных.

В это время, если будет принят вышеупомянутый режим поискового вызова, возникнет очень неприятная проблема.
Возможно, пользователи, обращающиеся к интерфейсу, настроены более враждебно и блокируют все привязки на первой странице, что приведет к тому, что фактические данные, возвращаемые интерфейсом, будут0Мужик, это ужасно.

let data = [] // length: 10
data = data.filter(filterBlackList)
return data   // length: 0

В этом случае, должен ли клиент отображать это как отсутствие данных или он должен немедленно запросить вторую страницу данных.

Поэтому такой дизайн пейджинга в ряде случаев не может удовлетворить наши потребности, и на данный момент его удалось найти.Redisкоманда в:scan.

Курсор + номер реализации интерфейса подкачки

scanкоманда для итерацииRedisвсе в базеkey, но из-заkeyКоличество определить невозможно(Прямое исполнение онлайнkeysбудет убит),иkeyИх количество также постоянно меняется в процессе вашей работы, какие-то могут удаляться, а какие-то добавляться в течение периода. так,scanКоманда требует передачи курсора, который передается при первом вызове.0может быть, покаscanВозвращаемое значение команды имеет два элемента, первый элемент — это курсор, необходимый для следующей итерации, а второй элемент — это коллекция, указывающая все возвращаемые значения этой итерации.key. а такжеscanМожно добавить регулярные выражения для повторения определенных правил, которые удовлетворяют правиламkey, например всеtemp_началоkey:scan 0 temp_*scanне совсем соответствует указанным вами правиламkeyа потом вернулся к вам, это не гарантирует, что итерация вернетсяNЧасть данных, существует большая вероятность того, что часть данных не будет возвращена за раз.

Если нам явно нужноXXфрагменты данных, а затем вызывать его несколько раз в соответствии с курсором.

// 用一个递归简单的实现获取十个匹配的key
await function getKeys (pattern, oldCursor = 0, res = []) {
  const [ cursor, data ] = await redis.scan(oldCursor, pattern)

  res = res.concat(data)
  if (res.length >= 10) return res.slice(0, 10)
  else return getKeys(cursor, pattern, res)
}

await getKeys('temp_*') // length: 10

Такой способ использования натолкнул меня на некоторые идеи, и я планирую аналогичным образом реализовать интерфейс подкачки.
Однако размещение такой логики на стороне клиента сделает ее корректировку позже очень проблематичной. Его нужно выпустить, чтобы решить проблему, а совместимость старой и новой версии также сделает более поздние версии привязанными.
Так что такая логика будет разрабатываться на стороне сервера, а клиенту нужно будет использовать только курсор, возвращаемый интерфейсомcursorВы можете перенести его на следующий запрос интерфейса.

грубая структура

Для клиента это простое хранение и использование курсора.
Но логика на стороне сервера немного сложнее:

  1. Во-первых, нам нужна функция, которая получает данные
  2. Во-вторых, должна быть функция фильтрации данных.
  3. Есть функция оценки длины данных и перехвата
function getData () {
  // 获取数据
}

function filterData () {
  // 过滤数据
}

function generatedData () {
  // 合并、生成、返回数据
}

выполнить

node.js 10.xсталLTS, поэтому пример кода будет использовать10некоторые новые функции.

Поскольку список, скорее всего, будет храниться в виде набора, аналогичного набору идентификаторов пользователей, вRedisсредний даsetилиzset.

Если источником данных являетсяRedis, я предлагаю глобально кэшировать полный список, регулярно обновлять данные, а затем передавать их на уровне интерфейса.sliceчтобы получить некоторые данные, необходимые для этого запроса.

P.S. Пример кода ниже предполагаетlistВ данных хранится набор уникальных идентификаторов, и соответствующие подробные данные получаются из других баз данных с помощью этих уникальных идентификаторов.

redis> SMEMBER list
     > 1
     > 2
     > 3

mysql> SELECT * FROM user_info
+-----+---------+------+--------+
| uid | name    | age  | gender |
+-----+---------+------+--------+
|   1 | Niko    |   18 |      1 |
|   2 | Bellic  |   20 |      2 |
|   3 | Jarvis  |   22 |      2 |
+-----+---------+------+--------+

Данные списка кэшируются глобально

// 完整列表在全局的缓存
let globalList = null

async function updateGlobalData () {
  globalList = await redis.smembers('list')
}

updateGlobalData()
setInterval(updateGlobalData, 2000) // 2s 更新一次

Получить данные Реализация функции фильтрации данных

из-за вышеизложенногоscanПример сделан рекурсивно, но читабельность не очень высокая, поэтому можно использовать генераторGeneratorЧтобы помочь нам достичь таких потребностей:

// 获取数据的函数
async function * getData (list, size) {
  const count = Math.ceil(list.length / size)

  let index = 0

  do {
    const start = index * size
    const end   = start + size
    const piece = list.slice(start, end)
    
    // 查询 MySQL 获取对应的用户详细数据
    const results = await mysql.query(`
      SELECT * FROM user_info
      WHERE uid in (${piece})
    `)

    // 过滤所需要的函数,会在下方列出来
    yield filterData(results)
  } while (index++ < count)
}

В то же время нам также нужна функция для фильтрации данных.Эти функции могут получать данные из каких-то других источников данных для проверки легитимности данных списка.Например, у пользователя А есть черный список, в котором есть пользователь Б, пользователь С, затем, когда пользователь A получает доступ к интерфейсу, B и C должны быть отфильтрованы.
Или нам нужно оценить текущий статус определенного фрагмента данных, например, закрыл ли хост комнату прямой трансляции и нормальный ли статус потоковой передачи, Это может вызвать другие интерфейсы для проверки.

// 过滤数据的函数
async function filterData (list) {
  const validList = await Promise.all(list.map(async item => {
    const [
      isLive,
      inBlackList
    ] = await Promise.all([
      http.request(`https://XXX.com/live?target=${item.id}`), redis.sismember(`XXX:black:list`, item.id)
    ])

    // 正确的状态
    if (isLive && !inBlackList) {
      return item
    }
  }))

  // 过滤无效数据
  return validList.filter(i => i)
}

Последняя функция для объединения данных

После того, как реализованы функции двух вышеуказанных ключевых функций, должна появиться функция проверки и сращивания данных.
Используется для принятия решения о том, когда возвращать данные клиенту и когда инициировать новый запрос на получение данных:

async function generatedData ({
  cursor,
  size,
}) {
  let list = globalList

  // 如果传入游标,从游标处截取列表
  if (cursor) {
    // + 1 的作用在下边有提到
    list = list.slice(list.indexOf(cursor) + 1)
  }

  let results = []

  // 注意这里的是 for 循环, 而非 map、forEach 之类的
  for await (const res of getData(list, size)) {
    results = results.concat(res)

    if (results.length >= size) {
      const list = results.slice(0, size)
      return {
        list,
        // 如果还有数据,那么就需要将本次
        // 我们返回列表最后一项的 ID 作为游标,这也就解释了接口入口处的 indexOf 为什么会有一个 + 1 的操作了
        cursor: list[size - 1].id,
      }
    }
  }

  return {
    list: results,
  }
}

очень простойforцикл, сforЦикл заключается в том, чтобы сделать процесс запроса интерфейса последовательным.После того, как первый запрос интерфейса получает результат, определяется, что данных недостаточно, и необходимо продолжить получение данных для заполнения.В это время второй запрос будет инициирован, чтобы избежать дополнительной траты ресурсов.
После получения необходимых данных вы можете напрямуюreturn, цикл завершается, и последующие генераторы также уничтожаются.

И размещение этой функции в нашем интерфейсе завершает сборку всего процесса:

router.get('/list', async ctx => {
  const { cursor, size } = this.query

  const data = await generatedData({
    cursor,
    size,
  })

  ctx.body = {
    code: 200,
    data,
  }
})

Возвращаемое значение такой структуры, вероятно,listсcursor,похожийscanВозвращаемое значение курсора и данных.
Клиенты также могут передавать необязательныеsizeчтобы указать количество возвращаемых элементов, ожидаемых интерфейсом за раз.
Однако по сравнению с обычнымpage+sizeВ методе пейджинга такой запрос интерфейса должен быть медленнее (поскольку обычный пейджинг может не возвращать фиксированное количество данных на страницу, и это может выполнять несколько внутренних операций сбора данных).

Однако для некоторых интерфейсов с высокими требованиями к реальному времени я лично считаю, что этот метод реализации будет более удобным для пользователя.

сравнение между двумя

Оба метода хороши для пагинации, первый встречается чаще, а второй не является панацеей, но в некоторых случаях может быть лучше.

Первый метод может быть более применим вBНа терминале некоторые рабочие задания, отчеты, архивные данные и т.д.
Вторая возможностьCКонечное использование будет лучше, в конце концов, продукт, предоставленный пользователю;
На странице ПК может быть таблица пейджинга, первый дисплей10Статья, отображается вторая страница8статью, но третья страница становится10бар, что является катастрофой для пользователя.
На мобильной стороне страница может быть относительно лучше, подобно потоку водопада с бесконечной прокруткой, но она также будет отображаться после загрузки пользователем.2кусок данных, снова загрузился и появился8части данных, это едва ли приемлемо в случае не домашней страницы, но если домашняя страница появляется2Часть данных, tsk tsk.

А со вторым курсорcursorСпособ гарантировать, что каждый раз, когда интерфейс возвращает данные,sizeЕсли этого недостаточно, значит, позади нет данных.
Пользовательский опыт будет лучше. (Конечно, если в списке нет никаких условий фильтрации, это просто обычное отображение, то рекомендуется использовать первый, нет необходимости добавлять эти логические обработки)

резюме

Конечно, это только некоторая обработка, связанная с пейджингом, которую можно сделать с сервера, но это еще не решает всех проблем, таких как некоторые списки с более быстрой скоростью обновления, таблицы лидеров и т. д., данные в секунду могут быть в Изменить , возможно, что при первом запросе пользователь А будет на десятом месте, а при втором запросе интерфейса пользователь А будет на одиннадцатом месте, то в базе данных будет запись о пользователе А два интерфейса.

В ответ на такую ​​ситуацию клиент также должен выполнить соответствующую обработку дедупликации, но такая дедупликация приведет к уменьшению объема данных.
Это еще одна большая тема, и я не планирую ее расширять. .
Простой способ обмана пользователей — запрос интерфейса16бар, шоу10бар, оставшийся6Панель существует в локальном соседнем интерфейсе, вставлена ​​и отображена.

Если в тексте есть какие-то ошибки, или у вас есть лучший способ реализации пейджинга или ваш любимый способ, поделитесь, пожалуйста.

использованная литература