SSM (18) Практика архитектуры Second Kill

Spring Boot Redis Архитектура Kafka

предисловие

доJava-InterviewЯ упомянул дизайн архитектуры seckill, и на этот раз я просто реализовал его на основе теории.

На этот раз производительность постепенно улучшается, чтобы добиться эффекта одновременных всплесков.Если статья длинная, пожалуйста, подготовьте скамейку для семян дыни (liushuizhang😂).

Весь код, задействованный в этой статье:

Окончательная схема архитектуры:

系统架构设计.png

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

  • внешний запросwebслой, соответствующий кодcontroller.
  • Затем отправьте реальную проверку запасов, заказ и другие запросы наServiceслой (где вызовы RPC все еще используютdubbo, просто обновитесь до последней версии, на этот раз мы не будем слишком много обсуждать детали, связанные с даббо, вы можете проверить это, если вам интересноРаспределенная архитектура на основе dubbo).
  • ServiceЗатем слой передает данные, и заказ выполняется.

неограниченный

На самом деле, помимо сценария seckill, обычный процесс заказа можно просто разделить на следующие этапы:

  • посмотри инвентарь
  • вычет инвентаря
  • Создать заказ
  • платить

Основываясь на приведенной выше архитектуре, мы имеем следующую реализацию:

Посмотрите на фактическую структуру проекта:

Все так же, как и раньше:

  • обеспечитьAPIиспользуется дляServiceреализация слоя иwebУровень потребления.
  • Веб-слой — это простоSpringMVC.
  • ServiceСлой — это реальная посадка данных.
  • SSM-SECONDS-KILL-ORDER-CONSUMERбудет упомянуто позжеKafkaПотребление.

В базе также всего две простые таблицы для имитации размещения заказа:

CREATE TABLE `stock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
  `count` int(11) NOT NULL COMMENT '库存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '乐观锁,版本号',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
CREATE TABLE `stock_order` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `sid` int(11) NOT NULL COMMENT '库存ID',
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;

веб-уровеньcontrollerвыполнить:


@Autowired
private StockService stockService;
@Autowired
private OrderService orderService;
@RequestMapping("/createWrongOrder/{sid}")
@ResponseBody
public String createWrongOrder(@PathVariable int sid) {
    logger.info("sid=[{}]", sid);
    int id = 0;
    try {
        id = orderService.createWrongOrder(sid);
    } catch (Exception e) {
        logger.error("Exception",e);
    }
    return String.valueOf(id);
}

где Web называется потребителем, чтобы увидетьOrderServiceOut dubbo предоставляет услуги.

Сервисный уровень,OrderServiceвыполнить:

Во-первых, это реализация API (исходящий интерфейс будет предоставлен в API):

@Service
public class OrderServiceImpl implements OrderService {
    @Resource(name = "DBOrderService")
    private com.crossoverJie.seconds.kill.service.OrderService orderService ;
    @Override
    public int createWrongOrder(int sid) throws Exception {
        return orderService.createWrongOrder(sid);
    }
}

Вот простой звонокDBOrderServiceВ реализации DBOrderService — это реальная посадка данных, то есть запись в БД.

Реализация DOrderService:

Transactional(rollbackFor = Exception.class)
@Service(value = "DBOrderService")
public class OrderServiceImpl implements OrderService {
    @Resource(name = "DBStockService")
    private com.crossoverJie.seconds.kill.service.StockService stockService;
    @Autowired
    private StockOrderMapper orderMapper;
    
    @Override
    public int createWrongOrder(int sid) throws Exception{
        //校验库存
        Stock stock = checkStock(sid);
        //扣库存
        saleStock(stock);
        //创建订单
        int id = createOrder(stock);
        return id;
    }
    
    private Stock checkStock(int sid) {
        Stock stock = stockService.getStockById(sid);
        if (stock.getSale().equals(stock.getCount())) {
            throw new RuntimeException("库存不足");
        }
        return stock;
    }
    
    private int saleStock(Stock stock) {
        stock.setSale(stock.getSale() + 1);
        return stockService.updateStockById(stock);
    }
    
    private int createOrder(Stock stock) {
        StockOrder order = new StockOrder();
        order.setSid(stock.getId());
        order.setName(stock.getName());
        int id = orderMapper.insertSelective(order);
        return id;
    }        
        
}

10 акций предварительно инициализированы.

вызвать его вручнуюcreateWrongOrder/1Обнаружение интерфейса:

Инвентарная таблица:

Форма заказа:

Все выглядит хорошо, и данные в порядке.

но при использованииJMeterПри одновременном тестировании:

Конфигурация теста: 300 потоков одновременно, протестируйте два раунда, чтобы увидеть результаты в базе данных:

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

Это явно классический феномен перепроданности.

На самом деле вызов интерфейса вручную сейчас вернет недостаточный запас, но уже слишком поздно.

оптимистическое обновление блокировки

Как избежать вышеуказанного явления?

Самый простой способ - естественно-оптимистическая блокировка.Я не буду здесь слишком много обсуждать это.Друзья, которые незнакомы, могут прочитать.это.

Давайте посмотрим на конкретную реализацию:

На самом деле больше ничего не изменилось, в основном сервисный слой.

@Override
public int createOptimisticOrder(int sid) throws Exception {
    //校验库存
    Stock stock = checkStock(sid);
    //乐观锁更新库存
    saleStockOptimistic(stock);
    //创建订单
    int id = createOrder(stock);
    return id;
}
private void saleStockOptimistic(Stock stock) {
    int count = stockService.updateStockByOptimistic(stock);
    if (count == 0){
        throw new RuntimeException("并发更新库存失败") ;
    }
}

Соответствующий XML:

<update id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock">
    update stock
    <set>
        sale = sale + 1,
        version = version + 1,
    </set>
    WHERE id = #{id,jdbcType=INTEGER}
    AND version = #{version,jdbcType=INTEGER}
</update>

Те же условия тестирования, мы снова проведем вышеуказанный тест/createOptimisticOrder/1:

На этот раз я обнаружил, что оба ордера на акции в порядке.

Глядя в лог нашел:

Многие одновременные запросы будут отвечать ошибками, и это помогает.

Увеличение пропускной способности

Чтобы еще больше повысить пропускную способность и эффективность реагирования во время всплеска, сеть и сервис здесь были масштабированы горизонтально.

  • Интернет использует Nginx для загрузки.
  • Службы также являются несколькими приложениями.

Вы можете интуитивно увидеть эффект при тестировании с помощью JMeter.

Так как я тестировал его на маленьком сервере водопровода в облаке Alibaba, а конфигурация невысокая и приложения все на одном сервере, это не в полной мере отражает преимущества в производительности (NginxЭто также увеличит дополнительное потребление сети при переадресации нагрузки).

Простой CI со сценарием оболочки

Считается, что с момента применения нескольких развертываний боль ручного тестирования выпуска испытывает каждый, кто с этим столкнулся.

На этот раз у меня не было сил создавать полный CI CD, я просто написал простой скрипт для реализации автоматического развертывания.Я надеюсь вдохновить студентов, у которых нет опыта в этой области:

построить сеть

#!/bin/bash
# 构建 web 消费者
#read appname
appname="consumer"
echo "input="$appname
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')
# 遍历杀掉 pid
for var in ${PID[@]};
do
    echo "loop pid= $var"
    kill -9 $var
done
echo "kill $appname success"
cd ..
git pull
cd SSM-SECONDS-KILL
mvn -Dmaven.test.skip=true clean package
echo "build war success"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/webapps
echo "cp tomcat-dubbo-consumer-8083/webapps ok!"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/webapps
echo "cp tomcat-dubbo-consumer-7083-slave/webapps ok!"
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.sh
echo "tomcat-dubbo-consumer-8083/bin/startup.sh success"
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.sh
echo "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success"
echo "start $appname success"

Услуги сборки

# 构建服务提供者
#read appname
appname="provider"
echo "input="$appname
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')
#if [ $? -eq 0 ]; then
#    echo "process id:$PID"
#else
#    echo "process $appname not exit"
#    exit
#fi
# 遍历杀掉 pid
for var in ${PID[@]};
do
    echo "loop pid= $var"
    kill -9 $var
done
echo "kill $appname success"
cd ..
git pull
cd SSM-SECONDS-KILL
mvn -Dmaven.test.skip=true clean package
echo "build war success"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/webapps
echo "cp tomcat-dubbo-provider-8080/webapps ok!"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/webapps
echo "cp tomcat-dubbo-provider-7080-slave/webapps ok!"
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"
echo "start $appname success"

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

Это самые основные команды Linux, и я считаю, что каждый может их понять.

Оптимистическое обновление блокировки + лимит распределенного тока

Вышеуказанные результаты вроде бы не проблема, но до них еще далеко.

Здесь нет проблем, просто имитируя параллелизм 300, но когда запрос достигает 3000, 3W, 300W?

Хотя его можно масштабировать по горизонтали для поддержки большего количества запросов.

Но можно ли решить проблему с наименьшим количеством ресурсов?

На самом деле, тщательный анализ покажет:

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

Так что будет99%запросы недействительны.

Всем известно: большинство баз данных приложений — это соломинка, которая ломает хребет верблюду.

пройти черезDruidМониторинг, чтобы увидеть ситуацию предыдущего запроса к базе данных:

Потому что Сервис — это два приложения.

База данных также имеет более 20 подключений.

Как его оптимизировать?
На самом деле легко представитьРаспределенный лимит.

Мы держим параллелизм в управляемом диапазоне и быстро теряем работоспособность, чтобы защитить систему в максимально возможной степени.

распределенный-redis-инструмент ⬆️v1.0.3

правильно для этогоGitHub.com/crossover J я…Сделал небольшой апгрейд.

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

Обновление API

Измененный API выглядит следующим образом:

@Configuration
public class RedisLimitConfig {
    private Logger logger = LoggerFactory.getLogger(RedisLimitConfig.class);
    @Value("${redis.limit}")
    private int limit;
    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;
    @Bean
    public RedisLimit build() {
        RedisLimit redisLimit = new RedisLimit.Builder(jedisConnectionFactory, RedisToolsConstant.SINGLE)
                .limit(limit)
                .build();
        return redisLimit;
    }
}

Здесь строитель использует вместоJedisConnectionFactory, поэтому его нужно использовать с Spring.

И во время инициализации он показывает, развернут ли входящий Redis в кластере или на одной машине (кластер настоятельно рекомендуется, после текущего ограничения все еще существует определенное давление на Redis).

Реализация ограничения тока

Теперь, когда API был обновлен, реализацию, естественно, нужно будет изменить:

/**
 * limit traffic
 * @return if true
 */
public boolean limit() {
    //get connection
    Object connection = getConnection();
    Object result = limitRequest(connection);
    if (FAIL_CODE != (Long) result) {
        return true;
    } else {
        return false;
    }
}
private Object limitRequest(Object connection) {
    Object result = null;
    String key = String.valueOf(System.currentTimeMillis() / 1000);
    if (connection instanceof Jedis){
        result = ((Jedis)connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        ((Jedis) connection).close();
    }else {
        result = ((JedisCluster) connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        try {
            ((JedisCluster) connection).close();
        } catch (IOException e) {
            logger.error("IOException",e);
        }
    }
    return result;
}
private Object getConnection() {
    Object connection ;
    if (type == RedisToolsConstant.SINGLE){
        RedisConnection redisConnection = jedisConnectionFactory.getConnection();
        connection = redisConnection.getNativeConnection();
    }else {
        RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection();
        connection = clusterConnection.getNativeConnection() ;
    }
    return connection;
}

Если это родное приложение Spring, его следует использовать@SpringControllerLimit(errorCode = 200)аннотация.

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

веб-сторона:

/**
 * 乐观锁更新库存 限流
 * @param sid
 * @return
 */
@SpringControllerLimit(errorCode = 200)
@RequestMapping("/createOptimisticLimitOrder/{sid}")
@ResponseBody
public String createOptimisticLimitOrder(@PathVariable int sid) {
    logger.info("sid=[{}]", sid);
    int id = 0;
    try {
        id = orderService.createOptimisticOrder(sid);
    } catch (Exception e) {
        logger.error("Exception",e);
    }
    return String.valueOf(id);
}

На стороне сервиса обновления нет, а оптимистическая блокировка по-прежнему используется для обновления базы данных.

Проверьте еще раз, чтобы увидеть эффект/createOptimisticLimitOrderByRedis/1:

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

Оптимистическое обновление блокировки + лимит распределенного тока + кеш Redis

На самом деле, более внимательное изучение данных мониторинга Druid показало, что этот SQL запрашивался несколько раз:

Фактически, это SQL для запроса запасов в реальном времени, в основном для определения наличия запасов перед размещением каждого заказа.

Это тоже пункт оптимизации.

Мы можем поместить такие данные в память, и эффективность намного выше, чем в базе данных.

Так как наше приложение распределённое, то кэширование в куче явно не подходит, а Redis очень подходит.

Основное преобразование на этот раз — это сервисный уровень:

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

Основной код выглядит следующим образом:

@Override
public int createOptimisticOrderUseRedis(int sid) throws Exception {
    //检验库存,从 Redis 获取
    Stock stock = checkStockByRedis(sid);
    //乐观锁更新库存 以及更新 Redis
    saleStockOptimisticByRedis(stock);
    //创建订单
    int id = createOrder(stock);
    return id ;
}
private Stock checkStockByRedis(int sid) throws Exception {
    Integer count = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_COUNT + sid));
    Integer sale = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_SALE + sid));
    if (count.equals(sale)){
        throw new RuntimeException("库存不足 Redis currentCount=" + sale);
    }
    Integer version = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_VERSION + sid));
    Stock stock = new Stock() ;
    stock.setId(sid);
    stock.setCount(count);
    stock.setSale(sale);
    stock.setVersion(version);
    return stock;
}    
/**
 * 乐观锁更新数据库 还要更新 Redis
 * @param stock
 */
private void saleStockOptimisticByRedis(Stock stock) {
    int count = stockService.updateStockByOptimistic(stock);
    if (count == 0){
        throw new RuntimeException("并发更新库存失败") ;
    }
    //自增
    redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ;
    redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ;
}

Испытание под давлением, чтобы увидеть фактический эффект/createOptimisticLimitOrderByRedis/1:

В итоге выяснилось, что с данными проблем нет, да и запросы и параллелизм базы тоже упали.

Оптимистическое обновление блокировки + лимит распределенного тока + кеш Redis + асинхронный Kafka

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

Все приведенные выше примеры на самом деле являются синхронными запросами, и мы можем использовать преобразование синхронного в асинхронное для повышения производительности.

Здесь мы будем асинхронизировать операции написания заказов и обновления инвентаря, используяKafkaДля выполнения роли развязки и очереди.

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

Затем потребительская программа хранит и выгружает данные.

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

Кода здесь слишком много, чтобы публиковать его, программа-потребитель фактически переписывает логику предыдущего уровня службы, но использует SpringBoot.

Заинтересованные друзья могут посмотреть.

GitHub.com/crossover J я…

Суммировать

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

  • Попробуйте заблокировать запросы вверх по течению.
  • Вы также можете ограничить ток на основе UID.
  • Минимизируйте запросы, попадающие в БД.
  • Используйте кеш.
  • Синхронная работа асинхронной.
  • Быстрый сбой, ранний сбой, защита приложений.

Кодировать слова непросто Это должно быть самое большое количество слов, которое я когда-либо писал. Думая о составе 800 слов в старшей школе тогда, я не мог удержаться 😂, можно представить, насколько это редкость.

Приведенное выше содержание приветствуется для обсуждения.

Дополнительный

Недавно я обобщил некоторые знания, связанные с Java, и заинтересованные друзья могут поддерживать их вместе.

адрес:GitHub.com/crossover J я…