热点数据隔离实现解决方案,WT令牌实现登录,Lua识别Jwt令牌,非热点抢单实现,Lua操作Redis集群

42 篇文章 0 订阅
20 篇文章 0 订阅

1 热点数据隔离

​ 热点数据统计主要是为了找出热点数据,找出热点数据后,我们需要对热点数据采取各种措施,例如隔离、做缓存、优化等。

1.1 热点数据隔离流程分析

​ 我们这章实现热点数据收集,我们可以以小时为单位,算出平均每小时访问量最高的商品信息,并对该商品信息进行隔离,下单方式也单独处理,流程如下图:

流程说明:

1
2
3
4
1.实时读取Apache Druid的数据
2.分析哪些数据访问频率高
3.对访问频率高的数据进行隔离处理,可以把数据单独放到Redis缓存中
4.用户每次下单的时候,可以先到Redis缓存中检测该商品是否是热点商品,如果不是热点商品,则直接走订单系统下单,如果是热点商品,则走Kafka排队,不直接下单

1.2 实时热点数据分析

​ 我们在热点数据分析系统中查询Druid,然后将热点数据存入到Redis缓存进行隔离。我们可以采用elastic-job每5秒钟查询一次被访问的商品信息,如果访问量超过1000,我们可以认为是热点数据,并且这里不能查历史访问量,应该查询近期一段时间,比如最近1天最近1小时最近一分钟等。热点数据查询出来后,我们需要将热点数据隔离,隔离的方式我们可以直接采用将数据单独存储到Redis的方式隔离。

热点数据隔离:


a.编写定时任务->定时查询Druid
b.配置Redis集群->热点商品存入到Redis实现隔离
c.每次定时查询热点商品的时候,需要排除之前已经成为热点商品的数据
1.2.1 热点数据查询

​ 工程名字:seckill-monitor,我们在该工程下实现热点数据查询功能,Redis集群我们就不在这里演示搭建了,直接配置链接使用了。

1)配置Redis链接

在bootstrap.yml中配置redis集群链接,如下代码:


  redis:
    cluster:
      nodes:
        - redis-server:7001
        - redis-server:7002
        - redis-server:7003
        - redis-server:7004
        - redis-server:7005
        - redis-server:7006

2)配置定时任务

因为我们需要定时去查询Apache Druid,所以我们可以配置elastic-job来查询热点数据,在bootstrap.yml中配置如下:


elaticjob:
  zookeeper:
    server-lists: zk-server:2181
    namespace: monitortask

3)热点数据查询

​ 我们查询最近5小时访问量超过1000的商品,真实环境中时间粒度会更小,每次查询的时候,之前已经被定为热点商品的数据要排除。

SQL语句如下:

SELECT uri,count(*) AS "viewcount" FROM(SELECT * FROM "itemlogs" WHERE __time>'2020-04-10 14:01:46' ORDER BY __time DESC) GROUP BY uri HAVING "viewcount">1000  LIMIT 1000

接着我们用代码把上面的语句实现定时查询即可,每次查询出来的热点数据需要存入到Redis中进行隔离,存入到Redis中的数据我们给个固定前缀方便查询,key的规则定为:SKU_id,例如:商品id=S990,key=SKU_S990

另外一种参考:

SELECT COUNT(*) AS "ViewCount","uri" FROM "logsitems" WHERE __time>=CURRENT_TIMESTAMP - INTERVAL '1' HOUR GROUP BY "uri" HAVING  "ViewCount">3

在bootstrap.yml中配置druid地址:


#Druid
druidurl: jdbc:avatica:remote:url=http://192.168.211.137:8082/druid/v2/sql/avatica/

创建com.seckill.monitor.hot.MonitorItemsAccess,在该类中实现查询:


@Component
public class MonitorItemsAccess {

    @Value("${druidurl}")
    private String druidurl;

    @Autowired
    private RedisTemplate redisTemplate;

    /****
     * 查询统计数据,1天以内的热点秒杀商品
     * @throws Exception
     */
    public List<String> loadData() throws Exception{
        //获取连接对象
        AvaticaConnection connection = (AvaticaConnection) DriverManager.getConnection(druidurl);
        //创建Statment
        AvaticaStatement statement = connection.createStatement();
        //执行查询
        ResultSet resultSet = statement.executeQuery(druidSQL());
        //记录所有热点商品的ID
        List<String> ids = new ArrayList<String>();

        while (resultSet.next()) {
            //获取uri,格式:/web/items/S1235433012716498944.html
            String uri = resultSet.getString("uri");
            //处理掉/web/items/和.html
            if(uri.startsWith("/web/items/") && uri.endsWith(".html")){
                uri=uri.replaceFirst("/web/items/","");
                uri=uri.substring(0,uri.length()-5);
                //记录ID
                ids.add(uri);
            }
        }
        return ids;
    }

    /***
     * 组装SQL
     * @return
     */
    public String druidSQL(){
        //加载所有热点秒杀商品的ID
        Set<String> keys = redisTemplate.keys("SKU_*");
        //1天前的时间
        String yesterday = TimeUtil.date2FormatYYYYMMDDHHmmss(TimeUtil.addDateHour(new Date(), -72));

        //SQL语句
        String sql="SELECT uri,count(*) AS \"viewcount\" FROM(SELECT * FROM \"itemlogs\" WHERE __time>'"+yesterday+"'";

        //排除掉已经存在的数据
        if(keys!=null && keys.size()>0){
            StringBuffer buffer = new StringBuffer();
            for (String key : keys) {
                buffer.append("'/web/items/"+key.substring(4)+".html',");
            }
            String ids = buffer.toString().substring(0,buffer.toString().length()-1);

            //组装SQL
            sql+=" AND uri NOT IN("+ids+")";
        }

        //排序部分组装
        sql+=" ORDER BY __time DESC) GROUP BY uri HAVING \"viewcount\">1  LIMIT 5000";
        return sql;
    }
}

4)定时查询热点数据

我们这里实现每5秒中查询1次热点数据,采用elastic-job定时操作。

创建com.seckill.monitor.task.MonitorTask,实现定时调用查询热点数据,代码如下:


@ElasticSimpleJob(
        cron = "1/5 * * * * ?",
        jobName = "monitortask",
        shardingTotalCount = 1
)
@Component
public class MonitorTask implements SimpleJob {

    @Autowired
    private MonitorItemsAccess monitorItemsAccess;

    /***
     * 执行任务
     * @param shardingContext
     */
    @Override
    public void execute(ShardingContext shardingContext) {
        try {
            List<String> ids = monitorItemsAccess.loadData();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
1.2.2 实时热点数据隔离

​ 热点数据隔离,需要考虑很多问题,首先要将商品从数据库中进行锁定,然后将商品数据导入到Redis,导入到Redis的时候,需要支持事务操作。

1)Service

seckill-goodscom.seckill.goods.service.SkuService中添加隔离方法,代码如下:


/***
 * 热点商品隔离
 * @param id
 */
void hotIsolation(String id);

com.seckill.goods.service.impl.SkuServiceImpl中添加隔离实现方法:


@Autowired
private RedisTemplate redisTemplate;

/***
 * 热点商品隔离
 */
@Override
public void hotIsolation(String id) {
    Sku sku = new Sku();
    sku.setIslock(2);

    Example example = new Example(Sku.class);
    Example.Criteria criteria = example.createCriteria();
    criteria.andEqualTo("islock",1);
    criteria.andEqualTo("id",id);
    //执行锁定
    int mcount = skuMapper.updateByExampleSelective(sku,example);

    if(mcount>0){
        //查询商品剩余库存
        Sku currentSku = skuMapper.selectByPrimaryKey(id);
        //剩余库存
        String prefix = "SKU_";
        redisTemplate.boundHashOps(prefix+id).increment("num",currentSku.getSeckillNum());

        //提取Sku的信息
        Map<String,Object> skuMap = new HashMap<String,Object>();
        skuMap.put("id",id);
        skuMap.put("price",currentSku.getSeckillPrice());
        skuMap.put("name",currentSku.getName());
        redisTemplate.boundHashOps(prefix+id).put("info",skuMap);
    }
}

2)Controller

​ 在seckill-goodscom.seckill.goods.controller.SkuController中添加隔离方法调用,代码如下:


/***
 * 热点商品隔离
 */
@PostMapping(value = "/hot/isolation")
public Result hotIsolation(@RequestParam List<String> ids){
    if(ids!=null && ids.size()>0){
        for (String id : ids) {
            skuService.hotIsolation(id);
        }
    }
    return new Result(true,StatusCode.OK,"热点商品隔离成功!");
}

3)Feign

seckill-goods-apicom.seckill.goods.feign.SkuFeign中添加,代码如下:


/***
 * 热点商品隔离
 */
@PostMapping(value = "/sku/hot/isolation")
Result hotIsolation(@RequestParam List<String> ids);

4)热点数据隔离调用

seckill-monitorcom.seckill.monitor.task.MonitorTask中添加隔离方法调用,代码如下:

5)测试

我们启动整个服务进行测试,Redis中的数据如下:

1.3 Redis集群事务问题

​ Redis集群是不具备事务的,单个节点是具备事务的,所以我们商品信息存储到Redis集群多个节点中是没法实现集群事务控制,上面的代码如下图:

​ 我们观察上面代码,①和②处其实key相同,既然key相同,那么数据一定不是存储在不同节点上,如果把2次操作Redis合成一次操作Reids,就不会有事务问题了,我们可以把上面代码改造一下即可解决事务问题,改造代码如下图:

2 用户登录

​ 用户抢单的时候,必须要先登录,我们先编写一个方法,用于实现用户登录,用户登录成功后,每次抢单的时候,还需要识别用户身份,我们这里采用JWT令牌保存用户身份信息,每次抢单识别JWT令牌即可。

2.1 Jwt令牌

​ JWT令牌这里我们将实现管理员令牌生成和普通用户令牌生成,管理员和普通用户他们生成了令牌的秘钥一定是不同的。

​ 在seckill-common工程中添加JWT令牌生成类com.seckill.util.JwtTokenUtil,在该类中实现令牌生成以及令牌解析,代码如下:


public class JwtTokenUtil {

    //秘钥
    public static final String SECRETUSER="5pil6aOO5YaN576O5Lmf5q+U5LiN5LiK5bCP6ZuF55qE56yR";//用户
    public static final String SECRETADMIN="ADMIN5pil6aOO5YaN576O5Lmf5q+U5LiN5LiK5bCP6ZuF55qE56yR";//管理员

    /***
     * 生成令牌-管理员
     * @param uid:唯一标识符
     * @param ttlMillis:有效期
     * @return
     * @throws Exception
     */
    public static String generateTokenAdmin(String uid,Map<String,Object> payload, long ttlMillis) throws Exception {
        return generateToken(uid,payload,ttlMillis,SECRETADMIN);
    }

    /***
     * 生成令牌-普通用户
     * @param uid:唯一标识符
     * @param ttlMillis:有效期
     * @return
     * @throws Exception
     */
    public static String generateTokenUser(String uid,Map<String,Object> payload, long ttlMillis) throws Exception {
        return generateToken(uid,payload,ttlMillis,SECRETUSER);
    }

    /***
     * 生成令牌
     * @param uid:唯一标识符
     * @param ttlMillis:有效期
     * @return
     * @throws Exception
     */
    public static String generateToken(String uid,Map<String,Object> payload, long ttlMillis,String secret) throws Exception {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        Key signingKey = new SecretKeySpec(secret.getBytes(), signatureAlgorithm.getJcaName());

        Map<String,Object> header=new HashMap<String,Object>();
        header.put("typ","JWT");
        header.put("alg","HS256");
        JwtBuilder builder = Jwts.builder().setId(uid)
                .setIssuedAt(now)
                .setIssuer(uid)
                .setSubject(uid)
                .setHeader(header)
                .signWith(signatureAlgorithm, signingKey);

        //设置载体
        builder.addClaims(payload);

        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);
        }
        return builder.compact();
    }

    /***
     * 解密JWT令牌
     */
    public static Map<String, Object> parseToken(String token){
        //以Bearer开头处理
        if(token.startsWith("Bearer")){
            token=token.substring(6).trim();
        }

        //秘钥处理
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        Key signingKey = new SecretKeySpec(SECRETUSER.getBytes(), signatureAlgorithm.getJcaName());

        Claims claims = Jwts.parser()
                .setSigningKey(signingKey)
                .parseClaimsJws(token)
                .getBody();
        return claims;
    }
}

2.2 用户登录

​ 在seckill-user中实现用户登录,用户登录表机构如下:


CREATE TABLE `tb_user` (
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '密码,加密存储,MD5加密',
  `phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
  `email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',
  `created` datetime NOT NULL COMMENT '创建时间',
  `updated` datetime NOT NULL COMMENT '修改时间',
  `nick_name` varchar(50) DEFAULT NULL COMMENT '昵称',
  `name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
  PRIMARY KEY (`username`),
  UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

1)Service

com.seckill.user.service.UserService中编写登录方法,代码如下:


/**
 * 根据ID查询User
 * @param id
 * @return
 */
 User findById(String id);

com.seckill.user.service.impl.UserServiceImpl中编写登录方法实现,代码如下:


/**
 * 根据ID查询User
 * @param id
 * @return
 */
@Override
public User findById(String id){
    return  userMapper.selectByPrimaryKey(id);
}

2)Controller

com.seckill.user.controller.UserController中编写登录实现方法,代码如下:


/***
 * 根据ID查询User数据
 * @return
 */
@GetMapping("/login")
public Result<User> findById(String username,String password) throws Exception {
    //调用UserService实现根据主键查询User
    User user = userService.findById(username);
    if(user==null){
        return new Result<User>(false,StatusCode.ERROR,"用户不存在");
    }

    if(!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes()))){
        return new Result<User>(false,StatusCode.ERROR,"密码错误");
    }

    //登录成功,生成令牌
    Map<String,Object> payload = new HashMap<String,Object>();
    payload.put("username",user.getUsername());
    payload.put("name",user.getName());
    payload.put("phone",user.getPhone());

    //生成令牌
    String jwt =JwtTokenUtil.generateTokenUser(UUID.randomUUID().toString(),payload, 900000L);
    return new Result<User>(true,StatusCode.OK,"登录成功",jwt);
}

我们可以生成一个令牌。

登录地址:http://localhost:8001/api/user/login?username=user&password=pwd

生成的令牌如下:


{
    "flag": true,
    "code": 20000,
    "message": "登录成功",
    "data": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIyMmI5YzRjMy0yOTg5LTRkNTQtOGY3My0wYmI5MzAyMjMwYjQiLCJpYXQiOjE1ODczMjAxMDUsImlzcyI6IjIyYjljNGMzLTI5ODktNGQ1NC04ZjczLTBiYjkzMDIyMzBiNCIsInN1YiI6IjIyYjljNGMzLTI5ODktNGQ1NC04ZjczLTBiYjkzMDIyMzBiNCIsInBob25lIjoiMTM2NzAwODEzNzYiLCJuYW1lIjoi5rKI5Z2k5p6XIiwidXNlcm5hbWUiOiJpdGhlaW1hIiwiZXhwIjoxNTg4MjIwMTA1fQ.kzPYWLLnOtFBgedJZaiwzXnKFKHUnQXIqhWtCvl2zgk"
}

2.3 Jwt令牌识别

​ 识别Jwt令牌主要用于解析用户令牌,判断令牌是否真实有效。

2.3.1 流程分析

​ 前面我们编写的Java代码可以解析识别用户令牌,但我们现在要的流程如上图,用户请求Nginx执行抢单的时候,需要识别用户登录状态,如果已登录,则允许用户抢单,未登录是不允许用户抢单的,识别用户是否登录,我们这里采用Lua脚本实现。

2.3.2 Lua识别Jwt令牌

​ 如果想使用Lua识别用户令牌,我们需要引入lua-resty-jwt模块,是用于 ngx_lua 和 LuaJIT 的 Lua 实现库,在该模块能实现Jwt令牌生成、Jwt令牌校验,依赖库的地址:GitHub - SkyLothar/lua-resty-jwt: JWT For The Great Openresty

1)lua-resty-jwt安装

​ 在资料\lua中已经下载好了该依赖库lua-resty-jwt-master.zip,我们将该库文件上传到服务器上,并解压,当然,我们也可以使用opm直接安装lua-resty-jwt,配置lua-resty-jwt之前,我们需要先安装resty和opm。

安装仓库管理工具包:

yum install yum-utils

添加仓库地址:

yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo

安装resty:

yum install openresty-resty

安装opm:

yum install openresty-opm

安装Jwt组件:

opm get SkyLothar/lua-resty-jwt

此时lua-resty-jwt安装好了,可以直接使用了。

2)令牌识别

令牌识别有可能在很多操作都需要用到,所以我们可以创建一个独立的模块,用于识别令牌,文件名字叫token.lua



--依赖jwt库
local jwt = require("resty.jwt")
--秘钥
local secret="5pil6aOO5YaN576O5Lmf5q+U5LiN5LiK5bCP6ZuF55qE56yR"

-- 定义一个名为 jwttoken 的模块
jwttoken = {}

--令牌校验
function jwttoken.check(auth_header)
    --定义响应数据
    local response = {}

    --如果请求头中没有令牌,则直接返回401
    if auth_header == nil then
        response["code"]=401
        response["message"]="没有找到令牌数据"
        return response
    end

    --查找令牌中的Bearer前缀字符,并进行截取
    local _, _, token = string.find(auth_header, "Bearer%s+(.+)")

    --如果没有Bearer,则表示令牌无效
    if token == nil then
        response["code"]=401
        response["message"]="令牌格式不正确"
        return response
    end

    --校验令牌
    local jwt_obj = jwt:verify(secret, token)

    --如果校验结果中的verified==false,则表示令牌无效
    if jwt_obj.verified == false then
        response["code"]=401
        response["message"]="令牌无效"
        return response
    end

    --全部校验完成后,说明令牌有效,返回令牌数据
    response["code"]=200
    response["message"]="令牌校验通过"
    response["body"]=jwt_obj
    return response
end

return jwttoken

我们创建一个auth_verify.lua用于识别令牌,代码如下:


ngx.header.content_type="application/json;charset=utf8"

--引入json库
local cjson = require "cjson"

--引入jwt模块
local jwttoken = require "token"

--获取请求头中的令牌数据
local auth_header = ngx.var.http_Authorization

--调用令牌校验
local result = jwttoken.check(auth_header)

-- 输出结果
ngx.say(cjson.encode(result))
ngx.exit(result.code)

nginx.conf配置一个用于校验令牌的地址,代码如下:


#令牌校验
location /token {
    content_by_lua_file /usr/local/openresty/nginx/lua/auth_verify.lua;
}

3)令牌测试

我们用上面java生成的令牌进行测试,请求:http://192.168.211.137/token测试令牌结果,如下图:

令牌错误输入,结果如下:

3 用户下单

​ 商品分为热点商品抢单和非热点商品抢单,因此此系统中抢单模式并非一种。

3.1 抢单分析

​ 如上图,用户登录后经过nginx,进行抢单,此时会先判断商品是否是热点商品,如果是非热点商品,则直接调用订单系统进行下单操作,如果是热点商品,则向Kafka生产消息进行排队下单,订单系统会订阅排队下单信息,这样可以降低服务器所直接承受的抢单压力,这种操作也叫队列削峰。

3.2 非热点商品抢单

​ 我们在订单系统中实现非热点商品抢单操作,非热点商品只用在订单系统中实现抢单即可,但抢单的时候要注意这么几个问题:


1.先递减库存
2.库存递减成功后,执行下单
3.下单失败,需要实现分布式事务
4.下单成功后,要记录用户抢单信息,在24小时内不允许再抢该商品
5.抢单中,有可能存在抢购的商品正好变成了热点商品,此时应该走排队的方式抢单,否则商品数量会发生不精准问题
3.2.1 库存递减

库存递减我们需要


/***
* 递减库存
* @param id
* @param count
* @return
*/
@Update("UPDATE tb_sku SET seckill_num=seckill_num-#{count} WHERE id=#{id} AND islock=1 AND seckill_num>=#{count}")
int dcount(@Param("id") String id,@Param("count") Integer count);

这里我们需要控制数据的原子性,因此不能在内存中进行操作,需要用SQL语句在数据库中执行。

1)库存递减

修改seckill-goodscom.seckill.goods.service.SkuService添加库存递减方法,代码如下:


/***
 * 库存递减
 * @param id
 * @param count
 * @return
 */
int dcount(String id, Integer count);

修改seckill-goodscom.seckill.goods.service.impl.SkuServiceImpl添加库存递减实现方法,代码如下:


/***
 * 库存递减
 * @param id
 * @param count
 * @return
 */
@Override
public int dcount(String id, Integer count) {
    int dcount = skuMapper.dcount(id,count);
    if(dcount<=0){
        //递减失败,查询状态
        Sku sku = skuMapper.selectByPrimaryKey(id);
        if(sku.getIslock()==2){
            return 205;
        }
        if(sku.getSeckillNum()<count){
            return 405;
        }
    }
    return dcount;
}

修改com.seckill.goods.controller.SkuController添加库存递减调用方法,代码如下:


/***
 * Sku数量递减
 * @return
 */
@PutMapping(value = "/dcount/{id}/{count}" )
public Result<Sku> dcount(@PathVariable(value = "id")String id,@PathVariable(value = "count")Integer count){
    int code = skuService.dcount(id,count);
    String message = "";
    Sku sku = null;
    switch (code){
        case 1:
            message="库存削减成功";
            sku = skuService.findById(id);
            break;
        case 405:
            message="库存不足";
            break;
        case 205:
            message="该商品为热点商品";
            break;
            default:
    }
    return new Result<Sku>(true,code,message,sku);
}

2)SkuFeign配置

修改com.seckill.goods.feign.SkuFeign,添加库存递减方法,代码如下:


/***
 * Sku数量递减
 * @return
 */
@PutMapping(value = "/sku/dcount/{id}/{count}" )
Result<Sku> dcount(@PathVariable(value = "id")String id, @PathVariable(value = "count")Integer count);
3.2.2 抢单实现

​ 当库存递减成功后,需要给用户直接下单,如果递减不成功,会出现商品变成热卖商品的现象,我们需要向Kafka发送队列数据,所以需要引入Kafka配置。

bootstrap.yml配置kafka:


  kafka:
    producer:
      acks: all #acks:消息的确认机制,默认值是0, acks=0:如果设置为0,生产者不会等待kafka的响应。 acks=1:这个配置意味着kafka会把这条消息写到本地日志文件中,但是不会等待集群中其他机器的成功响应。 acks=all:这个配置意味着leader会等待所有的follower同步完成。这个确保消息不会丢失,除非kafka集群中所有机器挂掉。这是最强的可用性保证。
      retries: 0 #发送失败重试次数,配置为大于0的值的话,客户端会在消息发送失败时重新发送。
      batch-size: 16384 #当多条消息需要发送到同一个分区时,生产者会尝试合并网络请求。这会提高client和生产者的效率。
      buffer-memory: 33554432 #即32MB的批处理缓冲区
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      bootstrap-servers: kafka-server:9092 #如果kafka启动错误,打开debug级别日志,出现Can't resolve address: flink:9092 的错误,需要在 windows下修改IP映射即可, C:\Windows\System32\drivers\etc\hosts, 192.168.234.128 flink。
    consumer:
      group-id: test
      auto-offset-reset: latest #(1)earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费;(2)latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据 ;(3)none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
      enable-auto-commit:  true  #如果为true,消费者的偏移量将在后台定期提交。
      auto-commit-interval: 1000 #消费者偏移自动提交给Kafka的频率 (以毫秒为单位),默认值为5000
      max-poll-records: 5 #一次拉起的条数
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      bootstrap-servers: kafka-server:9092

修改seckill-ordercom.seckill.order.service.impl.OrderServiceImpl的add方法,代码如下:


/**
 * 增加Order
 * @param order
 */
@Override
public int add(Order order){
    //1.库存递减
    Result<Sku> result = skuFeign.dcount(order.getId(), 1);

    //2.递减成功,下单
    if(result.getCode()==1){
        //商品秒杀价格
        Sku sku = result.getData();
        order.setName(sku.getName());
        order.setPrice(sku.getSeckillPrice());
        order.setId("No"+idWorker.nextId());
        order.setOrderStatus("0");
        order.setPayStatus("0");
        order.setConsignStatus("0");
        orderMapper.insertSelective(order);
    }else if(result.getCode()==205){//3.商品转入了热点商品,排队
        //检查用户是否在排队
        String key = "SKU_"+order.getSkuId();
        String userKey = "USER"+order.getUsername()+"ID"+order.getSkuId();

        //如果为true,则表示用户正在排队
        Boolean bo = redisTemplate.boundHashOps(key).hasKey(userKey);
        if(!bo){
            Map<String,String> orderMap = new HashMap<String,String>();
            orderMap.put("username",order.getUsername());
            orderMap.put("id",order.getSkuId());
            //抢单排队
            kafkaTemplate.send("neworder", JSON.toJSONString(orderMap));
        }
        return 202;
    }
    return result.getCode();
}

修改com.seckill.order.controller.OrderController的add方法,代码如下:


/***
 * 新增Order数据
 * @return
 */
@GetMapping(value = "/{id}")
public Result add(@RequestHeader(value = "Authorization")String authorization,//令牌
                  @PathVariable(value = "id")String id) //商品ID
{
    //解析令牌
    Map<String,Object> tokenMap = JwtTokenUtil.parseToken(authorization);
    Order order = new Order();
    order.setCreateTime(new Date());
    order.setUpdateTime(order.getCreateTime());
    order.setSkuId(id);
    order.setUsername(tokenMap.get("username").toString());

    //调用OrderService实现添加Order
    int code = orderService.add(order);
    String message = "";
    switch (code){
        case 405:
            message="库存不足";
            break;
        case 200:
            message="抢购成功";
            break;
        case 202:
            message="正在排队";
            break;
        case 1:
            message="抢单成功";
            break;
        default:
    }
    return new Result(true, StatusCode.OK,message);
}
3.2.3 抢单测试

编写lua脚本控制抢单,当用户处于已登录状态,则执行下单,创建脚本seckill-order-add.lua,脚本如下:


ngx.header.content_type="application/json;charset=utf8"
--引入json库
local cjson = require "cjson"
--引入jwt模块
local jwttoken = require "token"
--获取请求头中的令牌数据
local auth_header = ngx.var.http_Authorization
--调用令牌校验
local result = jwttoken.check(auth_header)


--如果code==200表示令牌校验通过
if result.code==200 then
        --获取id
        local uri_args = ngx.req.get_uri_args()
        local id = uri_args["id"]

        --拼接url
        local url = "/api/order/"..id
        --执行请求
        ngx.exec(url)
else
        -- 输出结果
        ngx.say(cjson.encode(result))
        ngx.exit(result.code)
end

在nginx.conf中添加api以及抢单请求路径的路由,配置如下:


#抢单
location /lua/order/add {
    content_by_lua_file /usr/local/openresty/nginx/lua/seckill-order-add.lua;
}

#微服务网关
location /api/ {
    proxy_pass http://192.168.1.5:8001;
}

注意如果是POST提交,Lua脚本中需要读取请求体数据:


ngx.req.read_body()

ngx.req.get_post_args()

3.3 热点商品抢单

​ 上面我们完成了非热点商品抢单,接着我们实现以下热点商品抢单。热点商品和非热点商品不一样,热点商品已经隔离出来,在Redis缓存中,并且热点商品抢单要实现高效率操作而且还能抗压,Nginx的并发能力远远超越tomcat,因此热点商品抢单我们可以使用Lua+Nginx。

3.3.1 抢单流程分析

​ 用户进入抢单流程,通过Lua脚本判断令牌是否有效,如果有效,则进入抢单环节,抢单环节执行过程我们做一个分析:


1.判断该商品用户是否在24小时内购买过
2.如果购买了,直接提示用户24小时内无法购买
3.如果用户没有购买过该商品,则判断该商品是否属于热点商品
4.如果是非热点商品,则走非热点商品抢单流程
5.如果是热点商品,则走热点商品抢单流程
6.判断该商品用户是否已经排队,如果没有排队,则进入排队,如果已经排队,则提示用户正在排队
7.下单过程交给订单系统,订单系统通过队列订阅读取用户下单信息,并进行下单
3.3.2 Lua实现Redis操作

​ 判断用户是否在24小时内抢购过该商品,我们可以将用户抢单信息存入到Redis缓存中,定时24小时过期即可,此时需要在Lua里面实现Redis集群操作,需要第三方库的支持lua-resty-redis-cluster

​ 我们需要安装lua-resty-redis-cluster,下载地址:<https://github.com/cuiweixie/lua-resty-redis-cluster>,下载该文件配置后即可实现Redis集群操作。

3.3.2.1 配置lua-resty-redis-cluster

1)lua-resty-redis-cluster配置

​ 将下载好的文件上传到服务器的`/usr/local/openresty目录下,并解压,我们只需要用到包中2个文件rediscluster.lua和redis_slot.c

​ 将lua-resty-redis-cluster/lib/redis_slot.c 拷贝到 openresty/lualib 目录下,将lua-resty-redis-cluster/lib/resty/rediscluster.lua 拷贝到 openresty/lualib/resty 目录下。

拷贝redis_slot.c:


cd /usr/local/openresty/lua-resty-redis-cluster-master/lib/

cp redis_slot.c /usr/local/openresty/lualib/

拷贝rediscluster.lua:


cd /usr/local/openresty/lua-resty-redis-cluster-master/lib/resty

cp rediscluster.lua /usr/local/openresty/lualib/resty/

编译:


cd /usr/local/openresty/lualib

gcc redis_slot.c -fPIC -shared -o libredis_slot.so

编译的时候有可能会发生如下错误

错误解决:


解决:应该是lua版本不对,自带的lua应该不好使
方式一:删除自带的lua,一般是/usr/lua和/usr/luac ,删除这两个文件
方式二:yum install lua-devel 下载一个依赖
方式三:自己重新再lua官网下载一个lua,重新安装一个lua(这个很好使)

这里我们可以选择第二种方式解决,再次进行编译就没问题了。

2)指令配置

​ lua-resty-redis-cluster中有部分redis指令并未开放,我们可以手动修改,开放相关指令,我们这里开放过期指令,因为后面会用到该指令。

修改/usr/local/openresty/lualib/resty/rediscluster.lua文件,添加相关指令,如下图:

3.3.2.2 操作Redis集群实现

​ 以后别的地方也有可能会用到redis,我们可以写个工具类redis-cluster.lua,实现redis的操作,这里主要实现了根据key获取缓存数据、根据key设置缓存过期时间、根据key从hash类型中获取数据、往hash类型中添加数据,代码如下:


--redis连接配置
local config = {
    name = "test",
    serv_list = {
        {ip="192.168.211.137", port = 7001},
        {ip="192.168.211.137", port = 7002},
        {ip="192.168.211.137", port = 7003},
        {ip="192.168.211.137", port = 7004},
        {ip="192.168.211.137", port = 7005},
        {ip="192.168.211.137", port = 7006},
    },
    idle_timeout    = 1000,
    pool_size       = 10000,
}

--引入redis集群配置
local redis_cluster = require "resty.rediscluster"

--定义一个对象
local lredis = {}

--根据key查询
function lredis.get(key)
        --创建链接
        local red = redis_cluster:new(config)
        red:init_pipeline()

        --根据key获取数据
        red:get(key)
        local rresult = red:commit_pipeline()

        --关闭链接
        red:close()

        return rresult
end


--添加带过期的数据
function lredis.setexp(key,value,time)
        --创建链接
        local red = redis_cluster:new(config)
        red:init_pipeline()

        --添加key,同时设置过期时间
        red:set(key,value)
        red:expire(key,time)

        local rresult = red:commit_pipeline()
end

--根据key查询hash
function lredis.hget(key1,key2)
        --创建链接
        local red = redis_cluster:new(config)
        red:init_pipeline()

        --根据key获取数据
        red:hmget(key1,key2)
        local rresult = red:commit_pipeline()

        --关闭链接
        red:close()

        return rresult[1]
end

--hash数据添加
function lredis.hset(key1,key2,value)
        --创建链接
        local red = redis_cluster:new(config)
        red:init_pipeline()

        --添加hash数据
        red:hmset(key1,key2,value)
        local rresult = red:commit_pipeline()

        --关闭链接
        red:close()
        return rresult
end

--hash中指定的key自增
function lredis.hincrby(key1,key2,value)
        --创建链接
        local red = redis_cluster:new(config)
        red:init_pipeline()

        --添加hash数据
        red:hincrby(key1,key2,value)
        local rresult = red:commit_pipeline()

        --关闭链接
        red:close()

        return rresult
end

return lredis

我们接着来测试一次集群操作,修改nginx.conf,配置一个location节点,如下:


#redis
location /test/redis {
    content_by_lua '
        ngx.header.content_type="application/json;charset=utf8"
        --引入redis
        local rredis = require "redis-cluster"
        --从redis中查询hash类型数据
        local sku = rredis.hget("SKU_S1235433012716498944","num")[1]
        ngx.say(sku)
    ';
}

测试效果如下:

3.3.3 Lua实现Kafka操作

​ 用户抢单的时候,如果是热点商品,这时候需要实现用户排队,用户排队我们需要向kafka发送抢单信息,因此需要使用Lua脚本操作kafka,我们需要依赖lua-restry-kafka库,该库我们也已经配置使用过了,所以这里无需再配置了。

​ 以后使用kafka的地方也有可能会有很多,所以针对kafka我们也可以单独抽取出一个配置脚本,创建一个脚本名字叫’kafka.lua’,用于配置kafka的操作,代码如下:


--创建对象
local kafka={}

--kafka依赖库
local client = require "resty.kafka.client"
local producer = require "resty.kafka.producer"

--配置kafka的链接地址
local broker_list = {
      { host = "192.168.211.137", port = 9092 }
}

--发送消息
--queuename:队列名字
--content:发送的内容
function kafka.send(queuename,content)
        --创建生产者
        local pro = producer:new(broker_list,{ producer_type="async"})
        --发送消息
        local offset, err = pro:send(queuename, nil, content)
        --返回结果
        return offset
end

return kafka

编写一段代码向kafka发送信息,修改nginx.conf,添加如下代码:


#kafka
location /test/kafka {
    content_by_lua '
        ngx.header.content_type="application/json;charset=utf8"
         --引入kafka
        local kafka = require "kafka"
        --发送消息
        local offset = kafka.send("demo","hello")
        ngx.say(offset)
    ';
}

测试效果如下:

3.3.4 抢单实现

​ 抢单这里分为2部分,首先需要向Kafka发送抢单信息实现排队,排队后,订单系统订阅抢单信息实现下单操作,所有的数据操作一律在Redis中完成,降低程序对服务器的压力。

3.3.4.1 排队

排队抢单需要引入redis和kafka,我们的实现思路如下:


1.校验用户令牌,如果不通过直接结束程序提示用户
2.令牌校验通过,从Redis中获取用户在24小时内是抢购过该商品,如果抢购过直接结束程序并提示用户
3.如果符合购买该商品条件,则校验该商品是否是热点商品,如果不是,直接请求后台下单
4.如果是热点商品,并且库存>0,校验用户是否已经在排队,使用redis的incr自增判断排队次数可以去除重复排队
5.如果没有排队,则向Kafka发送消息实现排队

创建seckill-order-add.lua,实现代码如下:


ngx.header.content_type="application/json;charset=utf8"
--引入json库
local cjson = require "cjson"
--引入jwt模块
local jwttoken = require "token"
--引入redis
local redis = require "redis-cluster"
--引入kafka
local kafka = require "kafka"

--获取请求头中的令牌数据
local auth_header = ngx.var.http_Authorization
--调用令牌校验
local result = jwttoken.check(auth_header)

--如果code==200表示令牌校验通过
if result.code==200 then
	--响应结果
	local response = {}

	--获取id
	local uri_args = ngx.req.get_uri_args()
	local id = uri_args["id"]

	--判断该商品用户是否已经在指定时间内购买过
	local username = result["body"]["payload"]["username"]
	local userKey= "USER"..username.."ID"..id
	local hasbuy = redis.get(userKey)
	
	--如果没有购买,则判断该商品是否是热点商品
	if hasbuy==nil or hasbuy[1]==nil or hasbuy[1]==ngx.null then
		--从redis中获取该商品信息
		local num = redis.hget("SKU_"..id,"num")[1]

		--如果不是热点商品,则走普通抢单流程
		if num==nil or num==ngx.null then
			--拼接url
			local url = "/api/order/"..id
			--执行请求
			ngx.exec(url)
			return
		else
			--热点商品
			num = tonumber(num)
			
			--如果有库存,才允许抢单
			if num<=0 then
                --库存不足,无法排队
		        response["code"]=405
                response["message"]="当前商品库存不足,无法抢购"
		        ngx.say(cjson.encode(response))
				return
			else
				--递增排队
				local incrresult = redis.hincrby("SKU_"..id,userKey,1)
				incrresult=tonumber(incrresult)
                
				if incrresult==1 then
                    --热点数据,发送MQ排队
                    local userorder = {}
                    userorder["username"]=username
                    userorder["id"]=id

					--排队抢单
					kafka.send("neworder",cjson.encode(userorder))

                    response["code"]=202
                    response["message"]="您正在排队抢购该商品"
					ngx.say(cjson.encode(response))
					return
				else
					--响应用户正在排队抢购该商品
                	response["code"]=202
        	        response["message"]="您正在排队抢购该商品"
	                ngx.say(cjson.encode(response))
					return
				end
			end
		end
	else
		--24小时内购买过该商品
		response["code"]=415
        response["message"]="您24小时内已经抢购了该商品,不能重复抢购"
        ngx.say(cjson.encode(response))
		return
	end
else
	-- 输出结果
	ngx.say(cjson.encode(result))
	ngx.exit(result.code)
end
3.3.4.2 下单实现

创建com.seckill.order.config.KafkaOrderListener,用于读取排队信息,并调用下单操作,代码如下:


@Component
public class KafkaOrderListener {

    @Autowired
    private OrderService orderService;

    /***
     * 监听消息
     * 创建订单
     * @param message
     */
    @KafkaListener(topics = {"neworder"})
    public void receive(String message){
        //将消息转成Map
        Map<String,String> orderMap = JSON.parseObject(message,Map.class);
        //创建订单
        orderService.addHotOrder(orderMap);
    }
}

修改com.seckill.order.service.OrderService添加热点数据下单方法,代码如下:


/***
 * 热点数据下单
 * @param orderMap
 */
void addHotOrder(Map<String, String> orderMap);

修改com.seckill.order.service.impl.OrderServiceImpl添加热点数据下单实现方法,代码如下:


/***
 * 秒杀下单
 * @param orderMap
 */
@Override
public void addHotOrder(Map<String, String> orderMap) {
    String id = orderMap.get("id");
    String username = orderMap.get("username");
    //key
    String key = "SKU_" + id;
    //用户购买的key
    String userKey = "USER" + username + "ID" + id;

    if (redisTemplate.hasKey(key)) {
        //数量
        Integer num = Integer.parseInt(redisTemplate.boundHashOps(key).get("num").toString());

        //拥有库存,执行递减操作
        if (num > 0) {
            //查询商品
            Result<Sku> result = skuFeign.findById(id);
            Sku sku = result.getData();
            Order order = new Order();
            order.setCreateTime(new Date());
            order.setUpdateTime(order.getCreateTime());
            order.setUsername(username);
            order.setSkuId(id);
            order.setName(sku.getName());
            order.setPrice(sku.getSeckillPrice());
            order.setId("No" + idWorker.nextId());
            order.setOrderStatus("0");
            order.setPayStatus("0");
            order.setConsignStatus("0");
            orderMapper.insertSelective(order);

            //库存递减
            num--;

            if (num == 0) {
                //同步数据到数据库,秒杀数量归零
                skuFeign.zero(id);
            }

            //更新数据
            Map<String, Object> dataMap = new HashMap<String, Object>();
            dataMap.put("num", num);
            dataMap.put(userKey, 0);

            //存数据
            redisTemplate.boundHashOps(key).putAll(dataMap);
        }

        //记录该商品用户24小时内无法再次购买,测试环境,我们只配置成1分钟
        redisTemplate.boundValueOps(userKey).set("");
        redisTemplate.expire(userKey, 1, TimeUnit.MINUTES);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纵然间

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值