zoukankan      html  css  js  c++  java
  • shiro之redis频繁访问问题

    目前安全框架shiro使用较为广泛,其功能也比较强大。为了分布式session共享,通常的做法是将session存储在redis中,实现多个节点获取同一个session。此实现可以实现session共享,但session的特点是内存存储,就是为了高速频繁访问,每个请求都必须验证session是否存在是否过期,也从session中获取数据。这样导致一个页面刷新过程中的数十个请求会同时访问redis,在几毫秒内同时操作session的获取,修改,更新,保存,删除等操作,从而造成redis的并发量飙升,刷新一个页面操作redis几十到几百次。

    为了解决由于session共享造成的redis高并发问题,很明显需要在redis之前做一次短暂的session缓存,如果该缓存存在就不用从redis中获取,从而减少同时访问redis的次数。如果做session缓存,主要有两种种方案,其实原理都相同:

      1>重写sessionManager的retrieveSession方法。首先从request中获取session,如果request中不存在再走原来的从redis中获取。这样可以让一个请求的多次访问redis问题得到解决,因为request的生命周期为浏览器发送一个请求到接收服务器的一次响应完成,因此,在一次请求中,request中的session是一直存在的,并且不用担心session超时过期等的问题。这样就可以达到有多少次请求就几乎有多少次访问redis,大大减少单次请求,频繁访问redis的问题。大大减少redis的并发数量。此实现方法最为简单。

     1 package cn.uce.web.login.filter;
     2 
     3 import java.io.Serializable;
     4 
     5 import javax.servlet.ServletRequest;
     6 
     7 import org.apache.shiro.session.Session;
     8 import org.apache.shiro.session.UnknownSessionException;
     9 import org.apache.shiro.session.mgt.SessionKey;
    10 import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
    11 import org.apache.shiro.web.session.mgt.WebSessionKey;
    12 
    13 public class ShiroSessionManager extends DefaultWebSessionManager {
    14      /**
    15      * 获取session
    16      * 优化单次请求需要多次访问redis的问题
    17      * @param sessionKey
    18      * @return
    19      * @throws UnknownSessionException
    20      */
    21     @Override
    22     protected Session retrieveSession(SessionKey sessionKey) throws UnknownSessionException {
    23         Serializable sessionId = getSessionId(sessionKey);
    24 
    25         ServletRequest request = null;
    26         if (sessionKey instanceof WebSessionKey) {
    27             request = ((WebSessionKey) sessionKey).getServletRequest();
    28         }
    29 
    30         if (request != null && null != sessionId) {
    31             Object sessionObj = request.getAttribute(sessionId.toString());
    32             if (sessionObj != null) {
    33                 return (Session) sessionObj;
    34             }
    35         }
    36 
    37         Session session = super.retrieveSession(sessionKey);
    38         if (request != null && null != sessionId) {
    39             request.setAttribute(sessionId.toString(), session);
    40         }
    41         return session;
    42     }
    43 }
     <!-- session管理器 -->
        <bean id="sessionManager" class="cn.uce.web.login.filter.ShiroSessionManager">
          <!-- 超时时间 -->
          <property name="globalSessionTimeout" value="${session.global.timeout}" />
          <!-- session存储的实现 -->
          <property name="sessionDAO" ref="redisSessionDAO" />
          <!-- <property name="deleteInvalidSessions" value="true"/> -->
          <!-- 定时检查失效的session -->
          <!-- <property name="sessionValidationSchedulerEnabled" value="true" /> -->
          <!-- <property name="sessionValidationScheduler" ref="sessionValidationScheduler"/>
          <property name="sessionIdCookieEnabled" value="true"/> -->
          <property name="sessionIdCookie" ref="sessionIdCookie" />  
        </bean>

      2>session缓存于本地内存中。自定义cacheRedisSessionDao,该sessionDao中一方面注入cacheManager用于session缓存,另一方面注入redisManager用于session存储,当createSession和updateSession直接使用redisManager操作redis.保存session.当readSession先用cacheManager从cache中读取,如果不存在再用redisManager从redis中读取。注意:该方法最大的特点是session缓存的存活时间必须小于redis中session的存活时间,就是当redus的session死亡,cahe中的session一定死亡,为了保证这一特点,cache中的session的存活时间应该设置为s级,设置为1s比较合适,并且存活时间固定不能刷新,不能随着访问而延长存活。

    /**
     * 
     */
    package com.uc56.web.omg.authentication;
    
    
    import java.io.Serializable;
    import java.util.Collection;
    import java.util.Date;
    import java.util.HashSet;
    import java.util.Set;
    
    import org.apache.shiro.session.ExpiredSessionException;
    import org.apache.shiro.session.Session;
    import org.apache.shiro.session.UnknownSessionException;
    import org.apache.shiro.session.mgt.ValidatingSession;
    import org.apache.shiro.session.mgt.eis.CachingSessionDAO;
    import org.apache.shiro.subject.support.DefaultSubjectContext;
    import org.crazycake.shiro.SerializeUtils;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    import com.uc56.web.omg.shiroredis.CustomRedisManager;
    
    
    
    /**
     * 将从redis读取的session进行本地缓存,本地缓存失效时重新从redis读取并更新最后访问时间,解决shiro频繁读取redis问题
     */
    public class CachingShiroSessionDao extends CachingSessionDAO {
    
        private static final Logger logger = LoggerFactory.getLogger(CachingShiroSessionDao.class);
        
        /** 保存到Redis中key的前缀 */
        private String keyPrefix = "";
    
        /**
         * jedis  操作redis的封装 
         */
        private CustomRedisManager redisManager;
        
    
        /**
         * 如DefaultSessionManager在创建完session后会调用该方法;
         * 如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化;
         * 返回会话ID;主要此处返回的ID.equals(session.getId());
         */
        @Override
        protected Serializable doCreate(Session session) {
            // 创建一个Id并设置给Session
            Serializable sessionId = this.generateSessionId(session);
            assignSessionId(session, sessionId);
            this.saveSession(session);
            return sessionId;
        }
        
        /**
         * 重写CachingSessionDAO中readSession方法,如果Session中没有登陆信息就调用doReadSession方法从Redis中重读
         */
        @Override
        public Session readSession(Serializable sessionId) throws UnknownSessionException {
            Session session = getCachedSession(sessionId);
            if (session == null
                    || session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) == null) {
                session = this.doReadSession(sessionId);
                if (session == null) {
                    throw new UnknownSessionException("There is no session with id [" + sessionId + "]");
                } else {
                    // 缓存
                    cache(session, session.getId());
                }
            }
            return session;
        }
        
    
        /**
         * 根据会话ID获取会话
         *
         * @param sessionId 会话ID
         * @return 
         */
        @Override
        protected Session doReadSession(Serializable sessionId) {
            ShiroSession shiroSession = null;
            try {
                shiroSession = (ShiroSession)SerializeUtils.deserialize(redisManager.get(this.getByteKey(sessionId)));
                if (shiroSession != null 
                        && shiroSession.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) != null) {
                    //检查session是否过期
                    shiroSession.validate();
                    // 重置Redis中Session的最后访问时间
                    shiroSession.setLastAccessTime(new Date());
                    this.saveSession(shiroSession);
                    logger.info("sessionId {} name {} 被读取并更新访问时间", sessionId, shiroSession.getClass().getName());
                }
            } catch (Exception e) {
                if (!(e instanceof ExpiredSessionException)) {
                    logger.warn("读取Session失败", e);
                }else {
                    logger.warn("session已失效:{}", e.getMessage());
                }
            }
    
            return shiroSession;
        }
        
        //扩展更新缓存机制,每次请求不重新更新session,更新session会延长session的失效时间
        @Override
        public void update(Session session) throws UnknownSessionException {
            doUpdate(session);
            if (session instanceof ValidatingSession) {
                if (((ValidatingSession) session).isValid()) {
                    //不更新ehcach中的session,使它在设定的时间内过期
                    //cache(session, session.getId());
                } else {
                    uncache(session);
                }
            } else {
                cache(session, session.getId());
            }
        }
        
        /**
         * 更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用
         */
        @Override
        protected void doUpdate(Session session) {
            //如果会话过期/停止 没必要再更新了
            try {
                if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) {
                    return;
                }
            } catch (Exception e) {
                logger.error("ValidatingSession error");
            }
    
            try {
                if (session instanceof ShiroSession) {
                    // 如果没有主要字段(除lastAccessTime以外其他字段)发生改变
                    ShiroSession shiroSession = (ShiroSession) session;
                    if (!shiroSession.isChanged()) {
                        return;
                    }
                    shiroSession.setChanged(false);
                    this.saveSession(session);
                    logger.info("sessionId {} name {} 被更新", session.getId(), session.getClass().getName());
    
                } else if (session instanceof Serializable) {
                    this.saveSession(session);
                    logger.info("sessionId {} name {} 作为非ShiroSession对象被更新, ", session.getId(), session.getClass().getName());
                } else {
                    logger.warn("sessionId {} name {} 不能被序列化 更新失败", session.getId(), session.getClass().getName());
                }
            } catch (Exception e) {
                logger.warn("更新Session失败", e);
            }
        }
    
        /**
         * 删除会话;当会话过期/会话停止(如用户退出时)会调用
         */
        @Override
        protected void doDelete(Session session) {
            try {
                redisManager.del(this.getByteKey(session.getId()));
                logger.debug("Session {} 被删除", session.getId());
            } catch (Exception e) {
                logger.warn("修改Session失败", e);
            }
        }
        
        /**
         * 删除cache中缓存的Session
         */
        public void uncache(Serializable sessionId) {
            Session session = this.readSession(sessionId);
            super.uncache(session);
            logger.info("取消session {} 的缓存", sessionId);
        }
        
        /**
         * 
         *  统计当前活动的session
         */
        @Override
        public Collection<Session> getActiveSessions() {
            Set<Session> sessions = new HashSet<Session>();
            
            Set<byte[]> keys = redisManager.keys(this.keyPrefix + "*");
            if(keys != null && keys.size()>0){
                for(byte[] key:keys){
                    Session s = (Session)SerializeUtils.deserialize(redisManager.get(key));
                    sessions.add(s);
                }
            }
            
            return sessions;
        }
        
        /**
         * save session
         * @param session
         * @throws UnknownSessionException
         */
        private void saveSession(Session session) throws UnknownSessionException{
            if(session == null || session.getId() == null){
                logger.error("session or session id is null");
                return;
            }
            
            byte[] key = getByteKey(session.getId());
            byte[] value = SerializeUtils.serialize(session);
            session.setTimeout(redisManager.getExpire() * 1L);        
            this.redisManager.set(key, value, redisManager.getExpire());
        }
        
        /**
         * 将key转换为byte[]
         * @param key
         * @return
         */
        private byte[] getByteKey(Serializable sessionId){
            String preKey = this.keyPrefix + sessionId;
            return preKey.getBytes();
        }
        
        public CustomRedisManager getRedisManager() {
            return redisManager;
        }
    
        public void setRedisManager(CustomRedisManager redisManager) {
            this.redisManager = redisManager;
            
            /**
             * 初使化RedisManager
             */
            this.redisManager.init();
        }
    
        /**
         * 获取 保存到Redis中key的前缀
         * @return keyPrefix
         */
        public String getKeyPrefix() {
            return keyPrefix;
        }
    
        /**
         * 设置 保存到Redis中key的前缀
         * @param keyPrefix 保存到Redis中key的前缀
         */
        public void setKeyPrefix(String keyPrefix) {
            this.keyPrefix = keyPrefix;
        }
    
    }

    /**
     * 
     */
    package com.uc56.web.omg.authentication;
    
    import java.io.Serializable;
    import java.util.Date;
    import java.util.Map;
    
    import org.apache.shiro.session.mgt.SimpleSession;
    
    
    /**
     * 由于SimpleSession lastAccessTime更改后也会调用SessionDao update方法,
     * 增加标识位,如果只是更新lastAccessTime SessionDao update方法直接返回
     */
    public class ShiroSession extends SimpleSession implements Serializable {
        /**
         * 
         */
        private static final long serialVersionUID = 1L;
        
        // 除lastAccessTime以外其他字段发生改变时为true
        private boolean isChanged;
    
        public ShiroSession() {
            super();
            this.setChanged(true);
        }
    
        public ShiroSession(String host) {
            super(host);
            this.setChanged(true);
        }
    
    
        @Override
        public void setId(Serializable id) {
            super.setId(id);
            this.setChanged(true);
        }
    
        @Override
        public void setStopTimestamp(Date stopTimestamp) {
            super.setStopTimestamp(stopTimestamp);
            this.setChanged(true);
        }
    
        @Override
        public void setExpired(boolean expired) {
            super.setExpired(expired);
            this.setChanged(true);
        }
    
        @Override
        public void setTimeout(long timeout) {
            super.setTimeout(timeout);
            this.setChanged(true);
        }
    
        @Override
        public void setHost(String host) {
            super.setHost(host);
            this.setChanged(true);
        }
    
        @Override
        public void setAttributes(Map<Object, Object> attributes) {
            super.setAttributes(attributes);
            this.setChanged(true);
        }
    
        @Override
        public void setAttribute(Object key, Object value) {
            super.setAttribute(key, value);
            this.setChanged(true);
        }
    
        @Override
        public Object removeAttribute(Object key) {
            this.setChanged(true);
            return super.removeAttribute(key);
        }
        
        //更新最后访问时间不更新redis
        @Override
        public void touch() {
            this.setChanged(false);
            super.touch();
        }
    
        /**
         * 停止
         */
        @Override
        public void stop() {
            super.stop();
            this.setChanged(true);
        }
    
        /**
         * 设置过期
         */
        @Override
        protected void expire() {
            this.stop();
            this.setExpired(true);
        }
    
        public boolean isChanged() {
            return isChanged;
        }
    
        public void setChanged(boolean isChanged) {
            this.isChanged = isChanged;
        }
    
        @Override
        public boolean equals(Object obj) {
            return super.equals(obj);
        }
    
        @Override
        protected boolean onEquals(SimpleSession ss) {
            return super.onEquals(ss);
        }
    
        @Override
        public int hashCode() {
            return super.hashCode();
        }
    
        @Override
        public String toString() {
            return super.toString();
        }
    }
    /**
     * 
     */
    package com.uc56.web.omg.authentication;
    
    import org.apache.shiro.session.Session;
    import org.apache.shiro.session.mgt.SessionContext;
    import org.apache.shiro.session.mgt.SessionFactory;
    
    public class ShiroSessionFactory implements SessionFactory {
    
        @Override
        public Session createSession(SessionContext initData) {
            ShiroSession session = new ShiroSession();
            return session;
        }
    }
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"  
        xmlns:aop="http://www.springframework.org/schema/aop" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
        xmlns:context="http://www.springframework.org/schema/context" xmlns:tx="http://www.springframework.org/schema/tx"  
        xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.5.xsd http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa-1.0.xsd">  
        
        <!-- 自定义权限定义 -->
        <bean id="permissionsRealm" class="com.uc56.web.omg.realm.PermissionsRealm">
            <!-- 缓存管理器 -->
            <property name="cacheManager" ref="shiroRedisCacheManager" />
        </bean>
        <!-- 安全管理器 -->
        <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
            <!-- 缓存管理器 -->
            <property name="cacheManager" ref="shiroEhcacheManager" />
            <!-- session 管理器 -->
              <property name="sessionManager" ref="sessionManager" />
              <property name="realm" ref="permissionsRealm"/>
        </bean>
        <!-- redis 缓存管理器 -->
        <bean id="shiroRedisCacheManager" class="com.uc56.web.omg.shiroredis.CustomRedisCacheManager">
            <property name="redisManager" ref="shiroRedisManager" />
        </bean>
        <bean id="shiroRedisManager" class="com.uc56.web.omg.shiroredis.CustomRedisManager">
            <property name="host" value="${redis.host}" />
            <property name="port" value="${redis.port}" />
            <property name="password" value="${redis.password}" />
            <property name="expire" value="${session.maxInactiveInterval}" />
            <property name="timeout" value="${redis.timeout}" />
        </bean>
        <!-- 提供单独的redis Dao -->
        <!-- <bean id="redisSessionDAO" class="com.uc56.web.omg.shiroredis.CustomRedisSessionDAO">
          <property name="redisManager" ref="shiroRedisManager" />
          <property name="keyPrefix" value="${session.redis.namespace}"></property>
        </bean> -->
        <bean id="sessionDao" class="com.uc56.web.omg.authentication.CachingShiroSessionDao">
            <property name="keyPrefix" value="${session.redis.namespace}"/>
            <property name="redisManager" ref="shiroRedisManager" />
        </bean>
        <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">  
            <property name="securityManager" ref="securityManager"/>
            <property name="loginUrl" value="/login/loginAuthc.do"></property>
            <property name="successUrl" value="login/loginIndex.do"></property>
            <property name="unauthorizedUrl" value="login/forbidden.do" />
            <property name="filters">  
                <map>  
                   <entry key="authc" value-ref="formAuthenticationFilter"/>
                   <entry key="LoginFailureCheck" value-ref="LoginFailureCheckFilter"/>
                </map>  
            </property> 
            <property name="filterChainDefinitions">  
                <value>
                   /login/login.do=anon
                   /login/loginAuthc.do=anon
                   /login/authCheck.do=anon
                   /login/forbidden.do=anon
                   /login/validateUser.do=anon
                   /city/**=anon
                   /easyui-themes/**=anon
                   /images/**=anon
                   /jquery-easyui-1.5.1/**=anon
                   /scripts/**=anon
                   /users/**=anon
                   /**=LoginFailureCheck,authc,user
                </value>
            </property>  
        </bean>
        <!-- 用户授权信息Cache, 采用EhCache,本地缓存最长时间应比中央缓存时间短一些,以确保Session中doReadSession方法调用时更新中央缓存过期时间 -->
        <bean id="shiroEhcacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
            <property name="cacheManagerConfigFile" value="classpath:springShiro/spring-shiro-ehcache.xml"/>
        </bean>
        
        <bean id="formAuthenticationFilter" class="org.apache.shiro.web.filter.authc.FormAuthenticationFilter"/> 
        <bean id="LoginFailureCheckFilter" class="com.uc56.web.omg.filter.LoginFailureCheckFilter">
            <property name="casService" ref="casService"></property>
            <property name="loginUserService" ref="loginUserService"></property>
        </bean>
        <bean id="loginUserService" class="com.uc56.web.omg.control.LoginUserService"/>
        <bean id="passwordEncoder" class="com.uc56.core.security.MD5PasswordEncoder"/>
        
         <!-- session管理器 -->
       <bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
          <!-- 超时时间 -->
          <property name="globalSessionTimeout" value="${session.global.timeout}" />
          <property name="sessionFactory" ref="sessionFactory"/>
          <!-- session存储的实现 -->
          <property name="sessionDAO" ref="sessionDao" />
          <!-- 定时检查失效的session -->
          <property name="sessionValidationSchedulerEnabled" value="true" />
          <!-- <property name="sessionValidationInterval" value="180000"/> -->
          <property name="sessionIdCookie" ref="sharesession" />  
          <property name="sessionListeners">
                <list>
                    <bean class="com.uc56.web.omg.authentication.listener.ShiroSessionListener"/>
                </list>
          </property>
       </bean>
       
       <!-- sessionIdCookie的实现,用于重写覆盖容器默认的JSESSIONID -->  
        <bean id="sharesession" class="org.apache.shiro.web.servlet.SimpleCookie">  
            <!-- cookie的name,对应的默认是JSESSIONID -->  
            <constructor-arg name="name" value="redisManager.sessionname" />  
            <!-- jsessionId的path为/用于多个系统共享jsessionId -->  
            <property name="path" value="/" />  
             <property name="httpOnly" value="false"/>  
        </bean>    
        
        <!-- 自定义Session工厂方法 返回会标识是否修改主要字段的自定义Session-->
        <bean id="sessionFactory" class="com.uc56.web.omg.authentication.ShiroSessionFactory"/>  
    </beans>
    <?xml version="1.0" encoding="UTF-8"?>
    <ehcache updateCheck="false"  name="shirocache">
        <!-- <diskStore path="java.io.tmpdir"/>
        登录记录缓存 锁定10分钟
        <cache name="passwordRetryCache"
               maxEntriesLocalHeap="2000"
               eternal="false"
               timeToIdleSeconds="3600"
               timeToLiveSeconds="0"
               overflowToDisk="false"
               statistics="true">
        </cache>
        <cache name="authorizationCache"
               maxEntriesLocalHeap="2000"
               eternal="false"
               timeToIdleSeconds="3600"
               timeToLiveSeconds="0"
               overflowToDisk="false"
               statistics="true">
        </cache>
        <cache name="authenticationCache"
               maxEntriesLocalHeap="2000"
               eternal="false"
               timeToIdleSeconds="3600"
               timeToLiveSeconds="0"
               overflowToDisk="false"
               statistics="true">
        </cache>
        <cache name="shiro-activeSessionCache"
               maxEntriesLocalHeap="2000"
               eternal="false"
               timeToIdleSeconds="3600"
               timeToLiveSeconds="0"
               overflowToDisk="false"
               statistics="true">
        </cache>
        <cache name="shiro_cache"
               maxElementsInMemory="2000"
               maxEntriesLocalHeap="2000"
               eternal="false"
               timeToIdleSeconds="0"
               timeToLiveSeconds="0"
               maxElementsOnDisk="0"
               overflowToDisk="true"
               memoryStoreEvictionPolicy="FIFO"
               statistics="true">
        </cache> -->
        <!-- <defaultCache
            在内存中最大的对象数量
            maxElementsInMemory="10000"
            设置元素是否永久的
            eternal="false"
            设置元素过期前的空闲时间
            timeToIdleSeconds="60"
            缓存数据的生存时间(TTL)
            timeToLiveSeconds="60"
            是否当memory中的数量达到限制后,保存到Disk
            overflowToDisk="false"
            diskPersistent="false"
            磁盘失效线程运行时间间隔,默认是120秒
            diskExpiryThreadIntervalSeconds="10"
            缓存满了之后的淘汰算法: LRU(最近最少使用)、FIFO(先进先出)、LFU(较少使用)
            memoryStoreEvictionPolicy="LRU"
         /> -->
         <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToLiveSeconds="60"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="10"
         />
    </ehcache>

    此设计中最重要的一点就是:

      1.cache中的session只存储不更新,也就是说每次访问不会刷新缓存中的session,cache中的session一定会在设定的时间中过期
      2.cache中设置的session的时间一定要短于redis中存储的session,保证redis中session过期是,cache中的session一定过期

      3.redis中的session更新会清楚cache中的session保证session一直性

      

      

  • 相关阅读:
    [转]c++访问python3-实例化类的方法
    【转】利用Boost.Python将C++代码封装为Python模块
    [转]Linux下Python与C++混合编程
    [转]Windows下使用VS2015编译openssl库
    [转]boost::python开发环境搭建
    [转]linux下编译boost.python
    [转]阿里巴巴十年Java架构师分享,会了这个知识点的人都去BAT了
    [转]Python3《机器学习实战》学习笔记(一):k-近邻算法(史诗级干货长文)
    [转]马上2018年了,该不该下定决心转型AI呢
    [转]PostgreSQL命令行使用手册
  • 原文地址:https://www.cnblogs.com/xj-blog/p/8289429.html
Copyright © 2011-2022 走看看