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-goods
的com.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-goods
的com.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-api
的com.seckill.goods.feign.SkuFeign
中添加,代码如下:
/*** * 热点商品隔离 */ @PostMapping(value = "/sku/hot/isolation") Result hotIsolation(@RequestParam List<String> ids);
4)热点数据隔离调用
在seckill-monitor
的com.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-goods
的com.seckill.goods.service.SkuService
添加库存递减方法,代码如下:
/*** * 库存递减 * @param id * @param count * @return */ int dcount(String id, Integer count);
修改seckill-goods
的com.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-order
的com.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); } }