Project development: seckill system module development record

Simple development of second kill system

Reference: programming bad people

Video tutorial: https://www.bilibili.com/video/BV13a4y1t7Wh

Reference: https://github.com/engureguo/miaosha

Project source code: https://gitee.com/gengkunyuan/second-kill-case

Simple system development

Build environment

Import dependency:

 <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.2.6</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
</dependencies>

Profile:

server.port=8999
server.servlet.context-path=/ms

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ms
spring.datasource.username=root
spring.datasource.password=88888888

mybatis.mapper-locations=classpath:com/lut/mapper/*.xml
mybatis.type-aliases-package=com.lut.entity

logging.level.root=info
logging.level.com.lut.dao=debug

Establish data table and database table:

DROP TABLE IF EXISTS `order`;
CREATE TABLE `order`  (
  `id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `sid` int(11) NULL DEFAULT NULL,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `createDate` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `order` VALUES ('001', 1, 'zhangsan', '2021-11-12 00:00:00');

DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'name',
  `count` int(11) NOT NULL COMMENT 'stock',
  `sale` int(11) NOT NULL COMMENT 'Sold ',
  `version` int(11) NOT NULL COMMENT 'Optimistic lock, version number',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `stock` VALUES (1, 'IPhoneX', 100, 0, 0);

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `user` VALUES (1, 'admin', '123456');

SET FOREIGN_KEY_CHECKS = 1;

Existing problems

For oversold goods, Apache JMeter is used for stress testing. When multiple requests come, a large number of orders will be created

Pessimistic lock solution

Pessimistic lock: synchronized locks the entire called method

    @GetMapping("/kill")
    public synchronized String kill(Integer id){
        System.out.println("commodity ID by:"+id);
        try {
            //Call the seckill business according to the seckill commodity ID
            String orderid=orderService.kill(id);
            return "Second kill success,order ID by: "+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

Pit: pessimistic lock and transaction scope

Cause: after using pessimistic lock, there will be "oversold" in the test.

be careful!!!

Error statement: business layer plus synchronization code block

Pessimistic lock pit! Multi commit problems: Transactional and synchronized use initial concurrency problems at the same time. The transaction synchronization range is larger than the thread synchronization range. The synchronized code block is executed within a transaction. It can be inferred that the transaction has not been committed when the code block is executed. Therefore, after other threads enter the synchronized code block, the database data read is not the latest.

Solution: the synchronized synchronization range is larger than the transaction synchronization range. Synchronize outside the kill method of the business layer to ensure that the transaction has been committed when the lock is released

Improvement: ↓↓

Pessimistic lock: the method actually called by the synchronized lock

    @GetMapping("/kill")
    public String kill(Integer id){
        System.out.println("commodity ID by:"+id);
        try {
            synchronized (this){
                //Call the seckill business according to the seckill commodity ID
                String orderid=orderService.kill(id);
                return "Second kill success,order ID by: "+orderid;
            }
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

Problems caused by:

1. This causes thread blocking and system throughput degradation

2. The user experience is not good

Optimistic lock solution

Note: using optimistic lock to solve the oversold problem of goods is actually to hand over the main problem of preventing oversold to the database, and solve the oversold problem of goods under concurrent conditions by using the version field defined in the database and transactions in the database

Filter out certain requests at the database level

Simple code modification

@Service
@Transactional
public class OrderServiceImpl implements OrderService{

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Override
    public String kill(Integer id) {
        Stock stock=checkStock(id);
        updateSale(stock);
        return createOrder(stock);
    }

    //Verify inventory
    private Stock checkStock(Integer id){
        Stock stock = stockDao.checkStock(id);
        if (stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("Insufficient inventory!!!");
        }
        return stock;
    }

    //Deduct inventory
    private void updateSale(Stock stock){
        stock.setSale(stock.getSale()+1);
        stockDao.updateSale(stock);
    }

    //Create order
    private String createOrder(Stock stock){
        Order order=new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        order.setId(UUID.randomUUID().toString().substring(0,8));
        orderDao.createOrder(order);
        return order.getId();
    }
}

Dao layer:

@Component
public interface StockDao {
    //Check inventory
    Stock checkStock(Integer id);
    //Deduct inventory
    void updateSale(Stock stock);
    //Deduct inventory, use version number, return value: the number of items affected by database operation
    int updateSaleWithVersion(Stock stock);
}

Service layer:

    //Deduct inventory, use version number, return value: the number of items affected by database operation
    private void updateSaleWithVersion(Stock stock){
        int updaterows=stockDao.updateSaleWithVersion(stock);
        if (updaterows==0){
            throw new RuntimeException("Rush purchase failed, please try again");
        }
    }

Mapper file:

<!--    Deduct inventory, use version number, return value: the number of items affected by database operation-->
    <update id="updateSaleWithVersion" parameterType="Stock">
        update stock set
            sale=sale+1,version=version+1
        where
            id=#{id} and version=#{version}
    </update>

Interface current limiting scheme

Current limit: limit the number of requests in a certain time window, maintain the availability and stability of the system, and prevent the slow operation and downtime of the system caused by traffic surge

In the face of highly concurrent rush purchase requests, if we do not limit the flow of the interface, it may cause great pressure on the background system. When a large number of requests are snapped up successfully, the order interface needs to be called. Too many requests to the database will affect the stability of the system

Interface current limiting solution

The commonly used current limiting algorithms include token bucket and leaky bucket (funnel algorithm), while RateLimiter in Google's open source project Guava uses token bucket control algorithm. When developing high concurrency systems, there are three sharp tools to protect the system: cache, degradation and current limiting.

  • Cache: the purpose of cache is to improve system access speed and increase system processing capacity
  • Downgrade: when the server pressure increases sharply, downgrade some services and pages strategically according to the current business conditions and traffic, so as to release server resources and ensure the normal operation of core tasks
  • Current limiting: the purpose of current limiting is to protect the system by limiting the speed of concurrent access / requests or requests within a time window. Once the limit rate is reached, it can be processed such as denial of service, queuing or waiting, degradation, etc.

Funnel algorithm and token bucket algorithm

  • Funnel algorithm: the idea of leaky bucket algorithm is very simple. Water (request) enters the leaky bucket first, and the leaky bucket leaves the water at a certain speed. When the water inflow speed is too high, it will directly overflow. It can be seen that the leaky bucket algorithm can forcibly limit the data transmission rate.
  • Token bucket algorithm: originally originated from the computer network. When transmitting data on the network, in order to prevent network congestion, it is necessary to limit the flow out of the network so that the flow can be sent out at a relatively uniform speed. The token bucket algorithm realizes this function, which can control the number of data sent to the network and allow the sending of burst data. The token bucket with a fixed size can be sent at a constant speed by itself Tokens are generated continuously at the rate of. If the tokens are not consumed, or the consumed speed is less than the generated speed, the tokens will continue to increase until the bucket is filled. The tokens generated later will overflow from the bucket. Finally, the maximum number of tokens that can be saved in the bucket will never exceed the size of the bucket. This means that in the face of instantaneous large traffic, the algorithm can be used in a short time Request to get a large number of tokens in the room, and the process of getting tokens is not very expensive.

Funnel algorithm: it is not usually used, because requests exceeding the capacity of the bucket will be directly discarded

Token bucket algorithm: the request to get the token executes the business. The request that cannot get the token can wait until the token is obtained, or try to obtain the token within a certain time, and discard it after timeout

Token bucket algorithm is simple to use

Import dependency:

<dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>30.1.1-jre</version>
</dependency>
    private RateLimiter rateLimiter= RateLimiter.create(30);

    @GetMapping("/testLimiter")
    public String TestRateLimiter(Integer id){

        //Scheme 1: the token request is not obtained, and the user waits until the token is obtained
        //log.info("wait time:" + rateLimiter.acquire());

        //Scheme 2: set the waiting time. If the token is not obtained within the specified time, it will be discarded
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
            System.out.println("The current request is restricted and discarded directly, and the post sequence logic cannot be called");
            return "There are too many people at present. Please try again!";
        }

        System.out.println("Processing business.....");
        return "Successful rush purchase";
    }

Transformation spike project

After multiple requests come in, some requests are restricted, resulting in most of the goods in the database being sold, and a small number of goods cannot be sold. This is a normal phenomenon. These goods can be used for subsequent return and exchange

Benefit: inventory backup

Increase sales quantity: 1. More requests 2. Increase timeout 3. Increase requests for release

    private RateLimiter rateLimiter= RateLimiter.create(30);


    @GetMapping("killwithtoken")
    public String killWithToken(Integer id){
        System.out.println("Second kill commodity ID=" + id);
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("Rush purchase failed,Current activity is too hot,Please try again later");
            return "Rush purchase failed,Current activity is too hot,Please try again later";
        }
        try {
            //Kill the product according to the product ID
            String orderid=orderService.kill(id);
            return "Second kill success,order ID by:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

Hide seckill interface

In the previous courses, we have completed the current limit to prevent oversold goods and rush buying interfaces, which can prevent large traffic from directly exploding our server. In this article, we will start to pay attention to some details. There are still some problems in the system we are designing:

1. We should execute the second kill processing within a certain time, and we can't accept the second kill request at any time. How to join time verification?

2. For those who know a little about computers and have a crooked mind, they begin to get our interface address by capturing packets. What about rush buying through scripts?

3. After the start of the second kill, how to limit the request frequency of a single user, that is, the number of accesses per unit time?

This chapter mainly explains the single user anti brushing measures related to the rush purchase (order) interface in the seckill system, mainly including the following contents:

  • flash sale

  • Hide the interface of rush purchase (avoid F12 and capturing packets to obtain the interface)

  • Single user restricted frequency (restricted access times per unit time)

flash sale

Importing Redis configuration:

<!--        redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.5.1</version>
        </dependency>
spring.redis.port=6379
spring.redis.host=localhost
spring.redis.database=0

Use in project:

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    //Specific implementation of second kill
    @Override
    public String kill(Integer id) {
        if (!stringRedisTemplate.hasKey("kill"+id)){
            throw new RuntimeException("The current rush purchase of goods is over");
        }

        Stock stock=checkStock(id);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

Set the survival time of the key in redis cli:

set kill1 1 EX 10  
//key: kill + commodity ID value: 1 ex set the survival time to 10s

Buy interface concealment

For those who know a little about computers and use crooked brains, click F12 to open the browser console, and then click the snap up button to get the link of our snap up interface. (mobile APP and other clients can grab the package to get it) once the villain gets the rush purchase link, just write a little crawler code and simulate a rush purchase request, you can directly request our interface in the code to complete the order without clicking the order button. So there are thousands of mushroom wool legions, writing some scripts to rush to buy all kinds of second kill goods.

They only need to start sending a large number of requests continuously in 000 milliseconds at the rush buying time, which is faster than clicking the rush buying button on the APP. After all, people's speed is limited, not to mention that the APP may have to go through several layers of front-end verification to really send requests.

Add user table:

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;


INSERT INTO `user` VALUES (1, 'admin', '123456');

Function of generating MD5

Controllers: generating MD5

    @Autowired
    private OrderService orderService;
    
	//Generate an MD5 based on the product ID and user ID
    @RequestMapping("md5")
    public String getMd5(Integer id,Integer userid){
        String md5;
        try {
            md5=orderService.getMd5(id,userid);
        }catch (Exception e){
            e.printStackTrace();
            return "obtain MD5 Failed:"+e.getMessage();
        }
        return "Obtained MD5 Is:"+md5;
    }

ServiceImpl:

    @Override
    public String getMd5(Integer goodid, Integer userid) {
        //1. Check the legitimacy of users
        User user=userDao.findById(userid);
        if (user==null){
            throw new RuntimeException("User information does not exist!");
        }
        log.info("User information:"+ user);

        //2. Inspect the legitimacy of the goods
        Stock stock = stockDao.checkStock(goodid);
        if (stock==null){
            throw new RuntimeException("Product information does not exist!");
        }
        log.info("Commodity information:"+stock);

        //3. Generate hashkey: user ID + commodity ID
        String hashkey="KEY_"+userid+"_"+goodid;

        //4. Generate MD5, random salt: #$% 587@ Salt and put it into Redis
        String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
        stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
        return md5Str;
    }

Dao layer:

@Mapper
public interface UserDao {
    User findById(Integer id);
}

@Mapper
public interface StockDao {
    //Check inventory
    Stock checkStock(Integer id);
    //Deduct inventory
    void updateSale(Stock stock);
    //Deduct inventory, use version number, return value: the number of items affected by database operation
    int updateSaleWithVersion(Stock stock);
}

Mapper file: UserDaoMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lut.dao.UserDao">

   <select id="findById" parameterType="Integer" resultType="User">
       select id,name,password from user where id=#{id}
   </select>

</mapper>

Transform seckill interface

Controller: now you need to pass three parameters: Commodity ID, user ID and MD5 string

    @Autowired
    private OrderService orderService;

    private RateLimiter rateLimiter= RateLimiter.create(30);

    //Optimistic lock + token bucket + MD5
    @GetMapping("killWithTokenMd5")
    public String killWithTokenMD5(Integer stockid,Integer userId,String md5Str){
        System.out.println("Second kill commodity ID=" + stockid);
        //Token bucket current limiting
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("Rush purchase failed,Current activity is too hot,Please try again later");
            return "Rush purchase failed,Current activity is too hot,Please try again later";
        }
        try {
            //Second kill the product according to the product ID. here, judge MD5 in Redis first
            String orderid=orderService.kill(stockid,userId,md5Str);
            return "Second kill success,order ID by:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

ServiceImpl: second kill implementation, passing three parameters

    @Override
    public String kill(Integer stockid, Integer userId, String md5Str) {
        //Verify whether the second kill commodity in Redis times out
//        if (!stringRedisTemplate.hasKey("kill"+stockid)){
//            throw new RuntimeException("the rush buying activity of the current commodity has ended");
//        }

        //Verify that the passed signature is valid
        String hashkey="KEY_"+userId+"_"+stockid;
        String redisStr=stringRedisTemplate.opsForValue().get(hashkey);

        if (redisStr==null){
            throw new RuntimeException("The request is illegal because it does not carry a verification signature");
        }
        if (!redisStr.equals(md5Str)){
            throw new RuntimeException("The current request data is illegal, please try again!");
        }

        Stock stock=checkStock(stockid);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

Access test

First access the MD5 interface, get the MD5 string and put it in Redis:

http://localhost:8999/ms/stock/md5?id=1&userid=1

Then access the order interface and pass the parameters: Commodity ID, user ID and MD5 string

http://localhost:8999/ms/stock/killWithTokenMd5?stockid=1&userId=1&md5Str=7177028f5b50bd46a448263d4cff1183

Single user limited frequency

Suppose we do a good job of interface hiding, but as I said above, there are always boring people who will write a complex script, first request the hash value, and then immediately request the purchase. If your app's order button is very poor, everyone needs to start the grab 0.5 seconds to request success, which may make the script still be able to rush to buy successfully in front of everyone.

We need to take an additional measure to limit the rush frequency of individual users.

In fact, it is easy to think of using redis to make access statistics for each user, or even bring the commodity id to make access statistics for a single commodity, which is feasible. We first implement a limit on the user's access frequency. When the user applies for an order, we check the user's access times. If the user exceeds the access times, he will not be allowed to place an order!

Modify Controller:

    //Optimistic lock + token bucket + MD5 (interface hiding) + single user access frequency limit
    @GetMapping("/killWithTokenMD5Count")
    public String killWithTokenMD5Count(Integer stockid,Integer userId,String md5Str){
        System.out.println("Second kill commodity ID=" + stockid);

        //1. Token bucket current limiting
        if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            log.info("Rush purchase failed,Current activity is too hot,Please try again later");
            return "Rush purchase failed,Current activity is too hot,Please try again later";
        }
        try {

            //2. Add single user limit call frequency
            //2.1 add count in redis
            int count=userService.saveUserCount(userId);
            log.info("user"+userId+"The number of visits as of this time is: "+count);
            //2.2 judge whether the number of times is exceeded
            boolean isBanned=userService.getUserCount(userId);
            if (isBanned){
                return "Your clicks are too frequent, please try again later!";
            }

            //3. Kill the commodity according to the commodity ID, and judge MD5 in Redis first
            String orderid=orderService.kill(stockid,userId,md5Str);
            return "Second kill success,order ID by:"+orderid;
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

UserService:

public interface UserService {
    boolean getUserCount(Integer userId);
    int saveUserCount(Integer userId);
}

UserServiceImpl: store access times in Redis

/**
 * @Author: GengKY
 * @Date: 2021/11/11 18:14
 */
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService{

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Autowired
    private UserDao userDao;

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public String kill(Integer stockid, Integer userId, String md5Str) {
        //Verify whether the second kill commodity in Redis times out
//        if (!stringRedisTemplate.hasKey("kill"+stockid)){
//            throw new RuntimeException("the rush buying activity of the current commodity has ended");
//        }

        //Verify that the passed signature is valid
        String hashkey="KEY_"+userId+"_"+stockid;
        String redisStr=stringRedisTemplate.opsForValue().get(hashkey);

        if (redisStr==null){
            throw new RuntimeException("The request is illegal because it does not carry a verification signature");
        }
        if (!redisStr.equals(md5Str)){
            throw new RuntimeException("The current request data is illegal, please try again!");
        }

        Stock stock=checkStock(stockid);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

    //Specific implementation of second kill
    @Override
    public String kill(Integer id) {
        if (!stringRedisTemplate.hasKey("kill"+id)){
            throw new RuntimeException("The current rush purchase of goods is over");
        }

        Stock stock=checkStock(id);
        updateSaleWithVersion(stock);
        return createOrder(stock);
    }

    @Override
    public String getMd5(Integer goodid, Integer userid) {
        //1. Check the legitimacy of users
        User user=userDao.findById(userid);
        if (user==null){
            throw new RuntimeException("User information does not exist!");
        }
        log.info("User information:"+ user);

        //2. Inspect the legitimacy of the goods
        Stock stock = stockDao.checkStock(goodid);
        if (stock==null){
            throw new RuntimeException("Product information does not exist!");
        }
        log.info("Commodity information:"+stock);

        //3. Generate hashkey: user ID + commodity ID
        String hashkey="KEY_"+userid+"_"+goodid;

        //4. Generate MD5, random salt: #$% 587@ Salt and put it into Redis
        String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
        stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
        return md5Str;
    }



    //Verify inventory
    private Stock checkStock(Integer id){
        Stock stock = stockDao.checkStock(id);
        if (stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("Insufficient inventory!!!");
        }
        return stock;
    }

    //Deduct inventory
    private void updateSale(Stock stock){
        stock.setSale(stock.getSale()+1);
        stockDao.updateSale(stock);
    }

    //Deduct inventory, use version number, return value: the number of items affected by database operation
    private void updateSaleWithVersion(Stock stock){
        int updaterows=stockDao.updateSaleWithVersion(stock);
        if (updaterows==0){
            throw new RuntimeException("Rush purchase failed, please try again");
        }
    }

    //Create order
    private String createOrder(Stock stock){
        Order order=new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        order.setId(UUID.randomUUID().toString().substring(0,8));
        orderDao.createOrder(order);
        return order.getId();
    }
}

Tags: Java Database MySQL

Posted on Fri, 12 Nov 2021 09:36:39 -0500 by covert215