12306 Захватываете билеты, думаете об экстремальном параллелизме?
Каждый праздничный период люди, возвращающиеся в родные города и выезжающие в города первого и второго эшелонов, практически сталкиваются с проблемой: достать билеты на поезд! , я верю всем Всех глубоко пережил. Особенно во время Весеннего фестиваля все не только используют 12306, но также рассматривают «Chixing» и другое программное обеспечение для захвата билетов.Сотни миллионов людей по всей стране хватают билеты в это время. «Служба 12306» имеет число запросов в секунду, которое не может быть превзойдено ни одной системой мгновенного уничтожения в мире, и миллионы параллелизма — это нормально! Автор специально изучил серверную архитектуру "12306", и узнал много моментов в ее системном устройстве.Здесь я поделюсь с вами и смоделирую пример: как обеспечить нормальную систему, когда 1 миллион человек захватывает 10 000 билетов на поезд в то же время стабильный сервис. кодовый адрес гитхаба
1. Крупномасштабная системная архитектура с высокой степенью параллелизма
Архитектура системы с высокой степенью параллелизма будет развернута в распределенных кластерах.Верхний уровень сервиса имеет послойную балансировку нагрузки и предоставляет различные методы аварийного восстановления (двойной компьютерный зал, отказоустойчивость узла, аварийное восстановление сервера и т. д.). .) для обеспечения высокой доступности системы, а трафик также будет основываться на различных мощностях нагрузки и стратегиях конфигурации, сбалансированных для разных серверов. Ниже приведена простая схема:
1.1 Введение в балансировку нагрузки
На приведенном выше рисунке показано, что пользовательский запрос к серверу прошел три уровня балансировки нагрузки.Три типа балансировки нагрузки кратко представлены ниже:
-
OSPF (Open Shortest Link First) — это протокол внутреннего шлюза (IGP). OSPF создает базу данных состояния каналов, сообщая о состоянии сетевых интерфейсов между маршрутизаторами, и создает дерево кратчайших путей.OSPF автоматически рассчитает значение стоимости интерфейса маршрутизации, но значение стоимости интерфейса также можно указать вручную. расчетное значение. Стоимость, вычисляемая OSPF, также обратно пропорциональна пропускной способности интерфейса: чем выше пропускная способность, тем меньше значение стоимости. Пути, которые достигают цели с одинаковым значением стоимости, могут выполнять балансировку нагрузки, и до 6 ссылок могут выполнять балансировку нагрузки одновременно.
-
LVS (Linux Virtual Server), представляющий собой кластерную технологию, использует технологию балансировки нагрузки IP и технологию распределения запросов на основе содержимого. Планировщик имеет хорошую пропускную способность и сбалансированно передает запросы на разные серверы для выполнения, а планировщик автоматически экранирует сбой сервера, тем самым формируя группу серверов в высокопроизводительный виртуальный сервер с высокой доступностью. .
-
Nginx должен быть знаком всем, это очень высокопроизводительный http-прокси/обратный прокси-сервер, который часто используется для балансировки нагрузки при разработке сервисов. В Nginx есть три основных способа балансировки нагрузки: опрос, взвешенный опрос и опрос хэша IP.Далее мы выполним специальную настройку и тестирование для взвешенного опроса Nginx.
1.2 Демонстрация взвешенного опроса Nginx
Nginx реализует балансировку нагрузки через восходящий модуль.Настройка взвешенного опроса может добавить значение веса к соответствующему сервису.При настройке может быть установлена соответствующая нагрузка в соответствии с производительностью и грузоподъемностью сервера. Ниже приведена конфигурация взвешенной нагрузки при опросе: я буду прослушивать порты 3001-3004 локально и настраивать веса 1, 2, 3 и 4 соответственно:
#配置负载均衡
upstream load_rule {
server 127.0.0.1:3001 weight=1;
server 127.0.0.1:3002 weight=2;
server 127.0.0.1:3003 weight=3;
server 127.0.0.1:3004 weight=4;
}
...
server {
listen 80;
server_name load_balance.com www.load_balance.com;
location / {
proxy_pass http://load_rule;
}
}
Я настроил его в локальном каталоге /etc/hostsАдрес виртуального доменного имени www.load\_balance.com. Затем с помощью языка Go откройте четыре службы мониторинга http-портов. Ниже представлена программа Go, которая прослушивает порт 3001. Другим нужно только изменить порт:
package main
import (
"net/http"
"os"
"strings"
)
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3001", nil)
}
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
failedMsg := "handle in port:"
writeLog(failedMsg, "./stat.log")
}
//写入日志
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "3001")
buf := []byte(content)
fd.Write(buf)
}
Я записал запрошенную информацию журнала порта в файл ./stat.log, а затем использовал инструмент стресс-тестирования ab для проведения стресс-тестирования:
ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket
По результатам в журнале статистики порты 3001-3004 получили 100, 200, 300 и 400 запросов соответственно, что хорошо согласуется с настроенным мною в nginx соотношением весов, а трафик после нагрузки очень высокий. равномерный и случайный. Для конкретной реализации вы можете обратиться к исходному коду реализации модуля upsteam nginx.Вот рекомендуемая статья: Балансировка нагрузки восходящего механизма в Nginx
2. Выбор типа системы быстрой покупки
Вернемся к вопросу, который мы упоминали в начале: как система seckill билетов на поезд обеспечивает нормальные и стабильные услуги в условиях высокой параллелизма?
Из приведенного выше введения мы знаем, что пользовательский пиковый трафик равномерно распределяется по разным серверам посредством послойной балансировки нагрузки, но даже в этом случае количество запросов в секунду на одной машине в кластере очень велико. Как максимально оптимизировать автономную производительность? Чтобы решить эту проблему, мы должны понять одну вещь: обычно система бронирования билетов должна иметь дело с тремя основными этапами создания заказов, вычетом инвентаря и оплатой пользователей.Что наша система должна сделать, так это убедиться, что заказ билета на поезд не превышает Продавать, продавать много, каждый проданный билет должен быть оплачен, чтобы быть действительным, и система также должна выдерживать чрезвычайно высокий параллелизм. Как можно изменить порядок этих трех этапов, чтобы он был более разумным?
2.1 Приказ о сокращении запасов
Когда параллельный запрос пользователя поступает на сервер, сначала создается заказ, затем производится вычет инвентаря, и пользователь ожидает оплаты. Этот заказ является первым решением, о котором думает большинство людей.В этом случае он также может гарантировать, что заказ не будет перепродан, потому что запасы будут уменьшены после создания заказа, что является атомарной операцией. Но и это вызовет некоторые проблемы.Во-первых, в случае экстремального параллелизма на производительность будут влиять детали любой операции с памятью, особенно логика создания ордера, которую вообще-то нужно хранить в дисковой БД, которая оказывает давление на базу данных.Это возможно;во-вторых, если пользователь размещает заказ злонамеренно, только размещение заказа без оплаты уменьшит запасы и продаст много заказов, хотя сервер может ограничить IP и покупку пользователя количество заказа, это тоже не лучший способ.
2.2 Оплата за вычетом запасов
Если ждать, пока пользователь оплатит заказ и уменьшит инвентарь, то первое ощущение, что меньше он не продастся. Но это большое табу для параллельной архитектуры, потому что в случае экстремального параллелизма пользователи могут создавать много заказов, а когда запасы сводятся к нулю, многие пользователи обнаруживают, что захваченные заказы не могут быть оплачены, что является таковым. называется «перепроданностью». Также невозможно избежать параллельной работы дискового ввода-вывода БД.
2.3 Удержание инвентаря
Из рассмотрения двух вышеприведенных схем можно сделать вывод, что пока создается заказ, БД IO приходится часто эксплуатировать. Итак, есть ли решение, которое не требует прямой операции ввода-вывода базы данных, которая удерживает инвентарь. Сначала вычитается инвентарь, чтобы убедиться, что он не перепродан, а затем заказ пользователя генерируется асинхронно, так что ответ пользователю будет намного быстрее; тогда как гарантировать много продаж? Что делать, если пользователь получил заказ и не заплатил? Все мы знаем, что у заказов есть срок годности. Например, если пользователь не заплатит в течение пяти минут, заказ станет недействительным. По истечении срока действия заказа будет добавлен новый инвентарь. Это также решение, принятое во многих интернет-магазинах. компании для обеспечения того, чтобы многие продукты были проданы. Генерация заказов является асинхронной, и они обычно обрабатываются в очередях потребления в реальном времени, таких как MQ и Kafka.Когда объем заказа относительно невелик, генерация заказа происходит очень быстро, и пользователям почти не нужно стоять в очереди.
3. Искусство вычета запасов
Из вышеприведенного анализа очевидно, что план удержания запасов является наиболее разумным. Далее анализируем детали вычета инвентаря.Еще много возможностей для оптимизации.Где существует инвентарь? Как обеспечить высокий уровень параллелизма, правильное списание запасов и быстрое реагирование на запросы пользователей?
В случае низкого параллелизма одной машины мы обычно вычитаем инвентарь следующим образом:
Чтобы обеспечить атомарность вычета запасов и создания заказов, необходимо использовать обработку транзакций, затем оценивать запасы, уменьшать запасы и, наконец, отправлять транзакцию.Весь процесс включает много операций ввода-вывода и работу с базой данных. заблокирован. Этот метод вообще не подходит для шиповой системы с высоким параллелизмом.
Далее оптимизируем схему вычета инвентаря для отдельной машины: локальное вычет инвентаря. Мы выделяем определенный объем инвентаря на локальную машину, напрямую уменьшаем инвентарь в памяти, а затем асинхронно создаем заказы по предыдущей логике. Усовершенствованная автономная система выглядит следующим образом:
Таким образом, можно избежать частых операций ввода-вывода в базе данных, а операции выполняются только в памяти, что значительно повышает антиконкурентные возможности отдельной машины. Однако одиночная машина с миллионами пользовательских запросов все равно не выдержит.Хотя nginx использует модель epoll для обработки сетевых запросов, проблема c10k уже решена в отрасли. Однако в системе Linux все ресурсы являются файлами, и то же самое относится и к сетевым запросам: большое количество дескрипторов файлов приведет к тому, что операционная система мгновенно потеряет отклик. Выше мы упоминали стратегию взвешенной балансировки nginx.С таким же успехом можно предположить, что объем пользовательских запросов в 100 Вт равномерно распределяется по 100 серверам, так что объем параллелизма, переносимый одной машиной, намного меньше. Затем мы храним по 100 билетов на поезд локально на каждой машине, а общий запас на 100 серверах по-прежнему составляет 10 000, что гарантирует, что заказы на акции не будут перепроданы. Ниже представлена архитектура кластера, которую мы описываем:
Далее следуют проблемы.В случае высокого параллелизма мы не можем гарантировать высокую доступность системы.Если две или три машины на 100 серверах не работают из-за того, что они не могут обрабатывать параллельный трафик или по другим причинам. Тогда заказы на этих серверах не могут быть проданы, что приводит к продаже меньшего количества заказов. Чтобы решить эту проблему, нам необходимо унифицированно управлять общим объемом заказов, что является следующим отказоустойчивым решением. Серверу необходимо не только уменьшить запасы локально, но и удаленно. С помощью операции удаленного унифицированного сокращения запасов мы можем выделить некоторый избыточный «буферный запас» для каждой машины в соответствии с нагрузкой машины, чтобы предотвратить простои машины. Давайте подробно проанализируем это с помощью следующей архитектурной схемы:
Мы используем Redis для хранения унифицированного инвентаря, потому что производительность Redis очень высока, и утверждается, что QPS на одной машине может противостоять 10 Вт параллелизма. После локального сокращения запасов, если есть локальный заказ, мы запросим Redis для удаленного сокращения запасов.После того, как локальное сокращение запасов и удаленное сокращение запасов будут успешными, мы вернем пользователю запрос на успешное получение билета, что также может эффективно гарантировать, что ордер не будет уменьшен. Когда одна из машин не работает, поскольку каждая машина зарезервировала оставшиеся билеты в буфере, оставшиеся билеты на неисправной машине все еще можно компенсировать на других машинах, что обеспечивает большие продажи. Каково подходящее количество оставшихся тикетов в буфере? Теоретически, чем больше буферов установлено, тем больше машин система может выдержать простоя. Однако, если буфер задан слишком большим, это также окажет определенное влияние на Redis. Несмотря на то, что возможности защиты от параллелизма в базе данных Redis в памяти очень высоки, запрос все равно будет проходить через сетевой ввод-вывод.На самом деле, количество запросов к Redis в процессе захвата билетов — это общий объем локальной инвентаризации и буферный инвентарь, потому что, когда местного инвентаря недостаточно, система напрямую возвращает пользователю »сообщение «Распродано», логика равномерного удержания инвентаря больше не будет соблюдаться, что в определенной степени также позволяет избежать огромного количества сетевые запросы перегружают Redis, поэтому величина установленного значения буфера зависит от нагрузки архитектора на систему. Возможность серьезного рассмотрения.
4. Демонстрация кода
Язык Go изначально разработан для параллелизма.Я использую язык go, чтобы продемонстрировать конкретный процесс захвата билетов на одной машине.
4.1 Работа по инициализации
Функция init в пакете go выполняется перед основной функцией, и на этом этапе в основном выполняется некоторая подготовительная работа. Наша система должна выполнить следующие приготовления: инициализировать локальную инвентаризацию, инициализировать значение хеш-ключа удаленной унифицированной инвентаризации хранилища Redis, инициализировать пул соединений Redis; кроме того, нам нужно инициализировать chan типа int размером 1. , цель состоит в том, чтобы реализовать функцию распределенной блокировки, вы также можете использовать блокировки чтения-записи напрямую или использовать другие методы, такие как Redis, чтобы избежать конкуренции за ресурсы, но более эффективно использовать каналы.Это философия языка go : не общайтесь через общую память, а делитесь памятью через общение. Библиотека redis использует redigo, и ниже приведена реализация кода:
...
//localSpike包结构体定义
package localSpike
type LocalSpike struct {
LocalInStock int64
LocalSalesVolume int64
}
...
//remoteSpike对hash结构的定义和redis连接池
package remoteSpike
//远程订单存储健值
type RemoteSpikeKeys struct {
SpikeOrderHashKey string //redis中秒杀订单hash结构key
TotalInventoryKey string //hash结构中总订单库存key
QuantityOfOrderKey string //hash结构中已有订单数量key
}
//初始化redis连接池
func NewPool() *redis.Pool {
return &redis.Pool{
MaxIdle: 10000,
MaxActive: 12000, // max number of connections
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", ":6379")
if err != nil {
panic(err.Error())
}
return c, err
},
}
}
...
func init() {
localSpike = localSpike2.LocalSpike{
LocalInStock: 150,
LocalSalesVolume: 0,
}
remoteSpike = remoteSpike2.RemoteSpikeKeys{
SpikeOrderHashKey: "ticket_hash_key",
TotalInventoryKey: "ticket_total_nums",
QuantityOfOrderKey: "ticket_sold_nums",
}
redisPool = remoteSpike2.NewPool()
done = make(chan int, 1)
done <- 1
}
4.2 Локальный вычет запасов и единый вычет запасов
Логика вычета местных запасов очень проста: пользователь запрашивает его, добавляет объем продаж, затем сравнивает, превышает ли объем продаж объем местных запасов, и возвращает логическое значение:
package localSpike
//本地扣库存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
spike.LocalSalesVolume = spike.LocalSalesVolume + 1
return spike.LocalSalesVolume < spike.LocalInStock
}
Обратите внимание, что операция с общими данными LocalSalesVolume здесь реализована с использованием блокировок, но поскольку вычет локального инвентаря и вычет унифицированного инвентаря являются атомарными операциями, для реализации используется канал на верхнем уровне, который будет обсуждаться позже. Унифицированный вывод операции инвентаризации redis, потому что redis однопоточный, и нам нужно реализовать процесс выборки данных из него, запись данных и вычисление некоторой последовательности шагов, нам нужно взаимодействовать со скриптом lua для упаковки команд в обеспечить атомарность операции:
package remoteSpike
......
const LuaScript = `
local ticket_key = KEYS[1]
local ticket_total_key = ARGV[1]
local ticket_sold_key = ARGV[2]
local ticket_total_nums = tonumber(redis.call('HGET', ticket_key, ticket_total_key))
local ticket_sold_nums = tonumber(redis.call('HGET', ticket_key, ticket_sold_key))
-- 查看是否还有余票,增加订单数量,返回结果值
if(ticket_total_nums >= ticket_sold_nums) then
return redis.call('HINCRBY', ticket_key, ticket_sold_key, 1)
end
return 0
`
//远端统一扣库存
func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool {
lua := redis.NewScript(1, LuaScript)
result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey))
if err != nil {
return false
}
return result != 0
}
Мы используем хэш-структуру для хранения информации об общем объеме запасов и общих продажах.Когда пользователь запрашивает, он определяет, превышает ли общий объем продаж объем запасов, а затем возвращает соответствующее логическое значение. Перед запуском службы нам нужно инициализировать начальную информацию об инвентаризации Redis:
hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0
4.3 Ответ на информацию о пользователе
Мы запускаем службу http, прослушивающую порт:
package main
...
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3005", nil)
}
Мы проделали всю работу по инициализации выше, далее логика handleReq предельно ясна, достаточно судить об успешном захвате билета и вернуть информацию пользователю.
package main
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
redisConn := redisPool.Get()
LogMsg := ""
<-done
//全局读写锁
if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) {
util.RespJson(w, 1, "抢票成功", nil)
LogMsg = LogMsg + "result:1,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
} else {
util.RespJson(w, -1, "已售罄", nil)
LogMsg = LogMsg + "result:0,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
}
done <- 1
//将抢票状态写入到log中
writeLog(LogMsg, "./stat.log")
}
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "")
buf := []byte(content)
fd.Write(buf)
}
Как упоминалось ранее, при вычете запасов нам необходимо учитывать условия гонки.Здесь мы используем каналы, чтобы избежать одновременного чтения и записи и обеспечить эффективное и последовательное выполнение запросов. Мы записали возвращаемую информацию интерфейса в файл ./stat.log, чтобы упростить статистику измерения напряжения.
4.4 Стресс-тест автономной службы
Чтобы запустить службу, мы используем инструмент для проверки давления абс, чтобы проверить:
ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket
Ниже приведена информация о тесте под давлением моего локального бюджетного Mac.
This is ApacheBench, Version 2.3 <$Revision: 1826891 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: 127.0.0.1
Server Port: 3005
Document Path: /buy/ticket
Document Length: 29 bytes
Concurrency Level: 100
Time taken for tests: 2.339 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 1370000 bytes
HTML transferred: 290000 bytes
Requests per second: 4275.96 [#/sec] (mean)
Time per request: 23.387 [ms] (mean)
Time per request: 0.234 [ms] (mean, across all concurrent requests)
Transfer rate: 572.08 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 8 14.7 6 223
Processing: 2 15 17.6 11 232
Waiting: 1 11 13.5 8 225
Total: 7 23 22.8 18 239
Percentage of the requests served within a certain time (ms)
50% 18
66% 24
75% 26
80% 28
90% 33
95% 39
98% 45
99% 54
100% 239 (longest request)
По показателям у меня одна машина может обрабатывать 4000+ запросов в секунду, нормальные серверы все многоядерные конфигурации и проблем с обработкой 1W+ запросов нет. И глядя в лог, обнаруживается, что в течение всего процесса обслуживания запросы идут нормально, трафик ровный, и редис тоже нормальный:
//stat.log
...
result:1,localSales:145
result:1,localSales:146
result:1,localSales:147
result:1,localSales:148
result:1,localSales:149
result:1,localSales:150
result:0,localSales:151
result:0,localSales:152
result:0,localSales:153
result:0,localSales:154
result:0,localSales:156
...
5. Сводный обзор
В целом система seckill очень сложная. Здесь мы просто кратко представим и смоделируем, как можно оптимизировать одну машину для высокой производительности, как кластер может избежать единой точки отказа и обеспечить, чтобы заказы не были перепроданы или проданы много.Есть задача регулярно синхронизировать оставшиеся счета-фактуры и информацию о запасах из общего запаса и отображать ее пользователю, а пользователь не платит в течение срока действия заказа, освобождает заказ, пополняет запасы и так далее.
Мы реализовали основную логику захвата билетов с высокой степенью параллелизма.Можно сказать, что дизайн системы очень умный.Он ловко избегает операции ввода-вывода базы данных БД и большого количества одновременных запросов для сетевого ввода-вывода Redis.Почти все вычисления находятся в памяти. Он завершен, и он эффективно гарантирует, что он не будет перепродан или продан много, а также может допускать простои некоторых машин. Я думаю, что два из них особенно заслуживают изучения и обобщения:
-
Балансировка нагрузки, разделяй и властвуй. Благодаря балансировке нагрузки разный трафик распределяется по разным машинам, каждая машина обрабатывает свои собственные запросы и максимизирует собственную производительность, так что система в целом может выдерживать чрезвычайно высокий уровень параллелизма, как при работе в команде. крайность, и рост команды, естественно, велик.
-
Разумное использование параллелизма и асинхронности. Поскольку модель сетевой архитектуры epoll решила проблему c10k, асинхронность становится все более и более приемлемой для разработчиков серверов.Работа, которая может выполняться асинхронно, может выполняться асинхронно, что может привести к неожиданным результатам при функциональной разборке.Это может быть отражено в nginx, node.js и redis.Модель epoll, которую они используют для обработки сетевых запросов, показала нам, что один поток все еще может оказывать мощное влияние. Сервер вступил в эру многоядерности. Язык go, язык, рожденный для параллелизма, прекрасно использует преимущества многоядерности сервера. Многие задачи, которые могут обрабатываться одновременно, могут быть решены с помощью параллелизма. Например, когда go обрабатывает http-запросы, каждый запрос будет выполняться в горутине, короче говоря: как разумно сжать ЦП и заставить его играть должным образом — это направление, которое нам всегда нужно исследовать и изучать.