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(); } }