秒杀demo

192 阅读7分钟

秒杀项目真的是早有耳闻,可以说是大火有一阵子,因为这其中涉及高并发、数据库、缓存,更有甚者还有分布式、分库分表、集群等。

这次有机会跟着视频学习了一点秒杀系统,这里做个总结

参考

www.bilibili.com/video/BV1CE…

www.bilibili.com/video/BV13a…

基础环境和工具

  • IDEA 2020 + JDK8
  • SpringBoot 2.x
  • 虚拟机CentOS上的 MySQL 5.7x + Redis 6.x
  • Mybatis
  • Lombok
  • Navicat (MySQL可视化工具)
  • Redis Desktop Manager (Redis可视化工具)
  • MobaXterm (SSH工具)
  • JMeter (压力测试工具)
  • Postman/Chrome

pom.xml 基础依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.2.4</version>
    </dependency>


    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

application

server:
  port: 8090
spring:
  datasource:
    url: jdbc:mysql://192.168.1.106:3306/seckill
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root

  redis:
    host: 192.168.1.106
    port: 6379
    password: root
mybatis:
  mapper-locations: classpath:mapper/*.xml #sql映射文件位置
  type-aliases-package: com.wnh.entity #实体类别名
  configuration:
    map-underscore-to-camel-case: true
# 配置日志
logging.level.root=info
logging.level.com.wnh.dao=debug

项目框架

项目结构

-- ----------------------------
-- Table structure for stock
-- ----------------------------
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock`  (
  `sid` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',
  `name` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '商品名称',
  `total` int(11) NOT NULL COMMENT '库存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '版本号',
  PRIMARY KEY (`sid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for stock_order
-- ----------------------------
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order`  (
  `oid` int(11) NOT NULL AUTO_INCREMENT,
  `sid` int(11) NOT NULL,
  `create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0),
  PRIMARY KEY (`oid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4749 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

注意这里订单不能命名为order,MySQL保留字错误

Entity

Stock

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Stock {
    private Integer sid;
    private String name;
    private Integer total;
    private Integer sale;
    private Integer version;

}

Order

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Order {
    private Integer oid;
    private Integer sid;
    private Date createTime;
}

Mapper

商品Mapper

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wnh.dao.StockMapper">
    <update id="updateSale" parameterType="stock">
        update stock
        set sale=sale + 1
        where sid = #{sid}
          and total > sale
    </update>

    <update id="updateSaleWithVersion" parameterType="stock">
        update stock
        set sale=sale + 1,
            version=version + 1
        where sid = #{sid}
          and version = #{version}
    </update>

    <select id="checkStock" parameterType="int" resultType="stock">
        select sid, name, total, sale, version
        from stock
        where sid = #{id}
    </select>

    <select id="listStocks" resultType="stock">
        select sid, total, sale
        from stock
    </select>
</mapper>

可以发现这里有两个不同的更新操作,两个都是利用数据库的乐观锁实现的简单的并发处理

订单Mapper

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wnh.dao.OrderMapper">

    <!--useGeneratedKeys="true" 数据库自增生成 keyProperty="oid" 返回生成值到 -->
    <insert id="createOrder" parameterType="order" useGeneratedKeys="true" keyProperty="oid">
        insert into stock_order
        values (#{oid}, #{sid}, #{createTime})
    </insert>
</mapper>

Service

StockService

@Service
@Transactional
public class StockServiceImpl implements StockService{
    @Autowired
    private StockMapper stockMapper;

    @Override
    public List<Stock> listStocks() {
        return stockMapper.listStocks();
    }

    // 检查库存
    @Override
    public Stock checkStock(Integer id) {
        Stock stock = stockMapper.checkStock(id);
        if (stock.getSale().equals(stock.getTotal())) {
            throw new RuntimeException("库存不足");
        }
        return stock;
    }

    // 已售增加
    @Override
    public int updateSale(Stock stock) {
        return stockMapper.updateSale(stock);
    }
    
}

OrderService

@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockService stockService;

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public int kill(Integer id) {
        // 校验库存
        Stock stock = stockService.checkStock(id);
        // 扣除库存
        int up = stockService.updateSale(stock);
        if (up == 0) {
            throw new RuntimeException("库存不足");
        }
        // 创建订单
        return createOrder(stock);
    }

    // 创建订单
    private Integer createOrder(Stock stock) {
        Order order = new Order();
        order.setSid(stock.getSid()).setCreateTime(new Date());
        orderMapper.createOrder(order);
        return order.getOid();
    }
}

关键接口是 OrderService 的 kill 方法

Controller

@RestController
@RequestMapping("stock")
public class BuyController {
    @Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;

    // 秒杀

    @GetMapping("kill")
    public String kill(Integer id) {

        System.out.println("秒杀商品id = " + id);

        try {
            int orderId = orderService.kill(id);
            return "秒杀成功,订单id为:" + orderId;
        } catch (Exception e) {
            e.printStackTrace();
            return e.getMessage();
        }
    }

}

启动项目测试

初始数据库数据

商品表

sidnametotalsaleversion
1iphone810000
2p40500
3k3020000

订单表为空

postman一次请求

一次请求后

sale+1,变为1,增加了一条订单,其他没有变化

JMeter测试

JMeter测试

sale变为100,订单总数100条,Throughput为100-200/sec

总体没出现大问题,但是Throughput不尽人意

恢复原数据把 JMeter 参数稍稍一改,1000不变,Loop Cout 变为10,也就是总共 10000 次请求,这次整个测试过程变得很慢,最终Throughput也停留在 30-40/sec,当然这需要优化

加入Redis

@RestController
@RequestMapping("stock")
public class BuyController {
    @Autowired
    private StockService stockService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private OrderService orderService;

    @PostConstruct
    public void init() {
        List<Stock> stocks = stockService.listStocks();
        for (Stock stock : stocks) {
            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");
        }
    }

    // 秒杀

    @GetMapping("kill")
    public String kill(Integer id) {
        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);
        if (increment < 0) {
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            return "商品已售完";
        }

        System.out.println("秒杀商品id = " + id);

        try {
            int orderId = orderService.kill(id);
            return "秒杀成功,订单id为:" + orderId;
        } catch (Exception e) {
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            e.printStackTrace();
            return e.getMessage();
        }
    }

}

启动系统

查到 Redis 里已经存入数据,Postman测试没有问题

JMeter 测试过程很快,但 Throughput 提升一点

二级缓存

@RestController
@RequestMapping("stock")
public class BuyController {
    @Autowired
    private StockService stockService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private OrderService orderService;

    private static ConcurrentHashMap<Integer, Boolean> stockSoldOutMap = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        List<Stock> stocks = stockService.listStocks();
        for (Stock stock : stocks) {
            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");
        }
    }

    // 秒杀

    @GetMapping("kill")
    public String kill(Integer id) {
        if (stockSoldOutMap.get(id) != null) {
            return "商品已售完";
        }
        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);
        if (increment < 0) {
            stockSoldOutMap.put(id, true);
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            return "商品已售完";
        }

        System.out.println("秒杀商品id = " + id);

        try {
            int orderId = orderService.kill(id);
            return "秒杀成功,订单id为:" + orderId;
        } catch (Exception e) {
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            if (stockSoldOutMap.get(id) != null) {
                stockSoldOutMap.remove(id);
            }
            e.printStackTrace();
            return e.getMessage();
        }
    }

}

再次测试,因为通过JVM内存缓存了是否售空,所以系统还能再提升一些。

令牌桶限流

依赖

<!-- google开源工具类RateLimiter令牌桶实现 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1-jre</version>
</dependency>

Controller

// 创建令牌桶实例

private RateLimiter rateLimiter = RateLimiter.create(40);

@GetMapping("sale")
public String sale(Integer id) {
    // 1.没有获取 token 请一直到获取到 token 令牌
    // log.info("等待时间:"+rateLimiter.acquire());

    // 2.设置等待时间,如果在等待时间内获取到了 token 令牌,则处理业务,没有则抛弃
    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");
    }
    System.out.println("处理业务.............");
    return "抢购成功";
}

完整Controller

@RestController
@RequestMapping("stock")
@Slf4j
public class BuyController {
    @Autowired
    private StockService stockService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private OrderService orderService;

    private static ConcurrentHashMap<Integer, Boolean> stockSoldOutMap = new ConcurrentHashMap<>();


    // 创建令牌桶实例

    private RateLimiter rateLimiter = RateLimiter.create(20);

    @GetMapping("sale")
    public String sale(Integer id) {
        // 1.没有获取 token 请一直到获取到 token 令牌
        // log.info("等待时间:"+rateLimiter.acquire());

        // 2.设置等待时间,如果在等待时间内获取到了 token 令牌,则处理业务,没有则抛弃
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
            System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");
            return "抢购失败";
        }
        System.out.println("处理业务.............");
        return "抢购成功";
    }

    @GetMapping("killtoken")
    public String killtoken(Integer id) {
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
            System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");
            return "抢购失败";
        }
        return kill(id);
    }


    @PostConstruct
    public void init() {
        List<Stock> stocks = stockService.listStocks();
        for (Stock stock : stocks) {
            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");
        }
    }

    // 秒杀

    @GetMapping("kill")
    public String kill(Integer id) {
        if (stockSoldOutMap.get(id) != null) {
            return "商品已售完";
        }
        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);
        if (increment < 0) {
            stockSoldOutMap.put(id, true);
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            return "商品已售完";
        }

        System.out.println("秒杀商品id = " + id);

        try {
            int orderId = orderService.kill(id);
            return "秒杀成功,订单id为:" + orderId;
        } catch (Exception e) {
            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
            if (stockSoldOutMap.get(id) != null) {
                stockSoldOutMap.remove(id);
            }
            e.printStackTrace();
            return e.getMessage();
        }
    }

}

测试 killtoken 接口,可以发现在一定情况下是卖不完的,虽然请求数大于库存数,这时仍然没有超卖问题

问题

  • 规定时间段内可抢购,其他时间不能
  • 恶意抓包获取接口,脚本抢购
  • 单个用户限制抢购

限时抢购

利用 Redis 过期时间,设置抢购时间

@GetMapping("kill")
public String kill(Integer id) {
    // 存在则还是抢购时间段内,否则活动已结束
    if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + id)) {
        System.out.println("秒杀活动已结束...");
        return "秒杀活动已结束";
    }
    if (stockSoldOutMap.get(id) != null) {
        return "商品已售完";
    }
    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);
    if (increment < 0) {
        stockSoldOutMap.put(id, true);
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
        return "商品已售完";
    }


    System.out.println("秒杀商品id = " + id);

    try {
        int orderId = orderService.kill(id);
        return "秒杀成功,订单id为:" + orderId;
    } catch (Exception e) {
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);
        if (stockSoldOutMap.get(id) != null) {
            stockSoldOutMap.remove(id);
        }
        e.printStackTrace();
        return e.getMessage();
    }
}

测试

设置1号商品5秒内可抢购

set stock_kill_1 1 EX 5

JMeter测试正常,部分因令牌桶限流,部分因超过抢购时间直接返回

防脚本验证

用户表

CREATE TABLE `user` (
  `uid` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `name` varchar(20) NOT NULL COMMENT '用户名',
  `password` varchar(20) NOT NULL COMMENT '密码',
  PRIMARY KEY (`uid`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8

加入

uidnamepassword
1小杨123456

User

@Data
@ToString
public class User {
    private Integer uid;
    private String name;
    private String password;
}

UserMapper

@Mapper
public interface UserMapper {
    User findUserById(Integer id);
}

UserMapper.xml

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wnh.dao.UserMapper">


    <select id="findUserById" parameterType="int" resultType="user">
        select uid, name, password
        from user
        where uid = #{id}
    </select>
</mapper>

Controller新加入

// 生成MD5
@RequestMapping("md5")
public String getMD5(Integer sid, Integer uid) {
    String md5;
    try {
        md5 = orderService.getMD5(sid, uid);
    } catch (Exception e) {
        e.printStackTrace();
        return "获取md5失败:" + e.getMessage();
    }
    return "获取的MD5为:" + md5;
}

OrderService

@Autowired
private UserMapper userMapper;

@Autowired
StringRedisTemplate stringRedisTemplate;

@Override
public String getMD5(Integer sid, Integer uid) {
    // 验证用户
    User user = userMapper.findUserById(uid);
    if (user == null) {
        throw new RuntimeException("用户不存在!");
    }
    log.info("用户信息:[{}]", user.toString());

    // 验证商品
    Stock stock = stockService.checkStock(sid);
    if (stock == null) {
        throw new RuntimeException("商品不存在!");
    }
    log.info("商品信息:[{}]", stock.toString());

    // 生成hashkey
    String hashKey = "KEY_" + uid + "_" + sid;
    // 生成MD5 随机盐-"!Qr*#3"
    String key = DigestUtils.md5DigestAsHex((uid + "!Qr*#3" + sid).getBytes());
    stringRedisTemplate.opsForValue().set(hashKey, key, 120, TimeUnit.SECONDS);

    log.info("Redis写入:[{}]-[{}]", hashKey, key);
    return key;
}

postman测试接口为http://localhost:8090/stock/md5?sid=1&uid=1

结果为:获取的MD5为:6f2c1a31e4b297c4f85c166c09009ec6

请求MD5串

控制台

图片转存失败,建议将图片保存下来直接上传

查看Redis,没有问题

查看Redis

看到设置了120秒的过期时间

改造加入验证

Controller

@GetMapping("killtokenmd5")
public String killtokenmd5(Integer sid, Integer uid, String md5) {
    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");
        return "抢购失败";
    }

    // 测试验证用户 暂时注释
    // if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + sid)) {
    //     System.out.println("秒杀活动已结束...");
    //     return "秒杀活动已结束";
    // }

    if (stockSoldOutMap.get(sid) != null) {
        return "商品已售完";
    }
    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + sid);
    if (increment < 0) {
        stockSoldOutMap.put(sid, true);
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);
        return "商品已售完";
    }


    System.out.println("秒杀商品id = " + sid);

    try {
        int orderId = orderService.killtokenmd5(sid, uid, md5);
        return "秒杀成功,订单id为:" + orderId;
    } catch (Exception e) {
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);
        if (stockSoldOutMap.get(sid) != null) {
            stockSoldOutMap.remove(sid);
        }
        e.printStackTrace();
        return e.getMessage();
    }
}

Service

@Override
public int killtokenmd5(Integer sid, Integer uid, String md5) {
    //验证签名
    String hashKey = "KEY_" + uid + "_" + sid;
    if (!md5.equals(stringRedisTemplate.opsForValue().get(hashKey))) {
        throw new RuntimeException("当前请求不合法,请稍后再试!");
    }

    // 校验库存
    Stock stock = stockService.checkStock(sid);
    // 扣除库存
    int up = stockService.updateSale(stock);
    if (up == 0) {
        throw new RuntimeException("库存不足");
    }
    // 创建订单
    return createOrder(stock);
}

测试,先请求获取 md5 接口使 Redis 存在该 md5,在过期时间内请求新的秒杀接口,不同于前,需要加上用户 id 和 md5 用以验证

正确结果如下,若不先请求 md5,则无法利用 Redis 验证导致失败,错误的 md5 同样 失败

图片转存失败,建议将图片保存下来直接上传

用户限制

限制用户访问频率

利用 Redis 超时时间和 incr 操作限制访问频率

UserService

@Service
@Transactional
@Slf4j
public class UserServiceImpl implements UserService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public long saveUserView(Integer uid) {
        // 根据用户id生成调用次数key
        String limitKey = "LIMIT" + "_" + uid;
        // 获取Redis指定key的调用次数
        String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
        long limit = -1;
        if (limitNum == null) {
            stringRedisTemplate.opsForValue().set(limitKey, "0", 3600, TimeUnit.SECONDS);
        } else {
            limit = stringRedisTemplate.opsForValue().increment(limitKey);
        }
        return limit;
    }

    @Override
    public boolean getUserView(Integer uid) {
        // 根据用户id生成调用次数key
        String limitKey = "LIMIT" + "_" + uid;
        // 获取Redis指定key的调用次数
        String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
        if (limitNum == null) {
            // 为空直接抛弃说明key异常
            log.error("该用户没用申请验证值记录,疑似异常");
        }
        return Integer.parseInt(limitNum) <= 10;
    }
}

Controller

@GetMapping("killtokenmd5limit")
public String killtokenmd5limit(Integer sid, Integer uid, String md5) {
    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");
        return "抢购失败";
    }

    // 测试验证用户 暂时注释
    // if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + sid)) {
    //     System.out.println("秒杀活动已结束...");
    //     return "秒杀活动已结束";
    // }

    if (stockSoldOutMap.get(sid) != null) {
        return "商品已售完";
    }
    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + sid);
    if (increment < 0) {
        stockSoldOutMap.put(sid, true);
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);
        return "商品已售完";
    }


    System.out.println("秒杀商品id = " + sid);

    try {
        // 加入用户访问频率限制
        long view = userService.saveUserView(uid);
        log.info("用户已访问次数:[{}]", view);
        boolean isAllowed= userService.getUserView(uid);
        if (!isAllowed) {
            log.info("购买失败,超过访问频率!");
            return "购买失败,超过访问频率!";
        }
        // 秒杀业务
        int orderId = orderService.killtokenmd5(sid, uid, md5);
        return "秒杀成功,订单id为:" + orderId;
    } catch (Exception e) {
        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);
        if (stockSoldOutMap.get(sid) != null) {
            stockSoldOutMap.remove(sid);
        }
        e.printStackTrace();
        return e.getMessage();
    }
}

测试,依然先获取md5,在用 JMeter 测试,接口及参数/stock/killtokenmd5limit?sid=1&uid=1&md5=6f2c1a31e4b297c4f85c166c09009ec6访问 20 次结果如下,因为限制10次,所以10次后被限制,也就是限制为10/(h*u)单用户每小时限制访问10次

图片转存失败,建议将图片保存下来直接上传

总结

这仅仅是一个小demo,还有好多要学习

继续努力吧