秒杀系统——商品模块展示技术难点
商品详情页
商品详情页是展示商品详细信息的一个页面,承载在网站的大部分流量和订单的入口。京东商城目前有通用版、全球购、闪购、易车、惠买车、服装、拼购、今日抄底等许多套模板。各套模板的元数据是一样的,只是展示方式不一样。目前商品详情页个性化需求非常多,数据来源也是非常多的,而且许多基础服务做不了的都放我们这,因此我们需要一种架构能快速响应和优雅的解决这些需求问题。
因此我们重新设计了商品详情页的架构,主要包括三部分:
-
商品详情页系统:商品详情页系统负责静的部分
-
商品详情页统一服务系统:统一服务负责动的部分
-
商品详情页动态服务系统:动态服务负责给内网其他系统提供一些数据服务
商品详情页前端结构
前端展示可以分为这么几个维度:商品维度(标题、图片、属性等)、主商品维度(商品介绍、规格参数)、分类维度、商家维度、店铺维度等;另外还有一些实时性要求比较高的如实时价格、实时促销、广告词、配送至、预售等是通过异步加载。
SPU: Standard Product Unit (标准化产品单元),SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。
SKU: Stock keeping unit(库存量单位) SKU即库存进出计量的单位(买家购买、商家进货、供应商备货、工厂生产都是依据SKU进行的),在服装、鞋类商品中使用最多最普遍。 例如纺织品中一个SKU通常表示:规格、颜色、款式。SKU是物理上不可分割的最小存货单元。
单品页流量特点
热点少,各种爬虫、比价软件抓取。
2.1、压测测试,进行压力测试
提升系统反应速度方法:
1、换数据库 ——换数据库
2、分库分表——进行优化
下图是我对电商商品进行Jmeter压测的截图。
Jmeter上图主要看两个参数Average和Throuhtput
其中平均值越小越好,吞吐量是越大越好。
其中遇到情况,就是有时候请求数量过大超过系统承受力,吞吐量更大,是后面大量请求错误,进行压测的时候需要注意。
2.2、后台
影响系统主要的开销是两方面 ——磁盘IO 、网络IO
获取商品详情信息,下面是我获取商品详细页的部分代码
/**
* 获取商品详情信息
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo(Long id) {
PmsProductParam productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
return null;
}
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
return productInfo;
}
压测结果:
采用5000并发,可以看到异常率很高,下面进行优化。
静态化处理
将网页页面进行静态化处理,把它放在CDN(Content Delivery Network内容转发器)上。
不直接访问数据库,转而去访问CDN。采用FreeMarker工具生成静态化工具。
FreeMarker 是一款模板引擎:即基于模板和数据源生成输出文本(html网页,配置文件,电子邮件,源代码)的通用工具。它是一个 java 类库,最初被设计用来在MVC模式的Web开发框架中生成HTML页面,它没有被绑定到Servlet或HTML或任意Web相关的东西上。也可以用于非Web应用环境中。
模板编写使用FreeMarker Template Language(FTL)。使用方式类似JSP的EL表达式。模板中专注于如何展示数据,模板之外可以专注于要展示什么数据。
使用模板Template和数据源 Java Object生成输出文本(html网页、配置文件、电子邮件、源代码)
pom引入:
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.23</version>
</dependency>
来一个demo:
使用步骤:
第一步:创建一个Configuration对象,直接new一个对象。构造方法的参数就是freemarker对于的版本号。
第二步:设置模板文件所在的路径。
第三步:设置模板文件使用的字符集。一般就是utf-8.
第四步:加载一个模板,创建一个模板对象。
第五步:创建一个模板使用的数据集,可以是pojo也可以是map。一般是Map。
第六步:创建一个Writer对象,一般创建一FileWriter对象,指定生成的文件名。
第七步:调用模板对象的process方法输出文件。
第八步:关闭流。
public class FreeMarkTest {
public static void main(String[] args) throws Exception {
// 第一步:创建一个Configuration对象,直接new一个对象。构造方法的参数就是freemarker对于的版本号。
Configuration configuration = new Configuration(Configuration.getVersion());
// 第二步:设置模板文件所在的路径。
configuration.setDirectoryForTemplateLoading(new File("D:\\ProgramData\\ftl"));
// 第三步:设置模板文件使用的字符集。一般就是utf-8.
configuration.setDefaultEncoding("utf-8");
// 第四步:加载一个模板,创建一个模板对象。
Template template = configuration.getTemplate("test.ftl");
// 第五步:创建一个模板使用的数据集,可以是pojo也可以是map。一般是Map。
Map dataModel = new HashMap<>();
//向数据集中添加数据
dataModel.put("hello", "我们来测试下数据看可以显示出来嘛");
// 第六步:创建一个Writer对象,一般创建一FileWriter对象,指定生成的文件名。
Writer out = new FileWriter(new File("D:\\ProgramData\\ftl\\test.html"));
// 第七步:调用模板对象的process方法输出文件。
template.process(dataModel, out);
// 第八步:关闭流。
out.close();
}
<h1>
${hello}
</h1>
list标签:
<#list studentList as student>
${student.id}/${studnet.name}
</#list>
if条件标签:
<#if student_index % 2 == 0>
<#else>
</#if>
Null值的处理:
<#if a??>
a不为空时。。
<#else>
a为空时###
</#if>
日期标签:
当前日期: ${date?date}
当前时间:${date?time}
当前日期和时间:${date?datetime}
自定义日期格式:${date?string("yyyyMM/dd HH:mm: ss")}
包含标签:
<#include "hello.ftl"/>
实战:
ItemController
@RestController
@Api(description = "商品列表信息")
@RequestMapping("/item")
public class ItemController {
@Autowired
ItemService itemService;
@RequestMapping(value = "/static/{id}",method = RequestMethod.GET)
@ApiOperation(value = "静态化商品")
public CommonResult<String> buildStatic(@PathVariable Long id){
String path = itemService.toStatic(id);
if(StringUtils.isEmpty(path)){
return CommonResult.failed("静态化商品页面出现异常");
}
return CommonResult.success(path);
}
}
接口:
public interface ItemService {
/**
* 静态化商品详情页
* @param id
* @return
*/
String toStatic(Long id);
}
静态化核心代码: ItemServiceImpl
@Override
public String toStatic(Long id) {
//查询商品信息
PmsProduct pmsProduct=productMapper.selectByPrimaryKey(id);
if (pmsProduct==null){
return null;
}
String outPath="";
try {
String userHome = System.getProperty("user.home");
// 第一步:创建一个Configuration对象,直接new一个对象。构造方法的参数就是freemarker对于的版本号。
Configuration configuration = new Configuration(Configuration.getVersion());
// 第二步:设置模板文件所在的路径。
configuration.setDirectoryForTemplateLoading(new File(userHome+"/template/ftl"));
// 第三步:设置模板文件使用的字符集。一般就是utf-8.
configuration.setDefaultEncoding("utf-8");
// 第四步:加载一个模板,创建一个模板对象。
Template template = null;
template = configuration.getTemplate("report.ftl");
// 第五步:创建一个模板使用的数据集,可以是pojo也可以是map。一般是Map。
Map dataModel = new HashMap();
// 向数据集中添加数据
dataModel.put("item", pmsProduct);
String images= pmsProduct.getPic();
if(StringUtils.isNotEmpty(images)){
String[] split = images.split(",");
List<String> imageList= Arrays.asList(split);
dataModel.put("imageList", imageList);
}
// 第六步:创建一个Writer对象,一般创建一FileWriter对象,指定生成的文件名。
outPath=userHome+"/template/report/1000"+pmsProduct.getId()+".html";
Writer out = new FileWriter(new File(outPath));
// 第七步:调用模板对象的process方法输出文件。
template.process(dataModel, out);
// 第八步:关闭流。
out.close();
} catch (IOException e) {
e.printStackTrace();
} catch (TemplateException te) {
te.printStackTrace();
}
return outPath;
}
前端:pms/index.vue
<el-button
size="mini"
@click="product_static(scope.$index, scope.row)">静
</el-button>
定义vue的product_static方法的js代码
script:
product_static(index,obj){
console.log(index,obj.id)
this.$confirm('确认要静态化', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(()=>{
productStatic(obj.id).then(response=>{
this.$message({
message: '静态化成功',
type: 'success',
duration: 1000
});
this.editSkuInfo.dialogVisible=false;
});
});
}
product.js:
export function productStatic(id) {
return request({
url:'/item/static/'+id,
method:'get',
})
}
优化:如果发生价格改变、秒杀 or 倒计时、下单的情况下 ?? 由于静态化文件不能实时修改 ,js没有生效(js、css、图片url)
这个方案只适合小流量架构 。为什么?
re:如果类似京东这种商品级别,使用页面静态化,每次修改页面栏位需要生成的新页面太多,并不适合。
小流量架构:https://www.processon.com/view/link/5e5774dae4b0cb56daac5a80
分布式场景下:
1000个静态商品页面使用 1个模板,当商品界面发生修改,需要修改的页面数量:1000个静态商品页面 * 机房(服务)数量;
公式 :1000个静态页面*机房数量。
过程: 是修改了一个字段,然后生成1k个静态页面,然后拷贝到其他N-1台服务器上
小米:1000个商品页面 12台 12000个静态化数据 CDN 12*1000 个静态化页面
京东:10000000个商品也米娜 50台 上亿级别静态化页面, 京东商品多,静态化页面太多
插入、修改、数据调整,这些都需要重新生成静态页面。
1个模板改了所有的静态化页面跟着改, 如果修改静态页面的一个字段(如果改个字段),需要重新生成所有的静态页面
架构方案的问题:
问题一:
我们知道数据新增分:增量和全量数据
如果后台的小二新增了很多的商品,那我们都要对这些商品进行静态化,但是现在有个问题。那这些数据如何同步了?这是一个新增商品同步的问题,那这个问题怎么解决比较好了?。
背景:不同应用部署在不同服务器甚至在不同的机房不同的国家。数据修改后,需要进行数据同步
同步的方案
1、通过网络同步的方式 就是其中一台服务器静态化之后,然后把文件同步到其他应用服务器上去。比如我们的linux命令scp方式。这种方式虽然可行,但是我们发现问题还是蛮多的,有多少个节点就需要同步多少份,等于是商品的数量*服务器的应用数数。很显然这种办法不是最优的解决办法
如果上述办法无法解决,那我们就用另外的方案,同学们你们觉得还有其他的方案没有?
**2、定时任务:**可以在某个应用用一个定时任务,然后分别去执行数据库需要静态化的数据即可,可以解决上述1数据同步的问题,因为所有的任务都是在本机运行,就不需要数据同步了。但是也有一个问题。就是如何避免不通的机器跑的数据不要重复,也就是A和B定时任务都跑了一份商品。这个是这种方案需要解决的。(比较直观的就是上锁) 我理解:就是每个节点服务器上启动定时任务,自动去复制静态化页面和数据
**3、消息中间件:**还有一种办法就是通过消息中间件来解决。订阅topic然后生成当前服务器静态化的页面。
问题二:
我们的freemark它是数据要事先按我这个模板生产好的,那就是说一定你改了模板,如果要生效的话,需要重新在把数据取出来和我们这个模板进行匹配生产更多的的静态html文件。那这是一个比较大的问题
如果后台数据有变更呢?如何及时同步到其它服务端?
如果页面静态化了,我们搜索打开一个商品详细页,怎么知道要我需要的访问的静态页面?
万一我们模板需要修改了怎么办?
牵一发动全身。
后台优化:
redis缓存:
redis设置:RedisConifg》RedisOpsUtil
/**
* 获取商品详情信息
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo(Long id) {
PmsProductParam productInfo = null;
//从缓存Redis里找
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if(null!=productInfo){
return productInfo;
}
productInfo = portalProductDao.getProductInfo(id);
if (null==productInfo) {
log.warn("没有查询到商品信息,id:"+id);
return null;
}
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
return productInfo;
}
好处:
加入redis之后我们发现提高了可以把之前请求 数据库查询的商品都缓存到redis中,通过对redis的访问来减少对数据里的依赖,减少了依赖本质就是减少了磁盘IO。
问题:
提高请求的吞吐量,除了减少磁盘IO,还有网络IO,我们可以发现,请求redis其实也会涉及到网络IO,我们所有的请求都要走xxx端口号。这个问题下一篇再总结
压力测试:
我们发现吞吐量有一定的提高。但是问题还是有的。