背景:
現如今網際網路無時無刻不在面臨着高并發問題,比如早年的小米手機出新産品時,大量的買家使用各種終端裝置進行瘋搶。再比如春運火車票開始發售時,微信群裡發紅包時。網際網路的開發包括java背景、Nosql、資料庫、限流、CDN、負載均衡等内容。
高并發系統的分析和設計
任何系統都不是獨立于業務開發的,都應該先分析業務需求和實際的場景。對于業務分析,首先是有效需求和無效需求,有效需求就是指真正的需求,這裡主要讨論無效的需求。
無效需求分為很多種類,比如通過腳本連續重新整理網站首頁,使得網站頻繁的通路資料庫和其他資源,又比如使用刷票軟體連續請求。鑒别有效請求和無效請求是擷取有效請求的高并發網站業務分析的第一步,下面分析無效請求的場景,以及應對的方法。
首先,一個賬号的連續請求,對于一些懂技術的使用者來說,可以使用軟體對請求的服務接口連續請求,甚至在一秒内請求成百上千次,這毫無疑問是無效請求。應對的方法有很多,最常見的就是加入驗證碼。一般而言,首次無驗證碼以便減少使用者錄入,第二次請求開始加入驗證碼,可以是圖形也可以是等式運算等。使用圖檔驗證碼有可能存在識别圖檔作弊軟體的攻擊,是以在一些網際網路網站上,圖檔驗證碼還會被加工成東倒西歪的形式,簡單的等式運算會使圖檔識别作弊軟體更加的難以辨認。 其次,使用短信服務,把驗證碼發送到短信平台以規避部分作弊軟體。僅僅做這一步還是不夠的,,畢竟驗證碼還是可以有其他的作弊軟體可以讀取的。應該在進一步限制請求,比如限制使用者在機關時間内的購買量來壓制使用者的請求量,注意這些判斷邏輯應該在負載均衡中實作,而不是在web伺服器中。負載均衡判斷驗證碼可以使用Lua或者C語言聯合Redis進行判斷。
這是對一個賬号連續無效請求的壓制,但有時候有些使用者可以申請多個帳戶來迷惑伺服器,使得可以避開對單個賬戶的驗證,進而獲得更多的伺服器資源。一個人多個賬戶的場景還是比較容易應對的,可以通過提高賬戶的等級來壓制請求,比如支付交易的網站,可以通過銀行卡驗證,實名制擷取相關證件号碼,進而使用證件号碼使得多個賬戶歸結為一人。
還有就是有組織的請求,對于一些黃牛組織,可能通過多個人的賬号發送請求,統一組織僞造有效的請求,對于這樣的請求我們可以使用僵屍賬号排除法對可交易的賬号進行排除,所謂僵屍賬号,是指那些平時沒有交易的賬号,在某些特殊的日子交易,比如春運期間進行大批量的搶購的賬号。還可以使用IP封禁,尤其是對同一網段或者是同一IP頻繁請求的,但是這樣可能會誤傷有效請求,是以IP封禁需要慎重!
系統設計
高并發的系統往往需要分布式的系統進行分攤請求的壓力,這就需要負載均衡服務了。它進行簡易判斷後就會分發到WEB伺服器了。伺服器可以按照業務分,比如購買分為産品維護、交易維護、資金維護、報表統計和使用者維護子產品,按照功能子產品進行區分,使得它們互相隔離,就可以降低資料的複雜性。這種方法被稱為水準分法。
這種方法的好處在于:首先一個服務管理一種業務,業務簡單,提高了開發效率;其次,資料庫的設計友善了很多。但是也會帶來麻煩,由于各個系統業務之間存在關聯,還要通過RPC處理關聯資訊,比較流行的有Dubbo、Thrift、Hessian等。其原理是,每一個服務都會暴露一些公共接口給RPC服務,這樣對于任何一個伺服器都能通過RPC服務擷取其他伺服器對應的接口氣排程各個伺服器的邏輯來完成功能,但是接口的互相調用會造成一定的緩慢。有水準分法就有垂直分法,垂直分法不是按照功能進行劃分,而是多台伺服器均攤請求,每個伺服器處理自己的業務互不幹擾,使得每個伺服器都包含所有的業務邏輯,會造成開發上的業務困難,資料庫也是如此。
對于大型網站多采用兩種方式結合的方法,首先按照業務區分為多個子系統,然後在每個子系統下再分多個伺服器,通過每個子系統的路由器找到對應的子系統的伺服器提供服務。
資料庫設計
對于資料庫而言,為了高性能,可以使用分表或者分庫技術。分表是指本來在一張表可以存儲的資料房子多張表進行存儲,比如transcation表,可以根據年份分為transcation_2016/transcation_2017等。分庫則不一樣,他把表資料放在不同的資料庫,分庫資料庫需要一個路由算法确定資料在哪個資料庫上。一些會員網站還區分活躍會員和非活躍會員。活躍會員通過資料遷移的手段,也就是先記錄在某個時間段會員的活躍度,然後通過資料遷移,将活躍會員平均分攤到各個資料庫中,進而達到資料庫的負載均衡。
做完這些還可以考慮優化sql,建立索引等優化,提高資料庫的性能。對于優化sql,在開發網站使用更新語句或者複雜查詢語句要時刻記住更新是表鎖定還是行鎖定,比如id是主鍵,而user_name是使用者名稱,也是唯一索引,更新使用者的生日時,可以使用如下兩條sql語句實作。
update t_user set birthday=#{birthday} where id=#{id};
update t_user set birthday=#{birthday] where user_name={userName};
,對于上面的兩條sql語句優先使用主鍵更新,其原因是MYSQL在運作的過程中,第二條sql語句會鎖表,即不僅鎖定更新的資料,而且鎖定其他表的資料,進而影響并發,而使用主鍵更新則是行鎖定。
sql優化還有很多細節,比如可以使用連接配接查詢代替子查詢。查詢一個沒有配置設定角色使用者的id,可能有人會用下面的sql
select u.id from t_user u
where u.id not in(select ur.user_id form t_user_role ur);
這是一個not in語句,性能低下,對于這樣的not in 或者 not exists語句,應該全部修改為表連接配接去執行。如下
select u.id from t_user u left join t_user_role ur
on u.id=ur.user_id
where ur.user_id is null;
這樣就大大提高了sql的執行性能。
此外還可以通過讀/寫分離技術,一台主機主要負責寫業務,多台備機負責讀業務,有助于性能的提高。
對于分布式資料庫而言,還有一個麻煩就是事務的一緻性。這個比較麻煩。
動靜分離技術
對于網際網路來說大部分的資料都是靜态資料,動态資料非常少,如果像視訊、圖檔之類的靜态資料都從動态伺服器(Tocmat、WildFly、WebLogic等)進行擷取,那麼動态伺服器的帶寬壓力就會很大,這時候可以采用動靜分離技術。有條件的企業可以考慮使用CDN技術,它允許企業将自己的靜态資料存儲在網絡CDN的節點中,就近取出緩存節點的資料,速度會很快。。一些企業也會是用自己的靜态HTTP伺服器,将靜态資料存儲在靜态伺服器上,盡量使用Cookie等技術,讓用戶端緩存可以緩存資料,避免多次請求,降低伺服器壓力。
鎖和高并發
動态資料是高并發網站關注的重點,而動态資料的請求最後會落在一台WEB伺服器上,高并發系統存在的一個麻煩就是并發資料不一緻的問題。比如搶紅包,會出現多個線程同時享有大紅包的場景,在高并發的場景中,由于每一個線程的順序不一樣,所有會導緻資料不一緻的問題。并且在一瞬間産生很高的并發,還要考慮性能的問題。加鎖會影響并發,而不加鎖難以保證資料的一緻性,這就是鎖和高并發的沖突。
為解決這一沖突,很多企業提出了悲觀鎖和樂觀鎖的概念。
開發設計
搭建搶紅包開發環境和超發現象
搭建Service層和DAO層
資料庫使用mysql資料庫,工具使用navicat for mysql.
遇到的第一個問題
2003 can't connect to mysql server 10038
原因
是因為把服務禁用的緣故
解決方法(3)
第一種方法:
第一步:
先看報錯視窗
2003 can't connect to MySQL server on '127.0.0.1'(10038).
第二步:
原因是:遠端3306端口未對外開放操作。
第三步:
首先遠端連接配接伺服器,點選"開始"--> "管理工具"-->"進階安全Windows防火牆"。
第四步:
在打開的視窗中,左邊選中"入站規則",右邊點選"建立規則"來建立一個入站規則。
第五步:
在"規則類型"中選擇"端口",然後下一步。
第六步:
選擇"特定本地連接配接",輸入3306。
第七步:
選擇"允許連接配接",然後點選下一步。
第八步:
預設都選中就行,然後下一步。
第九步:
給規則起一個名字,什麼都可以,隻要自己認識就行。
第十步:
重新打開MySql就可以了。
第二種方法:
第一步:
打開服務,看看MySql是否啟動。
第二步:
啟動MySql服務。
第三種方法:
第一步:
找到"開始"菜單,打開cmd。
第二步:
輸入net start mysql。
開發設計
搭建搶紅包開發環境和超發現象
首先要在資料庫建表,一個是紅包表,另一個是使用者搶紅包表。這裡的紅包表是指一個大紅包的資訊,分為若幹個小紅包,為了業務簡單,假設每個紅包是等額的。下面給出這兩個表的sql和資料。如下:
CREATE TABLE T_RED_PACKET
(
id int(12) not null auto_increment,
user_id int(12) not NULL,
amount decimal(16,2) not null,
send_date timestamp not null,
total int(12) not NULL,
unit_amount decimal(12) not null,
stock int(12) not null,
version int(12) default 0 not null,
note varchar(256) null,
primary key clustered(id)
);
create table T_USER_RED_PACKET
(
id int(12) not null auto_increment,
red_packet_id int(12) not null,
user_id int(12) not null,
amount decimal(16,2) not null,
grab_time timestamp not null,
note varchar(256) null,
primary key clustered (id)
);
insert into t_red_packet(user_id,amount,send_date,total,unit_amount,stock,note)
values(1,200000.00,now(),20000,10.00,20000,'20萬元金額,2萬個小紅包,每個10元');
有了這兩個表,我們就可以為這兩個表建pojo了
首先建立web項目,如果不會請參考-eclipse建立java web項目
接下來建立包和pojo
//RedPacket.java
package com.ssm.wdz.pojo;
import java.io.Serializable;
import java.sql.Timestamp;
public class RedPacket implements Serializable{
private static final long serialVersionUID = -5921274777655497099L;
private Long id;
private Long userId;
private Double amount;
private Timestamp sendDate;
private Integer total;
private Double unitAmount;
private Integer stock;
private Integer version;
private String note;
...
}
//UserRedPacket.java
package com.ssm.wdz.pojo;
import java.io.Serializable;
import java.sql.Timestamp;
public class UserRedPacket implements Serializable{
private static final long serialVersionUID = 1229009027725607718L;
private Long id;
private Long redPacketId;
private Long userId;
private Double amount;
private Timestamp grabTime;
private String note;
...
}
這兩個pojo,一個是紅包資訊,一個是搶紅包資訊,使用Mybatis開發它們,先來完成大紅包的查詢,定義DAO對象。
//RedPacketDao.java
package com.ssm.wdz.dao;
import com.ssm.wdz.pojo.RedPacket;
@Repository
public interface RedPacketDao {
public RedPacket getRedPacket(Long id);
public int decreaseRedPacket(Long id);
}
其中的兩個方法,其中的兩個方法,一個是查詢紅包,另一個是扣減紅包庫存。下面使用xml映射,如下:
//RedPacket.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.ssm.wdz.dao.RedPacketDao">
< !-- 查詢紅包具體資訊 -->
< select id="getRedPacket" parameterType="long" resultType="com.ssm.wdz.pojo.RedPacket">
select id user_id as userId,amount,send_date as sendDate,total,unit_amount as unitAmount,stock,version,note
from T_RED_PACKET where id=#{id}
< /select>
< !-- 扣減搶紅包庫存 -->
< update id="decreaseRedPacket">
update T_RED_PACKET set stock=stock-1 where id=#{id}
< /update>
< /mapper>
這裡沒有加鎖之類的操作,目的是為了示範超發紅發的情況,接下來就是搶紅包的設計了,下面是插入搶紅包的DAO。
開發設計
搭建搶紅包開發環境和超發現象
緊接上次的設計,搶紅包的設計,先來定義搶紅包的DAO,如下:
UserRedPacketDao.java
package com.ssm.wdz.dao;
import org.springframework.stereotype.Repository;
import com.ssm.wdz.pojo.UserRedPacket;
@Repository
public interface UserRedPacketDao {
public int grapRedPacket(UserRedPacket userRedPacket);
}
同樣的,也要映射xml,如下:
//UserRedPacket.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.ssm.wdz.dao.UserRedPacketDao">
< !-- 插入搶紅包資訊 -->
< insert id="grapRedPacket" useGeneratedKeys="true" keyProperty="id" parameterType="com.ssm.wdz.pojo.UserRedPacket">
insert into t_user_red_packet(red_packed_id,user_id,amount,grab_time,note)
values(#{redPacketId},#{userId},#{amount},now(),#{note})
< /insert>
< /mapper>
在這裡使用了useGeneratedKeys和KeyProPerty,這就意味着會傳回資料庫生成的主鍵資訊,進行主鍵回填,關于DAO層在這裡就基本完成了。
接下來定義兩個Service接口,分别是UserRedPacketService和RedPacketService,如下:
//RedPacketService.java
package com.ssm.wdz.service;
import com.ssm.wdz.pojo.RedPacket;
public interface RedPacketService {
public int decreaseRedPacket(Long id);
public RedPacket getRedPacket(Long id);
}
//UserRedPacketService.java
package com.ssm.wdz.service;
public interface UserRedPacketService {
public int grapRedPacket(Long redPacketId,Long userId);
}
它的兩個實作類,如下:
//RedPacketServiceImpl.java
package com.ssm.wdz.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.ssm.wdz.dao.RedPacketDao;
import com.ssm.wdz.pojo.RedPacket;
import com.ssm.wdz.service.RedPacketService;
@Service
public class RedPacketServiceImpl implements RedPacketService{
@Autowired
private RedPacketDao redPacketDao=null;
@Override
@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
public RedPacket getRedPacket(Long id) {
return redPacketDao.getRedPacket(id);
}
@Override
@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
public int decreaseRedPacket(Long id) {
return redPacketDao.decreaseRedPacket(id);
}
}
配置了事務注解@Transactional,讓程式能夠在事務中運作,保證資料的一緻性,這裡采用的是讀/寫送出的隔離級别,對于傳播行為采用的Propagation.REQUIRED,這樣調用這個 方法的時候,如果沒有事務就會建立,如果有則沿用目前的事務。
//UserRedPacketServiceImpl.java
package com.ssm.wdz.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.ssm.wdz.dao.RedPacketDao;
import com.ssm.wdz.dao.UserRedPacketDao;
import com.ssm.wdz.pojo.RedPacket;
import com.ssm.wdz.pojo.UserRedPacket;
import com.ssm.wdz.service.UserRedPacketService;
@Service
public class UserRedPacketServiceImpl implements UserRedPacketService{
@Autowired
private UserRedPacketDao userRedPacketDao=null;
@Autowired
private RedPacketDao redPacketDao=null;
//失敗
private static final int FAILED=0;
@Override
@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
public int grapRedPacket(Long redPacketId,Long userId) {
//擷取紅包資訊
RedPacket redPacket=redPacketDao.getRedPacket(redPacketId);
//目前小紅包庫存大于0
if(redPacket.getStock()>0) {
redPacketDao.decreaseRedPacket(redPacketId);
//生成搶紅包資訊
UserRedPacket userRedPacket=new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("搶紅包"+redPacketId);
//插入強紅包資訊
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
//失敗傳回
return FAILED;
}
}
grapRedPacket方法的邏輯是先擷取紅包資訊,如果發現紅包庫存大于0,則搶紅包并生成搶紅包的資訊将其儲存到資料庫中。因為有注解Transactional的存在,所有的操作都在一個事務中完成。在高并發中就會發生超發現象。
使用全注解搭建SSM開發環境
//WebAppInitializer.java
package com.ssm.wdz.config;
import javax.servlet.MultipartConfigElement;
import javax.servlet.ServletRegistration.Dynamic;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer{
//Sring Ioc環境配置
@Override
protected Class< ?>[] getRootConfigClasses(){
//配置Spring Ioc資源
return new Class< ?>[] {RootConfig.class};
}
//DispactchServlet環境配置
@Override
protected Class< ?>[] getServletConfigClasses() {
//加載java配置類
return new Class< ?>[] {WebConfig.class};
}
//DispactchServlet攔截請求配置
@Override
protected String[] getServletMappings() {
return new String[] {"*.do"};
}
@Override
protected void customizeRegistration(Dynamic dynamic) {
//配置上傳檔案路徑
String filepath="e:/mvc/uploads";
//5MB
Long singleMax=(long) (5*Math.pow(2, 20));
//10MB
Long totalMax=(long) (10*Math.pow(2, 20));
//設定上傳檔案配置
dynamic.setMultipartConfig(new MultipartConfigElement(filepath,singleMax,totalMax,0));
}
}
這個類繼承了AbstractAnnotationConfigDispatcherServletInitializer,它實作了3個抽象方法,并且覆寫了父類的customizeRedistration方法,作為上傳檔案的配置。3個方法為:
getRootConfigClasses是一個配置Spring Ioc容器的上下文配置,此配置在代碼中将由RootConfig完成。
getServletConfigClasses配置DispatcherServlet上下文配置,将會由WebConfig完成。
getServletMappings配置DispatcherServlet攔截内容,攔截所有的以.do結尾的請求。
通過這三個方法就可以配置Web工程中的Spring Ioc資源和DispatcherServlet的内容,首先是配置Spring Ioc的内容,配置類RootConfig,如下:
//RootConfig.java
package com.ssm.wdz.config;
import java.util.Properties;
import javax.sql.DataSource;
import org.apache.tomcat.dbcp.dbcp.BasicDataSourceFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.TransactionManagementConfigurer;
@Configuration
//定義Spring掃描的包
@ComponentScan(value="com.*",includeFilters= {@Filter(type=FilterType.ANNOTATION,value= {Service.class})})
//使用事務驅動管理器
@EnableTransactionManagement
//實作接口TransactionManagementConfigurer,這樣就可以配置注解驅動事務
public class RootConfig implements TransactionManagementConfigurer{
private DataSource dataSource=null;
@Bean(name="dataSource")
public DataSource initDataSource() {
if(dataSource!=null) {
return dataSource;
}
Properties props=new Properties();
props.setProperty("driverClassName", "com.mysql.jdbc.Driver");
props.setProperty("url", "jdbc:localhost:3306/redpacket");
props.setProperty("username", "root");
props.setProperty("password", "123456");
props.setProperty("maxActive", "200");
props.setProperty("maxIdle", "20");
props.setProperty("maxWait", "30000");
try {
dataSource=BasicDataSourceFactory.createDataSource(props);
}catch(Exception e) {
e.printStackTrace();
}
return dataSource;
}
@Bean(name="sqlSessionFactory")
public SqlSessionFactoryBean initSqlSessionFactory() {
SqlSessionFactoryBean sqlSessionFactory=new SqlSessionFactoryBean();
sqlSessionFactory.setDataSource(initDataSource());
//配置Mybatis配置檔案
Resource resource=new ClassPathResource("mybatis/mybatis-config.xml");
sqlSessionFactory.setConfigLocation(resource);
return sqlSessionFactory;
}
@Bean
public MapperScannerConfigurer initMapperScannerConfigurer() {
MapperScannerConfigurer msc=new MapperScannerConfigurer();
msc.setBasePackage("com.*");
msc.setSqlSessionFactoryBeanName("sqlSessionFactory");
msc.setAnnotationClass(Repository.class);
return msc;
}
@Override
@Bean(name="annotationDrivenTransactionManager")
public PlatformTransactionManager annotationDrivenTransactionManager() {
DataSourceTransactionManager transactionManager=new DataSourceTransactionManager();
transactionManager.setDataSource(initDataSource());
return transactionManager;
}
}
這個類标注了注解@EnableTransactionManagement,實作了接口TransactionManagementConfigurer,這樣配置是為了實作注解式的事務,将來可以通過注解@Transactional配置資料庫事務。它有一個方法定義,這個方法是annotationDrivenTransactionManager,這需要将一個事務管理器傳回給它就行了。除了配置資料庫事務外,還配置了資料源SqlSessionFactoryBean和Mybatis的掃描類,并把Mybatis的掃描類通過注解@Repository和包名限定。這樣Mu把提示就會通過Spring的機制找到對應的接口和配置,Spring會自動把對應的接口裝配到Ioc容器中。
有了Spring Ioc容器後,還需要配置DispatcherServlet上下文,完成這個任務的是WenConfig配置類,
開發設計
昨天完成了Spring Ioc容器的配置,還需要配置DispatcherServlet上下文,完成這個任務的類是WebConfig,如下:
//WebConfig.java
package com.ssm.wdz.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
@Configuration
//定義Spring Mvc的掃描包
@ComponentScan(value="com.*",includeFilters= {@Filter(type=FilterType.ANNOTATION,value=Controller.class)})
//啟動Spring Mvc配置
@EnableWebMvc
public class WebConfig {
@Bean(name="internalResourceViewResolver")
public ViewResolver initViewResolver() {
InternalResourceViewResolver viewResolver=new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
@Bean(name="requestMappingHandlerAdapter")
public HandlerAdapter initRequestMappingHandlerAdapter() {
//建立RequestMappingHandlerAdapter擴充卡
RequestMappingHandlerAdapter rmhd=new RequestMappingHandlerAdapter();
//HTTP JSON轉換器
MappingJackson2HttpMessageConverter jsonConverter=new MappingJackson2HttpMessageConverter();
//MappingJackson2HttpMessageConverter接收JSON類型消息的轉換
MediaType mediaType=MediaType.APPLICATION_JSON;
List mediaTypes=new ArrayList();
mediaTypes.add(mediaType);
//加入轉化器支援類型
jsonConverter.setSupportedMediaTypes(mediaTypes);
//往擴充卡加入json轉換器
rmhd.getMessageConverters().add(jsonConverter);
return rmhd;
}
}
這裡配置了一個視圖解析器,通過他找到對應的jsp檔案,然後使用資料模型進行渲染,采用自定義建立RequestMappingHandlerAdapter,為了讓它能夠支援JSON格式(@ResponseBody)的轉換,是以需要建立一個關于對象和JSON的轉換消息類,那就是MappingJackSon2HttpMessageConverter類對象。建立它之後,把它注冊給RequestMappingHandlerAdapter對象,這樣當控制器遇到注解@ResponseBody的時候就知道采用JSON消息類型進行應答,那麼在控制器完成邏輯之後,由處理器将其和消息轉換類型做比對,找到MappingJackSon2HttpMessageConverter類對象,進而轉變為JSON資料。
通過之前的的三個類就搭建好了Spring Mvc和Spring的開發環境,但是沒有完成對MyBatis配置檔案,在RootConfig中可以看出,使用檔案/mybatis/mybatis-config.xml進行配置,代碼如下:
//mybatis-config.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">
< configutation>
< mappers>
< mapper resource="com/ssm/wdz/mapper/UserRedPacket.xml"/>
< mapper resource="com/ssm/wdz/mapper/RedPacket.xml"/>
< /mappers>
< /configutation>
這樣關于背景的邏輯已經完成,接下來就要開發控制器。
樂觀鎖重入機制
使用樂觀鎖造成大量的更新失敗的問題,使用時間戳執行樂觀鎖重入,是一種提高成功率的方法,比如考慮在100毫秒内允許重入,把UserRedPacketServiceImpl中的方法grapRedPacketForVersion修改為如下代碼:
/ *
* 樂觀鎖重入機制,時間戳限制
* /
long startTime = System.currentTimeMillis();
//記錄開始時間
long start=startTime;
//無限循環,等待 成功或者滿100毫秒退出
while(true) {
//擷取循環目前時間
long end=System.currentTimeMillis();
if(end-start>100) {
return FAILED;
}
//擷取紅包資訊,注意version值
RedPacket redPacket=redPacketDao.getRedPacket(redPacketId);
//目前小紅包庫存大于0
if(redPacket.getStock()>0) {
//再次傳入線程儲存的version舊值給sql判斷,是否有其他線程更改過資料
int update=redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
//如果沒有資料更新,則說明其他線程已經修改過資料,本次搶紅包失敗
if(update==0) {
continue;
}
//生成搶紅包資訊
UserRedPacket userRedPacket=new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("redPacketId:"+redPacketId);
//插入搶紅包資訊
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}else {
long endTime = System.currentTimeMillis();
//輸出程式運作時間
System.out.println("搶紅包程式運作時間:" + (endTime - startTime) + "ms");
//失敗傳回
return FAILED;
}
}
當因為版本号原因更新失敗的時候,會重新嘗試搶紅包,但是會實作判斷時間戳,如果時間戳在100毫秒之内,就繼續,否則就不再重新嘗試,這樣可以避免過多sql執行,維持系統穩定,但是有的時候時間戳也不是那麼穩定,也會随着系統的空閑或者繁忙導緻重試次數不一,有時候我們會考慮重試次數,比如3次,下面在改寫上一個方法,如下:
/*
* 樂觀鎖重入機制,次數限制
* /
long startTime = System.currentTimeMillis();
for(int i=0;i<3;i++) {
//擷取紅包資訊,注意version值
RedPacket redPacket=redPacketDao.getRedPacket(redPacketId);
//目前小紅包庫存大于0
if(redPacket.getStock()>0) {
//再次傳入線程儲存的version舊值給sql判斷,是否有其他線程更改過資料
int update=redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
//如果沒有資料更新,則說明其他線程已經修改過資料,本次搶紅包失敗
if(update==0) {
continue;
}
//生成搶紅包資訊
UserRedPacket userRedPacket=new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("redPacketId:"+redPacketId);
//插入搶紅包資訊
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}else {
long endTime = System.currentTimeMillis();
//輸出程式運作時間
System.out.println("搶紅包程式運作時間:" + (endTime - startTime) + "ms");
//失敗傳回
return FAILED;
}
}
return FAILED;
通過for循環限定重試3次,3次過後無論失敗與否都會判定為失敗而退出,這樣就能避免過多的重試導緻過多的sql被執行的問題,進而保證資料庫的性能。進行了上述兩種方法的測試,結果如下:
時間戳限制:搶紅包程式運作時間:909231ms
次數限制:搶紅包程式運作時間:311098ms
顯然,使用次數限制的重入樂觀鎖取得了很好的效果,沒有超發但是會有有搶紅包失敗!
還是沒怎麼解決高機率失敗現象。因機器而異吧。
但是現在是使用資料庫的情況,有時候并不想使用資料庫作為搶紅包的資料儲存載體,而是選擇性能優于資料庫的redis。明天将使用redis處理高并發的請求。
開發設計
使用Redis實作搶紅包
資料庫最終會将資料儲存到磁盤中,而Redis使用的是記憶體,記憶體的速度要比磁盤的速度快的多,是以這裡将談論使用Redis實作搶紅包。
對于使用Redis實作搶紅包,首先需要知道的是Redis功能不如資料庫強大,事務也不完整,是以要保證資料的正确性,資料的正确性可以通過嚴格地驗證進行保證。而Redis的Lua語言是原子性的,且功能更為強大,是以優先選擇使用Lua語言來實作搶紅包。但是無論如何對于資料而言,在Redis當中存儲,始終都不是長久之計,因為Redis并非一個長久存儲資料的地方,它存儲的資料是非嚴格和安全的環境,更多的時候隻是為了提供更多的緩存,是以當紅包金額為0或者紅包逾時的時候(逾時操作可以使用定時機制實作),會将紅包資料儲存到資料庫中,這樣才能保證資料的安全性和嚴格性。
使用注解方式配置Redis
想要使用
接下來進行編碼,
首先在RootConfig上建立一個RedisTemplate對象,并将其裝載到Spring Ioc容器中,如下:
@Bean(name="redisTemplate")
public RedisTemplate initRedisTemplate() {
JedisPoolConfig poolConfig=new JedisPoolConfig();
//最大空閑數
poolConfig.setMaxIdle(50);
//最大連接配接數
poolConfig.setMaxTotal(100);
//最大等待毫秒數
poolConfig.setMaxWaitMillis(20000);
//建立Jedis連結工廠
JedisConnectionFactory connectionFactory=new JedisConnectionFactory(poolConfig);
connectionFactory.setHostName("localhost");
connectionFactory.setPort(6379);
//調用初始化方法,沒有它将抛出異常
connectionFactory.afterPropertiesSet();
//自定義Redis序列化器
RedisSerializer jdkSerializationRedisSerializer=new JdkSerializationRedisSerializer();
RedisSerializer stringRedisSerializer=new StringRedisSerializer();
//定義RedisTemplate,并設定連接配接工廠
RedisTemplate redisTemplate=new RedisTemplate();
redisTemplate.setConnectionFactory(connectionFactory);
//設定序列化器
redisTemplate.setDefaultSerializer(stringRedisSerializer);
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setHashValueSerializer(stringRedisSerializer);
return redisTemplate;
}
這樣RedisTemplate就可以在Spring上下文中使用了。注意,JedisConnectionFactory對象在最後的時候需要自行調用afterPropertiesSet方法,它實作了InitializingBean接口,如果其配置在Spring Ioc容器中,Spring會自動調用它,但是這裡我們是自行建立的,是以需要自行調用,否則在運用的時候會抛出異常,進而出現錯誤。
資料存儲設計
Redis并不是一個嚴格的事務,而且事務功能也是有限的。加上Redis本身的指令也比較有限,功能性不強,為了增強功能性,還可以使用Lua語言。Redis中的Lua語言是一種原子性的操作,可以保證資料的一緻性。依據這個原理可以避免超發現象,完成搶紅包的功能,而且對性能而言,Redis要比資料庫快的多。
第一次運作Lua腳本的時候,現在Redis中編譯和緩存腳本,這樣就可以得到一個SHAl字元串,之後通過SHAl字元串和參數就能調用Lua腳本了。先來編寫Lua腳本,代碼如下:
local listKey="'red_packet_list_'..KEYS[1] "
+"local redPacket='red_packet_'..KEYS[1] "
+"local stock=tonumber(redis.call('hget',redPacket,'stock')) "
+"if stock<=0 then return 0 end "
+"stock=stock-1 "
+"redis.call('hset',redPacket,'stock',toString(stock)) "
+"redis.call('rpush',listKey,ARGV[1]) "
+"if stock ==0 then return 2 end "
+"return 1"
這裡可以看到這樣一個流程:
判斷是否存在可以搶奪的紅包,對于紅包的庫存,如果已經沒有可以搶奪的紅包,則傳回為0,結束流程。
有可搶奪的紅包,對于紅包的庫存減1,然後重新設定庫存。
将搶紅包資料儲存到Redis的連結清單當中,連結清單的key為red_packet_list_{id}。
如果目前庫存為0,那麼傳回2,這說明可以觸發資料庫對Redis連結清單資料的儲存。
連結清單的key為red_packet_list_{id}.它将儲存搶紅包的使用者名和搶得時間。
如果目前庫存不為0,那麼傳回1,這說明搶紅包資訊儲存成功。
當傳回為2的時候(現實中如果搶不完紅包,可以使用逾時機制觸發,比較複雜),說明紅包已經沒有庫存,會觸發資料庫對連結清單的資料的儲存,這是一個大資料量的儲存。為了不影響最後一次搶紅包的響應,在實際的操作中往往會考慮使用JMS消息發送到别的伺服器進行操作,這樣會比較複雜,這裡隻是建立一條新的線程去運作儲存Redis連結清單資料到資料庫,為此我們需要一個新的服務類,如下
//RedisRedPacketService.java
package com.ssm.wdz.service;
public interface RedisRedPacketService {
/ **
* 儲存redis搶紅包清單
* @param redPacketId 搶紅包編号
* @param unitAmount 紅包金額
* /
public void saveUserRedPacketByRedis(Long redPacketId,Double unitAmount);
}
//RedisRedPacketServiceImpl.java
package com.ssm.wdz.service.impl;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.ssm.wdz.pojo.UserRedPacket;
import com.ssm.wdz.service.RedisRedPacketService;
@Service
public class RedisRedPacketServiceImpl implements RedisRedPacketService{
private static final String PREFIX="red_packet_list_";
//每次取出1000條,避免一次取出消耗太多記憶體
private static final int TIME_SIZE=1000;
@Autowired
//RedisTemplate
private RedisTemplate redisTemplate=null;
@Autowired
//資料源
DataSource dataSource=null;
@Override
//開啟新線程運作
@Async
public void saveUserRedPacketByRedis(Long redPacketId,Double unitAmount) {
System.err.println("開始存資料");
Long start =System.currentTimeMillis();
//擷取清單操作對象
BoundListOperations ops=redisTemplate.boundListOps(PREFIX+redPacketId);
Long size=ops.size();
Long times=size%TIME_SIZE==0?size/TIME_SIZE:size/TIME_SIZE+1;
int count=0;
List userRedPacketList=new ArrayList(TIME_SIZE);
for(int i=0;i
//擷取至多TIME_SIZE個搶紅包資訊
List userIdList=null;
if(i==0) {
userIdList=ops.range(i*TIME_SIZE, (i+1)*TIME_SIZE);
}else {
userIdList=ops.range(i*TIME_SIZE+1,(i+1)*TIME_SIZE);
}
userRedPacketList.clear();
//儲存紅包資訊
for(int j=0;j
String args=userIdList.get(j).toString();
String[] arr=args.split("-");
String userIdStr=arr[0];
String timeStr=arr[1];
Long userId =Long.parseLong(userIdStr);
Long time=Long.parseLong(timeStr);
//生成搶紅包資訊
UserRedPacket userRedPacket=new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(unitAmount);
userRedPacket.setGrabTime(new Timestamp(time));
userRedPacket.setNote("redPakcetId:"+redPacketId);
userRedPacketList.add(userRedPacket);
}
//插入搶紅包資訊
count+=executeBatch(userRedPacketList);
}
//删除Redis清單
redisTemplate.delete(PREFIX+redPacketId);
Long end=System.currentTimeMillis();
System.err.println("儲存資料結束,耗時"+(end-start)+"毫秒,共"+count+"條記錄被儲存。");
}
/ **
*使用JDBC批量處理Redis緩存資料
* @param userRedPacketList --搶紅包清單
* @return 搶紅包插入數量
* /
private int executeBatch(List userRedPacketList) {
Connection conn=null;
Statement stmt=null;
int []count=null;
try {
conn=dataSource.getConnection();
conn.setAutoCommit(false);
stmt=conn.createStatement();
for(UserRedPacket userRedPacket:userRedPacketList) {
String sql1="update t_red_packet set stock=stock-1 where id="+userRedPacket.getRedPacketId();
DateFormat df=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String sql2="insert into t_user_red_packet(red_packet_id,user_id,"+"amount,grab_time,note)"
+ "values("+userRedPacket.getRedPacketId()+","
+userRedPacket.getUserId()+","
+userRedPacket.getAmount()+","
+"'"+userRedPacket.getNote()+"')";
stmt.addBatch(sql1);
stmt.addBatch(sql2);
}
//執行批量
count=stmt.executeBatch();
//送出事務
conn.commit();
}catch(SQLException e) {
/ **錯誤處理邏輯** /
throw new RuntimeException("搶紅包批量執行程式錯誤");
}finally {
try {
if(conn!=null&&!conn.isClosed()) {
conn.close();
}
}catch(SQLException e) {
e.printStackTrace();
}
}
//傳回插入搶紅包資料記錄
return count.length/2;
}
}
注意,注解@Async表示讓Spring自動建立另外一條線程去運作它,這樣它便不在搶最後一個紅包的線程内。因為這個方法是一個較長時間的方法,如果在同一個線程内,那麼對于最後一個搶紅包的使用者需要等待的時間太長,影響體驗。這裡是每次取出1000個搶紅包的資訊,之是以這樣做是為了避免取出的資料過大,導緻JVM消耗過多的記憶體影響系統性能。對于大批量的資料操作,這是我們在實際操作中要注意的,最後還會删除Redis儲存的連結清單資訊,這樣就能幫助Redis釋放記憶體了。對于資料庫的儲存,這裡采用了JDBC的批量處理,每1000條批量儲存一次,使用批量有助于性能的提高。
使用@Async的前提是提供一個任務池給Spring環境,這個時候要在原有的基礎上改寫配置類WebConfig,如下:
@EnableAsync
public class WebConfig extends AsyncConfigurerSupport{
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor taskExecutor=new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(5);
taskExecutor.setMaxPoolSize(10);
taskExecutor.setQueueCapacity(200);
taskExecutor.initialize();
return taskExecutor;
}
使用@EnableAsync表明支援異步調用,接着重寫了抽象類AsyncConfigurerSupport的getAsyncExecutor方法,它是擷取一個任務池,當在Spring環境中遇到注解@Async就會啟動這個任務池的一條線程去運作對應的方法,這樣便能夠異步執行了。
使用Redsi實作搶紅包
首先應該編寫Lua語言,使用對應的連結發送給Redis伺服器,那麼Redis會傳回一個SHAl字元串,我們儲存它,之後的發送可以隻發送這個字元和對應的參數。下面在UserRedPacketService中加入一個新的方法:
//UserRedPacketService.java
/ **
* 通過Redis實作搶紅包
* @param redPacketId 紅包編号
* @param userId 使用者編号
* @return
* 0-沒有庫存,失敗
* 1-成功,且不是最後一個紅包
* 2-成功,且是最後一個紅包
* /
public Long grapRedPacketByRedis(Long redPacketId,Long userId);
它的實作類UserRedPacketServiceImpl也要加入實作方法,如下:
//UserRedpacketServiceImpl.java
@Autowired
private RedisTemplate redisTemplate=null;
@Autowired
private RedisRedPacketService redisRedPacketService=null;
//Lua腳本
String script="local listKey='red_packet_list_'..KEYS[1] \n"
+"local redPacket='red_packet_'..KEYS[1] \n"
+"local stock=tonumber(redis.call('hget',redPacket,'stock')) \n"
+"if stock<=0 then return 0 end \n"
+"stock=stock-1 \n"
+"redis.call('hset',redPacket,'stock',toString(stock)) \n"
+"redis.call('rpush',listKey,ARGV[1]) \n"
+"if stock ==0 then return 2 end \n"
+"return 1 \n";
//緩存Lua腳本後,使用該變量儲存Redis傳回的32位SHAl編碼,使用它去執行緩存的Lua腳本
String shal=null;
@Override
public Long grapRedPacketByRedis(Long redPacketId,Long userId) {
//目前搶紅包使用者和日期資訊
String args=userId+"-"+System.currentTimeMillis();
Long result=null;
//擷取底層Redis操作對象
Jedis jedis=(Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();
try {
//如果腳本沒有加載過那麼進行加載,這樣就會傳回一個SHAl編碼
if(shal==null) {
shal=jedis.scriptLoad(script);
}
//執行腳本,傳回結果
Object res=jedis.evalsha(shal,1,redPacketId+"",args);
result =(Long) res;
//傳回2時為最後一個紅包,此時将紅包資訊通過異步儲存到資料庫中
if(result==2) {
//擷取單個小紅包金額
String unitAmountStr=jedis.hget("red_packet_"+redPacketId,"unit_amount" );
//觸發儲存資料庫操作
Double unitAmount=Double.parseDouble(unitAmountStr);
System.err.println("thread_name="+Thread.currentThread().getName());
redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);
}
}finally {
//確定jedis順利關閉
if(jedis!=null&&jedis.isConnected()) {
jedis.close();
}
}
return result;
}
這裡使用了儲存腳本傳回的SHAl字元串,是以隻會發送一次腳本到Redis伺服器,之後隻傳輸SHAl字元串和參數到Redis就能執行腳本了,當腳本傳回2的時候,表示此時所有的紅包都被搶光了,那麼就會觸發redisRedPacketService的saveUserRedPacketByRedis方法。由于在此方法加入了注解@Async,是以Spring會建立一條新的線程去運作它,這樣就不會影響最後搶紅包使用者的響應時間了。
此時重新在控制器UserRedPacketController中加入新的方法作為響應就可以了,如下
//UserRedPacketController.java
@RequestMapping(value="/grapRedPacketByRedis")
@ResponseBody
public Map grapRedPacketByRedis(Long redPacketId,Long userId){
Map resultMap=new HashMap();
long result=userRedPacketService.grapRedPacketByRedis(redPacketId, userId);
boolean flag=result>0;
resultMap.put("result", flag);
resultMap.put("message", flag?"搶紅包成功":"搶紅包失敗");
return resultMap;
}
為了測試它,我們現在Redis上添加紅包資訊,于是執行如下指令:
初始化了一個編号為5的大紅包,其中庫存為2萬個,每個10元。最後測試時間僅需2秒就可搶完所有紅包而且也沒有超發少搶現象。